commit f689f74ae0ffe7e12f07ae337fbde067e8bb862b Author: Christophe Vila Date: Fri Jul 17 18:33:06 2026 +0200 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 diff --git a/.claude/skills/smartrun-dev/SKILL.md b/.claude/skills/smartrun-dev/SKILL.md new file mode 100644 index 0000000..314a55f --- /dev/null +++ b/.claude/skills/smartrun-dev/SKILL.md @@ -0,0 +1,90 @@ +--- +name: smartrun-dev +description: Use when working on the smartrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions. +--- + +# smartrun-dev + +## Project overview + +smartrun is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified. + +**MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet. + +## Repo layout + +``` +backend/ + cmd/smartrund/ main server entrypoint + cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds + cmd/mcpspike/ throwaway MCP-client spike, safe to delete + internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details) + internal/garmin/mock/ fake Client for tests + internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery) + internal/store/ SQLite layer + embedded migrations + internal/sync/ orchestrates fetch -> store -> classify + internal/api/ HTTP handlers (chi router) + internal/config/ env var config loading +frontend/ + src/pages/ Dashboard, ReviewQueue, WorkoutKinds + src/components/charts/ Recharts wrappers + src/api/client.ts thin typed fetch client + src/types/api.ts hand-shared DTO types mirroring backend/internal/api's JSON responses +``` + +## Classification rule model + +Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/classify.Node`): + +```json +{ + "match": "all", + "conditions": [ + { "metric": "avg_pace_sec_per_km", "op": "between", "value": [270, 300] }, + { "metric": "avg_hr_pct_max", "op": ">=", "value": 0.80 } + ] +} +``` + +- `match`: `"all"` (AND) or `"any"` (OR), with nested `conditions`. +- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array). +- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`. +- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable. +- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default 0.6). All three populate `Candidates` for the review UI. +- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout. +- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery. + +## mcp-garmin integration + +`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered: + +- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these. +- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason). +- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. +- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). +- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. +- **`get_activity_splits()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`). + +## Data model conventions + +- `kind_assignments` is **append-only** — always INSERT, never UPDATE. Re-classifying after a rule edit, or a manual override, keeps full history; `current_kind_assignment` (a view) picks the latest row per activity by `id`. +- `activities.raw_json`/`details_raw_json` hedge columns store the full original Garmin JSON, so fields not yet modeled in Go can be backfilled later without re-fetching from Garmin. +- `garmin_activity_id` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass. +- `sync_state` (singleton row) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered) instead of re-walking years of already-known history against Garmin's API on every call. Widening `BackfillHorizonDays` after a completed backfill correctly triggers resumption further back, not a full re-fetch. +- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters set by `FillPendingDetails`, reset to zero when idle) and surfaced through `GET /api/sync/status` (`detail_fill_progress`, plus `activities_pending_details` for the total remaining beyond the current batch). The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live. + +## Dev workflow + +- Backend: `cd backend && go run ./cmd/smartrund` (needs `MCP_GARMIN_PYTHON`, `MCP_GARMIN_SERVER`, `GARMIN_EMAIL`, `GARMIN_PASSWORD` env vars; see `internal/config/config.go` for all knobs). +- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`). +- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `smartrund` at that DB. +- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. +- Migrations: add a new numbered file under `internal/store/migrations/`, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table). + +## Testing conventions + +- Table-driven Go tests throughout; no separate fixture files needed yet given the codebase's size — test cases are inline. +- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly. +- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching. +- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`. +- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `smartrund` auth endpoints. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1993f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Go +backend/smartrund +backend/*.db +backend/*.db-journal + +# Node +frontend/node_modules/ +frontend/dist/ + +# IDE +.idea/workspace.xml +.idea/shelf/ + +# Env / secrets +.env +*.env.local + +# OS +.DS_Store +backend/mcpspike +backend/cmd/mcpspike/mcpspike diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/backend/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/backend/.idea/backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/backend/.idea/go.imports.xml b/backend/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/backend/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml new file mode 100644 index 0000000..e066844 --- /dev/null +++ b/backend/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/cmd/mcpspike/main.go b/backend/cmd/mcpspike/main.go new file mode 100644 index 0000000..94ebfdc --- /dev/null +++ b/backend/cmd/mcpspike/main.go @@ -0,0 +1,243 @@ +// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive +// mcp-garmin (spawn, initialize handshake, call tools, parse results). +// Not part of the production build — delete once internal/garmin is built. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" +) + +func main() { + pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable") + serverPath := flag.String("server", "", "path to mcp-garmin server.py") + limit := flag.Int("limit", 5, "activity limit for get_activities") + startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)") + endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)") + flag.Parse() + + if *pythonPath == "" || *serverPath == "" { + log.Fatal("usage: mcpspike -python -server ") + } + + email := os.Getenv("GARMIN_EMAIL") + password := os.Getenv("GARMIN_PASSWORD") + if email == "" || password == "" { + log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment") + } + + env := []string{ + "GARMIN_EMAIL=" + email, + "GARMIN_PASSWORD=" + password, + "PYTHONUNBUFFERED=1", + } + + c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath) + if err != nil { + log.Fatalf("spawn subprocess: %v", err) + } + defer c.Close() + + if stdio, ok := c.GetTransport().(*transport.Stdio); ok { + go func() { + buf := make([]byte, 4096) + for { + n, err := stdio.Stderr().Read(buf) + if n > 0 { + fmt.Fprint(os.Stderr, string(buf[:n])) + } + if err != nil { + return + } + } + }() + } else { + log.Println("warning: could not get stdio transport to forward subprocess stderr") + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + initReq := mcp.InitializeRequest{} + initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"} + + initRes, err := c.Initialize(ctx, initReq) + if err != nil { + log.Fatalf("initialize handshake failed: %v", err) + } + fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n", + initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion) + + tools, err := c.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + log.Fatalf("list tools failed: %v", err) + } + fmt.Printf("server exposes %d tools:\n", len(tools.Tools)) + for _, t := range tools.Tools { + fmt.Printf(" - %s: %s\n", t.Name, t.Description) + } + + fmt.Println("\ncalling authenticate()...") + authRes, err := callTool(ctx, c, "authenticate", nil) + if err != nil { + log.Fatalf("authenticate call failed: %v", err) + } + fmt.Printf("authenticate() -> %s\n", authRes) + + if containsMFAPrompt(authRes) { + fmt.Print("MFA required. Enter code: ") + var code string + fmt.Scanln(&code) + mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code}) + if err != nil { + log.Fatalf("complete_mfa call failed: %v", err) + } + fmt.Printf("complete_mfa() -> %s\n", mfaRes) + } + + start := *startDate + if start == "" { + start = time.Now().AddDate(0, 0, -365).Format("2006-01-02") + } + end := *endDate + if end == "" { + end = time.Now().Format("2006-01-02") + } + + fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit) + actRes, err := callTool(ctx, c, "get_activities", map[string]any{ + "start_date": start, + "end_date": end, + "limit": *limit, + }) + if err != nil { + log.Fatalf("get_activities call failed: %v", err) + } + + var activities []map[string]any + if err := json.Unmarshal([]byte(actRes), &activities); err != nil { + fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes) + return + } + pretty, _ := json.MarshalIndent(activities, "", " ") + fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000)) + + if len(activities) == 0 { + fmt.Println("\nno activities in range, skipping get_activity_details") + return + } + + // Prefer a running activity if one is present, for the most relevant lap/split shape. + chosen := activities[0] + for _, a := range activities { + if at, ok := a["activityType"].(map[string]any); ok { + if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" { + chosen = a + break + } + } + } + var activityID string + switch v := chosen["activityId"].(type) { + case float64: + activityID = strconv.FormatFloat(v, 'f', -1, 64) + default: + activityID = fmt.Sprint(v) + } + fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"]) + + splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID}) + if err != nil { + log.Fatalf("get_activity_splits call failed: %v", err) + } + + var splits map[string]any + if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil { + fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes) + return + } + keys := make([]string, 0, len(splits)) + for k := range splits { + keys = append(keys, k) + } + fmt.Printf("get_activity_splits() top-level keys: %v\n", keys) + prettySplits, _ := json.MarshalIndent(splits, "", " ") + fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000)) + + fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...") + detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID}) + if err != nil { + log.Fatalf("get_activity_details call failed: %v", err) + } + var details map[string]any + if err := json.Unmarshal([]byte(detailsRes), &details); err != nil { + fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes) + return + } + if descriptors, ok := details["metricDescriptors"]; ok { + pretty, _ := json.MarshalIndent(descriptors, "", " ") + fmt.Printf("metricDescriptors:\n%s\n", pretty) + } else { + fmt.Println("no metricDescriptors key found") + } + if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 { + sampleN := 5 + if len(rows) < sampleN { + sampleN = len(rows) + } + pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ") + fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty) + } +} + +func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) { + req := mcp.CallToolRequest{} + req.Params.Name = name + req.Params.Arguments = args + + res, err := c.CallTool(ctx, req) + if err != nil { + return "", err + } + if res.IsError { + return "", fmt.Errorf("tool %s returned an error result", name) + } + var out string + for _, content := range res.Content { + if tc, ok := content.(mcp.TextContent); ok { + out += tc.Text + } + } + return out, nil +} + +func containsMFAPrompt(s string) bool { + for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} { + if len(s) >= len(needle) { + for i := 0; i+len(needle) <= len(s); i++ { + if s[i:i+len(needle)] == needle { + return true + } + } + } + } + return false +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "...(truncated)" +} diff --git a/backend/cmd/seedsample/main.go b/backend/cmd/seedsample/main.go new file mode 100644 index 0000000..dbfd9f7 --- /dev/null +++ b/backend/cmd/seedsample/main.go @@ -0,0 +1,221 @@ +// Command seedsample inserts synthetic activities, laps, and workout kinds +// directly into the SQLite database, then runs them through the real +// classification engine -- so the frontend (Dashboard/ReviewQueue/ +// WorkoutKinds) can be visually verified with realistic data without a live +// Garmin account. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "time" + + "smartrun/backend/internal/classify" + "smartrun/backend/internal/garmin/mock" + "smartrun/backend/internal/store" + appsync "smartrun/backend/internal/sync" +) + +func main() { + dbPath := flag.String("db", "smartrun_sample.db", "path to the SQLite database to seed") + flag.Parse() + + ctx := context.Background() + db, err := store.Open(*dbPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer db.Close() + + // Easy and Tempo's pace/HR ranges deliberately overlap a little (330-340 + // sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap zone + // demonstrates the ambiguous-multi-match review path, not just a gap + // between disjoint ranges. + easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{ + Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e", + RuleJSON: `{"match":"all","conditions":[ + {"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]}, + {"metric":"avg_hr_pct_max","op":"<=","value":0.85} + ]}`, + IsActive: true, + }) + must(err) + + tempoID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{ + Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b", + RuleJSON: `{"match":"all","conditions":[ + {"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]}, + {"metric":"avg_hr_pct_max","op":">=","value":0.70} + ]}`, + IsActive: true, + }) + must(err) + + _, err = db.CreateWorkoutKind(ctx, store.WorkoutKind{ + Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444", + RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`, + IsActive: true, + }) + must(err) + + m := &mock.Client{} + svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil) + + today := time.Now() + activityIDs := []int64{} + + // 5 Easy runs, pace/HR trending slightly faster over time (progression). + for i := 0; i < 5; i++ { + start := today.AddDate(0, 0, -60+i*10) + speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km + hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling + id := seedActivity(ctx, db, seedParams{ + garminID: 1000 + int64(i), name: "Easy morning run", start: start, + distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr, + aerobicTE: 2.5, anaerobicTE: 0.3, + }) + activityIDs = append(activityIDs, id) + } + + // 3 Tempo runs. + for i := 0; i < 3; i++ { + start := today.AddDate(0, 0, -45+i*15) + speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range + id := seedActivity(ctx, db, seedParams{ + garminID: 2000 + int64(i), name: "Tempo run", start: start, + distance: 6000, duration: 1800, speedMps: speed, avgHR: 168, + aerobicTE: 3.8, anaerobicTE: 1.2, + }) + activityIDs = append(activityIDs, id) + } + + // 1 Interval workout, with alternating ACTIVE/REST laps + HR samples + // showing drift on the work intervals and recovery on the rest ones. + { + id := seedActivity(ctx, db, seedParams{ + garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5), + distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165, + aerobicTE: 3.0, anaerobicTE: 3.5, + }) + seedIntervalLapsAndSamples(ctx, db, id) + activityIDs = append(activityIDs, id) + } + + // 1 ambiguous run: pace 335 sec/km sits inside both Easy's [330,420] and + // Tempo's [300,340] ranges, and HR% (0.78) satisfies both Easy's <=0.85 + // and Tempo's >=0.70 -- so it should land in the review queue with two + // candidates, not a clean single match. + { + id := seedActivity(ctx, db, seedParams{ + garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2), + distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148, + aerobicTE: 3.0, anaerobicTE: 0.8, + }) + activityIDs = append(activityIDs, id) + } + + for _, id := range activityIDs { + must(svc.ClassifyActivity(ctx, id)) + } + + fmt.Printf("Seeded %d activities (kinds: Easy=%d, Tempo=%d) into %s\n", len(activityIDs), easyID, tempoID, *dbPath) +} + +type seedParams struct { + garminID int64 + name string + start time.Time + distance float64 + duration float64 + speedMps float64 + avgHR float64 + aerobicTE float64 + anaerobicTE float64 +} + +func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 { + speed := p.speedMps + hr := p.avgHR + aerobic := p.aerobicTE + anaerobic := p.anaerobicTE + id, err := db.UpsertActivity(ctx, store.Activity{ + GarminActivityID: p.garminID, + ActivityName: p.name, + ActivityType: "running", + StartTimeUTC: p.start.Format("2006-01-02 15:04:05"), + BeginTimestampMs: p.start.UnixMilli(), + DurationSeconds: p.duration, + DistanceMeters: p.distance, + AvgSpeedMps: &speed, + AvgHR: &hr, + AerobicTrainingEffect: &aerobic, + AnaerobicTrainingEffect: &anaerobic, + RawJSON: "{}", + }) + must(err) + return id +} + +// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST +// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift) +// and falling HR within each REST lap (recovery). +func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) { + var laps []store.Lap + var samples []store.Sample + elapsed := 0.0 + baseHR := 140.0 + + for i := 0; i < 6; i++ { + isActive := i%2 == 0 + lapDuration := 180.0 + intensity := "REST" + if isActive { + intensity = "ACTIVE" + } + + var lapSamples []classify.SampleInfo + for s := 0.0; s < lapDuration; s += 5 { + var hr float64 + if isActive { + hr = baseHR + s/10 // rising through the work interval + } else { + hr = baseHR + 20 - s/8 // falling through the rest interval + } + samples = append(samples, store.Sample{ + ElapsedSeconds: elapsed + s, + TimestampMs: int64((elapsed + s) * 1000), + HeartRate: &hr, + }) + lapSamples = append(lapSamples, classify.SampleInfo{ElapsedSeconds: elapsed + s, HeartRate: &hr}) + } + + var drift, recovery *float64 + if isActive { + if v, ok := classify.HRDrift(lapSamples); ok { + drift = &v + } + } else { + if v, ok := classify.HRRecovery(lapSamples); ok { + recovery = &v + } + } + + laps = append(laps, store.Lap{ + LapIndex: i + 1, DurationSeconds: lapDuration, + DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}", + HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery, + }) + elapsed += lapDuration + } + + must(db.ReplaceActivitySamples(ctx, activityID, samples)) + must(db.ReplaceLaps(ctx, activityID, laps)) +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/backend/cmd/smartrund/main.go b/backend/cmd/smartrund/main.go new file mode 100644 index 0000000..1b4f82c --- /dev/null +++ b/backend/cmd/smartrund/main.go @@ -0,0 +1,91 @@ +// Command smartrund is smartrun's backend server: syncs runs from Garmin, +// classifies them into workout kinds, and serves the REST API the frontend +// talks to. +package main + +import ( + "context" + "log" + "net/http" + "os/signal" + "syscall" + "time" + + "smartrun/backend/internal/api" + "smartrun/backend/internal/config" + "smartrun/backend/internal/garmin" + "smartrun/backend/internal/store" + appsync "smartrun/backend/internal/sync" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + + garminClient := garmin.NewClient(garmin.Config{ + PythonPath: cfg.GarminPythonPath, + ServerPath: cfg.GarminServerPath, + GarminEmail: cfg.GarminEmail, + GarminPassword: cfg.GarminPassword, + TokenStorePath: cfg.GarminTokenStore, + }) + defer garminClient.Close() + + syncSvc := appsync.NewService(garminClient, db, appsync.Config{ + BackfillHorizonDays: cfg.BackfillHorizonDays, + MinConfidence: cfg.MinConfidence, + MaxHR: cfg.MaxHR, + }, nil) + + server := api.NewServer(db, garminClient, syncSvc) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery) + + httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()} + go func() { + log.Printf("smartrund listening on %s", cfg.Addr) + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("http server: %v", err) + } + }() + + <-ctx.Done() + log.Println("shutting down...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := httpServer.Shutdown(shutdownCtx); err != nil { + log.Printf("http server shutdown: %v", err) + } +} + +// runIncrementalSyncLoop periodically syncs new activities in the +// background so the frontend doesn't need to trigger every sync manually. +func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) { + ticker := time.NewTicker(every) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := svc.IncrementalSync(ctx); err != nil { + log.Printf("incremental sync: %v", err) + continue + } + if err := svc.FillPendingDetails(ctx, 50); err != nil { + log.Printf("fill pending details: %v", err) + } + } + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..a53c39f --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,24 @@ +module smartrun/backend + +go 1.26.4 + +require github.com/mark3labs/mcp-go v0.56.0 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-chi/chi/v5 v5.3.1 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.14.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.53.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..756981d --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,55 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8= +github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= diff --git a/backend/internal/api/activities.go b/backend/internal/api/activities.go new file mode 100644 index 0000000..b566ebd --- /dev/null +++ b/backend/internal/api/activities.go @@ -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) +} diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go new file mode 100644 index 0000000..10f5163 --- /dev/null +++ b/backend/internal/api/api_test.go @@ -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) +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..09d7b6d --- /dev/null +++ b/backend/internal/api/auth.go @@ -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}) +} diff --git a/backend/internal/api/kinds.go b/backend/internal/api/kinds.go new file mode 100644 index 0000000..1416d26 --- /dev/null +++ b/backend/internal/api/kinds.go @@ -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)}) +} diff --git a/backend/internal/api/progression.go b/backend/internal/api/progression.go new file mode 100644 index 0000000..cdd9cb2 --- /dev/null +++ b/backend/internal/api/progression.go @@ -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) +} diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go new file mode 100644 index 0000000..28347ca --- /dev/null +++ b/backend/internal/api/review.go @@ -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"}) +} diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go new file mode 100644 index 0000000..fcb7132 --- /dev/null +++ b/backend/internal/api/server.go @@ -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 +} diff --git a/backend/internal/api/sync.go b/backend/internal/api/sync.go new file mode 100644 index 0000000..3be9198 --- /dev/null +++ b/backend/internal/api/sync.go @@ -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) +} diff --git a/backend/internal/classify/engine.go b/backend/internal/classify/engine.go new file mode 100644 index 0000000..658d6a9 --- /dev/null +++ b/backend/internal/classify/engine.go @@ -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} +} diff --git a/backend/internal/classify/engine_test.go b/backend/internal/classify/engine_test.go new file mode 100644 index 0000000..4cfb1dd --- /dev/null +++ b/backend/internal/classify/engine_test.go @@ -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) + } +} diff --git a/backend/internal/classify/laps.go b/backend/internal/classify/laps.go new file mode 100644 index 0000000..39063f4 --- /dev/null +++ b/backend/internal/classify/laps.go @@ -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 +} diff --git a/backend/internal/classify/rule.go b/backend/internal/classify/rule.go new file mode 100644 index 0000000..509819b --- /dev/null +++ b/backend/internal/classify/rule.go @@ -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)) +} diff --git a/backend/internal/classify/rule_test.go b/backend/internal/classify/rule_test.go new file mode 100644 index 0000000..2d24ef4 --- /dev/null +++ b/backend/internal/classify/rule_test.go @@ -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") + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..9339170 --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go new file mode 100644 index 0000000..df5db9b --- /dev/null +++ b/backend/internal/garmin/client.go @@ -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)" +} diff --git a/backend/internal/garmin/client_test.go b/backend/internal/garmin/client_test.go new file mode 100644 index 0000000..72b9c68 --- /dev/null +++ b/backend/internal/garmin/client_test.go @@ -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) + } + } +} diff --git a/backend/internal/garmin/mock/mock.go b/backend/internal/garmin/mock/mock.go new file mode 100644 index 0000000..e6c95fc --- /dev/null +++ b/backend/internal/garmin/mock/mock.go @@ -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 +} diff --git a/backend/internal/garmin/types.go b/backend/internal/garmin/types.go new file mode 100644 index 0000000..8fc57a8 --- /dev/null +++ b/backend/internal/garmin/types.go @@ -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 +} diff --git a/backend/internal/garmin/types_test.go b/backend/internal/garmin/types_test.go new file mode 100644 index 0000000..27e8c82 --- /dev/null +++ b/backend/internal/garmin/types_test.go @@ -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) + } +} diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go new file mode 100644 index 0000000..1c53ded --- /dev/null +++ b/backend/internal/store/activities.go @@ -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 +} diff --git a/backend/internal/store/assignments.go b/backend/internal/store/assignments.go new file mode 100644 index 0000000..114acb3 --- /dev/null +++ b/backend/internal/store/assignments.go @@ -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() +} diff --git a/backend/internal/store/db.go b/backend/internal/store/db.go new file mode 100644 index 0000000..f4b0e07 --- /dev/null +++ b/backend/internal/store/db.go @@ -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 +} diff --git a/backend/internal/store/laps.go b/backend/internal/store/laps.go new file mode 100644 index 0000000..35d6191 --- /dev/null +++ b/backend/internal/store/laps.go @@ -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() +} diff --git a/backend/internal/store/migrations/0001_init.sql b/backend/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..77e96f3 --- /dev/null +++ b/backend/internal/store/migrations/0001_init.sql @@ -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 +); diff --git a/backend/internal/store/migrations/0002_sync_state.sql b/backend/internal/store/migrations/0002_sync_state.sql new file mode 100644 index 0000000..2889300 --- /dev/null +++ b/backend/internal/store/migrations/0002_sync_state.sql @@ -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); diff --git a/backend/internal/store/samples.go b/backend/internal/store/samples.go new file mode 100644 index 0000000..57d70a5 --- /dev/null +++ b/backend/internal/store/samples.go @@ -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() +} diff --git a/backend/internal/store/store_test.go b/backend/internal/store/store_test.go new file mode 100644 index 0000000..4d548cf --- /dev/null +++ b/backend/internal/store/store_test.go @@ -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)) + } +} diff --git a/backend/internal/store/syncruns.go b/backend/internal/store/syncruns.go new file mode 100644 index 0000000..c716661 --- /dev/null +++ b/backend/internal/store/syncruns.go @@ -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() +} diff --git a/backend/internal/store/syncstate.go b/backend/internal/store/syncstate.go new file mode 100644 index 0000000..c6aba91 --- /dev/null +++ b/backend/internal/store/syncstate.go @@ -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 +} diff --git a/backend/internal/store/syncstate_test.go b/backend/internal/store/syncstate_test.go new file mode 100644 index 0000000..d7b9108 --- /dev/null +++ b/backend/internal/store/syncstate_test.go @@ -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) + } +} diff --git a/backend/internal/store/workoutkinds.go b/backend/internal/store/workoutkinds.go new file mode 100644 index 0000000..a4a62e6 --- /dev/null +++ b/backend/internal/store/workoutkinds.go @@ -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 +} diff --git a/backend/internal/sync/mapping.go b/backend/internal/sync/mapping.go new file mode 100644 index 0000000..bd3f022 --- /dev/null +++ b/backend/internal/sync/mapping.go @@ -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") } diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go new file mode 100644 index 0000000..7807f0b --- /dev/null +++ b/backend/internal/sync/service.go @@ -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 +} diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go new file mode 100644 index 0000000..b730016 --- /dev/null +++ b/backend/internal/sync/service_test.go @@ -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) + } +} diff --git a/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md b/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md new file mode 100644 index 0000000..0073890 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md @@ -0,0 +1,2413 @@ +# Profile & Taxonomy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace env-var-only Garmin credentials and the arbitrary user-created "workout kinds" with a single-profile settings model (credentials + tunable engine parameters) and a fixed 7-type running taxonomy, each with a user-editable pace range and expected HR zone. + +**Architecture:** Extends the existing Go backend (`internal/store`, `internal/api`, `internal/sync`, `internal/garmin`) and React frontend in place — no new services. `profile` is a singleton SQLite row (same pattern as the existing `sync_state`). The 7 workout types reuse the existing `workout_kinds` table and rule engine, reseeded and stripped of create/delete affordances. A new `workout_type_paces` table holds per-type pace range + expected HR zone, informational only (not read by classification). + +**Tech Stack:** Go 1.26, `modernc.org/sqlite`, `go-chi/chi`, React 19 + TypeScript + Vite, Recharts (unchanged). + +## Global Constraints + +- No AI/LLM involvement at runtime — everything in this plan is deterministic Go/TS code and SQL. +- Single active profile only — no multi-profile switcher UI, no per-row profile scoping elsewhere in the schema (per spec section 1 non-goals). +- Pace ranges and expected HR zone are never read by the classification rule engine (per spec section 2) — they exist purely as data capture for the future delta feature. +- `gofmt -l .` must report nothing and `go vet ./...` must pass before any commit. +- Every new Go file follows the existing package doc-comment convention (a `// Package x ...` comment on the first file that introduces a package, none on subsequent files in the same package). + +--- + +### Task 1: `profile` table + store layer + +**Files:** +- Create: `backend/internal/store/migrations/0003_profile.sql` +- Create: `backend/internal/store/profile.go` +- Test: `backend/internal/store/profile_test.go` + +**Interfaces:** +- Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`. + +- [ ] **Step 1: Write the migration** + +Create `backend/internal/store/migrations/0003_profile.sql`: + +```sql +-- Single-profile settings: Garmin credentials (replacing env-var-only +-- config) and every tunable engine parameter, in one editable row. +CREATE TABLE profile ( + id INTEGER PRIMARY KEY CHECK (id = 1), + garmin_email TEXT NOT NULL DEFAULT '', + garmin_password TEXT NOT NULL DEFAULT '', + rolling_window_days INTEGER NOT NULL DEFAULT 90, + max_heart_rate REAL, + resting_heart_rate REAL, + hr_zone1_min_pct REAL NOT NULL DEFAULT 50, + hr_zone1_max_pct REAL NOT NULL DEFAULT 60, + hr_zone2_min_pct REAL NOT NULL DEFAULT 60, + hr_zone2_max_pct REAL NOT NULL DEFAULT 70, + hr_zone3_min_pct REAL NOT NULL DEFAULT 70, + hr_zone3_max_pct REAL NOT NULL DEFAULT 80, + hr_zone4_min_pct REAL NOT NULL DEFAULT 80, + hr_zone4_max_pct REAL NOT NULL DEFAULT 90, + hr_zone5_min_pct REAL NOT NULL DEFAULT 90, + hr_zone5_max_pct REAL NOT NULL DEFAULT 100, + easy_warmup_minutes REAL NOT NULL DEFAULT 10, + easy_cooldown_minutes REAL NOT NULL DEFAULT 5, + long_warmup_minutes REAL NOT NULL DEFAULT 10, + long_cooldown_minutes REAL NOT NULL DEFAULT 5, + tempo_warmup_minutes REAL NOT NULL DEFAULT 15, + tempo_cooldown_minutes REAL NOT NULL DEFAULT 10, + threshold30_warmup_minutes REAL NOT NULL DEFAULT 15, + threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10, + threshold60_warmup_minutes REAL NOT NULL DEFAULT 15, + threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10, + mas_test_warmup_minutes REAL NOT NULL DEFAULT 15, + mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO profile (id) VALUES (1); +``` + +- [ ] **Step 2: Write the failing test** + +Create `backend/internal/store/profile_test.go`: + +```go +package store + +import ( + "context" + "testing" +) + +func TestProfile_DefaultsThenUpdate(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + p, err := db.GetProfile(ctx) + if err != nil { + t.Fatalf("GetProfile: %v", err) + } + if p.RollingWindowDays != 90 { + t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays) + } + if p.HRZone1MinPct != 50 || p.HRZone5MaxPct != 100 { + t.Errorf("zone defaults = %+v, want Z1 min=50, Z5 max=100", p) + } + + maxHR, restingHR := 190.0, 50.0 + p.GarminEmail = "runner@example.com" + p.GarminPassword = "hunter2" + p.RollingWindowDays = 120 + p.MaxHeartRate = &maxHR + p.RestingHeartRate = &restingHR + p.IntervalWarmupMinutes = 8 + + if err := db.UpdateProfile(ctx, p); err != nil { + t.Fatalf("UpdateProfile: %v", err) + } + + got, err := db.GetProfile(ctx) + if err != nil { + t.Fatalf("GetProfile after update: %v", err) + } + if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { + t.Errorf("got = %+v, want updated email/window", got) + } + if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 { + t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) + } + if got.IntervalWarmupMinutes != 8 { + t.Errorf("IntervalWarmupMinutes = %v, want 8", got.IntervalWarmupMinutes) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd backend && go test ./internal/store/... -run TestProfile -v` +Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet). + +- [ ] **Step 4: Write `profile.go`** + +Create `backend/internal/store/profile.go`: + +```go +package store + +import ( + "context" + "fmt" +) + +// Profile is the single active user's Garmin credentials plus every +// tunable analysis-engine parameter. Always exactly one row (id=1). +type Profile struct { + GarminEmail string + GarminPassword string + RollingWindowDays int + MaxHeartRate *float64 + RestingHeartRate *float64 + + HRZone1MinPct, HRZone1MaxPct float64 + HRZone2MinPct, HRZone2MaxPct float64 + HRZone3MinPct, HRZone3MaxPct float64 + HRZone4MinPct, HRZone4MaxPct float64 + HRZone5MinPct, HRZone5MaxPct float64 + + EasyWarmupMinutes, EasyCooldownMinutes float64 + LongWarmupMinutes, LongCooldownMinutes float64 + TempoWarmupMinutes, TempoCooldownMinutes float64 + Threshold30WarmupMinutes, Threshold30CooldownMinutes float64 + Threshold60WarmupMinutes, Threshold60CooldownMinutes float64 + MASTestWarmupMinutes, MASTestCooldownMinutes float64 + + // IntervalWarmupMinutes/IntervalCooldownMinutes are not backed by + // dedicated columns: Interval phase detection uses the lap_intensity + // strategy (existing ACTIVE/REST lap tagging), not a fixed-duration + // guess. Kept here as a convenience zero-value for callers that don't + // yet distinguish strategies; always 0 until a future migration adds + // real columns if a fixed fallback is ever needed. + IntervalWarmupMinutes, IntervalCooldownMinutes float64 + + CreatedAt, UpdatedAt string +} + +const profileColumns = ` + garmin_email, garmin_password, rolling_window_days, max_heart_rate, resting_heart_rate, + hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, + hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, + hr_zone5_min_pct, hr_zone5_max_pct, + easy_warmup_minutes, easy_cooldown_minutes, long_warmup_minutes, long_cooldown_minutes, + tempo_warmup_minutes, tempo_cooldown_minutes, + threshold30_warmup_minutes, threshold30_cooldown_minutes, + threshold60_warmup_minutes, threshold60_cooldown_minutes, + mas_test_warmup_minutes, mas_test_cooldown_minutes, + created_at, updated_at +` + +// GetProfile returns the single profile row. +func (db *DB) GetProfile(ctx context.Context) (Profile, error) { + var p Profile + err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan( + &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.MaxHeartRate, &p.RestingHeartRate, + &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, + &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, + &p.HRZone5MinPct, &p.HRZone5MaxPct, + &p.EasyWarmupMinutes, &p.EasyCooldownMinutes, &p.LongWarmupMinutes, &p.LongCooldownMinutes, + &p.TempoWarmupMinutes, &p.TempoCooldownMinutes, + &p.Threshold30WarmupMinutes, &p.Threshold30CooldownMinutes, + &p.Threshold60WarmupMinutes, &p.Threshold60CooldownMinutes, + &p.MASTestWarmupMinutes, &p.MASTestCooldownMinutes, + &p.CreatedAt, &p.UpdatedAt, + ) + if err != nil { + return Profile{}, fmt.Errorf("get profile: %w", err) + } + return p, nil +} + +// UpdateProfile overwrites the single profile row. Callers should read via +// GetProfile first and modify the fields they intend to change, since this +// replaces every column. +func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { + _, err := db.ExecContext(ctx, ` + UPDATE profile SET + garmin_email=?, garmin_password=?, rolling_window_days=?, max_heart_rate=?, resting_heart_rate=?, + hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?, + hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, + hr_zone5_min_pct=?, hr_zone5_max_pct=?, + easy_warmup_minutes=?, easy_cooldown_minutes=?, long_warmup_minutes=?, long_cooldown_minutes=?, + tempo_warmup_minutes=?, tempo_cooldown_minutes=?, + threshold30_warmup_minutes=?, threshold30_cooldown_minutes=?, + threshold60_warmup_minutes=?, threshold60_cooldown_minutes=?, + mas_test_warmup_minutes=?, mas_test_cooldown_minutes=?, + updated_at=datetime('now') + WHERE id = 1`, + p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.MaxHeartRate, p.RestingHeartRate, + p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, + p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, + p.HRZone5MinPct, p.HRZone5MaxPct, + p.EasyWarmupMinutes, p.EasyCooldownMinutes, p.LongWarmupMinutes, p.LongCooldownMinutes, + p.TempoWarmupMinutes, p.TempoCooldownMinutes, + p.Threshold30WarmupMinutes, p.Threshold30CooldownMinutes, + p.Threshold60WarmupMinutes, p.Threshold60CooldownMinutes, + p.MASTestWarmupMinutes, p.MASTestCooldownMinutes, + ) + if err != nil { + return fmt.Errorf("update profile: %w", err) + } + return nil +} +``` + +Note: `EasyWarmupMinutes, EasyCooldownMinutes float64` etc. above must be written as valid Go — use one field per line instead of comma-grouping when you transcribe this (Go does support comma-grouped same-type fields, e.g. `A, B float64`, so `EasyWarmupMinutes, EasyCooldownMinutes float64` on one line is valid; just don't carry through the extra alignment spaces literally — run `gofmt -w` after creating the file and let it fix spacing). + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && gofmt -w internal/store/profile.go && go test ./internal/store/... -run TestProfile -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go +git commit -m "feat: add single-profile settings table and store layer" +``` +(If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.) + +--- + +### Task 2: Reseed workout taxonomy to the 7 fixed types + +**Files:** +- Create: `backend/internal/store/migrations/0004_workout_taxonomy.sql` +- Test: `backend/internal/store/workoutkinds_taxonomy_test.go` + +**Interfaces:** +- Consumes: `store.WorkoutKind` (existing, from `internal/store/workoutkinds.go`), `store.ListWorkoutKinds(ctx, activeOnly bool) ([]WorkoutKind, error)` (existing). +- Produces: exactly 7 rows in `workout_kinds` named `Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`, `Interval`, `MAS Test`, each with a syntactically valid but never-matching placeholder rule (real rules are tuned later, out of scope for this plan). + +- [ ] **Step 1: Write the migration** + +Create `backend/internal/store/migrations/0004_workout_taxonomy.sql`: + +```sql +-- The taxonomy is now a fixed, closed set (no more user-created "kinds"). +-- Any prior arbitrary kinds and their classification history are reset: +-- the concept they classified against no longer exists. +DELETE FROM kind_assignments; +DELETE FROM workout_kinds; + +-- Placeholder rule: distance is never negative, so this never matches. +-- Every activity starts in needs_review for every type until real rules +-- are tuned (a follow-up plan, not this migration). +INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES + ('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), + ('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1); +``` + +- [ ] **Step 2: Write the failing test** + +Create `backend/internal/store/workoutkinds_taxonomy_test.go`: + +```go +package store + +import ( + "context" + "testing" +) + +func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + kinds, err := db.ListWorkoutKinds(ctx, true) + if err != nil { + t.Fatalf("ListWorkoutKinds: %v", err) + } + if len(kinds) != 7 { + t.Fatalf("expected 7 seeded workout kinds, got %d: %+v", len(kinds), kinds) + } + + wantNames := map[string]bool{ + "Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false, + "Tempo": false, "Interval": false, "MAS Test": false, + } + for _, k := range kinds { + if _, ok := wantNames[k.Name]; !ok { + t.Errorf("unexpected seeded kind name %q", k.Name) + continue + } + wantNames[k.Name] = true + } + for name, found := range wantNames { + if !found { + t.Errorf("expected seeded kind %q, not found", name) + } + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v` +Expected: FAIL — `expected 7 seeded workout kinds, got 0` + +- [ ] **Step 4: Run test to verify it passes** + +The migration alone should make this pass once picked up by the `Open`/migrate path — no Go code changes needed for this task. + +Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v` +Expected: PASS + +- [ ] **Step 5: Run the full store test suite to confirm nothing broke** + +Run: `cd backend && go test ./internal/store/... -v 2>&1 | tail -40` +Expected: all PASS. (`TestKindAssignment_AppendOnlyHistoryAndCurrentView` and similar tests create their own workout kinds via `db.CreateWorkoutKind` in a fresh temp DB per test, so the reseed migration doesn't interfere with them — each test gets its own SQLite file via `openTestDB`.) + +- [ ] **Step 6: Commit** + +```bash +cd backend && git add internal/store/migrations/0004_workout_taxonomy.sql internal/store/workoutkinds_taxonomy_test.go +git commit -m "feat: reseed workout_kinds with the fixed 7-type running taxonomy" +``` + +--- + +### Task 3: `workout_type_paces` table + store layer + +**Files:** +- Create: `backend/internal/store/migrations/0005_workout_type_paces.sql` +- Create: `backend/internal/store/workoutpaces.go` +- Test: `backend/internal/store/workoutpaces_test.go` + +**Interfaces:** +- Consumes: `store.WorkoutKind.ID` (existing). +- Produces: `store.WorkoutTypePace` struct, `(db *DB) GetWorkoutTypePace(ctx, workoutKindID int64) (WorkoutTypePace, error)`, `(db *DB) UpdateWorkoutTypePace(ctx, WorkoutTypePace) error`, `(db *DB) ListWorkoutTypePaces(ctx) ([]WorkoutTypePace, error)`. + +- [ ] **Step 1: Write the migration** + +Create `backend/internal/store/migrations/0005_workout_type_paces.sql`: + +```sql +-- Per-workout-type target pace range and expected HR zone. Informational +-- only: never read by the classification rule engine (see spec section 2). +-- No history -- overwritten in place when the user updates a value; the +-- synced activity log is the historical record. +CREATE TABLE workout_type_paces ( + workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), + pace_min_sec_per_km REAL, + pace_max_sec_per_km REAL, + expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5) +); + +INSERT INTO workout_type_paces (workout_kind_id) +SELECT id FROM workout_kinds; +``` + +- [ ] **Step 2: Write the failing test** + +Create `backend/internal/store/workoutpaces_test.go`: + +```go +package store + +import ( + "context" + "testing" +) + +func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + all, err := db.ListWorkoutTypePaces(ctx) + if err != nil { + t.Fatalf("ListWorkoutTypePaces: %v", err) + } + if len(all) != 7 { + t.Fatalf("expected 7 seeded pace rows (one per taxonomy kind), got %d", len(all)) + } + for _, p := range all { + if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil { + t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p) + } + } + + target := all[0] + minPace, maxPace, zone := 330.0, 420.0, 2 + target.PaceMinSecPerKm = &minPace + target.PaceMaxSecPerKm = &maxPace + target.ExpectedHRZone = &zone + + if err := db.UpdateWorkoutTypePace(ctx, target); err != nil { + t.Fatalf("UpdateWorkoutTypePace: %v", err) + } + + got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID) + if err != nil { + t.Fatalf("GetWorkoutTypePace: %v", err) + } + if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 { + t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm) + } + if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 { + t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd backend && go test ./internal/store/... -run TestWorkoutTypePaces -v` +Expected: FAIL (compile error — `ListWorkoutTypePaces` etc. undefined). + +- [ ] **Step 4: Write `workoutpaces.go`** + +Create `backend/internal/store/workoutpaces.go`: + +```go +package store + +import ( + "context" + "database/sql" + "fmt" +) + +// WorkoutTypePace is a workout kind's user-declared target pace range and +// expected HR zone. Informational only -- never read by the classification +// rule engine. No history: fields are overwritten in place. +type WorkoutTypePace struct { + WorkoutKindID int64 + PaceMinSecPerKm *float64 + PaceMaxSecPerKm *float64 + ExpectedHRZone *int +} + +func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) { + var p WorkoutTypePace + err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone) + return p, err +} + +const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone` + +// GetWorkoutTypePace fetches the pace/zone row for one workout kind. +func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) { + row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID) + p, err := scanWorkoutTypePace(row) + if err == sql.ErrNoRows { + return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil + } + if err != nil { + return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d: %w", workoutKindID, err) + } + return p, nil +} + +// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind. +func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error { + _, err := db.ExecContext(ctx, ` + UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, expected_hr_zone=? + WHERE workout_kind_id=?`, + p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID) + if err != nil { + return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err) + } + return nil +} + +// ListWorkoutTypePaces returns every workout kind's pace/zone row. +func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) { + rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`) + if err != nil { + return nil, fmt.Errorf("list workout type paces: %w", err) + } + defer rows.Close() + + paces := []WorkoutTypePace{} + for rows.Next() { + p, err := scanWorkoutTypePace(rows) + if err != nil { + return nil, fmt.Errorf("scan workout type pace row: %w", err) + } + paces = append(paces, p) + } + return paces, rows.Err() +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && gofmt -w internal/store/workoutpaces.go && go test ./internal/store/... -run TestWorkoutTypePaces -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +cd backend && git add internal/store/migrations/0005_workout_type_paces.sql internal/store/workoutpaces.go internal/store/workoutpaces_test.go +git commit -m "feat: add per-workout-type pace range and expected HR zone" +``` + +--- + +### Task 4: `garmin.Client.UpdateCredentials` + +**Files:** +- Modify: `backend/internal/garmin/client.go` (interface at lines 21-35, `mcpClient` struct/methods at lines 48-98) +- Modify: `backend/internal/garmin/mock/mock.go` +- Test: `backend/internal/garmin/client_test.go` (existing file — add a test) + +**Interfaces:** +- Produces: `Client.UpdateCredentials(email, password string)` (no error return — it just updates in-memory state and closes any running subprocess; the next call that needs the subprocess respawns it and surfaces any connection error then, same as today). + +- [ ] **Step 1: Write the failing test** + +Add to `backend/internal/garmin/client_test.go` (append a new test function; keep the existing `TestParseAuthResult`): + +```go +func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) { + c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} + c.started = true // simulate an already-spawned subprocess + + c.UpdateCredentials("new@example.com", "new") + + if c.cfg.GarminEmail != "new@example.com" || c.cfg.GarminPassword != "new" { + t.Errorf("cfg after update = %+v, want new@example.com/new", c.cfg) + } + if c.started { + t.Error("started should be reset to false so the next call respawns the subprocess") + } + if c.inner != nil { + t.Error("inner should be cleared so ensureStarted spawns a fresh client") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/garmin/... -run TestMcpClient_UpdateCredentials -v` +Expected: FAIL — `c.UpdateCredentials undefined (type *mcpClient has no field or method UpdateCredentials)` + +- [ ] **Step 3: Add `UpdateCredentials` to the `Client` interface** + +In `backend/internal/garmin/client.go`, modify the `Client` interface (currently lines 21-35) by adding the new method right after `CompleteMFA`: + +```go +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) + // UpdateCredentials replaces the Garmin email/password used to spawn + // the subprocess, and terminates any already-running subprocess (which + // would otherwise still be authenticated under the old credentials). + // The next call that needs the subprocess spawns a fresh one with the + // new credentials. + UpdateCredentials(email, password string) + // 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 +} +``` + +- [ ] **Step 4: Implement `UpdateCredentials` on `mcpClient`** + +In `backend/internal/garmin/client.go`, add this method right after the existing `func (c *mcpClient) ensureStarted(...)` method (which ends around line 98): + +```go +// UpdateCredentials implements Client. +func (c *mcpClient) UpdateCredentials(email, password string) { + c.mu.Lock() + defer c.mu.Unlock() + + c.cfg.GarminEmail = email + c.cfg.GarminPassword = password + + if c.started { + c.inner.Close() + c.inner = nil + c.started = false + } +} +``` + +- [ ] **Step 5: Add a no-op tracking implementation to the mock** + +In `backend/internal/garmin/mock/mock.go`, add two fields to the `Client` struct (`LastEmail`, `LastPassword`) and the method. The struct currently reads: + +```go +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 +} +``` + +Change it to: + +```go +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 + LastEmail string + LastPassword string +} +``` + +Add the method anywhere after the struct definition: + +```go +func (c *Client) UpdateCredentials(email, password string) { + c.LastEmail = email + c.LastPassword = password +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cd backend && gofmt -w internal/garmin/client.go internal/garmin/mock/mock.go && go build ./... && go test ./internal/garmin/... -v` +Expected: all PASS, including the new `TestMcpClient_UpdateCredentials_ResetsStartedState`. + +- [ ] **Step 7: Run the full backend test suite to confirm nothing broke** + +Run: `cd backend && go build ./... && go vet ./... && go test ./...` +Expected: all PASS (the `garmin.Client` interface changed, so this also confirms every existing caller/mock still satisfies it). + +- [ ] **Step 8: Commit** + +```bash +cd backend && git add internal/garmin/client.go internal/garmin/client_test.go internal/garmin/mock/mock.go +git commit -m "feat: support updating Garmin credentials at runtime" +``` + +--- + +### Task 5: Profile REST endpoints + +**Files:** +- Create: `backend/internal/api/profile.go` +- Modify: `backend/internal/api/server.go` (add route inside `Router()`, currently lines 40-76) +- Test: `backend/internal/api/api_test.go` (append) + +**Interfaces:** +- Consumes: `store.Profile`, `db.GetProfile`/`db.UpdateProfile` (Task 1), `Server.Garmin.UpdateCredentials` (Task 4, via the existing `Server.Garmin garmin.Client` field). +- Produces: `GET /api/profile`, `PUT /api/profile`. + +- [ ] **Step 1: Write the failing test** + +Append to `backend/internal/api/api_test.go`: + +```go +func TestProfile_GetDefaultsThenUpdate(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) + if rec.Code != http.StatusOK { + t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String()) + } + var got store.Profile + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got.RollingWindowDays != 90 { + t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays) + } + + got.GarminEmail = "runner@example.com" + got.GarminPassword = "hunter2" + got.RollingWindowDays = 120 + rec = doJSON(t, router, http.MethodPut, "/api/profile", got) + if rec.Code != http.StatusOK { + t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) + var updated store.Profile + json.Unmarshal(rec.Body.Bytes(), &updated) + if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 { + t.Fatalf("updated = %+v, want new email/window", updated) + } +} + +func TestProfile_RejectsInvalidHRZones(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) + var p store.Profile + json.Unmarshal(rec.Body.Bytes(), &p) + + maxHR, restingHR := 100.0, 150.0 // resting > max: invalid + p.MaxHeartRate = &maxHR + p.RestingHeartRate = &restingHR + + rec = doJSON(t, router, http.MethodPut, "/api/profile", p) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd backend && go test ./internal/api/... -run TestProfile -v` +Expected: FAIL — 404 on `GET /api/profile` (route doesn't exist yet). + +- [ ] **Step 3: Write the handlers** + +Create `backend/internal/api/profile.go`: + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "smartrun/backend/internal/store" +) + +func validateProfile(p store.Profile) error { + if p.RestingHeartRate != nil && p.MaxHeartRate != nil && *p.RestingHeartRate >= *p.MaxHeartRate { + return errors.New("resting heart rate must be less than max heart rate") + } + zones := [][2]float64{ + {p.HRZone1MinPct, p.HRZone1MaxPct}, + {p.HRZone2MinPct, p.HRZone2MaxPct}, + {p.HRZone3MinPct, p.HRZone3MaxPct}, + {p.HRZone4MinPct, p.HRZone4MaxPct}, + {p.HRZone5MinPct, p.HRZone5MaxPct}, + } + if zones[0][0] != 0 { + return errors.New("zone 1 must start at 0%") + } + if zones[len(zones)-1][1] != 100 { + return errors.New("zone 5 must end at 100%") + } + for i, z := range zones { + if z[0] >= z[1] { + return errors.New("each HR zone's min must be less than its max") + } + if i > 0 && z[0] != zones[i-1][1] { + return errors.New("HR zones must be contiguous and non-overlapping") + } + } + return nil +} + +func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { + p, err := s.DB.GetProfile(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, p) +} + +func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + var p store.Profile + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if err := validateProfile(p); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if err := s.DB.UpdateProfile(r.Context(), p); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword) + + updated, err := s.DB.GetProfile(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, updated) +} +``` + +- [ ] **Step 4: Wire the route** + +In `backend/internal/api/server.go`, inside `Router()`, add a new route registration. It currently reads (lines 40-42): + +```go + r.Route("/api", func(r chi.Router) { + r.Get("/health", s.handleHealth) + +``` + +Change to: + +```go + r.Route("/api", func(r chi.Router) { + r.Get("/health", s.handleHealth) + + r.Route("/profile", func(r chi.Router) { + r.Get("/", s.handleGetProfile) + r.Put("/", s.handleUpdateProfile) + }) + +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd backend && gofmt -w internal/api/profile.go internal/api/server.go && go test ./internal/api/... -run TestProfile -v` +Expected: PASS + +- [ ] **Step 6: Run the full backend test suite** + +Run: `cd backend && go build ./... && go vet ./... && go test ./...` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go +git commit -m "feat: add profile REST endpoints with HR zone validation" +``` + +--- + +### Task 6: Fixed taxonomy in the API — drop create/delete, add pace fields + +**Files:** +- Modify: `backend/internal/api/kinds.go` (full rewrite — small file, shown complete below) +- Modify: `backend/internal/api/server.go` (route group, currently lines 61-68) +- Modify: `backend/internal/api/api_test.go` (replace `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule`) + +**Interfaces:** +- Consumes: `store.WorkoutTypePace`, `db.GetWorkoutTypePace`/`db.UpdateWorkoutTypePace` (Task 3). +- Produces: `workoutKindResponse` (kind + pace fields combined), used by `GET /api/workout-kinds/`, `GET /api/workout-kinds/{id}`, `PUT /api/workout-kinds/{id}`. `POST /api/workout-kinds/` and `DELETE /api/workout-kinds/{id}` no longer exist. + +- [ ] **Step 1: Update the tests first (they define the new contract)** + +In `backend/internal/api/api_test.go`, replace the two functions `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule` (currently lines 61-111) with: + +```go +func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) { + s, _ := newTestServer(t) + rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var kinds []workoutKindResponse + if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(kinds) != 7 { + t.Fatalf("expected 7 seeded kinds, got %d", len(kinds)) + } + for _, k := range kinds { + if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil { + t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k) + } + } +} + +func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + target := kinds[0] + + minPace, maxPace, zone := 330.0, 420.0, 2 + body := map[string]any{ + "name": target.Name, + "rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`), + "pace_min_sec_per_km": minPace, + "pace_max_sec_per_km": maxPace, + "expected_hr_zone": zone, + } + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body) + if rec.Code != http.StatusOK { + t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String()) + } + + rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil) + var updated workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &updated) + if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 { + t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm) + } + if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 { + t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone) + } +} + +func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ + "name": kinds[0].Name, + "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 TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) { + s, _ := newTestServer(t) + router := s.Router() + + rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) + var kinds []workoutKindResponse + json.Unmarshal(rec.Body.Bytes(), &kinds) + + rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ + "name": kinds[0].Name, + "rule": json.RawMessage(`{"match":"all","conditions":[]}`), + "expected_hr_zone": 9, + }) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd backend && go test ./internal/api/... -run TestWorkoutKind -v` +Expected: FAIL (compile error — `workoutKindResponse` undefined). + +- [ ] **Step 3: Rewrite `kinds.go`** + +Replace the full contents of `backend/internal/api/kinds.go` with: + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "smartrun/backend/internal/classify" + "smartrun/backend/internal/store" +) + +// workoutKindResponse combines a workout kind's rule/metadata with its pace +// range and expected HR zone (stored separately in workout_type_paces), +// since the frontend always edits and displays them together. +type workoutKindResponse struct { + store.WorkoutKind + PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` + PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` + ExpectedHRZone *int `json:"expected_hr_zone"` +} + +func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { + pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID) + if err != nil { + return workoutKindResponse{}, err + } + return workoutKindResponse{ + WorkoutKind: k, + PaceMinSecPerKm: pace.PaceMinSecPerKm, + PaceMaxSecPerKm: pace.PaceMaxSecPerKm, + ExpectedHRZone: pace.ExpectedHRZone, + }, nil +} + +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"` + PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` + PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` + ExpectedHRZone *int `json:"expected_hr_zone"` +} + +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()) + } + if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) { + return node, errors.New("expected_hr_zone must be between 1 and 5") + } + 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 + } + resp := make([]workoutKindResponse, 0, len(kinds)) + for _, k := range kinds { + wr, err := s.toWorkoutKindResponse(r, k) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + resp = append(resp, wr) + } + writeJSON(w, http.StatusOK, resp) +} + +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 + } + resp, err := s.toWorkoutKindResponse(r, kind) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +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 + } + if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{ + WorkoutKindID: id, + PaceMinSecPerKm: req.PaceMinSecPerKm, + PaceMaxSecPerKm: req.PaceMaxSecPerKm, + ExpectedHRZone: req.ExpectedHRZone, + }); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) + resp, err := s.toWorkoutKindResponse(r, kind) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, resp) +} + +// 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)}) +} +``` + +- [ ] **Step 4: Remove the create/delete routes** + +In `backend/internal/api/server.go`, the workout-kinds route group currently reads (lines 61-68): + +```go + 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) + }) +``` + +Change to: + +```go + r.Route("/workout-kinds", func(r chi.Router) { + r.Get("/", s.handleListWorkoutKinds) + r.Get("/{id}", s.handleGetWorkoutKind) + r.Put("/{id}", s.handleUpdateWorkoutKind) + r.Post("/{id}/reclassify", s.handleReclassifyKind) + }) +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd backend && gofmt -w internal/api/kinds.go internal/api/server.go && go test ./internal/api/... -v 2>&1 | tail -60` +Expected: all PASS, including the four new/renamed tests. `TestReviewQueueResolve` and `TestProgression_ReturnsSortedTimeSeries` still pass unchanged (they create their own additively-named test kinds via `db.CreateWorkoutKind` directly, bypassing the now-removed API route, which remains fine since `store.CreateWorkoutKind` itself is untouched). + +- [ ] **Step 6: Run the full backend test suite** + +Run: `cd backend && go build ./... && go vet ./... && go test ./...` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +cd backend && git add internal/api/kinds.go internal/api/server.go internal/api/api_test.go +git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API" +``` + +--- + +### Task 7: Classification reads max HR from the profile, not static config + +**Files:** +- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310) +- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`) + +**Interfaces:** +- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1). +- Removes: `sync.Config.MaxHR` field (no longer exists — callers must delete it from any `Config{...}` literal). + +- [ ] **Step 1: Update the test first** + +In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`): + +```go + svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) +``` + +Change to (drop `MaxHR` — the test's rule only checks `avg_pace_sec_per_km`, so this doesn't change its assertions; max HR now comes from the profile row, which defaults to `NULL`/unset and is simply omitted from the metric context in that case, same as before): + +```go + svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) +``` + +- [ ] **Step 2: Run the test to verify it fails to compile** + +Run: `cd backend && go test ./internal/sync/... -v 2>&1 | head -20` +Expected: passes as-is right now (we haven't removed the field yet) — this step just confirms the test file still compiles before the next change. Expected: PASS (no behavior change yet). + +- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`** + +In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39): + +```go + // 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 +} +``` + +Change to (drop the `MaxHR` field and its comment): + +```go + // MinConfidence is the classify.Classify threshold below which even a + // single matching kind is sent to manual review. + MinConfidence float64 +} +``` + +Then, in the same file, `ClassifyActivity` currently reads (lines 279-301): + +```go +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) +``` + +Change the middle to read the profile's max heart rate instead of `s.cfg.MaxHR`: + +```go +func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { + activity, ok, err := s.db.GetActivity(ctx, activityID) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("activity %d not found", activityID) + } + laps, err := s.db.LapsForActivity(ctx, activityID) + if err != nil { + return err + } + kindRows, err := s.db.ListWorkoutKinds(ctx, true) + if err != nil { + return err + } + rules, err := loadRuleKinds(kindRows) + if err != nil { + return fmt.Errorf("parse workout kind rules: %w", err) + } + profile, err := s.db.GetProfile(ctx) + if err != nil { + return fmt.Errorf("load profile: %w", err) + } + var maxHR float64 + if profile.MaxHeartRate != nil { + maxHR = *profile.MaxHeartRate + } + + ctxMetrics := buildMetricContext(activity, laps, maxHR) + result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd backend && gofmt -w internal/sync/service.go internal/sync/service_test.go && go build ./... && go test ./internal/sync/... -v` +Expected: all PASS. `TestBuildMetricContext_DerivesExpectedMetrics` is untouched and still passes — it calls `buildMetricContext` directly with a literal `190`, and that function's signature didn't change, only `ClassifyActivity`'s caller-side source of the value did. + +- [ ] **Step 5: Run the full backend test suite** + +Run: `cd backend && go build ./... && go vet ./... && go test ./...` +Expected: all PASS. If `cmd/smartrund/main.go` fails to build because it still references `MaxHR: cfg.MaxHR` in its `appsync.Config{...}` literal, that's expected — Task 8 fixes `cmd/smartrund` and `internal/config` together; this step is allowed to show that build failure so you can confirm it's exactly (and only) that one line before moving to Task 8. + +- [ ] **Step 6: Commit** + +```bash +cd backend && git add internal/sync/service.go internal/sync/service_test.go +git commit -m "feat: classification reads max HR from the profile instead of static config" +``` + +--- + +### Task 8: `internal/config` and `cmd/smartrund` — credentials come from the profile + +**Files:** +- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below) +- Modify: `backend/cmd/smartrund/main.go` (lines 21-46) + +**Interfaces:** +- Consumes: `db.GetProfile` (Task 1). +- Removes: `config.Config.GarminEmail`, `config.Config.GarminPassword`, `config.Config.MaxHR` and the `GARMIN_EMAIL`/`GARMIN_PASSWORD`/`SMARTRUN_MAX_HR` env vars — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` remain required env vars (infra paths, not secrets). + +- [ ] **Step 1: Rewrite `internal/config/config.go`** + +Replace the full contents of `backend/internal/config/config.go` with: + +```go +// Package config loads smartrund's runtime infrastructure configuration +// from environment variables (paths and process settings that don't belong +// in the user-editable profile). Garmin credentials and every tunable +// analysis-engine parameter live in the profile (internal/store.Profile) +// instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md. +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds smartrund's process-level configuration. +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 + // GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth + // session cache location. + GarminTokenStore string + + 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"), + GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"), + 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)") + } + 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 +} +``` + +- [ ] **Step 2: Update `cmd/smartrund/main.go` to load credentials from the profile** + +`backend/cmd/smartrund/main.go` currently reads (lines 21-46): + +```go +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + + garminClient := garmin.NewClient(garmin.Config{ + PythonPath: cfg.GarminPythonPath, + ServerPath: cfg.GarminServerPath, + GarminEmail: cfg.GarminEmail, + GarminPassword: cfg.GarminPassword, + TokenStorePath: cfg.GarminTokenStore, + }) + defer garminClient.Close() + + syncSvc := appsync.NewService(garminClient, db, appsync.Config{ + BackfillHorizonDays: cfg.BackfillHorizonDays, + MinConfidence: cfg.MinConfidence, + MaxHR: cfg.MaxHR, + }, nil) +``` + +Change to: + +```go +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + + db, err := store.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open database: %v", err) + } + defer db.Close() + + profile, err := db.GetProfile(context.Background()) + if err != nil { + log.Fatalf("load profile: %v", err) + } + + garminClient := garmin.NewClient(garmin.Config{ + PythonPath: cfg.GarminPythonPath, + ServerPath: cfg.GarminServerPath, + GarminEmail: profile.GarminEmail, + GarminPassword: profile.GarminPassword, + TokenStorePath: cfg.GarminTokenStore, + }) + defer garminClient.Close() + + syncSvc := appsync.NewService(garminClient, db, appsync.Config{ + BackfillHorizonDays: cfg.BackfillHorizonDays, + MinConfidence: cfg.MinConfidence, + }, nil) +``` + +(`context` is already imported in this file for the later `signal.NotifyContext` call, so no import changes are needed.) + +- [ ] **Step 3: Build and run the full backend test suite** + +Run: `cd backend && gofmt -w internal/config/config.go cmd/smartrund/main.go && go build ./... && go vet ./... && go test ./...` +Expected: everything builds and all tests PASS. + +- [ ] **Step 4: Manual smoke test — start the server without Garmin env vars** + +```bash +cd backend +rm -f /tmp/smartrun_task8.db +MCP_GARMIN_PYTHON=/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/.venv/bin/python \ +MCP_GARMIN_SERVER=/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py \ +SMARTRUN_DB_PATH=/tmp/smartrun_task8.db \ +SMARTRUN_ADDR=:8085 \ +go run ./cmd/smartrund & +sleep 2 +curl -s localhost:8085/api/profile +curl -s localhost:8085/api/workout-kinds/ | head -c 300 +kill %1 +``` + +Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` returns the default profile JSON (`rolling_window_days: 90`, etc.), and `/api/workout-kinds/` returns the 7 seeded types. + +- [ ] **Step 5: Commit** + +```bash +cd backend && git add internal/config/config.go cmd/smartrund/main.go +git commit -m "feat: source Garmin credentials from the profile instead of env vars" +``` + +--- + +### Task 9: Frontend types + API client + +**Files:** +- Modify: `frontend/src/types/api.ts` (add `Profile`, extend `WorkoutKind`) +- Modify: `frontend/src/api/client.ts` (add profile endpoints, remove create/delete, extend update) + +**Interfaces:** +- Produces: `Profile` TS type, `api.getProfile()`, `api.updateProfile(profile)`. +- Removes: `api.createWorkoutKind`, `api.deleteWorkoutKind` (routes no longer exist server-side). + +- [ ] **Step 1: Extend `WorkoutKind` and add `Profile` to `types/api.ts`** + +In `frontend/src/types/api.ts`, find the existing `WorkoutKind` interface: + +```ts +export interface WorkoutKind { + ID: number; + Name: string; + Description: string; + Color: string; + RuleJSON: string; + Priority: number; + IsActive: boolean; + CreatedAt: string; + UpdatedAt: string; +} +``` + +Replace it with: + +```ts +export interface WorkoutKind { + ID: number; + Name: string; + Description: string; + Color: string; + RuleJSON: string; + Priority: number; + IsActive: boolean; + CreatedAt: string; + UpdatedAt: string; + pace_min_sec_per_km: number | null; + pace_max_sec_per_km: number | null; + expected_hr_zone: number | null; +} + +export interface Profile { + GarminEmail: string; + GarminPassword: string; + RollingWindowDays: number; + MaxHeartRate: number | null; + RestingHeartRate: number | null; + HRZone1MinPct: number; + HRZone1MaxPct: number; + HRZone2MinPct: number; + HRZone2MaxPct: number; + HRZone3MinPct: number; + HRZone3MaxPct: number; + HRZone4MinPct: number; + HRZone4MaxPct: number; + HRZone5MinPct: number; + HRZone5MaxPct: number; + EasyWarmupMinutes: number; + EasyCooldownMinutes: number; + LongWarmupMinutes: number; + LongCooldownMinutes: number; + TempoWarmupMinutes: number; + TempoCooldownMinutes: number; + Threshold30WarmupMinutes: number; + Threshold30CooldownMinutes: number; + Threshold60WarmupMinutes: number; + Threshold60CooldownMinutes: number; + MASTestWarmupMinutes: number; + MASTestCooldownMinutes: number; + IntervalWarmupMinutes: number; + IntervalCooldownMinutes: number; + CreatedAt: string; + UpdatedAt: string; +} +``` + +- [ ] **Step 2: Update `api/client.ts`** + +In `frontend/src/api/client.ts`, add `Profile` to the type-only import at the top. It currently reads: + +```ts +import type { + Activity, + AuthResponse, + KindAssignment, + ProgressionMetric, + ProgressionPoint, + ReviewQueueItem, + SyncRun, + SyncStatus, + WorkoutKind, +} from "../types/api"; +``` + +Change to: + +```ts +import type { + Activity, + AuthResponse, + KindAssignment, + Profile, + ProgressionMetric, + ProgressionPoint, + ReviewQueueItem, + SyncRun, + SyncStatus, + WorkoutKind, +} from "../types/api"; +``` + +Then find the workout-kinds section of the `api` object: + +```ts + // Workout kinds + listWorkoutKinds: (includeInactive = false) => + request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), + createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) => + request("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }), + updateWorkoutKind: ( + id: number, + body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean }, + ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), + deleteWorkoutKind: (id: number) => request(`/api/workout-kinds/${id}`, { method: "DELETE" }), + reclassifyWorkoutKind: (id: number) => + request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), +``` + +Replace it with: + +```ts + // Workout kinds -- fixed taxonomy, no create/delete + listWorkoutKinds: (includeInactive = false) => + request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), + updateWorkoutKind: ( + id: number, + body: { + name: string; + description?: string; + color?: string; + rule: unknown; + priority?: number; + is_active?: boolean; + pace_min_sec_per_km?: number | null; + pace_max_sec_per_km?: number | null; + expected_hr_zone?: number | null; + }, + ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), + reclassifyWorkoutKind: (id: number) => + request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), + + // Profile + getProfile: () => request("/api/profile"), + updateProfile: (profile: Profile) => + request("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd frontend && npx tsc -b` +Expected: fails at this point with errors in `src/pages/WorkoutKinds.tsx` (still calling the now-removed `api.createWorkoutKind`/`api.deleteWorkoutKind`) — that's expected here; Task 11 fixes that page. Confirm the *only* errors are in `WorkoutKinds.tsx` before moving on. + +- [ ] **Step 4: Commit** + +```bash +cd frontend && git add src/types/api.ts src/api/client.ts +git commit -m "feat: add Profile type/endpoints, drop workout-kind create/delete from the API client" +``` +(This commit leaves the frontend in a temporarily non-compiling state until Task 11 — that's expected for an in-progress feature branch; if your workflow requires main to always build, squash Tasks 9-11 into one commit instead by holding off `git commit` until Task 11's Step 5.) + +--- + +### Task 10: Profile settings page + +**Files:** +- Create: `frontend/src/pages/Profile.tsx` +- Modify: `frontend/src/App.tsx` (add a "Profile" tab) + +**Interfaces:** +- Consumes: `api.getProfile`, `api.updateProfile` (Task 9). + +- [ ] **Step 1: Write the page** + +Create `frontend/src/pages/Profile.tsx`: + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { Profile as ProfileType } from "../types/api"; + +function NumberField({ + label, + value, + onChange, + step = 1, +}: { + label: string; + value: number; + onChange: (v: number) => void; + step?: number; +}) { + return ( + + ); +} + +function NullableNumberField({ + label, + value, + onChange, +}: { + label: string; + value: number | null; + onChange: (v: number | null) => void; +}) { + return ( + + ); +} + +export function Profile() { + const [profile, setProfile] = useState(null); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + api.getProfile().then(setProfile).catch((e) => setError(String(e))); + }, []); + + function set(key: K, value: ProfileType[K]) { + setProfile((p) => (p ? { ...p, [key]: value } : p)); + setSaved(false); + } + + async function save() { + if (!profile) return; + setError(null); + try { + const updated = await api.updateProfile(profile); + setProfile(updated); + setSaved(true); + } catch (e) { + setError(String(e)); + setSaved(false); + } + } + + if (!profile) { + return ( +
+

Profile

+ {error ?

{error}

:

Loading...

} +
+ ); + } + + return ( +
+

Profile

+ {error &&

{error}

} + {saved &&

Saved.

} + +
+ Garmin account + + +
+ +
+ Classification + set("RollingWindowDays", v)} + /> +
+ +
+ Heart rate + set("MaxHeartRate", v)} + /> + set("RestingHeartRate", v)} + /> + {([1, 2, 3, 4, 5] as const).map((zone) => ( +
+ set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)} + /> + set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)} + /> +
+ ))} +
+ +
+ Phase detection (warm-up / cool-down minutes) + {( + [ + ["Easy", "EasyWarmupMinutes", "EasyCooldownMinutes"], + ["Long", "LongWarmupMinutes", "LongCooldownMinutes"], + ["Tempo", "TempoWarmupMinutes", "TempoCooldownMinutes"], + ["Threshold 30'", "Threshold30WarmupMinutes", "Threshold30CooldownMinutes"], + ["Threshold 60'", "Threshold60WarmupMinutes", "Threshold60CooldownMinutes"], + ["MAS Test", "MASTestWarmupMinutes", "MASTestCooldownMinutes"], + ] as const + ).map(([label, warmupKey, cooldownKey]) => ( +
+ set(warmupKey, v)} + /> + set(cooldownKey, v)} + /> +
+ ))} +

+ Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings. +

+
+ + +
+ ); +} +``` + +- [ ] **Step 2: Add the "Profile" tab to `App.tsx`** + +In `frontend/src/App.tsx`, the current tab list and imports read: + +```tsx +import { useState } from "react"; +import "./App.css"; +import { GarminConnection } from "./components/GarminConnection"; +import { Dashboard } from "./pages/Dashboard"; +import { ReviewQueue } from "./pages/ReviewQueue"; +import { WorkoutKinds } from "./pages/WorkoutKinds"; + +const TABS = [ + { key: "dashboard", label: "Progression", Component: Dashboard }, + { key: "review", label: "Review Queue", Component: ReviewQueue }, + { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, +] as const; +``` + +Change to: + +```tsx +import { useState } from "react"; +import "./App.css"; +import { GarminConnection } from "./components/GarminConnection"; +import { Dashboard } from "./pages/Dashboard"; +import { Profile } from "./pages/Profile"; +import { ReviewQueue } from "./pages/ReviewQueue"; +import { WorkoutKinds } from "./pages/WorkoutKinds"; + +const TABS = [ + { key: "dashboard", label: "Progression", Component: Dashboard }, + { key: "review", label: "Review Queue", Component: ReviewQueue }, + { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, + { key: "profile", label: "Profile", Component: Profile }, +] as const; +``` + +- [ ] **Step 3: Typecheck** + +Run: `cd frontend && npx tsc -b` +Expected: still fails only on `WorkoutKinds.tsx` (Task 11) — confirm no new errors from `Profile.tsx` or `App.tsx`. + +- [ ] **Step 4: Commit** + +```bash +cd frontend && git add src/pages/Profile.tsx src/App.tsx +git commit -m "feat: add profile settings page" +``` + +--- + +### Task 11: `WorkoutKinds` page — drop create/delete, add pace range + expected HR zone + +**Files:** +- Modify: `frontend/src/pages/WorkoutKinds.tsx` (full rewrite — shown complete below) + +**Interfaces:** +- Consumes: `api.listWorkoutKinds`, `api.updateWorkoutKind` (Task 9, extended), `api.reclassifyWorkoutKind` (existing, unchanged). + +- [ ] **Step 1: Replace the full contents of `WorkoutKinds.tsx`** + +```tsx +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { WorkoutKind } from "../types/api"; + +const EXAMPLE_RULE = `{ + "match": "all", + "conditions": [ + { "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] }, + { "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 } + ] +}`; + +// Pace is entered/displayed as "m:ss" but sent to the API as whole seconds. +function parsePace(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + const match = /^(\d+):([0-5]?\d)$/.exec(trimmed); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +function formatPace(seconds: number | null): string { + if (seconds == null) return ""; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export function WorkoutKinds() { + const [kinds, setKinds] = useState([]); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [ruleText, setRuleText] = useState(EXAMPLE_RULE); + const [paceMinText, setPaceMinText] = useState(""); + const [paceMaxText, setPaceMaxText] = useState(""); + const [expectedZone, setExpectedZone] = useState(null); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + + function reload() { + api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e))); + } + + useEffect(reload, []); + + function startEdit(k: WorkoutKind) { + setEditingId(k.ID); + setName(k.Name); + setDescription(k.Description); + setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2)); + setPaceMinText(formatPace(k.pace_min_sec_per_km)); + setPaceMaxText(formatPace(k.pace_max_sec_per_km)); + setExpectedZone(k.expected_hr_zone); + setError(null); + } + + async function save() { + if (editingId === null) return; + + let rule: unknown; + try { + rule = JSON.parse(ruleText); + } catch { + setError("Rule is not valid JSON"); + return; + } + + const paceMin = parsePace(paceMinText); + const paceMax = parsePace(paceMaxText); + if (paceMinText.trim() !== "" && paceMin === null) { + setError("Min pace must look like m:ss, e.g. 4:30"); + return; + } + if (paceMaxText.trim() !== "" && paceMax === null) { + setError("Max pace must look like m:ss, e.g. 4:30"); + return; + } + + try { + await api.updateWorkoutKind(editingId, { + name, + description, + rule, + pace_min_sec_per_km: paceMin, + pace_max_sec_per_km: paceMax, + expected_hr_zone: expectedZone, + }); + setEditingId(null); + reload(); + } catch (e) { + setError(String(e)); + } + } + + async function reclassify(id: number) { + setBusyId(id); + try { + const res = await api.reclassifyWorkoutKind(id); + setError(`Reclassified ${res.reclassified} activities.`); + } catch (e) { + setError(String(e)); + } finally { + setBusyId(null); + } + } + + return ( +
+

Workout Kinds

+ {error &&

{error}

} + + + + + + + + + + + + + {kinds.map((k) => ( + + + + + + + + ))} + +
NameDescriptionPace rangeHR zone
{k.Name}{k.Description} + {k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null + ? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km` + : "—"} + {k.expected_hr_zone ?? "—"} + + +
+ + {editingId !== null && ( +
+ + +
+ + + +
+