Garmin run classification and progression tracker. Go backend (MCP client to mcp-garmin, SQLite store, deterministic rule engine, REST API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.6 KiB
name, description
| name | description |
|---|---|
| smartrun-dev | Use when working on the smartrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions. |
smartrun-dev
Project overview
smartrun 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/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)
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.
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'sparseAuthResultpattern-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 whetherprompt_mfa()was actually invoked (mcp-garmin now logs this to stderr for exactly this reason). - mcp-garmin persists Garmin sessions via a
GARMIN_TOKENSTOREenv var (default~/.garth) 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_details()returns raw per-second telemetry (activityDetailMetrics+metricDescriptors), not lap/split summaries, despite what its docstring used to say. ItsmetricDescriptorsindex-to-field mapping is not stable across activities/devices —garmin.ExtractSamplesalways resolves fields by descriptor key, never by fixed array position.get_activity_splits()(added to mcp-garmin, wrapsgarminconnect's existingget_activity_splits) is the one that returns 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. WideningBackfillHorizonDaysafter a completed backfill correctly triggers resumption further back, not a full re-fetch.- 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/smartrund(needsMCP_GARMIN_PYTHON,MCP_GARMIN_SERVER,GARMIN_EMAIL,GARMIN_PASSWORDenv vars; seeinternal/config/config.gofor all knobs). - 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 pointsmartrundat 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 MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via
cmd/mcpspikeor the realsmartrundauth endpoints.