Initial commit: smartrun MVP
Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
204
backend/internal/sync/mapping.go
Normal file
204
backend/internal/sync/mapping.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/classify"
|
||||
"smartrun/backend/internal/garmin"
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func toActivityRow(a garmin.Activity) store.Activity {
|
||||
return store.Activity{
|
||||
GarminActivityID: a.ActivityID,
|
||||
ActivityName: a.ActivityName,
|
||||
ActivityType: a.ActivityType.TypeKey,
|
||||
StartTimeUTC: a.StartTimeGMT,
|
||||
BeginTimestampMs: a.BeginTimestamp,
|
||||
DurationSeconds: a.Duration,
|
||||
DistanceMeters: a.Distance,
|
||||
AvgHR: nonZero(a.AverageHR),
|
||||
MaxHR: nonZero(a.MaxHR),
|
||||
AvgSpeedMps: nonZero(a.AverageSpeed),
|
||||
MaxSpeedMps: nonZero(a.MaxSpeed),
|
||||
ElevationGainM: a.ElevationGain,
|
||||
ElevationLossM: a.ElevationLoss,
|
||||
Calories: nonZero(a.Calories),
|
||||
LapCount: a.LapCount,
|
||||
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
||||
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
||||
TrainingEffectLabel: a.TrainingEffectLabel,
|
||||
VO2MaxValue: a.VO2MaxValue,
|
||||
HrTimeInZone1: nonZero(a.HrTimeInZone1),
|
||||
HrTimeInZone2: nonZero(a.HrTimeInZone2),
|
||||
HrTimeInZone3: nonZero(a.HrTimeInZone3),
|
||||
HrTimeInZone4: nonZero(a.HrTimeInZone4),
|
||||
HrTimeInZone5: nonZero(a.HrTimeInZone5),
|
||||
RawJSON: string(a.Raw),
|
||||
}
|
||||
}
|
||||
|
||||
// nonZero returns nil for a zero value so store columns stay NULL instead of
|
||||
// a misleading 0 when Garmin simply didn't report that field.
|
||||
func nonZero(v float64) *float64 {
|
||||
if v == 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
}
|
||||
|
||||
// toLapRows converts garmin lap DTOs into store rows, computing each lap's
|
||||
// HR drift (active laps) or recovery rate (rest laps) from the samples that
|
||||
// fall within that lap's time window. Lap boundaries are derived from
|
||||
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps
|
||||
// are contiguous and this sidesteps timezone parsing entirely.
|
||||
func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap {
|
||||
rows := make([]store.Lap, 0, len(laps))
|
||||
var elapsedStart float64
|
||||
for _, l := range laps {
|
||||
elapsedEnd := elapsedStart + l.ElapsedDuration
|
||||
|
||||
var driftPtr, recoveryPtr *float64
|
||||
lapSamples := samplesInWindow(samples, elapsedStart, elapsedEnd)
|
||||
switch l.IntensityType {
|
||||
case "ACTIVE":
|
||||
if v, ok := classify.HRDrift(lapSamples); ok {
|
||||
driftPtr = &v
|
||||
}
|
||||
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
|
||||
if v, ok := classify.HRRecovery(lapSamples); ok {
|
||||
recoveryPtr = &v
|
||||
}
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(l)
|
||||
rows = append(rows, store.Lap{
|
||||
LapIndex: l.LapIndex,
|
||||
StartTimeUTC: l.StartTimeGMT,
|
||||
DurationSeconds: l.Duration,
|
||||
DistanceMeters: l.Distance,
|
||||
AvgHR: nonZero(l.AverageHR),
|
||||
MaxHR: nonZero(l.MaxHR),
|
||||
AvgSpeedMps: nonZero(l.AverageSpeed),
|
||||
MaxSpeedMps: nonZero(l.MaxSpeed),
|
||||
ElevationGainM: nonZero(l.ElevationGain),
|
||||
ElevationLossM: nonZero(l.ElevationLoss),
|
||||
IntensityType: l.IntensityType,
|
||||
HRDriftBpmPerMin: driftPtr,
|
||||
HRRecoveryBpmPerMin: recoveryPtr,
|
||||
RawJSON: string(raw),
|
||||
})
|
||||
elapsedStart = elapsedEnd
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||
var out []classify.SampleInfo
|
||||
for _, s := range samples {
|
||||
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||
continue
|
||||
}
|
||||
out = append(out, classify.SampleInfo{ElapsedSeconds: s.ElapsedSeconds, HeartRate: s.HeartRate})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
||||
rows := make([]store.Sample, len(samples))
|
||||
for i, s := range samples {
|
||||
rows[i] = store.Sample{
|
||||
ElapsedSeconds: s.ElapsedSeconds,
|
||||
TimestampMs: s.TimestampMS,
|
||||
HeartRate: s.HeartRate,
|
||||
SpeedMps: s.SpeedMps,
|
||||
DistanceM: s.DistanceM,
|
||||
ElevationM: s.ElevationM,
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// buildMetricContext computes the classify.MetricContext for one activity
|
||||
// from its stored summary and laps, ready to evaluate against workout kind
|
||||
// rules. maxHR is the user's configured max heart rate, used only to derive
|
||||
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
||||
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
||||
ctx := classify.MetricContext{
|
||||
"duration_seconds": a.DurationSeconds,
|
||||
"distance_meters": a.DistanceMeters,
|
||||
}
|
||||
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
||||
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
||||
}
|
||||
if a.AvgHR != nil {
|
||||
ctx["avg_hr"] = *a.AvgHR
|
||||
if maxHR > 0 {
|
||||
ctx["avg_hr_pct_max"] = *a.AvgHR / maxHR
|
||||
}
|
||||
}
|
||||
if a.MaxHR != nil {
|
||||
ctx["max_hr"] = *a.MaxHR
|
||||
}
|
||||
if a.ElevationGainM != nil {
|
||||
ctx["elevation_gain_m"] = *a.ElevationGainM
|
||||
}
|
||||
if a.AerobicTrainingEffect != nil {
|
||||
ctx["aerobic_training_effect"] = *a.AerobicTrainingEffect
|
||||
}
|
||||
if a.AnaerobicTrainingEffect != nil {
|
||||
ctx["anaerobic_training_effect"] = *a.AnaerobicTrainingEffect
|
||||
}
|
||||
if a.VO2MaxValue != nil {
|
||||
ctx["vo2max_value"] = *a.VO2MaxValue
|
||||
}
|
||||
|
||||
lapInfos := make([]classify.LapInfo, len(laps))
|
||||
var paces []float64
|
||||
var maxDrift, maxRecovery float64
|
||||
haveDrift, haveRecovery := false, false
|
||||
for i, l := range laps {
|
||||
lapInfos[i] = classify.LapInfo{IntensityType: l.IntensityType}
|
||||
if l.AvgSpeedMps != nil && *l.AvgSpeedMps > 0 {
|
||||
paces = append(paces, 1000 / *l.AvgSpeedMps)
|
||||
}
|
||||
if l.HRDriftBpmPerMin != nil && (!haveDrift || *l.HRDriftBpmPerMin > maxDrift) {
|
||||
maxDrift, haveDrift = *l.HRDriftBpmPerMin, true
|
||||
}
|
||||
if l.HRRecoveryBpmPerMin != nil && (!haveRecovery || *l.HRRecoveryBpmPerMin > maxRecovery) {
|
||||
maxRecovery, haveRecovery = *l.HRRecoveryBpmPerMin, true
|
||||
}
|
||||
}
|
||||
if len(laps) > 0 {
|
||||
if classify.DetectIntervalPattern(lapInfos) {
|
||||
ctx["lap_interval_pattern"] = 1
|
||||
} else {
|
||||
ctx["lap_interval_pattern"] = 0
|
||||
}
|
||||
}
|
||||
if len(paces) > 0 {
|
||||
ctx["lap_pace_stddev"] = classify.LapPaceStdDev(paces)
|
||||
}
|
||||
if haveDrift {
|
||||
ctx["lap_hr_drift_bpm_per_min"] = maxDrift
|
||||
}
|
||||
if haveRecovery {
|
||||
ctx["lap_hr_recovery_bpm_per_min"] = maxRecovery
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func loadRuleKinds(kinds []store.WorkoutKind) ([]classify.RuleKind, error) {
|
||||
rules := make([]classify.RuleKind, 0, len(kinds))
|
||||
for _, k := range kinds {
|
||||
var node classify.Node
|
||||
if err := json.Unmarshal([]byte(k.RuleJSON), &node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules = append(rules, classify.RuleKind{WorkoutKindID: k.ID, Name: k.Name, Rule: node})
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func dateStr(t time.Time) string { return t.Format("2006-01-02") }
|
||||
317
backend/internal/sync/service.go
Normal file
317
backend/internal/sync/service.go
Normal file
@@ -0,0 +1,317 @@
|
||||
// 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
|
||||
// MaxHR is used only to derive avg_hr_pct_max for the rule engine.
|
||||
MaxHR 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
ctxMetrics := buildMetricContext(activity, laps, s.cfg.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
|
||||
}
|
||||
284
backend/internal/sync/service_test.go
Normal file
284
backend/internal/sync/service_test.go
Normal file
@@ -0,0 +1,284 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"smartrun/backend/internal/classify"
|
||||
"smartrun/backend/internal/garmin"
|
||||
"smartrun/backend/internal/garmin/mock"
|
||||
"smartrun/backend/internal/store"
|
||||
)
|
||||
|
||||
func f(v float64) *float64 { return &v }
|
||||
|
||||
func openTestDB(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixedNow(t time.Time) func() time.Time {
|
||||
return func() time.Time { return t }
|
||||
}
|
||||
|
||||
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||
}}
|
||||
svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
if err != nil {
|
||||
t.Fatalf("ListActivities: %v", err)
|
||||
}
|
||||
if len(activities) != 1 {
|
||||
t.Fatalf("expected 1 stored activity, got %d", len(activities))
|
||||
}
|
||||
if activities[0].GarminActivityID != 1 {
|
||||
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
||||
}
|
||||
|
||||
runs, err := db.ListSyncRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSyncRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
|
||||
t.Fatalf("expected 1 successful sync run, got %+v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const garminActivityID = 42
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{
|
||||
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||
},
|
||||
Splits: map[int64]garmin.ActivitySplits{
|
||||
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
||||
}},
|
||||
},
|
||||
Details: map[int64]garmin.ActivityDetails{
|
||||
garminActivityID: {
|
||||
ActivityID: garminActivityID,
|
||||
MetricDescriptors: []garmin.MetricDescriptor{
|
||||
{Key: "directHeartRate", MetricsIndex: 0},
|
||||
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
|
||||
// A workout kind that should cleanly match the seeded activity's pace.
|
||||
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
||||
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
||||
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.FillPendingDetails(ctx, 10); err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
|
||||
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||
if err != nil || len(activities) != 1 {
|
||||
t.Fatalf("ListActivities: %v, %+v", err, activities)
|
||||
}
|
||||
activityID := activities[0].ID
|
||||
|
||||
if activities[0].DetailsFetchedAt == nil {
|
||||
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
|
||||
}
|
||||
if activities[0].SplitsFetchedAt == nil {
|
||||
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
||||
}
|
||||
|
||||
laps, err := db.LapsForActivity(ctx, activityID)
|
||||
if err != nil || len(laps) != 1 {
|
||||
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||
}
|
||||
|
||||
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if assignment.Status != classify.StatusAssigned {
|
||||
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
||||
activity := store.Activity{
|
||||
DurationSeconds: 1800,
|
||||
DistanceMeters: 6000,
|
||||
AvgSpeedMps: f(3.33),
|
||||
AvgHR: f(152),
|
||||
AerobicTrainingEffect: f(3.2),
|
||||
}
|
||||
laps := []store.Lap{
|
||||
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
|
||||
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
|
||||
}
|
||||
|
||||
ctx := buildMetricContext(activity, laps, 190)
|
||||
|
||||
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
|
||||
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
|
||||
}
|
||||
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
|
||||
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
|
||||
}
|
||||
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
|
||||
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
|
||||
}
|
||||
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
|
||||
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
|
||||
}
|
||||
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
|
||||
// structured interval workout.
|
||||
if got := ctx["lap_interval_pattern"]; got != 0 {
|
||||
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("first Backfill: %v", err)
|
||||
}
|
||||
firstCallCount := m.GetActivitiesCalls
|
||||
if firstCallCount == 0 {
|
||||
t.Fatal("expected first backfill to call GetActivities at least once")
|
||||
}
|
||||
|
||||
state, err := db.GetSyncState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSyncState: %v", err)
|
||||
}
|
||||
if !state.BackfillComplete {
|
||||
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
|
||||
}
|
||||
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("second Backfill: %v", err)
|
||||
}
|
||||
if m.GetActivitiesCalls != firstCallCount {
|
||||
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
|
||||
m.GetActivitiesCalls-firstCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &mock.Client{Activities: []garmin.Activity{
|
||||
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||
}}
|
||||
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10}, now)
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("first Backfill: %v", err)
|
||||
}
|
||||
firstCallCount := m.GetActivitiesCalls
|
||||
|
||||
// Simulate the user widening the horizon later -- should resume from the
|
||||
// watermark (not re-fetch the already-covered recent window) but still
|
||||
// make progress toward the new, deeper horizon.
|
||||
svc2 := NewService(m, db, Config{BackfillHorizonDays: 30, BackfillWindowDays: 10}, now)
|
||||
if err := svc2.Backfill(ctx); err != nil {
|
||||
t.Fatalf("second Backfill: %v", err)
|
||||
}
|
||||
if m.GetActivitiesCalls <= firstCallCount {
|
||||
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
||||
}
|
||||
|
||||
state, err := db.GetSyncState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSyncState: %v", err)
|
||||
}
|
||||
if !state.BackfillComplete {
|
||||
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
m := &mock.Client{
|
||||
Activities: []garmin.Activity{},
|
||||
Splits: map[int64]garmin.ActivitySplits{},
|
||||
Details: map[int64]garmin.ActivityDetails{},
|
||||
}
|
||||
const n = 3
|
||||
for i := int64(1); i <= n; i++ {
|
||||
m.Activities = append(m.Activities, garmin.Activity{
|
||||
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
||||
})
|
||||
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
||||
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
||||
}
|
||||
|
||||
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
|
||||
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||
if err := svc.Backfill(ctx); err != nil {
|
||||
t.Fatalf("Backfill: %v", err)
|
||||
}
|
||||
|
||||
if p := svc.Progress(); p.Total != 0 {
|
||||
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- svc.FillPendingDetails(ctx, n) }()
|
||||
|
||||
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
|
||||
mid := svc.Progress()
|
||||
if mid.Total != n {
|
||||
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
|
||||
}
|
||||
if mid.Done <= 0 || mid.Done >= n {
|
||||
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
|
||||
}
|
||||
|
||||
if err := <-done; err != nil {
|
||||
t.Fatalf("FillPendingDetails: %v", err)
|
||||
}
|
||||
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
|
||||
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user