feat(sync): split FillPendingDetails into activities/workouts phases

Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.

alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.

Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
This commit is contained in:
2026-07-27 06:55:23 +02:00
parent 32fdfc1c72
commit 35d9933d1b
4 changed files with 293 additions and 52 deletions

View File

@@ -94,9 +94,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
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).
// alignWorkoutTargets zips an activity's lap count against its structured
// workout's flattened steps, returning one *garmin.WorkoutStep 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
@@ -110,11 +110,11 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
// 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 {
func alignWorkoutTargets(lapCount int, workout garmin.Workout) []*garmin.WorkoutStep {
steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, len(laps))
out := make([]*garmin.WorkoutStep, lapCount)
switch len(laps) - len(steps) {
switch lapCount - len(steps) {
case 0, 1:
for i := range steps {
s := steps[i]