From 619277b0ee3c5785cb7fb4da8374610d8d9548d2 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 19 Jul 2026 17:17:19 +0200 Subject: [PATCH] Suppress the target band for laps that are just an unplanned continuation Confirmed against Garmin Connect's own workout view: a lap whose IntensityType merely repeats the immediately preceding lap's (e.g. a second "cool-down" lap right after the first) means the recording continued past the end of that step, not that the workout prescribed a second target for it. Only the first lap of such a run keeps its target/HR fields; the continuation shows the actual trace with no expectation overlay. Doesn't affect normal interval structure, since Effort/Recovery always alternate and never appear back-to-back. --- .../charts/ExpectedVsActualChart.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx index 94647cd..be1cd31 100644 --- a/frontend/src/components/charts/ExpectedVsActualChart.tsx +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -147,10 +147,20 @@ interface Point { function buildLapWindows(laps: Lap[]): LapWindow[] { const windows: LapWindow[] = []; let elapsedMin = 0; - for (const l of laps) { + laps.forEach((l, i) => { const start = elapsedMin; elapsedMin += l.DurationSeconds / 60; + // A lap whose type merely repeats the previous lap's (e.g. a second + // "cool-down" lap right after the first) means the recording continued + // past the end of that step -- confirmed against Garmin Connect's own + // workout view for a real activity where this happened: the run kept + // going well past the prescribed cool-down, and Garmin logged the extra + // time as a further same-type lap that was never actually part of the + // workout. Only the first lap of such a run carries a real prescribed + // target; the continuation's target/HR fields are suppressed. + const isContinuation = i > 0 && laps[i - 1].IntensityType === l.IntensityType; + // 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; @@ -160,13 +170,13 @@ function buildLapWindows(laps: Lap[]): LapWindow[] { 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, + targetPaceRange: !isContinuation && paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null, + targetHRRange: !isContinuation && l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null, avgPace: paceSecPerKm(l.AvgSpeedMps), avgHR: l.AvgHR, color: PHASE_COLORS[l.IntensityType], }); - } + }); return windows; }