diff --git a/src/components/WorkoutCreate.vue b/src/components/WorkoutCreate.vue index 9f7ba02..d1914e9 100644 --- a/src/components/WorkoutCreate.vue +++ b/src/components/WorkoutCreate.vue @@ -41,14 +41,34 @@
- - + + + +
+ +
+ + +
+
+ +
+
+ +
@@ -63,27 +83,33 @@
- +

Workout Preview

-
- Total Duration - {{ calculatedDuration }} min -
-
- Intervals - {{ form.intervals.length }} -
-
- Estimated IF - {{ calculatedIF }} -
-
- Estimated TSS - {{ calculatedTSS }} + +
+
+ Total Duration + {{ calculatedDuration }} min +
+
+ Intervals + {{ totalIntervalCount }} +
+
+ Estimated IF + {{ calculatedIF }} +
+
+ Estimated TSS + {{ calculatedTSS }} +
@@ -131,6 +157,7 @@ import { ref, reactive, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import ModernNavbar from './ModernNavbar.vue' import IntervalBuilder from './workout/IntervalBuilder.vue' +import IntervalDisplay from './workout/IntervalDisplay.vue' import { useWorkoutLibraryStore } from '@/stores/workoutLibrary' const route = useRoute() @@ -141,33 +168,115 @@ const saving = ref(false) const error = ref('') const isEditing = computed(() => !!route.params.workoutId) +// Default options +const workoutTypes = [ + { value: 'endurance', label: 'Endurance' }, + { value: 'tempo', label: 'Tempo' }, + { value: 'threshold', label: 'Threshold' }, + { value: 'vo2max', label: 'VO2 Max' }, + { value: 'sprint', label: 'Sprint' }, + { value: 'recovery', label: 'Recovery' }, + { value: 'climbing', label: 'Climbing' }, + { value: 'interval', label: 'Interval' }, + { value: 'freeride', label: 'Free Ride' }, + { value: 'race', label: 'Race' } +] + +const workoutCategories = [ + { value: 'base', label: 'Base' }, + { value: 'build', label: 'Build' }, + { value: 'peak', label: 'Peak' }, + { value: 'recovery', label: 'Recovery' }, + { value: 'test', label: 'Test' }, + { value: 'fun', label: 'Fun' } +] + +const difficultyLevels = [ + { value: 'beginner', label: 'Beginner' }, + { value: 'intermediate', label: 'Intermediate' }, + { value: 'advanced', label: 'Advanced' }, + { value: 'expert', label: 'Expert' } +] + const form = reactive({ name: '', description: '', + type: '', category: '', + difficulty: '', is_public: false, - intervals: [] + structure: { + warmup: [], + main: [], + cooldown: [] + } }) const isValid = computed(() => { - return form.name && form.category && form.intervals.length > 0 + return form.name && form.type && totalIntervalCount.value > 0 +}) + +// Flatten structure for calculations +const flattenedIntervals = computed(() => { + const result = [] + const structure = form.structure + + // Add warmup intervals + for (const interval of structure.warmup || []) { + expandInterval(interval, result) + } + + // Add main intervals + for (const interval of structure.main || []) { + expandInterval(interval, result) + } + + // Add cooldown intervals + for (const interval of structure.cooldown || []) { + expandInterval(interval, result) + } + + return result +}) + +function expandInterval(interval, result) { + const repeatCount = interval.repeat || 1 + + for (let i = 0; i < repeatCount; i++) { + result.push({ ...interval }) + + // Add rest interval between repeats + if (interval.rest_between && i < repeatCount - 1) { + result.push({ + duration: interval.rest_between, + power_low: 0.5, + power_high: 0.5 + }) + } + } +} + +const totalIntervalCount = computed(() => { + const s = form.structure + return (s.warmup?.length || 0) + (s.main?.length || 0) + (s.cooldown?.length || 0) }) const calculatedDuration = computed(() => { - const totalSeconds = form.intervals.reduce((sum, i) => sum + (i.duration_seconds || 0), 0) + const totalSeconds = flattenedIntervals.value.reduce((sum, i) => sum + (i.duration || 0), 0) return Math.round(totalSeconds / 60) }) const calculatedIF = computed(() => { - if (form.intervals.length === 0) return '—' + if (flattenedIntervals.value.length === 0) return '—' let weightedPower = 0 let totalDuration = 0 - form.intervals.forEach(interval => { - const duration = interval.duration_seconds || 0 - const power = interval.power_target_value || 0 - weightedPower += (power / 100) * duration + flattenedIntervals.value.forEach(interval => { + const duration = interval.duration || 0 + // Use average of power_low and power_high (already in decimal form like 0.65) + const power = ((interval.power_low || 0.5) + (interval.power_high || 0.5)) / 2 + weightedPower += power * duration totalDuration += duration }) @@ -188,13 +297,14 @@ async function loadWorkout() { try { const workout = await store.fetchWorkout(route.params.workoutId) - const intervals = await store.fetchWorkoutIntervals(route.params.workoutId) form.name = workout.name form.description = workout.description || '' - form.category = workout.category + form.type = workout.type || '' + form.category = workout.category || '' + form.difficulty = workout.difficulty || '' form.is_public = workout.is_public || false - form.intervals = intervals + form.structure = workout.structure || { warmup: [], main: [], cooldown: [] } } catch (err) { error.value = 'Failed to load workout' } @@ -210,20 +320,14 @@ async function saveWorkout() { const payload = { name: form.name, description: form.description, - category: form.category, + type: form.type, + category: form.category || null, + difficulty: form.difficulty || null, is_public: form.is_public, duration_minutes: calculatedDuration.value, intensity_factor: parseFloat(calculatedIF.value) || null, tss: parseInt(calculatedTSS.value) || null, - intervals: form.intervals.map((interval, index) => ({ - order_index: index, - interval_type: interval.interval_type, - duration_seconds: interval.duration_seconds, - power_target_type: interval.power_target_type, - power_target_value: interval.power_target_value, - cadence_target: interval.cadence_target, - notes: interval.notes - })) + structure: form.structure } if (isEditing.value) { @@ -241,6 +345,7 @@ async function saveWorkout() { } onMounted(() => { + store.fetchTypes() loadWorkout() }) @@ -290,7 +395,7 @@ onMounted(() => { .form-layout { display: grid; - grid-template-columns: 1fr 320px; + grid-template-columns: 1fr 360px; gap: var(--spacing-xl); margin-bottom: var(--spacing-xl); } @@ -337,6 +442,10 @@ onMounted(() => { height: fit-content; } +.stats-preview .stats-list { + margin-top: var(--spacing-lg); +} + .stats-preview .stat-row { display: flex; justify-content: space-between; diff --git a/src/components/WorkoutLibrary.vue b/src/components/WorkoutLibrary.vue index cfcf7ee..fcb8538 100644 --- a/src/components/WorkoutLibrary.vue +++ b/src/components/WorkoutLibrary.vue @@ -20,6 +20,9 @@ @@ -139,7 +142,7 @@ const store = useWorkoutLibraryStore() const viewMode = ref('grid') const totalPages = computed(() => { - return Math.ceil(store.pagination.total / store.pagination.limit) + return Math.ceil(store.pagination.total / store.pagination.pageSize) }) function handleFilterChange(filters) { @@ -165,6 +168,7 @@ async function toggleFavorite(workoutId) { } onMounted(() => { + store.fetchTypes() store.fetchWorkouts() store.fetchFavorites() }) diff --git a/src/components/WorkoutLibraryDetail.vue b/src/components/WorkoutLibraryDetail.vue index ba0c37d..1e7919e 100644 --- a/src/components/WorkoutLibraryDetail.vue +++ b/src/components/WorkoutLibraryDetail.vue @@ -30,8 +30,16 @@
-
- {{ formatCategory(workout.category) }} +
+
+ {{ formatType(workout.type) }} +
+
+ {{ formatCategory(workout.category) }} +
+
+ {{ formatDifficulty(workout.difficulty) }} +

{{ workout.name }}

{{ workout.description }}

@@ -56,7 +64,7 @@

Workout Structure

@@ -142,23 +150,51 @@ const router = useRouter() const store = useWorkoutLibraryStore() const workout = ref(null) -const intervals = ref([]) const loading = ref(true) const error = ref('') const userRating = ref(0) -const categoryLabels = { +const typeLabels = { endurance: 'Endurance', + tempo: 'Tempo', threshold: 'Threshold', vo2max: 'VO2 Max', sprint: 'Sprint', - recovery: 'Recovery' + recovery: 'Recovery', + climbing: 'Climbing', + interval: 'Interval', + freeride: 'Free Ride', + race: 'Race' +} + +const categoryLabels = { + base: 'Base', + build: 'Build', + peak: 'Peak', + recovery: 'Recovery', + test: 'Test', + fun: 'Fun' +} + +const difficultyLabels = { + beginner: 'Beginner', + intermediate: 'Intermediate', + advanced: 'Advanced', + expert: 'Expert' +} + +function formatType(type) { + return typeLabels[type] || type } function formatCategory(category) { return categoryLabels[category] || category } +function formatDifficulty(difficulty) { + return difficultyLabels[difficulty] || difficulty +} + function formatIF(value) { if (!value) return '—' return value.toFixed(2) @@ -171,7 +207,6 @@ async function loadWorkout() { try { const workoutId = route.params.workoutId workout.value = await store.fetchWorkout(workoutId) - intervals.value = await store.fetchWorkoutIntervals(workoutId) await store.fetchFavorites() } catch (err) { error.value = err.response?.data?.error || 'Failed to load workout' @@ -294,7 +329,16 @@ onMounted(() => { flex: 1; } -.workout-category { +.workout-badges { + display: flex; + gap: var(--spacing-xs); + flex-wrap: wrap; + margin-bottom: var(--spacing-sm); +} + +.workout-type, +.workout-category, +.workout-difficulty { display: inline-block; padding: var(--spacing-xs) var(--spacing-sm); border-radius: var(--radius-sm); @@ -302,32 +346,81 @@ onMounted(() => { font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.5px; - margin-bottom: var(--spacing-sm); } -.category-endurance { +.type-endurance { background: rgba(46, 204, 113, 0.15); color: #27ae60; } -.category-threshold { +.type-tempo { + background: rgba(52, 152, 219, 0.15); + color: #2980b9; +} + +.type-threshold { background: rgba(241, 196, 15, 0.15); color: #f39c12; } -.category-vo2max { +.type-vo2max { background: rgba(231, 76, 60, 0.15); color: #c0392b; } -.category-sprint { +.type-sprint { background: rgba(155, 89, 182, 0.15); color: #8e44ad; } -.category-recovery { - background: rgba(52, 152, 219, 0.15); - color: #2980b9; +.type-recovery { + background: rgba(149, 165, 166, 0.15); + color: #7f8c8d; +} + +.type-climbing { + background: rgba(230, 126, 34, 0.15); + color: #d35400; +} + +.type-interval { + background: rgba(26, 188, 156, 0.15); + color: #16a085; +} + +.type-freeride { + background: rgba(52, 73, 94, 0.15); + color: #2c3e50; +} + +.type-race { + background: rgba(192, 57, 43, 0.15); + color: #c0392b; +} + +.workout-category { + background: var(--color-surface-secondary); + color: var(--color-text-secondary); +} + +.difficulty-beginner { + background: rgba(46, 204, 113, 0.1); + color: #27ae60; +} + +.difficulty-intermediate { + background: rgba(241, 196, 15, 0.1); + color: #f39c12; +} + +.difficulty-advanced { + background: rgba(230, 126, 34, 0.1); + color: #d35400; +} + +.difficulty-expert { + background: rgba(231, 76, 60, 0.1); + color: #c0392b; } .header-info h1 { diff --git a/src/components/workout/IntervalBuilder.vue b/src/components/workout/IntervalBuilder.vue index 09aca16..e3fc995 100644 --- a/src/components/workout/IntervalBuilder.vue +++ b/src/components/workout/IntervalBuilder.vue @@ -1,237 +1,310 @@ @@ -257,158 +330,60 @@ watch(intervals, () => { color: var(--color-text-primary); } -.btn-sm { +.total-duration { padding: var(--spacing-xs) var(--spacing-md); + background: var(--color-primary); + color: white; + border-radius: var(--radius-md); font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); } -.btn-icon { - width: 16px; - height: 16px; - margin-right: var(--spacing-xs); +.workout-graph { + margin-bottom: var(--spacing-xl); } -.intervals-list { - margin-top: var(--spacing-lg); - display: flex; - flex-direction: column; - gap: var(--spacing-md); -} - -.interval-row { - display: flex; - align-items: flex-start; - gap: var(--spacing-md); +.section { + margin-bottom: var(--spacing-lg); padding: var(--spacing-md); background: var(--color-surface-secondary); - border: 1px solid var(--color-border); border-radius: var(--radius-md); - cursor: grab; } -.interval-row:active { - cursor: grabbing; +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); } -.drag-handle { - width: 24px; - height: 24px; +.section-header h4 { + margin: 0; display: flex; align-items: center; - justify-content: center; - color: var(--color-text-secondary); - flex-shrink: 0; - margin-top: var(--spacing-lg); -} - -.drag-handle svg { - width: 18px; - height: 18px; -} - -.interval-fields { - flex: 1; - display: grid; - grid-template-columns: 120px 140px 200px 80px 1fr; - gap: var(--spacing-md); - align-items: end; -} - -.field-group { - display: flex; - flex-direction: column; - gap: var(--spacing-xs); -} - -.field-group label { - font-size: var(--font-size-xs); + gap: var(--spacing-sm); + font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); color: var(--color-text-secondary); } -.field-notes { - min-width: 150px; +.section-badge { + padding: 2px var(--spacing-sm); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + color: white; } -.duration-inputs, -.power-inputs { +.section-badge.warmup { background: #3498db; } +.section-badge.main { background: #e74c3c; } +.section-badge.cooldown { background: #9b59b6; } + +.btn-add { display: flex; align-items: center; gap: var(--spacing-xs); -} - -.duration-inputs input { - width: 50px; - text-align: center; -} - -.duration-inputs span { - color: var(--color-text-secondary); -} - -.power-inputs input { - width: 60px; -} - -.power-inputs select { - width: 90px; -} - -.btn-remove { - width: 32px; - height: 32px; - border: none; - background: transparent; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-md); - color: var(--color-text-secondary); - transition: all var(--transition-base); - flex-shrink: 0; - margin-top: var(--spacing-lg); -} - -.btn-remove:hover { - background: rgba(230, 57, 70, 0.1); - color: var(--color-danger); -} - -.btn-remove svg { - width: 16px; - height: 16px; -} - -.empty-intervals { - margin-top: var(--spacing-lg); - padding: var(--spacing-xl); - text-align: center; - background: var(--color-surface-secondary); - border-radius: var(--radius-md); -} - -.empty-intervals p { - margin: 0; - color: var(--color-text-secondary); -} - -.quick-add { - display: flex; - align-items: center; - gap: var(--spacing-sm); - margin-top: var(--spacing-lg); - padding-top: var(--spacing-lg); - border-top: 1px solid var(--color-border); - flex-wrap: wrap; -} - -.quick-add-label { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); -} - -.btn-quick { padding: var(--spacing-xs) var(--spacing-sm); border: 1px solid var(--color-border); border-radius: var(--radius-sm); @@ -419,28 +394,122 @@ watch(intervals, () => { transition: all var(--transition-base); } -.btn-quick:hover { +.btn-add:hover { border-color: var(--color-primary); color: var(--color-primary); } -@media (max-width: 900px) { - .interval-fields { - grid-template-columns: 1fr 1fr; - } - - .field-notes { - grid-column: span 2; - } +.btn-add svg { + width: 14px; + height: 14px; } -@media (max-width: 600px) { - .interval-fields { - grid-template-columns: 1fr; +.intervals-list { + display: flex; + flex-direction: column; + gap: var(--spacing-sm); +} + +.interval-row { + display: flex; + align-items: flex-start; + gap: var(--spacing-md); + padding: var(--spacing-md); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); +} + +.interval-actions { + display: flex; + flex-direction: column; + gap: 4px; + flex-shrink: 0; +} + +.btn-action { + width: 28px; + height: 28px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-base); +} + +.btn-action:hover:not(:disabled) { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.btn-action:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.btn-action.btn-danger:hover:not(:disabled) { + border-color: var(--color-danger); + color: var(--color-danger); +} + +.btn-action svg { + width: 14px; + height: 14px; +} + +.empty-section { + padding: var(--spacing-md); + text-align: center; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + background: var(--color-surface); + border-radius: var(--radius-sm); + border: 1px dashed var(--color-border); +} + +.quick-presets { + display: flex; + align-items: center; + gap: var(--spacing-sm); + flex-wrap: wrap; + padding-top: var(--spacing-lg); + border-top: 1px solid var(--color-border); +} + +.presets-label { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); +} + +.btn-preset { + padding: var(--spacing-xs) var(--spacing-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + cursor: pointer; + transition: all var(--transition-base); +} + +.btn-preset:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +@media (max-width: 768px) { + .interval-row { + flex-direction: column; } - .field-notes { - grid-column: span 1; + .interval-actions { + flex-direction: row; + width: 100%; + justify-content: flex-end; } } diff --git a/src/components/workout/IntervalDisplay.vue b/src/components/workout/IntervalDisplay.vue index 9dc8cac..d078076 100644 --- a/src/components/workout/IntervalDisplay.vue +++ b/src/components/workout/IntervalDisplay.vue @@ -1,11 +1,11 @@