diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go index 888a99c..d25f398 100644 --- a/backend/internal/store/activities.go +++ b/backend/internal/store/activities.go @@ -272,3 +272,47 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) ( } return n, nil } + +// ActivitiesMissingWorkout returns userID's activities that have a +// structured workout (WorkoutID set at initial upsert time, straight from +// Garmin's activity summary) but haven't had get_workout_by_id fetched yet. +// Independently queryable from ActivitiesMissingDetails: workout_id is +// known well before any detail fetch, and workout_raw_json is only ever +// set by SetActivityWorkout, so this also picks up an activity whose +// details were fetched successfully in some prior run but whose workout +// fetch failed back then -- ActivitiesMissingDetails would never surface +// that activity again (details_fetched_at/splits_fetched_at are already +// set), silently losing its target pace/HR bands forever without this. +func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) { + rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities + WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL + ORDER BY start_time_utc DESC LIMIT ?`, userID, limit) + if err != nil { + return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err) + } + defer rows.Close() + + activities := []Activity{} + for rows.Next() { + a, err := scanActivity(rows) + if err != nil { + return nil, fmt.Errorf("scan activity row: %w", err) + } + activities = append(activities, a) + } + return activities, rows.Err() +} + +// CountActivitiesMissingWorkout returns how many of userID's activities +// still need get_workout_by_id fetched, regardless of any per-call batch +// limit -- used to report overall remaining work, mirroring +// CountActivitiesMissingDetails. +func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) { + var n int + err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities + WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL`, userID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err) + } + return n, nil +} diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go index f066bf9..d494670 100644 --- a/backend/internal/store/store_test.go +++ b/backend/internal/store/store_test.go @@ -356,6 +356,86 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) { } } +func TestActivitiesMissingWorkout_IndependentOfDetailsFetchStatus(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + userID, err := db.ProvisionUser(ctx, "test-sub", "Test") + if err != nil { + t.Fatalf("ProvisionUser: %v", err) + } + + workoutID := int64(999) + withWorkoutNoDetails := Activity{ + GarminActivityID: 1, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}", + } + idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails) + if err != nil { + t.Fatalf("UpsertActivity (a): %v", err) + } + + withWorkoutAndDetails := Activity{ + GarminActivityID: 2, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}", + } + idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails) + if err != nil { + t.Fatalf("UpsertActivity (b): %v", err) + } + // Simulate an activity whose details/splits were already fetched in a + // prior run, but whose workout fetch failed back then -- this is exactly + // the case ActivitiesMissingWorkout must still surface, since it's + // queried independently of details_fetched_at/splits_fetched_at. + if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil { + t.Fatalf("SetActivityDetails (b): %v", err) + } + if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil { + t.Fatalf("SetActivitySplitsFetched (b): %v", err) + } + + noWorkout := Activity{ + GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}", + } + if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil { + t.Fatalf("UpsertActivity (c): %v", err) + } + + alreadyHasWorkout := Activity{ + GarminActivityID: 4, WorkoutID: &workoutID, + StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}", + } + idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout) + if err != nil { + t.Fatalf("UpsertActivity (d): %v", err) + } + if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil { + t.Fatalf("SetActivityWorkout (d): %v", err) + } + + n, err := db.CountActivitiesMissingWorkout(ctx, userID) + if err != nil { + t.Fatalf("CountActivitiesMissingWorkout: %v", err) + } + if n != 2 { + t.Fatalf("CountActivitiesMissingWorkout = %d, want 2 (activities a and b)", n) + } + + pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10) + if err != nil { + t.Fatalf("ActivitiesMissingWorkout: %v", err) + } + if len(pending) != 2 { + t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 2", len(pending)) + } + ids := map[int64]bool{} + for _, a := range pending { + ids[a.ID] = true + } + if !ids[idA] || !ids[idB] { + t.Errorf("ActivitiesMissingWorkout = %+v, want to include activities a and b", pending) + } +} + func contains(s string, substrs ...string) bool { lower := strings.ToLower(s) for _, substr := range substrs {