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">
<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>

View File

@@ -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>

View File

@@ -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
})

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,
devServer: {
port: 3000,
proxy: {
'/api': {
target: process.env.VUE_APP_API_URL || 'http://127.0.0.1:5000',
changeOrigin: true,
},
},
},
});