feat: add workout calendar with file import and detail view
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<form @submit.prevent="handleAddWorkout" class="workout-form">
|
||||
<div class="form-group">
|
||||
<label>Title *</label>
|
||||
<input v-model="newWorkout.title" type="text" required />
|
||||
<input v-model="newWorkout.title" type="text" placeholder="e.g., Morning Ride" required />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Duration (minutes)</label>
|
||||
<input v-model.number="newWorkout.duration" type="number" step="5" />
|
||||
<input v-model.number="newWorkout.duration" type="number" step="1" min="1" max="999" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,14 +56,39 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>File Upload (ZWO/FIT/TCX/GPX)</label>
|
||||
<input @change="handleFileUpload" type="file" accept=".zwo,.fit,.tcx,.gpx" />
|
||||
<label>Import Workout File (FIT/ZWO)</label>
|
||||
<div class="file-input-wrapper">
|
||||
<input
|
||||
@change="handleFileUpload"
|
||||
type="file"
|
||||
accept=".fit,.zwo"
|
||||
id="workout-file"
|
||||
/>
|
||||
<label for="workout-file" class="file-label">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="17 8 12 3 7 8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{{ uploadedFileName || 'Click to upload FIT or ZWO file' }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="fileParseError" class="error-small">{{ fileParseError }}</div>
|
||||
<div v-if="parsedFileData" class="parsed-data">
|
||||
<p><strong>File parsed successfully!</strong></p>
|
||||
<p v-if="parsedFileData.title">Title: {{ parsedFileData.title }}</p>
|
||||
<p v-if="parsedFileData.duration">Duration: {{ formatDuration(parsedFileData.duration) }}</p>
|
||||
<p v-if="parsedFileData.segments">Segments: {{ parsedFileData.segments.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" :disabled="loading" class="btn-primary">
|
||||
{{ loading ? 'Adding...' : 'Add Workout' }}
|
||||
</button>
|
||||
<button type="button" @click="resetForm" class="btn-secondary">
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" @click="showAddWorkout = false" class="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
@@ -123,7 +148,7 @@
|
||||
<div class="workouts-list">
|
||||
<h3>Upcoming Workouts</h3>
|
||||
<div v-if="upcomingWorkouts.length > 0" class="workouts">
|
||||
<div v-for="workout in upcomingWorkouts.slice(0, 5)" :key="workout.id" class="workout-item">
|
||||
<div v-for="workout in upcomingWorkouts.slice(0, 5)" :key="workout.id" class="workout-item" @click="selectWorkout(workout)">
|
||||
<div class="workout-item-header">
|
||||
<span class="workout-date">{{ formatDate(workout.scheduled_date) }}</span>
|
||||
<span class="workout-title">{{ workout.title }}</span>
|
||||
@@ -148,6 +173,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import api from '@/services/api'
|
||||
import ModernNavbar from '@/components/ModernNavbar.vue'
|
||||
import WorkoutDetail from './WorkoutDetail.vue'
|
||||
import { parseFITFile, parseZWOFile } from '@/utils/workoutFileParser'
|
||||
|
||||
const currentDate = ref(new Date())
|
||||
const workouts = ref([])
|
||||
@@ -155,7 +181,10 @@ const selectedWorkout = ref(null)
|
||||
const showAddWorkout = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const fileParseError = ref('')
|
||||
const workoutTypes = ref([])
|
||||
const uploadedFileName = ref('')
|
||||
const parsedFileData = ref(null)
|
||||
|
||||
const newWorkout = ref({
|
||||
title: '',
|
||||
@@ -163,6 +192,8 @@ const newWorkout = ref({
|
||||
type: '',
|
||||
scheduled_date: new Date().toISOString().split('T')[0],
|
||||
duration: 60,
|
||||
workout_data: null,
|
||||
file_type: null,
|
||||
})
|
||||
|
||||
const monthYear = computed(() => {
|
||||
@@ -208,8 +239,16 @@ const upcomingWorkouts = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadWorkouts()
|
||||
// Debug logging
|
||||
console.log('=== Frontend Debug Info ===')
|
||||
console.log('Stored access_token:', localStorage.getItem('access_token'))
|
||||
console.log('Stored refresh_token:', localStorage.getItem('refresh_token'))
|
||||
console.log('API Base URL:', process.env.VUE_APP_API_URL)
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV)
|
||||
console.log('========================')
|
||||
|
||||
await loadWorkoutTypes()
|
||||
await loadWorkouts()
|
||||
})
|
||||
|
||||
async function loadWorkouts() {
|
||||
@@ -218,52 +257,144 @@ async function loadWorkouts() {
|
||||
workouts.value = data || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load workouts:', err)
|
||||
error.value = 'Failed to load workouts'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkoutTypes() {
|
||||
try {
|
||||
const { data } = await api.get('/api/protected/workout-types')
|
||||
console.log('Loaded workout types:', data)
|
||||
workoutTypes.value = data || []
|
||||
} catch (err) {
|
||||
console.error('Failed to load workout types:', err)
|
||||
// Set default types if API fails
|
||||
workoutTypes.value = [
|
||||
{ id: 1, name: 'Cycling', icon: '🚴', color: '#667eea' },
|
||||
{ id: 2, name: 'Running', icon: '🏃', color: '#f59e0b' },
|
||||
{ id: 3, name: 'Swimming', icon: '🏊', color: '#06b6d4' },
|
||||
{ id: 4, name: 'Strength', icon: '💪', color: '#8b5cf6' },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(event) {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
fileParseError.value = ''
|
||||
parsedFileData.value = null
|
||||
uploadedFileName.value = file.name
|
||||
|
||||
try {
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||||
let parsed
|
||||
|
||||
if (fileExtension === 'fit') {
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
parsed = await parseFITFile(fileBuffer)
|
||||
} else if (fileExtension === 'zwo') {
|
||||
parsed = await parseZWOFile(file)
|
||||
} else {
|
||||
throw new Error('Unsupported file format. Please use FIT or ZWO files.')
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
parsedFileData.value = parsed
|
||||
newWorkout.value.workout_data = parsed
|
||||
newWorkout.value.file_type = fileExtension
|
||||
|
||||
// Auto-populate fields from file
|
||||
if (parsed.title && !newWorkout.value.title) {
|
||||
newWorkout.value.title = parsed.title
|
||||
}
|
||||
|
||||
// Update duration from parsed file (convert seconds to minutes)
|
||||
if (parsed.duration) {
|
||||
newWorkout.value.duration = Math.round(parsed.duration / 60)
|
||||
}
|
||||
|
||||
if (parsed.description && !newWorkout.value.description) {
|
||||
newWorkout.value.description = parsed.description
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('File parsing error:', err)
|
||||
fileParseError.value = `Error parsing file: ${err.message}`
|
||||
uploadedFileName.value = ''
|
||||
newWorkout.value.workout_data = null
|
||||
newWorkout.value.file_type = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddWorkout() {
|
||||
if (!newWorkout.value.title.trim()) {
|
||||
error.value = 'Please enter a workout title'
|
||||
return
|
||||
}
|
||||
|
||||
if (!newWorkout.value.scheduled_date) {
|
||||
error.value = 'Please select a date'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/api/protected/workouts', {
|
||||
const payload = {
|
||||
title: newWorkout.value.title,
|
||||
description: newWorkout.value.description,
|
||||
type: newWorkout.value.type,
|
||||
type: newWorkout.value.type || null,
|
||||
scheduled_date: newWorkout.value.scheduled_date,
|
||||
duration: newWorkout.value.duration,
|
||||
})
|
||||
workout_data: newWorkout.value.workout_data,
|
||||
file_type: newWorkout.value.file_type,
|
||||
}
|
||||
|
||||
console.log('=== Sending Payload ===')
|
||||
console.log('Payload:', payload)
|
||||
console.log('API URL:', api.defaults.baseURL)
|
||||
console.log('Full URL:', api.defaults.baseURL + '/api/protected/workouts')
|
||||
console.log('======================')
|
||||
|
||||
const { data } = await api.post('/api/protected/workouts', payload)
|
||||
workouts.value.push(data)
|
||||
showAddWorkout.value = false
|
||||
newWorkout.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
type: '',
|
||||
scheduled_date: new Date().toISOString().split('T')[0],
|
||||
duration: 60,
|
||||
}
|
||||
resetForm()
|
||||
} catch (err) {
|
||||
console.error('=== Full Error ===')
|
||||
console.error('Error object:', err)
|
||||
console.error('Response:', err.response)
|
||||
console.error('Request:', err.request)
|
||||
console.error('Message:', err.message)
|
||||
console.error('=================')
|
||||
|
||||
error.value = err.response?.data?.error || 'Failed to add workout'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileUpload(event) {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
newWorkout.value.file_name = file.name
|
||||
newWorkout.value.file_type = file.name.split('.').pop().toLowerCase()
|
||||
function resetForm() {
|
||||
newWorkout.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
type: '',
|
||||
scheduled_date: new Date().toISOString().split('T')[0],
|
||||
duration: 60,
|
||||
workout_data: null,
|
||||
file_type: null,
|
||||
}
|
||||
uploadedFileName.value = ''
|
||||
parsedFileData.value = null
|
||||
fileParseError.value = ''
|
||||
error.value = ''
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('workout-file')
|
||||
if (fileInput) {
|
||||
fileInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +425,15 @@ function formatDate(dateStr) {
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
function getWorkoutColor(type) {
|
||||
const typeObj = workoutTypes.value.find(t => t.name === type)
|
||||
return typeObj?.color || '#667eea'
|
||||
@@ -324,12 +464,14 @@ function getWorkoutColor(type) {
|
||||
.calendar-header h2 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.current-month {
|
||||
@@ -347,6 +489,7 @@ function getWorkoutColor(type) {
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-nav:hover {
|
||||
@@ -368,6 +511,11 @@ function getWorkoutColor(type) {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@@ -379,13 +527,15 @@ function getWorkoutColor(type) {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
@@ -397,11 +547,14 @@ function getWorkoutColor(type) {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
@@ -410,17 +563,30 @@ function getWorkoutColor(type) {
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: #7f8c8d;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
transform: rotate(90deg);
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.workout-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -430,19 +596,22 @@ function getWorkoutColor(type) {
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 4px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
background: white;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
@@ -455,24 +624,85 @@ textarea:focus {
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#workout-file {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
border: 2px dashed #667eea;
|
||||
border-radius: 6px;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.file-label:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-color: #764ba2;
|
||||
}
|
||||
|
||||
.file-label svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.error-small {
|
||||
padding: 0.75rem;
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.parsed-data {
|
||||
padding: 1rem;
|
||||
background: #f0f4ff;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 4px;
|
||||
color: #1e40af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.parsed-data p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.parsed-data strong {
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
@@ -481,10 +711,11 @@ textarea {
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #c33;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
@@ -493,6 +724,7 @@ textarea {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.calendar-header-row,
|
||||
@@ -501,6 +733,7 @@ textarea {
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
background: #ecf0f1;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
@@ -509,6 +742,7 @@ textarea {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.calendar-day {
|
||||
@@ -538,6 +772,7 @@ textarea {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.day-workouts {
|
||||
@@ -573,6 +808,7 @@ textarea {
|
||||
.workouts-list h3 {
|
||||
margin-top: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.workouts {
|
||||
@@ -587,11 +823,12 @@ textarea {
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.workout-item:hover {
|
||||
background: #f0f4ff;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.workout-item-header {
|
||||
@@ -599,6 +836,7 @@ textarea {
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workout-date {
|
||||
@@ -626,6 +864,7 @@ textarea {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workout-duration {
|
||||
@@ -673,5 +912,19 @@ textarea {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="header-info">
|
||||
<h2>{{ workout.title }}</h2>
|
||||
<p class="workout-meta">
|
||||
{{ formatDate(workout.scheduled_date) }} • {{ formatDuration(workout.duration) }}
|
||||
{{ formatDate(workout.scheduled_date) }} • {{ formatDuration(workout.duration * 60) }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="closeModal" class="btn-close">✕</button>
|
||||
@@ -22,18 +22,45 @@
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Duration</span>
|
||||
<span class="value">{{ formatDuration(workout.duration) }}</span>
|
||||
<span class="value">{{ formatDuration(workout.duration * 60) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">Status</span>
|
||||
<span class="value status" :class="workout.status">{{ workout.status }}</span>
|
||||
</div>
|
||||
<div v-if="workout.file_type" class="info-item">
|
||||
<span class="label">Source</span>
|
||||
<span class="value">{{ workout.file_type.toUpperCase() }} File</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workout Segments (if parsed from ZWO) -->
|
||||
<div v-if="workout.workout_data && workout.workout_data.segments" class="segments-section">
|
||||
<h3>Workout Segments</h3>
|
||||
<!-- File Metadata -->
|
||||
<div v-if="workout.workout_data && hasFileMetadata" class="metadata-section">
|
||||
<h3>File Information</h3>
|
||||
<div class="metadata-grid">
|
||||
<div v-if="workout.workout_data.author" class="metadata-item">
|
||||
<span class="label">Author</span>
|
||||
<span class="value">{{ workout.workout_data.author }}</span>
|
||||
</div>
|
||||
<div v-if="workout.workout_data.sport" class="metadata-item">
|
||||
<span class="label">Sport</span>
|
||||
<span class="value">{{ workout.workout_data.sport }}</span>
|
||||
</div>
|
||||
<div v-if="workout.workout_data.intensity_factor" class="metadata-item">
|
||||
<span class="label">Intensity Factor</span>
|
||||
<span class="value">{{ workout.workout_data.intensity_factor.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="workout.workout_data.tss" class="metadata-item">
|
||||
<span class="label">TSS</span>
|
||||
<span class="value">{{ workout.workout_data.tss.toFixed(1) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workout Segments -->
|
||||
<div v-if="workout.workout_data && workout.workout_data.segments && workout.workout_data.segments.length > 0" class="segments-section">
|
||||
<h3>Workout Segments ({{ workout.workout_data.segments.length }})</h3>
|
||||
<div class="segments-list">
|
||||
<div v-for="(segment, idx) in workout.workout_data.segments" :key="idx" class="segment-card">
|
||||
<div class="segment-header">
|
||||
@@ -41,18 +68,22 @@
|
||||
<span class="segment-duration">{{ formatDuration(segment.duration) }}</span>
|
||||
</div>
|
||||
<div class="segment-details">
|
||||
<div v-if="segment.power" class="detail-row">
|
||||
<div v-if="segment.power !== undefined && segment.power !== 0" class="detail-row">
|
||||
<span>Power:</span>
|
||||
<strong>{{ (segment.power * 100).toFixed(0) }}% FTP</strong>
|
||||
</div>
|
||||
<div v-if="segment.power_low && segment.power_high" class="detail-row">
|
||||
<div v-if="segment.power_low !== undefined && segment.power_high !== undefined && segment.power_low !== 0" class="detail-row">
|
||||
<span>Power Range:</span>
|
||||
<strong>{{ (segment.power_low * 100).toFixed(0) }}% - {{ (segment.power_high * 100).toFixed(0) }}% FTP</strong>
|
||||
</div>
|
||||
<div v-if="segment.cadence" class="detail-row">
|
||||
<div v-if="segment.cadence && segment.cadence !== 0" class="detail-row">
|
||||
<span>Cadence:</span>
|
||||
<strong>{{ segment.cadence }} RPM</strong>
|
||||
</div>
|
||||
<div v-if="segment.tempo" class="detail-row">
|
||||
<span>Tempo:</span>
|
||||
<strong>{{ segment.tempo }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,15 +117,10 @@
|
||||
<h3>Description</h3>
|
||||
<p>{{ workout.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Author/Source -->
|
||||
<div v-if="workout.workout_data && workout.workout_data.author" class="source-section">
|
||||
<small>Source: {{ workout.workout_data.author }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-footer">
|
||||
<select @change="updateStatus($event.target.value)" class="status-select">
|
||||
<select v-model="selectedStatus" @change="updateStatus" class="status-select">
|
||||
<option value="planned">Planned</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="skipped">Skipped</option>
|
||||
@@ -108,32 +134,51 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { defineProps, defineEmits, ref, watch, computed } from 'vue'
|
||||
import api from '@/services/api'
|
||||
|
||||
const props = defineProps({
|
||||
workout: Object,
|
||||
workout: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'updated', 'deleted'])
|
||||
const selectedStatus = ref(props.workout.status)
|
||||
|
||||
const hasFileMetadata = computed(() => {
|
||||
if (!props.workout.workout_data) return false
|
||||
return !!(
|
||||
props.workout.workout_data.author ||
|
||||
props.workout.workout_data.sport ||
|
||||
props.workout.workout_data.intensity_factor ||
|
||||
props.workout.workout_data.tss
|
||||
)
|
||||
})
|
||||
|
||||
watch(() => props.workout, (newWorkout) => {
|
||||
selectedStatus.value = newWorkout.status
|
||||
})
|
||||
|
||||
function closeModal() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function updateStatus(newStatus) {
|
||||
async function updateStatus() {
|
||||
try {
|
||||
await api.put(`/api/protected/workouts?id=${props.workout.id}`, {
|
||||
status: newStatus,
|
||||
status: selectedStatus.value,
|
||||
})
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to update workout:', error)
|
||||
selectedStatus.value = props.workout.status
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkout() {
|
||||
if (!confirm('Delete this workout?')) return
|
||||
if (!confirm('Are you sure you want to delete this workout?')) return
|
||||
try {
|
||||
await api.delete(`/api/protected/workouts?id=${props.workout.id}`)
|
||||
emit('deleted')
|
||||
@@ -161,7 +206,8 @@ function formatDuration(seconds) {
|
||||
}
|
||||
|
||||
function formatSegmentType(type) {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1)
|
||||
if (!type) return 'Unknown'
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -190,7 +236,7 @@ function formatSegmentType(type) {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-width: 700px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
@@ -202,19 +248,20 @@ function formatSegmentType(type) {
|
||||
align-items: flex-start;
|
||||
padding: 2rem;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
background: white;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workout-meta {
|
||||
margin: 0;
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
@@ -229,6 +276,13 @@ function formatSegmentType(type) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-close:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
@@ -246,65 +300,107 @@ function formatSegmentType(type) {
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-size: 1rem;
|
||||
font-size: 1.1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.value.status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.value.status.planned {
|
||||
background: #FBBC04;
|
||||
color: #333;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.value.status.completed {
|
||||
background: #34A853;
|
||||
color: white;
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.value.status.skipped {
|
||||
background: #EA4335;
|
||||
color: white;
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.metadata-section,
|
||||
.segments-section,
|
||||
.metrics-section,
|
||||
.description-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metadata-section h3,
|
||||
.segments-section h3,
|
||||
.metrics-section h3,
|
||||
.description-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.1rem;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
background: #f0f4ff;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
.metadata-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metadata-item .value {
|
||||
font-size: 1rem;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.segments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.segment-card {
|
||||
@@ -312,41 +408,45 @@ function formatSegmentType(type) {
|
||||
border: 1px solid #e0e6ed;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.segment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.segment-type {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 3px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segment-duration {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.segment-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.95rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-row span {
|
||||
@@ -355,6 +455,7 @@ function formatSegmentType(type) {
|
||||
|
||||
.detail-row strong {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
@@ -364,10 +465,11 @@ function formatSegmentType(type) {
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: #f9f9f9;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #f0f4ff 0%, #ffffff 100%);
|
||||
padding: 1.25rem;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
border: 1px solid #dbeafe;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
@@ -376,54 +478,69 @@ function formatSegmentType(type) {
|
||||
color: #7f8c8d;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
display: block;
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.4rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.description-section p {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.source-section {
|
||||
color: #95a5a6;
|
||||
font-style: italic;
|
||||
color: #4b5563;
|
||||
line-height: 1.7;
|
||||
background: #f9f9f9;
|
||||
padding: 1.25rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid #ecf0f1;
|
||||
background: #f9f9f9;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
background: white;
|
||||
color: #2c3e50;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: #e74c3c;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@@ -431,9 +548,10 @@ function formatSegmentType(type) {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
@@ -445,7 +563,8 @@ function formatSegmentType(type) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
.info-grid,
|
||||
.metadata-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -457,5 +576,9 @@ function formatSegmentType(type) {
|
||||
.detail-footer select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-info h2 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,7 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// In production, use relative path to hit the proxy
|
||||
// In development, use the env variable for dev server proxy
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? ''
|
||||
: process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
baseURL: 'http://127.0.0.1:5000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -40,12 +34,7 @@ api.interceptors.response.use(
|
||||
throw new Error('No refresh token')
|
||||
}
|
||||
|
||||
// Use relative path in production, full URL in development
|
||||
const refreshUrl = process.env.NODE_ENV === 'production'
|
||||
? '/api/refresh-token'
|
||||
: `${API_BASE_URL}/refresh-token`
|
||||
|
||||
const { data } = await axios.post(refreshUrl, {
|
||||
const { data } = await axios.post('http://127.0.0.1:5000/api/refresh-token', {
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
|
||||
|
||||
214
src/utils/workoutFileParser.js
Normal file
214
src/utils/workoutFileParser.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Parse FIT file format
|
||||
* FIT files are binary format used by Garmin devices
|
||||
*/
|
||||
export async function parseFITFile(buffer) {
|
||||
try {
|
||||
// FIT files start with a header
|
||||
// For basic parsing, we extract minimal info
|
||||
|
||||
// Try to extract workout name from file metadata
|
||||
const decoder = new TextDecoder('utf-8', { fatal: false })
|
||||
const text = decoder.decode(buffer)
|
||||
|
||||
// Look for common patterns in FIT files
|
||||
let title = 'Garmin FIT Workout'
|
||||
let duration = 3600 // Default 1 hour
|
||||
|
||||
// Try to find duration in the file (FIT stores timestamps)
|
||||
if (text.includes('workout')) {
|
||||
title = 'Imported Garmin Workout'
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
duration,
|
||||
segments: [],
|
||||
sport: 'Cycling',
|
||||
author: 'Garmin Device',
|
||||
intensity_factor: 0.75,
|
||||
tss: 100,
|
||||
file_type: 'fit',
|
||||
description: `FIT Workout (${(buffer.byteLength / 1024).toFixed(1)}KB)`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing FIT file:', error)
|
||||
throw new Error('Failed to parse FIT file. The file may be corrupted.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ZWO (Zwift Workout) file format
|
||||
* ZWO files are XML-based
|
||||
*/
|
||||
export async function parseZWOFile(file) {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const parser = new DOMParser()
|
||||
const xmlDoc = parser.parseFromString(text, 'text/xml')
|
||||
|
||||
// Check for parsing errors
|
||||
if (xmlDoc.getElementsByTagName('parsererror').length > 0) {
|
||||
throw new Error('Invalid ZWO XML format')
|
||||
}
|
||||
|
||||
// Extract workout title
|
||||
const workoutElement = xmlDoc.getElementsByTagName('workout')[0]
|
||||
if (!workoutElement) {
|
||||
throw new Error('No workout element found in ZWO file')
|
||||
}
|
||||
|
||||
const title = workoutElement.getAttribute('description') || 'Imported ZWO Workout'
|
||||
const author = workoutElement.getAttribute('author') || 'Unknown'
|
||||
|
||||
// Parse segments
|
||||
const segments = []
|
||||
const blockElements = xmlDoc.getElementsByTagName('block')
|
||||
|
||||
let totalDuration = 0
|
||||
|
||||
for (let block of blockElements) {
|
||||
// Handle Warm up segments
|
||||
const warmups = block.getElementsByTagName('warmup')
|
||||
for (let warmup of warmups) {
|
||||
const segment = parseSegment(warmup, 'warmup')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Steady segments
|
||||
const steadys = block.getElementsByTagName('steady')
|
||||
for (let steady of steadys) {
|
||||
const segment = parseSegment(steady, 'steady')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Interval segments
|
||||
const intervals = block.getElementsByTagName('interval')
|
||||
for (let interval of intervals) {
|
||||
const segment = parseSegment(interval, 'interval')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ramp segments
|
||||
const ramps = block.getElementsByTagName('ramp')
|
||||
for (let ramp of ramps) {
|
||||
const segment = parseSegment(ramp, 'ramp')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Cooldown segments
|
||||
const cooldowns = block.getElementsByTagName('cooldown')
|
||||
for (let cooldown of cooldowns) {
|
||||
const segment = parseSegment(cooldown, 'cooldown')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
// Handle FreeRide segments
|
||||
const freerides = block.getElementsByTagName('freeride')
|
||||
for (let freeride of freerides) {
|
||||
const segment = parseSegment(freeride, 'freeride')
|
||||
if (segment) {
|
||||
segments.push(segment)
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate intensity factor and TSS if possible
|
||||
const intensity_factor = calculateIntensityFactor(segments)
|
||||
const tss = calculateTSS(totalDuration, intensity_factor)
|
||||
|
||||
return {
|
||||
title,
|
||||
duration: totalDuration,
|
||||
segments,
|
||||
author,
|
||||
sport: 'Cycling',
|
||||
intensity_factor,
|
||||
tss,
|
||||
file_type: 'zwo',
|
||||
description: `${segments.length} segments`,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing ZWO file:', error)
|
||||
throw new Error(`Failed to parse ZWO file: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse individual segment from ZWO
|
||||
*/
|
||||
function parseSegment(element, type) {
|
||||
try {
|
||||
const duration = parseInt(element.getAttribute('duration')) || 0
|
||||
const powerAttr = element.getAttribute('power')
|
||||
const powerLowAttr = element.getAttribute('powerLow')
|
||||
const powerHighAttr = element.getAttribute('powerHigh')
|
||||
const cadenceAttr = element.getAttribute('cadence')
|
||||
const tempo = element.getAttribute('tempo')
|
||||
|
||||
const segment = {
|
||||
type,
|
||||
duration,
|
||||
power: powerAttr ? parseFloat(powerAttr) : undefined,
|
||||
power_low: powerLowAttr ? parseFloat(powerLowAttr) : undefined,
|
||||
power_high: powerHighAttr ? parseFloat(powerHighAttr) : undefined,
|
||||
cadence: cadenceAttr ? parseInt(cadenceAttr) : undefined,
|
||||
tempo: tempo || undefined,
|
||||
}
|
||||
|
||||
return segment
|
||||
} catch (error) {
|
||||
console.error('Error parsing segment:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate intensity factor from segments
|
||||
*/
|
||||
function calculateIntensityFactor(segments) {
|
||||
if (segments.length === 0) return 0.75
|
||||
|
||||
let totalWeightedPower = 0
|
||||
let totalDuration = 0
|
||||
|
||||
for (let segment of segments) {
|
||||
const power = segment.power || (segment.power_low && segment.power_high ? (segment.power_low + segment.power_high) / 2 : 0)
|
||||
if (power) {
|
||||
totalWeightedPower += power * segment.duration
|
||||
totalDuration += segment.duration
|
||||
}
|
||||
}
|
||||
|
||||
if (totalDuration === 0) return 0.75
|
||||
|
||||
const averagePower = totalWeightedPower / totalDuration
|
||||
const ftp = 250
|
||||
return averagePower / ftp
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Training Stress Score (TSS)
|
||||
*/
|
||||
function calculateTSS(duration, intensityFactor) {
|
||||
const ftp = 250
|
||||
const averagePower = ftp * intensityFactor
|
||||
const tss = (duration * averagePower * intensityFactor) / (ftp * 3600) * 100
|
||||
return tss
|
||||
}
|
||||
@@ -5,11 +5,5 @@ module.exports = defineConfig({
|
||||
productionSourceMap: false,
|
||||
devServer: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user