Files
geniusrun/frontend/src/components/charts/ExpectedVsActualChart.tsx
Christophe Vila 6c6783ce50 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.
2026-07-19 14:54:10 +02:00

160 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number | null | undefined>, 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<string, string> = {
WARMUP: "#f59e0b",
ACTIVE: "#ef4444",
REST: "#3b82f6",
RECOVERY: "#3b82f6",
COOLDOWN: "#a855f7",
};
const PHASE_LABELS: Record<string, string> = {
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 (
<div className="expected-actual-charts">
{phasesPresent.length > 0 && (
<div className="phase-legend">
{phasesPresent.map((phase) => (
<span key={phase} className="phase-legend-item">
<span className="phase-legend-swatch" style={{ background: PHASE_COLORS[phase] }} />
{PHASE_LABELS[phase] ?? phase}
</span>
))}
</div>
)}
<div className="mini-chart">
<span className="mini-chart-label">Pace{hasPaceTarget ? " vs target" : ""}</span>
<ResponsiveContainer width="100%" height={100}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{phaseBands.map((b, i) => (
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
))}
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
<YAxis reversed domain={paceDomain} tick={{ fontSize: 10 }} width={34} tickFormatter={(v) => formatPaceShort(Number(v))} />
<Tooltip formatter={paceTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
{hasPaceTarget && (
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
)}
<Area type="monotone" dataKey="actualPace" stroke="#3b82f6" strokeWidth={2} fill="#3b82f6" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mini-chart">
<span className="mini-chart-label">HR{hasHRTarget ? " vs target" : ""}</span>
<ResponsiveContainer width="100%" height={100}>
<AreaChart data={points} margin={{ top: 4, right: 4, bottom: 0, left: 4 }}>
{phaseBands.map((b, i) => (
<ReferenceArea key={i} x1={b.x1} x2={b.x2} fill={b.color} fillOpacity={0.12} strokeOpacity={0} />
))}
<XAxis dataKey="t" type="number" domain={[0, elapsedMin]} tick={{ fontSize: 10 }} tickFormatter={(v) => `${Math.round(Number(v))}m`} />
<YAxis domain={hrDomain} tick={{ fontSize: 10 }} width={30} tickFormatter={(v) => String(Math.round(Number(v)))} />
<Tooltip formatter={hrTooltipFormatter} labelFormatter={(t) => `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} />
{hasHRTarget && (
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
)}
<Area type="monotone" dataKey="actualHR" stroke="#ef4444" strokeWidth={2} fill="#ef4444" fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}