feat: add workout visualization utilities and components
This commit is contained in:
46
src/components/workout/MiniSegmentChart.vue
Normal file
46
src/components/workout/MiniSegmentChart.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full overflow-hidden rounded-sm"
|
||||
:style="{ height: height + 'px' }"
|
||||
v-if="segments && segments.length > 0"
|
||||
>
|
||||
<div
|
||||
v-for="(seg, idx) in segments"
|
||||
:key="idx"
|
||||
class="min-w-[2px]"
|
||||
:style="{
|
||||
width: getWidth(seg) + '%',
|
||||
backgroundColor: getColor(seg),
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from 'vue'
|
||||
import { getZoneColor, getSegmentPower } from '@/utils/workoutHelpers'
|
||||
|
||||
const props = defineProps({
|
||||
segments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 18,
|
||||
},
|
||||
})
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
return props.segments.reduce((sum, seg) => sum + (seg.duration || 0), 0)
|
||||
})
|
||||
|
||||
function getWidth(seg) {
|
||||
if (totalDuration.value === 0) return 0
|
||||
return (seg.duration / totalDuration.value) * 100
|
||||
}
|
||||
|
||||
function getColor(seg) {
|
||||
return getZoneColor(getSegmentPower(seg))
|
||||
}
|
||||
</script>
|
||||
48
src/components/workout/ZoneDistribution.vue
Normal file
48
src/components/workout/ZoneDistribution.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div v-if="activeZones.length > 0">
|
||||
<!-- Stacked horizontal bar -->
|
||||
<div class="flex w-full h-2 rounded-full overflow-hidden">
|
||||
<div
|
||||
v-for="z in activeZones"
|
||||
:key="z.zone"
|
||||
:style="{ width: z.percentage + '%', backgroundColor: z.color }"
|
||||
class="min-w-[2px]"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1.5 mt-3">
|
||||
<div v-for="z in activeZones" :key="z.zone" class="flex items-center gap-2 text-xs">
|
||||
<span class="w-2.5 h-2.5 rounded-sm shrink-0" :style="{ backgroundColor: z.color }"></span>
|
||||
<span class="text-zinc-500 dark:text-zinc-400 truncate">Z{{ z.zone }} {{ z.label }}</span>
|
||||
<span class="ml-auto font-semibold text-zinc-700 dark:text-zinc-300 whitespace-nowrap">
|
||||
{{ formatTime(z.seconds) }}
|
||||
<span class="text-zinc-400 dark:text-zinc-500 font-normal">({{ Math.round(z.percentage) }}%)</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineProps } from 'vue'
|
||||
import { computeZoneDistribution } from '@/utils/workoutHelpers'
|
||||
|
||||
const props = defineProps({
|
||||
segments: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const distribution = computed(() => computeZoneDistribution(props.segments))
|
||||
|
||||
const activeZones = computed(() => distribution.value.filter(z => z.seconds > 0))
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!seconds) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
290
src/utils/workoutHelpers.js
Normal file
290
src/utils/workoutHelpers.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// Power zone definitions (% of FTP as decimal)
|
||||
export const POWER_ZONES = [
|
||||
{ zone: 1, label: 'Active Recovery', min: 0, max: 0.55, color: '#4285F4' },
|
||||
{ zone: 2, label: 'Endurance', min: 0.55, max: 0.75, color: '#34A853' },
|
||||
{ zone: 3, label: 'Tempo', min: 0.75, max: 0.90, color: '#FBBC04' },
|
||||
{ zone: 4, label: 'Threshold', min: 0.90, max: 1.05, color: '#EA4335' },
|
||||
{ zone: 5, label: 'VO2max', min: 1.05, max: 1.20, color: '#A61C00' },
|
||||
{ zone: 6, label: 'Anaerobic', min: 1.20, max: 1.50, color: '#800080' },
|
||||
{ zone: 7, label: 'Neuromuscular', min: 1.50, max: 3.0, color: '#FF1744' },
|
||||
]
|
||||
|
||||
export function getZoneForPower(powerDecimal) {
|
||||
if (!powerDecimal || powerDecimal <= 0) return POWER_ZONES[0]
|
||||
for (const zone of POWER_ZONES) {
|
||||
if (powerDecimal >= zone.min && powerDecimal < zone.max) return zone
|
||||
}
|
||||
return POWER_ZONES[POWER_ZONES.length - 1]
|
||||
}
|
||||
|
||||
export function getZoneColor(powerDecimal) {
|
||||
return getZoneForPower(powerDecimal).color
|
||||
}
|
||||
|
||||
export function getSegmentPower(segment) {
|
||||
if (segment.power) return segment.power
|
||||
if (segment.power_low && segment.power_high) {
|
||||
return (segment.power_low + segment.power_high) / 2
|
||||
}
|
||||
if (segment.power_low) return segment.power_low
|
||||
if (segment.power_high) return segment.power_high
|
||||
return 0.5
|
||||
}
|
||||
|
||||
export function computeZoneDistribution(segments) {
|
||||
if (!segments?.length) return []
|
||||
|
||||
const zoneTimes = POWER_ZONES.map(z => ({ ...z, seconds: 0 }))
|
||||
let totalSeconds = 0
|
||||
|
||||
for (const seg of segments) {
|
||||
const power = getSegmentPower(seg)
|
||||
const duration = seg.duration || 0
|
||||
totalSeconds += duration
|
||||
const zone = getZoneForPower(power)
|
||||
const idx = zoneTimes.findIndex(z => z.zone === zone.zone)
|
||||
if (idx >= 0) zoneTimes[idx].seconds += duration
|
||||
}
|
||||
|
||||
return zoneTimes.map(z => ({
|
||||
zone: z.zone,
|
||||
label: z.label,
|
||||
color: z.color,
|
||||
seconds: z.seconds,
|
||||
percentage: totalSeconds > 0 ? (z.seconds / totalSeconds) * 100 : 0,
|
||||
}))
|
||||
}
|
||||
|
||||
export function formatDurationCompact(seconds) {
|
||||
if (!seconds || seconds <= 0) return '0m'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (hours > 0) return `${hours}h${minutes > 0 ? minutes + 'm' : ''}`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
export function formatDurationMinSec(seconds) {
|
||||
if (!seconds) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function segmentsToStructure(segments) {
|
||||
if (!segments?.length) return null
|
||||
|
||||
const structure = { warmup: [], main: [], cooldown: [] }
|
||||
|
||||
for (const seg of segments) {
|
||||
const mapped = {
|
||||
duration: seg.duration || 0,
|
||||
power_low: seg.power_low || seg.power || 0.5,
|
||||
power_high: seg.power_high || seg.power || 0.5,
|
||||
cadence: seg.cadence || null,
|
||||
name: seg.name || null,
|
||||
}
|
||||
|
||||
const type = (seg.type || '').toLowerCase()
|
||||
if (type === 'warmup' || type === 'warm_up') {
|
||||
structure.warmup.push(mapped)
|
||||
} else if (type === 'cooldown' || type === 'cool_down') {
|
||||
structure.cooldown.push(mapped)
|
||||
} else {
|
||||
structure.main.push(mapped)
|
||||
}
|
||||
}
|
||||
|
||||
return structure
|
||||
}
|
||||
|
||||
export function generateWorkoutDescription(segments) {
|
||||
if (!segments?.length) return ''
|
||||
|
||||
const lines = []
|
||||
let i = 0
|
||||
|
||||
while (i < segments.length) {
|
||||
const seg = segments[i]
|
||||
const type = (seg.type || 'interval').toLowerCase()
|
||||
const power = formatPowerRange(seg)
|
||||
const duration = formatDurationCompact(seg.duration)
|
||||
const cadence = seg.cadence ? `, ${seg.cadence}rpm` : ''
|
||||
|
||||
if (type === 'warmup' || type === 'warm_up') {
|
||||
lines.push(`Warmup: ${duration} @ ${power}${cadence}`)
|
||||
i++
|
||||
} else if (type === 'cooldown' || type === 'cool_down') {
|
||||
lines.push(`Cooldown: ${duration} @ ${power}${cadence}`)
|
||||
i++
|
||||
} else {
|
||||
// Look ahead for repeating patterns (work + rest pairs)
|
||||
const pattern = detectRepeatPattern(segments, i)
|
||||
if (pattern.count > 1) {
|
||||
const workSeg = segments[pattern.workIdx]
|
||||
const workPower = formatPowerRange(workSeg)
|
||||
const workDur = formatDurationCompact(workSeg.duration)
|
||||
const workCad = workSeg.cadence ? `, ${workSeg.cadence}rpm` : ''
|
||||
|
||||
let line = `Main Set ${pattern.count}x: ${workDur} @ ${workPower}${workCad}`
|
||||
|
||||
if (pattern.restIdx !== null) {
|
||||
const restSeg = segments[pattern.restIdx]
|
||||
const restPower = formatPowerRange(restSeg)
|
||||
const restDur = formatDurationCompact(restSeg.duration)
|
||||
line += ` / ${restDur} @ ${restPower}`
|
||||
}
|
||||
|
||||
lines.push(line)
|
||||
i += pattern.totalSegments
|
||||
} else {
|
||||
const label = formatSegmentLabel(type)
|
||||
lines.push(`${label}: ${duration} @ ${power}${cadence}`)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatPowerRange(seg) {
|
||||
const low = seg.power_low || seg.power || 0
|
||||
const high = seg.power_high || seg.power || 0
|
||||
|
||||
if (low && high && low !== high) {
|
||||
return `${Math.round(low * 100)}-${Math.round(high * 100)}% FTP`
|
||||
}
|
||||
const val = low || high
|
||||
if (val) return `${Math.round(val * 100)}% FTP`
|
||||
return 'easy'
|
||||
}
|
||||
|
||||
function formatSegmentLabel(type) {
|
||||
if (!type) return 'Interval'
|
||||
return type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function detectRepeatPattern(segments, startIdx) {
|
||||
const result = { count: 1, workIdx: startIdx, restIdx: null, totalSegments: 1 }
|
||||
|
||||
if (startIdx + 1 >= segments.length) return result
|
||||
|
||||
const work = segments[startIdx]
|
||||
const next = segments[startIdx + 1]
|
||||
const nextType = (next.type || '').toLowerCase()
|
||||
|
||||
// Check if next is a rest/recovery segment
|
||||
const isRest = nextType === 'rest' || nextType === 'recovery' || nextType === 'active_recovery'
|
||||
|
||||
if (isRest) {
|
||||
result.restIdx = startIdx + 1
|
||||
const pairSize = 2
|
||||
let count = 1
|
||||
let idx = startIdx + pairSize
|
||||
|
||||
while (idx + pairSize <= segments.length) {
|
||||
const w = segments[idx]
|
||||
const r = segments[idx + 1]
|
||||
if (segmentsMatch(work, w) && segmentsMatch(next, r)) {
|
||||
count++
|
||||
idx += pairSize
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a final work interval without rest
|
||||
if (idx < segments.length && segmentsMatch(work, segments[idx])) {
|
||||
count++
|
||||
idx++
|
||||
}
|
||||
|
||||
result.count = count
|
||||
result.totalSegments = count * pairSize - (idx > startIdx + count * pairSize ? 0 : (count > 1 ? 1 : 0))
|
||||
result.totalSegments = idx - startIdx
|
||||
} else {
|
||||
// Check for repeated identical work segments
|
||||
let count = 1
|
||||
let idx = startIdx + 1
|
||||
|
||||
while (idx < segments.length && segmentsMatch(work, segments[idx])) {
|
||||
count++
|
||||
idx++
|
||||
}
|
||||
|
||||
result.count = count
|
||||
result.totalSegments = count
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function segmentsMatch(a, b) {
|
||||
return a.duration === b.duration &&
|
||||
(a.power || 0) === (b.power || 0) &&
|
||||
(a.power_low || 0) === (b.power_low || 0) &&
|
||||
(a.power_high || 0) === (b.power_high || 0)
|
||||
}
|
||||
|
||||
export function structureToSegments(structure) {
|
||||
if (!structure) return []
|
||||
const segments = []
|
||||
|
||||
for (const interval of (structure.warmup || [])) {
|
||||
segments.push({ type: 'warmup', ...mapInterval(interval) })
|
||||
}
|
||||
|
||||
for (const interval of (structure.main || [])) {
|
||||
const repeats = interval.repeat || 1
|
||||
for (let i = 0; i < repeats; i++) {
|
||||
segments.push({ type: 'interval', ...mapInterval(interval) })
|
||||
if (interval.rest_between && i < repeats - 1) {
|
||||
segments.push({ type: 'rest', duration: interval.rest_between, power: 0.5, power_low: 0.5, power_high: 0.5 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const interval of (structure.cooldown || [])) {
|
||||
segments.push({ type: 'cooldown', ...mapInterval(interval) })
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
function mapInterval(interval) {
|
||||
return {
|
||||
duration: interval.duration || 0,
|
||||
power_low: interval.power_low ?? 0.5,
|
||||
power_high: interval.power_high ?? 0.5,
|
||||
cadence: interval.cadence || null,
|
||||
name: interval.name || null,
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateIF(segments) {
|
||||
if (!segments?.length) return 0
|
||||
|
||||
let totalWeightedPower = 0
|
||||
let totalDuration = 0
|
||||
|
||||
for (const seg of segments) {
|
||||
const power = getSegmentPower(seg)
|
||||
const duration = seg.duration || 0
|
||||
totalWeightedPower += power * duration
|
||||
totalDuration += duration
|
||||
}
|
||||
|
||||
return totalDuration > 0 ? totalWeightedPower / totalDuration : 0
|
||||
}
|
||||
|
||||
export function calculateTSS(segments) {
|
||||
if (!segments?.length) return 0
|
||||
|
||||
const ifValue = calculateIF(segments)
|
||||
let totalDuration = 0
|
||||
for (const seg of segments) {
|
||||
totalDuration += seg.duration || 0
|
||||
}
|
||||
|
||||
return (totalDuration * ifValue * ifValue) / 3600 * 100
|
||||
}
|
||||
Reference in New Issue
Block a user