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