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:
@@ -170,6 +170,12 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("ReplaceLaps: %v", err)
|
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()
|
router := s.Router()
|
||||||
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||||
@@ -185,6 +191,10 @@ func TestReviewQueueResolve(t *testing.T) {
|
|||||||
if len(laps) != 1 {
|
if len(laps) != 1 {
|
||||||
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String())
|
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})
|
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
|||||||
store.KindAssignment
|
store.KindAssignment
|
||||||
Activity store.Activity `json:"activity"`
|
Activity store.Activity `json:"activity"`
|
||||||
Laps []store.Lap `json:"laps"`
|
Laps []store.Lap `json:"laps"`
|
||||||
|
Samples []store.Sample `json:"samples"`
|
||||||
}
|
}
|
||||||
items := make([]item, 0, len(queue))
|
items := make([]item, 0, len(queue))
|
||||||
for _, a := range 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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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
|
// Most recent run first, by when the activity actually happened (not
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Area, AreaChart, ReferenceArea, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
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 {
|
function paceSecPerKm(mps: number | null): number | null {
|
||||||
if (mps == null || mps <= 0) return null;
|
if (mps == null || mps <= 0) return null;
|
||||||
@@ -55,6 +55,14 @@ const PHASE_LABELS: Record<string, string> = {
|
|||||||
COOLDOWN: "Cool-down",
|
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 {
|
interface Point {
|
||||||
t: number;
|
t: number;
|
||||||
actualPace: number | null;
|
actualPace: number | null;
|
||||||
@@ -63,27 +71,8 @@ interface Point {
|
|||||||
targetHRRange: [number, number] | null;
|
targetHRRange: [number, number] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rangeTooltipFormatter(rangeLabel: string, formatValue: (v: number) => string) {
|
function buildLapWindows(laps: Lap[]): LapWindow[] {
|
||||||
return (value: unknown, name: unknown): [string, string] => {
|
const windows: LapWindow[] = [];
|
||||||
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 }> = [];
|
|
||||||
let elapsedMin = 0;
|
let elapsedMin = 0;
|
||||||
for (const l of laps) {
|
for (const l of laps) {
|
||||||
const start = elapsedMin;
|
const start = elapsedMin;
|
||||||
@@ -94,23 +83,86 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
|||||||
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
|
const paceLow = l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null;
|
||||||
const paceHigh = l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null;
|
const paceHigh = l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null;
|
||||||
|
|
||||||
points.push({
|
windows.push({
|
||||||
t: start,
|
start,
|
||||||
actualPace: paceSecPerKm(l.AvgSpeedMps),
|
end: elapsedMin,
|
||||||
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
targetPaceRange: paceLow != null && paceHigh != null ? [paceLow, paceHigh] : null,
|
||||||
actualHR: l.AvgHR,
|
|
||||||
targetHRRange: l.TargetHRLowBpm != null && l.TargetHRHighBpm != null ? [l.TargetHRLowBpm, l.TargetHRHighBpm] : null,
|
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.
|
return windows;
|
||||||
points.push({ ...points[points.length - 1], t: elapsedMin });
|
}
|
||||||
|
|
||||||
|
// 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 paceDomain = paddedDomain(points.flatMap((p) => [p.actualPace, ...(p.targetPaceRange ?? [])]), 10);
|
||||||
const hrDomain = paddedDomain(points.flatMap((p) => [p.actualHR, ...(p.targetHRRange ?? [])]), 5);
|
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 paceTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${formatPace(v)}`);
|
||||||
const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`);
|
const hrTooltipFormatter = rangeTooltipFormatter("Target range", (v) => `${Math.round(v)} bpm`);
|
||||||
@@ -140,7 +192,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
|||||||
{hasPaceTarget && (
|
{hasPaceTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetPaceRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<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>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,7 +209,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
|
|||||||
{hasHRTarget && (
|
{hasHRTarget && (
|
||||||
<Area type="stepAfter" dataKey="targetHRRange" stroke="none" fill="#9aa0ab" fillOpacity={0.25} isAnimationActive={false} name="Target range" connectNulls />
|
<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>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function ReviewQueue() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExpectedVsActualChart laps={item.laps} />
|
<ExpectedVsActualChart laps={item.laps} samples={item.samples} />
|
||||||
|
|
||||||
<div className="review-item-actions">
|
<div className="review-item-actions">
|
||||||
{manuallyAssignableKinds.map((k) => (
|
{manuallyAssignableKinds.map((k) => (
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ export interface Lap {
|
|||||||
RawJSON: string;
|
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 {
|
export interface ActivityListItem extends Activity {
|
||||||
workout_kind_id: number | null;
|
workout_kind_id: number | null;
|
||||||
workout_kind_name: string | null;
|
workout_kind_name: string | null;
|
||||||
@@ -128,6 +137,7 @@ export interface KindAssignment {
|
|||||||
export interface ReviewQueueItem extends KindAssignment {
|
export interface ReviewQueueItem extends KindAssignment {
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
laps: Lap[];
|
laps: Lap[];
|
||||||
|
samples: Sample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProgressionPoint {
|
export interface ProgressionPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user