2026-08-04 16:04:18 +02:00
// Package garmin orchestrates fetching activities from Garmin (via
2026-07-17 18:33:06 +02:00
// internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other.
2026-08-04 16:04:18 +02:00
package garmin
2026-07-17 18:33:06 +02:00
import (
"context"
"encoding/json"
2026-07-27 19:03:09 +02:00
"errors"
2026-07-17 18:33:06 +02:00
"fmt"
"sync"
"time"
2026-07-24 21:08:07 +02:00
"geniusrun/backend/internal/classify"
2026-08-04 16:33:26 +02:00
applog "geniusrun/backend/internal/log"
2026-07-24 21:08:07 +02:00
"geniusrun/backend/internal/store"
2026-07-17 18:33:06 +02:00
)
// Config tunes sync behavior. Zero values fall back to sensible defaults in
2026-08-04 16:04:18 +02:00
// NewSync. How far back Backfill reaches is not here -- it's
2026-07-19 12:57:18 +02:00
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart.
2026-08-04 16:04:18 +02:00
type SyncConfig struct {
2026-07-17 18:33:06 +02:00
// BackfillWindowDays is the page size for each get_activities call
// during backfill.
BackfillWindowDays int
// IncrementalOverlapDays re-fetches a small trailing window on every
// incremental sync, guarding against activities that were still
// uploading/processing at the time of the previous sync.
IncrementalOverlapDays int
// InterCallDelay is a pause between sequential Garmin calls during
// detail-fill, to avoid tripping Garmin/Cloudflare's rate limiting
// (observed firsthand during development).
InterCallDelay time . Duration
// MinConfidence is the classify.Classify threshold below which even a
// single matching kind is sent to manual review.
MinConfidence float64
}
2026-08-04 16:04:18 +02:00
func ( c SyncConfig ) withDefaults ( ) SyncConfig {
2026-07-17 18:33:06 +02:00
if c . BackfillWindowDays == 0 {
c . BackfillWindowDays = 90
}
if c . IncrementalOverlapDays == 0 {
c . IncrementalOverlapDays = 2
}
if c . InterCallDelay == 0 {
c . InterCallDelay = time . Second
}
if c . MinConfidence == 0 {
c . MinConfidence = classify . DefaultMinConfidence
}
return c
}
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
// Phase values reported by Progress.Phase.
const (
PhaseIdle = "idle"
PhaseDiscovering = "discovering"
PhaseActivities = "activities"
PhaseWorkouts = "workouts"
)
// Progress reports how far a currently-running (or just-finished) FullSync
// has gotten, for a status modal to poll. PhaseDiscovering (backfillCore/
// incrementalSyncCore) has no meaningful Total -- discovering how many
// activities exist IS the act of fetching them, so it's reported as an
// indeterminate step (Done/Total both 0) rather than a fake percentage.
// PhaseActivities/PhaseWorkouts do have a real Total, taken once from a
// local DB count at the start of each pass (see FillPendingDetails).
2026-07-17 18:33:06 +02:00
type Progress struct {
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
Phase string
2026-07-17 18:33:06 +02:00
Done int
Total int
}
2026-08-04 16:04:18 +02:00
// Sync is the sync orchestrator, scoped to one user -- every store call
2026-07-25 17:45:41 +02:00
// it makes is for userID's data only.
2026-08-04 16:04:18 +02:00
type Sync struct {
garmin Client
2026-07-17 18:33:06 +02:00
db * store . DB
2026-07-25 17:45:41 +02:00
userID int64
2026-08-04 16:04:18 +02:00
cfg SyncConfig
2026-07-17 18:33:06 +02:00
now func ( ) time . Time
progressMu sync . Mutex
progress Progress
}
2026-08-04 16:04:18 +02:00
// NewSync builds a Sync scoped to userID. now defaults to time.Now if
2026-07-25 17:45:41 +02:00
// nil (tests can override it for deterministic date windows).
2026-08-04 16:04:18 +02:00
func NewSync ( g Client , db * store . DB , userID int64 , cfg SyncConfig , now func ( ) time . Time ) * Sync {
2026-07-17 18:33:06 +02:00
if now == nil {
now = time . Now
}
2026-08-04 16:04:18 +02:00
return & Sync { garmin : g , db : db , userID : userID , cfg : cfg . withDefaults ( ) , now : now , progress : Progress { Phase : PhaseIdle } }
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
// Progress returns the current sync progress (phase idle, 0/0 when nothing
// is running).
2026-08-04 16:04:18 +02:00
func ( s * Sync ) Progress ( ) Progress {
2026-07-17 18:33:06 +02:00
s . progressMu . Lock ( )
defer s . progressMu . Unlock ( )
return s . progress
}
2026-08-04 16:04:18 +02:00
func ( s * Sync ) setProgress ( phase string , done , total int ) {
2026-07-17 18:33:06 +02:00
s . progressMu . Lock ( )
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
s . progress = Progress { Phase : phase , Done : done , Total : total }
2026-07-17 18:33:06 +02:00
s . progressMu . Unlock ( )
}
2026-07-27 06:44:39 +02:00
// backfillCore pages backward in Config.BackfillWindowDays windows until
2026-07-19 12:57:18 +02:00
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
// The horizon is read fresh from the profile on every call (not fixed at
// server startup), so a user-edited value takes effect on the very next
// sync. Safe to re-run: activities are upserted by garmin_activity_id, and
// thanks to the sync_state watermark (Garmin history is immutable once
// recorded) a repeat call only fetches whatever's newer than the last
// completed backfill, or is a fast no-op if the configured horizon is
// already fully covered -- it does not re-walk years of already-known
// history. Widening the horizon between calls resumes further back instead
2026-07-27 06:44:39 +02:00
// of re-fetching everything. Used by FullSync as one step of its single
// combined SyncRun; there is no standalone entrypoint for this anymore
// (the periodic background sync loop that used to call one is gone -- see
// 4d2cbe4 refactor: remove automatic background incremental sync).
2026-08-04 16:04:18 +02:00
func ( s * Sync ) backfillCore ( ctx context . Context ) ( int , error ) {
2026-07-25 17:45:41 +02:00
profile , err := s . db . GetProfile ( ctx , s . userID )
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 {
return 0 , fmt . Errorf ( "load profile: %w" , err )
2026-07-19 12:57:18 +02:00
}
horizon := s . now ( ) . AddDate ( 0 , 0 , - profile . BackfillHorizonDays )
2026-07-17 18:33:06 +02:00
2026-07-25 17:45:41 +02:00
state , err := s . db . GetSyncState ( ctx , s . userID )
2026-07-17 18:33:06 +02:00
if err != nil {
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
return 0 , err
2026-07-17 18:33:06 +02:00
}
end := s . now ( )
if state . EarliestSyncedDate != nil {
if watermark , err := time . Parse ( "2006-01-02" , * state . EarliestSyncedDate ) ; err == nil {
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.
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
return 0 , nil
2026-07-17 18:33:06 +02:00
}
end = watermark . AddDate ( 0 , 0 , - 1 )
}
}
total := 0
reachedStartOfHistory := false
for end . After ( horizon ) {
start := end . AddDate ( 0 , 0 , - s . cfg . BackfillWindowDays )
if start . Before ( horizon ) {
start = horizon
}
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
rawCount , newCount , err := s . fetchAndStoreWindow ( ctx , dateStr ( start ) , dateStr ( end ) )
2026-07-17 18:33:06 +02:00
if err != nil {
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
return total , fmt . Errorf ( "backfill window %s..%s: %w" , dateStr ( start ) , dateStr ( end ) , err )
2026-07-17 18:33:06 +02:00
}
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
total += newCount
2026-07-17 18:33:06 +02:00
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 rawCount == 0 {
2026-07-17 18:33:06 +02:00
// Empty page: reached the start of this account's history,
// regardless of the configured horizon.
reachedStartOfHistory = true
2026-07-25 17:45:41 +02:00
if err := s . db . UpdateSyncState ( ctx , s . userID , dateStr ( start ) , true ) ; err != nil {
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
return total , err
2026-07-17 18:33:06 +02:00
}
break
}
2026-07-25 17:45:41 +02:00
if err := s . db . UpdateSyncState ( ctx , s . userID , dateStr ( start ) , false ) ; err != nil {
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
return total , err
2026-07-17 18:33:06 +02:00
}
end = start . AddDate ( 0 , 0 , - 1 )
}
if ! reachedStartOfHistory {
// Reached the configured horizon (not Garmin's actual history
// start) -- mark complete relative to that horizon.
2026-07-25 17:45:41 +02:00
if err := s . db . UpdateSyncState ( ctx , s . userID , dateStr ( horizon ) , true ) ; err != nil {
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
return total , err
2026-07-17 18:33:06 +02:00
}
}
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
return total , nil
2026-07-17 18:33:06 +02:00
}
2026-07-27 06:44:39 +02:00
// incrementalSyncCore fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today. Used
// by FullSync as one step of its single combined SyncRun -- see
// backfillCore's comment for why there's no standalone entrypoint.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) incrementalSyncCore ( ctx context . Context ) ( int , error ) {
2026-07-17 18:33:06 +02:00
start := s . now ( ) . AddDate ( 0 , 0 , - s . cfg . IncrementalOverlapDays )
2026-07-25 17:45:41 +02:00
if latest , ok , err := s . db . LatestActivityStartTime ( ctx , s . userID ) ; err == nil && ok {
2026-07-17 18:33:06 +02:00
if t , err := time . Parse ( "2006-01-02 15:04:05" , latest ) ; err == nil {
start = t . AddDate ( 0 , 0 , - s . cfg . IncrementalOverlapDays )
}
}
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
_ , newCount , err := s . fetchAndStoreWindow ( ctx , dateStr ( start ) , dateStr ( s . now ( ) ) )
return newCount , err
}
2026-07-27 06:44:39 +02:00
// FullSync performs a complete manual "Sync now" pass -- backfillCore
// (resumes from the watermark), then incrementalSyncCore (catches anything
// new since the latest known activity), then FillPendingDetails --
// recorded as a single SyncRun so the reported activity count covers the
// whole action instead of only whichever stage happened to finish last.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) FullSync ( ctx context . Context , detailFillLimit int ) error {
2026-07-25 17:45:41 +02:00
runID , err := s . db . StartSyncRun ( ctx , s . userID , store . SyncKindFull )
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 {
return 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
// Reset to idle on every return path, including an early error return
// from backfillCore/incrementalSyncCore before FillPendingDetails (which
// otherwise owns its own idle-reset) ever runs.
s . setProgress ( PhaseDiscovering , 0 , 0 )
defer s . setProgress ( PhaseIdle , 0 , 0 )
2026-07-17 18:33:06 +02:00
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
backfillCount , err := s . backfillCore ( ctx )
2026-07-17 18:33:06 +02:00
if err != nil {
msg := err . Error ( )
2026-07-25 17:45:41 +02:00
s . db . FinishSyncRun ( ctx , s . userID , runID , backfillCount , & msg )
2026-07-17 18:33:06 +02:00
return err
}
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
incrementalCount , err := s . incrementalSyncCore ( ctx )
total := backfillCount + incrementalCount
if err != nil {
msg := err . Error ( )
2026-07-25 17:45:41 +02:00
s . db . FinishSyncRun ( ctx , s . userID , runID , total , & msg )
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
return err
}
if err := s . FillPendingDetails ( ctx , detailFillLimit ) ; err != nil {
msg := err . Error ( )
2026-07-25 17:45:41 +02:00
s . db . FinishSyncRun ( ctx , s . userID , runID , total , & msg )
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
return err
}
2026-07-25 17:45:41 +02:00
return s . db . FinishSyncRun ( ctx , s . userID , runID , total , nil )
2026-07-17 18:33:06 +02:00
}
2026-07-19 12:41:38 +02:00
// 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
// wherever the previous one left off. Workout kinds are left untouched.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) ResetAll ( ctx context . Context ) error {
2026-07-25 17:45:41 +02:00
return s . db . ResetAllSyncedData ( ctx , s . userID )
2026-07-19 12:41:38 +02:00
}
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
// 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).
2026-08-04 16:04:18 +02:00
func ( s * Sync ) fetchAndStoreWindow ( ctx context . Context , startDate , endDate string ) ( rawCount , newCount int , err error ) {
2026-07-17 18:33:06 +02:00
activities , err := s . garmin . GetActivities ( ctx , startDate , endDate , 500 )
if err != nil {
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
return 0 , 0 , fmt . Errorf ( "get_activities(%s, %s): %w" , startDate , endDate , err )
2026-07-17 18:33:06 +02:00
}
for _ , a := range activities {
2026-07-19 10:52:09 +02:00
// Only running activities are of interest here; other sports (padel,
// cycling, strength training, ...) also come back from
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
// get_activities() but are dropped rather than stored.
2026-07-19 10:52:09 +02:00
if ! isRunningActivityType ( a . ActivityType . TypeKey ) {
continue
}
2026-07-25 17:45:41 +02:00
exists , err := s . db . ActivityExists ( ctx , s . userID , a . ActivityID )
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 {
return 0 , 0 , err
}
2026-07-25 17:45:41 +02:00
if _ , err := s . db . UpsertActivity ( ctx , s . userID , toActivityRow ( a ) ) ; err != nil {
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
return 0 , 0 , fmt . Errorf ( "store activity %d: %w" , a . ActivityID , err )
}
if ! exists {
newCount ++
2026-07-17 18:33:06 +02:00
}
}
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
return len ( activities ) , newCount , nil
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
// FillPendingDetails fetches activity details/splits for up to limit
// activities missing them, then fetches workouts for up to limit activities
// missing those (independently -- see ActivitiesMissingWorkout), then
// (re)classifies every activity touched by the first pass. Each pass makes
// its Garmin calls sequentially with Config.InterCallDelay between them to
2026-07-17 18:33:06 +02:00
// avoid Garmin/Cloudflare rate limiting.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) FillPendingDetails ( ctx context . Context , limit int ) error {
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
defer s . setProgress ( PhaseIdle , 0 , 0 )
if err := s . fillPendingActivityDetails ( ctx , limit ) ; err != nil {
return err
}
return s . fillPendingWorkouts ( ctx , limit )
}
2026-08-04 16:04:18 +02:00
func ( s * Sync ) fillPendingActivityDetails ( ctx context . Context , limit int ) error {
2026-07-25 17:45:41 +02:00
pending , err := s . db . ActivitiesMissingDetails ( ctx , s . userID , limit )
2026-07-17 18:33:06 +02:00
if err != nil {
return err
}
2026-07-25 17:45:41 +02:00
profile , err := s . db . GetProfile ( ctx , s . userID )
2026-07-19 11:55:13 +02:00
if err != nil {
return fmt . Errorf ( "load profile: %w" , 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
s . setProgress ( PhaseActivities , 0 , len ( pending ) )
2026-07-17 18:33:06 +02:00
for i , a := range pending {
if i > 0 {
select {
case <- ctx . Done ( ) :
return ctx . Err ( )
case <- time . After ( s . cfg . InterCallDelay ) :
}
}
2026-07-19 11:55:13 +02:00
if err := s . fillActivityDetails ( ctx , a , profile ) ; err != nil {
2026-07-17 18:33:06 +02:00
return fmt . Errorf ( "fill details for activity %d: %w" , a . GarminActivityID , err )
}
if err := s . ClassifyActivity ( ctx , a . ID ) ; err != nil {
return fmt . Errorf ( "classify activity %d: %w" , a . GarminActivityID , 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
s . setProgress ( PhaseActivities , i + 1 , len ( pending ) )
}
return nil
}
// fillPendingWorkouts fetches get_workout_by_id for up to limit activities
// missing it. Unlike fillPendingActivityDetails, one activity's workout
// fetch failing is non-fatal (logged, loop continues) -- workout target
// bands are enrichment, not core activity data, and workout_raw_json
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
// the next sync.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) fillPendingWorkouts ( ctx context . Context , limit int ) error {
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
pending , err := s . db . ActivitiesMissingWorkout ( ctx , s . userID , limit )
if err != nil {
return err
}
profile , err := s . db . GetProfile ( ctx , s . userID )
if err != nil {
return fmt . Errorf ( "load profile: %w" , err )
}
s . setProgress ( PhaseWorkouts , 0 , len ( pending ) )
for i , a := range pending {
if i > 0 {
select {
case <- ctx . Done ( ) :
return ctx . Err ( )
case <- time . After ( s . cfg . InterCallDelay ) :
}
}
if err := s . fillActivityWorkout ( ctx , a , profile ) ; err != nil {
2026-08-04 16:04:18 +02:00
if errors . Is ( err , ErrNotFound ) {
2026-07-27 19:03:09 +02:00
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever.
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
applog . App ( "garmin.Sync" , "fillPendingWorkouts" ) . Warn ( "workout not found on Garmin, marking as such (will not retry)" , "garmin_activity_id" , a . GarminActivityID , "error" , err )
2026-07-27 19:03:09 +02:00
if serr := s . db . SetActivityWorkoutNotFound ( ctx , s . userID , a . ID ) ; serr != nil {
return fmt . Errorf ( "mark activity %d workout not found: %w" , a . GarminActivityID , serr )
}
} else {
refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
applog . App ( "garmin.Sync" , "fillPendingWorkouts" ) . Warn ( "fill workout failed, will retry next sync" , "garmin_activity_id" , a . GarminActivityID , "error" , err )
2026-07-27 19:03:09 +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
}
s . setProgress ( PhaseWorkouts , i + 1 , len ( pending ) )
2026-07-17 18:33:06 +02:00
}
return nil
}
2026-08-04 16:04:18 +02:00
func ( s * Sync ) fillActivityDetails ( ctx context . Context , a store . Activity , profile store . Profile ) error {
2026-07-17 18:33:06 +02:00
splits , err := s . garmin . GetActivitySplits ( ctx , a . GarminActivityID )
if err != nil {
return fmt . Errorf ( "get_activity_splits: %w" , err )
}
details , err := s . garmin . GetActivityDetails ( ctx , a . GarminActivityID )
if err != nil {
return fmt . Errorf ( "get_activity_details: %w" , err )
}
2026-08-04 16:04:18 +02:00
samples := ExtractSamples ( details )
2026-07-25 17:45:41 +02:00
if err := s . db . ReplaceActivitySamples ( ctx , s . userID , a . ID , toSampleRows ( samples ) ) ; err != nil {
2026-07-17 18:33:06 +02:00
return 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
// No workout-target alignment here -- that's fillActivityWorkout's job,
// run as its own later pass (see fillPendingWorkouts). targets is an
// all-nil placeholder the same length as splits.Laps.
2026-08-04 16:04:18 +02:00
targets := make ( [ ] * WorkoutStep , len ( splits . Laps ) )
2026-07-25 17:45:41 +02:00
if err := s . db . ReplaceLaps ( ctx , s . userID , a . ID , toLapRows ( splits . Laps , samples , targets , profile ) ) ; err != nil {
2026-07-17 18:33:06 +02:00
return err
}
2026-07-25 17:45:41 +02:00
if err := s . db . SetActivityDetails ( ctx , s . userID , a . ID , string ( details . Raw ) ) ; err != nil {
2026-07-17 18:33:06 +02:00
return err
}
2026-07-25 17:45:41 +02:00
return s . db . SetActivitySplitsFetched ( ctx , s . userID , a . ID )
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
// fillActivityWorkout fetches a's structured workout and re-derives its
// laps' target pace/HR bands from it. Requires a.WorkoutID to be set --
// only ever called for activities ActivitiesMissingWorkout returned, which
// already filters on that. Reads laps back from the DB (already written by
// fillActivityDetails, in some earlier pass or run) rather than needing the
// original garmin.Lap data again, since alignWorkoutTargets only needs a
// count.
2026-08-04 16:04:18 +02:00
func ( s * Sync ) fillActivityWorkout ( ctx context . Context , a store . Activity , profile store . Profile ) error {
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
workout , err := s . garmin . GetWorkoutByID ( ctx , * a . WorkoutID )
if err != nil {
return fmt . Errorf ( "get_workout_by_id: %w" , err )
}
laps , err := s . db . LapsForActivity ( ctx , s . userID , a . ID )
if err != nil {
return err
}
targets := alignWorkoutTargets ( len ( laps ) , workout )
for i := range laps {
if i < len ( targets ) && targets [ i ] != nil {
laps [ i ] . TargetPaceLowMps , laps [ i ] . TargetPaceHighMps = targetPaceRange ( * targets [ i ] )
laps [ i ] . TargetHRLowBpm , laps [ i ] . TargetHRHighBpm = targetHRRange ( * targets [ i ] , profile )
}
}
if err := s . db . ReplaceLaps ( ctx , s . userID , a . ID , laps ) ; err != nil {
return err
}
return s . db . SetActivityWorkout ( ctx , s . userID , a . ID , string ( workout . Raw ) )
}
2026-07-17 18:33:06 +02:00
// ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
2026-08-04 16:04:18 +02:00
func ( s * Sync ) ClassifyActivity ( ctx context . Context , activityID int64 ) error {
2026-07-25 17:45:41 +02:00
activity , ok , err := s . db . GetActivity ( ctx , s . userID , activityID )
2026-07-17 18:33:06 +02:00
if err != nil {
return err
}
if ! ok {
return fmt . Errorf ( "activity %d not found" , activityID )
}
2026-07-25 17:45:41 +02:00
laps , err := s . db . LapsForActivity ( ctx , s . userID , activityID )
2026-07-17 18:33:06 +02:00
if err != nil {
return err
}
2026-07-25 17:45:41 +02:00
kindRows , err := s . db . ListWorkoutKinds ( ctx , s . userID , true )
2026-07-17 18:33:06 +02:00
if err != nil {
return err
}
rules , err := loadRuleKinds ( kindRows )
if err != nil {
return fmt . Errorf ( "parse workout kind rules: %w" , err )
}
2026-07-25 17:45:41 +02:00
profile , err := s . db . GetProfile ( ctx , s . userID )
2026-07-17 19:13:24 +02:00
if err != nil {
return fmt . Errorf ( "load profile: %w" , err )
}
var maxHR float64
if profile . MaxHeartRate != nil {
maxHR = * profile . MaxHeartRate
}
2026-07-17 18:33:06 +02:00
2026-07-17 19:13:24 +02:00
ctxMetrics := buildMetricContext ( activity , laps , maxHR )
2026-07-17 18:33:06 +02:00
result := classify . Classify ( ctxMetrics , rules , s . cfg . MinConfidence )
candidatesJSON , err := json . Marshal ( result . Candidates )
if err != nil {
return err
}
2026-07-25 17:45:41 +02:00
_ , err = s . db . InsertKindAssignment ( ctx , s . userID , store . KindAssignment {
2026-07-17 18:33:06 +02:00
ActivityID : activityID ,
WorkoutKindID : result . WorkoutKindID ,
AssignmentSource : store . AssignmentSourceRuleEngine ,
Status : result . Status ,
Confidence : result . Confidence ,
CandidateKindsJSON : string ( candidatesJSON ) ,
} )
return err
}