From 1fb00f5bc67fac30edbe4cb04f24f948e24b4343 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 16:57:47 +0200 Subject: [PATCH] Merge consecutive same-phase laps before computing the average reference line A cool-down (or warm-up) split across two or more laps was drawing each lap's own average as a separate flat segment, so a workout with a 2-lap cool-down showed two different "cool-down averages" back to back instead of one. Consecutive laps sharing an IntensityType are now merged into a single phase segment with one duration-weighted average across all of them. --- .../charts/ExpectedVsActualChart.tsx | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index 7900b39..94647cd 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -177,6 +177,45 @@ function lapWindowAt(t: number, windows: LapWindow[]): LapWindow | undefined { return windows.find((w) => t >= w.start && t < w.end) ?? windows[windows.length - 1]; } +interface PhaseSegment { + start: number; + end: number; + intensityType: string; + avgPace: number | null; + avgHR: number | null; +} + +// Merges consecutive laps of the same IntensityType into one segment (e.g. a +// two-lap cool-down becomes one), with a single duration-weighted average +// across all of its laps -- otherwise each lap would draw its own average +// line, showing e.g. two different "cool-down averages" back to back where +// there's really just one cool-down. +function buildPhaseSegments(windows: LapWindow[]): PhaseSegment[] { + const segments: PhaseSegment[] = []; + let group: LapWindow[] = []; + function flush() { + if (group.length === 0) return; + segments.push({ + start: group[0].start, + end: group[group.length - 1].end, + intensityType: group[0].intensityType, + avgPace: weightedMean(group.map((w) => [w.avgPace, w.end - w.start])), + avgHR: weightedMean(group.map((w) => [w.avgHR, w.end - w.start])), + }); + group = []; + } + for (const w of windows) { + if (group.length > 0 && group[group.length - 1].intensityType !== w.intensityType) flush(); + group.push(w); + } + flush(); + return segments; +} + +function phaseSegmentAt(t: number, segments: PhaseSegment[]): PhaseSegment | undefined { + return segments.find((s) => t >= s.start && t < s.end) ?? segments[segments.length - 1]; +} + 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]; @@ -223,10 +262,12 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: const lapWindows = buildLapWindows(laps); const lapTotalMin = lapWindows[lapWindows.length - 1].end; + const phaseSegments = buildPhaseSegments(lapWindows); - function phaseAvg(w: LapWindow | undefined, metric: "avgPace" | "avgHR"): number | null { - if (!w || (w.intensityType !== "WARMUP" && w.intensityType !== "COOLDOWN")) return null; - return w[metric]; + function phaseAvg(t: number, metric: "avgPace" | "avgHR"): number | null { + const seg = phaseSegmentAt(t, phaseSegments); + if (!seg || (seg.intensityType !== "WARMUP" && seg.intensityType !== "COOLDOWN")) return null; + return seg[metric]; } let points: Point[]; @@ -239,10 +280,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: t, actualPace: paceSecPerKm(s.SpeedMps), targetPaceRange: w?.targetPaceRange ?? null, - phaseAvgPace: phaseAvg(w, "avgPace"), + phaseAvgPace: phaseAvg(t, "avgPace"), actualHR: s.HeartRate, targetHRRange: w?.targetHRRange ?? null, - phaseAvgHR: phaseAvg(w, "avgHR"), + phaseAvgHR: phaseAvg(t, "avgHR"), }; }); elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t); @@ -253,10 +294,10 @@ export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: t: w.start, actualPace: null, targetPaceRange: w.targetPaceRange, - phaseAvgPace: phaseAvg(w, "avgPace"), + phaseAvgPace: phaseAvg(w.start, "avgPace"), actualHR: null, targetHRRange: w.targetHRRange, - phaseAvgHR: phaseAvg(w, "avgHR"), + phaseAvgHR: phaseAvg(w.start, "avgHR"), })); points.push({ ...points[points.length - 1], t: lapTotalMin }); elapsedMin = lapTotalMin;