Files
geniusrun/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md
Christophe Vila e2b2bf9611 refactor: merge internal/sync into internal/garmin, regroup api files and routes
Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:04:18 +02:00

2383 lines
81 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) =>
request<WorkoutKind>("/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<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
deleteWorkoutKind: (id: number) => request<void>(`/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<WorkoutKind[]>(`/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<WorkoutKind>(`/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<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/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 (
<label>
{label}
<input
type="number"
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</label>
);
}
function NullableNumberField({
label,
value,
onChange,
}: {
label: string;
value: number | null;
onChange: (v: number | null) => void;
}) {
return (
<label>
{label}
<input
type="number"
value={value ?? ""}
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
/>
</label>
);
}
export function Profile() {
const [profile, setProfile] = useState<ProfileType | null>(null);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
}, []);
function set<K extends keyof ProfileType>(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 (
<div className="page">
<h2>Profile</h2>
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
</div>
);
}
return (
<div className="page">
<h2>Profile</h2>
{error && <p className="error">{error}</p>}
{saved && <p className="garmin-connection-message">Saved.</p>}
<fieldset className="kind-editor">
<legend>Garmin account</legend>
<label>
Email
<input
type="text"
value={profile.GarminEmail}
onChange={(e) => set("GarminEmail", e.target.value)}
/>
</label>
<label>
Password
<input
type="password"
value={profile.GarminPassword}
onChange={(e) => set("GarminPassword", e.target.value)}
/>
</label>
</fieldset>
<fieldset className="kind-editor">
<legend>Classification</legend>
<NumberField
label="Rolling window (days)"
value={profile.RollingWindowDays}
onChange={(v) => set("RollingWindowDays", v)}
/>
</fieldset>
<fieldset className="kind-editor">
<legend>Heart rate</legend>
<NullableNumberField
label="Max heart rate (bpm)"
value={profile.MaxHeartRate}
onChange={(v) => set("MaxHeartRate", v)}
/>
<NullableNumberField
label="Resting heart rate (bpm)"
value={profile.RestingHeartRate}
onChange={(v) => set("RestingHeartRate", v)}
/>
{([1, 2, 3, 4, 5] as const).map((zone) => (
<div key={zone} className="controls">
<NumberField
label={`Zone ${zone} min %HRR`}
value={profile[`HRZone${zone}MinPct` as keyof ProfileType] as number}
onChange={(v) => set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)}
/>
<NumberField
label={`Zone ${zone} max %HRR`}
value={profile[`HRZone${zone}MaxPct` as keyof ProfileType] as number}
onChange={(v) => set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)}
/>
</div>
))}
</fieldset>
<fieldset className="kind-editor">
<legend>Phase detection (warm-up / cool-down minutes)</legend>
{(
[
["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]) => (
<div key={label} className="controls">
<NumberField
label={`${label} warm-up`}
value={profile[warmupKey]}
onChange={(v) => set(warmupKey, v)}
/>
<NumberField
label={`${label} cool-down`}
value={profile[cooldownKey]}
onChange={(v) => set(cooldownKey, v)}
/>
</div>
))}
<p className="empty-state">
Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings.
</p>
</fieldset>
<button onClick={save}>Save</button>
</div>
);
}
```
- [ ] **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<WorkoutKind[]>([]);
const [editingId, setEditingId] = useState<number | null>(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<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(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 (
<div className="page">
<h2>Workout Kinds</h2>
{error && <p className="error">{error}</p>}
<table className="kinds-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Pace range</th>
<th>HR zone</th>
<th></th>
</tr>
</thead>
<tbody>
{kinds.map((k) => (
<tr key={k.ID}>
<td>{k.Name}</td>
<td>{k.Description}</td>
<td>
{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`
: "—"}
</td>
<td>{k.expected_hr_zone ?? "—"}</td>
<td className="kinds-table-actions">
<button onClick={() => startEdit(k)}>Edit</button>
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
Reclassify
</button>
</td>
</tr>
))}
</tbody>
</table>
{editingId !== null && (
<div className="kind-editor">
<label>
Name
<input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<label>
Description
<input value={description} onChange={(e) => setDescription(e.target.value)} />
</label>
<div className="controls">
<label>
Min pace (m:ss/km)
<input value={paceMinText} onChange={(e) => setPaceMinText(e.target.value)} placeholder="4:30" />
</label>
<label>
Max pace (m:ss/km)
<input value={paceMaxText} onChange={(e) => setPaceMaxText(e.target.value)} placeholder="4:45" />
</label>
<label>
Expected HR zone
<select
value={expectedZone ?? ""}
onChange={(e) => setExpectedZone(e.target.value === "" ? null : Number(e.target.value))}
>
<option value=""></option>
{[1, 2, 3, 4, 5].map((z) => (
<option key={z} value={z}>
Zone {z}
</option>
))}
</select>
</label>
</div>
<label>
Rule (JSON condition tree)
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />
</label>
<div className="kind-editor-actions">
<button onClick={save}>Save</button>
<button onClick={() => setEditingId(null)}>Cancel</button>
</div>
</div>
)}
</div>
);
}
```
- [ ] **Step 8: Typecheck the whole frontend**
Run: `cd frontend && npx tsc -b`
Expected: no errors — this confirms Steps 1-7 together leave the frontend fully compiling.
- [ ] **Step 9: Commit everything from this task together**
```bash
cd frontend && git add src/types/api.ts src/api/client.ts src/pages/Profile.tsx src/App.tsx src/pages/WorkoutKinds.tsx
git commit -m "feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing"
```
---
### Task 10: Fix `cmd/seedsample` for the fixed taxonomy, then full manual verification
`cmd/seedsample` currently calls `db.CreateWorkoutKind` with names `"Easy"`, `"Tempo"`, `"Interval"`. Migration 0004 (Task 2) already seeds a row named exactly `"Tempo"` and `"Interval"` (and `"Easy Run"`, not `"Easy"`) -- since `workout_kinds.name` is `UNIQUE`, `seedsample`'s `CreateWorkoutKind("Tempo", ...)`/`CreateWorkoutKind("Interval", ...)` calls would now fail with a unique-constraint error. It also still passes the now-removed `appsync.Config.MaxHR` field (Task 7 deleted it), which would fail to compile. Both must be fixed before this plan's changes are usable end-to-end.
**Files:**
- Modify: `backend/cmd/seedsample/main.go` (lines 32-64)
**Interfaces:**
- Consumes: `db.ListWorkoutKinds` (existing), `db.UpdateWorkoutKind` (existing), `db.GetProfile`/`db.UpdateProfile` (Task 1).
- [ ] **Step 1: Replace the workout-kind seeding block**
In `backend/cmd/seedsample/main.go`, replace lines 32-64 (from the comment `// Easy and Tempo's pace/HR ranges...` through the `must(err)` following the `Interval` block) with:
```go
// Set a max heart rate on the profile so avg_hr_pct_max is computed
// during classification below (it's nil/unset by default).
profile, err := db.GetProfile(ctx)
must(err)
maxHR := 190.0
profile.MaxHeartRate = &maxHR
must(db.UpdateProfile(ctx, profile))
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
// zone demonstrates the ambiguous-multi-match review path, not just a
// gap between disjoint ranges. The taxonomy migration already seeded
// these rows (by fixed name) -- update their rules in place rather than
// creating new ones, since names are unique.
easyID := mustFindKindID(ctx, db, "Easy Run")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
ID: easyID, Name: "Easy Run", Description: "Easy conversational runs", Color: "#22c55e",
RuleJSON: `{"match":"all","conditions":[
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
{"metric":"avg_hr_pct_max","op":"<=","value":0.85}
]}`,
IsActive: true,
}))
tempoID := mustFindKindID(ctx, db, "Tempo")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
RuleJSON: `{"match":"all","conditions":[
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
{"metric":"avg_hr_pct_max","op":">=","value":0.70}
]}`,
IsActive: true,
}))
intervalID := mustFindKindID(ctx, db, "Interval")
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
ID: intervalID, Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444",
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
IsActive: true,
}))
```
- [ ] **Step 2: Add the `mustFindKindID` helper**
In the same file, add this function next to `must` (at the end of the file):
```go
func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 {
kinds, err := db.ListWorkoutKinds(ctx, false)
must(err)
for _, k := range kinds {
if k.Name == name {
return k.ID
}
}
log.Fatalf("seedsample: no workout kind named %q found (did migration 0004 run?)", name)
return 0
}
```
- [ ] **Step 3: Drop the removed `MaxHR` config field**
In the same file, find:
```go
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil)
```
Change to:
```go
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)
```
- [ ] **Step 4: Build and run seedsample**
```bash
cd backend
gofmt -w cmd/seedsample/main.go
go build ./... && go vet ./...
rm -f /tmp/geniusrun_verify.db
go run ./cmd/seedsample -db /tmp/geniusrun_verify.db
```
Expected: builds clean, prints `Seeded 10 activities (kinds: Easy=<id>, Tempo=<id>) into /tmp/geniusrun_verify.db` with no errors (no unique-constraint failure).
- [ ] **Step 5: Run the full backend test suite one more time**
Run: `cd backend && gofmt -l . && go build ./... && go vet ./... && go test ./... -race`
Expected: `gofmt -l .` prints nothing; everything else PASSes.
- [ ] **Step 6: Start the backend against the seeded DB**
```bash
cd backend
go build -o /tmp/geniusrund ./cmd/geniusrund
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_verify.db \
GENIUSRUN_ADDR=:8080 \
/tmp/geniusrund &
sleep 1
curl -s localhost:8080/api/workout-kinds/ | python3 -m json.tool | head -30
curl -s localhost:8080/api/profile | python3 -m json.tool
```
Expected: 7 workout kinds returned (Easy Run, Long Run, Threshold 30', Threshold 60', Tempo, Interval, MAS Test), the "Easy Run" and "Tempo" entries showing their updated `rule_json`; `/api/profile` shows `rolling_window_days: 90`, `max_heart_rate: 190`, and the default HR zone/phase-minute values from the migration.
- [ ] **Step 7: Start the frontend and click through in a real browser**
```bash
cd frontend
VITE_API_BASE_URL=http://localhost:8080 npm run dev
```
Open the printed URL and check:
- **Profile** tab: shows the saved `max_heart_rate: 190`, rolling window 90, default HR zones and phase minutes; edit a value, save, reload the page, confirm it persisted.
- **Workout Kinds** tab: shows exactly 7 rows, no "+ New workout kind" button anywhere; edit "Long Run"'s pace range (e.g. `9:00``9:30`) and expected HR zone, save, confirm the table's "Pace range"/"HR zone" columns update.
- **Review Queue** / **Progression** tabs: still work as before (unaffected by this plan).
- Open the browser DevTools console: zero errors across all four tabs.
- [ ] **Step 8: Tear down**
```bash
kill %1 2>/dev/null
rm -f /tmp/geniusrund /tmp/geniusrun_verify.db
```
- [ ] **Step 9: Commit**
```bash
cd backend && git add cmd/seedsample/main.go
git commit -m "fix: update seedsample for the fixed workout taxonomy and profile-based max HR"
```
---
## Plan self-review
**Spec coverage:** Section 1 (profile, credential updates) → Tasks 1, 4, 5, 8. Section 2 (taxonomy + pace ranges) → Tasks 2, 3, 6, 9. Section 5's profile-screen/pace-editing UI → Task 9. The credential-update addition made during spec review → Task 4/5. Sections 3 (relative classification) and 4 (phase segmentation) are explicitly out of scope for this plan (Plans 2 and 3).
**Placeholder scan:** No TBD/TODO markers; every step shows complete code.
**Type consistency:** `store.Profile`, `store.WorkoutTypePace`, `workoutKindResponse`, and the frontend `Profile`/`WorkoutKind` TS types use matching field names throughout (verified field-by-field against the Go structs' zero-json-tag PascalCase serialization and the snake_case pace/zone fields). `garmin.Client.UpdateCredentials(email, password string)` signature matches across the interface, `mcpClient`, `mock.Client`, and its one caller in `internal/api/profile.go`.
## Execution
Plan complete and saved to `docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md`. Two execution options:
1. **Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?