Build the Review Queue chart's actual trace from per-second samples, not lap averages

A lap can span many minutes, so plotting one flat value per lap hid real
within-lap variation -- a single-lap hill repeat rendered as a dead-flat
line despite the pace/HR swinging throughout it. The actual pace/HR trace
now comes from activity_samples (per-second telemetry) when available,
looking up each sample's enclosing lap only for the target band/phase
color. Falls back to the previous lap-stepped rendering when an activity
has no samples.
This commit is contained in:
2026-07-19 15:25:48 +02:00
parent 647757dcab
commit 7f0373f2f3
5 changed files with 117 additions and 36 deletions

View File

@@ -170,6 +170,12 @@ func TestReviewQueueResolve(t *testing.T) {
}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
hr := 150.0
if err := db.ReplaceActivitySamples(ctx, activityID, []store.Sample{
{ElapsedSeconds: 0, HeartRate: &hr},
}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
@@ -185,6 +191,10 @@ func TestReviewQueueResolve(t *testing.T) {
if len(laps) != 1 {
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
}
samples, _ := queue[0]["samples"].([]any)
if len(samples) != 1 {
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
if rec.Code != http.StatusOK {

View File

@@ -22,6 +22,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
store.KindAssignment
Activity store.Activity `json:"activity"`
Laps []store.Lap `json:"laps"`
Samples []store.Sample `json:"samples"`
}
items := make([]item, 0, len(queue))
for _, a := range queue {
@@ -38,7 +39,15 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps})
// Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps, Samples: samples})
}
// Most recent run first, by when the activity actually happened (not

View File

@@ -1,5 +1,5 @@
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
import type { Lap } from "../../types/api";
import type { Lap, Sample } from "../../types/api";
function paceSecPerKm(mps: number | null): number | null {
if (mps == null || mps <= 0) return null;
@@ -55,6 +55,14 @@ const PHASE_LABELS: Record<string, string> = {
COOLDOWN: "Cool-down",
};
interface LapWindow {
start: number; // minutes, elapsed since activity start
end: number;
targetPaceRange: [number, number] | null;
targetHRRange: [number, number] | null;
color?: string;
}
interface Point {
t: number;
actualPace: number | null;
@@ -63,27 +71,8 @@ interface Point {
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 }> = [];
function buildLapWindows(laps: Lap[]): LapWindow[] {
const windows: LapWindow[] = [];
let elapsedMin = 0;
for (const l of laps) {
const start = elapsedMin;
@@ -94,23 +83,86 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
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),
windows.push({
start,
end: elapsedMin,
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
actualHR: l.AvgHR,
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
color: PHASE_COLORS[l.IntensityType],
});
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 });
return windows;
}
// Finds the lap window covering elapsed time t (minutes), for attaching a
// lap's target band to a specific sample. Lap count is small (rarely more
// than a few dozen), so a linear scan is simplest and fast enough.
function lapWindowAt(t: number, windows: LapWindow[]): LapWindow | undefined {
return windows.find((w) => t >= w.start && t < w.end) ?? windows[windows.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];
return [formatValue(Number(value)), String(name)];
};
}
// 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.
//
// 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
// would hide real within-lap variation (a single-lap hill repeat, for
// example, would otherwise render as a dead-flat line despite the pace/HR
// swinging throughout it).
export function ExpectedVsActualChart({ laps, samples }: { laps: Lap[]; samples: Sample[] }) {
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 lapWindows = buildLapWindows(laps);
const lapTotalMin = lapWindows[lapWindows.length - 1].end;
let points: Point[];
let elapsedMin: number;
if (samples.length > 0) {
points = samples.map((s) => {
const t = s.ElapsedSeconds / 60;
const w = lapWindowAt(t, lapWindows);
return {
t,
actualPace: paceSecPerKm(s.SpeedMps),
targetPaceRange: w?.targetPaceRange ?? null,
actualHR: s.HeartRate,
targetHRRange: w?.targetHRRange ?? null,
};
});
elapsedMin = Math.max(lapTotalMin, points[points.length - 1].t);
} else {
// Fallback for activities without per-second telemetry: one flat step
// per lap, same as before samples were available.
points = lapWindows.map((w) => ({
t: w.start,
actualPace: null,
targetPaceRange: w.targetPaceRange,
actualHR: null,
targetHRRange: w.targetHRRange,
}));
points.push({ ...points[points.length - 1], t: lapTotalMin });
elapsedMin = lapTotalMin;
}
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]))];
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`);
@@ -140,7 +192,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
{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={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
<Area type="monotone" dataKey="actualPace" stroke={PACE_COLOR} strokeWidth={2} fill={PACE_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -157,7 +209,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
{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={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={{ r: 2 }} isAnimationActive={false} name="Actual" connectNulls />
<Area type="monotone" dataKey="actualHR" stroke={HR_COLOR} strokeWidth={2} fill={HR_COLOR} fillOpacity={0.3} dot={false} isAnimationActive={false} name="Actual" connectNulls />
</AreaChart>
</ResponsiveContainer>
</div>

View File

@@ -132,7 +132,7 @@ export function ReviewQueue() {
</p>
)}
<ExpectedVsActualChart laps={item.laps} />
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
<div className="review-item-actions">
{manuallyAssignableKinds.map((k) => (

View File

@@ -62,6 +62,15 @@ export interface Lap {
RawJSON: string;
}
export interface Sample {
ElapsedSeconds: number;
TimestampMs: number;
HeartRate: number | null;
SpeedMps: number | null;
DistanceM: number | null;
ElevationM: number | null;
}
export interface ActivityListItem extends Activity {
workout_kind_id: number | null;
workout_kind_name: string | null;
@@ -128,6 +137,7 @@ export interface KindAssignment {
export interface ReviewQueueItem extends KindAssignment {
activity: Activity;
laps: Lap[];
samples: Sample[];
}
export interface ProgressionPoint {