Initial commit: smartrun MVP
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>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
# Profile, workout taxonomy & analysis engine foundations
|
||||
|
||||
**Status:** Draft — approved by user, pending implementation planning
|
||||
**Date:** 2026-07-17
|
||||
|
||||
## Context
|
||||
|
||||
smartrun currently classifies runs into arbitrary, user-created "workout
|
||||
kinds" using absolute pace/HR thresholds, and has no concept of a user
|
||||
profile — Garmin credentials are passed as environment variables at process
|
||||
start, and there is exactly one hardcoded set of engine parameters.
|
||||
|
||||
This spec refines that into a fixed running-specific taxonomy, replaces
|
||||
absolute-pace classification with structural and relative-to-self signals,
|
||||
adds warm-up/work/cool-down phase detection with per-phase heart-rate
|
||||
analysis, and introduces a single-profile settings model so all of this is
|
||||
tunable without redeploying.
|
||||
|
||||
Two related features are explicitly **out of scope** for this spec, per the
|
||||
user's direction to discuss them later once this foundation exists:
|
||||
|
||||
- **Deep per-phase workout analysis** beyond the HR metrics described in
|
||||
section 5 (the user has more detailed expectations to describe once phase
|
||||
segmentation exists to hang them on).
|
||||
- **Adaptive pace recommendation** ("delta" between the user's declared pace
|
||||
ranges and workout-derived ones) — still undecided whether this needs an
|
||||
AI component or can be fully deterministic.
|
||||
|
||||
The pace ranges and expected HR zones introduced here exist as **data
|
||||
capture** for that future delta feature; nothing in this spec computes or
|
||||
displays a delta.
|
||||
|
||||
## Goals
|
||||
|
||||
1. A single-profile settings model: Garmin credentials + all tunable engine
|
||||
parameters, stored in SQLite instead of environment variables, edited
|
||||
through one settings screen.
|
||||
2. A fixed, 7-type running workout taxonomy replacing the current
|
||||
user-created "workout kinds," each with a user-editable target pace
|
||||
range (no history — the synced activity log *is* the history).
|
||||
3. Classification driven by structure and by an activity's *relative*
|
||||
standing among its own recent history — never by matching the user's
|
||||
declared target pace ranges.
|
||||
4. Deterministic warm-up/work/cool-down phase detection per activity, with
|
||||
per-phase heart-rate analysis (zone, drift, basic recovery signal),
|
||||
configurable per workout type.
|
||||
5. A reusable "classification preview" — every activity scored against
|
||||
every workout type at once — as the shared tool for iteratively tuning
|
||||
natural-language rule definitions against real history.
|
||||
6. A "recompute" action that re-runs phase detection and reclassification
|
||||
across all activities, since relative metrics and phase parameters can
|
||||
change what a past activity should have been classified as.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Multi-profile switching / multi-tenant data (explicitly declined — one
|
||||
active profile at a time; the schema does not need per-row profile
|
||||
scoping, a singleton row is sufficient).
|
||||
- The delta/recommendation calculation itself (section above).
|
||||
- Deep per-phase analysis beyond HR zone/drift/basic recovery signal.
|
||||
- Any AI/LLM involvement at runtime — classification and phase detection
|
||||
are fully deterministic, tuned offline against real data before being
|
||||
encoded as rules/parameters.
|
||||
|
||||
## 1. Profile
|
||||
|
||||
A new **`profile`** table, a singleton row (`id = 1`, following the existing
|
||||
`sync_state` pattern), replacing environment-variable Garmin credentials and
|
||||
consolidating all tunable engine parameters:
|
||||
|
||||
- `garmin_email`, `garmin_password` — moved out of env vars; entered once
|
||||
via the profile screen.
|
||||
- `rolling_window_days` (default `90`) — the population window for
|
||||
relative-classification metrics (section 3).
|
||||
- `max_heart_rate`, `resting_heart_rate` — inputs to the Heart Rate Reserve
|
||||
(Karvonen) calculation used for zone detection (section 5).
|
||||
- Five HR zone ranges, each a `(min_pct, max_pct)` pair of "% of heart rate
|
||||
reserve," seeded with standard defaults (Z1 50–60, Z2 60–70, Z3 70–80,
|
||||
Z4 80–90, Z5 90–100) and user-editable.
|
||||
- Per-workout-type phase-detection parameters (section 4) — e.g.
|
||||
`easy_warmup_minutes`, `easy_cooldown_minutes`, `interval_warmup_minutes`,
|
||||
one pair per non-lap-based type.
|
||||
|
||||
`internal/config` (env-var loading) shrinks to just `SMARTRUN_ADDR`,
|
||||
`SMARTRUN_DB_PATH`, and the mcp-garmin subprocess paths — everything
|
||||
runtime-tunable moves into `profile`.
|
||||
|
||||
**Validation** (rejected at save time, not silently accepted):
|
||||
resting HR < max HR; zone ranges ascending and non-overlapping,
|
||||
collectively covering 0–100%.
|
||||
|
||||
**Credential updates & re-authentication.** `garmin.Client` currently takes
|
||||
a fixed `Config` (including email/password) at construction and lazily
|
||||
spawns the mcp-garmin subprocess on first use — credentials are only ever
|
||||
whatever the process was launched with. Saving new Garmin credentials via
|
||||
the profile screen must:
|
||||
|
||||
1. Update the running `garmin.Client`'s credentials in memory.
|
||||
2. Reset its "started" state and terminate any already-spawned subprocess
|
||||
(which would otherwise still be running under the *old* credentials).
|
||||
|
||||
The next "Connect to Garmin" click — the connect button and MFA-code entry
|
||||
UI already built for the previous single-env-var-credential model — then
|
||||
spawns a fresh subprocess with the newly-saved credentials and proceeds
|
||||
through the existing authenticate/MFA flow unchanged. No new UI is needed;
|
||||
saving the profile just makes that existing flow reachable at any time,
|
||||
not only right after process launch.
|
||||
|
||||
## 2. Workout taxonomy & pace ranges
|
||||
|
||||
The existing `workout_kinds` table is reseeded with exactly seven fixed
|
||||
rows and loses its "create new" / "delete" affordances in the UI — only
|
||||
`rule_json` (and the new phase/HR fields below) remain editable per type:
|
||||
|
||||
`Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`,
|
||||
`Interval`, `MAS Test`.
|
||||
|
||||
A new **`workout_type_paces`** table, one row per workout kind:
|
||||
|
||||
- `pace_min_sec_per_km`, `pace_max_sec_per_km` — entered/displayed as
|
||||
`m:ss–m:ss`.
|
||||
- `expected_hr_zone` — which of the 5 zones this type should predominantly
|
||||
sit in (e.g. Easy Run → Zone 2), used later by the per-phase HR check
|
||||
(section 5), not by classification.
|
||||
|
||||
Neither field has history — overwritten in place when the user updates
|
||||
them, per the user's explicit direction (the activity log is the history).
|
||||
|
||||
## 3. Classification: relative & structural metrics
|
||||
|
||||
New metrics available to `internal/classify`'s condition-tree engine,
|
||||
computed fresh at classification time (no cached/materialized column):
|
||||
|
||||
- **`distance_percentile`, `duration_percentile`** — this activity's rank
|
||||
(0–1) among all *running* activities (any type except MAS Test) whose
|
||||
start date falls within `rolling_window_days` of *this activity's own
|
||||
date* (not "today" — so reclassifying an old activity is stable and
|
||||
doesn't depend on when reclassification happens to run). Computed via a
|
||||
straightforward SQL ranking query over `activities`.
|
||||
- **`lap_pace_consistency`** — coefficient of variation of pace across the
|
||||
work phase specifically (depends on section 4's phase boundaries), a low
|
||||
value indicating "constant pace" — the structural signal the user
|
||||
described for Long Run, replacing any pace-target comparison.
|
||||
- Existing metrics (`lap_interval_pattern`, `lap_hr_drift_bpm_per_min`,
|
||||
`lap_hr_recovery_bpm_per_min`, `aerobic_training_effect`, etc.) remain
|
||||
available unchanged.
|
||||
|
||||
**`ReclassifyAll`**: a new bulk operation alongside the existing per-activity
|
||||
`ClassifyActivity`, iterating every stored activity and re-evaluating it.
|
||||
Necessary because relative metrics depend on the whole population — a
|
||||
rolling-window setting change (or a new activity altering others'
|
||||
percentile rank) can change what an old activity should be classified as,
|
||||
not just new ones. Per-activity failures are logged and counted, not fatal
|
||||
to the batch.
|
||||
|
||||
**Classification preview**: a new read path (`GET
|
||||
/api/classification-preview` or similar) returning, for every activity,
|
||||
*every* workout type's evaluation — matched or not, with score — not just
|
||||
the ones that crossed the confidence threshold (unlike `kind_assignments`,
|
||||
which only records matched candidates). This is the shared tool for the
|
||||
natural-language rule-tuning sessions: change a rule, hit preview, see the
|
||||
whole table shift.
|
||||
|
||||
## 4. Phase segmentation
|
||||
|
||||
A new **`activity_phases`** table: one row per detected phase per activity
|
||||
— `activity_id`, `phase_label` (`warmup` / `work` / `cooldown` for
|
||||
continuous types; `warmup` / `work_1` / `rest_1` / `work_2` / ... /
|
||||
`cooldown` for Interval), `start_elapsed_seconds`, `end_elapsed_seconds`.
|
||||
|
||||
Two phase-detection strategies, selected per workout type (not one
|
||||
one-size-fits-all algorithm):
|
||||
|
||||
- **`fixed_duration`** (Easy, Long, Tempo, Threshold-30/60, first pass at
|
||||
MAS Test): warm-up = first *N* configured minutes (`profile`'s
|
||||
per-type setting), cool-down = last *M* configured minutes, work =
|
||||
everything between.
|
||||
- **`lap_intensity`** (Interval): reuses the existing Garmin lap
|
||||
`IntensityType` tagging (ACTIVE/REST) built earlier — warm-up = before
|
||||
the first ACTIVE lap, work = the ACTIVE/REST lap sequence itself (each
|
||||
rep an individually labeled phase), cool-down = after the last one.
|
||||
|
||||
Phases are computed as part of the existing post-sync detail-fill step
|
||||
(`internal/sync`), stored once, and only recomputed via the explicit
|
||||
recompute action (section 6) — the same lifecycle as classification.
|
||||
|
||||
**Per-phase heart-rate analysis**, computed from `activity_samples` within
|
||||
each phase's elapsed-time window:
|
||||
|
||||
- `avg_hr`, `max_hr` over the phase.
|
||||
- `hr_zone` — the phase's average HR mapped to one of the 5 Karvonen zones
|
||||
from `profile`, compared against that workout type's `expected_hr_zone`
|
||||
(section 2) so a mismatch (e.g. an Easy Run run in Zone 4) is visible.
|
||||
- `hr_drift_bpm_per_min` — reuses the existing lap-level drift regression
|
||||
(already generic over any sample window), applied to the phase's window
|
||||
instead of a lap's.
|
||||
- A basic recovery signal for cool-down/rest phases (reusing the existing
|
||||
HR-recovery regression). The more nuanced version the user described —
|
||||
correlating HR fall against recovery *pace*, not HR alone — captures its
|
||||
raw ingredients here (per-phase HR trend + per-phase pace) but the
|
||||
composite "recovery quality" metric itself is deferred to the future deep
|
||||
per-phase analysis discussion.
|
||||
|
||||
## 5. Settings panel & frontend
|
||||
|
||||
- **Profile screen**: Garmin credentials, rolling window, max/resting HR,
|
||||
the 5 zone ranges, per-type phase parameters, and (from section 2)
|
||||
per-type pace ranges + expected HR zone. One screen, one save, validated
|
||||
per section 1.
|
||||
- **Recompute action**: re-runs phase segmentation then `ReclassifyAll`
|
||||
across all activities. Reuses the existing sync-progress-banner pattern
|
||||
(live done/total) since this walks the whole history.
|
||||
- **Classification preview page**: the table from section 3.
|
||||
- **Activity detail page** (new — no per-activity view exists today):
|
||||
pace/HR-vs-time chart (Recharts) with phase bands overlaid as
|
||||
`ReferenceArea`s, plus a per-phase readout (HR zone, drift, avg pace).
|
||||
|
||||
## 6. Testing & error handling
|
||||
|
||||
- Phase detection and the new classification metrics are pure functions
|
||||
tested against fixture sample/lap data, independent of the database or
|
||||
Garmin — same pattern as the existing `internal/classify` tests.
|
||||
- `ReclassifyAll` and the phase-recompute pass are per-activity fault
|
||||
isolated: one activity's failure is logged and counted, not fatal to the
|
||||
batch; the recompute status reports a failure count alongside progress.
|
||||
- Profile save validates HR zone ranges (ascending, non-overlapping, full
|
||||
0–100% coverage) and resting-HR-less-than-max-HR before persisting,
|
||||
returning a specific error message rather than accepting invalid state.
|
||||
|
||||
## Open questions carried forward (not blocking this spec)
|
||||
|
||||
- Exact per-type natural-language rule definitions and phase parameters —
|
||||
to be tuned interactively against the user's real Garmin history using
|
||||
the classification preview (section 3) once this foundation is built.
|
||||
- The deep per-phase analysis feature beyond HR zone/drift/recovery.
|
||||
- Whether the pace/HR-zone delta recommendation is AI-assisted or fully
|
||||
deterministic.
|
||||
Reference in New Issue
Block a user