Race is the 8th fixed workout kind, seeded with a real (not placeholder) rule since Garmin Connect's eventType.typeKey reports "race" for manually-tagged race activities. Non-running activity types (padel, cycling, strength training, ...) are now dropped at sync time instead of being stored. The Review Queue's type filter is now clickable exclusive pill buttons instead of a dropdown.
333 lines
10 KiB
Go
333 lines
10 KiB
Go
// Package sync orchestrates fetching activities from Garmin (via
|
|
// 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.
|
|
package sync
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"smartrun/backend/internal/classify"
|
|
"smartrun/backend/internal/garmin"
|
|
"smartrun/backend/internal/store"
|
|
)
|
|
|
|
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
|
// NewService.
|
|
type Config struct {
|
|
// BackfillHorizonDays bounds how far back a full backfill reaches.
|
|
BackfillHorizonDays int
|
|
// 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
|
|
}
|
|
|
|
func (c Config) withDefaults() Config {
|
|
if c.BackfillHorizonDays == 0 {
|
|
c.BackfillHorizonDays = 3 * 365
|
|
}
|
|
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
|
|
}
|
|
|
|
// Progress reports how far a currently-running (or just-finished)
|
|
// FillPendingDetails pass has gotten, for a status banner to poll.
|
|
type Progress struct {
|
|
Done int
|
|
Total int
|
|
}
|
|
|
|
// Service is the sync orchestrator.
|
|
type Service struct {
|
|
garmin garmin.Client
|
|
db *store.DB
|
|
cfg Config
|
|
now func() time.Time
|
|
|
|
progressMu sync.Mutex
|
|
progress Progress
|
|
}
|
|
|
|
// NewService builds a Service. now defaults to time.Now if nil (tests can
|
|
// override it for deterministic date windows).
|
|
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service {
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now}
|
|
}
|
|
|
|
// Progress returns the current detail-fill progress (0/0 when idle).
|
|
func (s *Service) Progress() Progress {
|
|
s.progressMu.Lock()
|
|
defer s.progressMu.Unlock()
|
|
return s.progress
|
|
}
|
|
|
|
func (s *Service) setProgress(done, total int) {
|
|
s.progressMu.Lock()
|
|
s.progress = Progress{Done: done, Total: total}
|
|
s.progressMu.Unlock()
|
|
}
|
|
|
|
// Backfill pages backward in Config.BackfillWindowDays windows until
|
|
// Config.BackfillHorizonDays is reached or Garmin returns an empty page.
|
|
// 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.
|
|
func (s *Service) Backfill(ctx context.Context) error {
|
|
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
horizon := s.now().AddDate(0, 0, -s.cfg.BackfillHorizonDays)
|
|
|
|
state, err := s.db.GetSyncState(ctx)
|
|
if err != nil {
|
|
msg := err.Error()
|
|
s.db.FinishSyncRun(ctx, runID, 0, &msg)
|
|
return err
|
|
}
|
|
|
|
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.
|
|
return s.db.FinishSyncRun(ctx, runID, 0, nil)
|
|
}
|
|
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
|
|
}
|
|
|
|
n, 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)
|
|
}
|
|
total += n
|
|
|
|
if n == 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
|
|
}
|
|
break
|
|
}
|
|
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
|
|
msg := err.Error()
|
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
|
return err
|
|
}
|
|
end = start.AddDate(0, 0, -1)
|
|
}
|
|
|
|
if !reachedStartOfHistory {
|
|
// 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 s.db.FinishSyncRun(ctx, runID, total, nil)
|
|
}
|
|
|
|
// IncrementalSync fetches activities from just before the latest known
|
|
// activity (or a short recent window if none exist yet) through today.
|
|
func (s *Service) IncrementalSync(ctx context.Context) error {
|
|
runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental)
|
|
if err != nil {
|
|
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()))
|
|
if err != nil {
|
|
msg := err.Error()
|
|
s.db.FinishSyncRun(ctx, runID, n, &msg)
|
|
return err
|
|
}
|
|
return s.db.FinishSyncRun(ctx, runID, n, nil)
|
|
}
|
|
|
|
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, 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)
|
|
}
|
|
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.
|
|
if !isRunningActivityType(a.ActivityType.TypeKey) {
|
|
continue
|
|
}
|
|
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
|
|
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
|
}
|
|
}
|
|
return len(activities), nil
|
|
}
|
|
|
|
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
|
|
// to limit activities that don't have them yet, then (re)classifies each one.
|
|
// Calls are made sequentially with Config.InterCallDelay between them to
|
|
// avoid Garmin/Cloudflare rate limiting.
|
|
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
|
pending, err := s.db.ActivitiesMissingDetails(ctx, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.setProgress(0, len(pending))
|
|
defer s.setProgress(0, 0)
|
|
|
|
for i, a := range pending {
|
|
if i > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(s.cfg.InterCallDelay):
|
|
}
|
|
}
|
|
if err := s.fillActivityDetails(ctx, a); err != nil {
|
|
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)
|
|
}
|
|
s.setProgress(i+1, len(pending))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) error {
|
|
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)
|
|
}
|
|
|
|
samples := garmin.ExtractSamples(details)
|
|
if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil {
|
|
return err
|
|
}
|
|
if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples)); err != nil {
|
|
return err
|
|
}
|
|
detailsRaw, _ := json.Marshal(details)
|
|
if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
|
|
return err
|
|
}
|
|
return s.db.SetActivitySplitsFetched(ctx, a.ID)
|
|
}
|
|
|
|
// 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).
|
|
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
|
activity, ok, err := s.db.GetActivity(ctx, activityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return fmt.Errorf("activity %d not found", activityID)
|
|
}
|
|
laps, err := s.db.LapsForActivity(ctx, activityID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rules, err := loadRuleKinds(kindRows)
|
|
if err != nil {
|
|
return fmt.Errorf("parse workout kind rules: %w", err)
|
|
}
|
|
profile, err := s.db.GetProfile(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("load profile: %w", err)
|
|
}
|
|
var maxHR float64
|
|
if profile.MaxHeartRate != nil {
|
|
maxHR = *profile.MaxHeartRate
|
|
}
|
|
|
|
ctxMetrics := buildMetricContext(activity, laps, maxHR)
|
|
result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence)
|
|
|
|
candidatesJSON, err := json.Marshal(result.Candidates)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{
|
|
ActivityID: activityID,
|
|
WorkoutKindID: result.WorkoutKindID,
|
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
|
Status: result.Status,
|
|
Confidence: result.Confidence,
|
|
CandidateKindsJSON: string(candidatesJSON),
|
|
})
|
|
return err
|
|
}
|