Files
geniusrun/.claude/skills/geniusrun-dev/SKILL.md

9.5 KiB

name, description
name description
geniusrun-dev Use when working on the geniusrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions.

geniusrun-dev

Project overview

geniusrun 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/geniusrund/     main server entrypoint
  cmd/seedsample/     inserts synthetic data for frontend dev/demoing without live Garmin creds
  internal/garmin/     Garmin Connect client: spawns an embedded Python wrapper over a JSON-lines subprocess protocol
  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):

{
  "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/devicesgarmin.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. 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, and correctly triggers resumption further back rather than a full re-fetch. "Sync now" (POST /api/sync/run) calls Backfill then IncrementalSync then FillPendingDetails in one pass — there's no separate "full backfill" trigger anymore. "Reset all" (POST /api/sync/reset) is the destructive counterpart: deletes every activity (cascading to laps/samples/kind_assignments) and rewinds the watermark, so the next sync is a genuinely fresh pull — the only way to get already-synced activities re-processed against newer schema fields (e.g. a newly-added metric), 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 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/geniusrund (needs MCP_GARMIN_PYTHON/MCP_GARMIN_SERVER env vars for the mcp-garmin subprocess paths; see internal/config/config.go for all knobs). Garmin credentials are not env vars — they live in the profile singleton row (internal/store.Profile), set via the frontend's Profile page or directly through PUT /api/profile.
  • 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 geniusrund 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 + RuleKinds 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 Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real geniusrund auth endpoints.