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:
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user