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 {