2026-07-17 18:33:06 +02:00
package sync
import (
"context"
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.
2026-07-27 06:55:23 +02:00
"fmt"
2026-07-17 18:33:06 +02:00
"path/filepath"
"testing"
"time"
2026-07-24 21:08:07 +02:00
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
2026-07-17 18:33:06 +02:00
)
func f ( v float64 ) * float64 { return & v }
func openTestDB ( t * testing . T ) * store . DB {
t . Helper ( )
2026-07-24 21:08:07 +02:00
db , err := store . Open ( filepath . Join ( t . TempDir ( ) , "geniusrun_test.db" ) )
2026-07-17 18:33:06 +02:00
if err != nil {
t . Fatalf ( "store.Open: %v" , err )
}
t . Cleanup ( func ( ) { db . Close ( ) } )
return db
}
func fixedNow ( t time . Time ) func ( ) time . Time {
return func ( ) time . Time { return t }
}
2026-07-25 17:45:41 +02:00
func provisionTestUser ( t * testing . T , db * store . DB ) int64 {
t . Helper ( )
userID , err := db . ProvisionUser ( context . Background ( ) , "test-sub" , "Test" )
if err != nil {
t . Fatalf ( "ProvisionUser: %v" , err )
}
return userID
}
2026-07-19 12:57:18 +02:00
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
2026-07-25 17:45:41 +02:00
func setBackfillHorizon ( t * testing . T , db * store . DB , userID int64 , days int ) {
2026-07-19 12:57:18 +02:00
t . Helper ( )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
profile , err := db . GetProfile ( ctx , userID )
2026-07-19 12:57:18 +02:00
if err != nil {
t . Fatalf ( "GetProfile: %v" , err )
}
profile . BackfillHorizonDays = days
2026-07-25 17:45:41 +02:00
if err := db . UpdateProfile ( ctx , userID , profile ) ; err != nil {
2026-07-19 12:57:18 +02:00
t . Fatalf ( "UpdateProfile: %v" , err )
}
}
2026-07-27 06:44:39 +02:00
func TestBackfillCore_StoresActivities ( t * testing . T ) {
2026-07-17 18:33:06 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-17 18:33:06 +02:00
m := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityName : "Morning Run" , ActivityType : garmin . ActivityType { TypeKey : "running" } ,
StartTimeGMT : "2026-07-01 06:00:00" , Distance : 5000 , Duration : 1500 , AverageSpeed : 3.33 , AverageHR : 145 } ,
} }
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-17 18:33:06 +02:00
2026-07-27 06:44:39 +02:00
total , err := svc . backfillCore ( ctx )
if err != nil {
t . Fatalf ( "backfillCore: %v" , err )
}
if total != 1 {
t . Errorf ( "backfillCore returned total = %d, want 1" , total )
2026-07-17 18:33:06 +02:00
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-17 18:33:06 +02:00
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
if len ( activities ) != 1 {
t . Fatalf ( "expected 1 stored activity, got %d" , len ( activities ) )
}
if activities [ 0 ] . GarminActivityID != 1 {
t . Errorf ( "GarminActivityID = %d, want 1" , activities [ 0 ] . GarminActivityID )
}
}
2026-07-19 11:55:13 +02:00
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" } } ,
} } ,
} }
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.
2026-07-27 06:55:23 +02:00
targets := alignWorkoutTargets ( len ( laps ) , workout )
2026-07-19 11:55:13 +02:00
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 ] )
}
}
2026-07-19 18:01:06 +02:00
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 ) } ,
} } ,
} }
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.
2026-07-27 06:55:23 +02:00
targets := alignWorkoutTargets ( len ( laps ) , workout )
2026-07-19 18:01:06 +02:00
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 ] )
}
}
2026-07-19 11:55:13 +02:00
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" } ,
} } ,
} }
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.
2026-07-27 06:55:23 +02:00
targets := alignWorkoutTargets ( len ( laps ) , workout )
2026-07-19 11:55:13 +02:00
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 )
}
}
2026-07-27 06:44:39 +02:00
func TestBackfillCore_SkipsNonRunningActivities ( t * testing . T ) {
2026-07-19 10:52:09 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-19 10:52:09 +02:00
m := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } ,
StartTimeGMT : "2026-07-01 06:00:00" , Distance : 5000 , Duration : 1500 } ,
{ ActivityID : 2 , ActivityType : garmin . ActivityType { TypeKey : "trail_running" } ,
StartTimeGMT : "2026-07-02 06:00:00" , Distance : 8000 , Duration : 2400 } ,
{ ActivityID : 3 , ActivityType : garmin . ActivityType { TypeKey : "paddelball" } ,
StartTimeGMT : "2026-07-03 06:00:00" , Distance : 0 , Duration : 1800 } ,
{ ActivityID : 4 , ActivityType : garmin . ActivityType { TypeKey : "indoor_cycling" } ,
StartTimeGMT : "2026-07-04 06:00:00" , Distance : 0 , Duration : 1800 } ,
} }
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-19 10:52:09 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
2026-07-19 10:52:09 +02:00
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-19 10:52:09 +02:00
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
if len ( activities ) != 2 {
t . Fatalf ( "expected 2 stored (running-only) activities, got %d: %+v" , len ( activities ) , activities )
}
for _ , a := range activities {
if a . GarminActivityID != 1 && a . GarminActivityID != 2 {
t . Errorf ( "unexpected non-running activity stored: %+v" , a )
}
}
}
2026-07-17 18:33:06 +02:00
func TestFillPendingDetailsAndClassify_EndToEnd ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-17 18:33:06 +02:00
const garminActivityID = 42
m := & mock . Client {
Activities : [ ] garmin . Activity {
{ ActivityID : garminActivityID , ActivityType : garmin . ActivityType { TypeKey : "running" } ,
StartTimeGMT : "2026-07-01 06:00:00" , Distance : 5000 , Duration : 1500 , AverageSpeed : 3.33 , AverageHR : 145 } ,
} ,
Splits : map [ int64 ] garmin . ActivitySplits {
garminActivityID : { ActivityID : garminActivityID , Laps : [ ] garmin . Lap {
{ LapIndex : 1 , Duration : 900 , ElapsedDuration : 900 , Distance : 3000 , AverageHR : 150 , AverageSpeed : 3.33 , IntensityType : "ACTIVE" } ,
} } ,
} ,
Details : map [ int64 ] garmin . ActivityDetails {
garminActivityID : {
ActivityID : garminActivityID ,
MetricDescriptors : [ ] garmin . MetricDescriptor {
{ Key : "directHeartRate" , MetricsIndex : 0 } ,
{ Key : "sumElapsedDuration" , MetricsIndex : 1 } ,
} ,
} ,
} ,
}
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { MinConfidence : 0.5 } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-17 18:33:06 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
// A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := ` { "match":"all","conditions":[ { "metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]} `
2026-07-25 17:45:41 +02:00
if _ , err := db . CreateWorkoutKind ( ctx , userID , store . WorkoutKind { Name : "Test Classification Tempo" , RuleJSON : ruleJSON , IsActive : true } ) ; err != nil {
2026-07-17 18:33:06 +02:00
t . Fatalf ( "CreateWorkoutKind: %v" , err )
}
if err := svc . FillPendingDetails ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FillPendingDetails: %v" , err )
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-17 18:33:06 +02:00
if err != nil || len ( activities ) != 1 {
t . Fatalf ( "ListActivities: %v, %+v" , err , activities )
}
activityID := activities [ 0 ] . ID
if activities [ 0 ] . DetailsFetchedAt == nil {
t . Error ( "expected DetailsFetchedAt to be set after FillPendingDetails" )
}
if activities [ 0 ] . SplitsFetchedAt == nil {
t . Error ( "expected SplitsFetchedAt to be set after FillPendingDetails" )
}
2026-07-25 17:45:41 +02:00
laps , err := db . LapsForActivity ( ctx , userID , activityID )
2026-07-17 18:33:06 +02:00
if err != nil || len ( laps ) != 1 {
t . Fatalf ( "LapsForActivity: %v, %+v" , err , laps )
}
2026-07-25 17:45:41 +02:00
assignment , ok , err := db . CurrentAssignment ( ctx , userID , activityID )
2026-07-17 18:33:06 +02:00
if err != nil || ! ok {
t . Fatalf ( "CurrentAssignment: ok=%v err=%v" , ok , err )
}
if assignment . Status != classify . StatusAssigned {
t . Fatalf ( "assignment.Status = %q, want %q (candidates: %s)" , assignment . Status , classify . StatusAssigned , assignment . CandidateKindsJSON )
}
}
2026-07-19 11:55:13 +02:00
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-19 11:55:13 +02:00
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 ) } ,
} } ,
} } ,
} ,
}
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-19 11:55:13 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
2026-07-19 11:55:13 +02:00
}
if err := svc . FillPendingDetails ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FillPendingDetails: %v" , err )
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-19 11:55:13 +02:00
if err != nil || len ( activities ) != 1 {
t . Fatalf ( "ListActivities: %v, %+v" , err , activities )
}
2026-07-25 17:45:41 +02:00
laps , err := db . LapsForActivity ( ctx , userID , activities [ 0 ] . ID )
2026-07-19 11:55:13 +02:00
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 )
}
}
2026-07-19 18:01:06 +02:00
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets ( t * testing . T ) {
2026-07-19 11:55:13 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-19 11:55:13 +02:00
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 {
2026-07-19 18:01:06 +02:00
// 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.
2026-07-19 11:55:13 +02:00
workoutID : { Segments : [ ] garmin . WorkoutSegment {
{ Steps : [ ] garmin . WorkoutStep {
{ Type : "ExecutableStepDTO" , TargetType : garmin . WorkoutTargetType { TypeKey : "pace.zone" } , TargetValueOne : f ( 3.0 ) , TargetValueTwo : f ( 3.5 ) } ,
} } ,
} } ,
} ,
}
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-19 11:55:13 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
2026-07-19 11:55:13 +02:00
}
if err := svc . FillPendingDetails ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FillPendingDetails: %v" , err )
}
2026-07-25 17:45:41 +02:00
activities , _ := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
laps , err := db . LapsForActivity ( ctx , userID , activities [ 0 ] . ID )
2026-07-19 11:55:13 +02:00
if err != nil || len ( laps ) != 2 {
t . Fatalf ( "LapsForActivity: %v, %+v" , err , laps )
}
2026-07-19 18:01:06 +02:00
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 )
2026-07-19 11:55:13 +02:00
}
}
2026-07-17 18:33:06 +02:00
func TestBuildMetricContext_DerivesExpectedMetrics ( t * testing . T ) {
activity := store . Activity {
DurationSeconds : 1800 ,
DistanceMeters : 6000 ,
AvgSpeedMps : f ( 3.33 ) ,
AvgHR : f ( 152 ) ,
AerobicTrainingEffect : f ( 3.2 ) ,
}
laps := [ ] store . Lap {
{ IntensityType : "ACTIVE" , AvgSpeedMps : f ( 3.33 ) , HRDriftBpmPerMin : f ( 2.5 ) } ,
{ IntensityType : "REST" , AvgSpeedMps : f ( 1.5 ) , HRRecoveryBpmPerMin : f ( 4.0 ) } ,
}
ctx := buildMetricContext ( activity , laps , 190 )
if got := ctx [ "avg_pace_sec_per_km" ] ; got < 300 || got > 301 {
t . Errorf ( "avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)" , got )
}
if got , want := ctx [ "avg_hr_pct_max" ] , 152.0 / 190.0 ; got != want {
t . Errorf ( "avg_hr_pct_max = %v, want %v" , got , want )
}
if got := ctx [ "lap_hr_drift_bpm_per_min" ] ; got != 2.5 {
t . Errorf ( "lap_hr_drift_bpm_per_min = %v, want 2.5" , got )
}
if got := ctx [ "lap_hr_recovery_bpm_per_min" ] ; got != 4.0 {
t . Errorf ( "lap_hr_recovery_bpm_per_min = %v, want 4.0" , got )
}
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
// structured interval workout.
if got := ctx [ "lap_interval_pattern" ] ; got != 0 {
t . Errorf ( "lap_interval_pattern = %v, want 0" , got )
}
2026-07-19 10:52:09 +02:00
if got := ctx [ "is_race" ] ; got != 0 {
t . Errorf ( "is_race = %v, want 0 (EventTypeKey not set)" , got )
}
}
func TestBuildMetricContext_DerivesIsRace ( t * testing . T ) {
ctx := buildMetricContext ( store . Activity { EventTypeKey : "race" } , nil , 0 )
if got := ctx [ "is_race" ] ; got != 1 {
t . Errorf ( "is_race = %v, want 1 when EventTypeKey is \"race\"" , got )
}
ctx = buildMetricContext ( store . Activity { EventTypeKey : "training" } , nil , 0 )
if got := ctx [ "is_race" ] ; got != 0 {
t . Errorf ( "is_race = %v, want 0 when EventTypeKey is not \"race\"" , got )
}
2026-07-17 18:33:06 +02:00
}
2026-07-27 06:44:39 +02:00
func TestBackfillCore_SecondRunIsANoOpOnceHorizonFullyCovered ( t * testing . T ) {
2026-07-17 18:33:06 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-17 18:33:06 +02:00
m := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-05 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} }
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { BackfillWindowDays : 10 } ,
2026-07-17 18:33:06 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-25 17:45:41 +02:00
setBackfillHorizon ( t , db , userID , 10 )
2026-07-17 18:33:06 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "first backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
firstCallCount := m . GetActivitiesCalls
if firstCallCount == 0 {
t . Fatal ( "expected first backfill to call GetActivities at least once" )
}
2026-07-25 17:45:41 +02:00
state , err := db . GetSyncState ( ctx , userID )
2026-07-17 18:33:06 +02:00
if err != nil {
t . Fatalf ( "GetSyncState: %v" , err )
}
if ! state . BackfillComplete {
t . Fatalf ( "expected backfill_complete=true after covering the full horizon, got %+v" , state )
}
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "second backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
if m . GetActivitiesCalls != firstCallCount {
2026-07-27 06:44:39 +02:00
t . Errorf ( "second backfillCore made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)" ,
2026-07-17 18:33:06 +02:00
m . GetActivitiesCalls - firstCallCount )
}
}
2026-07-19 12:41:38 +02:00
func TestResetAll_AllowsFreshBackfillAfterwards ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-19 12:41:38 +02:00
m := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-05 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} }
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { BackfillWindowDays : 10 } ,
2026-07-19 12:41:38 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-25 17:45:41 +02:00
setBackfillHorizon ( t , db , userID , 10 )
2026-07-19 12:41:38 +02:00
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "first backfillCore: %v" , err )
2026-07-19 12:41:38 +02:00
}
firstCallCount := m . GetActivitiesCalls
if err := svc . ResetAll ( ctx ) ; err != nil {
t . Fatalf ( "ResetAll: %v" , err )
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-19 12:41:38 +02:00
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
if len ( activities ) != 0 {
t . Fatalf ( "expected 0 activities after ResetAll, got %d" , len ( activities ) )
}
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore after reset: %v" , err )
2026-07-19 12:41:38 +02:00
}
if m . GetActivitiesCalls <= firstCallCount {
t . Errorf ( "expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d" , m . GetActivitiesCalls )
}
2026-07-25 17:45:41 +02:00
activities , err = db . ListActivities ( ctx , userID , store . ActivityFilter { } )
2026-07-19 12:41:38 +02:00
if err != nil {
t . Fatalf ( "ListActivities after re-backfill: %v" , err )
}
if len ( activities ) != 1 {
t . Fatalf ( "expected 1 activity re-fetched after reset+backfill, got %d" , len ( activities ) )
}
}
2026-07-27 06:44:39 +02:00
func TestBackfillCore_ResumesFromWatermarkWhenHorizonGrows ( t * testing . T ) {
2026-07-17 18:33:06 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-17 18:33:06 +02:00
m := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-05 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} }
now := fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) )
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { BackfillWindowDays : 10 } , now )
setBackfillHorizon ( t , db , userID , 10 )
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "first backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
firstCallCount := m . GetActivitiesCalls
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
2026-07-25 17:45:41 +02:00
setBackfillHorizon ( t , db , userID , 30 )
svc2 := NewService ( m , db , userID , Config { BackfillWindowDays : 10 } , now )
2026-07-27 06:44:39 +02:00
if _ , err := svc2 . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "second backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
if m . GetActivitiesCalls <= firstCallCount {
t . Errorf ( "expected additional GetActivities calls when horizon grows, got %d total (was %d)" , m . GetActivitiesCalls , firstCallCount )
}
2026-07-25 17:45:41 +02:00
state , err := db . GetSyncState ( ctx , userID )
2026-07-17 18:33:06 +02:00
if err != nil {
t . Fatalf ( "GetSyncState: %v" , err )
}
if ! state . BackfillComplete {
t . Fatalf ( "expected backfill_complete=true after covering the new horizon, got %+v" , state )
}
}
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
func TestFullSync_RecordsOneCombinedSyncRun ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
m := & mock . Client {
Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-05 06:00:00" , Distance : 5000 , Duration : 1500 } ,
{ ActivityID : 2 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-08 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} ,
Splits : map [ int64 ] garmin . ActivitySplits {
1 : { ActivityID : 1 } , 2 : { ActivityID : 2 } ,
} ,
Details : map [ int64 ] garmin . ActivityDetails {
1 : { ActivityID : 1 } , 2 : { ActivityID : 2 } ,
} ,
}
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { BackfillWindowDays : 10 } ,
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-25 17:45:41 +02:00
setBackfillHorizon ( t , db , userID , 10 )
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
if err := svc . FullSync ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FullSync: %v" , err )
}
2026-07-25 17:45:41 +02:00
runs , err := db . ListSyncRuns ( ctx , userID , 10 )
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
if err != nil {
t . Fatalf ( "ListSyncRuns: %v" , err )
}
if len ( runs ) != 1 {
t . Fatalf ( "expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v" , len ( runs ) , runs )
}
run := runs [ 0 ]
if run . Kind != store . SyncKindFull {
t . Errorf ( "Kind = %q, want %q" , run . Kind , store . SyncKindFull )
}
if run . Status != store . SyncStatusSuccess {
t . Errorf ( "Status = %q, want success" , run . Status )
}
// Backfill (one window covering the whole horizon) stores both of the
// mock's activities as genuinely new (2). IncrementalSync then runs its
// own separate fetch and -- since the fake client ignores the date range
// it's called with -- sees the exact same 2 activities again, but they're
// already stored by then, so it contributes 0 new ones. The combined
// run's count (2) must reflect that dedup, not naively sum each stage's
// raw fetch count (which would double-count to 4) or report only
// whichever stage happened to run last (which would silently drop
// Backfill's count) -- both are bugs this test guards against.
if run . ActivitiesFetched != 2 {
t . Errorf ( "ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)" , run . ActivitiesFetched )
}
2026-07-25 17:45:41 +02:00
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
(activity name/type, lap duration/HR, structured workout raw JSON) from
RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
use tight non-zero-based domains, m:ss/km pace formatting, and rounded
ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
alongside activity/lap/detail JSON; enlarge the modal and shrink array
indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
combined sync run per manual "Sync now" and count genuinely new
activities instead of re-listing whatever Garmin returned for the queried
window
- Let a Review Queue activity be manually cleared back to Unclassified, and
make "Reset all" available even while disconnected from Garmin
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
if len ( activities ) != 2 {
t . Fatalf ( "expected 2 stored activities (upserted, not duplicated), got %d" , len ( activities ) )
}
}
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.
2026-07-27 06:55:23 +02:00
func TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases ( t * testing . T ) {
2026-07-17 18:33:06 +02:00
db := openTestDB ( t )
ctx := context . Background ( )
2026-07-25 17:45:41 +02:00
userID := provisionTestUser ( t , db )
2026-07-17 18:33:06 +02:00
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.
2026-07-27 06:55:23 +02:00
const n = 2
2026-07-17 18:33:06 +02:00
m := & mock . Client {
Activities : [ ] garmin . Activity { } ,
Splits : map [ int64 ] garmin . ActivitySplits { } ,
Details : map [ int64 ] garmin . ActivityDetails { } ,
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.
2026-07-27 06:55:23 +02:00
Workouts : map [ int64 ] garmin . Workout { } ,
2026-07-17 18:33:06 +02:00
}
for i := int64 ( 1 ) ; i <= n ; i ++ {
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.
2026-07-27 06:55:23 +02:00
workoutID := i + 100
2026-07-17 18:33:06 +02:00
m . Activities = append ( m . Activities , garmin . Activity {
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.
2026-07-27 06:55:23 +02:00
ActivityID : i , ActivityType : garmin . ActivityType { TypeKey : "running" } , WorkoutID : & workoutID ,
2026-07-17 18:33:06 +02:00
StartTimeGMT : "2026-07-0" + string ( rune ( '0' + i ) ) + " 06:00:00" , Distance : 5000 , Duration : 1500 ,
} )
m . Splits [ i ] = garmin . ActivitySplits { ActivityID : i }
m . Details [ i ] = garmin . ActivityDetails { ActivityID : i }
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.
2026-07-27 06:55:23 +02:00
m . Workouts [ workoutID ] = garmin . Workout { }
2026-07-17 18:33:06 +02:00
}
2026-07-25 17:45:41 +02:00
svc := NewService ( m , db , userID , Config { InterCallDelay : 150 * time . Millisecond } ,
2026-07-17 18:33:06 +02:00
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-27 06:44:39 +02:00
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
2026-07-17 18:33:06 +02:00
}
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.
2026-07-27 06:55:23 +02:00
if p := svc . Progress ( ) ; p . Phase != PhaseIdle {
t . Fatalf ( "Progress before FillPendingDetails = %+v, want PhaseIdle" , p )
2026-07-17 18:33:06 +02:00
}
done := make ( chan error , 1 )
go func ( ) { done <- svc . FillPendingDetails ( ctx , n ) } ( )
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.
2026-07-27 06:55:23 +02:00
// Timeline with InterCallDelay=150ms and n=2 per phase: activities pass
// does item0 (instant), delays ~150ms, does item1 -- landing here around
// t=75ms should be mid-delay in the activities phase.
time . Sleep ( 75 * time . Millisecond )
midActivities := svc . Progress ( )
if midActivities . Phase != PhaseActivities {
t . Errorf ( "mid-activities Progress().Phase = %q, want %q" , midActivities . Phase , PhaseActivities )
2026-07-17 18:33:06 +02:00
}
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.
2026-07-27 06:55:23 +02:00
if midActivities . Total != n {
t . Errorf ( "mid-activities Progress().Total = %d, want %d" , midActivities . Total , n )
}
if midActivities . Done != 1 {
t . Errorf ( "mid-activities Progress().Done = %d, want 1" , midActivities . Done )
}
// The activities phase finishes its own delay+item1 around t=150ms, then
// the workouts phase starts: item0 (instant), delay ~150ms, item1.
// Landing around t=150+75=225ms should be mid-delay in the workouts phase.
time . Sleep ( 150 * time . Millisecond )
midWorkouts := svc . Progress ( )
if midWorkouts . Phase != PhaseWorkouts {
t . Errorf ( "mid-workouts Progress().Phase = %q, want %q" , midWorkouts . Phase , PhaseWorkouts )
}
if midWorkouts . Total != n {
t . Errorf ( "mid-workouts Progress().Total = %d, want %d" , midWorkouts . Total , n )
}
if midWorkouts . Done != 1 {
t . Errorf ( "mid-workouts Progress().Done = %d, want 1" , midWorkouts . Done )
2026-07-17 18:33:06 +02:00
}
if err := <- done ; err != nil {
t . Fatalf ( "FillPendingDetails: %v" , err )
}
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.
2026-07-27 06:55:23 +02:00
if final := svc . Progress ( ) ; final . Phase != PhaseIdle || final . Total != 0 || final . Done != 0 {
t . Errorf ( "Progress after completion = %+v, want zero-value PhaseIdle" , final )
}
}
func TestFillPendingDetails_RetriesWorkoutFetchForActivityWithDetailsAlreadyFetched ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
userID := provisionTestUser ( t , db )
const garminActivityID = 77
const workoutID = 888
workoutIDPtr := int64 ( workoutID )
id , err := db . UpsertActivity ( ctx , userID , store . Activity {
GarminActivityID : garminActivityID , WorkoutID : & workoutIDPtr ,
StartTimeUTC : "2026-07-01 06:00:00" , RawJSON : "{}" ,
} )
if err != nil {
t . Fatalf ( "UpsertActivity: %v" , err )
}
// Simulate a prior run that successfully fetched details/splits but
// whose workout fetch failed (workout_raw_json stays NULL).
if err := db . SetActivityDetails ( ctx , userID , id , "{}" ) ; err != nil {
t . Fatalf ( "SetActivityDetails: %v" , err )
}
if err := db . SetActivitySplitsFetched ( ctx , userID , id ) ; err != nil {
t . Fatalf ( "SetActivitySplitsFetched: %v" , err )
}
if err := db . ReplaceLaps ( ctx , userID , id , [ ] store . Lap { { LapIndex : 1 , IntensityType : "ACTIVE" } } ) ; err != nil {
t . Fatalf ( "ReplaceLaps: %v" , err )
}
m := & mock . Client {
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 , userID , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
// A prior FillPendingDetails call (which had ActivitiesMissingDetails
// return nothing, since details are already fetched) still reaches this
// activity via ActivitiesMissingWorkout.
if err := svc . FillPendingDetails ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FillPendingDetails: %v" , err )
}
laps , err := db . LapsForActivity ( ctx , userID , 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 (workout fetch should have been retried)" , laps [ 0 ] . TargetPaceLowMps )
}
}
func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
userID := provisionTestUser ( t , db )
const failingWorkoutID = 200
const okWorkoutID = 201
failingPtr , okPtr := int64 ( failingWorkoutID ) , int64 ( okWorkoutID )
m := & mock . Client {
Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , WorkoutID : & failingPtr ,
StartTimeGMT : "2026-07-01 06:00:00" , Distance : 5000 , Duration : 1500 } ,
{ ActivityID : 2 , ActivityType : garmin . ActivityType { TypeKey : "running" } , WorkoutID : & okPtr ,
StartTimeGMT : "2026-07-02 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} ,
Splits : map [ int64 ] garmin . ActivitySplits {
1 : { ActivityID : 1 , Laps : [ ] garmin . Lap { { LapIndex : 1 , IntensityType : "ACTIVE" } } } ,
2 : { ActivityID : 2 , Laps : [ ] garmin . Lap { { LapIndex : 1 , IntensityType : "ACTIVE" } } } ,
} ,
Details : map [ int64 ] garmin . ActivityDetails { 1 : { ActivityID : 1 } , 2 : { ActivityID : 2 } } ,
Workouts : map [ int64 ] garmin . Workout {
okWorkoutID : { Segments : [ ] garmin . WorkoutSegment {
{ Steps : [ ] garmin . WorkoutStep {
{ Type : "ExecutableStepDTO" , TargetType : garmin . WorkoutTargetType { TypeKey : "pace.zone" } , TargetValueOne : f ( 3.0 ) , TargetValueTwo : f ( 3.5 ) } ,
} } ,
} } ,
} ,
WorkoutErrByID : map [ int64 ] error { failingWorkoutID : fmt . Errorf ( "garmin says no" ) } ,
}
// Not timing-sensitive -- keep InterCallDelay negligible so the 2
// activities/2 workouts here don't cost real wall-clock seconds
// (Config{}'s default is 1s per gap).
svc := NewService ( m , db , userID , Config { InterCallDelay : time . Millisecond } ,
fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
if _ , err := svc . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore: %v" , err )
}
if err := svc . FillPendingDetails ( ctx , 10 ) ; err != nil {
t . Fatalf ( "FillPendingDetails should not return an error for a per-activity workout fetch failure: %v" , err )
}
remaining , err := db . CountActivitiesMissingWorkout ( ctx , userID )
if err != nil {
t . Fatalf ( "CountActivitiesMissingWorkout: %v" , err )
}
if remaining != 1 {
t . Errorf ( "CountActivitiesMissingWorkout = %d, want 1 (the failing activity stays pending, retryable next sync)" , remaining )
}
activities , err := db . ListActivities ( ctx , userID , store . ActivityFilter { } )
if err != nil {
t . Fatalf ( "ListActivities: %v" , err )
}
var okActivity store . Activity
for _ , a := range activities {
if a . GarminActivityID == 2 {
okActivity = a
}
}
laps , err := db . LapsForActivity ( ctx , userID , okActivity . 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 ( "the succeeding activity's TargetPaceLowMps = %v, want 3.0 (should not be affected by the other activity's failure)" , laps [ 0 ] . TargetPaceLowMps )
2026-07-17 18:33:06 +02:00
}
}
2026-07-25 17:45:41 +02:00
func TestService_TwoUsersSyncIndependently ( t * testing . T ) {
db := openTestDB ( t )
ctx := context . Background ( )
userA , err := db . ProvisionUser ( ctx , "sub-a" , "A" )
if err != nil {
t . Fatalf ( "ProvisionUser(a): %v" , err )
}
userB , err := db . ProvisionUser ( ctx , "sub-b" , "B" )
if err != nil {
t . Fatalf ( "ProvisionUser(b): %v" , err )
}
mA := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 1 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-01 06:00:00" , Distance : 5000 , Duration : 1500 } ,
} }
mB := & mock . Client { Activities : [ ] garmin . Activity {
{ ActivityID : 2 , ActivityType : garmin . ActivityType { TypeKey : "running" } , StartTimeGMT : "2026-07-01 06:00:00" , Distance : 8000 , Duration : 2400 } ,
} }
svcA := NewService ( mA , db , userA , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
svcB := NewService ( mB , db , userB , Config { } , fixedNow ( time . Date ( 2026 , 7 , 11 , 0 , 0 , 0 , 0 , time . UTC ) ) )
2026-07-27 06:44:39 +02:00
if _ , err := svcA . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore(a): %v" , err )
2026-07-25 17:45:41 +02:00
}
2026-07-27 06:44:39 +02:00
if _ , err := svcB . backfillCore ( ctx ) ; err != nil {
t . Fatalf ( "backfillCore(b): %v" , err )
2026-07-25 17:45:41 +02:00
}
activitiesA , err := db . ListActivities ( ctx , userA , store . ActivityFilter { } )
if err != nil {
t . Fatalf ( "ListActivities(a): %v" , err )
}
activitiesB , err := db . ListActivities ( ctx , userB , store . ActivityFilter { } )
if err != nil {
t . Fatalf ( "ListActivities(b): %v" , err )
}
if len ( activitiesA ) != 1 || activitiesA [ 0 ] . GarminActivityID != 1 {
t . Fatalf ( "userA's activities = %+v, want exactly garmin id 1" , activitiesA )
}
if len ( activitiesB ) != 1 || activitiesB [ 0 ] . GarminActivityID != 2 {
t . Fatalf ( "userB's activities = %+v, want exactly garmin id 2" , activitiesB )
}
}