From 6c6783ce508ae54d870b41a9dd44fb3cdb4ac9ca Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 14:54:10 +0200 Subject: [PATCH] Redesign the Review Queue pace/HR chart: always show actuals, time-based X-axis, area style, phase-colored bands Previously the chart rendered nothing at all unless a structured Garmin workout's target was resolved, which meant most activities showed no chart. Now it always shows actual pace/HR over elapsed time (in minutes) for any activity with laps, filled as an area rather than a line, with the expected band layered on top only when available. Each lap's warm-up/effort/recovery/ cool-down phase (from Garmin's per-lap IntensityType) shades the background, with a small legend for whichever phases are present. --- frontend/src/App.css | 22 +++ .../charts/ExpectedVsActualChart.tsx | 174 ++++++++++++------ 2 files changed, 141 insertions(+), 55 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 5d56cf4..f7be6d8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -240,6 +240,28 @@ button:disabled { margin-bottom: 0.15rem; } +.phase-legend { + flex-basis: 100%; + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.phase-legend-item { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.7rem; + color: #9aa0ab; +} + +.phase-legend-swatch { + width: 8px; + height: 8px; + border-radius: 2px; + display: inline-block; +} + .kinds-table { width: 100%; border-collapse: collapse; diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index 8783044..2bfc8e0 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -1,4 +1,4 @@ -import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { Lap } from "../../types/api"; function paceSecPerKm(mps: number | null): number | null { @@ -16,6 +16,12 @@ 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] { @@ -24,72 +30,130 @@ function paddedDomain(values: Array, pad: number): [n 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. +// 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); - if (!hasPaceTarget && !hasHRTarget) return null; - const data = laps.map((l, i) => ({ - lap: i + 1, - actualPace: paceSecPerKm(l.AvgSpeedMps), + 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. - 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 paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null; + const paceHigh = l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null; - 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); + 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 (
- {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 }} /> - - - - - + {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 && ( + + )} + + + +
); }