feat: add workout calendar with file import and detail view
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<form @submit.prevent="handleAddWorkout" class="workout-form">
|
||||
<div class="form-group">
|
||||
<label>Title *</label>
|
||||
<input v-model="newWorkout.title" type="text" required />
|
||||
<input v-model="newWorkout.title" type="text" placeholder="e.g., Morning Ride" required />
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
@@ -46,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>
|
||||
Reference in New Issue
Block a user