diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9ac4eeb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project overview + +geniusrun (Go module `smartrun/backend`) is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into one of a fixed set of running-specific "workout kinds" (Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race), 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. + +All Garmin credentials and every tunable analysis-engine parameter (HR zones, phase-detection minutes, pace-artifact filtering, chart colors, etc.) live in a single-row `profile` table, edited from the frontend's Profile screen — there is no multi-profile support, and `internal/config`'s env vars are limited to process-level plumbing (listen addr, DB path, mcp-garmin subprocess paths). + +The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty stub — a future training-recommendation engine (analyzing training-effect balance across kinds, and a pace/HR-zone "delta" between declared targets and workout-derived reality) is deferred; see `docs/superpowers/specs/` and `docs/superpowers/plans/` for the design history behind what's already built and what's still open. + +## Commands + +Backend (from `backend/`): +- Run the server: `./start.sh` (wraps `go run ./cmd/smartrund` with the local mcp-garmin subprocess paths and DB path already set) or `go run ./cmd/smartrund` directly if you export `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` yourself. +- Build/vet: `go build ./...` && `go vet ./...` +- Format: `gofmt -l .` must report nothing before committing. +- All tests: `go test ./...` +- Single package: `go test ./internal/store/...` +- Single test: `go test ./internal/store/... -run TestProfile -v` +- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `smartrund` at that DB via `SMARTRUN_DB_PATH`. +- Migrations live in `internal/store/migrations/`; add a new numbered file, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table). + +Frontend (from `frontend/`): +- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`. +- Build: `npm run build` (`tsc -b && vite build`) +- Lint: `npm run lint` (oxlint) +- No frontend test suite exists 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, get_workout_by_id) + 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 (process-level only, not user-tunable params) +frontend/ + src/pages/ ReviewQueue ("Activities" tab), Dashboard ("Progression" tab), Plan (stub), Profile (reached via the profile-name button, not a tab) + src/components/charts/ Recharts wrappers (ExpectedVsActualChart, ProgressionChart) + src/components/ ColorField, PaceField, NullableNumberField, RawDataModal, TrainingTypesCard + 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`, `is_race`, `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 from `SMARTRUN_MIN_CONFIDENCE`, 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. +- **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. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery. +- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring. +- **Race** is special-cased: `is_race` is derived at sync time from Garmin's own `eventType.typeKey == "race"` (see `internal/sync/mapping.go`), not a rule the user tunes. Once an activity is assigned Race (or manually overridden to any kind), `POST /api/reclassify` (`handleReclassifyAll`) skips it — Race is a hard Garmin fact and manual assignments are the user's definitive word, neither is ever silently overwritten by a global reclassify. + +## 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()`** returns the actual lap/split summaries (`lapDTOs`). +- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band. +- Garmin credentials are set via `garmin.Client.UpdateCredentials` when the profile is saved, which resets the client's "started" state and terminates any already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow. + +## 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`. `assignment_source` distinguishes `manual` overrides (locked against future global reclassifies) from rule-engine assignments. +- **Raw-JSON is the source of truth for anything not actively computed on.** `activities.raw_json`/`details_raw_json`/`workout_raw_json` store the full original Garmin JSON. Fields that are a pure untransformed copy of something already in `raw_json` (e.g. `activity_name`, `activity_type`, lap `duration_seconds`/`avg_hr`) were dropped from their own columns entirely (migration `0013_dedup_activity_lap_columns.sql`) and are instead decoded fresh at API-response time by `internal/api/display_fields.go` (`decodeActivityDisplayFields`/`decodeLapDisplayFields`) — don't reintroduce a stored column for something derivable from `raw_json` alone. +- `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). `Backfill` reads `Profile.BackfillHorizonDays` fresh on every call (not a fixed `Config` field), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (`POST /api/sync/run`) calls `Backfill` then `IncrementalSync` then `FillPendingDetails` in one pass — there's no separate "full backfill" trigger. "Reset all" (`POST /api/sync/reset`) deletes every activity (cascading to laps/samples/kind_assignments) and rewinds the watermark — the only way to get already-synced activities re-processed against newer schema fields, since `FillPendingDetails` only ever touches activities whose details were never fetched. +- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters, reset to zero when idle) and surfaced through `GET /api/sync/status`. The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live. +- The 8-type taxonomy (`workout_kinds`) is a fixed, closed set with a fixed display order (`priority DESC, name`) — there's no create/delete UI, only rule/pace/HR-zone/color editing per type. `workout_type_paces` holds each type's target pace range + expected HR zone, informational only (never read by the classification engine) — no history, overwritten in place, since the synced activity log is the history. +- Chart colors (pace/HR line colors, warmup/effort/recovery/cooldown phase colors) are user-editable `profile` columns, not hardcoded, consumed by `ExpectedVsActualChart`. +- Pace-artifact filtering (`profile.min_representative_pace_sec_per_km`/`min_representative_time_seconds`) drops brief slow-pace blips (GPS/motion settling at recording start) from the Review Queue chart unless they persist long enough to be a real stop/walk break. + +## Dev workflow + +- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. +- No live Garmin account needed for frontend/UI work: `cmd/seedsample` seeds realistic activities/laps/kinds through the real classification engine. + +## Testing conventions + +- Table-driven Go tests throughout; test cases are inline, no separate fixture files. +- `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()`) — deliberate, not mocked, since 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/backend/cmd/seedsample/main.go b/backend/cmd/seedsample/main.go index b134522..45440eb 100644 --- a/backend/cmd/seedsample/main.go +++ b/backend/cmd/seedsample/main.go @@ -43,9 +43,9 @@ func main() { // gap between disjoint ranges. The taxonomy migration already seeded // these rows (by fixed name) -- update their rules in place rather than // creating new ones, since names are unique. - easyID := mustFindKindID(ctx, db, "Easy Run") + easyID := mustFindKindID(ctx, db, "Easy") must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ - ID: easyID, Name: "Easy Run", Description: "Easy conversational runs", Color: "#22c55e", + ID: easyID, 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} @@ -63,9 +63,9 @@ func main() { IsActive: true, })) - intervalID := mustFindKindID(ctx, db, "Interval") + intervalID := mustFindKindID(ctx, db, "Intervals") must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{ - ID: intervalID, Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444", + ID: intervalID, Name: "Intervals", Description: "Structured work/rest intervals", Color: "#ef4444", RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`, IsActive: true, })) diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go index f3bab3a..c5be4f7 100644 --- a/backend/internal/garmin/client.go +++ b/backend/internal/garmin/client.go @@ -146,15 +146,15 @@ func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]a 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) } } + if res.IsError { + return "", fmt.Errorf("tool %s returned an error result: %s", name, out.String()) + } return out.String(), nil } diff --git a/backend/start.sh b/backend/start.sh new file mode 100755 index 0000000..d566563 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +export MCP_GARMIN_PYTHON="${MCP_GARMIN_PYTHON:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/.venv/bin/python3}" +export MCP_GARMIN_SERVER="${MCP_GARMIN_SERVER:-/Users/cvila/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py}" +export SMARTRUN_DB_PATH="${SMARTRUN_DB_PATH:-smartrun.db}" + +exec go run ./cmd/smartrund diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5662b89..ba22e10 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -212,9 +212,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -232,9 +229,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -252,9 +246,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -272,9 +263,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -292,9 +280,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -312,9 +297,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -332,9 +314,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -352,9 +331,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -551,9 +527,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -571,9 +544,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -591,9 +561,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -611,9 +578,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -631,9 +595,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -651,9 +612,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1252,9 +1210,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1276,9 +1231,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1300,9 +1252,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1324,9 +1273,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/frontend/src/components/GarminConnection.tsx b/frontend/src/components/GarminConnection.tsx index 053fb41..81ec159 100644 --- a/frontend/src/components/GarminConnection.tsx +++ b/frontend/src/components/GarminConnection.tsx @@ -14,7 +14,7 @@ function syncProgressLabel(status: SyncStatus): string { return "syncing..."; } -export function GarminConnection() { +export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () => Promise }) { const [auth, setAuth] = useState(null); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); @@ -50,6 +50,7 @@ export function GarminConnection() { setBusy(true); setError(null); try { + await onBeforeConnect?.(); setAuth(await api.login()); setDisconnected(false); } catch (e) { diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 8d9e33e..41aea2c 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -88,6 +88,18 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { saveTimeoutRef.current = setTimeout(() => persist(next), AUTO_SAVE_DELAY_MS); } + // GarminConnection's "Connect" button reads credentials from the backend's + // in-memory profile, which only updates once the debounced autosave above + // actually fires -- clicking Connect right after typing a password would + // otherwise race it and authenticate with stale (possibly empty) creds. + async function flushPendingSave() { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + if (profileRef.current) await persist(profileRef.current); + } + } + if (!profile) { return (
@@ -132,7 +144,7 @@ export function Profile({ onSaved }: { onSaved?: (p: ProfileType) => void }) { value={profile.BackfillHorizonDays} onChange={(v) => set("BackfillHorizonDays", v)} /> - + {/* TODO: these settings currently have no explanation in the UI -- diff --git a/frontend/start.sh b/frontend/start.sh new file mode 100755 index 0000000..cbadf08 --- /dev/null +++ b/frontend/start.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" + +# node/npm are installed via Homebrew (node@22) but not linked onto PATH. +export PATH="/opt/homebrew/opt/node@22/bin:$PATH" + +if [ ! -d node_modules ]; then + npm install +fi + +exec npm run dev