From da7dc73328113358ca9c999cf0a6ce914cd5fc73 Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Thu, 12 Feb 2026 10:08:06 -0600 Subject: [PATCH] feat: add workout visualization utilities and components --- src/components/workout/MiniSegmentChart.vue | 46 ++++ src/components/workout/ZoneDistribution.vue | 48 ++++ src/utils/workoutHelpers.js | 290 ++++++++++++++++++++ 3 files changed, 384 insertions(+) create mode 100644 src/components/workout/MiniSegmentChart.vue create mode 100644 src/components/workout/ZoneDistribution.vue create mode 100644 src/utils/workoutHelpers.js diff --git a/src/components/workout/MiniSegmentChart.vue b/src/components/workout/MiniSegmentChart.vue new file mode 100644 index 0000000..71730c5 --- /dev/null +++ b/src/components/workout/MiniSegmentChart.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/components/workout/ZoneDistribution.vue b/src/components/workout/ZoneDistribution.vue new file mode 100644 index 0000000..b82f16a --- /dev/null +++ b/src/components/workout/ZoneDistribution.vue @@ -0,0 +1,48 @@ + + + diff --git a/src/utils/workoutHelpers.js b/src/utils/workoutHelpers.js new file mode 100644 index 0000000..f4c5c30 --- /dev/null +++ b/src/utils/workoutHelpers.js @@ -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 +}