diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index c39f2a0..a30b010 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -1,4 +1,4 @@ -import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import { Area, AreaChart, Line, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { Lap, Sample } from "../../types/api"; // Speeds below this read as an implausibly slow "pace" (20:00/km is well @@ -64,14 +64,13 @@ function robustDomain(actualValues: Array, targetValu return [lo - pad, hi + pad]; } -// Pace axis ticks always land on a round 30-second mark (5:30, 6:00, 6:30, -// ...) rather than whatever raw values the domain's min/max happen to be. -// A fixed, small tick count (rather than every 30s multiple in range) keeps -// labels legible in a ~100px-tall mini chart -- more ticks than that just -// collide, and Recharts' own collision-avoidance would silently drop most -// of them anyway, which is what left only one tick visible originally. -function paceTicksEvery30s([lo, hi]: [number, number], count = 4): number[] { - const step = 30; +// Picks a small number of evenly-spaced ticks across the domain, each +// snapped to a round increment (30s for pace, 5-20bpm for HR depending on +// range). A fixed, small count -- rather than every increment in range -- +// keeps labels legible and non-overlapping in a ~100px-tall mini chart. +// Recharts' own collision-avoidance would otherwise silently drop most of a +// denser tick set anyway. +function niceTicks([lo, hi]: [number, number], step: number, count = 3): number[] { const ticks = new Set(); for (let i = 0; i < count; i++) { const raw = lo + ((hi - lo) * i) / (count - 1); @@ -80,6 +79,13 @@ function paceTicksEvery30s([lo, hi]: [number, number], count = 4): number[] { return [...ticks].sort((a, b) => a - b); } +function hrTickStep(domain: [number, number]): number { + const range = domain[1] - domain[0]; + if (range > 60) return 20; + if (range > 30) return 10; + return 5; +} + // Pace and HR each keep one dedicated accent color across every chart, so // the two metrics stay visually distinct from each other and from the phase // bands below. @@ -90,9 +96,13 @@ const HR_COLOR = "#ef4444"; // red // Colors distinguish warm-up / effort / recovery / cool-down bands behind // the pace/HR trace so a run's structure reads at a glance -- deliberately // avoiding blue/red, since those are already spoken for by PACE_COLOR/HR_COLOR. +// ACTIVE and INTERVAL both mean "this is a work/effort segment" -- Garmin +// uses ACTIVE for structured-workout steps and INTERVAL for manually-lapped +// or auto-detected effort segments, but they're the same concept here. const PHASE_COLORS: Record = { WARMUP: "#f59e0b", // amber ACTIVE: "#ec4899", // pink + INTERVAL: "#ec4899", // pink REST: "#14b8a6", // teal RECOVERY: "#14b8a6", // teal COOLDOWN: "#8b5cf6", // violet @@ -100,6 +110,7 @@ const PHASE_COLORS: Record = { const PHASE_LABELS: Record = { WARMUP: "Warm-up", ACTIVE: "Effort", + INTERVAL: "Effort", REST: "Recovery", RECOVERY: "Recovery", COOLDOWN: "Cool-down", @@ -108,8 +119,11 @@ const PHASE_LABELS: Record = { interface LapWindow { start: number; // minutes, elapsed since activity start end: number; + intensityType: string; targetPaceRange: [number, number] | null; targetHRRange: [number, number] | null; + avgPace: number | null; + avgHR: number | null; color?: string; } @@ -117,8 +131,10 @@ interface Point { t: number; actualPace: number | null; targetPaceRange: [number, number] | null; + warmupCooldownAvgPace: number | null; actualHR: number | null; targetHRRange: [number, number] | null; + warmupCooldownAvgHR: number | null; } function buildLapWindows(laps: Lap[]): LapWindow[] { @@ -136,8 +152,11 @@ function buildLapWindows(laps: Lap[]): LapWindow[] { windows.push({ start, end: elapsedMin, + intensityType: l.IntensityType, targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null, targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null, + avgPace: paceSecPerKm(l.AvgSpeedMps), + avgHR: l.AvgHR, color: PHASE_COLORS[l.IntensityType], }); } @@ -160,10 +179,13 @@ function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => s // Shows 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. +// behind it (a light vertical line marks each phase change) and the +// expected band from a structured Garmin workout (see backend's +// alignWorkoutTargets) 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. Warm-up and cool-down segments, +// which rarely have a target, instead get a dashed line at their own +// average pace/HR so there's still a reference point to judge them against. // // The actual trace is built from per-second samples when available, not lap // averages: a lap can span many minutes, so plotting one flat value per lap @@ -179,6 +201,11 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: const lapWindows = buildLapWindows(laps); const lapTotalMin = lapWindows[lapWindows.length - 1].end; + function warmupCooldownAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null { + if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null; + return w[metric]; + } + let points: Point[]; let elapsedMin: number; if (samples.length > 0) { @@ -189,8 +216,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: t, actualPace: paceSecPerKm(s.SpeedMps), targetPaceRange: w?.targetPaceRange ?? null, + warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"), actualHR: s.HeartRate, targetHRRange: w?.targetHRRange ?? null, + warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"), }; }); elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t); @@ -201,8 +230,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: t: w.start, actualPace: null, targetPaceRange: w.targetPaceRange, + warmupCooldownAvgPace: warmupCooldownAvg(w, "avgPace"), actualHR: null, targetHRRange: w.targetHRRange, + warmupCooldownAvgHR: warmupCooldownAvg(w, "avgHR"), })); points.push({ ...points[points.length - 1], t: lapTotalMin }); elapsedMin = lapTotalMin; @@ -210,6 +241,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: const phaseBands = lapWindows.filter((w) => w.color).map((w) => ({ x1: w.start, x2: w.end, color: w.color! })); const phasesPresent = [...new Set(laps.map((l) => l.IntensityType).filter((t) => PHASE_COLORS[t]))]; + // A light vertical line at every point the effort type changes (warm-up + // -> effort -> recovery -> ...), regardless of whether that type has a + // background color, so the workout's structure reads at a glance. + const phaseBoundaries = lapWindows.slice(1).filter((w, i) => w.intensityType !== lapWindows[i].intensityType).map((w) => w.start); const paceDomain = robustDomain( points.map((p) => p.actualPace), @@ -221,7 +256,8 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: points.flatMap((p) => p.targetHRRange ?? [null, null]), 5, ); - const paceTicks = paceTicksEvery30s(paceDomain); + const paceTicks = niceTicks(paceDomain, 30); + const hrTicks = niceTicks(hrDomain, hrTickStep(hrDomain)); const paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`); const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`); @@ -245,6 +281,9 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: {phaseBands.map((b, i) => ( ))} + {phaseBoundaries.map((x, i) => ( + + ))} `${Math.round(Number(v))}m`} /> formatPaceShort(Number(v))} /> `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} /> @@ -252,6 +291,7 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: )} + @@ -262,13 +302,17 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: {phaseBands.map((b, i) => ( ))} + {phaseBoundaries.map((x, i) => ( + + ))} `${Math.round(Number(v))}m`} /> - String(Math.round(Number(v)))} /> + String(Math.round(Number(v)))} /> `${formatElapsed(Number(t))} elapsed`} contentStyle={{ fontSize: 12 }} /> {hasHRTarget && ( )} +