fix intervals not loading and other library issues
This commit is contained in:
@@ -41,14 +41,34 @@
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Category *</label>
|
||||
<select v-model="form.category" class="form-input-modern" required>
|
||||
<label class="form-label-modern">Type *</label>
|
||||
<select v-model="form.type" class="form-input-modern" required>
|
||||
<option value="">Select type</option>
|
||||
<option v-for="t in workoutTypes" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Category</label>
|
||||
<select v-model="form.category" class="form-input-modern">
|
||||
<option value="">Select category</option>
|
||||
<option value="endurance">Endurance</option>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="vo2max">VO2 Max</option>
|
||||
<option value="sprint">Sprint</option>
|
||||
<option value="recovery">Recovery</option>
|
||||
<option v-for="c in workoutCategories" :key="c.value" :value="c.value">
|
||||
{{ c.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Difficulty</label>
|
||||
<select v-model="form.difficulty" class="form-input-modern">
|
||||
<option value="">Select difficulty</option>
|
||||
<option v-for="d in difficultyLevels" :key="d.value" :value="d.value">
|
||||
{{ d.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -63,27 +83,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalBuilder v-model="form.intervals" />
|
||||
<IntervalBuilder v-model="form.structure" />
|
||||
</div>
|
||||
|
||||
<div class="side-column">
|
||||
<div class="card-modern stats-preview">
|
||||
<h3>Workout Preview</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Duration</span>
|
||||
<span class="stat-value">{{ calculatedDuration }} min</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Intervals</span>
|
||||
<span class="stat-value">{{ form.intervals.length }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated IF</span>
|
||||
<span class="stat-value">{{ calculatedIF }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated TSS</span>
|
||||
<span class="stat-value">{{ calculatedTSS }}</span>
|
||||
<IntervalDisplay
|
||||
:structure="form.structure"
|
||||
:show-labels="false"
|
||||
/>
|
||||
<div class="stats-list">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Duration</span>
|
||||
<span class="stat-value">{{ calculatedDuration }} min</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Intervals</span>
|
||||
<span class="stat-value">{{ totalIntervalCount }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated IF</span>
|
||||
<span class="stat-value">{{ calculatedIF }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated TSS</span>
|
||||
<span class="stat-value">{{ calculatedTSS }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
</script>
|
||||
@@ -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;
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
<aside class="filters-sidebar">
|
||||
<WorkoutFilters
|
||||
:filters="store.filters"
|
||||
:workout-types="store.workoutTypes"
|
||||
:workout-categories="store.workoutCategories"
|
||||
:difficulty-levels="store.difficultyLevels"
|
||||
@update:filters="handleFilterChange"
|
||||
/>
|
||||
</aside>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -30,8 +30,16 @@
|
||||
|
||||
<div class="header-row">
|
||||
<div class="header-info">
|
||||
<div class="workout-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
<div class="workout-badges">
|
||||
<div class="workout-type" :class="`type-${workout.type}`">
|
||||
{{ formatType(workout.type) }}
|
||||
</div>
|
||||
<div v-if="workout.category" class="workout-category">
|
||||
{{ formatCategory(workout.category) }}
|
||||
</div>
|
||||
<div v-if="workout.difficulty" class="workout-difficulty" :class="`difficulty-${workout.difficulty}`">
|
||||
{{ formatDifficulty(workout.difficulty) }}
|
||||
</div>
|
||||
</div>
|
||||
<h1>{{ workout.name }}</h1>
|
||||
<p v-if="workout.description">{{ workout.description }}</p>
|
||||
@@ -56,7 +64,7 @@
|
||||
<div class="card-modern">
|
||||
<h2>Workout Structure</h2>
|
||||
<IntervalDisplay
|
||||
:intervals="intervals"
|
||||
:structure="workout.structure"
|
||||
:show-legend="true"
|
||||
:show-details="true"
|
||||
/>
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,237 +1,310 @@
|
||||
<template>
|
||||
<div class="interval-builder">
|
||||
<div class="builder-header">
|
||||
<h3>Workout Intervals</h3>
|
||||
<button type="button" class="btn-modern btn-modern-secondary btn-sm" @click="addInterval">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add Interval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IntervalDisplay :intervals="intervals" :show-labels="true" />
|
||||
|
||||
<div class="intervals-list" v-if="intervals.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in intervals"
|
||||
:key="interval.id || index"
|
||||
class="interval-row"
|
||||
draggable="true"
|
||||
@dragstart="dragStart(index)"
|
||||
@dragover.prevent
|
||||
@drop="drop(index)"
|
||||
>
|
||||
<div class="drag-handle">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="8" y1="6" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="18" x2="16" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="interval-fields">
|
||||
<div class="field-group">
|
||||
<label>Type</label>
|
||||
<select v-model="interval.interval_type" class="form-input-modern">
|
||||
<option value="warmup">Warmup</option>
|
||||
<option value="work">Work</option>
|
||||
<option value="rest">Rest</option>
|
||||
<option value="cooldown">Cooldown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Duration</label>
|
||||
<div class="duration-inputs">
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.floor((interval.duration_seconds || 0) / 60)"
|
||||
@input="updateDuration(index, $event, 'minutes')"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
placeholder="min"
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="(interval.duration_seconds || 0) % 60"
|
||||
@input="updateDuration(index, $event, 'seconds')"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="sec"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Power Target</label>
|
||||
<div class="power-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="interval.power_target_value"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="200"
|
||||
placeholder="Value"
|
||||
/>
|
||||
<select v-model="interval.power_target_type" class="form-input-modern">
|
||||
<option value="ftp_percent">% FTP</option>
|
||||
<option value="watts">Watts</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Cadence</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="interval.cadence_target"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="200"
|
||||
placeholder="rpm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group field-notes">
|
||||
<label>Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="interval.notes"
|
||||
class="form-input-modern"
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove" @click="removeInterval(index)" title="Remove interval">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<h3>Workout Structure</h3>
|
||||
<div class="total-duration">
|
||||
Total: {{ formatTotalDuration(calculatedTotalSeconds) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-intervals">
|
||||
<p>No intervals added yet. Click "Add Interval" to build your workout.</p>
|
||||
<!-- Workout Graph Preview -->
|
||||
<div class="workout-graph">
|
||||
<IntervalDisplay :structure="localStructure" :show-labels="true" />
|
||||
</div>
|
||||
|
||||
<div class="quick-add">
|
||||
<span class="quick-add-label">Quick add:</span>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('warmup', 300, 55)">
|
||||
5min Warmup
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('work', 300, 95)">
|
||||
5min @ 95%
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('rest', 180, 55)">
|
||||
3min Rest
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('cooldown', 300, 50)">
|
||||
5min Cooldown
|
||||
</button>
|
||||
<!-- Warmup Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge warmup">Warmup</span>
|
||||
{{ formatSectionDuration(localStructure.warmup) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('warmup')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.warmup.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.warmup"
|
||||
:key="`warmup-${index}`"
|
||||
class="interval-row"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
@update="updateInterval('warmup', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('warmup', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('warmup', index, 1)" :disabled="index === localStructure.warmup.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('warmup', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add a warmup interval</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge main">Main Set</span>
|
||||
{{ formatSectionDuration(localStructure.main) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('main')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.main.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.main"
|
||||
:key="`main-${index}`"
|
||||
class="interval-row"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
:show-repeats="true"
|
||||
@update="updateInterval('main', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('main', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('main', index, 1)" :disabled="index === localStructure.main.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('main', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add main set intervals</div>
|
||||
</div>
|
||||
|
||||
<!-- Cooldown Section -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<h4>
|
||||
<span class="section-badge cooldown">Cooldown</span>
|
||||
{{ formatSectionDuration(localStructure.cooldown) }}
|
||||
</h4>
|
||||
<button type="button" class="btn-add" @click="addInterval('cooldown')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="intervals-list" v-if="localStructure.cooldown.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in localStructure.cooldown"
|
||||
:key="`cooldown-${index}`"
|
||||
class="interval-row"
|
||||
>
|
||||
<IntervalRowFields
|
||||
:interval="interval"
|
||||
@update="updateInterval('cooldown', index, $event)"
|
||||
/>
|
||||
<div class="interval-actions">
|
||||
<button type="button" class="btn-action" @click="moveInterval('cooldown', index, -1)" :disabled="index === 0" title="Move up">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M18 15L12 9L6 15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action" @click="moveInterval('cooldown', index, 1)" :disabled="index === localStructure.cooldown.length - 1" title="Move down">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
<button type="button" class="btn-action btn-danger" @click="removeInterval('cooldown', index)" title="Remove">
|
||||
<svg viewBox="0 0 24 24" fill="none"><line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-section">Click "Add" to add a cooldown interval</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add Presets -->
|
||||
<div class="quick-presets">
|
||||
<span class="presets-label">Quick add:</span>
|
||||
<button type="button" class="btn-preset" @click="addPreset('warmup-easy')">10min Warmup</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('sweetspot')">Sweet Spot 2x20</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('vo2max')">VO2Max 5x3</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('threshold')">Threshold 2x20</button>
|
||||
<button type="button" class="btn-preset" @click="addPreset('cooldown')">5min Cooldown</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, defineProps, defineEmits } from 'vue'
|
||||
import { ref, computed, watch, defineProps, defineEmits } from 'vue'
|
||||
import IntervalDisplay from './IntervalDisplay.vue'
|
||||
import IntervalRowFields from './IntervalRowFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
type: Object,
|
||||
default: () => ({
|
||||
warmup: [],
|
||||
main: [],
|
||||
cooldown: []
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const intervals = ref([...props.modelValue])
|
||||
const dragIndex = ref(null)
|
||||
const localStructure = ref({
|
||||
warmup: [...(props.modelValue?.warmup || [])],
|
||||
main: [...(props.modelValue?.main || [])],
|
||||
cooldown: [...(props.modelValue?.cooldown || [])]
|
||||
})
|
||||
|
||||
function addInterval() {
|
||||
intervals.value.push({
|
||||
id: Date.now(),
|
||||
order_index: intervals.value.length,
|
||||
interval_type: 'work',
|
||||
duration_seconds: 300,
|
||||
power_target_type: 'ftp_percent',
|
||||
power_target_value: 90,
|
||||
cadence_target: null,
|
||||
notes: ''
|
||||
})
|
||||
// Calculate total duration including repeats
|
||||
const calculatedTotalSeconds = computed(() => {
|
||||
let total = 0
|
||||
for (const section of ['warmup', 'main', 'cooldown']) {
|
||||
for (const interval of localStructure.value[section]) {
|
||||
const duration = interval.duration || 0
|
||||
const repeats = interval.repeat || 1
|
||||
const restBetween = interval.rest_between || 0
|
||||
total += duration * repeats + restBetween * Math.max(0, repeats - 1)
|
||||
}
|
||||
}
|
||||
return total
|
||||
})
|
||||
|
||||
function formatTotalDuration(seconds) {
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${mins}m`
|
||||
}
|
||||
return `${mins} min`
|
||||
}
|
||||
|
||||
function formatSectionDuration(intervals) {
|
||||
let total = 0
|
||||
for (const interval of intervals) {
|
||||
const duration = interval.duration || 0
|
||||
const repeats = interval.repeat || 1
|
||||
const restBetween = interval.rest_between || 0
|
||||
total += duration * repeats + restBetween * Math.max(0, repeats - 1)
|
||||
}
|
||||
const mins = Math.floor(total / 60)
|
||||
return `(${mins} min)`
|
||||
}
|
||||
|
||||
function addInterval(section) {
|
||||
const defaults = {
|
||||
warmup: { duration: 600, power_low: 0.50, power_high: 0.65 },
|
||||
main: { duration: 300, power_low: 0.90, power_high: 1.00 },
|
||||
cooldown: { duration: 300, power_low: 0.50, power_high: 0.55 }
|
||||
}
|
||||
localStructure.value[section].push({ ...defaults[section] })
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function addQuickInterval(type, duration, power) {
|
||||
intervals.value.push({
|
||||
id: Date.now(),
|
||||
order_index: intervals.value.length,
|
||||
interval_type: type,
|
||||
duration_seconds: duration,
|
||||
power_target_type: 'ftp_percent',
|
||||
power_target_value: power,
|
||||
cadence_target: null,
|
||||
notes: ''
|
||||
})
|
||||
function updateInterval(section, index, updatedInterval) {
|
||||
localStructure.value[section][index] = { ...updatedInterval }
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function removeInterval(index) {
|
||||
intervals.value.splice(index, 1)
|
||||
updateOrderIndices()
|
||||
function removeInterval(section, index) {
|
||||
localStructure.value[section].splice(index, 1)
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function updateDuration(index, event, unit) {
|
||||
const value = parseInt(event.target.value) || 0
|
||||
const current = intervals.value[index].duration_seconds || 0
|
||||
const minutes = Math.floor(current / 60)
|
||||
const seconds = current % 60
|
||||
function moveInterval(section, index, direction) {
|
||||
const newIndex = index + direction
|
||||
if (newIndex < 0 || newIndex >= localStructure.value[section].length) return
|
||||
|
||||
if (unit === 'minutes') {
|
||||
intervals.value[index].duration_seconds = value * 60 + seconds
|
||||
} else {
|
||||
intervals.value[index].duration_seconds = minutes * 60 + Math.min(value, 59)
|
||||
const intervals = localStructure.value[section]
|
||||
const item = intervals.splice(index, 1)[0]
|
||||
intervals.splice(newIndex, 0, item)
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function addPreset(preset) {
|
||||
switch (preset) {
|
||||
case 'warmup-easy':
|
||||
localStructure.value.warmup.push({
|
||||
name: 'Easy Warmup',
|
||||
duration: 600,
|
||||
power_low: 0.50,
|
||||
power_high: 0.65
|
||||
})
|
||||
break
|
||||
case 'sweetspot':
|
||||
localStructure.value.main.push({
|
||||
name: 'Sweet Spot',
|
||||
duration: 1200,
|
||||
power_low: 0.88,
|
||||
power_high: 0.93,
|
||||
repeat: 2,
|
||||
rest_between: 300
|
||||
})
|
||||
break
|
||||
case 'vo2max':
|
||||
localStructure.value.main.push({
|
||||
name: 'VO2Max',
|
||||
duration: 180,
|
||||
power_low: 1.10,
|
||||
power_high: 1.20,
|
||||
repeat: 5,
|
||||
rest_between: 180
|
||||
})
|
||||
break
|
||||
case 'threshold':
|
||||
localStructure.value.main.push({
|
||||
name: 'Threshold',
|
||||
duration: 1200,
|
||||
power_low: 0.95,
|
||||
power_high: 1.05,
|
||||
repeat: 2,
|
||||
rest_between: 300
|
||||
})
|
||||
break
|
||||
case 'cooldown':
|
||||
localStructure.value.cooldown.push({
|
||||
name: 'Easy Cooldown',
|
||||
duration: 300,
|
||||
power_low: 0.50,
|
||||
power_high: 0.55
|
||||
})
|
||||
break
|
||||
}
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function dragStart(index) {
|
||||
dragIndex.value = index
|
||||
}
|
||||
|
||||
function drop(index) {
|
||||
if (dragIndex.value === null || dragIndex.value === index) return
|
||||
|
||||
const item = intervals.value.splice(dragIndex.value, 1)[0]
|
||||
intervals.value.splice(index, 0, item)
|
||||
updateOrderIndices()
|
||||
emitUpdate()
|
||||
dragIndex.value = null
|
||||
}
|
||||
|
||||
function updateOrderIndices() {
|
||||
intervals.value.forEach((interval, i) => {
|
||||
interval.order_index = i
|
||||
function emitUpdate() {
|
||||
emit('update:modelValue', {
|
||||
warmup: localStructure.value.warmup.map(i => ({ ...i })),
|
||||
main: localStructure.value.main.map(i => ({ ...i })),
|
||||
cooldown: localStructure.value.cooldown.map(i => ({ ...i }))
|
||||
})
|
||||
}
|
||||
|
||||
function emitUpdate() {
|
||||
emit('update:modelValue', [...intervals.value])
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
intervals.value = [...newVal]
|
||||
}, { deep: true })
|
||||
|
||||
watch(intervals, () => {
|
||||
emitUpdate()
|
||||
if (newVal) {
|
||||
localStructure.value = {
|
||||
warmup: [...(newVal.warmup || [])],
|
||||
main: [...(newVal.main || [])],
|
||||
cooldown: [...(newVal.cooldown || [])]
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="interval-display">
|
||||
<div class="interval-chart" v-if="intervals.length > 0">
|
||||
<div class="interval-chart" v-if="flattenedIntervals.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in intervals"
|
||||
v-for="(interval, index) in flattenedIntervals"
|
||||
:key="index"
|
||||
class="interval-bar"
|
||||
:class="`interval-${interval.interval_type}`"
|
||||
:class="`interval-${interval.section}`"
|
||||
:style="{
|
||||
width: `${getIntervalWidth(interval)}%`,
|
||||
height: `${getIntervalHeight(interval)}%`
|
||||
@@ -13,7 +13,7 @@
|
||||
:title="getIntervalTooltip(interval)"
|
||||
>
|
||||
<span class="interval-label" v-if="showLabels && getIntervalWidth(interval) > 8">
|
||||
{{ formatDuration(interval.duration_seconds) }}
|
||||
{{ formatDuration(interval.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,8 +27,8 @@
|
||||
<span>Warmup</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-work"></span>
|
||||
<span>Work</span>
|
||||
<span class="legend-color interval-main"></span>
|
||||
<span>Main</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-rest"></span>
|
||||
@@ -40,18 +40,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDetails && intervals.length > 0" class="interval-details">
|
||||
<div v-for="(interval, index) in intervals" :key="index" class="interval-detail-row">
|
||||
<span class="interval-index">{{ index + 1 }}</span>
|
||||
<span class="interval-type-badge" :class="`interval-${interval.interval_type}`">
|
||||
{{ formatType(interval.interval_type) }}
|
||||
</span>
|
||||
<span class="interval-duration">{{ formatDuration(interval.duration_seconds) }}</span>
|
||||
<span class="interval-power">{{ formatPower(interval) }}</span>
|
||||
<span class="interval-cadence" v-if="interval.cadence_target">
|
||||
{{ interval.cadence_target }} rpm
|
||||
</span>
|
||||
<span class="interval-notes" v-if="interval.notes">{{ interval.notes }}</span>
|
||||
<div v-if="showDetails && flattenedIntervals.length > 0" class="interval-details">
|
||||
<div v-for="(section, sectionName) in groupedIntervals" :key="sectionName" class="interval-section">
|
||||
<h4 class="section-title">{{ formatSectionName(sectionName) }}</h4>
|
||||
<div v-for="(interval, index) in section" :key="index" class="interval-detail-row">
|
||||
<span class="interval-index">{{ index + 1 }}</span>
|
||||
<span class="interval-type-badge" :class="`interval-${sectionName}`">
|
||||
{{ interval.name || formatSectionName(sectionName) }}
|
||||
</span>
|
||||
<span class="interval-duration">{{ formatDuration(interval.duration) }}</span>
|
||||
<span class="interval-power">{{ formatPower(interval) }}</span>
|
||||
<span class="interval-cadence" v-if="interval.cadence">
|
||||
{{ interval.cadence }} rpm
|
||||
</span>
|
||||
<span class="interval-repeat" v-if="interval.repeat && interval.repeat > 1">
|
||||
x{{ interval.repeat }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,9 +66,9 @@
|
||||
import { computed, defineProps } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
intervals: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
structure: {
|
||||
type: Object,
|
||||
default: () => ({ warmup: [], main: [], cooldown: [] })
|
||||
},
|
||||
showLegend: {
|
||||
type: Boolean,
|
||||
@@ -79,36 +84,100 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
// Flatten structure into array with section tags for chart display
|
||||
const flattenedIntervals = computed(() => {
|
||||
const result = []
|
||||
const structure = props.structure || { warmup: [], main: [], cooldown: [] }
|
||||
|
||||
// Add warmup intervals
|
||||
for (const interval of structure.warmup || []) {
|
||||
expandInterval(interval, 'warmup', result)
|
||||
}
|
||||
|
||||
// Add main intervals
|
||||
for (const interval of structure.main || []) {
|
||||
expandInterval(interval, 'main', result)
|
||||
}
|
||||
|
||||
// Add cooldown intervals
|
||||
for (const interval of structure.cooldown || []) {
|
||||
expandInterval(interval, 'cooldown', result)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// Expand intervals with repeats
|
||||
function expandInterval(interval, section, result) {
|
||||
const repeatCount = interval.repeat || 1
|
||||
|
||||
for (let i = 0; i < repeatCount; i++) {
|
||||
result.push({ ...interval, section })
|
||||
|
||||
// Add rest interval between repeats (not after last one)
|
||||
if (interval.rest_between && i < repeatCount - 1) {
|
||||
result.push({
|
||||
duration: interval.rest_between,
|
||||
power_low: 0.5,
|
||||
power_high: 0.5,
|
||||
section: 'rest',
|
||||
name: 'Rest'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by section for detailed view
|
||||
const groupedIntervals = computed(() => {
|
||||
const structure = props.structure || { warmup: [], main: [], cooldown: [] }
|
||||
const result = {}
|
||||
|
||||
if (structure.warmup?.length > 0) {
|
||||
result.warmup = structure.warmup
|
||||
}
|
||||
if (structure.main?.length > 0) {
|
||||
result.main = structure.main
|
||||
}
|
||||
if (structure.cooldown?.length > 0) {
|
||||
result.cooldown = structure.cooldown
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
return props.intervals.reduce((sum, i) => sum + (i.duration_seconds || 0), 0)
|
||||
return flattenedIntervals.value.reduce((sum, i) => sum + (i.duration || 0), 0)
|
||||
})
|
||||
|
||||
function getIntervalWidth(interval) {
|
||||
if (totalDuration.value === 0) return 0
|
||||
return (interval.duration_seconds / totalDuration.value) * 100
|
||||
return (interval.duration / totalDuration.value) * 100
|
||||
}
|
||||
|
||||
function getIntervalHeight(interval) {
|
||||
const power = interval.power_target_value || 50
|
||||
// Use the higher power value for height, convert from decimal (0.65 = 65%)
|
||||
const power = Math.max(interval.power_low || 0.5, interval.power_high || 0.5)
|
||||
const powerPercent = power * 100
|
||||
const maxPower = 150
|
||||
return Math.min((power / maxPower) * 100, 100)
|
||||
return Math.min((powerPercent / maxPower) * 100, 100)
|
||||
}
|
||||
|
||||
function getIntervalTooltip(interval) {
|
||||
const type = formatType(interval.interval_type)
|
||||
const duration = formatDuration(interval.duration_seconds)
|
||||
const section = formatSectionName(interval.section)
|
||||
const duration = formatDuration(interval.duration)
|
||||
const power = formatPower(interval)
|
||||
return `${type}: ${duration} @ ${power}`
|
||||
const name = interval.name ? ` (${interval.name})` : ''
|
||||
return `${section}${name}: ${duration} @ ${power}`
|
||||
}
|
||||
|
||||
function formatType(type) {
|
||||
const types = {
|
||||
function formatSectionName(section) {
|
||||
const sections = {
|
||||
warmup: 'Warmup',
|
||||
work: 'Work',
|
||||
main: 'Main',
|
||||
rest: 'Rest',
|
||||
cooldown: 'Cooldown'
|
||||
}
|
||||
return types[type] || type
|
||||
return sections[section] || section
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
@@ -119,12 +188,13 @@ function formatDuration(seconds) {
|
||||
}
|
||||
|
||||
function formatPower(interval) {
|
||||
if (interval.power_target_type === 'ftp_percent') {
|
||||
return `${interval.power_target_value}% FTP`
|
||||
} else if (interval.power_target_type === 'watts') {
|
||||
return `${interval.power_target_value}W`
|
||||
const low = Math.round((interval.power_low || 0) * 100)
|
||||
const high = Math.round((interval.power_high || 0) * 100)
|
||||
|
||||
if (low === high) {
|
||||
return `${low}% FTP`
|
||||
}
|
||||
return `${interval.power_target_value || 0}%`
|
||||
return `${low}-${high}% FTP`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -161,7 +231,7 @@ function formatPower(interval) {
|
||||
background: linear-gradient(to top, #3498db, #5dade2);
|
||||
}
|
||||
|
||||
.interval-work {
|
||||
.interval-main {
|
||||
background: linear-gradient(to top, #e74c3c, #ec7063);
|
||||
}
|
||||
|
||||
@@ -218,7 +288,7 @@ function formatPower(interval) {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.legend-color.interval-work {
|
||||
.legend-color.interval-main {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
@@ -232,11 +302,26 @@ function formatPower(interval) {
|
||||
|
||||
.interval-details {
|
||||
margin-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.interval-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.interval-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -272,7 +357,7 @@ function formatPower(interval) {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-work {
|
||||
.interval-type-badge.interval-main {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
@@ -292,16 +377,19 @@ function formatPower(interval) {
|
||||
|
||||
.interval-power {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 80px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.interval-cadence {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.interval-notes {
|
||||
flex: 1;
|
||||
.interval-repeat {
|
||||
padding: 2px var(--spacing-xs);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
296
src/components/workout/IntervalRowFields.vue
Normal file
296
src/components/workout/IntervalRowFields.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div class="interval-row-fields">
|
||||
<div class="field-group duration-field">
|
||||
<label>Duration</label>
|
||||
<div class="duration-input">
|
||||
<input
|
||||
type="number"
|
||||
:value="minutes"
|
||||
@input="updateDuration('minutes', $event)"
|
||||
min="0"
|
||||
max="180"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span class="separator">:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="seconds"
|
||||
@input="updateDuration('seconds', $event)"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group power-field">
|
||||
<label>Power Low (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="powerLowPercent"
|
||||
@input="updatePower('power_low', $event)"
|
||||
min="0"
|
||||
max="200"
|
||||
step="5"
|
||||
placeholder="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group power-field">
|
||||
<label>Power High (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="powerHighPercent"
|
||||
@input="updatePower('power_high', $event)"
|
||||
min="0"
|
||||
max="200"
|
||||
step="5"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group name-field">
|
||||
<label>Name</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="interval.name || ''"
|
||||
@input="updateField('name', $event.target.value)"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group cadence-field">
|
||||
<label>Cadence</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="interval.cadence || ''"
|
||||
@input="updateField('cadence', $event.target.value ? parseInt($event.target.value) : null)"
|
||||
min="40"
|
||||
max="150"
|
||||
placeholder="RPM"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="showRepeats">
|
||||
<div class="field-group repeat-field">
|
||||
<label>Repeat</label>
|
||||
<input
|
||||
type="number"
|
||||
:value="interval.repeat || 1"
|
||||
@input="updateField('repeat', parseInt($event.target.value) || 1)"
|
||||
min="1"
|
||||
max="20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group rest-field" v-if="(interval.repeat || 1) > 1">
|
||||
<label>Rest Between</label>
|
||||
<div class="duration-input">
|
||||
<input
|
||||
type="number"
|
||||
:value="restMinutes"
|
||||
@input="updateRestDuration('minutes', $event)"
|
||||
min="0"
|
||||
max="30"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span class="separator">:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="restSeconds"
|
||||
@input="updateRestDuration('seconds', $event)"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
interval: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showRepeats: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
// Duration conversion (seconds to min:sec)
|
||||
const minutes = computed(() => Math.floor((props.interval.duration || 0) / 60))
|
||||
const seconds = computed(() => (props.interval.duration || 0) % 60)
|
||||
|
||||
// Rest duration conversion
|
||||
const restMinutes = computed(() => Math.floor((props.interval.rest_between || 0) / 60))
|
||||
const restSeconds = computed(() => (props.interval.rest_between || 0) % 60)
|
||||
|
||||
// Power conversion (decimal to percent)
|
||||
const powerLowPercent = computed(() => {
|
||||
if (props.interval.power_low == null) return ''
|
||||
return Math.round(props.interval.power_low * 100)
|
||||
})
|
||||
|
||||
const powerHighPercent = computed(() => {
|
||||
if (props.interval.power_high == null) return ''
|
||||
return Math.round(props.interval.power_high * 100)
|
||||
})
|
||||
|
||||
function updateDuration(part, event) {
|
||||
const value = parseInt(event.target.value) || 0
|
||||
let newMinutes = minutes.value
|
||||
let newSeconds = seconds.value
|
||||
|
||||
if (part === 'minutes') {
|
||||
newMinutes = Math.max(0, Math.min(180, value))
|
||||
} else {
|
||||
newSeconds = Math.max(0, Math.min(59, value))
|
||||
}
|
||||
|
||||
const totalSeconds = newMinutes * 60 + newSeconds
|
||||
emitUpdate({ duration: totalSeconds })
|
||||
}
|
||||
|
||||
function updateRestDuration(part, event) {
|
||||
const value = parseInt(event.target.value) || 0
|
||||
let newMinutes = restMinutes.value
|
||||
let newSeconds = restSeconds.value
|
||||
|
||||
if (part === 'minutes') {
|
||||
newMinutes = Math.max(0, Math.min(30, value))
|
||||
} else {
|
||||
newSeconds = Math.max(0, Math.min(59, value))
|
||||
}
|
||||
|
||||
const totalSeconds = newMinutes * 60 + newSeconds
|
||||
emitUpdate({ rest_between: totalSeconds })
|
||||
}
|
||||
|
||||
function updatePower(field, event) {
|
||||
const percentValue = parseInt(event.target.value)
|
||||
if (isNaN(percentValue)) {
|
||||
emitUpdate({ [field]: null })
|
||||
} else {
|
||||
const decimalValue = Math.max(0, Math.min(2, percentValue / 100))
|
||||
emitUpdate({ [field]: decimalValue })
|
||||
}
|
||||
}
|
||||
|
||||
function updateField(field, value) {
|
||||
emitUpdate({ [field]: value })
|
||||
}
|
||||
|
||||
function emitUpdate(changes) {
|
||||
emit('update', { ...props.interval, ...changes })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-row-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.field-group input {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.field-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.field-group input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.field-group input[type="number"]::-webkit-outer-spin-button,
|
||||
.field-group input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.duration-field {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.duration-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.duration-input input {
|
||||
width: 45px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-input .separator {
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.power-field {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.power-field input {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.name-field {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.name-field input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cadence-field {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.cadence-field input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.repeat-field {
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.repeat-field input {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.rest-field {
|
||||
min-width: 100px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,13 @@
|
||||
<template>
|
||||
<div class="workout-card" @click="$emit('click', workout)">
|
||||
<div class="workout-card-header">
|
||||
<div class="workout-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
<div class="workout-badges">
|
||||
<div class="workout-type" :class="`type-${workout.type}`">
|
||||
{{ formatType(workout.type) }}
|
||||
</div>
|
||||
<div v-if="workout.difficulty" class="workout-difficulty" :class="`difficulty-${workout.difficulty}`">
|
||||
{{ formatDifficulty(workout.difficulty) }}
|
||||
</div>
|
||||
</div>
|
||||
<FavoriteButton
|
||||
v-if="showFavorite"
|
||||
@@ -75,16 +80,32 @@ defineProps({
|
||||
|
||||
defineEmits(['click', 'toggle-favorite'])
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return categoryLabels[category] || category
|
||||
const difficultyLabels = {
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
expert: 'Expert'
|
||||
}
|
||||
|
||||
function formatType(type) {
|
||||
return typeLabels[type] || type
|
||||
}
|
||||
|
||||
function formatDifficulty(difficulty) {
|
||||
return difficultyLabels[difficulty] || difficulty
|
||||
}
|
||||
|
||||
function formatIF(value) {
|
||||
@@ -122,7 +143,14 @@ function truncateDescription(text) {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.workout-category {
|
||||
.workout-badges {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workout-type,
|
||||
.workout-difficulty {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -132,29 +160,74 @@ function truncateDescription(text) {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.workout-name {
|
||||
|
||||
@@ -24,16 +24,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Type</label>
|
||||
<div class="filter-options">
|
||||
<button
|
||||
v-for="t in types"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.type === t.value }"
|
||||
@click="toggleFilter('type', t.value)"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Category</label>
|
||||
<div class="category-options">
|
||||
<div class="filter-options">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.value"
|
||||
type="button"
|
||||
class="category-btn"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.category === cat.value }"
|
||||
@click="toggleCategory(cat.value)"
|
||||
@click="toggleFilter('category', cat.value)"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
@@ -41,52 +57,18 @@
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Duration (minutes)</label>
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.duration_min"
|
||||
@input="emitFilters"
|
||||
placeholder="Min"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
/>
|
||||
<span class="range-separator">to</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.duration_max"
|
||||
@input="emitFilters"
|
||||
placeholder="Max"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Intensity Factor</label>
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.intensity_min"
|
||||
@input="emitFilters"
|
||||
placeholder="Min"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
/>
|
||||
<span class="range-separator">to</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.intensity_max"
|
||||
@input="emitFilters"
|
||||
placeholder="Max"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
/>
|
||||
<label class="filter-label">Difficulty</label>
|
||||
<div class="filter-options">
|
||||
<button
|
||||
v-for="diff in difficulties"
|
||||
:key="diff.value"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ active: localFilters.difficulty === diff.value }"
|
||||
@click="toggleFilter('difficulty', diff.value)"
|
||||
>
|
||||
{{ diff.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,36 +81,68 @@ const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
workoutTypes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
workoutCategories: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
difficultyLevels: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:filters'])
|
||||
|
||||
const categories = [
|
||||
// Default types if not provided from store
|
||||
const defaultTypes = [
|
||||
{ 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: 'recovery', label: 'Recovery' },
|
||||
{ value: 'climbing', label: 'Climbing' },
|
||||
{ value: 'interval', label: 'Interval' }
|
||||
]
|
||||
|
||||
const defaultCategories = [
|
||||
{ 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 defaultDifficulties = [
|
||||
{ value: 'beginner', label: 'Beginner' },
|
||||
{ value: 'intermediate', label: 'Intermediate' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
{ value: 'expert', label: 'Expert' }
|
||||
]
|
||||
|
||||
const types = computed(() => props.workoutTypes.length > 0 ? props.workoutTypes : defaultTypes)
|
||||
const categories = computed(() => props.workoutCategories.length > 0 ? props.workoutCategories : defaultCategories)
|
||||
const difficulties = computed(() => props.difficultyLevels.length > 0 ? props.difficultyLevels : defaultDifficulties)
|
||||
|
||||
const localFilters = ref({
|
||||
search: '',
|
||||
type: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
difficulty: '',
|
||||
...props.filters
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return localFilters.value.search ||
|
||||
localFilters.value.type ||
|
||||
localFilters.value.category ||
|
||||
localFilters.value.duration_min ||
|
||||
localFilters.value.duration_max ||
|
||||
localFilters.value.intensity_min ||
|
||||
localFilters.value.intensity_max
|
||||
localFilters.value.difficulty
|
||||
})
|
||||
|
||||
let debounceTimer = null
|
||||
@@ -143,19 +157,17 @@ function emitFilters() {
|
||||
emit('update:filters', { ...localFilters.value })
|
||||
}
|
||||
|
||||
function toggleCategory(category) {
|
||||
localFilters.value.category = localFilters.value.category === category ? '' : category
|
||||
function toggleFilter(filterName, value) {
|
||||
localFilters.value[filterName] = localFilters.value[filterName] === value ? '' : value
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
localFilters.value = {
|
||||
search: '',
|
||||
type: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null
|
||||
difficulty: ''
|
||||
}
|
||||
emitFilters()
|
||||
}
|
||||
@@ -234,13 +246,13 @@ watch(() => props.filters, (newFilters) => {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.category-options {
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
.filter-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
@@ -251,30 +263,14 @@ watch(() => props.filters, (newFilters) => {
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
.filter-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
.filter-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.range-input {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.range-separator {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,72 +1,113 @@
|
||||
import api from './api'
|
||||
|
||||
export const workoutLibraryApi = {
|
||||
// Browse & Search
|
||||
async getWorkouts(params = {}) {
|
||||
const { data } = await api.get('/api/protected/workout-library', { params })
|
||||
// Get workout types, categories, and difficulties
|
||||
async getTypes() {
|
||||
const { data } = await api.get('/protected/library/types')
|
||||
return data
|
||||
},
|
||||
|
||||
// Get system-provided workouts
|
||||
async getSystemWorkouts() {
|
||||
const { data } = await api.get('/protected/library/system')
|
||||
return data
|
||||
},
|
||||
|
||||
// Browse public workouts (paginated)
|
||||
async browseWorkouts(page = 1, pageSize = 20) {
|
||||
const { data } = await api.get('/protected/library/browse', {
|
||||
params: { page, page_size: pageSize }
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
// Search/filter workouts
|
||||
async searchWorkouts(params = {}) {
|
||||
const { data } = await api.get('/protected/library/search', {
|
||||
params: {
|
||||
q: params.search || undefined,
|
||||
type: params.type || undefined,
|
||||
category: params.category || undefined,
|
||||
difficulty: params.difficulty || undefined,
|
||||
page: params.page || 1,
|
||||
page_size: params.pageSize || 20
|
||||
}
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
// Get workouts by type
|
||||
async getWorkoutsByType(type) {
|
||||
const { data } = await api.get(`/protected/library/type/${type}`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Get workouts by category
|
||||
async getWorkoutsByCategory(category) {
|
||||
const { data } = await api.get(`/protected/library/category/${category}`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Get single workout details
|
||||
async getWorkout(workoutId) {
|
||||
const { data } = await api.get(`/api/protected/workout-library/${workoutId}`)
|
||||
const { data } = await api.get(`/protected/library/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
async getWorkoutIntervals(workoutId) {
|
||||
const { data } = await api.get(`/api/protected/workout-library/${workoutId}/intervals`)
|
||||
return data
|
||||
},
|
||||
|
||||
// User's Workouts
|
||||
// Get current user's custom workouts
|
||||
async getUserWorkouts() {
|
||||
const { data } = await api.get('/api/protected/workouts')
|
||||
const { data } = await api.get('/protected/library/mine')
|
||||
return data
|
||||
},
|
||||
|
||||
async createWorkout(workout) {
|
||||
const { data } = await api.post('/api/protected/workouts', workout)
|
||||
return data
|
||||
},
|
||||
|
||||
async updateWorkout(workoutId, workout) {
|
||||
const { data } = await api.put(`/api/protected/workouts/${workoutId}`, workout)
|
||||
return data
|
||||
},
|
||||
|
||||
async deleteWorkout(workoutId) {
|
||||
const { data } = await api.delete(`/api/protected/workouts/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
async publishWorkout(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workouts/${workoutId}/publish`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Favorites
|
||||
// Get user's favorited workouts
|
||||
async getFavorites() {
|
||||
const { data } = await api.get('/api/protected/workout-favorites')
|
||||
const { data } = await api.get('/protected/library/favorites')
|
||||
return data
|
||||
},
|
||||
|
||||
async addFavorite(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workout-favorites/${workoutId}`)
|
||||
// Create custom workout
|
||||
async createWorkout(workout) {
|
||||
const { data } = await api.post('/protected/library', workout)
|
||||
return data
|
||||
},
|
||||
|
||||
async removeFavorite(workoutId) {
|
||||
const { data } = await api.delete(`/api/protected/workout-favorites/${workoutId}`)
|
||||
// Update user's workout
|
||||
async updateWorkout(workoutId, workout) {
|
||||
const { data } = await api.put(`/protected/library/${workoutId}`, workout)
|
||||
return data
|
||||
},
|
||||
|
||||
// Usage & Ratings
|
||||
// Delete user's workout
|
||||
async deleteWorkout(workoutId) {
|
||||
const { data } = await api.delete(`/protected/library/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Mark workout as used
|
||||
async recordUsage(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workout-library/${workoutId}/use`)
|
||||
const { data } = await api.post(`/protected/library/${workoutId}/use`)
|
||||
return data
|
||||
},
|
||||
|
||||
async rateWorkout(workoutId, rating) {
|
||||
const { data } = await api.post(`/api/protected/workout-library/${workoutId}/rate`, { rating })
|
||||
// Add to favorites
|
||||
async addFavorite(workoutId) {
|
||||
const { data } = await api.post(`/protected/library/${workoutId}/favorite`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Remove from favorites
|
||||
async removeFavorite(workoutId) {
|
||||
const { data } = await api.delete(`/protected/library/${workoutId}/favorite`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Rate workout (1-5 stars + optional comment)
|
||||
async rateWorkout(workoutId, rating, comment = null) {
|
||||
const { data } = await api.post(`/protected/library/${workoutId}/rate`, {
|
||||
rating,
|
||||
comment: comment || undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,55 +3,155 @@ import { ref, computed } from 'vue'
|
||||
import workoutLibraryApi from '@/services/workoutLibraryApi'
|
||||
|
||||
export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
// State
|
||||
const workouts = ref([])
|
||||
const systemWorkouts = ref([])
|
||||
const userWorkouts = ref([])
|
||||
const favorites = ref([])
|
||||
const currentWorkout = ref(null)
|
||||
const currentIntervals = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Workout types, categories, difficulties from API
|
||||
const workoutTypes = ref([])
|
||||
const workoutCategories = ref([])
|
||||
const difficultyLevels = ref([])
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
type: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
difficulty: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
// Pagination
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
pageSize: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// Computed
|
||||
const favoriteIds = computed(() => new Set(favorites.value.map(f => f.id)))
|
||||
|
||||
const isFavorited = (workoutId) => favoriteIds.value.has(workoutId)
|
||||
|
||||
async function fetchWorkouts() {
|
||||
// Fetch workout types, categories, difficulties
|
||||
async function fetchTypes() {
|
||||
try {
|
||||
const data = await workoutLibraryApi.getTypes()
|
||||
workoutTypes.value = data.types || []
|
||||
workoutCategories.value = data.categories || []
|
||||
difficultyLevels.value = data.difficulties || []
|
||||
return data
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch workout types:', err)
|
||||
// Set defaults if API fails
|
||||
workoutTypes.value = [
|
||||
{ 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' }
|
||||
]
|
||||
workoutCategories.value = [
|
||||
{ value: 'base', label: 'Base' },
|
||||
{ value: 'build', label: 'Build' },
|
||||
{ value: 'peak', label: 'Peak' },
|
||||
{ value: 'recovery', label: 'Recovery' },
|
||||
{ value: 'test', label: 'Test' },
|
||||
{ value: 'fun', label: 'Fun' }
|
||||
]
|
||||
difficultyLevels.value = [
|
||||
{ value: 'beginner', label: 'Beginner' },
|
||||
{ value: 'intermediate', label: 'Intermediate' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
{ value: 'expert', label: 'Expert' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch system workouts
|
||||
async function fetchSystemWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit,
|
||||
...Object.fromEntries(
|
||||
Object.entries(filters.value).filter(([, v]) => v !== '' && v !== null)
|
||||
)
|
||||
}
|
||||
const data = await workoutLibraryApi.getWorkouts(params)
|
||||
workouts.value = data.workouts || []
|
||||
pagination.value.total = data.total || 0
|
||||
const data = await workoutLibraryApi.getSystemWorkouts()
|
||||
systemWorkouts.value = data.workouts || data || []
|
||||
return systemWorkouts.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch workouts'
|
||||
error.value = err.response?.data?.error || 'Failed to fetch system workouts'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Browse public workouts
|
||||
async function browseWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.browseWorkouts(
|
||||
pagination.value.page,
|
||||
pagination.value.pageSize
|
||||
)
|
||||
workouts.value = data.workouts || data || []
|
||||
pagination.value.total = data.total || workouts.value.length
|
||||
return workouts.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to browse workouts'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Search/filter workouts
|
||||
async function searchWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.searchWorkouts({
|
||||
search: filters.value.search,
|
||||
type: filters.value.type,
|
||||
category: filters.value.category,
|
||||
difficulty: filters.value.difficulty,
|
||||
page: pagination.value.page,
|
||||
pageSize: pagination.value.pageSize
|
||||
})
|
||||
workouts.value = data.workouts || data || []
|
||||
pagination.value.total = data.total || workouts.value.length
|
||||
return workouts.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to search workouts'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch workouts (uses search if filters set, otherwise browse)
|
||||
async function fetchWorkouts() {
|
||||
const hasFilters = filters.value.search || filters.value.type ||
|
||||
filters.value.category || filters.value.difficulty
|
||||
|
||||
if (hasFilters) {
|
||||
return searchWorkouts()
|
||||
} else {
|
||||
return browseWorkouts()
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch single workout
|
||||
async function fetchWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -68,39 +168,50 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkoutIntervals(workoutId) {
|
||||
try {
|
||||
const data = await workoutLibraryApi.getWorkoutIntervals(workoutId)
|
||||
currentIntervals.value = data.intervals || []
|
||||
return currentIntervals.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch intervals'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch user's custom workouts
|
||||
async function fetchUserWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getUserWorkouts()
|
||||
userWorkouts.value = data.workouts || []
|
||||
userWorkouts.value = data.workouts || data || []
|
||||
return userWorkouts.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch your workouts'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch favorites
|
||||
async function fetchFavorites() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getFavorites()
|
||||
favorites.value = data.workouts || data || []
|
||||
return favorites.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch favorites'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Create workout
|
||||
async function createWorkout(workout) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.createWorkout(workout)
|
||||
userWorkouts.value.unshift(data.workout || data)
|
||||
return data.workout || data
|
||||
const newWorkout = data.workout || data
|
||||
userWorkouts.value.unshift(newWorkout)
|
||||
return newWorkout
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to create workout'
|
||||
throw err
|
||||
@@ -109,17 +220,19 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Update workout
|
||||
async function updateWorkout(workoutId, workout) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.updateWorkout(workoutId, workout)
|
||||
const updatedWorkout = data.workout || data
|
||||
const index = userWorkouts.value.findIndex(w => w.id === workoutId)
|
||||
if (index !== -1) {
|
||||
userWorkouts.value[index] = data.workout || data
|
||||
userWorkouts.value[index] = updatedWorkout
|
||||
}
|
||||
return data.workout || data
|
||||
return updatedWorkout
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to update workout'
|
||||
throw err
|
||||
@@ -128,6 +241,7 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Delete workout
|
||||
async function deleteWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -143,39 +257,23 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
// Record usage
|
||||
async function recordUsage(workoutId) {
|
||||
try {
|
||||
const data = await workoutLibraryApi.publishWorkout(workoutId)
|
||||
const index = userWorkouts.value.findIndex(w => w.id === workoutId)
|
||||
if (index !== -1) {
|
||||
userWorkouts.value[index].is_public = true
|
||||
await workoutLibraryApi.recordUsage(workoutId)
|
||||
// Update usage count in local state if workout exists
|
||||
const workout = workouts.value.find(w => w.id === workoutId) ||
|
||||
systemWorkouts.value.find(w => w.id === workoutId) ||
|
||||
currentWorkout.value
|
||||
if (workout) {
|
||||
workout.usage_count = (workout.usage_count || 0) + 1
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to publish workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFavorites() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getFavorites()
|
||||
favorites.value = data.favorites || data.workouts || []
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch favorites'
|
||||
} finally {
|
||||
loading.value = false
|
||||
console.error('Failed to record usage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle favorite
|
||||
async function toggleFavorite(workoutId) {
|
||||
try {
|
||||
if (isFavorited(workoutId)) {
|
||||
@@ -183,9 +281,12 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
favorites.value = favorites.value.filter(f => f.id !== workoutId)
|
||||
} else {
|
||||
await workoutLibraryApi.addFavorite(workoutId)
|
||||
const workout = workouts.value.find(w => w.id === workoutId) || currentWorkout.value
|
||||
// Find workout to add to favorites
|
||||
const workout = workouts.value.find(w => w.id === workoutId) ||
|
||||
systemWorkouts.value.find(w => w.id === workoutId) ||
|
||||
currentWorkout.value
|
||||
if (workout) {
|
||||
favorites.value.push(workout)
|
||||
favorites.value.push({ ...workout })
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -194,19 +295,13 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function recordUsage(workoutId) {
|
||||
// Rate workout
|
||||
async function rateWorkout(workoutId, rating, comment = null) {
|
||||
try {
|
||||
await workoutLibraryApi.recordUsage(workoutId)
|
||||
} catch (err) {
|
||||
console.error('Failed to record usage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function rateWorkout(workoutId, rating) {
|
||||
try {
|
||||
const data = await workoutLibraryApi.rateWorkout(workoutId, rating)
|
||||
const data = await workoutLibraryApi.rateWorkout(workoutId, rating, comment)
|
||||
// Update rating in current workout if viewing
|
||||
if (currentWorkout.value && currentWorkout.value.id === workoutId) {
|
||||
currentWorkout.value.average_rating = data.average_rating
|
||||
currentWorkout.value.rating = data.rating || rating
|
||||
currentWorkout.value.rating_count = data.rating_count
|
||||
currentWorkout.value.user_rating = rating
|
||||
}
|
||||
@@ -217,6 +312,7 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter helpers
|
||||
function setFilters(newFilters) {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
pagination.value.page = 1
|
||||
@@ -224,11 +320,9 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = {
|
||||
type: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
difficulty: '',
|
||||
search: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
@@ -238,32 +332,61 @@ export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
pagination.value.page = page
|
||||
}
|
||||
|
||||
// Get label for type/category/difficulty value
|
||||
function getTypeLabel(value) {
|
||||
const type = workoutTypes.value.find(t => t.value === value)
|
||||
return type?.label || value
|
||||
}
|
||||
|
||||
function getCategoryLabel(value) {
|
||||
const category = workoutCategories.value.find(c => c.value === value)
|
||||
return category?.label || value
|
||||
}
|
||||
|
||||
function getDifficultyLabel(value) {
|
||||
const difficulty = difficultyLevels.value.find(d => d.value === value)
|
||||
return difficulty?.label || value
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
workouts,
|
||||
systemWorkouts,
|
||||
userWorkouts,
|
||||
favorites,
|
||||
currentWorkout,
|
||||
currentIntervals,
|
||||
loading,
|
||||
error,
|
||||
workoutTypes,
|
||||
workoutCategories,
|
||||
difficultyLevels,
|
||||
filters,
|
||||
pagination,
|
||||
|
||||
// Computed
|
||||
favoriteIds,
|
||||
isFavorited,
|
||||
|
||||
// Actions
|
||||
fetchTypes,
|
||||
fetchSystemWorkouts,
|
||||
browseWorkouts,
|
||||
searchWorkouts,
|
||||
fetchWorkouts,
|
||||
fetchWorkout,
|
||||
fetchWorkoutIntervals,
|
||||
fetchUserWorkouts,
|
||||
fetchFavorites,
|
||||
createWorkout,
|
||||
updateWorkout,
|
||||
deleteWorkout,
|
||||
publishWorkout,
|
||||
fetchFavorites,
|
||||
toggleFavorite,
|
||||
recordUsage,
|
||||
toggleFavorite,
|
||||
rateWorkout,
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setPage
|
||||
setPage,
|
||||
getTypeLabel,
|
||||
getCategoryLabel,
|
||||
getDifficultyLabel
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user