diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go
index 87de865..bc08ab5 100644
--- a/backend/internal/api/api_test.go
+++ b/backend/internal/api/api_test.go
@@ -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 {
diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go
index 6353b26..08c891c 100644
--- a/backend/internal/api/review.go
+++ b/backend/internal/api/review.go
@@ -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
diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx
index c546bb1..bd96cb7 100644
--- a/frontend/src/components/charts/ExpectedVsActualChart.tsx
+++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx
@@ -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 = {
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 && (
)}
-
+
@@ -157,7 +209,7 @@ export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) {
{hasHRTarget && (
)}
-
+
diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx
index 1d2ea1c..8b65c62 100644
--- a/frontend/src/pages/ReviewQueue.tsx
+++ b/frontend/src/pages/ReviewQueue.tsx
@@ -132,7 +132,7 @@ export function ReviewQueue() {
)}
-
+
{manuallyAssignableKinds.map((k) => (
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 37ce122..ace89f9 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -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 {