There's no reason the Garmin token-store root and the SQLite database
file need to live near each other -- they're independent env vars
(GARMIN_TOKENSTORE, GENIUSRUN_DB_PATH) and should stay independently
configurable, including in their defaults. Revert the "next to DBPath"
default introduced in 77f9588 back to a plain ".garmin" relative to
the working directory, so pointing GENIUSRUN_DB_PATH elsewhere never
silently drags the token-store default along with it.
20 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 geniusrun/backend) is a personal web app that pulls running activities from Garmin Connect (via an embedded Python wrapper around garminconnect, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), 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 profile row scoped to the logged-in user, edited from the frontend's Profile screen. Every table holding synced/tunable data (profile, workout_kinds/workout_type_paces, activities and everything under it, sync_state/sync_runs) is scoped to a user_id, so each OIDC account gets its own fully isolated dataset — see Authentication below. There's no admin UI or profile switcher, and internal/config's env vars are limited to process-level plumbing (listen addr, DB path, the Garmin wrapper's Python interpreter path).
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, and docs/IDEAS.md for informal, not-yet-formalized ideas for what's next — check it at the start of a session for context on where to pick up.
Commands
Backend (from backend/):
- Run the server:
./start.sh(wrapsgo run ./cmd/geniusrundwith the DB path already set) orgo run ./cmd/geniusrunddirectly. The Garmin wrapper's Python interpreter defaults topython3onPATH; setGARMIN_WRAPPER_PYTHONif you need a specific one (e.g. a venv withgarminconnectinstalled). - 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 pointgeniusrundat that DB viaGENIUSRUN_DB_PATH. - The schema lives in one file,
internal/store/schema.sql, applied in full on everyOpen()(idempotent -- skipped if theuserstable already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so editschema.sqldirectly rather than appending a migration. After changing it, regenerate the schema doc:go run ./cmd/dumpschema(writesdocs/DATABASE.mdfrom the live schema, so it can't drift out of sync).
Frontend (from frontend/):
- Run dev server:
./start.sh(puts Homebrew'snode@22onPATHand runsnpm installifnode_modulesis missing) ornpm run devdirectly. SetVITE_API_BASE_URLif the backend isn't onlocalhost:8080. - Build:
npm run build(tsc -b && vite build) - Lint:
npm run lint(oxlint) - No frontend test suite exists yet.
Authentication & per-user data model
geniusrun requires login via an existing Keycloak realm (OIDC Authorization Code flow, backend-driven -- the browser never sees a Keycloak token, only geniusrun's own signed session cookie). Access additionally requires a specific realm role (GENIUSRUN_OIDC_REQUIRED_ROLE, default geniusrun-user) -- a successful Keycloak login alone is not sufficient if the realm is shared with other apps/users.
Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to its own geniusrun users row and its own fully isolated dataset — there is no shared profile/dataset across accounts, no admin UI, and no profile switcher. internal/api/usercontext.go's resolveUser middleware resolves the session's OIDC subject to a store.User (never blocking by itself); requireProvisionedUser 403s every data route until one exists. Every handler derives userID only from userIDFromContext(r.Context()) — never a URL param, query string, or request body field; this is the entire security boundary the isolation depends on. See docs/superpowers/plans/2026-07-25-per-user-profile.md for the full design/implementation history, including the store/API-layer scoping pattern and the bugs it took to get right (a mid-transaction SQLite PRAGMA foreign_keys no-op, and a chi middleware-ordering panic).
A brand-new OIDC subject with no users row yet is routed to a "Create your profile" screen (frontend/src/CreateProfile.tsx) instead of the app. POST /api/setup (display name only) provisions it — a users row, a default profile row, the 8-kind workout taxonomy, and an initial sync_state row, all in one transaction (store.ProvisionUser). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
Required env vars (config.Load() fails fast if any are unset, same pattern as the Garmin paths above): GENIUSRUN_BACKEND_URL, GENIUSRUN_OIDC_ISSUER_URL, GENIUSRUN_OIDC_CLIENT_ID, GENIUSRUN_OIDC_CLIENT_SECRET, GENIUSRUN_SESSION_SECRET (>=32 characters). Optional: GENIUSRUN_FRONTEND_URL (defaults to GENIUSRUN_BACKEND_URL -- set this when the frontend and backend are different origins, e.g. local dev), GENIUSRUN_OIDC_REQUIRED_ROLE, GENIUSRUN_SESSION_DURATION (default 720h). See docs/superpowers/specs/2026-07-24-oidc-authentication-design.md for the original login-gate design and internal/auth for its implementation; the per-user data model on top of it lives in internal/store (schema/queries) and internal/api/usercontext.go+setup.go (HTTP layer).
Repo layout
backend/
cmd/geniusrund/ main server entrypoint
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
cmd/dumpschema/ regenerates docs/DATABASE.md from the live schema -- run after editing schema.sql
internal/garmin/ Garmin Connect client: spawns an embedded Python wrapper (pyscript/wrapper.py, garminconnect) over a JSON-lines subprocess protocol (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; schema.sql is the whole schema (no migration history), users.go owns account provisioning
internal/sync/ orchestrates fetch -> store -> classify; one Service instance per user
internal/api/ HTTP handlers (chi router); usercontext.go/setup.go own the per-user access boundary
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/CreateProfile.tsx "Create your profile" screen shown to a brand-new, unprovisioned OIDC session
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 nestedconditions.- Leaf conditions:
{metric, op, value}.opis==,!=,>,>=,<,<=, orbetween(value is a 2-element array). - Supported metrics (see
internal/sync/mapping.go'sbuildMetricContext):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 viamin(AND) /max(OR) — no ML, fully explainable. needs_reviewtriggers (inclassify.Classify): zero kinds matched, 2+ kinds matched, or exactly one matched belowmin_confidence(default fromGENIUSRUN_MIN_CONFIDENCE, 0.6). All three populateCandidatesfor the review UI.- Interval detection (
classify.DetectIntervalPattern) trusts Garmin's own per-lapIntensityTypetagging (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_maxcomes fromprofile.max_heart_rate, not a static config value — nil/unset omits that metric from the context rather than erroring. - Race is special-cased:
is_raceis derived at sync time from Garmin's owneventType.typeKey == "race"(seeinternal/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.
Garmin integration (direct wrapper, no MCP)
internal/garmin spawns a small Python wrapper script (internal/garmin/pyscript/wrapper.py, embedded into the Go binary via go:embed — see docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md) that imports garminconnect directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: {"id","cmd","params"} in, {"id","result"} or {"id","error"} out. cmd is authenticate, complete_mfa, or a fully generic call ({"method": "<any garminconnect.Garmin method>", "args": {...}}) — there's no MCP layer, and the separate mcp-garmin repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
authenticate/complete_mfareturn structured JSON ({"status": "success"|"mfa_required"|"failed", "message": "..."}), not plain strings —internal/garmin/client.gomapsstatusdirectly toAuthStatus, no string pattern-matching.- The 10s "MFA required" timeout in
wrapper.py'sauthenticatehandler 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 whetherprompt_mfa()was actually invoked (wrapper.pylogs this to stderr for exactly this reason). wrapper.pypersists Garmin sessions via aGARMIN_TOKENSTOREenv var passed toGarmin.login(tokenstore=...)— without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant,GARMIN_TOKENSTORE(config.GarminTokenStoreRoot) is a root directory, not a single session path:api.Server.garminFornamespaces each user's subprocess under{root}/{userID}so concurrent users never share a Garmin session. If unset,config.Load()defaults it to a.garmindirectory relative to the working directory -- deliberately independent ofGENIUSRUN_DB_PATH, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.activityIdis a large int64 — never round-trip it throughfloat64/genericmap[string]anyJSON decoding, or it corrupts into scientific notation. Decode into typed structs (garmin.Activity, notmap[string]any).get_activity_detailsreturns raw per-second telemetry (activityDetailMetrics+metricDescriptors), not lap/split summaries. ItsmetricDescriptorsindex-to-field mapping is not stable across activities/devices —garmin.ExtractSamplesalways resolves fields by descriptor key, never by fixed array position.get_activity_splitsreturns the actual lap/split summaries (lapDTOs).get_workout_by_idreturns a structured Garmin workout's flattened steps (target pace/HR per step);internal/sync/mapping.go'salignWorkoutTargetszips these 1:1 against an activity's recorded laps (when the activity carries aworkout_idand the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band.- Each user has their own lazily-built, cached
garmin.Client(api.Server.garminFor, double-checked-locked, keyed byuserID). Saving that user's profile callsUpdateCredentialson their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session.
Data model conventions
See docs/DATABASE.md for the full, always-current schema (every table/column/index, regenerated via go run ./cmd/dumpschema -- see Commands above). The conventions below explain the why behind it; the doc itself is the ground truth for the what.
- Every table is scoped to a
user_id, one way or another.users(keyed by OIDC subject) anchors it.profile,workout_kinds,activities,sync_state,sync_runseach carry their ownuser_idcolumn and are queried with an explicitWHERE user_id = ?.laps,activity_samples,kind_assignments, andworkout_type_paceshave nouser_idcolumn of their own — they're always accessed through a specific owning row (an activity or a workout kind) via aJOIN/subquery back to that row'suser_id, since they're never queried except through that owner. Everyinternal/storemethod that touches any of this takes an explicituserIDparameter used in a realWHERE/JOINclause — accepting the parameter without using it to filter would be a real cross-user leak, not a style nit. kind_assignmentsis 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 byid.assignment_sourcedistinguishesmanualoverrides (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_jsonstore the full original Garmin JSON. Fields that are a pure untransformed copy of something already inraw_json(e.g.activity_name,activity_type, lapduration_seconds/avg_hr) are deliberately not modeled as their own columns and are instead decoded fresh at API-response time byinternal/api/display_fields.go(decodeActivityDisplayFields/decodeLapDisplayFields) — don't reintroduce a stored column for something derivable fromraw_jsonalone. (user_id, garmin_activity_id)is the natural idempotency key forUpsertActivity(ON CONFLICT ... DO UPDATE), safe to re-run on every sync pass — scoped by user so two different users' Garmin accounts can never collide even if their activity IDs coincided.sync_state(one row per user) tracks a backfill watermark (earliest_synced_date,backfill_complete) — since Garmin history is immutable once recorded,Service.Backfilluses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered).BackfillreadsProfile.BackfillHorizonDaysfresh on every call (not a fixedConfigfield), so widening it in the Profile page takes effect on the very next sync, no restart needed. "Sync now" (POST /api/sync/run) callsBackfillthenIncrementalSyncthenFillPendingDetailsin one pass — there's no separate "full backfill" trigger. "Reset all" (POST /api/sync/reset) deletes every activity belonging to that user (cascading to laps/samples/kind_assignments) and rewinds their watermark — the only way to get already-synced activities re-processed against newer schema fields, sinceFillPendingDetailsonly ever touches activities whose details were never fetched.- Live sync progress is exposed via
Service.Progress()(in-memory, mutex-guardedDone/Totalcounters, reset to zero when idle) and surfaced throughGET /api/sync/status. Sincesync.Serviceis one instance per user, this is naturally per-user too. The frontend'sGarminConnectionbanner 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. Each user gets their own independently-tunable copy of the same 8 kinds, seeded bystore.ProvisionUseron signup (UNIQUE(user_id, name), not a globalUNIQUE(name)— the same kind name is expected across different users).workout_type_pacesholds 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
profilecolumns, not hardcoded, consumed byExpectedVsActualChart. - 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/mockprovides a fakeClientfor tests that need to exerciseinternal/sync/internal/apiwithout a live subprocess.- No live Garmin account needed for frontend/UI work:
cmd/seedsampleprovisions one fixed"seedsample-user"account (viastore.ProvisionUser) then seeds realistic activities/laps/kinds under it through the real classification engine.
Testing conventions
- Table-driven Go tests throughout; test cases are inline, no separate fixture files.
internal/classifytests are pure (no DB/network): construct aMetricContext+RuleKinds directly.internal/storeandinternal/synctests open a real temp-file SQLite DB (store.Openagainstt.TempDir()) — deliberate, not mocked, since schema/SQL correctness is exactly what needs catching. Since every table isuser_id-scoped, tests provision a user first (db.ProvisionUser) and thread thatuserIDinto every call.internal/apitests usehttptestagainst aServerwired to a temp DB +mock.Client;newTestServerauto-provisions a"test-user"account matching the session cookiedoJSONmints, so most handler tests don't need to think about provisioning at all. Tests that specifically need an unprovisioned session (e.g. the setup flow, orresolveUser's not-found path) build a bareNewServerdirectly instead of usingnewTestServer.- Cross-user isolation is tested adversarially, not just in parallel —
internal/store/isolation_test.goandinternal/api/isolation_test.goprovision two real users and attempt real reads/writes against the other user's real row IDs, asserting on not-found/403/empty-result rather than merely checking two separately-created rows don't collide. Any new per-user-scoped feature should get the same treatment, not just a "two users each see their own data" happy-path test. - The Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via the real
geniusrundauth endpoints.