import { Line, LineChart, 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`; } // 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]; } // Shows each lap's actual pace/HR against the expected band from the // activity's structured Garmin workout (see backend's alignWorkoutTargets). // Renders nothing if no lap has a resolved target -- most activities aren't // from a structured workout, and an empty chart isn't useful. export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null); const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null); if (!hasPaceTarget && !hasHRTarget) return null; const data = laps.map((l, i) => ({ lap: i + 1, actualPace: paceSecPerKm(l.AvgSpeedMps), // Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed // bound is the *lower* (faster) pace bound. targetPaceLow: l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null, targetPaceHigh: l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null, actualHR: l.AvgHR, targetHRLow: l.TargetHRLowBpm, targetHRHigh: l.TargetHRHighBpm, })); const paceDomain = paddedDomain(data.flatMap((d) => [d.actualPace, d.targetPaceLow, d.targetPaceHigh]), 10); const hrDomain = paddedDomain(data.flatMap((d) => [d.actualHR, d.targetHRLow, d.targetHRHigh]), 5); return (
{hasPaceTarget && (
Pace vs target formatPaceShort(Number(v))} /> formatPace(Number(v))} labelFormatter={(l) => `Lap ${l}`} contentStyle={{ fontSize: 12 }} />
)} {hasHRTarget && (
HR vs target String(Math.round(Number(v)))} /> `${Math.round(Number(v))} bpm`} labelFormatter={(l) => `Lap ${l}`} contentStyle={{ fontSize: 12 }} />
)}
); }