Files
geniusrun/docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md
Christophe Vila 0e6cf00dba refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout
All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

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

81 KiB
Raw Permalink Blame History

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/profiles.go
  • Test: ../../../backend/internal/store/profiles_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:

-- 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/profiles_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 profiles.go

Create ../../../backend/internal/store/profiles.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/profiles.go internal/store/profiles_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:

-- 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.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:

-- 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, 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):

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:

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):

// 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 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:

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/profiles.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:

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 (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):

	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 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):

	// 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.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:

// 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):

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 (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:

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 mustFindKindID helper

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 MaxHR config 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:009: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:

  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?