# Profile & Taxonomy Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace env-var-only Garmin credentials and the arbitrary user-created "workout kinds" with a single-profile settings model (credentials + tunable engine parameters) and a fixed 7-type running taxonomy, each with a user-editable pace range and expected HR zone. **Architecture:** Extends the existing Go backend (`internal/store`, `internal/api`, `internal/sync`, `internal/garmin`) and React frontend in place — no new services. `profile` is a singleton SQLite row (same pattern as the existing `sync_state`). The 7 workout types reuse the existing `workout_kinds` table and rule engine, reseeded and stripped of create/delete affordances. A new `workout_type_paces` table holds per-type pace range + expected HR zone, informational only (not read by classification). **Tech Stack:** Go 1.26, `modernc.org/sqlite`, `go-chi/chi`, React 19 + TypeScript + Vite, Recharts (unchanged). ## Global Constraints - No AI/LLM involvement at runtime — everything in this plan is deterministic Go/TS code and SQL. - Single active profile only — no multi-profile switcher UI, no per-row profile scoping elsewhere in the schema (per spec section 1 non-goals). - Pace ranges and expected HR zone are never read by the classification rule engine (per spec section 2) — they exist purely as data capture for the future delta feature. - `gofmt -l .` must report nothing and `go vet ./...` must pass before any commit. - Every new Go file follows the existing package doc-comment convention (a `// Package x ...` comment on the first file that introduces a package, none on subsequent files in the same package). --- ### Task 1: `profile` table + store layer **Files:** - Create: `backend/internal/store/migrations/0003_profile.sql` - Create: `backend/internal/store/profile.go` - Test: `backend/internal/store/profile_test.go` **Interfaces:** - Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`. - [ ] **Step 1: Write the migration** Create `backend/internal/store/migrations/0003_profile.sql`: ```sql -- Single-profile settings: Garmin credentials (replacing env-var-only -- config) and every tunable engine parameter, in one editable row. CREATE TABLE profile ( id INTEGER PRIMARY KEY CHECK (id = 1), garmin_email TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '', rolling_window_days INTEGER NOT NULL DEFAULT 90, max_heart_rate REAL, resting_heart_rate REAL, hr_zone1_min_pct REAL NOT NULL DEFAULT 50, hr_zone1_max_pct REAL NOT NULL DEFAULT 60, hr_zone2_min_pct REAL NOT NULL DEFAULT 60, hr_zone2_max_pct REAL NOT NULL DEFAULT 70, hr_zone3_min_pct REAL NOT NULL DEFAULT 70, hr_zone3_max_pct REAL NOT NULL DEFAULT 80, hr_zone4_min_pct REAL NOT NULL DEFAULT 80, hr_zone4_max_pct REAL NOT NULL DEFAULT 90, hr_zone5_min_pct REAL NOT NULL DEFAULT 90, hr_zone5_max_pct REAL NOT NULL DEFAULT 100, easy_warmup_minutes REAL NOT NULL DEFAULT 10, easy_cooldown_minutes REAL NOT NULL DEFAULT 5, long_warmup_minutes REAL NOT NULL DEFAULT 10, long_cooldown_minutes REAL NOT NULL DEFAULT 5, tempo_warmup_minutes REAL NOT NULL DEFAULT 15, tempo_cooldown_minutes REAL NOT NULL DEFAULT 10, threshold30_warmup_minutes REAL NOT NULL DEFAULT 15, threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10, threshold60_warmup_minutes REAL NOT NULL DEFAULT 15, threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10, mas_test_warmup_minutes REAL NOT NULL DEFAULT 15, mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO profile (id) VALUES (1); ``` - [ ] **Step 2: Write the failing test** Create `backend/internal/store/profile_test.go`: ```go package store import ( "context" "testing" ) func TestProfile_DefaultsThenUpdate(t *testing.T) { db := openTestDB(t) ctx := context.Background() p, err := db.GetProfile(ctx) if err != nil { t.Fatalf("GetProfile: %v", err) } if p.RollingWindowDays != 90 { t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays) } if p.HRZone1MinPct != 50 || p.HRZone5MaxPct != 100 { t.Errorf("zone defaults = %+v, want Z1 min=50, Z5 max=100", p) } maxHR, restingHR := 190.0, 50.0 p.GarminEmail = "runner@example.com" p.GarminPassword = "hunter2" p.RollingWindowDays = 120 p.MaxHeartRate = &maxHR p.RestingHeartRate = &restingHR p.IntervalWarmupMinutes = 8 if err := db.UpdateProfile(ctx, p); err != nil { t.Fatalf("UpdateProfile: %v", err) } got, err := db.GetProfile(ctx) if err != nil { t.Fatalf("GetProfile after update: %v", err) } if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { t.Errorf("got = %+v, want updated email/window", got) } if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 { t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) } if got.IntervalWarmupMinutes != 8 { t.Errorf("IntervalWarmupMinutes = %v, want 8", got.IntervalWarmupMinutes) } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `cd backend && go test ./internal/store/... -run TestProfile -v` Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet). - [ ] **Step 4: Write `profile.go`** Create `backend/internal/store/profile.go`: ```go package store import ( "context" "fmt" ) // Profile is the single active user's Garmin credentials plus every // tunable analysis-engine parameter. Always exactly one row (id=1). type Profile struct { GarminEmail string GarminPassword string RollingWindowDays int MaxHeartRate *float64 RestingHeartRate *float64 HRZone1MinPct, HRZone1MaxPct float64 HRZone2MinPct, HRZone2MaxPct float64 HRZone3MinPct, HRZone3MaxPct float64 HRZone4MinPct, HRZone4MaxPct float64 HRZone5MinPct, HRZone5MaxPct float64 EasyWarmupMinutes, EasyCooldownMinutes float64 LongWarmupMinutes, LongCooldownMinutes float64 TempoWarmupMinutes, TempoCooldownMinutes float64 Threshold30WarmupMinutes, Threshold30CooldownMinutes float64 Threshold60WarmupMinutes, Threshold60CooldownMinutes float64 MASTestWarmupMinutes, MASTestCooldownMinutes float64 // IntervalWarmupMinutes/IntervalCooldownMinutes are not backed by // dedicated columns: Interval phase detection uses the lap_intensity // strategy (existing ACTIVE/REST lap tagging), not a fixed-duration // guess. Kept here as a convenience zero-value for callers that don't // yet distinguish strategies; always 0 until a future migration adds // real columns if a fixed fallback is ever needed. IntervalWarmupMinutes, IntervalCooldownMinutes float64 CreatedAt, UpdatedAt string } const profileColumns = ` garmin_email, garmin_password, rolling_window_days, max_heart_rate, resting_heart_rate, hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, hr_zone5_min_pct, hr_zone5_max_pct, easy_warmup_minutes, easy_cooldown_minutes, long_warmup_minutes, long_cooldown_minutes, tempo_warmup_minutes, tempo_cooldown_minutes, threshold30_warmup_minutes, threshold30_cooldown_minutes, threshold60_warmup_minutes, threshold60_cooldown_minutes, mas_test_warmup_minutes, mas_test_cooldown_minutes, created_at, updated_at ` // GetProfile returns the single profile row. func (db *DB) GetProfile(ctx context.Context) (Profile, error) { var p Profile err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan( &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct, &p.EasyWarmupMinutes, &p.EasyCooldownMinutes, &p.LongWarmupMinutes, &p.LongCooldownMinutes, &p.TempoWarmupMinutes, &p.TempoCooldownMinutes, &p.Threshold30WarmupMinutes, &p.Threshold30CooldownMinutes, &p.Threshold60WarmupMinutes, &p.Threshold60CooldownMinutes, &p.MASTestWarmupMinutes, &p.MASTestCooldownMinutes, &p.CreatedAt, &p.UpdatedAt, ) if err != nil { return Profile{}, fmt.Errorf("get profile: %w", err) } return p, nil } // UpdateProfile overwrites the single profile row. Callers should read via // GetProfile first and modify the fields they intend to change, since this // replaces every column. func (db *DB) UpdateProfile(ctx context.Context, p Profile) error { _, err := db.ExecContext(ctx, ` UPDATE profile SET garmin_email=?, garmin_password=?, rolling_window_days=?, max_heart_rate=?, resting_heart_rate=?, hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?, hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, hr_zone5_min_pct=?, hr_zone5_max_pct=?, easy_warmup_minutes=?, easy_cooldown_minutes=?, long_warmup_minutes=?, long_cooldown_minutes=?, tempo_warmup_minutes=?, tempo_cooldown_minutes=?, threshold30_warmup_minutes=?, threshold30_cooldown_minutes=?, threshold60_warmup_minutes=?, threshold60_cooldown_minutes=?, mas_test_warmup_minutes=?, mas_test_cooldown_minutes=?, updated_at=datetime('now') WHERE id = 1`, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.MaxHeartRate, p.RestingHeartRate, p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone5MinPct, p.HRZone5MaxPct, p.EasyWarmupMinutes, p.EasyCooldownMinutes, p.LongWarmupMinutes, p.LongCooldownMinutes, p.TempoWarmupMinutes, p.TempoCooldownMinutes, p.Threshold30WarmupMinutes, p.Threshold30CooldownMinutes, p.Threshold60WarmupMinutes, p.Threshold60CooldownMinutes, p.MASTestWarmupMinutes, p.MASTestCooldownMinutes, ) if err != nil { return fmt.Errorf("update profile: %w", err) } return nil } ``` Note: `EasyWarmupMinutes, EasyCooldownMinutes float64` etc. above must be written as valid Go — use one field per line instead of comma-grouping when you transcribe this (Go does support comma-grouped same-type fields, e.g. `A, B float64`, so `EasyWarmupMinutes, EasyCooldownMinutes float64` on one line is valid; just don't carry through the extra alignment spaces literally — run `gofmt -w` after creating the file and let it fix spacing). - [ ] **Step 5: Run test to verify it passes** Run: `cd backend && gofmt -w internal/store/profile.go && go test ./internal/store/... -run TestProfile -v` Expected: PASS - [ ] **Step 6: Commit** ```bash cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go git commit -m "feat: add single-profile settings table and store layer" ``` (If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.) --- ### Task 2: Reseed workout taxonomy to the 7 fixed types **Files:** - Create: `backend/internal/store/migrations/0004_workout_taxonomy.sql` - Test: `backend/internal/store/workoutkinds_taxonomy_test.go` **Interfaces:** - Consumes: `store.WorkoutKind` (existing, from `internal/store/workoutkinds.go`), `store.ListWorkoutKinds(ctx, activeOnly bool) ([]WorkoutKind, error)` (existing). - Produces: exactly 7 rows in `workout_kinds` named `Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`, `Interval`, `MAS Test`, each with a syntactically valid but never-matching placeholder rule (real rules are tuned later, out of scope for this plan). - [ ] **Step 1: Write the migration** Create `backend/internal/store/migrations/0004_workout_taxonomy.sql`: ```sql -- The taxonomy is now a fixed, closed set (no more user-created "kinds"). -- Any prior arbitrary kinds and their classification history are reset: -- the concept they classified against no longer exists. DELETE FROM kind_assignments; DELETE FROM workout_kinds; -- Placeholder rule: distance is never negative, so this never matches. -- Every activity starts in needs_review for every type until real rules -- are tuned (a follow-up plan, not this migration). INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES ('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1), ('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1); ``` - [ ] **Step 2: Write the failing test** Create `backend/internal/store/workoutkinds_taxonomy_test.go`: ```go package store import ( "context" "testing" ) func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) { db := openTestDB(t) ctx := context.Background() kinds, err := db.ListWorkoutKinds(ctx, true) if err != nil { t.Fatalf("ListWorkoutKinds: %v", err) } if len(kinds) != 7 { t.Fatalf("expected 7 seeded workout kinds, got %d: %+v", len(kinds), kinds) } wantNames := map[string]bool{ "Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false, "Tempo": false, "Interval": false, "MAS Test": false, } for _, k := range kinds { if _, ok := wantNames[k.Name]; !ok { t.Errorf("unexpected seeded kind name %q", k.Name) continue } wantNames[k.Name] = true } for name, found := range wantNames { if !found { t.Errorf("expected seeded kind %q, not found", name) } } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v` Expected: FAIL — `expected 7 seeded workout kinds, got 0` - [ ] **Step 4: Run test to verify it passes** The migration alone should make this pass once picked up by the `Open`/migrate path — no Go code changes needed for this task. Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v` Expected: PASS - [ ] **Step 5: Run the full store test suite to confirm nothing broke** Run: `cd backend && go test ./internal/store/... -v 2>&1 | tail -40` Expected: all PASS. (`TestKindAssignment_AppendOnlyHistoryAndCurrentView` and similar tests create their own workout kinds via `db.CreateWorkoutKind` in a fresh temp DB per test, so the reseed migration doesn't interfere with them — each test gets its own SQLite file via `openTestDB`.) - [ ] **Step 6: Commit** ```bash cd backend && git add internal/store/migrations/0004_workout_taxonomy.sql internal/store/workoutkinds_taxonomy_test.go git commit -m "feat: reseed workout_kinds with the fixed 7-type running taxonomy" ``` --- ### Task 3: `workout_type_paces` table + store layer **Files:** - Create: `backend/internal/store/migrations/0005_workout_type_paces.sql` - Create: `backend/internal/store/workoutpaces.go` - Test: `backend/internal/store/workoutpaces_test.go` **Interfaces:** - Consumes: `store.WorkoutKind.ID` (existing). - Produces: `store.WorkoutTypePace` struct, `(db *DB) GetWorkoutTypePace(ctx, workoutKindID int64) (WorkoutTypePace, error)`, `(db *DB) UpdateWorkoutTypePace(ctx, WorkoutTypePace) error`, `(db *DB) ListWorkoutTypePaces(ctx) ([]WorkoutTypePace, error)`. - [ ] **Step 1: Write the migration** Create `backend/internal/store/migrations/0005_workout_type_paces.sql`: ```sql -- Per-workout-type target pace range and expected HR zone. Informational -- only: never read by the classification rule engine (see spec section 2). -- No history -- overwritten in place when the user updates a value; the -- synced activity log is the historical record. CREATE TABLE workout_type_paces ( workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), pace_min_sec_per_km REAL, pace_max_sec_per_km REAL, expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5) ); INSERT INTO workout_type_paces (workout_kind_id) SELECT id FROM workout_kinds; ``` - [ ] **Step 2: Write the failing test** Create `backend/internal/store/workoutpaces_test.go`: ```go package store import ( "context" "testing" ) func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) { db := openTestDB(t) ctx := context.Background() all, err := db.ListWorkoutTypePaces(ctx) if err != nil { t.Fatalf("ListWorkoutTypePaces: %v", err) } if len(all) != 7 { t.Fatalf("expected 7 seeded pace rows (one per taxonomy kind), got %d", len(all)) } for _, p := range all { if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil { t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p) } } target := all[0] minPace, maxPace, zone := 330.0, 420.0, 2 target.PaceMinSecPerKm = &minPace target.PaceMaxSecPerKm = &maxPace target.ExpectedHRZone = &zone if err := db.UpdateWorkoutTypePace(ctx, target); err != nil { t.Fatalf("UpdateWorkoutTypePace: %v", err) } got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID) if err != nil { t.Fatalf("GetWorkoutTypePace: %v", err) } if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 { t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm) } if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 { t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone) } } ``` - [ ] **Step 3: Run test to verify it fails** Run: `cd backend && go test ./internal/store/... -run TestWorkoutTypePaces -v` Expected: FAIL (compile error — `ListWorkoutTypePaces` etc. undefined). - [ ] **Step 4: Write `workoutpaces.go`** Create `backend/internal/store/workoutpaces.go`: ```go package store import ( "context" "database/sql" "fmt" ) // WorkoutTypePace is a workout kind's user-declared target pace range and // expected HR zone. Informational only -- never read by the classification // rule engine. No history: fields are overwritten in place. type WorkoutTypePace struct { WorkoutKindID int64 PaceMinSecPerKm *float64 PaceMaxSecPerKm *float64 ExpectedHRZone *int } func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) { var p WorkoutTypePace err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone) return p, err } const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone` // GetWorkoutTypePace fetches the pace/zone row for one workout kind. func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) { row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID) p, err := scanWorkoutTypePace(row) if err == sql.ErrNoRows { return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil } if err != nil { return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d: %w", workoutKindID, err) } return p, nil } // UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind. func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error { _, err := db.ExecContext(ctx, ` UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, expected_hr_zone=? WHERE workout_kind_id=?`, p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID) if err != nil { return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err) } return nil } // ListWorkoutTypePaces returns every workout kind's pace/zone row. func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) { rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`) if err != nil { return nil, fmt.Errorf("list workout type paces: %w", err) } defer rows.Close() paces := []WorkoutTypePace{} for rows.Next() { p, err := scanWorkoutTypePace(rows) if err != nil { return nil, fmt.Errorf("scan workout type pace row: %w", err) } paces = append(paces, p) } return paces, rows.Err() } ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd backend && gofmt -w internal/store/workoutpaces.go && go test ./internal/store/... -run TestWorkoutTypePaces -v` Expected: PASS - [ ] **Step 6: Commit** ```bash cd backend && git add internal/store/migrations/0005_workout_type_paces.sql internal/store/workoutpaces.go internal/store/workoutpaces_test.go git commit -m "feat: add per-workout-type pace range and expected HR zone" ``` --- ### Task 4: `garmin.Client.UpdateCredentials` **Files:** - Modify: `backend/internal/garmin/client.go` (interface at lines 21-35, `mcpClient` struct/methods at lines 48-98) - Modify: `backend/internal/garmin/mock/mock.go` - Test: `backend/internal/garmin/client_test.go` (existing file — add a test) **Interfaces:** - Produces: `Client.UpdateCredentials(email, password string)` (no error return — it just updates in-memory state and closes any running subprocess; the next call that needs the subprocess respawns it and surfaces any connection error then, same as today). - [ ] **Step 1: Write the failing test** Add to `backend/internal/garmin/client_test.go` (append a new test function; keep the existing `TestParseAuthResult`): ```go func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) { c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} c.started = true // simulate an already-spawned subprocess c.UpdateCredentials("new@example.com", "new") if c.cfg.GarminEmail != "new@example.com" || c.cfg.GarminPassword != "new" { t.Errorf("cfg after update = %+v, want new@example.com/new", c.cfg) } if c.started { t.Error("started should be reset to false so the next call respawns the subprocess") } if c.inner != nil { t.Error("inner should be cleared so ensureStarted spawns a fresh client") } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && go test ./internal/garmin/... -run TestMcpClient_UpdateCredentials -v` Expected: FAIL — `c.UpdateCredentials undefined (type *mcpClient has no field or method UpdateCredentials)` - [ ] **Step 3: Add `UpdateCredentials` to the `Client` interface** In `backend/internal/garmin/client.go`, modify the `Client` interface (currently lines 21-35) by adding the new method right after `CompleteMFA`: ```go type Client interface { // Authenticate triggers Garmin login using credentials the subprocess // was started with. Spawns the subprocess on first call. Authenticate(ctx context.Context) (AuthResult, error) // CompleteMFA submits an MFA code for a login started by Authenticate. CompleteMFA(ctx context.Context, code string) (AuthResult, error) // UpdateCredentials replaces the Garmin email/password used to spawn // the subprocess, and terminates any already-running subprocess (which // would otherwise still be authenticated under the old credentials). // The next call that needs the subprocess spawns a fresh one with the // new credentials. UpdateCredentials(email, password string) // GetActivities lists activities between start and end (YYYY-MM-DD). GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) // GetActivitySplits fetches lap/split summaries for one activity. GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) // GetActivityDetails fetches raw per-second telemetry for one activity. GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) // Close terminates the subprocess, if running. Close() error } ``` - [ ] **Step 4: Implement `UpdateCredentials` on `mcpClient`** In `backend/internal/garmin/client.go`, add this method right after the existing `func (c *mcpClient) ensureStarted(...)` method (which ends around line 98): ```go // UpdateCredentials implements Client. func (c *mcpClient) UpdateCredentials(email, password string) { c.mu.Lock() defer c.mu.Unlock() c.cfg.GarminEmail = email c.cfg.GarminPassword = password if c.started { c.inner.Close() c.inner = nil c.started = false } } ``` - [ ] **Step 5: Add a no-op tracking implementation to the mock** In `backend/internal/garmin/mock/mock.go`, add two fields to the `Client` struct (`LastEmail`, `LastPassword`) and the method. The struct currently reads: ```go type Client struct { AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls Activities []garmin.Activity Splits map[int64]garmin.ActivitySplits Details map[int64]garmin.ActivityDetails Err error // if set, every call returns this error authResultCursor int ClosedCalled bool GetActivitiesCalls int } ``` Change it to: ```go type Client struct { AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls Activities []garmin.Activity Splits map[int64]garmin.ActivitySplits Details map[int64]garmin.ActivityDetails Err error // if set, every call returns this error authResultCursor int ClosedCalled bool GetActivitiesCalls int LastEmail string LastPassword string } ``` Add the method anywhere after the struct definition: ```go func (c *Client) UpdateCredentials(email, password string) { c.LastEmail = email c.LastPassword = password } ``` - [ ] **Step 6: Run test to verify it passes** Run: `cd backend && gofmt -w internal/garmin/client.go internal/garmin/mock/mock.go && go build ./... && go test ./internal/garmin/... -v` Expected: all PASS, including the new `TestMcpClient_UpdateCredentials_ResetsStartedState`. - [ ] **Step 7: Run the full backend test suite to confirm nothing broke** Run: `cd backend && go build ./... && go vet ./... && go test ./...` Expected: all PASS (the `garmin.Client` interface changed, so this also confirms every existing caller/mock still satisfies it). - [ ] **Step 8: Commit** ```bash cd backend && git add internal/garmin/client.go internal/garmin/client_test.go internal/garmin/mock/mock.go git commit -m "feat: support updating Garmin credentials at runtime" ``` --- ### Task 5: Profile REST endpoints **Files:** - Create: `backend/internal/api/profile.go` - Modify: `backend/internal/api/server.go` (add route inside `Router()`, currently lines 40-76) - Test: `backend/internal/api/api_test.go` (append) **Interfaces:** - Consumes: `store.Profile`, `db.GetProfile`/`db.UpdateProfile` (Task 1), `Server.Garmin.UpdateCredentials` (Task 4, via the existing `Server.Garmin garmin.Client` field). - Produces: `GET /api/profile`, `PUT /api/profile`. - [ ] **Step 1: Write the failing test** Append to `backend/internal/api/api_test.go`: ```go func TestProfile_GetDefaultsThenUpdate(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) if rec.Code != http.StatusOK { t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String()) } var got store.Profile if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal: %v", err) } if got.RollingWindowDays != 90 { t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays) } got.GarminEmail = "runner@example.com" got.GarminPassword = "hunter2" got.RollingWindowDays = 120 rec = doJSON(t, router, http.MethodPut, "/api/profile", got) if rec.Code != http.StatusOK { t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) var updated store.Profile json.Unmarshal(rec.Body.Bytes(), &updated) if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 { t.Fatalf("updated = %+v, want new email/window", updated) } } func TestProfile_RejectsInvalidHRZones(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) var p store.Profile json.Unmarshal(rec.Body.Bytes(), &p) maxHR, restingHR := 100.0, 150.0 // resting > max: invalid p.MaxHeartRate = &maxHR p.RestingHeartRate = &restingHR rec = doJSON(t, router, http.MethodPut, "/api/profile", p) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd backend && go test ./internal/api/... -run TestProfile -v` Expected: FAIL — 404 on `GET /api/profile` (route doesn't exist yet). - [ ] **Step 3: Write the handlers** Create `backend/internal/api/profile.go`: ```go package api import ( "encoding/json" "errors" "net/http" "geniusrun/backend/internal/store" ) func validateProfile(p store.Profile) error { if p.RestingHeartRate != nil && p.MaxHeartRate != nil && *p.RestingHeartRate >= *p.MaxHeartRate { return errors.New("resting heart rate must be less than max heart rate") } zones := [][2]float64{ {p.HRZone1MinPct, p.HRZone1MaxPct}, {p.HRZone2MinPct, p.HRZone2MaxPct}, {p.HRZone3MinPct, p.HRZone3MaxPct}, {p.HRZone4MinPct, p.HRZone4MaxPct}, {p.HRZone5MinPct, p.HRZone5MaxPct}, } if zones[0][0] != 0 { return errors.New("zone 1 must start at 0%") } if zones[len(zones)-1][1] != 100 { return errors.New("zone 5 must end at 100%") } for i, z := range zones { if z[0] >= z[1] { return errors.New("each HR zone's min must be less than its max") } if i > 0 && z[0] != zones[i-1][1] { return errors.New("HR zones must be contiguous and non-overlapping") } } return nil } func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { p, err := s.DB.GetProfile(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, p) } func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { var p store.Profile if err := json.NewDecoder(r.Body).Decode(&p); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if err := validateProfile(p); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if err := s.DB.UpdateProfile(r.Context(), p); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword) updated, err := s.DB.GetProfile(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, updated) } ``` - [ ] **Step 4: Wire the route** In `backend/internal/api/server.go`, inside `Router()`, add a new route registration. It currently reads (lines 40-42): ```go r.Route("/api", func(r chi.Router) { r.Get("/health", s.handleHealth) ``` Change to: ```go r.Route("/api", func(r chi.Router) { r.Get("/health", s.handleHealth) r.Route("/profile", func(r chi.Router) { r.Get("/", s.handleGetProfile) r.Put("/", s.handleUpdateProfile) }) ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd backend && gofmt -w internal/api/profile.go internal/api/server.go && go test ./internal/api/... -run TestProfile -v` Expected: PASS - [ ] **Step 6: Run the full backend test suite** Run: `cd backend && go build ./... && go vet ./... && go test ./...` Expected: all PASS. - [ ] **Step 7: Commit** ```bash cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go git commit -m "feat: add profile REST endpoints with HR zone validation" ``` --- ### Task 6: Fixed taxonomy in the API — drop create/delete, add pace fields **Files:** - Modify: `backend/internal/api/kinds.go` (full rewrite — small file, shown complete below) - Modify: `backend/internal/api/server.go` (route group, currently lines 61-68) - Modify: `backend/internal/api/api_test.go` (replace `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule`) **Interfaces:** - Consumes: `store.WorkoutTypePace`, `db.GetWorkoutTypePace`/`db.UpdateWorkoutTypePace` (Task 3). - Produces: `workoutKindResponse` (kind + pace fields combined), used by `GET /api/workout-kinds/`, `GET /api/workout-kinds/{id}`, `PUT /api/workout-kinds/{id}`. `POST /api/workout-kinds/` and `DELETE /api/workout-kinds/{id}` no longer exist. - [ ] **Step 1: Update the tests first (they define the new contract)** In `backend/internal/api/api_test.go`, replace the two functions `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule` (currently lines 61-111) with: ```go func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) { s, _ := newTestServer(t) rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } var kinds []workoutKindResponse if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil { t.Fatalf("unmarshal: %v", err) } if len(kinds) != 7 { t.Fatalf("expected 7 seeded kinds, got %d", len(kinds)) } for _, k := range kinds { if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil { t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k) } } } func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) target := kinds[0] minPace, maxPace, zone := 330.0, 420.0, 2 body := map[string]any{ "name": target.Name, "rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`), "pace_min_sec_per_km": minPace, "pace_max_sec_per_km": maxPace, "expected_hr_zone": zone, } rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body) if rec.Code != http.StatusOK { t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String()) } rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil) var updated workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &updated) if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 { t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm) } if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 { t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone) } } func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ "name": kinds[0].Name, "rule": json.RawMessage(`{"match":"xor","conditions":[]}`), }) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) { s, _ := newTestServer(t) router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil) var kinds []workoutKindResponse json.Unmarshal(rec.Body.Bytes(), &kinds) rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{ "name": kinds[0].Name, "rule": json.RawMessage(`{"match":"all","conditions":[]}`), "expected_hr_zone": 9, }) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String()) } } ``` - [ ] **Step 2: Run tests to verify they fail** Run: `cd backend && go test ./internal/api/... -run TestWorkoutKind -v` Expected: FAIL (compile error — `workoutKindResponse` undefined). - [ ] **Step 3: Rewrite `kinds.go`** Replace the full contents of `backend/internal/api/kinds.go` with: ```go package api import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "geniusrun/backend/internal/classify" "geniusrun/backend/internal/store" ) // workoutKindResponse combines a workout kind's rule/metadata with its pace // range and expected HR zone (stored separately in workout_type_paces), // since the frontend always edits and displays them together. type workoutKindResponse struct { store.WorkoutKind PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` ExpectedHRZone *int `json:"expected_hr_zone"` } func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) { pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID) if err != nil { return workoutKindResponse{}, err } return workoutKindResponse{ WorkoutKind: k, PaceMinSecPerKm: pace.PaceMinSecPerKm, PaceMaxSecPerKm: pace.PaceMaxSecPerKm, ExpectedHRZone: pace.ExpectedHRZone, }, nil } type workoutKindRequest struct { Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Rule json.RawMessage `json:"rule"` Priority int `json:"priority"` IsActive *bool `json:"is_active"` PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"` PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"` ExpectedHRZone *int `json:"expected_hr_zone"` } func (req workoutKindRequest) validate() (classify.Node, error) { var node classify.Node if req.Name == "" { return node, errors.New("name is required") } if err := json.Unmarshal(req.Rule, &node); err != nil { return node, errors.New("rule is not valid JSON: " + err.Error()) } if err := node.Validate(); err != nil { return node, errors.New("invalid rule: " + err.Error()) } if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) { return node, errors.New("expected_hr_zone must be between 1 and 5") } return node, nil } func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) { activeOnly := r.URL.Query().Get("include_inactive") != "true" kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp := make([]workoutKindResponse, 0, len(kinds)) for _, k := range kinds { wr, err := s.toWorkoutKindResponse(r, k) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } resp = append(resp, wr) } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if !ok { writeError(w, http.StatusNotFound, "workout kind not found") return } var req workoutKindRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } if _, err := req.validate(); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } isActive := existing.IsActive if req.IsActive != nil { isActive = *req.IsActive } if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{ ID: id, Name: req.Name, Description: req.Description, Color: req.Color, RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{ WorkoutKindID: id, PaceMinSecPerKm: req.PaceMinSecPerKm, PaceMaxSecPerKm: req.PaceMaxSecPerKm, ExpectedHRZone: req.ExpectedHRZone, }); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id) resp, err := s.toWorkoutKindResponse(r, kind) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, resp) } // handleReclassifyKind re-runs the rule engine for every activity currently // assigned to (or under review for) this kind. It's a synchronous, bounded // operation (unlike sync), so it runs inline rather than in the background. func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid workout kind id") return } assignments, err := s.DB.AssignmentsForKind(r.Context(), id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } reviewQueue, err := s.DB.ReviewQueue(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } seen := make(map[int64]bool) var activityIDs []int64 for _, a := range assignments { if !seen[a.ActivityID] { seen[a.ActivityID] = true activityIDs = append(activityIDs, a.ActivityID) } } for _, a := range reviewQueue { if !seen[a.ActivityID] { seen[a.ActivityID] = true activityIDs = append(activityIDs, a.ActivityID) } } for _, activityID := range activityIDs { if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } } writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)}) } ``` - [ ] **Step 4: Remove the create/delete routes** In `backend/internal/api/server.go`, the workout-kinds route group currently reads (lines 61-68): ```go r.Route("/workout-kinds", func(r chi.Router) { r.Get("/", s.handleListWorkoutKinds) r.Post("/", s.handleCreateWorkoutKind) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) r.Delete("/{id}", s.handleDeleteWorkoutKind) r.Post("/{id}/reclassify", s.handleReclassifyKind) }) ``` Change to: ```go r.Route("/workout-kinds", func(r chi.Router) { r.Get("/", s.handleListWorkoutKinds) r.Get("/{id}", s.handleGetWorkoutKind) r.Put("/{id}", s.handleUpdateWorkoutKind) r.Post("/{id}/reclassify", s.handleReclassifyKind) }) ``` - [ ] **Step 5: Run tests to verify they pass** Run: `cd backend && gofmt -w internal/api/kinds.go internal/api/server.go && go test ./internal/api/... -v 2>&1 | tail -60` Expected: all PASS, including the four new/renamed tests. `TestReviewQueueResolve` and `TestProgression_ReturnsSortedTimeSeries` still pass unchanged (they create their own additively-named test kinds via `db.CreateWorkoutKind` directly, bypassing the now-removed API route, which remains fine since `store.CreateWorkoutKind` itself is untouched). - [ ] **Step 6: Run the full backend test suite** Run: `cd backend && go build ./... && go vet ./... && go test ./...` Expected: all PASS. - [ ] **Step 7: Commit** ```bash cd backend && git add internal/api/kinds.go internal/api/server.go internal/api/api_test.go git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API" ``` --- ### Task 7: Classification reads max HR from the profile, not static config **Files:** - Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310) - Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`) **Interfaces:** - Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1). - Removes: `sync.Config.MaxHR` field (no longer exists — callers must delete it from any `Config{...}` literal). - [ ] **Step 1: Update the test first** In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`): ```go svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) ``` Change to (drop `MaxHR` — the test's rule only checks `avg_pace_sec_per_km`, so this doesn't change its assertions; max HR now comes from the profile row, which defaults to `NULL`/unset and is simply omitted from the metric context in that case, same as before): ```go svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) ``` - [ ] **Step 2: Run the test to verify it fails to compile** Run: `cd backend && go test ./internal/sync/... -v 2>&1 | head -20` Expected: passes as-is right now (we haven't removed the field yet) — this step just confirms the test file still compiles before the next change. Expected: PASS (no behavior change yet). - [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`** In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39): ```go // MinConfidence is the classify.Classify threshold below which even a // single matching kind is sent to manual review. MinConfidence float64 // MaxHR is used only to derive avg_hr_pct_max for the rule engine. MaxHR float64 } ``` Change to (drop the `MaxHR` field and its comment): ```go // MinConfidence is the classify.Classify threshold below which even a // single matching kind is sent to manual review. MinConfidence float64 } ``` Then, in the same file, `ClassifyActivity` currently reads (lines 279-301): ```go func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { activity, ok, err := s.db.GetActivity(ctx, activityID) if err != nil { return err } if !ok { return fmt.Errorf("activity %d not found", activityID) } laps, err := s.db.LapsForActivity(ctx, activityID) if err != nil { return err } kindRows, err := s.db.ListWorkoutKinds(ctx, true) if err != nil { return err } rules, err := loadRuleKinds(kindRows) if err != nil { return fmt.Errorf("parse workout kind rules: %w", err) } ctxMetrics := buildMetricContext(activity, laps, s.cfg.MaxHR) result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence) ``` Change the middle to read the profile's max heart rate instead of `s.cfg.MaxHR`: ```go func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { activity, ok, err := s.db.GetActivity(ctx, activityID) if err != nil { return err } if !ok { return fmt.Errorf("activity %d not found", activityID) } laps, err := s.db.LapsForActivity(ctx, activityID) if err != nil { return err } kindRows, err := s.db.ListWorkoutKinds(ctx, true) if err != nil { return err } rules, err := loadRuleKinds(kindRows) if err != nil { return fmt.Errorf("parse workout kind rules: %w", err) } profile, err := s.db.GetProfile(ctx) if err != nil { return fmt.Errorf("load profile: %w", err) } var maxHR float64 if profile.MaxHeartRate != nil { maxHR = *profile.MaxHeartRate } ctxMetrics := buildMetricContext(activity, laps, maxHR) result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence) ``` - [ ] **Step 4: Run tests to verify they pass** Run: `cd backend && gofmt -w internal/sync/service.go internal/sync/service_test.go && go build ./... && go test ./internal/sync/... -v` Expected: all PASS. `TestBuildMetricContext_DerivesExpectedMetrics` is untouched and still passes — it calls `buildMetricContext` directly with a literal `190`, and that function's signature didn't change, only `ClassifyActivity`'s caller-side source of the value did. - [ ] **Step 5: Run the full backend test suite** Run: `cd backend && go build ./... && go vet ./... && go test ./...` Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still references `MaxHR: cfg.MaxHR` in its `appsync.Config{...}` literal, that's expected — Task 8 fixes `cmd/geniusrund` and `internal/config` together; this step is allowed to show that build failure so you can confirm it's exactly (and only) that one line before moving to Task 8. - [ ] **Step 6: Commit** ```bash cd backend && git add internal/sync/sync.go internal/sync/sync_test.go git commit -m "feat: classification reads max HR from the profile instead of static config" ``` --- ### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile **Files:** - Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below) - Modify: `backend/cmd/geniusrund/main.go` (lines 21-46) **Interfaces:** - Consumes: `db.GetProfile` (Task 1). - Removes: `config.Config.GarminEmail`, `config.Config.GarminPassword`, `config.Config.MaxHR` and the `GARMIN_EMAIL`/`GARMIN_PASSWORD`/`GENIUSRUN_MAX_HR` env vars — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` remain required env vars (infra paths, not secrets). - [ ] **Step 1: Rewrite `internal/config/config.go`** Replace the full contents of `../../../backend/internal/config/envconfig.go` with: ```go // Package config loads geniusrund's runtime infrastructure configuration // from environment variables (paths and process settings that don't belong // in the user-editable profile). Garmin credentials and every tunable // analysis-engine parameter live in the profile (internal/store.Profile) // instead -- see docs/superpowers/specs/2026-07-17-profile-taxonomy-analysis-engine-design.md. package config import ( "fmt" "os" "strconv" "time" ) // Config holds geniusrund's process-level configuration. type Config struct { // Addr is the HTTP listen address, e.g. ":8080". Addr string // DBPath is the SQLite database file path. DBPath string // GarminPythonPath is mcp-garmin's venv python executable. GarminPythonPath string // GarminServerPath is mcp-garmin's server.py. GarminServerPath string // GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth // session cache location. GarminTokenStore string MinConfidence float64 BackfillHorizonDays int IncrementalSyncEvery time.Duration } // Load reads configuration from environment variables, applying defaults // for anything optional. Returns an error if a required variable is unset. func Load() (Config, error) { cfg := Config{ Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"), GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"), GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"), MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), BackfillHorizonDays: getEnvInt("GENIUSRUN_BACKFILL_HORIZON_DAYS", 3*365), IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour), } if cfg.GarminPythonPath == "" { return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)") } if cfg.GarminServerPath == "" { return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)") } return cfg, nil } func getEnvDefault(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func getEnvFloat(key string, def float64) float64 { if v := os.Getenv(key); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { return f } } return def } func getEnvInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def } func getEnvDuration(key string, def time.Duration) time.Duration { if v := os.Getenv(key); v != "" { if d, err := time.ParseDuration(v); err == nil { return d } } return def } ``` - [ ] **Step 2: Update `cmd/geniusrund/main.go` to load credentials from the profile** `backend/cmd/geniusrund/main.go` currently reads (lines 21-46): ```go func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open database: %v", err) } defer db.Close() garminClient := garmin.NewClient(garmin.Config{ PythonPath: cfg.GarminPythonPath, ServerPath: cfg.GarminServerPath, GarminEmail: cfg.GarminEmail, GarminPassword: cfg.GarminPassword, TokenStorePath: cfg.GarminTokenStore, }) defer garminClient.Close() syncSvc := appsync.NewService(garminClient, db, appsync.Config{ BackfillHorizonDays: cfg.BackfillHorizonDays, MinConfidence: cfg.MinConfidence, MaxHR: cfg.MaxHR, }, nil) ``` Change to: ```go func main() { cfg, err := config.Load() if err != nil { log.Fatalf("config: %v", err) } db, err := store.Open(cfg.DBPath) if err != nil { log.Fatalf("open database: %v", err) } defer db.Close() profile, err := db.GetProfile(context.Background()) if err != nil { log.Fatalf("load profile: %v", err) } garminClient := garmin.NewClient(garmin.Config{ PythonPath: cfg.GarminPythonPath, ServerPath: cfg.GarminServerPath, GarminEmail: profile.GarminEmail, GarminPassword: profile.GarminPassword, TokenStorePath: cfg.GarminTokenStore, }) defer garminClient.Close() syncSvc := appsync.NewService(garminClient, db, appsync.Config{ BackfillHorizonDays: cfg.BackfillHorizonDays, MinConfidence: cfg.MinConfidence, }, nil) ``` (`context` is already imported in this file for the later `signal.NotifyContext` call, so no import changes are needed.) - [ ] **Step 3: Build and run the full backend test suite** Run: `cd backend && gofmt -w internal/config/config.go cmd/geniusrund/main.go && go build ./... && go vet ./... && go test ./...` Expected: everything builds and all tests PASS. - [ ] **Step 4: Manual smoke test — start the server without Garmin env vars** ```bash cd backend rm -f /tmp/geniusrun_task8.db MCP_GARMIN_PYTHON=/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/.venv/bin/python \ MCP_GARMIN_SERVER=/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin/server.py \ GENIUSRUN_DB_PATH=/tmp/geniusrun_task8.db \ GENIUSRUN_ADDR=:8085 \ go run ./cmd/geniusrund & sleep 2 curl -s localhost:8085/api/profile curl -s localhost:8085/api/workout-kinds/ | head -c 300 kill %1 ``` Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` returns the default profile JSON (`rolling_window_days: 90`, etc.), and `/api/workout-kinds/` returns the 7 seeded types. - [ ] **Step 5: Commit** ```bash cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go git commit -m "feat: source Garmin credentials from the profile instead of env vars" ``` --- ### Task 9: Frontend — types, API client, Profile page, WorkoutKinds page Combined into a single task (rather than three separate ones) so every commit typechecks cleanly: splitting the API-client change (which removes `createWorkoutKind`/`deleteWorkoutKind`) from the `WorkoutKinds.tsx` rewrite that stops calling them would leave an intermediate commit with `tsc -b` failing. **Files:** - Modify: `frontend/src/types/api.ts` (add `Profile`, extend `WorkoutKind`) - Modify: `frontend/src/api/client.ts` (add profile endpoints, remove create/delete, extend update) - Create: `frontend/src/pages/Profile.tsx` - Modify: `frontend/src/App.tsx` (add a "Profile" tab) - Modify: `frontend/src/pages/WorkoutKinds.tsx` (full rewrite — shown complete below) **Interfaces:** - Produces: `Profile` TS type, `api.getProfile()`, `api.updateProfile(profile)`. - Removes: `api.createWorkoutKind`, `api.deleteWorkoutKind` (routes no longer exist server-side). - [ ] **Step 1: Extend `WorkoutKind` and add `Profile` to `types/api.ts`** In `frontend/src/types/api.ts`, find the existing `WorkoutKind` interface: ```ts export interface WorkoutKind { ID: number; Name: string; Description: string; Color: string; RuleJSON: string; Priority: number; IsActive: boolean; CreatedAt: string; UpdatedAt: string; } ``` Replace it with: ```ts export interface WorkoutKind { ID: number; Name: string; Description: string; Color: string; RuleJSON: string; Priority: number; IsActive: boolean; CreatedAt: string; UpdatedAt: string; pace_min_sec_per_km: number | null; pace_max_sec_per_km: number | null; expected_hr_zone: number | null; } export interface Profile { GarminEmail: string; GarminPassword: string; RollingWindowDays: number; MaxHeartRate: number | null; RestingHeartRate: number | null; HRZone1MinPct: number; HRZone1MaxPct: number; HRZone2MinPct: number; HRZone2MaxPct: number; HRZone3MinPct: number; HRZone3MaxPct: number; HRZone4MinPct: number; HRZone4MaxPct: number; HRZone5MinPct: number; HRZone5MaxPct: number; EasyWarmupMinutes: number; EasyCooldownMinutes: number; LongWarmupMinutes: number; LongCooldownMinutes: number; TempoWarmupMinutes: number; TempoCooldownMinutes: number; Threshold30WarmupMinutes: number; Threshold30CooldownMinutes: number; Threshold60WarmupMinutes: number; Threshold60CooldownMinutes: number; MASTestWarmupMinutes: number; MASTestCooldownMinutes: number; IntervalWarmupMinutes: number; IntervalCooldownMinutes: number; CreatedAt: string; UpdatedAt: string; } ``` - [ ] **Step 2: Update `api/client.ts`** In `frontend/src/api/client.ts`, add `Profile` to the type-only import at the top. It currently reads: ```ts import type { Activity, AuthResponse, KindAssignment, ProgressionMetric, ProgressionPoint, ReviewQueueItem, SyncRun, SyncStatus, WorkoutKind, } from "../types/api"; ``` Change to: ```ts import type { Activity, AuthResponse, KindAssignment, Profile, ProgressionMetric, ProgressionPoint, ReviewQueueItem, SyncRun, SyncStatus, WorkoutKind, } from "../types/api"; ``` Then find the workout-kinds section of the `api` object: ```ts // Workout kinds listWorkoutKinds: (includeInactive = false) => request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) => request("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }), updateWorkoutKind: ( id: number, body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean }, ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), deleteWorkoutKind: (id: number) => request(`/api/workout-kinds/${id}`, { method: "DELETE" }), reclassifyWorkoutKind: (id: number) => request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), ``` Replace it with: ```ts // Workout kinds -- fixed taxonomy, no create/delete listWorkoutKinds: (includeInactive = false) => request(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`), updateWorkoutKind: ( id: number, body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean; pace_min_sec_per_km?: number | null; pace_max_sec_per_km?: number | null; expected_hr_zone?: number | null; }, ) => request(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }), reclassifyWorkoutKind: (id: number) => request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }), // Profile getProfile: () => request("/api/profile"), updateProfile: (profile: Profile) => request("/api/profile", { method: "PUT", body: JSON.stringify(profile) }), ``` - [ ] **Step 3: Confirm the expected (temporary) typecheck errors** Run: `cd frontend && npx tsc -b` Expected: fails, with errors *only* in `src/pages/WorkoutKinds.tsx` (still calling the now-removed `api.createWorkoutKind`/`api.deleteWorkoutKind`). Confirm there are no errors anywhere else before continuing — this file is rewritten later in this same task (Step 6 below), not committed separately. - [ ] **Step 4: Write the Profile page** Create `frontend/src/pages/Profile.tsx`: ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import type { Profile as ProfileType } from "../types/api"; function NumberField({ label, value, onChange, step = 1, }: { label: string; value: number; onChange: (v: number) => void; step?: number; }) { return ( ); } function NullableNumberField({ label, value, onChange, }: { label: string; value: number | null; onChange: (v: number | null) => void; }) { return ( ); } export function Profile() { const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); useEffect(() => { api.getProfile().then(setProfile).catch((e) => setError(String(e))); }, []); function set(key: K, value: ProfileType[K]) { setProfile((p) => (p ? { ...p, [key]: value } : p)); setSaved(false); } async function save() { if (!profile) return; setError(null); try { const updated = await api.updateProfile(profile); setProfile(updated); setSaved(true); } catch (e) { setError(String(e)); setSaved(false); } } if (!profile) { return (

Profile

{error ?

{error}

:

Loading...

}
); } return (

Profile

{error &&

{error}

} {saved &&

Saved.

}
Garmin account
Classification set("RollingWindowDays", v)} />
Heart rate set("MaxHeartRate", v)} /> set("RestingHeartRate", v)} /> {([1, 2, 3, 4, 5] as const).map((zone) => (
set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)} /> set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)} />
))}
Phase detection (warm-up / cool-down minutes) {( [ ["Easy", "EasyWarmupMinutes", "EasyCooldownMinutes"], ["Long", "LongWarmupMinutes", "LongCooldownMinutes"], ["Tempo", "TempoWarmupMinutes", "TempoCooldownMinutes"], ["Threshold 30'", "Threshold30WarmupMinutes", "Threshold30CooldownMinutes"], ["Threshold 60'", "Threshold60WarmupMinutes", "Threshold60CooldownMinutes"], ["MAS Test", "MASTestWarmupMinutes", "MASTestCooldownMinutes"], ] as const ).map(([label, warmupKey, cooldownKey]) => (
set(warmupKey, v)} /> set(cooldownKey, v)} />
))}

Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings.

); } ``` - [ ] **Step 5: Add the "Profile" tab to `App.tsx`** In `frontend/src/App.tsx`, the current tab list and imports read: ```tsx import { useState } from "react"; import "./App.css"; import { GarminConnection } from "./components/GarminConnection"; import { Dashboard } from "./pages/Dashboard"; import { ReviewQueue } from "./pages/ReviewQueue"; import { WorkoutKinds } from "./pages/WorkoutKinds"; const TABS = [ { key: "dashboard", label: "Progression", Component: Dashboard }, { key: "review", label: "Review Queue", Component: ReviewQueue }, { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, ] as const; ``` Change to: ```tsx import { useState } from "react"; import "./App.css"; import { GarminConnection } from "./components/GarminConnection"; import { Dashboard } from "./pages/Dashboard"; import { Profile } from "./pages/Profile"; import { ReviewQueue } from "./pages/ReviewQueue"; import { WorkoutKinds } from "./pages/WorkoutKinds"; const TABS = [ { key: "dashboard", label: "Progression", Component: Dashboard }, { key: "review", label: "Review Queue", Component: ReviewQueue }, { key: "kinds", label: "Workout Kinds", Component: WorkoutKinds }, { key: "profile", label: "Profile", Component: Profile }, ] as const; ``` - [ ] **Step 6: Confirm still only the same expected typecheck errors** Run: `cd frontend && npx tsc -b` Expected: still fails only on `WorkoutKinds.tsx` — confirm no new errors from `Profile.tsx` or `App.tsx`. - [ ] **Step 7: Replace the full contents of `WorkoutKinds.tsx`** ```tsx import { useEffect, useState } from "react"; import { api } from "../api/client"; import type { WorkoutKind } from "../types/api"; const EXAMPLE_RULE = `{ "match": "all", "conditions": [ { "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] }, { "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 } ] }`; // Pace is entered/displayed as "m:ss" but sent to the API as whole seconds. function parsePace(text: string): number | null { const trimmed = text.trim(); if (trimmed === "") return null; const match = /^(\d+):([0-5]?\d)$/.exec(trimmed); if (!match) return null; return Number(match[1]) * 60 + Number(match[2]); } function formatPace(seconds: number | null): string { if (seconds == null) return ""; const m = Math.floor(seconds / 60); const s = Math.round(seconds % 60); return `${m}:${s.toString().padStart(2, "0")}`; } export function WorkoutKinds() { const [kinds, setKinds] = useState([]); const [editingId, setEditingId] = useState(null); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [ruleText, setRuleText] = useState(EXAMPLE_RULE); const [paceMinText, setPaceMinText] = useState(""); const [paceMaxText, setPaceMaxText] = useState(""); const [expectedZone, setExpectedZone] = useState(null); const [error, setError] = useState(null); const [busyId, setBusyId] = useState(null); function reload() { api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e))); } useEffect(reload, []); function startEdit(k: WorkoutKind) { setEditingId(k.ID); setName(k.Name); setDescription(k.Description); setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2)); setPaceMinText(formatPace(k.pace_min_sec_per_km)); setPaceMaxText(formatPace(k.pace_max_sec_per_km)); setExpectedZone(k.expected_hr_zone); setError(null); } async function save() { if (editingId === null) return; let rule: unknown; try { rule = JSON.parse(ruleText); } catch { setError("Rule is not valid JSON"); return; } const paceMin = parsePace(paceMinText); const paceMax = parsePace(paceMaxText); if (paceMinText.trim() !== "" && paceMin === null) { setError("Min pace must look like m:ss, e.g. 4:30"); return; } if (paceMaxText.trim() !== "" && paceMax === null) { setError("Max pace must look like m:ss, e.g. 4:30"); return; } try { await api.updateWorkoutKind(editingId, { name, description, rule, pace_min_sec_per_km: paceMin, pace_max_sec_per_km: paceMax, expected_hr_zone: expectedZone, }); setEditingId(null); reload(); } catch (e) { setError(String(e)); } } async function reclassify(id: number) { setBusyId(id); try { const res = await api.reclassifyWorkoutKind(id); setError(`Reclassified ${res.reclassified} activities.`); } catch (e) { setError(String(e)); } finally { setBusyId(null); } } return (

Workout Kinds

{error &&

{error}

} {kinds.map((k) => ( ))}
Name Description Pace range HR zone
{k.Name} {k.Description} {k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null ? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km` : "—"} {k.expected_hr_zone ?? "—"}
{editingId !== null && (