feat: add workout calendar with file import and detail view

This commit is contained in:
Blake Ridgway
2025-11-29 12:46:29 -06:00
parent bf72ef4503
commit 58b011decb
5 changed files with 699 additions and 126 deletions

View File

@@ -24,7 +24,7 @@
<form @submit.prevent="handleAddWorkout" class="workout-form"> <form @submit.prevent="handleAddWorkout" class="workout-form">
<div class="form-group"> <div class="form-group">
<label>Title *</label> <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>
<div class="form-row"> <div class="form-row">
@@ -46,24 +46,49 @@
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<label>Duration (minutes)</label> <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>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Description</label> <label>Description</label>
<textarea v-model="newWorkout.description" placeholder="Workout details..."></textarea> <textarea v-model="newWorkout.description" placeholder="Workout details..."></textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>File Upload (ZWO/FIT/TCX/GPX)</label> <label>Import Workout File (FIT/ZWO)</label>
<input @change="handleFileUpload" type="file" accept=".zwo,.fit,.tcx,.gpx" /> <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>
<div class="form-actions"> <div class="form-actions">
<button type="submit" :disabled="loading" class="btn-primary"> <button type="submit" :disabled="loading" class="btn-primary">
{{ loading ? 'Adding...' : 'Add Workout' }} {{ loading ? 'Adding...' : 'Add Workout' }}
</button> </button>
<button type="button" @click="resetForm" class="btn-secondary">
Reset
</button>
<button type="button" @click="showAddWorkout = false" class="btn-secondary"> <button type="button" @click="showAddWorkout = false" class="btn-secondary">
Cancel Cancel
</button> </button>
@@ -123,7 +148,7 @@
<div class="workouts-list"> <div class="workouts-list">
<h3>Upcoming Workouts</h3> <h3>Upcoming Workouts</h3>
<div v-if="upcomingWorkouts.length > 0" class="workouts"> <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"> <div class="workout-item-header">
<span class="workout-date">{{ formatDate(workout.scheduled_date) }}</span> <span class="workout-date">{{ formatDate(workout.scheduled_date) }}</span>
<span class="workout-title">{{ workout.title }}</span> <span class="workout-title">{{ workout.title }}</span>
@@ -148,6 +173,7 @@ import { ref, computed, onMounted } from 'vue'
import api from '@/services/api' import api from '@/services/api'
import ModernNavbar from '@/components/ModernNavbar.vue' import ModernNavbar from '@/components/ModernNavbar.vue'
import WorkoutDetail from './WorkoutDetail.vue' import WorkoutDetail from './WorkoutDetail.vue'
import { parseFITFile, parseZWOFile } from '@/utils/workoutFileParser'
const currentDate = ref(new Date()) const currentDate = ref(new Date())
const workouts = ref([]) const workouts = ref([])
@@ -155,7 +181,10 @@ const selectedWorkout = ref(null)
const showAddWorkout = ref(false) const showAddWorkout = ref(false)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const fileParseError = ref('')
const workoutTypes = ref([]) const workoutTypes = ref([])
const uploadedFileName = ref('')
const parsedFileData = ref(null)
const newWorkout = ref({ const newWorkout = ref({
title: '', title: '',
@@ -163,6 +192,8 @@ const newWorkout = ref({
type: '', type: '',
scheduled_date: new Date().toISOString().split('T')[0], scheduled_date: new Date().toISOString().split('T')[0],
duration: 60, duration: 60,
workout_data: null,
file_type: null,
}) })
const monthYear = computed(() => { const monthYear = computed(() => {
@@ -208,8 +239,16 @@ const upcomingWorkouts = computed(() => {
}) })
onMounted(async () => { 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 loadWorkoutTypes()
await loadWorkouts()
}) })
async function loadWorkouts() { async function loadWorkouts() {
@@ -218,52 +257,144 @@ async function loadWorkouts() {
workouts.value = data || [] workouts.value = data || []
} catch (err) { } catch (err) {
console.error('Failed to load workouts:', err) console.error('Failed to load workouts:', err)
error.value = 'Failed to load workouts'
} }
} }
async function loadWorkoutTypes() { async function loadWorkoutTypes() {
try { try {
const { data } = await api.get('/api/protected/workout-types') const { data } = await api.get('/api/protected/workout-types')
console.log('Loaded workout types:', data)
workoutTypes.value = data || [] workoutTypes.value = data || []
} catch (err) { } catch (err) {
console.error('Failed to load workout types:', 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() { 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 loading.value = true
error.value = '' error.value = ''
try { try {
const { data } = await api.post('/api/protected/workouts', { const payload = {
title: newWorkout.value.title, title: newWorkout.value.title,
description: newWorkout.value.description, description: newWorkout.value.description,
type: newWorkout.value.type, type: newWorkout.value.type || null,
scheduled_date: newWorkout.value.scheduled_date, scheduled_date: newWorkout.value.scheduled_date,
duration: newWorkout.value.duration, 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) workouts.value.push(data)
showAddWorkout.value = false showAddWorkout.value = false
newWorkout.value = { resetForm()
title: '',
description: '',
type: '',
scheduled_date: new Date().toISOString().split('T')[0],
duration: 60,
}
} catch (err) { } 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' error.value = err.response?.data?.error || 'Failed to add workout'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleFileUpload(event) { function resetForm() {
const file = event.target.files[0] newWorkout.value = {
if (file) { title: '',
newWorkout.value.file_name = file.name description: '',
newWorkout.value.file_type = file.name.split('.').pop().toLowerCase() 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) { function getWorkoutColor(type) {
const typeObj = workoutTypes.value.find(t => t.name === type) const typeObj = workoutTypes.value.find(t => t.name === type)
return typeObj?.color || '#667eea' return typeObj?.color || '#667eea'
@@ -324,12 +464,14 @@ function getWorkoutColor(type) {
.calendar-header h2 { .calendar-header h2 {
margin: 0; margin: 0;
color: #2c3e50; color: #2c3e50;
font-size: 2rem;
} }
.header-controls { .header-controls {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.current-month { .current-month {
@@ -347,6 +489,7 @@ function getWorkoutColor(type) {
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: background 0.2s;
} }
.btn-nav:hover { .btn-nav:hover {
@@ -368,6 +511,11 @@ function getWorkoutColor(type) {
transform: translateY(-2px); transform: translateY(-2px);
} }
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
top: 0; top: 0;
@@ -379,13 +527,15 @@ function getWorkoutColor(type) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 1000;
padding: 1rem;
overflow-y: auto;
} }
.modal-content { .modal-content {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
padding: 2rem; padding: 2rem;
max-width: 500px; max-width: 600px;
width: 100%; width: 100%;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
@@ -397,11 +547,14 @@ function getWorkoutColor(type) {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
} }
.modal-header h3 { .modal-header h3 {
margin: 0; margin: 0;
color: #2c3e50; color: #2c3e50;
font-size: 1.5rem;
} }
.btn-close { .btn-close {
@@ -410,17 +563,30 @@ function getWorkoutColor(type) {
font-size: 1.5rem; font-size: 1.5rem;
cursor: pointer; cursor: pointer;
color: #7f8c8d; 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 { .workout-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1.5rem;
} }
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
} }
.form-row { .form-row {
@@ -430,19 +596,22 @@ function getWorkoutColor(type) {
} }
label { label {
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
color: #2c3e50; color: #2c3e50;
font-weight: 500; font-weight: 600;
font-size: 0.95rem;
} }
input, input,
select, select,
textarea { textarea {
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #bdc3c7; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: 6px;
font-size: 1rem; font-size: 1rem;
font-family: inherit; font-family: inherit;
transition: all 0.2s;
background: white;
} }
input:focus, input:focus,
@@ -455,24 +624,85 @@ textarea:focus {
textarea { textarea {
resize: vertical; 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 { .form-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1rem; justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
} }
.btn-secondary { .btn-secondary {
flex: 1; padding: 0.75rem 1.5rem;
padding: 0.75rem;
background: #95a5a6; background: #95a5a6;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: background 0.2s;
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -481,10 +711,11 @@ textarea {
.error-message { .error-message {
margin-top: 1rem; margin-top: 1rem;
padding: 0.75rem; padding: 1rem;
background: #fee; background: #fee;
color: #c33; color: #c33;
border-radius: 4px; border-radius: 4px;
border-left: 4px solid #c33;
} }
.calendar-grid { .calendar-grid {
@@ -493,6 +724,7 @@ textarea {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 1rem; padding: 1rem;
margin-bottom: 2rem; margin-bottom: 2rem;
overflow-x: auto;
} }
.calendar-header-row, .calendar-header-row,
@@ -501,6 +733,7 @@ textarea {
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 1px; gap: 1px;
background: #ecf0f1; background: #ecf0f1;
min-width: 100%;
} }
.day-header { .day-header {
@@ -509,6 +742,7 @@ textarea {
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
font-size: 0.9rem;
} }
.calendar-day { .calendar-day {
@@ -538,6 +772,7 @@ textarea {
font-weight: 600; font-weight: 600;
color: #2c3e50; color: #2c3e50;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-size: 1.1rem;
} }
.day-workouts { .day-workouts {
@@ -573,6 +808,7 @@ textarea {
.workouts-list h3 { .workouts-list h3 {
margin-top: 0; margin-top: 0;
color: #2c3e50; color: #2c3e50;
font-size: 1.3rem;
} }
.workouts { .workouts {
@@ -587,11 +823,12 @@ textarea {
background: #f9f9f9; background: #f9f9f9;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: all 0.2s;
} }
.workout-item:hover { .workout-item:hover {
background: #f0f4ff; background: #f0f4ff;
transform: translateX(4px);
} }
.workout-item-header { .workout-item-header {
@@ -599,6 +836,7 @@ textarea {
gap: 1rem; gap: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.workout-date { .workout-date {
@@ -626,6 +864,7 @@ textarea {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
flex-wrap: wrap;
} }
.workout-duration { .workout-duration {
@@ -673,5 +912,19 @@ textarea {
.form-row { .form-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.modal-content {
max-width: 100%;
padding: 1.5rem;
}
.form-actions {
flex-direction: column;
}
.btn-primary,
.btn-secondary {
width: 100%;
}
} }
</style> </style>

View File

@@ -6,7 +6,7 @@
<div class="header-info"> <div class="header-info">
<h2>{{ workout.title }}</h2> <h2>{{ workout.title }}</h2>
<p class="workout-meta"> <p class="workout-meta">
{{ formatDate(workout.scheduled_date) }} {{ formatDuration(workout.duration) }} {{ formatDate(workout.scheduled_date) }} {{ formatDuration(workout.duration * 60) }}
</p> </p>
</div> </div>
<button @click="closeModal" class="btn-close"></button> <button @click="closeModal" class="btn-close"></button>
@@ -22,18 +22,45 @@
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">Duration</span> <span class="label">Duration</span>
<span class="value">{{ formatDuration(workout.duration) }}</span> <span class="value">{{ formatDuration(workout.duration * 60) }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="label">Status</span> <span class="label">Status</span>
<span class="value status" :class="workout.status">{{ workout.status }}</span> <span class="value status" :class="workout.status">{{ workout.status }}</span>
</div> </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>
</div> </div>
<!-- Workout Segments (if parsed from ZWO) --> <!-- File Metadata -->
<div v-if="workout.workout_data && workout.workout_data.segments" class="segments-section"> <div v-if="workout.workout_data && hasFileMetadata" class="metadata-section">
<h3>Workout Segments</h3> <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 class="segments-list">
<div v-for="(segment, idx) in workout.workout_data.segments" :key="idx" class="segment-card"> <div v-for="(segment, idx) in workout.workout_data.segments" :key="idx" class="segment-card">
<div class="segment-header"> <div class="segment-header">
@@ -41,18 +68,22 @@
<span class="segment-duration">{{ formatDuration(segment.duration) }}</span> <span class="segment-duration">{{ formatDuration(segment.duration) }}</span>
</div> </div>
<div class="segment-details"> <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> <span>Power:</span>
<strong>{{ (segment.power * 100).toFixed(0) }}% FTP</strong> <strong>{{ (segment.power * 100).toFixed(0) }}% FTP</strong>
</div> </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> <span>Power Range:</span>
<strong>{{ (segment.power_low * 100).toFixed(0) }}% - {{ (segment.power_high * 100).toFixed(0) }}% FTP</strong> <strong>{{ (segment.power_low * 100).toFixed(0) }}% - {{ (segment.power_high * 100).toFixed(0) }}% FTP</strong>
</div> </div>
<div v-if="segment.cadence" class="detail-row"> <div v-if="segment.cadence && segment.cadence !== 0" class="detail-row">
<span>Cadence:</span> <span>Cadence:</span>
<strong>{{ segment.cadence }} RPM</strong> <strong>{{ segment.cadence }} RPM</strong>
</div> </div>
<div v-if="segment.tempo" class="detail-row">
<span>Tempo:</span>
<strong>{{ segment.tempo }}</strong>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -86,15 +117,10 @@
<h3>Description</h3> <h3>Description</h3>
<p>{{ workout.description }}</p> <p>{{ workout.description }}</p>
</div> </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>
<div class="detail-footer"> <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="planned">Planned</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="skipped">Skipped</option> <option value="skipped">Skipped</option>
@@ -108,32 +134,51 @@
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue' import { defineProps, defineEmits, ref, watch, computed } from 'vue'
import api from '@/services/api' import api from '@/services/api'
const props = defineProps({ const props = defineProps({
workout: Object, workout: {
type: Object,
required: true,
},
}) })
const emit = defineEmits(['close', 'updated', 'deleted']) 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() { function closeModal() {
emit('close') emit('close')
} }
async function updateStatus(newStatus) { async function updateStatus() {
try { try {
await api.put(`/api/protected/workouts?id=${props.workout.id}`, { await api.put(`/api/protected/workouts?id=${props.workout.id}`, {
status: newStatus, status: selectedStatus.value,
}) })
emit('updated') emit('updated')
} catch (error) { } catch (error) {
console.error('Failed to update workout:', error) console.error('Failed to update workout:', error)
selectedStatus.value = props.workout.status
} }
} }
async function deleteWorkout() { async function deleteWorkout() {
if (!confirm('Delete this workout?')) return if (!confirm('Are you sure you want to delete this workout?')) return
try { try {
await api.delete(`/api/protected/workouts?id=${props.workout.id}`) await api.delete(`/api/protected/workouts?id=${props.workout.id}`)
emit('deleted') emit('deleted')
@@ -161,7 +206,8 @@ function formatDuration(seconds) {
} }
function formatSegmentType(type) { 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> </script>
@@ -190,7 +236,7 @@ function formatSegmentType(type) {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
width: 100%; width: 100%;
max-width: 600px; max-width: 700px;
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
@@ -202,19 +248,20 @@ function formatSegmentType(type) {
align-items: flex-start; align-items: flex-start;
padding: 2rem; padding: 2rem;
border-bottom: 1px solid #ecf0f1; border-bottom: 1px solid #ecf0f1;
background: white; background: linear-gradient(135deg, #f5f7fa 0%, #ffffff 100%);
} }
.header-info h2 { .header-info h2 {
margin: 0 0 0.5rem 0; margin: 0 0 0.5rem 0;
color: #2c3e50; color: #2c3e50;
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 700;
} }
.workout-meta { .workout-meta {
margin: 0; margin: 0;
color: #7f8c8d; color: #7f8c8d;
font-size: 0.9rem; font-size: 0.95rem;
} }
.btn-close { .btn-close {
@@ -229,6 +276,13 @@ function formatSegmentType(type) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .detail-body {
@@ -246,65 +300,107 @@ function formatSegmentType(type) {
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem; gap: 1.5rem;
} }
.info-item { .info-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem;
} }
.info-item .label { .info-item .label {
font-size: 0.8rem; font-size: 0.75rem;
color: #7f8c8d; color: #7f8c8d;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 700;
margin-bottom: 0.25rem; letter-spacing: 0.5px;
} }
.info-item .value { .info-item .value {
font-size: 1rem; font-size: 1.1rem;
color: #2c3e50; color: #2c3e50;
font-weight: 600; font-weight: 600;
} }
.value.status { .value.status {
display: inline-block; display: inline-block;
padding: 0.25rem 0.75rem; padding: 0.4rem 0.75rem;
border-radius: 4px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.9rem;
text-align: center;
width: fit-content;
} }
.value.status.planned { .value.status.planned {
background: #FBBC04; background: #fef3c7;
color: #333; color: #92400e;
} }
.value.status.completed { .value.status.completed {
background: #34A853; background: #d1fae5;
color: white; color: #065f46;
} }
.value.status.skipped { .value.status.skipped {
background: #EA4335; background: #fee2e2;
color: white; color: #991b1b;
} }
.metadata-section,
.segments-section,
.metrics-section,
.description-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.metadata-section h3,
.segments-section h3, .segments-section h3,
.metrics-section h3, .metrics-section h3,
.description-section h3 { .description-section h3 {
margin: 0 0 1rem 0; margin: 0;
color: #2c3e50; color: #2c3e50;
font-size: 1.1rem; font-size: 1.2rem;
font-weight: 700;
border-bottom: 2px solid #667eea; 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 { .segments-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 1rem;
} }
.segment-card { .segment-card {
@@ -312,41 +408,45 @@ function formatSegmentType(type) {
border: 1px solid #e0e6ed; border: 1px solid #e0e6ed;
border-left: 4px solid #667eea; border-left: 4px solid #667eea;
padding: 1rem; padding: 1rem;
border-radius: 4px; border-radius: 6px;
} }
.segment-header { .segment-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 0.75rem; margin-bottom: 1rem;
flex-wrap: wrap;
gap: 0.75rem;
} }
.segment-type { .segment-type {
display: inline-block; display: inline-block;
background: #667eea; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 0.25rem 0.75rem; padding: 0.4rem 0.85rem;
border-radius: 3px; border-radius: 6px;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 700;
} }
.segment-duration { .segment-duration {
color: #2c3e50; color: #2c3e50;
font-weight: 600; font-weight: 700;
font-size: 1rem;
} }
.segment-details { .segment-details {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.75rem;
} }
.detail-row { .detail-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-size: 0.9rem; font-size: 0.95rem;
align-items: center;
} }
.detail-row span { .detail-row span {
@@ -355,6 +455,7 @@ function formatSegmentType(type) {
.detail-row strong { .detail-row strong {
color: #2c3e50; color: #2c3e50;
font-weight: 600;
} }
.metrics-grid { .metrics-grid {
@@ -364,10 +465,11 @@ function formatSegmentType(type) {
} }
.metric { .metric {
background: #f9f9f9; background: linear-gradient(135deg, #f0f4ff 0%, #ffffff 100%);
padding: 1rem; padding: 1.25rem;
border-radius: 4px; border-radius: 6px;
text-align: center; text-align: center;
border: 1px solid #dbeafe;
} }
.metric-label { .metric-label {
@@ -376,54 +478,69 @@ function formatSegmentType(type) {
color: #7f8c8d; color: #7f8c8d;
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
font-weight: 700;
} }
.metric-value { .metric-value {
display: block; display: block;
font-size: 1.25rem; font-size: 1.4rem;
color: #667eea; color: #667eea;
font-weight: 700; font-weight: 800;
} }
.description-section p { .description-section p {
margin: 0; margin: 0;
color: #2c3e50; color: #4b5563;
line-height: 1.6; line-height: 1.7;
} background: #f9f9f9;
padding: 1.25rem;
.source-section { border-radius: 6px;
color: #95a5a6; border: 1px solid #e5e7eb;
font-style: italic;
} }
.detail-footer { .detail-footer {
display: flex; display: flex;
gap: 0.5rem; gap: 0.75rem;
padding: 1.5rem 2rem; padding: 1.5rem 2rem;
border-top: 1px solid #ecf0f1; border-top: 1px solid #ecf0f1;
background: #f9f9f9; background: #f9f9f9;
flex-wrap: wrap;
} }
.status-select { .status-select {
flex: 1; flex: 1;
min-width: 150px;
padding: 0.75rem; padding: 0.75rem;
border: 1px solid #bdc3c7; border: 2px solid #e5e7eb;
border-radius: 4px; border-radius: 6px;
font-size: 0.9rem; 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 { .btn-danger {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: #e74c3c; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: all 0.2s;
} }
.btn-danger:hover { .btn-danger:hover {
background: #c0392b; transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
} }
.btn-secondary { .btn-secondary {
@@ -431,9 +548,10 @@ function formatSegmentType(type) {
background: #95a5a6; background: #95a5a6;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 600;
transition: all 0.2s;
} }
.btn-secondary:hover { .btn-secondary:hover {
@@ -445,7 +563,8 @@ function formatSegmentType(type) {
max-width: 100%; max-width: 100%;
} }
.info-grid { .info-grid,
.metadata-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -457,5 +576,9 @@ function formatSegmentType(type) {
.detail-footer select { .detail-footer select {
width: 100%; width: 100%;
} }
.header-info h2 {
font-size: 1.4rem;
}
} }
</style> </style>

View File

@@ -1,13 +1,7 @@
import axios from 'axios' 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({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: 'http://127.0.0.1:5000',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -40,12 +34,7 @@ api.interceptors.response.use(
throw new Error('No refresh token') throw new Error('No refresh token')
} }
// Use relative path in production, full URL in development const { data } = await axios.post('http://127.0.0.1:5000/api/refresh-token', {
const refreshUrl = process.env.NODE_ENV === 'production'
? '/api/refresh-token'
: `${API_BASE_URL}/refresh-token`
const { data } = await axios.post(refreshUrl, {
refresh_token: refreshToken refresh_token: refreshToken
}) })

View 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
}

View File

@@ -5,11 +5,5 @@ module.exports = defineConfig({
productionSourceMap: false, productionSourceMap: false,
devServer: { devServer: {
port: 3000, port: 3000,
proxy: {
'/api': {
target: process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000',
changeOrigin: true,
},
},
}, },
}); });