fix(store): gate ActivitiesMissingWorkout on details already fetched

fillPendingDetails and fillPendingWorkouts are each independently
LIMIT-bounded over different candidate sets, so on a large first
backfill a structured-workout activity could fall inside the workouts
pass's window while still outside the details pass's window.
fillActivityWorkout would then align workout targets against zero laps
(details/laps never written yet) and unconditionally mark
workout_raw_json non-NULL, permanently losing that activity's target
pace/HR bands once its real laps arrived later -- silently, with no
error.

Add details_fetched_at IS NOT NULL to ActivitiesMissingWorkout's and
CountActivitiesMissingWorkout's WHERE clause: an activity is only
eligible for a workout fetch once its laps actually exist to align
against. This still covers the intended retry case (an activity whose
workout fetch previously failed always has details_fetched_at already
set) while excluding never-yet-processed activities.

Rename/rewrite the store test to assert the corrected exclusion
(count=1, not 2) as an explicit regression test, and fix the
TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending API fixture,
which exercised the same buggy shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 09:01:02 +02:00
parent 101ef639ab
commit 1046e9f7a0
3 changed files with 66 additions and 26 deletions

View File

@@ -669,12 +669,20 @@ func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
ctx := newCtx()
workoutID := int64(555)
if _, err := db.UpsertActivity(ctx, userID, store.Activity{
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
}); err != nil {
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// ActivitiesMissingWorkout only surfaces activities whose details have
// already been fetched (see store.ActivitiesMissingWorkout) -- an
// activity is only eligible for a workout fetch once fillActivityDetails
// has given it laps to align target pace/HR bands against.
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
t.Fatalf("SetActivityDetails: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/sync/status", nil)
if rec.Code != http.StatusOK {