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,24 +46,49 @@
<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>
<div class="form-group">
<label>Description</label>
<textarea v-model="newWorkout.description" placeholder="Workout details..."></textarea>
</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>