Show expected vs actual pace/HR per lap in the Review Queue
Activities recorded from a structured Garmin workout carry a workoutId; when its steps line up 1:1 with the recorded laps, the per-step target pace/HR zone is resolved (via a Karvonen lookup for named zones) and stored on each lap. The Review Queue plots it against the actual per-lap pace/HR so the user can eyeball whether a run matches its plan while sorting it.
This commit is contained in:
@@ -25,6 +25,7 @@ func toActivityRow(a garmin.Activity) store.Activity {
|
||||
ActivityName: a.ActivityName,
|
||||
ActivityType: a.ActivityType.TypeKey,
|
||||
EventTypeKey: a.EventType.TypeKey,
|
||||
WorkoutID: a.WorkoutID,
|
||||
StartTimeUTC: a.StartTimeGMT,
|
||||
BeginTimestampMs: a.BeginTimestamp,
|
||||
DurationSeconds: a.Duration,
|
||||
@@ -64,10 +65,10 @@ func nonZero(v float64) *float64 {
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for _, l := range laps {
|
||||
for i, l := range laps {
|
||||
elapsedEnd := elapsedStart + l.ElapsedDuration
|
||||
|
||||
var driftPtr, recoveryPtr *float64
|
||||
@@ -83,6 +84,12 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
}
|
||||
}
|
||||
|
||||
var paceLow, paceHigh, hrLow, hrHigh *float64
|
||||
if i < len(targets) && targets[i] != nil {
|
||||
paceLow, paceHigh = targetPaceRange(*targets[i])
|
||||
hrLow, hrHigh = targetHRRange(*targets[i], profile)
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(l)
|
||||
rows = append(rows, store.Lap{
|
||||
LapIndex: l.LapIndex,
|
||||
@@ -98,6 +105,10 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
IntensityType: l.IntensityType,
|
||||
HRDriftBpmPerMin: driftPtr,
|
||||
HRRecoveryBpmPerMin: recoveryPtr,
|
||||
TargetPaceLowMps: paceLow,
|
||||
TargetPaceHighMps: paceHigh,
|
||||
TargetHRLowBpm: hrLow,
|
||||
TargetHRHighBpm: hrHigh,
|
||||
RawJSON: string(raw),
|
||||
})
|
||||
elapsedStart = elapsedEnd
|
||||
@@ -105,6 +116,89 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
return rows
|
||||
}
|
||||
|
||||
// alignWorkoutTargets zips an activity's recorded laps against its
|
||||
// structured workout's flattened steps, returning one *garmin.WorkoutStep
|
||||
// per lap (nil where unavailable). Zipping only happens when the counts
|
||||
// match exactly -- a mismatch (extra manual laps, auto-lap-by-distance also
|
||||
// firing, etc.) means we can't trust the alignment, so every entry comes
|
||||
// back nil rather than risk showing a target against the wrong lap.
|
||||
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
|
||||
steps := workout.FlattenSteps()
|
||||
if len(steps) != len(laps) {
|
||||
return make([]*garmin.WorkoutStep, len(laps))
|
||||
}
|
||||
out := make([]*garmin.WorkoutStep, len(steps))
|
||||
for i := range steps {
|
||||
s := steps[i]
|
||||
out[i] = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// targetPaceRange returns the (low, high) m/s bounds of a workout step's
|
||||
// pace-zone target, or (nil, nil) if it doesn't target pace.
|
||||
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
|
||||
return nil, nil
|
||||
}
|
||||
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
return &lo, &hi
|
||||
}
|
||||
|
||||
// targetHRRange returns the (low, high) bpm bounds of a workout step's
|
||||
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
|
||||
// Steps that target a named zone (ZoneNumber) rather than a custom bpm
|
||||
// range are resolved via the user's Karvonen profile.
|
||||
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) {
|
||||
if step.TargetType.TypeKey != "heart.rate.zone" {
|
||||
return nil, nil
|
||||
}
|
||||
if step.TargetValueOne != nil && step.TargetValueTwo != nil {
|
||||
lo, hi := *step.TargetValueOne, *step.TargetValueTwo
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
return &lo, &hi
|
||||
}
|
||||
if step.ZoneNumber != nil {
|
||||
if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok {
|
||||
return &lo, &hi
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the
|
||||
// user's max/resting heart rate and the zone's %HRR range, both from
|
||||
// Profile. ok is false when max/resting heart rate aren't configured, or
|
||||
// the zone number is out of range.
|
||||
func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) {
|
||||
if p.MaxHeartRate == nil || p.RestingHeartRate == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate
|
||||
|
||||
var minPct, maxPct float64
|
||||
switch zone {
|
||||
case 1:
|
||||
minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct
|
||||
case 2:
|
||||
minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct
|
||||
case 3:
|
||||
minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct
|
||||
case 4:
|
||||
minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct
|
||||
case 5:
|
||||
minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct
|
||||
default:
|
||||
return 0, 0, false
|
||||
}
|
||||
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -233,6 +234,10 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, err := s.db.GetProfile(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load profile: %w", err)
|
||||
}
|
||||
|
||||
s.setProgress(0, len(pending))
|
||||
defer s.setProgress(0, 0)
|
||||
@@ -245,7 +250,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
case <-time.After(s.cfg.InterCallDelay):
|
||||
}
|
||||
}
|
||||
if err := s.fillActivityDetails(ctx, a); err != nil {
|
||||
if err := s.fillActivityDetails(ctx, a, profile); err != nil {
|
||||
return fmt.Errorf("fill details for activity %d: %w", a.GarminActivityID, err)
|
||||
}
|
||||
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
|
||||
@@ -256,7 +261,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) error {
|
||||
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
|
||||
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get_activity_splits: %w", err)
|
||||
@@ -266,11 +271,21 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) err
|
||||
return fmt.Errorf("get_activity_details: %w", err)
|
||||
}
|
||||
|
||||
targets := make([]*garmin.WorkoutStep, len(splits.Laps))
|
||||
if a.WorkoutID != nil {
|
||||
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
|
||||
if err != nil {
|
||||
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
|
||||
} else {
|
||||
targets = alignWorkoutTargets(splits.Laps, workout)
|
||||
}
|
||||
}
|
||||
|
||||
samples := garmin.ExtractSamples(details)
|
||||
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples)); err != nil {
|
||||
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
|
||||
return err
|
||||
}
|
||||
detailsRaw, _ := json.Marshal(details)
|
||||
|
||||
@@ -62,6 +62,85 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
|
||||
}
|
||||
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
|
||||
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
|
||||
}
|
||||
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
|
||||
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
|
||||
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
|
||||
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO"},
|
||||
}},
|
||||
}}
|
||||
|
||||
targets := alignWorkoutTargets(laps, workout)
|
||||
if len(targets) != 3 {
|
||||
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
|
||||
}
|
||||
for i, target := range targets {
|
||||
if target != nil {
|
||||
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
|
||||
lo, hi := targetPaceRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
|
||||
})
|
||||
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
|
||||
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
|
||||
lo, hi := targetHRRange(garmin.WorkoutStep{
|
||||
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
|
||||
}, store.Profile{})
|
||||
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
|
||||
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
|
||||
}
|
||||
|
||||
zone := 3
|
||||
profile := store.Profile{
|
||||
MaxHeartRate: f(190), RestingHeartRate: f(50),
|
||||
HRZone3MinPct: 70, HRZone3MaxPct: 80,
|
||||
}
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
|
||||
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
|
||||
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
|
||||
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
|
||||
}
|
||||
|
||||
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
|
||||
if lo != nil || hi != nil {
|
||||
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
@@ -164,6 +243,106 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const garminActivityID = 55
|
||||
const workoutID = 999
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
if err != nil || len(activities) != 1 {
|
||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
||||
}
|
||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
|
||||
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
|
||||
}
|
||||
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
|
||||
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const garminActivityID = 56
|
||||
const workoutID = 1000
|
||||
workoutIDPtr := int64(workoutID)
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
|
||||
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
|
||||
Workouts: map[int64]garmin.Workout{
|
||||
// Only one step for two recorded laps -- counts don't match.
|
||||
workoutID: {Segments: []garmin.WorkoutSegment{
|
||||
{Steps: []garmin.WorkoutStep{
|
||||
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
|
||||
}},
|
||||
}},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
activities, _ := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
laps, err := db.LapsForActivity(ctx, activities[0].ID)
|
||||
if err != nil || len(laps) != 2 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
for i, l := range laps {
|
||||
if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil {
|
||||
t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.TargetPaceHighMps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
||||
activity := store.Activity{
|
||||
DurationSeconds: 1800,
|
||||
|
||||
Reference in New Issue
Block a user