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