feat: add workout visualization utilities and components

This commit is contained in:
Blake Ridgway
2026-02-12 10:08:06 -06:00
parent a379f142a0
commit da7dc73328
3 changed files with 384 additions and 0 deletions

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

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