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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user