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:
2026-07-17 18:33:06 +02:00
commit f689f74ae0
69 changed files with 10666 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
package api
import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/store"
)
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
filter := store.ActivityFilter{
FromDate: q.Get("from"),
ToDate: q.Get("to"),
}
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
filter.Limit = limit
}
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
filter.Offset = offset
}
activities, err := s.DB.ListActivities(r.Context(), filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, activities)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
activity, ok, err := s.DB.GetActivity(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity not found")
return
}
laps, err := s.DB.LapsForActivity(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp := map[string]any{"activity": activity, "laps": laps}
if hasAssignment {
resp["assignment"] = assignment
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,187 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"time"
"smartrun/backend/internal/garmin/mock"
"smartrun/backend/internal/store"
appsync "smartrun/backend/internal/sync"
)
func newCtx() context.Context { return context.Background() }
func newTestServer(t *testing.T) (*Server, *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() })
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
return NewServer(db, m, svc), db
}
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request body: %v", err)
}
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
return rec
}
func TestHealth(t *testing.T) {
s, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestWorkoutKindCRUD(t *testing.T) {
s, _ := newTestServer(t)
router := s.Router()
createBody := map[string]any{
"name": "Tempo",
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
}
rec := doJSON(t, router, http.MethodPost, "/api/workout-kinds/", createBody)
if rec.Code != http.StatusCreated {
t.Fatalf("create status = %d, body = %s", rec.Code, rec.Body.String())
}
var created store.WorkoutKind
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("unmarshal created kind: %v", err)
}
if created.ID == 0 {
t.Fatal("expected non-zero id")
}
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code)
}
var kinds []store.WorkoutKind
json.Unmarshal(rec.Body.Bytes(), &kinds)
if len(kinds) != 1 {
t.Fatalf("expected 1 kind, got %d", len(kinds))
}
rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d", rec.Code)
}
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
json.Unmarshal(rec.Body.Bytes(), &kinds)
if len(kinds) != 0 {
t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds))
}
}
func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) {
s, _ := newTestServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{
"name": "Bad",
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
})
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
}
func TestReviewQueueResolve(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("review queue status = %d", rec.Code)
}
var queue []map[string]any
json.Unmarshal(rec.Body.Bytes(), &queue)
if len(queue) != 1 {
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID})
if rec.Code != http.StatusOK {
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
json.Unmarshal(rec.Body.Bytes(), &queue)
if len(queue) != 0 {
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
}
}
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
s, db := newTestServer(t)
ctx := newCtx()
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
speed := 3.0
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
for _, id := range []int64{a2, a1} { // insert out of order on purpose
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var points []progressionPoint
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(points) != 2 {
t.Fatalf("expected 2 points, got %d", len(points))
}
if points[0].Date > points[1].Date {
t.Errorf("points not sorted ascending by date: %+v", points)
}
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}

View File

@@ -0,0 +1,72 @@
package api
import (
"encoding/json"
"net/http"
"smartrun/backend/internal/garmin"
)
type authResponse struct {
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
Message string `json:"message"`
}
func authStatusString(s garmin.AuthStatus) string {
switch s {
case garmin.AuthSuccess:
return "authenticated"
case garmin.AuthMFARequired:
return "mfa_required"
case garmin.AuthFailed:
return "failed"
default:
return "unknown"
}
}
func (s *Server) recordAuthResult(res garmin.AuthResult) {
s.mu.Lock()
s.authStatus = res.Status
s.authMessage = res.Message
s.mu.Unlock()
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
res, err := s.Garmin.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
res, err := s.Garmin.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
status, msg := s.authStatus, s.authMessage
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}

View File

@@ -0,0 +1,192 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/classify"
"smartrun/backend/internal/store"
)
type workoutKindRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
Rule json.RawMessage `json:"rule"`
Priority int `json:"priority"`
IsActive *bool `json:"is_active"`
}
func (req workoutKindRequest) validate() (classify.Node, error) {
var node classify.Node
if req.Name == "" {
return node, errors.New("name is required")
}
if err := json.Unmarshal(req.Rule, &node); err != nil {
return node, errors.New("rule is not valid JSON: " + err.Error())
}
if err := node.Validate(); err != nil {
return node, errors.New("invalid rule: " + err.Error())
}
return node, nil
}
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
activeOnly := r.URL.Query().Get("include_inactive") != "true"
kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, kinds)
}
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "workout kind not found")
return
}
writeJSON(w, http.StatusOK, kind)
}
func (s *Server) handleCreateWorkoutKind(w http.ResponseWriter, r *http.Request) {
var req workoutKindRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if _, err := req.validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
isActive := true
if req.IsActive != nil {
isActive = *req.IsActive
}
id, err := s.DB.CreateWorkoutKind(r.Context(), store.WorkoutKind{
Name: req.Name, Description: req.Description, Color: req.Color,
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
})
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
writeJSON(w, http.StatusCreated, kind)
}
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "workout kind not found")
return
}
var req workoutKindRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if _, err := req.validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
isActive := existing.IsActive
if req.IsActive != nil {
isActive = *req.IsActive
}
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
writeJSON(w, http.StatusOK, kind)
}
func (s *Server) handleDeleteWorkoutKind(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleReclassifyKind re-runs the rule engine for every activity currently
// assigned to (or under review for) this kind. It's a synchronous, bounded
// operation (unlike sync), so it runs inline rather than in the background.
func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
assignments, err := s.DB.AssignmentsForKind(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
reviewQueue, err := s.DB.ReviewQueue(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
seen := make(map[int64]bool)
var activityIDs []int64
for _, a := range assignments {
if !seen[a.ActivityID] {
seen[a.ActivityID] = true
activityIDs = append(activityIDs, a.ActivityID)
}
}
for _, a := range reviewQueue {
if !seen[a.ActivityID] {
seen[a.ActivityID] = true
activityIDs = append(activityIDs, a.ActivityID)
}
}
for _, activityID := range activityIDs {
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
}

View File

@@ -0,0 +1,96 @@
package api
import (
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/store"
)
type progressionPoint struct {
Date string `json:"date"`
ActivityID int64 `json:"activity_id"`
Value float64 `json:"value"`
}
func metricValue(metric string, a store.Activity) (float64, bool) {
switch metric {
case "pace":
if a.AvgSpeedMps == nil || *a.AvgSpeedMps <= 0 {
return 0, false
}
return 1000 / *a.AvgSpeedMps, true
case "hr":
if a.AvgHR == nil {
return 0, false
}
return *a.AvgHR, true
case "vo2max":
if a.VO2MaxValue == nil {
return 0, false
}
return *a.VO2MaxValue, true
case "aerobic_te":
if a.AerobicTrainingEffect == nil {
return 0, false
}
return *a.AerobicTrainingEffect, true
case "anaerobic_te":
if a.AnaerobicTrainingEffect == nil {
return 0, false
}
return *a.AnaerobicTrainingEffect, true
default:
return 0, false
}
}
// handleProgression returns a time series of the requested metric for every
// activity currently assigned to a workout kind, for progression charts.
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid workout kind id")
return
}
metric := r.URL.Query().Get("metric")
if metric == "" {
metric = "pace"
}
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
points := []progressionPoint{}
for _, a := range assignments {
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
continue
}
if from != "" && activity.StartTimeUTC < from {
continue
}
if to != "" && activity.StartTimeUTC > to+" 23:59:59" {
continue
}
value, ok := metricValue(metric, activity)
if !ok {
continue
}
points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value})
}
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
writeJSON(w, http.StatusOK, points)
}

View File

@@ -0,0 +1,78 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/store"
)
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
queue, err := s.DB.ReviewQueue(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type item struct {
store.KindAssignment
Activity store.Activity `json:"activity"`
}
items := make([]item, 0, len(queue))
for _, a := range queue {
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
continue
}
items = append(items, item{KindAssignment: a, Activity: activity})
}
writeJSON(w, http.StatusOK, items)
}
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
var body struct {
WorkoutKindID int64 `json:"workout_kind_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.WorkoutKindID == 0 {
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
return
}
if _, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}

View File

@@ -0,0 +1,137 @@
// Package api is smartrun's HTTP layer: REST handlers over internal/store,
// internal/garmin, and internal/sync.
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"smartrun/backend/internal/garmin"
"smartrun/backend/internal/store"
appsync "smartrun/backend/internal/sync"
)
// Server wires the HTTP handlers to the app's dependencies.
type Server struct {
DB *store.DB
Garmin garmin.Client
Sync *appsync.Service
mu sync.Mutex
authStatus garmin.AuthStatus
authMessage string
syncRunning bool
}
// NewServer builds a Server.
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service) *Server {
return &Server{DB: db, Garmin: g, Sync: s}
}
// Router builds the HTTP routes.
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth)
r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin)
r.Post("/mfa", s.handleAuthMFA)
r.Get("/status", s.handleAuthStatus)
})
r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun)
r.Post("/backfill", s.handleSyncBackfill)
r.Get("/runs", s.handleSyncRuns)
r.Get("/status", s.handleSyncStatus)
})
r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities)
r.Get("/{id}", s.handleGetActivity)
})
r.Route("/workout-kinds", func(r chi.Router) {
r.Get("/", s.handleListWorkoutKinds)
r.Post("/", s.handleCreateWorkoutKind)
r.Get("/{id}", s.handleGetWorkoutKind)
r.Put("/{id}", s.handleUpdateWorkoutKind)
r.Delete("/{id}", s.handleDeleteWorkoutKind)
r.Post("/{id}/reclassify", s.handleReclassifyKind)
})
r.Route("/review-queue", func(r chi.Router) {
r.Get("/", s.handleReviewQueue)
r.Post("/{activityID}/resolve", s.handleResolveReview)
})
r.Get("/progression/{kindID}", s.handleProgression)
})
return r
}
// corsMiddleware allows the frontend dev server (a different port) to call
// this API. Single-user local app, so reflecting any origin is fine --
// there's no session/cookie auth to protect against CSRF.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("api: encode response: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
// only one sync operation runs at a time. Returns false if one is already
// in progress.
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
s.mu.Lock()
if s.syncRunning {
s.mu.Unlock()
return false
}
s.syncRunning = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.syncRunning = false
s.mu.Unlock()
}()
if err := fn(context.Background()); err != nil {
log.Printf("api: background sync error: %v", err)
}
}()
return true
}

View File

@@ -0,0 +1,77 @@
package api
import (
"context"
"net/http"
)
// detailFillBatchSize bounds how many activities' details/splits are fetched
// per sync trigger, matching the sequential rate-limited fetch in
// internal/sync.Service.FillPendingDetails.
const detailFillBatchSize = 50
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
if err := s.Sync.IncrementalSync(ctx); err != nil {
return err
}
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleSyncBackfill(w http.ResponseWriter, r *http.Request) {
ok := s.backgroundSync(func(ctx context.Context) error {
if err := s.Sync.Backfill(ctx); err != nil {
return err
}
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
run, ok, err := s.DB.LatestSyncRun(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.syncRunning
s.mu.Unlock()
progress := s.Sync.Progress()
resp := map[string]any{
"in_progress": inProgress,
"detail_fill_progress": progress,
"activities_pending_details": remaining,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,63 @@
package classify
import "sort"
const (
StatusAssigned = "assigned"
StatusNeedsReview = "needs_review"
// DefaultMinConfidence is the score below which even a single matching
// kind is sent to manual review rather than auto-assigned.
DefaultMinConfidence = 0.6
)
// RuleKind is one active workout kind's rule, as loaded from the store.
type RuleKind struct {
WorkoutKindID int64
Name string
Rule Node
}
// ScoredKind is one kind that matched an activity's metrics, with its
// confidence score.
type ScoredKind struct {
WorkoutKindID int64 `json:"workout_kind_id"`
Name string `json:"name"`
Score float64 `json:"score"`
}
// Result is the outcome of classifying one activity.
type Result struct {
Status string
WorkoutKindID *int64
Confidence *float64
Candidates []ScoredKind // every kind that matched, sorted by score descending
}
// Classify evaluates every active kind's rule against ctx and decides
// whether the activity is cleanly assignable, or needs manual review
// because zero kinds matched, multiple kinds matched, or the single match's
// confidence fell below minConfidence.
func Classify(ctx MetricContext, kinds []RuleKind, minConfidence float64) Result {
candidates := []ScoredKind{}
for _, k := range kinds {
matched, score := k.Rule.Evaluate(ctx)
if matched {
candidates = append(candidates, ScoredKind{WorkoutKindID: k.WorkoutKindID, Name: k.Name, Score: score})
}
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Score > candidates[j].Score })
if len(candidates) != 1 {
return Result{Status: StatusNeedsReview, Candidates: candidates}
}
only := candidates[0]
if only.Score < minConfidence {
return Result{Status: StatusNeedsReview, Candidates: candidates}
}
id := only.WorkoutKindID
score := only.Score
return Result{Status: StatusAssigned, WorkoutKindID: &id, Confidence: &score, Candidates: candidates}
}

View File

@@ -0,0 +1,190 @@
package classify
import "testing"
func easyRule() Node {
return Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{330.0, 420.0}},
{Metric: "avg_hr_pct_max", Op: OpLte, Value: 0.75},
}}
}
func tempoRule() Node {
return Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 330.0}},
{Metric: "avg_hr_pct_max", Op: OpGte, Value: 0.80},
}}
}
func testKinds() []RuleKind {
return []RuleKind{
{WorkoutKindID: 1, Name: "Easy", Rule: easyRule()},
{WorkoutKindID: 2, Name: "Tempo", Rule: tempoRule()},
}
}
func TestClassify_CleanSingleMatch(t *testing.T) {
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusAssigned {
t.Fatalf("Status = %q, want %q", result.Status, StatusAssigned)
}
if result.WorkoutKindID == nil || *result.WorkoutKindID != 1 {
t.Fatalf("WorkoutKindID = %v, want 1 (Easy)", result.WorkoutKindID)
}
if result.Confidence == nil || *result.Confidence < DefaultMinConfidence {
t.Fatalf("Confidence = %v, want >= %v", result.Confidence, DefaultMinConfidence)
}
}
func TestClassify_AmbiguousMultiMatch(t *testing.T) {
// Overlapping rule zone: a kind covering the same pace range as both
// Easy and Tempo, so a run in the overlap matches two kinds at once.
overlap := RuleKind{WorkoutKindID: 3, Name: "Overlap", Rule: Node{
Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{300.0, 400.0}},
},
}}
kinds := append(testKinds(), overlap)
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
result := Classify(ctx, kinds, DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
if len(result.Candidates) < 2 {
t.Fatalf("expected >= 2 candidates for ambiguous match, got %d: %+v", len(result.Candidates), result.Candidates)
}
if result.WorkoutKindID != nil {
t.Fatalf("WorkoutKindID should be nil when needs_review, got %v", *result.WorkoutKindID)
}
}
func TestClassify_NoMatch(t *testing.T) {
// A very slow, low-HR run that fits neither Easy nor Tempo's pace range.
ctx := MetricContext{"avg_pace_sec_per_km": 600, "avg_hr_pct_max": 0.55}
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
if len(result.Candidates) != 0 {
t.Fatalf("expected 0 candidates for no match, got %d: %+v", len(result.Candidates), result.Candidates)
}
}
func TestClassify_LowConfidenceSingleMatchNeedsReview(t *testing.T) {
// Right at the edge of Easy's pace window and right at the HR ceiling --
// technically matches but only barely, so confidence should be low.
ctx := MetricContext{"avg_pace_sec_per_km": 419, "avg_hr_pct_max": 0.75}
result := Classify(ctx, testKinds(), 0.9) // deliberately strict threshold
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q (low confidence should force review)", result.Status, StatusNeedsReview)
}
if len(result.Candidates) != 1 {
t.Fatalf("expected exactly 1 (low-confidence) candidate, got %d", len(result.Candidates))
}
}
func TestClassify_MissingMetricDoesNotMatch(t *testing.T) {
ctx := MetricContext{"avg_pace_sec_per_km": 375} // avg_hr_pct_max absent
result := Classify(ctx, testKinds(), DefaultMinConfidence)
if result.Status != StatusNeedsReview {
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
}
}
func TestDetectIntervalPattern(t *testing.T) {
cases := []struct {
name string
laps []LapInfo
want bool
}{
{
name: "clear interval workout: alternating active/rest",
laps: []LapInfo{
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
},
want: true,
},
{
name: "long run with a single hill lap should not look like intervals",
laps: []LapInfo{
{IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"},
},
want: false,
},
{
name: "warmup + single effort + cooldown is not repeated intervals",
laps: []LapInfo{
{IntensityType: "WARMUP"}, {IntensityType: "ACTIVE"}, {IntensityType: "COOLDOWN"},
},
want: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := DetectIntervalPattern(c.laps)
if got != c.want {
t.Errorf("DetectIntervalPattern() = %v, want %v", got, c.want)
}
})
}
}
func hr(v float64) *float64 { return &v }
func TestHRDrift_RisingHeartRateDetected(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 10), HeartRate: hr(140 + float64(i))})
}
drift, ok := HRDrift(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if drift <= 0 {
t.Errorf("drift = %v, want positive (rising HR)", drift)
}
}
func TestHRDrift_TooFewSamplesIsNotOk(t *testing.T) {
samples := []SampleInfo{{ElapsedSeconds: 0, HeartRate: hr(140)}, {ElapsedSeconds: 10, HeartRate: hr(142)}}
_, ok := HRDrift(samples)
if ok {
t.Error("expected ok=false with too few samples")
}
}
func TestHRRecovery_FallingHeartRateIsPositiveRecoveryRate(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(170 - float64(i)*2)})
}
recovery, ok := HRRecovery(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if recovery <= 0 {
t.Errorf("recovery = %v, want positive (HR dropping)", recovery)
}
}
func TestHRRecovery_StillRisingIsNegative(t *testing.T) {
var samples []SampleInfo
for i := 0; i < 20; i++ {
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(120 + float64(i))})
}
recovery, ok := HRRecovery(samples)
if !ok {
t.Fatal("expected ok=true with enough samples")
}
if recovery >= 0 {
t.Errorf("recovery = %v, want negative (HR still rising during recovery lap)", recovery)
}
}

View File

@@ -0,0 +1,125 @@
package classify
import (
"math"
"strings"
)
// LapInfo is the subset of a lap's fields needed for interval-pattern
// detection, independent of internal/store's row representation.
type LapInfo struct {
IntensityType string
}
// DetectIntervalPattern reports whether an activity's laps look like a
// structured interval workout, using Garmin's own per-lap IntensityType
// tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it
// from pace variance -- the device/app already knows which laps were work
// vs rest segments when the activity was recorded as a structured workout.
func DetectIntervalPattern(laps []LapInfo) bool {
active, rest := 0, 0
for _, l := range laps {
switch strings.ToUpper(l.IntensityType) {
case "ACTIVE":
active++
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
rest++
}
}
return active >= 2 && rest >= 2
}
// LapPaceStdDev returns the standard deviation of per-lap pace (any
// consistent unit, e.g. sec/km), a fallback signal for "uneven pacing" on
// activities without formal interval tagging.
func LapPaceStdDev(paces []float64) float64 {
n := float64(len(paces))
if n == 0 {
return 0
}
var sum float64
for _, p := range paces {
sum += p
}
mean := sum / n
var variance float64
for _, p := range paces {
variance += (p - mean) * (p - mean)
}
return math.Sqrt(variance / n)
}
// SampleInfo is one HR-bearing telemetry sample within a lap's time window.
type SampleInfo struct {
ElapsedSeconds float64
HeartRate *float64
}
// minSamplesForTrend is the fewest HR readings needed before a drift/recovery
// slope is considered meaningful rather than noise.
const minSamplesForTrend = 10
// HRTrend fits a line to heart rate vs elapsed time across samples and
// returns its slope in bpm/minute. Returns ok=false if there aren't enough
// HR readings to trust the result.
func HRTrend(samples []SampleInfo) (bpmPerMin float64, ok bool) {
var xs, ys []float64
for _, s := range samples {
if s.HeartRate == nil {
continue
}
xs = append(xs, s.ElapsedSeconds)
ys = append(ys, *s.HeartRate)
}
if len(xs) < minSamplesForTrend {
return 0, false
}
slope, ok := linregSlope(xs, ys)
if !ok {
return 0, false
}
return slope * 60, true
}
// HRDrift is HRTrend applied to an active/effort lap's samples: a positive
// result means heart rate is climbing over the interval (cardiac drift) for
// a comparable effort level.
func HRDrift(samples []SampleInfo) (bpmPerMin float64, ok bool) {
return HRTrend(samples)
}
// HRRecovery is HRTrend applied to a recovery/rest lap's samples, sign-
// flipped so a positive result means heart rate is dropping (higher =
// better recovery) and a negative result flags heart rate still rising
// during what was supposed to be a rest interval.
func HRRecovery(samples []SampleInfo) (bpmDropPerMin float64, ok bool) {
slope, ok := HRTrend(samples)
if !ok {
return 0, false
}
return -slope, true
}
func linregSlope(xs, ys []float64) (float64, bool) {
n := float64(len(xs))
if n < 2 {
return 0, false
}
var sumX, sumY float64
for i := range xs {
sumX += xs[i]
sumY += ys[i]
}
xbar, ybar := sumX/n, sumY/n
var num, den float64
for i := range xs {
dx := xs[i] - xbar
num += dx * (ys[i] - ybar)
den += dx * dx
}
if den == 0 {
return 0, false
}
return num / den, true
}

View File

@@ -0,0 +1,220 @@
// Package classify is smartrun's classification rule engine: it evaluates a
// user-editable AND/OR condition tree against a run's metrics to decide
// which "workout kind" (Easy, Tempo, Threshold, ...) it belongs to. Pure
// logic only -- no I/O, no database, no Garmin client -- so it's fully
// unit-testable against fixture data.
package classify
import (
"fmt"
"math"
)
// Node is one node of a workout kind's rule condition tree. A branch node
// sets Match ("all" or "any") and Conditions (children); a leaf node sets
// Metric, Op, and Value instead.
type Node struct {
Match string `json:"match,omitempty"`
Conditions []Node `json:"conditions,omitempty"`
Metric string `json:"metric,omitempty"`
Op string `json:"op,omitempty"`
Value any `json:"value,omitempty"`
}
const (
MatchAll = "all"
MatchAny = "any"
OpEq = "=="
OpNeq = "!="
OpGt = ">"
OpGte = ">="
OpLt = "<"
OpLte = "<="
OpBetween = "between"
)
// Validate reports whether a rule tree is well-formed, without needing a
// MetricContext to evaluate against. Intended for the API layer to give
// immediate feedback when a user edits a workout kind's rule.
func (n Node) Validate() error {
if n.Match != "" {
if n.Match != MatchAll && n.Match != MatchAny {
return fmt.Errorf("invalid match %q, want %q or %q", n.Match, MatchAll, MatchAny)
}
if len(n.Conditions) == 0 {
return fmt.Errorf("branch node %q has no conditions", n.Match)
}
for i, c := range n.Conditions {
if err := c.Validate(); err != nil {
return fmt.Errorf("condition %d: %w", i, err)
}
}
return nil
}
if n.Metric == "" {
return fmt.Errorf("leaf node missing metric")
}
switch n.Op {
case OpEq, OpNeq, OpGt, OpGte, OpLt, OpLte:
if n.Value == nil {
return fmt.Errorf("metric %q: op %q requires a value", n.Metric, n.Op)
}
case OpBetween:
arr, ok := n.Value.([]any)
if !ok || len(arr) != 2 {
return fmt.Errorf("metric %q: op %q requires a 2-element array value", n.Metric, n.Op)
}
default:
return fmt.Errorf("metric %q: unsupported op %q", n.Metric, n.Op)
}
return nil
}
// MetricContext is the set of computed metrics for one activity that a rule
// tree is evaluated against. Boolean metrics (e.g. has_interval_pattern) are
// represented as 1.0/0.0.
type MetricContext map[string]float64
// Evaluate recursively evaluates the tree against ctx, returning whether it
// matched and a confidence score. For branch nodes, "all" aggregates scores
// via min and requires every child matched; "any" aggregates via max and
// requires at least one child matched.
func (n Node) Evaluate(ctx MetricContext) (matched bool, score float64) {
if n.Match != "" {
switch n.Match {
case MatchAll:
matched = true
score = math.Inf(1)
for _, c := range n.Conditions {
m, s := c.Evaluate(ctx)
if !m {
matched = false
}
if s < score {
score = s
}
}
case MatchAny:
matched = false
score = math.Inf(-1)
for _, c := range n.Conditions {
m, s := c.Evaluate(ctx)
if m {
matched = true
}
if s > score {
score = s
}
}
default:
return false, 0
}
return matched, score
}
return evaluateLeaf(n, ctx)
}
func evaluateLeaf(n Node, ctx MetricContext) (matched bool, score float64) {
v, ok := ctx[n.Metric]
if !ok {
return false, 0
}
switch n.Op {
case OpEq, OpNeq:
want, ok := toFloat(n.Value)
if !ok {
return false, 0
}
eq := v == want
if n.Op == OpNeq {
eq = !eq
}
if eq {
return true, 1
}
return false, 0
case OpGt, OpGte, OpLt, OpLte:
threshold, ok := toFloat(n.Value)
if !ok {
return false, 0
}
var margin float64
switch n.Op {
case OpGt:
matched = v > threshold
margin = v - threshold
case OpGte:
matched = v >= threshold
margin = v - threshold
case OpLt:
matched = v < threshold
margin = threshold - v
case OpLte:
matched = v <= threshold
margin = threshold - v
}
return matched, squash(margin, scaleFor(threshold))
case OpBetween:
arr, ok := n.Value.([]any)
if !ok || len(arr) != 2 {
return false, 0
}
lo, ok1 := toFloat(arr[0])
hi, ok2 := toFloat(arr[1])
if !ok1 || !ok2 || lo > hi {
return false, 0
}
matched = v >= lo && v <= hi
mid := (lo + hi) / 2
halfRange := (hi - lo) / 2
if halfRange == 0 {
halfRange = 1
}
distance := math.Abs(v - mid)
score := 1 - distance/halfRange // 1.0 centered, 0 at boundary, negative outside
return matched, score
default:
return false, 0
}
}
func toFloat(v any) (float64, bool) {
switch t := v.(type) {
case float64:
return t, true
case bool:
if t {
return 1, true
}
return 0, true
case int:
return float64(t), true
default:
return 0, false
}
}
// scaleFor picks a margin-to-score scaling factor proportional to the
// threshold's magnitude, so e.g. a 30-second margin on a ~300s threshold
// scores similarly to a 3-minute margin on a ~1800s threshold.
func scaleFor(threshold float64) float64 {
abs := math.Abs(threshold)
if abs < 1e-9 {
return 1
}
return 5 / abs
}
// squash maps a signed margin to a 0..1 score via a logistic curve: 0 margin
// (exactly at the threshold) scores 0.5, comfortably-matched margins
// approach 1, comfortably-unmatched margins approach 0.
func squash(margin, scale float64) float64 {
return 1 / (1 + math.Exp(-margin*scale))
}

View File

@@ -0,0 +1,61 @@
package classify
import (
"encoding/json"
"testing"
)
func TestNode_ValidateAcceptsWellFormedTree(t *testing.T) {
n := Node{Match: MatchAll, Conditions: []Node{
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 300.0}},
{Match: MatchAny, Conditions: []Node{
{Metric: "aerobic_training_effect", Op: OpGte, Value: 3.5},
{Metric: "lap_interval_pattern", Op: OpEq, Value: true},
}},
}}
if err := n.Validate(); err != nil {
t.Errorf("Validate() = %v, want nil", err)
}
}
func TestNode_ValidateRejectsMalformed(t *testing.T) {
cases := []struct {
name string
n Node
}{
{"unknown match", Node{Match: "xor", Conditions: []Node{{Metric: "x", Op: OpGt, Value: 1.0}}}},
{"branch with no conditions", Node{Match: MatchAll}},
{"leaf with no metric", Node{Op: OpGt, Value: 1.0}},
{"between with non-array value", Node{Metric: "x", Op: OpBetween, Value: 5.0}},
{"between with wrong-length array", Node{Metric: "x", Op: OpBetween, Value: []any{1.0}}},
{"unsupported op", Node{Metric: "x", Op: "~=", Value: 1.0}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := c.n.Validate(); err == nil {
t.Error("Validate() = nil, want an error")
}
})
}
}
func TestNode_RoundTripsThroughJSON(t *testing.T) {
raw := `{
"match": "all",
"conditions": [
{"metric": "avg_pace_sec_per_km", "op": "between", "value": [270, 300]},
{"metric": "lap_interval_pattern", "op": "==", "value": true}
]
}`
var n Node
if err := json.Unmarshal([]byte(raw), &n); err != nil {
t.Fatalf("Unmarshal: %v", err)
}
if err := n.Validate(); err != nil {
t.Fatalf("Validate() after round-trip = %v", err)
}
matched, _ := n.Evaluate(MetricContext{"avg_pace_sec_per_km": 285, "lap_interval_pattern": 1})
if !matched {
t.Error("expected match after JSON round-trip")
}
}

View File

@@ -0,0 +1,97 @@
// Package config loads smartrund's runtime configuration from environment
// variables (a single-user local app has no need for a config file/flags
// beyond this).
package config
import (
"fmt"
"os"
"strconv"
"time"
)
// Config holds everything smartrund needs to start.
type Config struct {
// Addr is the HTTP listen address, e.g. ":8080".
Addr string
// DBPath is the SQLite database file path.
DBPath string
// GarminPythonPath is mcp-garmin's venv python executable.
GarminPythonPath string
// GarminServerPath is mcp-garmin's server.py.
GarminServerPath string
GarminEmail string
GarminPassword string
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
// session cache location.
GarminTokenStore string
MaxHR float64
MinConfidence float64
BackfillHorizonDays int
IncrementalSyncEvery time.Duration
}
// Load reads configuration from environment variables, applying defaults
// for anything optional. Returns an error if a required variable is unset.
func Load() (Config, error) {
cfg := Config{
Addr: getEnvDefault("SMARTRUN_ADDR", ":8080"),
DBPath: getEnvDefault("SMARTRUN_DB_PATH", "smartrun.db"),
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
GarminEmail: os.Getenv("GARMIN_EMAIL"),
GarminPassword: os.Getenv("GARMIN_PASSWORD"),
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
MaxHR: getEnvFloat("SMARTRUN_MAX_HR", 190),
MinConfidence: getEnvFloat("SMARTRUN_MIN_CONFIDENCE", 0.6),
BackfillHorizonDays: getEnvInt("SMARTRUN_BACKFILL_HORIZON_DAYS", 3*365),
IncrementalSyncEvery: getEnvDuration("SMARTRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
}
if cfg.GarminPythonPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
}
if cfg.GarminServerPath == "" {
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
}
if cfg.GarminEmail == "" || cfg.GarminPassword == "" {
return cfg, fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD are required")
}
return cfg, nil
}
func getEnvDefault(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func getEnvFloat(key string, def float64) float64 {
if v := os.Getenv(key); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
}
return def
}
func getEnvInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getEnvDuration(key string, def time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return def
}

View File

@@ -0,0 +1,265 @@
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client
// interface, so the rest of smartrun never deals with MCP/JSON-RPC directly.
package garmin
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// Client is the interface the rest of smartrun depends on. The real
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides
// a fake for tests and frontend-only development.
type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call.
Authenticate(ctx context.Context) (AuthResult, error)
// CompleteMFA submits an MFA code for a login started by Authenticate.
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
// GetActivities lists activities between start and end (YYYY-MM-DD).
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
// GetActivitySplits fetches lap/split summaries for one activity.
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
// GetActivityDetails fetches raw per-second telemetry for one activity.
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
// Close terminates the subprocess, if running.
Close() error
}
// Config configures how the mcp-garmin subprocess is spawned.
type Config struct {
PythonPath string // path to mcp-garmin's venv python executable
ServerPath string // path to mcp-garmin's server.py
GarminEmail string
GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin
// persists/resumes a Garmin session there instead of the default ~/.garth.
TokenStorePath string
}
// mcpClient is the real Client implementation, backed by an mcp-garmin
// subprocess spoken to over stdio MCP.
type mcpClient struct {
cfg Config
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client
inner *mcpclient.Client
started bool
}
// NewClient builds a Client. The subprocess is not spawned until the first
// call that needs it (Authenticate, or any data call once authenticated).
func NewClient(cfg Config) Client {
return &mcpClient{cfg: cfg}
}
func (c *mcpClient) ensureStarted(ctx context.Context) error {
if c.started {
return nil
}
env := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"PYTHONUNBUFFERED=1",
}
if c.cfg.TokenStorePath != "" {
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath)
if err != nil {
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err)
}
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok {
go drainStderr(stdio)
}
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{Name: "smartrund", Version: "0.0.1"}
if _, err := inner.Initialize(ctx, initReq); err != nil {
inner.Close()
return fmt.Errorf("mcp initialize handshake: %w", err)
}
c.inner = inner
c.started = true
return nil
}
// drainStderr forwards the subprocess's debug/log output so it isn't
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there).
func drainStderr(stdio *transport.Stdio) {
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Print(string(buf[:n]))
}
if err != nil {
return
}
}
}
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) {
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.inner.CallTool(ctx, req)
if err != nil {
return "", fmt.Errorf("call tool %s: %w", name, err)
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result", name)
}
var out strings.Builder
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out.WriteString(tc.Text)
}
}
return out.String(), nil
}
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "authenticate", nil)
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func (c *mcpClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func parseAuthResult(msg string) AuthResult {
switch {
case strings.Contains(msg, "Authenticated successfully") || strings.Contains(msg, "MFA accepted"):
return AuthResult{Status: AuthSuccess, Message: msg}
case strings.Contains(msg, "MFA required"):
return AuthResult{Status: AuthMFARequired, Message: msg}
default:
return AuthResult{Status: AuthFailed, Message: msg}
}
}
func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
msg, err := c.callTool(ctx, "get_activities", map[string]any{
"start_date": startDate,
"end_date": endDate,
"limit": limit,
})
if err != nil {
return nil, err
}
var rawActivities []json.RawMessage
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err)
}
activities := make([]Activity, 0, len(rawActivities))
for _, raw := range rawActivities {
var a Activity
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("parse activity: %w", err)
}
a.Raw = raw
activities = append(activities, a)
}
return activities, nil
}
func (c *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err
}
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
})
if err != nil {
return ActivitySplits{}, err
}
var splits ActivitySplits
if err := json.Unmarshal([]byte(msg), &splits); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
return splits, nil
}
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err
}
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
})
if err != nil {
return ActivityDetails{}, err
}
var details ActivityDetails
if err := json.Unmarshal([]byte(msg), &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
return details, nil
}
func (c *mcpClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return nil
}
return c.inner.Close()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -0,0 +1,22 @@
package garmin
import "testing"
func TestParseAuthResult(t *testing.T) {
cases := []struct {
msg string
want AuthStatus
}{
{"Authenticated successfully.", AuthSuccess},
{"MFA accepted. Authenticated successfully.", AuthSuccess},
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
{"Authentication failed after MFA: bad code", AuthFailed},
}
for _, c := range cases {
got := parseAuthResult(c.msg)
if got.Status != c.want {
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
}
}
}

View File

@@ -0,0 +1,76 @@
// Package mock provides a fake garmin.Client for tests and frontend/dev
// work without a live Garmin account or the mcp-garmin subprocess.
package mock
import (
"context"
"smartrun/backend/internal/garmin"
)
// Client is a fake garmin.Client returning data supplied by the test/caller.
type Client struct {
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []garmin.Activity
Splits map[int64]garmin.ActivitySplits
Details map[int64]garmin.ActivityDetails
Err error // if set, every call returns this error
authResultCursor int
ClosedCalled bool
GetActivitiesCalls int
}
var _ garmin.Client = (*Client)(nil)
func (c *Client) nextAuthResult() garmin.AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
c.GetActivitiesCalls++
if c.Err != nil {
return nil, c.Err
}
if limit > 0 && limit < len(c.Activities) {
return c.Activities[:limit], nil
}
return c.Activities, nil
}
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
if c.Err != nil {
return garmin.ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
if c.Err != nil {
return garmin.ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *Client) Close() error {
c.ClosedCalled = true
return nil
}

View File

@@ -0,0 +1,157 @@
package garmin
import "encoding/json"
// AuthStatus is the outcome of an authenticate()/complete_mfa() call.
// mcp-garmin's tools return a plain human-readable string rather than a
// structured status, so the client pattern-matches known phrases into this.
type AuthStatus int
const (
AuthUnknown AuthStatus = iota
AuthSuccess
AuthMFARequired
AuthFailed
)
type AuthResult struct {
Status AuthStatus
Message string
}
// ActivityType mirrors the nested "activityType" object in get_activities().
type ActivityType struct {
TypeKey string `json:"typeKey"`
}
// Activity mirrors the fields of interest from get_activities(); the full
// original object is kept in Raw for fields not modeled here.
type Activity struct {
ActivityID int64 `json:"activityId"`
ActivityName string `json:"activityName"`
ActivityType ActivityType `json:"activityType"`
BeginTimestamp int64 `json:"beginTimestamp"`
StartTimeGMT string `json:"startTimeGMT"`
StartTimeLocal string `json:"startTimeLocal"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
ElapsedDuration float64 `json:"elapsedDuration"`
MovingDuration float64 `json:"movingDuration"`
AverageHR float64 `json:"averageHR"`
MaxHR float64 `json:"maxHR"`
AverageSpeed float64 `json:"averageSpeed"`
MaxSpeed float64 `json:"maxSpeed"`
ElevationGain *float64 `json:"elevationGain"`
ElevationLoss *float64 `json:"elevationLoss"`
Calories float64 `json:"calories"`
LapCount int `json:"lapCount"`
AerobicTrainingEffect float64 `json:"aerobicTrainingEffect"`
AerobicTrainingEffectMessage string `json:"aerobicTrainingEffectMessage"`
AnaerobicTrainingEffect float64 `json:"anaerobicTrainingEffect"`
AnaerobicTrainingEffectMessage string `json:"anaerobicTrainingEffectMessage"`
TrainingEffectLabel string `json:"trainingEffectLabel"`
VO2MaxValue *float64 `json:"vO2MaxValue"`
HrTimeInZone1 float64 `json:"hrTimeInZone_1"`
HrTimeInZone2 float64 `json:"hrTimeInZone_2"`
HrTimeInZone3 float64 `json:"hrTimeInZone_3"`
HrTimeInZone4 float64 `json:"hrTimeInZone_4"`
HrTimeInZone5 float64 `json:"hrTimeInZone_5"`
// Raw holds the full original JSON object for this activity, for fields
// not modeled above (or discovered later) without needing to re-fetch.
Raw json.RawMessage `json:"-"`
}
// Lap mirrors one entry of get_activity_splits()'s "lapDTOs".
type Lap struct {
LapIndex int `json:"lapIndex"`
StartTimeGMT string `json:"startTimeGMT"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
ElapsedDuration float64 `json:"elapsedDuration"`
MovingDuration float64 `json:"movingDuration"`
AverageHR float64 `json:"averageHR"`
MaxHR float64 `json:"maxHR"`
AverageSpeed float64 `json:"averageSpeed"`
MaxSpeed float64 `json:"maxSpeed"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
IntensityType string `json:"intensityType"`
Raw json.RawMessage `json:"-"`
}
// ActivitySplits mirrors the full get_activity_splits() response.
type ActivitySplits struct {
ActivityID int64 `json:"activityId"`
Laps []Lap `json:"lapDTOs"`
}
// MetricDescriptor maps a named metric to its index within each
// ActivityDetailMetrics row. The index is NOT stable across activities or
// devices and must always be read from this descriptor list at parse time.
type MetricDescriptor struct {
Key string `json:"key"`
MetricsIndex int `json:"metricsIndex"`
}
type activityDetailMetricsRow struct {
Metrics []*float64 `json:"metrics"`
}
// ActivityDetails mirrors the full get_activity_details() response: raw
// per-second telemetry, position-mapped via MetricDescriptors.
type ActivityDetails struct {
ActivityID int64 `json:"activityId"`
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
ActivityDetailMetrics []activityDetailMetricsRow `json:"activityDetailMetrics"`
}
// Sample is one ~1-second telemetry reading extracted from ActivityDetails
// using its MetricDescriptors mapping. Pointer fields are nil when that
// channel wasn't reported for this sample (common for the first few seconds
// of an activity, e.g. before ground contact time can be computed).
type Sample struct {
ElapsedSeconds float64
TimestampMS int64
HeartRate *float64
SpeedMps *float64
DistanceM *float64
ElevationM *float64
}
// ExtractSamples converts ActivityDetails' raw metrics rows into named
// Samples, resolving each field by looking up its key in MetricDescriptors
// rather than assuming a fixed array position.
func ExtractSamples(d ActivityDetails) []Sample {
index := make(map[string]int, len(d.MetricDescriptors))
for _, md := range d.MetricDescriptors {
index[md.Key] = md.MetricsIndex
}
get := func(row []*float64, key string) *float64 {
i, ok := index[key]
if !ok || i < 0 || i >= len(row) {
return nil
}
return row[i]
}
samples := make([]Sample, 0, len(d.ActivityDetailMetrics))
for _, m := range d.ActivityDetailMetrics {
row := m.Metrics
s := Sample{
HeartRate: get(row, "directHeartRate"),
SpeedMps: get(row, "directSpeed"),
DistanceM: get(row, "sumDistance"),
ElevationM: get(row, "directElevation"),
}
if elapsed := get(row, "sumElapsedDuration"); elapsed != nil {
s.ElapsedSeconds = *elapsed
}
if ts := get(row, "directTimestamp"); ts != nil {
s.TimestampMS = int64(*ts)
}
samples = append(samples, s)
}
return samples
}

View File

@@ -0,0 +1,58 @@
package garmin
import "testing"
func f(v float64) *float64 { return &v }
func TestExtractSamples_UsesDescriptorIndexNotPosition(t *testing.T) {
// Deliberately out-of-order / non-contiguous indices, mirroring the real
// server response where metricDescriptors order is not guaranteed.
details := ActivityDetails{
MetricDescriptors: []MetricDescriptor{
{Key: "directSpeed", MetricsIndex: 2},
{Key: "directHeartRate", MetricsIndex: 0},
{Key: "sumElapsedDuration", MetricsIndex: 1},
{Key: "sumDistance", MetricsIndex: 3},
{Key: "directElevation", MetricsIndex: 4},
},
ActivityDetailMetrics: []activityDetailMetricsRow{
{Metrics: []*float64{f(101), f(0), f(1.2), f(0), f(237.6)}},
{Metrics: []*float64{f(105), f(1), f(1.3), f(1.2), nil}},
},
}
samples := ExtractSamples(details)
if len(samples) != 2 {
t.Fatalf("expected 2 samples, got %d", len(samples))
}
if *samples[0].HeartRate != 101 {
t.Errorf("sample 0 heart rate = %v, want 101", *samples[0].HeartRate)
}
if samples[0].ElapsedSeconds != 0 {
t.Errorf("sample 0 elapsed seconds = %v, want 0", samples[0].ElapsedSeconds)
}
if *samples[1].HeartRate != 105 {
t.Errorf("sample 1 heart rate = %v, want 105", *samples[1].HeartRate)
}
if samples[1].ElapsedSeconds != 1 {
t.Errorf("sample 1 elapsed seconds = %v, want 1", samples[1].ElapsedSeconds)
}
if samples[1].ElevationM != nil {
t.Errorf("sample 1 elevation should be nil (missing channel), got %v", *samples[1].ElevationM)
}
}
func TestExtractSamples_MissingDescriptorYieldsNilField(t *testing.T) {
details := ActivityDetails{
MetricDescriptors: []MetricDescriptor{
{Key: "directHeartRate", MetricsIndex: 0},
},
ActivityDetailMetrics: []activityDetailMetricsRow{
{Metrics: []*float64{f(120)}},
},
}
samples := ExtractSamples(details)
if samples[0].SpeedMps != nil {
t.Errorf("expected nil SpeedMps when directSpeed descriptor absent, got %v", *samples[0].SpeedMps)
}
}

View File

@@ -0,0 +1,260 @@
package store
import (
"context"
"database/sql"
"fmt"
)
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
// from the Garmin/MCP response happens in internal/sync, not here, so this
// package stays independent of internal/garmin.
type Activity struct {
ID int64
GarminActivityID int64
ActivityName string
ActivityType string
StartTimeUTC string
BeginTimestampMs int64
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
Calories *float64
LapCount int
AerobicTrainingEffect *float64
AnaerobicTrainingEffect *float64
TrainingEffectLabel string
VO2MaxValue *float64
HrTimeInZone1 *float64
HrTimeInZone2 *float64
HrTimeInZone3 *float64
HrTimeInZone4 *float64
HrTimeInZone5 *float64
RawJSON string
DetailsFetchedAt *string
DetailsRawJSON *string
SplitsFetchedAt *string
CreatedAt string
UpdatedAt string
}
// UpsertActivity inserts a new activity or updates the existing row for the
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
// returns its internal id.
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
garmin_activity_id, activity_name, activity_type, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
activity_name=excluded.activity_name,
activity_type=excluded.activity_type,
start_time_utc=excluded.start_time_utc,
begin_timestamp_ms=excluded.begin_timestamp_ms,
duration_seconds=excluded.duration_seconds,
distance_meters=excluded.distance_meters,
avg_hr=excluded.avg_hr,
max_hr=excluded.max_hr,
avg_speed_mps=excluded.avg_speed_mps,
max_speed_mps=excluded.max_speed_mps,
elevation_gain_m=excluded.elevation_gain_m,
elevation_loss_m=excluded.elevation_loss_m,
calories=excluded.calories,
lap_count=excluded.lap_count,
aerobic_training_effect=excluded.aerobic_training_effect,
anaerobic_training_effect=excluded.anaerobic_training_effect,
training_effect_label=excluded.training_effect_label,
vo2max_value=excluded.vo2max_value,
hr_time_in_zone_1=excluded.hr_time_in_zone_1,
hr_time_in_zone_2=excluded.hr_time_in_zone_2,
hr_time_in_zone_3=excluded.hr_time_in_zone_3,
hr_time_in_zone_4=excluded.hr_time_in_zone_4,
hr_time_in_zone_5=excluded.hr_time_in_zone_5,
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.ActivityName, a.ActivityType, a.StartTimeUTC,
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
a.TrainingEffectLabel, a.VO2MaxValue,
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
a.RawJSON,
)
if err != nil {
return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err)
}
var id int64
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
}
return id, nil
}
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.StartTimeUTC,
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
&a.TrainingEffectLabel, &a.VO2MaxValue,
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, activity_name, activity_type, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at,
created_at, updated_at
`
// GetActivity fetches one activity by its internal id.
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
a, err := scanActivity(row)
if err == sql.ErrNoRows {
return Activity{}, false, nil
}
if err != nil {
return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err)
}
return a, true, nil
}
// ActivityFilter narrows ListActivities results. Zero values mean "no filter".
type ActivityFilter struct {
FromDate string // inclusive, "YYYY-MM-DD"
ToDate string // inclusive, "YYYY-MM-DD"
Limit int
Offset int
}
// ListActivities returns activities newest-first, optionally filtered by date range.
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
var args []any
if f.FromDate != "" {
query += ` AND start_time_utc >= ?`
args = append(args, f.FromDate)
}
if f.ToDate != "" {
query += ` AND start_time_utc <= ?`
args = append(args, f.ToDate+" 23:59:59")
}
query += ` ORDER BY start_time_utc DESC`
if f.Limit > 0 {
query += ` LIMIT ? OFFSET ?`
args = append(args, f.Limit, f.Offset)
}
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list activities: %w", err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
var t string
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, fmt.Errorf("latest activity start time: %w", err)
}
return t, true, nil
}
// SetActivityDetails records that get_activity_details has been fetched for
// this activity, storing the raw response for future reprocessing.
func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
WHERE id = ?`, rawJSON, activityID)
if err != nil {
return fmt.Errorf("set activity %d details: %w", activityID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
WHERE id = ?`, activityID)
if err != nil {
return fmt.Errorf("set activity %d splits fetched: %w", activityID, err)
}
return nil
}
// ActivitiesMissingDetails returns activities that haven't had
// get_activity_details/get_activity_splits fetched yet, for the lazy
// background detail-fill pass.
func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list activities missing details: %w", err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// CountActivitiesMissingDetails returns how many activities still need
// get_activity_details/get_activity_splits fetched, regardless of any
// per-call batch limit -- used to report overall remaining work.
func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count activities missing details: %w", err)
}
return n, nil
}

View File

@@ -0,0 +1,108 @@
package store
import (
"context"
"database/sql"
"fmt"
)
const (
AssignmentSourceRuleEngine = "rule_engine"
AssignmentSourceManual = "manual"
AssignmentStatusAssigned = "assigned"
AssignmentStatusNeedsReview = "needs_review"
)
// KindAssignment is one append-only classification decision for an
// activity. New assignments (rule re-run or manual override) are always
// inserted, never updated, so the full history survives.
type KindAssignment struct {
ID int64
ActivityID int64
WorkoutKindID *int64
AssignmentSource string
Status string
Confidence *float64
CandidateKindsJSON string
CreatedAt string
}
// InsertKindAssignment appends a new assignment row for an activity.
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
VALUES (?,?,?,?,?,?)`,
a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON)
if err != nil {
return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err)
}
return res.LastInsertId()
}
func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) {
var a KindAssignment
err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt)
return a, err
}
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
// CurrentAssignment returns the latest assignment for an activity, if any.
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
a, err := scanKindAssignment(row)
if err == sql.ErrNoRows {
return KindAssignment{}, false, nil
}
if err != nil {
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err)
}
return a, true, nil
}
// ReviewQueue returns activities whose current assignment status is
// needs_review, newest first.
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
if err != nil {
return nil, fmt.Errorf("review queue: %w", err)
}
defer rows.Close()
assignments := []KindAssignment{}
for rows.Next() {
a, err := scanKindAssignment(rows)
if err != nil {
return nil, fmt.Errorf("scan kind assignment row: %w", err)
}
assignments = append(assignments, a)
}
return assignments, rows.Err()
}
// AssignmentsForKind returns every historical assignment where the given
// workout kind was the resolved kind (regardless of source), oldest first --
// the basis for progression-over-time charts.
func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
workoutKindID, AssignmentStatusAssigned)
if err != nil {
return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err)
}
defer rows.Close()
assignments := []KindAssignment{}
for rows.Next() {
a, err := scanKindAssignment(rows)
if err != nil {
return nil, fmt.Errorf("scan kind assignment row: %w", err)
}
assignments = append(assignments, a)
}
return assignments, rows.Err()
}

View File

@@ -0,0 +1,98 @@
// Package store is smartrun's SQLite persistence layer: activities, laps,
// per-second samples, workout kind rule config, and classification history.
package store
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// DB wraps a *sql.DB opened against a smartrun SQLite database file, with
// migrations already applied.
type DB struct {
*sql.DB
}
// Open opens (creating if needed) the SQLite database at path and applies
// any migrations that haven't run yet.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
if err != nil {
return nil, fmt.Errorf("open sqlite database: %w", err)
}
// SQLite only supports one writer at a time; a single connection avoids
// "database is locked" errors under any concurrent access from the app.
sqlDB.SetMaxOpenConns(1)
db := &DB{DB: sqlDB}
if err := db.migrate(); err != nil {
sqlDB.Close()
return nil, err
}
return db, nil
}
func (db *DB) migrate() error {
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
applied := make(map[string]bool)
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
if err != nil {
return fmt.Errorf("query applied migrations: %w", err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return fmt.Errorf("scan applied migration: %w", err)
}
applied[name] = true
}
rows.Close()
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return fmt.Errorf("glob migrations: %w", err)
}
sort.Strings(entries)
for _, entry := range entries {
name := entry[len("migrations/"):]
if applied[name] {
continue
}
content, err := migrationsFS.ReadFile(entry)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("begin migration tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}

View File

@@ -0,0 +1,85 @@
package store
import (
"context"
"fmt"
)
// Lap is one lap/split of an activity, from get_activity_splits, plus
// derived HR drift/recovery metrics computed from activity_samples.
type Lap struct {
ID int64
ActivityID int64
LapIndex int
StartTimeUTC string
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
IntensityType string
HRDriftBpmPerMin *float64
HRRecoveryBpmPerMin *float64
RawJSON string
}
// ReplaceLaps deletes any existing laps for activityID and inserts the given
// set, so re-syncing an activity's splits is idempotent.
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin replace laps tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
}
for _, l := range laps {
_, err := tx.ExecContext(ctx, `
INSERT INTO laps (
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, l.RawJSON,
)
if err != nil {
return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err)
}
}
return tx.Commit()
}
// LapsForActivity returns all laps for an activity, ordered by lap_index.
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
if err != nil {
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
}
defer rows.Close()
laps := []Lap{}
for rows.Next() {
var l Lap
if err := rows.Scan(
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, &l.RawJSON,
); err != nil {
return nil, fmt.Errorf("scan lap row: %w", err)
}
laps = append(laps, l)
}
return laps, rows.Err()
}

View File

@@ -0,0 +1,109 @@
CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
garmin_activity_id INTEGER NOT NULL UNIQUE,
activity_name TEXT NOT NULL DEFAULT '',
activity_type TEXT NOT NULL,
start_time_utc TEXT NOT NULL,
begin_timestamp_ms INTEGER NOT NULL,
duration_seconds REAL NOT NULL,
distance_meters REAL NOT NULL,
avg_hr REAL,
max_hr REAL,
avg_speed_mps REAL,
max_speed_mps REAL,
elevation_gain_m REAL,
elevation_loss_m REAL,
calories REAL,
lap_count INTEGER NOT NULL DEFAULT 0,
aerobic_training_effect REAL,
anaerobic_training_effect REAL,
training_effect_label TEXT NOT NULL DEFAULT '',
vo2max_value REAL,
hr_time_in_zone_1 REAL,
hr_time_in_zone_2 REAL,
hr_time_in_zone_3 REAL,
hr_time_in_zone_4 REAL,
hr_time_in_zone_5 REAL,
raw_json TEXT NOT NULL,
details_fetched_at TEXT,
details_raw_json TEXT,
splits_fetched_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
CREATE INDEX idx_activities_activity_type ON activities(activity_type);
CREATE TABLE laps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL,
start_time_utc TEXT NOT NULL,
duration_seconds REAL NOT NULL,
distance_meters REAL NOT NULL,
avg_hr REAL,
max_hr REAL,
avg_speed_mps REAL,
max_speed_mps REAL,
elevation_gain_m REAL,
elevation_loss_m REAL,
intensity_type TEXT NOT NULL DEFAULT '',
hr_drift_bpm_per_min REAL,
hr_recovery_bpm_per_min REAL,
raw_json TEXT NOT NULL,
UNIQUE(activity_id, lap_index)
);
CREATE TABLE activity_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
elapsed_seconds REAL NOT NULL,
timestamp_ms INTEGER NOT NULL,
heart_rate REAL,
speed_mps REAL,
distance_m REAL,
elevation_m REAL
);
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '',
rule_json TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE kind_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
workout_kind_id INTEGER REFERENCES workout_kinds(id),
assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
confidence REAL,
candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
CREATE VIEW current_kind_assignment AS
SELECT a.* FROM kind_assignments a
JOIN (
SELECT activity_id, MAX(id) AS max_id
FROM kind_assignments GROUP BY activity_id
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;
CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')),
started_at TEXT NOT NULL,
finished_at TEXT,
activities_fetched INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
error_message TEXT
);

View File

@@ -0,0 +1,11 @@
-- Tracks how far back a full backfill has already reached. Garmin activity
-- history is immutable once recorded, so once we've backfilled a historical
-- window there is no need to ever re-fetch get_activities() for it again --
-- this is the "cache" that lets repeated backfills be cheap/fast instead of
-- re-walking years of history against Garmin's API every time.
CREATE TABLE sync_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0
);
INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0);

View File

@@ -0,0 +1,66 @@
package store
import (
"context"
"fmt"
)
// Sample is one ~1-second telemetry reading for an activity.
type Sample struct {
ElapsedSeconds float64
TimestampMs int64
HeartRate *float64
SpeedMps *float64
DistanceM *float64
ElevationM *float64
}
// ReplaceActivitySamples deletes any existing samples for activityID and
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin replace samples tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_samples WHERE activity_id = ?`, activityID); err != nil {
return fmt.Errorf("delete existing samples for activity %d: %w", activityID, err)
}
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m)
VALUES (?,?,?,?,?,?,?)`)
if err != nil {
return fmt.Errorf("prepare insert sample: %w", err)
}
defer stmt.Close()
for _, s := range samples {
if _, err := stmt.ExecContext(ctx, activityID, s.ElapsedSeconds, s.TimestampMs, s.HeartRate, s.SpeedMps, s.DistanceM, s.ElevationM); err != nil {
return fmt.Errorf("insert sample for activity %d: %w", activityID, err)
}
}
return tx.Commit()
}
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
rows, err := db.QueryContext(ctx, `
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
if err != nil {
return nil, fmt.Errorf("samples for activity %d: %w", activityID, err)
}
defer rows.Close()
samples := []Sample{}
for rows.Next() {
var s Sample
if err := rows.Scan(&s.ElapsedSeconds, &s.TimestampMs, &s.HeartRate, &s.SpeedMps, &s.DistanceM, &s.ElevationM); err != nil {
return nil, fmt.Errorf("scan sample row: %w", err)
}
samples = append(samples, s)
}
return samples, rows.Err()
}

View File

@@ -0,0 +1,202 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func openTestDB(t *testing.T) *DB {
t.Helper()
path := filepath.Join(t.TempDir(), "smartrun_test.db")
db, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func f(v float64) *float64 { return &v }
func TestMigrateIsIdempotent(t *testing.T) {
path := filepath.Join(t.TempDir(), "smartrun_test.db")
db1, err := Open(path)
if err != nil {
t.Fatalf("first Open: %v", err)
}
db1.Close()
db2, err := Open(path)
if err != nil {
t.Fatalf("second Open (re-applying migrations): %v", err)
}
db2.Close()
}
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
a := Activity{
GarminActivityID: 23554066504,
ActivityName: "Auriol - W2-5-Base Endurance",
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:02:35",
BeginTimestampMs: 1783746155000,
DurationSeconds: 1800,
DistanceMeters: 6858,
AvgHR: f(148),
RawJSON: `{"activityId":23554066504}`,
}
id, err := db.UpsertActivity(ctx, a)
if err != nil {
t.Fatalf("UpsertActivity (insert): %v", err)
}
a.AvgHR = f(150) // simulate a re-sync with a corrected value
id2, err := db.UpsertActivity(ctx, a)
if err != nil {
t.Fatalf("UpsertActivity (update): %v", err)
}
if id != id2 {
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
}
got, ok, err := db.GetActivity(ctx, id)
if err != nil || !ok {
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
}
if *got.AvgHR != 150 {
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
}
all, err := db.ListActivities(ctx, ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(all) != 1 {
t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all))
}
}
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 1,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
Name: "Tempo",
RuleJSON: `{"match":"all","conditions":[]}`,
IsActive: true,
})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
// First: rule engine says needs_review (ambiguous).
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
ActivityID: activityID,
AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusNeedsReview,
CandidateKindsJSON: `[{"kind_id":1,"score":0.5}]`,
}); err != nil {
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
}
queue, err := db.ReviewQueue(ctx)
if err != nil {
t.Fatalf("ReviewQueue: %v", err)
}
if len(queue) != 1 {
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
}
// Then: user manually resolves it.
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: AssignmentSourceManual,
Status: AssignmentStatusAssigned,
}); err != nil {
t.Fatalf("InsertKindAssignment (manual): %v", err)
}
queue, err = db.ReviewQueue(ctx)
if err != nil {
t.Fatalf("ReviewQueue after resolve: %v", err)
}
if len(queue) != 0 {
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
}
current, ok, err := db.CurrentAssignment(ctx, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
if current.AssignmentSource != AssignmentSourceManual || current.Status != AssignmentStatusAssigned {
t.Errorf("current assignment = %+v, want manual/assigned", current)
}
// The original rule-engine assignment must still exist (append-only history).
var historyCount int
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID).Scan(&historyCount); err != nil {
t.Fatalf("count history: %v", err)
}
if historyCount != 2 {
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
}
forKind, err := db.AssignmentsForKind(ctx, kindID)
if err != nil {
t.Fatalf("AssignmentsForKind: %v", err)
}
if len(forKind) != 1 {
t.Fatalf("expected 1 assigned activity for kind %d, got %d", kindID, len(forKind))
}
}
func TestReplaceLapsIsIdempotent(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 2,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
laps := []Lap{
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
}
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
t.Fatalf("ReplaceLaps (first): %v", err)
}
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil {
t.Fatalf("ReplaceLaps (second): %v", err)
}
got, err := db.LapsForActivity(ctx, activityID)
if err != nil {
t.Fatalf("LapsForActivity: %v", err)
}
if len(got) != 1 {
t.Fatalf("expected 1 lap after replace, got %d", len(got))
}
}

View File

@@ -0,0 +1,91 @@
package store
import (
"context"
"database/sql"
"fmt"
)
const (
SyncKindBackfill = "backfill"
SyncKindIncremental = "incremental"
SyncStatusRunning = "running"
SyncStatusSuccess = "success"
SyncStatusError = "error"
)
// SyncRun records one backfill or incremental sync attempt.
type SyncRun struct {
ID int64
Kind string
StartedAt string
FinishedAt *string
ActivitiesFetched int
Status string
ErrorMessage *string
}
// StartSyncRun records a new in-progress sync run and returns its id.
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
kind, SyncStatusRunning)
if err != nil {
return 0, fmt.Errorf("start sync run: %w", err)
}
return res.LastInsertId()
}
// FinishSyncRun marks a sync run as finished, recording how many activities
// were fetched and whether it succeeded.
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
status := SyncStatusSuccess
if errMsg != nil {
status = SyncStatusError
}
_, err := db.ExecContext(ctx, `
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
WHERE id = ?`, activitiesFetched, status, errMsg, id)
if err != nil {
return fmt.Errorf("finish sync run %d: %w", id, err)
}
return nil
}
// LatestSyncRun returns the most recent sync run, if any.
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
row := db.QueryRowContext(ctx, `
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
FROM sync_runs ORDER BY id DESC LIMIT 1`)
var r SyncRun
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
if err == sql.ErrNoRows {
return SyncRun{}, false, nil
}
if err != nil {
return SyncRun{}, false, fmt.Errorf("latest sync run: %w", err)
}
return r, true, nil
}
// ListSyncRuns returns recent sync runs, newest first.
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
FROM sync_runs ORDER BY id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list sync runs: %w", err)
}
defer rows.Close()
runs := []SyncRun{}
for rows.Next() {
var r SyncRun
if err := rows.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage); err != nil {
return nil, fmt.Errorf("scan sync run row: %w", err)
}
runs = append(runs, r)
}
return runs, rows.Err()
}

View File

@@ -0,0 +1,37 @@
package store
import (
"context"
"fmt"
)
// SyncState tracks how far back into Garmin history a backfill has already
// reached. Since activities are immutable once recorded, this lets a repeat
// backfill skip everything already covered instead of re-fetching it.
type SyncState struct {
EarliestSyncedDate *string // "YYYY-MM-DD", nil if backfill has never run
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
}
// GetSyncState returns the current backfill watermark (the singleton row,
// created by migration 0002).
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
var s SyncState
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`).
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
if err != nil {
return SyncState{}, fmt.Errorf("get sync state: %w", err)
}
return s, nil
}
// UpdateSyncState records progress of a backfill run.
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
_, err := db.ExecContext(ctx, `
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
earliestSyncedDate, complete)
if err != nil {
return fmt.Errorf("update sync state: %w", err)
}
return nil
}

View File

@@ -0,0 +1,31 @@
package store
import (
"context"
"testing"
)
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
state, err := db.GetSyncState(ctx)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
if state.EarliestSyncedDate != nil || state.BackfillComplete {
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
}
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
state, err = db.GetSyncState(ctx)
if err != nil {
t.Fatalf("GetSyncState after update: %v", err)
}
if state.EarliestSyncedDate == nil || *state.EarliestSyncedDate != "2023-01-01" || !state.BackfillComplete {
t.Fatalf("state after update = %+v, want earliest=2023-01-01 complete=true", state)
}
}

View File

@@ -0,0 +1,102 @@
package store
import (
"context"
"database/sql"
"fmt"
)
// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...).
// RuleJSON holds the condition tree evaluated by internal/classify.
type WorkoutKind struct {
ID int64
Name string
Description string
Color string
RuleJSON string
Priority int
IsActive bool
CreatedAt string
UpdatedAt string
}
func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
var k WorkoutKind
err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt)
return k, err
}
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
// CreateWorkoutKind inserts a new workout kind and returns its id.
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
res, err := db.ExecContext(ctx, `
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
VALUES (?,?,?,?,?,?)`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
if err != nil {
return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err)
}
return res.LastInsertId()
}
// UpdateWorkoutKind updates an existing workout kind's editable fields.
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
_, err := db.ExecContext(ctx, `
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
WHERE id=?`,
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
if err != nil {
return fmt.Errorf("update workout kind %d: %w", k.ID, err)
}
return nil
}
// GetWorkoutKind fetches one workout kind by id.
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
k, err := scanWorkoutKind(row)
if err == sql.ErrNoRows {
return WorkoutKind{}, false, nil
}
if err != nil {
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d: %w", id, err)
}
return k, true, nil
}
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
// (is_active=0) kinds are excluded.
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
if activeOnly {
query += ` WHERE is_active = 1`
}
query += ` ORDER BY priority DESC, name`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("list workout kinds: %w", err)
}
defer rows.Close()
kinds := []WorkoutKind{}
for rows.Next() {
k, err := scanWorkoutKind(rows)
if err != nil {
return nil, fmt.Errorf("scan workout kind row: %w", err)
}
kinds = append(kinds, k)
}
return kinds, rows.Err()
}
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
// referencing it) intact.
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id)
if err != nil {
return fmt.Errorf("soft delete workout kind %d: %w", id, err)
}
return nil
}

View 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") }

View 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
}

View 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)
}
}