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>
81 KiB
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 andgo 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.Profilestruct,(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:
-- 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:
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:
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
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, frominternal/store/workoutkinds.go),store.ListWorkoutKinds(ctx, activeOnly bool) ([]WorkoutKind, error)(existing). -
Produces: exactly 7 rows in
workout_kindsnamedEasy 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:
-- 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:
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
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.WorkoutTypePacestruct,(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:
-- 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:
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:
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
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,mcpClientstruct/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):
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
UpdateCredentialsto theClientinterface
In backend/internal/garmin/client.go, modify the Client interface (currently lines 21-35) by adding the new method right after CompleteMFA:
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
UpdateCredentialsonmcpClient
In backend/internal/garmin/client.go, add this method right after the existing func (c *mcpClient) ensureStarted(...) method (which ends around line 98):
// 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:
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:
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:
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
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 insideRouter(), 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 existingServer.Garmin garmin.Clientfield). -
Produces:
GET /api/profile,PUT /api/profile. -
Step 1: Write the failing test
Append to backend/internal/api/api_test.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:
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):
r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth)
Change to:
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
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(replaceTestWorkoutKindCRUDandTestWorkoutKindCreate_RejectsInvalidRule)
Interfaces:
-
Consumes:
store.WorkoutTypePace,db.GetWorkoutTypePace/db.UpdateWorkoutTypePace(Task 3). -
Produces:
workoutKindResponse(kind + pace fields combined), used byGET /api/workout-kinds/,GET /api/workout-kinds/{id},PUT /api/workout-kinds/{id}.POST /api/workout-kinds/andDELETE /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:
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:
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):
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:
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
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(Configstruct lines 21-40,ClassifyActivitylines 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.MaxHRfield (no longer exists — callers must delete it from anyConfig{...}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):
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):
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
MaxHRfromConfigand source it from the profile inClassifyActivity
In ../../../backend/internal/garmin/sync.go, the Config struct currently ends with (lines 35-39):
// 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):
// 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):
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:
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
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.MaxHRand theGARMIN_EMAIL/GARMIN_PASSWORD/GENIUSRUN_MAX_HRenv vars —MCP_GARMIN_PYTHON/MCP_GARMIN_SERVERremain 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:
// 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.goto load credentials from the profile
backend/cmd/geniusrund/main.go currently reads (lines 21-46):
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:
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
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
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(addProfile, extendWorkoutKind) - 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:
ProfileTS type,api.getProfile(),api.updateProfile(profile). -
Removes:
api.createWorkoutKind,api.deleteWorkoutKind(routes no longer exist server-side). -
Step 1: Extend
WorkoutKindand addProfiletotypes/api.ts
In frontend/src/types/api.ts, find the existing WorkoutKind interface:
export interface WorkoutKind {
ID: number;
Name: string;
Description: string;
Color: string;
RuleJSON: string;
Priority: number;
IsActive: boolean;
CreatedAt: string;
UpdatedAt: string;
}
Replace it with:
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:
import type {
Activity,
AuthResponse,
KindAssignment,
ProgressionMetric,
ProgressionPoint,
ReviewQueueItem,
SyncRun,
SyncStatus,
WorkoutKind,
} from "../types/api";
Change to:
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:
// 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:
// 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:
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:
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:
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
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
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:
// 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
mustFindKindIDhelper
In the same file, add this function next to must (at the end of the file):
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
MaxHRconfig field
In the same file, find:
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil)
Change to:
m := &mock.Client{}
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)
- Step 4: Build and run seedsample
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
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
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
kill %1 2>/dev/null
rm -f /tmp/geniusrund /tmp/geniusrun_verify.db
- Step 9: Commit
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:
- Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration
- Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?