fix intervals not loading and other library issues

This commit is contained in:
Cipher Vance
2026-01-21 19:04:11 -06:00
parent 9c55db2128
commit 35e28590ff
10 changed files with 1558 additions and 666 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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