diff --git a/src/components/WorkoutCalendar.vue b/src/components/WorkoutCalendar.vue index abe186c..a6fa4cf 100644 --- a/src/components/WorkoutCalendar.vue +++ b/src/components/WorkoutCalendar.vue @@ -24,7 +24,7 @@
- +
@@ -46,24 +46,49 @@
- +
- +
- - + +
+ + +
+
{{ fileParseError }}
+
+

File parsed successfully!

+

Title: {{ parsedFileData.title }}

+

Duration: {{ formatDuration(parsedFileData.duration) }}

+

Segments: {{ parsedFileData.segments.length }}

+
+ @@ -123,7 +148,7 @@

Upcoming Workouts

-
+
{{ formatDate(workout.scheduled_date) }} {{ workout.title }} @@ -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%; + } } \ No newline at end of file diff --git a/src/components/WorkoutDetail.vue b/src/components/WorkoutDetail.vue index a100596..81d19ab 100644 --- a/src/components/WorkoutDetail.vue +++ b/src/components/WorkoutDetail.vue @@ -6,7 +6,7 @@

{{ workout.title }}

- {{ formatDate(workout.scheduled_date) }} • {{ formatDuration(workout.duration) }} + {{ formatDate(workout.scheduled_date) }} • {{ formatDuration(workout.duration * 60) }}

@@ -22,18 +22,45 @@
Duration - {{ formatDuration(workout.duration) }} + {{ formatDuration(workout.duration * 60) }}
Status {{ workout.status }}
+
+ Source + {{ workout.file_type.toUpperCase() }} File +
- -
-

Workout Segments

+ + + + +
+

Workout Segments ({{ workout.workout_data.segments.length }})

@@ -41,18 +68,22 @@ {{ formatDuration(segment.duration) }}
-
+
Power: {{ (segment.power * 100).toFixed(0) }}% FTP
-
+
Power Range: {{ (segment.power_low * 100).toFixed(0) }}% - {{ (segment.power_high * 100).toFixed(0) }}% FTP
-
+
Cadence: {{ segment.cadence }} RPM
+
+ Tempo: + {{ segment.tempo }} +
@@ -86,15 +117,10 @@

Description

{{ workout.description }}

- - -
- Source: {{ workout.workout_data.author }} -