_startup_login() ran garminconnect's login synchronously with no timeout before main() ever started reading stdin, so a slow/rate-limited Garmin login could wedge a user's whole subprocess before it became responsive. Give it the same background-thread + bounded-10s-timeout shape _handle_authenticate already uses, with tests for both the fast-success and timeout paths. Also refreshes .claude/skills/geniusrun-dev/SKILL.md (still describing the retired mcp-garmin MCP architecture) and three stale doc comments (garmin.AuthStatus, config.GarminTokenStoreRoot, mock package doc) left over from the direct-wrapper migration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
name, description
| name | description |
|---|---|
| geniusrun-dev | Use when working on the geniusrun repo (backend Go services, classification rule engine, 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 an embedded Python wrapper around garminconnect, spoken to over a JSON-lines subprocess protocol — see the Garmin integration section below), 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 (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 + 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 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,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 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 — 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.
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) 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.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).
Data model conventions
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.activities.raw_json/details_raw_jsonhedge 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_idis the natural idempotency key forUpsertActivity(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.Backfilluses 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.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, and correctly triggers resumption further back rather than a full re-fetch. "Sync now" (POST /api/sync/run) callsBackfillthenIncrementalSyncthenFillPendingDetailsin 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), sinceFillPendingDetailsonly ever touches activities whose details were never fetched.- Live sync progress is exposed via
Service.Progress()(in-memory, mutex-guardedDone/Totalcounters set byFillPendingDetails, reset to zero when idle) and surfaced throughGET /api/sync/status(detail_fill_progress, plusactivities_pending_detailsfor the total remaining beyond the current batch). The frontend'sGarminConnectionbanner polls this and shows "syncing: N/M activities" live.
Dev workflow
- Backend:
cd backend && go run ./cmd/geniusrund. The Garmin wrapper's Python interpreter defaults topython3onPATH; setGARMIN_WRAPPER_PYTHONif you need a specific one (e.g. a venv withgarminconnectinstalled) — seeinternal/config/config.gofor all knobs. Garmin credentials are not env vars — they live in theprofilesingleton row (internal/store.Profile), set via the frontend's Profile page or directly throughPUT /api/profile. - Frontend:
cd frontend && npm run dev(setVITE_API_BASE_URLif the backend isn't onlocalhost:8080). - No live Garmin account needed for frontend/UI work:
go run ./cmd/seedsample -db /tmp/sample.dbseeds realistic activities/laps/kinds and runs them through the real classification engine, then pointgeniusrundat that DB. internal/garmin/mockprovides a fakeClientfor tests that need to exerciseinternal/sync/internal/apiwithout 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 aschema_migrationstable).
Testing conventions
- Table-driven Go tests throughout; no separate fixture files needed yet given the codebase's size — test cases are inline.
internal/classifytests are pure (no DB/network): construct aMetricContext+RuleKinds directly.internal/storeandinternal/synctests open a real temp-file SQLite DB (store.Openagainstt.TempDir()) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching.internal/apitests usehttptestagainst aServerwired 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
geniusrundauth endpoints.