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>
This commit is contained in:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -22,31 +22,18 @@ func isRunningActivityType(typeKey string) bool {
func toActivityRow(a garmin.Activity) store.Activity {
return store.Activity{
GarminActivityID: a.ActivityID,
ActivityName: a.ActivityName,
ActivityType: a.ActivityType.TypeKey,
EventTypeKey: a.EventType.TypeKey,
WorkoutID: a.WorkoutID,
StartTimeUTC: a.StartTimeGMT,
BeginTimestampMs: a.BeginTimestamp,
DurationSeconds: a.Duration,
DistanceMeters: a.Distance,
AvgHR: nonZero(a.AverageHR),
MaxHR: nonZero(a.MaxHR),
AvgSpeedMps: nonZero(a.AverageSpeed),
MaxSpeedMps: nonZero(a.MaxSpeed),
ElevationGainM: a.ElevationGain,
ElevationLossM: a.ElevationLoss,
Calories: nonZero(a.Calories),
LapCount: a.LapCount,
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
TrainingEffectLabel: a.TrainingEffectLabel,
VO2MaxValue: a.VO2MaxValue,
HrTimeInZone1: nonZero(a.HrTimeInZone1),
HrTimeInZone2: nonZero(a.HrTimeInZone2),
HrTimeInZone3: nonZero(a.HrTimeInZone3),
HrTimeInZone4: nonZero(a.HrTimeInZone4),
HrTimeInZone5: nonZero(a.HrTimeInZone5),
RawJSON: string(a.Raw),
}
}
@@ -90,18 +77,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
hrLow, hrHigh = targetHRRange(*targets[i], profile)
}
raw, _ := json.Marshal(l)
rows = append(rows, store.Lap{
LapIndex: l.LapIndex,
StartTimeUTC: l.StartTimeGMT,
DurationSeconds: l.Duration,
DistanceMeters: l.Distance,
AvgHR: nonZero(l.AverageHR),
MaxHR: nonZero(l.MaxHR),
AvgSpeedMps: nonZero(l.AverageSpeed),
MaxSpeedMps: nonZero(l.MaxSpeed),
ElevationGainM: nonZero(l.ElevationGain),
ElevationLossM: nonZero(l.ElevationLoss),
IntensityType: l.IntensityType,
HRDriftBpmPerMin: driftPtr,
HRRecoveryBpmPerMin: recoveryPtr,
@@ -109,7 +87,7 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
TargetPaceHighMps: paceHigh,
TargetHRLowBpm: hrLow,
TargetHRHighBpm: hrHigh,
RawJSON: string(raw),
RawJSON: string(l.Raw),
})
elapsedStart = elapsedEnd
}

View File

@@ -111,19 +111,30 @@ func (s *Service) Backfill(ctx context.Context) error {
return err
}
profile, err := s.db.GetProfile(ctx)
total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, 0, &msg)
return fmt.Errorf("load profile: %w", err)
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
}
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
// bookkeeping, so FullSync can run it as one step of a single combined run
// instead of its own separately-recorded one. The returned count reflects
// whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx)
if err != nil {
return 0, fmt.Errorf("load profile: %w", err)
}
horizon := s.now().AddDate(0, 0, -profile.BackfillHorizonDays)
state, err := s.db.GetSyncState(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, 0, &msg)
return err
return 0, err
}
end := s.now()
@@ -132,7 +143,7 @@ func (s *Service) Backfill(ctx context.Context) error {
if state.BackfillComplete && !watermark.After(horizon) {
// Already backfilled at least as far back as the configured
// horizon -- nothing new to fetch from Garmin at all.
return s.db.FinishSyncRun(ctx, runID, 0, nil)
return 0, nil
}
end = watermark.AddDate(0, 0, -1)
}
@@ -146,29 +157,23 @@ func (s *Service) Backfill(ctx context.Context) error {
start = horizon
}
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
rawCount, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
return total, fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
}
total += n
total += newCount
if n == 0 {
if rawCount == 0 {
// Empty page: reached the start of this account's history,
// regardless of the configured horizon.
reachedStartOfHistory = true
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
break
}
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
end = start.AddDate(0, 0, -1)
}
@@ -177,13 +182,11 @@ func (s *Service) Backfill(ctx context.Context) error {
// Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon.
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
return total, err
}
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
return total, nil
}
// IncrementalSync fetches activities from just before the latest known
@@ -194,14 +197,7 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return err
}
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
}
}
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, n, &msg)
@@ -210,6 +206,58 @@ func (s *Service) IncrementalSync(ctx context.Context) error {
return s.db.FinishSyncRun(ctx, runID, n, nil)
}
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
}
}
_, newCount, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
return newCount, err
}
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes
// from the watermark), then IncrementalSync (catches anything new since the
// latest known activity), then FillPendingDetails -- recorded as a single
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when
// called on their own (used by the periodic background loop), but a manual
// sync runs both back to back, and FillPendingDetails records no run at all;
// showing the user only the most recently *recorded* run (IncrementalSync's)
// would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, store.SyncKindFull)
if err != nil {
return err
}
backfillCount, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, backfillCount, &msg)
return err
}
incrementalCount, err := s.incrementalSyncCore(ctx)
total := backfillCount + incrementalCount
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
if err := s.FillPendingDetails(ctx, detailFillLimit); err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, runID, total, nil)
}
// ResetAll deletes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from
@@ -218,26 +266,42 @@ func (s *Service) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx)
}
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
// fetchAndStoreWindow returns two counts: rawCount is every activity Garmin's
// API returned for this date range, regardless of sport or whether it was
// already known -- Backfill's "reached start of history" check needs this
// exact unfiltered count, since a page containing only non-running
// activities must not look like an empty page. newCount is how many running
// activities were genuinely new (not already stored), which is what actually
// belongs in the user-facing "activities fetched" report: the incremental
// overlap window and backfill's already-covered history mean Garmin almost
// always re-returns activities we already have, and reporting rawCount there
// produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window).
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil {
return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
}
for _, a := range activities {
// Only running activities are of interest here; other sports (padel,
// cycling, strength training, ...) also come back from
// get_activities() but are dropped rather than stored. len(activities)
// below stays the unfiltered count, since it drives the backfill
// watermark's "reached start of history" check -- a page containing
// only non-running activities must not look like an empty page.
// get_activities() but are dropped rather than stored.
if !isRunningActivityType(a.ActivityType.TypeKey) {
continue
}
exists, err := s.db.ActivityExists(ctx, a.ActivityID)
if err != nil {
return 0, 0, err
}
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
return 0, 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
}
if !exists {
newCount++
}
}
return len(activities), nil
return len(activities), newCount, nil
}
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
@@ -293,6 +357,9 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, a.ID, string(workout.Raw)); err != nil {
return err
}
}
}
@@ -303,8 +370,7 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err
}
detailsRaw, _ := json.Marshal(details)
if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
if err := s.db.SetActivityDetails(ctx, a.ID, string(details.Raw)); err != nil {
return err
}
return s.db.SetActivitySplitsFetched(ctx, a.ID)

View File

@@ -555,6 +555,66 @@ func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
}
}
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
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},
},
}
svc := NewService(m, db, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, 10)
if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err)
}
runs, err := db.ListSyncRuns(ctx, 10)
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)
}
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
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))
}
}
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()