Files
geniusrun/CLAUDE.md
Christophe Vila 2fab788208 Fix Garmin connect race, surface tool error content, add dev start scripts
- GarminConnection now flushes Profile's pending debounced autosave before
  connecting, avoiding a race where Connect fires with stale credentials.
- client.go reads res.Content before checking IsError so tool error
  messages actually include mcp-garmin's response text.
- seedsample: update kind lookups to match current taxonomy names
  ("Easy Run" -> "Easy", "Interval" -> "Intervals").
- Add backend/start.sh and frontend/start.sh dev launch scripts, and
  check in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 18:33:49 +02:00

13 KiB

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):

{
  "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/devicesgarmin.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 + RuleKinds 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.