import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { Lap } from "../../types/api"; function paceSecPerKm(mps: number | null): number | null { if (mps == null || mps <= 0) return null; return 1000 / mps; } function formatPaceShort(secPerKm: number): string { const m = Math.floor(secPerKm / 60); const s = Math.round(secPerKm % 60); return `${m}:${s.toString().padStart(2, "0")}`; } function formatPace(secPerKm: number): string { return `${formatPaceShort(secPerKm)}/km`; } function formatElapsed(minutes: number): string { const m = Math.floor(minutes); const s = Math.round((minutes - m) * 60); return `${m}:${s.toString().padStart(2, "0")}`; } // Recharts' "dataMin - N" domain expressions don't combine reliably with // reversed axes, so the padded numeric domain is computed directly instead. function paddedDomain(values: Array, pad: number): [number, number] { const nums = values.filter((v): v is number => v != null); if (nums.length === 0) return [0, 1]; return [Math.min(...nums) - pad, Math.max(...nums) + pad]; } // Phase a lap represents, by Garmin's own per-lap IntensityType tagging. // Colors distinguish warm-up / effort / recovery / cool-down bands behind // the pace/HR trace so a run's structure reads at a glance. const PHASE_COLORS: Record = { WARMUP: "#f59e0b", ACTIVE: "#ef4444", REST: "#3b82f6", RECOVERY: "#3b82f6", COOLDOWN: "#a855f7", }; const PHASE_LABELS: Record = { WARMUP: "Warm-up", ACTIVE: "Effort", REST: "Recovery", RECOVERY: "Recovery", COOLDOWN: "Cool-down", }; interface Point { t: number; actualPace: number | null; targetPaceRange: [number, number] | null; actualHR: number | null; targetHRRange: [number, number] | null; } function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) { return (value: unknown, name: unknown): [string, string] => { if (Array.isArray(value)) return [`${formatValue(value[0])}–${formatValue(value[1])}`, rangeLabel]; return [formatValue(Number(value)), String(name)]; }; } // Shows each lap's actual pace/HR over elapsed time for every activity that // has laps, with the workout's warm-up/effort/recovery/cool-down structure // shaded behind it. The expected band from a structured Garmin workout (see // backend's alignWorkoutTargets) is layered on top when available -- most // activities aren't from a structured workout with target zones, so absence // of that band is the common case, not an error. export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { if (laps.length === 0) return null; const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null); const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null); const points: Point[] = []; const phaseBands: Array<{ x1: number; x2: number; color: string }> = []; let elapsedMin = 0; for (const l of laps) { const start = elapsedMin; elapsedMin += l.DurationSeconds / 60; // Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed // bound is the *lower* (faster) pace bound. const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null; const paceHigh = l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null; points.push({ t: start, actualPace: paceSecPerKm(l.AvgSpeedMps), targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null, actualHR: l.AvgHR, targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null, }); const color = PHASE_COLORS[l.IntensityType]; if (color) phaseBands.push({ x1: start, x2: elapsedMin, color }); } // Closing point so the last lap's step extends visually to the end. points.push({ ...points[points.length - 1], t: elapsedMin }); const paceDomain = paddedDomain(points.flatMap((p) => [p.actualPace, ...(p.targetPaceRange ?? [])]), 10); const hrDomain = paddedDomain(points.flatMap((p) => [p.actualHR, ...(p.targetHRRange ?? [])]), 5); const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))]; const paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`); const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`); return (
{phasesPresent.length > 0 && (
{phasesPresent.map((phase) => ( {PHASE_LABELS[phase] ?? phase} ))}
)}
Pace{hasPaceTarget ? " vs target" : ""} {phaseBands.map((b, i) => ( ))} `${Math.round(Number(v))}m`} /> formatPaceShort(Number(v))} /> `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} /> {hasPaceTarget && ( )}
HR{hasHRTarget ? " vs target" : ""} {phaseBands.map((b, i) => ( ))} `${Math.round(Number(v))}m`} /> String(Math.round(Number(v)))} /> `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} /> {hasHRTarget && ( )}
); }