diff --git a/backend/internal/sync/mapping.go b/backend/internal/sync/mapping.go index 60d35fe..ebd454c 100644 --- a/backend/internal/sync/mapping.go +++ b/backend/internal/sync/mapping.go @@ -118,19 +118,30 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor // 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. +// per lap (nil where unavailable). +// +// Confirmed against a real activity (via Garmin Connect's own workout view) +// that recording sometimes continues one lap past the end of the workout's +// last step -- e.g. a 5-minute prescribed cool-down followed by another +// 6:46 the athlete just kept running, logged as a further lap Garmin never +// defined a target for. That shows up here as exactly one more recorded lap +// than the workout has steps, so that specific case zips the steps that do +// exist and leaves the trailing extra lap unmapped, rather than discarding +// every other lap's real target along with it. +// +// Any other mismatch (extra manual laps, auto-lap-by-distance also firing, +// etc.) can't be trusted at all, 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 + out := make([]*garmin.WorkoutStep, len(laps)) + + switch len(laps) - len(steps) { + case 0, 1: + for i := range steps { + s := steps[i] + out[i] = &s + } } return out } diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index 41bc6d6..b128c31 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -98,6 +98,35 @@ func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) { } } +func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) { + // Confirmed via Garmin Connect against a real activity: recording + // sometimes continues one lap past the workout's last step (e.g. a + // 5-minute prescribed cool-down followed by another 6:46 the athlete + // just kept running). The extra lap should have no target, but every + // other lap's real target must still come through. + laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}} + 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: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)}, + }}, + }} + + targets := alignWorkoutTargets(laps, workout) + if len(targets) != 3 { + t.Fatalf("expected 3 slots (one per lap), got %d", len(targets)) + } + if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" { + t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0]) + } + if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" { + t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1]) + } + if targets[2] != nil { + t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2]) + } +} + func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) { laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}} workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ @@ -309,7 +338,7 @@ func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { } } -func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) { +func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) { db := openTestDB(t) ctx := context.Background() @@ -329,7 +358,10 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) { }, Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}}, Workouts: map[int64]garmin.Workout{ - // Only one step for two recorded laps -- counts don't match. + // One step for two recorded laps -- the second lap is an + // unplanned continuation past the workout's end (confirmed via + // Garmin Connect against a real activity), not a genuine + // mismatch, so the first lap should still get a real target. workoutID: {Segments: []garmin.WorkoutSegment{ {Steps: []garmin.WorkoutStep{ {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)}, @@ -351,10 +383,11 @@ func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) { 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) - } + if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 { + t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps) + } + if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil { + t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps) } }