2383 lines
81 KiB
Markdown
2383 lines
81 KiB
Markdown
# Profile & Taxonomy Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace env-var-only Garmin credentials and the arbitrary user-created "workout kinds" with a single-profile settings model (credentials + tunable engine parameters) and a fixed 7-type running taxonomy, each with a user-editable pace range and expected HR zone.
|
||
|
||
**Architecture:** Extends the existing Go backend (`internal/store`, `internal/api`, `internal/sync`, `internal/garmin`) and React frontend in place — no new services. `profile` is a singleton SQLite row (same pattern as the existing `sync_state`). The 7 workout types reuse the existing `workout_kinds` table and rule engine, reseeded and stripped of create/delete affordances. A new `workout_type_paces` table holds per-type pace range + expected HR zone, informational only (not read by classification).
|
||
|
||
**Tech Stack:** Go 1.26, `modernc.org/sqlite`, `go-chi/chi`, React 19 + TypeScript + Vite, Recharts (unchanged).
|
||
|
||
## Global Constraints
|
||
|
||
- No AI/LLM involvement at runtime — everything in this plan is deterministic Go/TS code and SQL.
|
||
- Single active profile only — no multi-profile switcher UI, no per-row profile scoping elsewhere in the schema (per spec section 1 non-goals).
|
||
- Pace ranges and expected HR zone are never read by the classification rule engine (per spec section 2) — they exist purely as data capture for the future delta feature.
|
||
- `gofmt -l .` must report nothing and `go vet ./...` must pass before any commit.
|
||
- Every new Go file follows the existing package doc-comment convention (a `// Package x ...` comment on the first file that introduces a package, none on subsequent files in the same package).
|
||
|
||
---
|
||
|
||
### Task 1: `profile` table + store layer
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/migrations/0003_profile.sql`
|
||
- Create: `backend/internal/store/profile.go`
|
||
- Test: `backend/internal/store/profile_test.go`
|
||
|
||
**Interfaces:**
|
||
- Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`.
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
Create `backend/internal/store/migrations/0003_profile.sql`:
|
||
|
||
```sql
|
||
-- Single-profile settings: Garmin credentials (replacing env-var-only
|
||
-- config) and every tunable engine parameter, in one editable row.
|
||
CREATE TABLE profile (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
garmin_email TEXT NOT NULL DEFAULT '',
|
||
garmin_password TEXT NOT NULL DEFAULT '',
|
||
rolling_window_days INTEGER NOT NULL DEFAULT 90,
|
||
max_heart_rate REAL,
|
||
resting_heart_rate REAL,
|
||
hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
|
||
hr_zone1_max_pct REAL NOT NULL DEFAULT 60,
|
||
hr_zone2_min_pct REAL NOT NULL DEFAULT 60,
|
||
hr_zone2_max_pct REAL NOT NULL DEFAULT 70,
|
||
hr_zone3_min_pct REAL NOT NULL DEFAULT 70,
|
||
hr_zone3_max_pct REAL NOT NULL DEFAULT 80,
|
||
hr_zone4_min_pct REAL NOT NULL DEFAULT 80,
|
||
hr_zone4_max_pct REAL NOT NULL DEFAULT 90,
|
||
hr_zone5_min_pct REAL NOT NULL DEFAULT 90,
|
||
hr_zone5_max_pct REAL NOT NULL DEFAULT 100,
|
||
easy_warmup_minutes REAL NOT NULL DEFAULT 10,
|
||
easy_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||
long_warmup_minutes REAL NOT NULL DEFAULT 10,
|
||
long_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||
tempo_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||
tempo_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||
threshold30_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||
threshold30_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||
threshold60_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||
threshold60_cooldown_minutes REAL NOT NULL DEFAULT 10,
|
||
mas_test_warmup_minutes REAL NOT NULL DEFAULT 15,
|
||
mas_test_cooldown_minutes REAL NOT NULL DEFAULT 5,
|
||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
);
|
||
INSERT INTO profile (id) VALUES (1);
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Create `backend/internal/store/profile_test.go`:
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestProfile_DefaultsThenUpdate(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
p, err := db.GetProfile(ctx)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile: %v", err)
|
||
}
|
||
if p.RollingWindowDays != 90 {
|
||
t.Errorf("RollingWindowDays = %d, want 90 (migration default)", p.RollingWindowDays)
|
||
}
|
||
if p.HRZone1MinPct != 50 || p.HRZone5MaxPct != 100 {
|
||
t.Errorf("zone defaults = %+v, want Z1 min=50, Z5 max=100", p)
|
||
}
|
||
|
||
maxHR, restingHR := 190.0, 50.0
|
||
p.GarminEmail = "runner@example.com"
|
||
p.GarminPassword = "hunter2"
|
||
p.RollingWindowDays = 120
|
||
p.MaxHeartRate = &maxHR
|
||
p.RestingHeartRate = &restingHR
|
||
p.IntervalWarmupMinutes = 8
|
||
|
||
if err := db.UpdateProfile(ctx, p); err != nil {
|
||
t.Fatalf("UpdateProfile: %v", err)
|
||
}
|
||
|
||
got, err := db.GetProfile(ctx)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile after update: %v", err)
|
||
}
|
||
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
|
||
t.Errorf("got = %+v, want updated email/window", got)
|
||
}
|
||
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
|
||
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
|
||
}
|
||
if got.IntervalWarmupMinutes != 8 {
|
||
t.Errorf("IntervalWarmupMinutes = %v, want 8", got.IntervalWarmupMinutes)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
|
||
Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet).
|
||
|
||
- [ ] **Step 4: Write `profile.go`**
|
||
|
||
Create `backend/internal/store/profile.go`:
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
)
|
||
|
||
// Profile is the single active user's Garmin credentials plus every
|
||
// tunable analysis-engine parameter. Always exactly one row (id=1).
|
||
type Profile struct {
|
||
GarminEmail string
|
||
GarminPassword string
|
||
RollingWindowDays int
|
||
MaxHeartRate *float64
|
||
RestingHeartRate *float64
|
||
|
||
HRZone1MinPct, HRZone1MaxPct float64
|
||
HRZone2MinPct, HRZone2MaxPct float64
|
||
HRZone3MinPct, HRZone3MaxPct float64
|
||
HRZone4MinPct, HRZone4MaxPct float64
|
||
HRZone5MinPct, HRZone5MaxPct float64
|
||
|
||
EasyWarmupMinutes, EasyCooldownMinutes float64
|
||
LongWarmupMinutes, LongCooldownMinutes float64
|
||
TempoWarmupMinutes, TempoCooldownMinutes float64
|
||
Threshold30WarmupMinutes, Threshold30CooldownMinutes float64
|
||
Threshold60WarmupMinutes, Threshold60CooldownMinutes float64
|
||
MASTestWarmupMinutes, MASTestCooldownMinutes float64
|
||
|
||
// IntervalWarmupMinutes/IntervalCooldownMinutes are not backed by
|
||
// dedicated columns: Interval phase detection uses the lap_intensity
|
||
// strategy (existing ACTIVE/REST lap tagging), not a fixed-duration
|
||
// guess. Kept here as a convenience zero-value for callers that don't
|
||
// yet distinguish strategies; always 0 until a future migration adds
|
||
// real columns if a fixed fallback is ever needed.
|
||
IntervalWarmupMinutes, IntervalCooldownMinutes float64
|
||
|
||
CreatedAt, UpdatedAt string
|
||
}
|
||
|
||
const profileColumns = `
|
||
garmin_email, garmin_password, rolling_window_days, max_heart_rate, resting_heart_rate,
|
||
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
|
||
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
|
||
hr_zone5_min_pct, hr_zone5_max_pct,
|
||
easy_warmup_minutes, easy_cooldown_minutes, long_warmup_minutes, long_cooldown_minutes,
|
||
tempo_warmup_minutes, tempo_cooldown_minutes,
|
||
threshold30_warmup_minutes, threshold30_cooldown_minutes,
|
||
threshold60_warmup_minutes, threshold60_cooldown_minutes,
|
||
mas_test_warmup_minutes, mas_test_cooldown_minutes,
|
||
created_at, updated_at
|
||
`
|
||
|
||
// GetProfile returns the single profile row.
|
||
func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
|
||
var p Profile
|
||
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE id = 1`).Scan(
|
||
&p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.MaxHeartRate, &p.RestingHeartRate,
|
||
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
|
||
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
|
||
&p.HRZone5MinPct, &p.HRZone5MaxPct,
|
||
&p.EasyWarmupMinutes, &p.EasyCooldownMinutes, &p.LongWarmupMinutes, &p.LongCooldownMinutes,
|
||
&p.TempoWarmupMinutes, &p.TempoCooldownMinutes,
|
||
&p.Threshold30WarmupMinutes, &p.Threshold30CooldownMinutes,
|
||
&p.Threshold60WarmupMinutes, &p.Threshold60CooldownMinutes,
|
||
&p.MASTestWarmupMinutes, &p.MASTestCooldownMinutes,
|
||
&p.CreatedAt, &p.UpdatedAt,
|
||
)
|
||
if err != nil {
|
||
return Profile{}, fmt.Errorf("get profile: %w", err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// UpdateProfile overwrites the single profile row. Callers should read via
|
||
// GetProfile first and modify the fields they intend to change, since this
|
||
// replaces every column.
|
||
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE profile SET
|
||
garmin_email=?, garmin_password=?, rolling_window_days=?, max_heart_rate=?, resting_heart_rate=?,
|
||
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
|
||
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
|
||
hr_zone5_min_pct=?, hr_zone5_max_pct=?,
|
||
easy_warmup_minutes=?, easy_cooldown_minutes=?, long_warmup_minutes=?, long_cooldown_minutes=?,
|
||
tempo_warmup_minutes=?, tempo_cooldown_minutes=?,
|
||
threshold30_warmup_minutes=?, threshold30_cooldown_minutes=?,
|
||
threshold60_warmup_minutes=?, threshold60_cooldown_minutes=?,
|
||
mas_test_warmup_minutes=?, mas_test_cooldown_minutes=?,
|
||
updated_at=datetime('now')
|
||
WHERE id = 1`,
|
||
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.MaxHeartRate, p.RestingHeartRate,
|
||
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
|
||
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
|
||
p.HRZone5MinPct, p.HRZone5MaxPct,
|
||
p.EasyWarmupMinutes, p.EasyCooldownMinutes, p.LongWarmupMinutes, p.LongCooldownMinutes,
|
||
p.TempoWarmupMinutes, p.TempoCooldownMinutes,
|
||
p.Threshold30WarmupMinutes, p.Threshold30CooldownMinutes,
|
||
p.Threshold60WarmupMinutes, p.Threshold60CooldownMinutes,
|
||
p.MASTestWarmupMinutes, p.MASTestCooldownMinutes,
|
||
)
|
||
if err != nil {
|
||
return fmt.Errorf("update profile: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
```
|
||
|
||
Note: `EasyWarmupMinutes, EasyCooldownMinutes float64` etc. above must be written as valid Go — use one field per line instead of comma-grouping when you transcribe this (Go does support comma-grouped same-type fields, e.g. `A, B float64`, so `EasyWarmupMinutes, EasyCooldownMinutes float64` on one line is valid; just don't carry through the extra alignment spaces literally — run `gofmt -w` after creating the file and let it fix spacing).
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd backend && gofmt -w internal/store/profile.go && go test ./internal/store/... -run TestProfile -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go
|
||
git commit -m "feat: add single-profile settings table and store layer"
|
||
```
|
||
(If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.)
|
||
|
||
---
|
||
|
||
### Task 2: Reseed workout taxonomy to the 7 fixed types
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/migrations/0004_workout_taxonomy.sql`
|
||
- Test: `backend/internal/store/workoutkinds_taxonomy_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `store.WorkoutKind` (existing, from `internal/store/workoutkinds.go`), `store.ListWorkoutKinds(ctx, activeOnly bool) ([]WorkoutKind, error)` (existing).
|
||
- Produces: exactly 7 rows in `workout_kinds` named `Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`, `Interval`, `MAS Test`, each with a syntactically valid but never-matching placeholder rule (real rules are tuned later, out of scope for this plan).
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
Create `backend/internal/store/migrations/0004_workout_taxonomy.sql`:
|
||
|
||
```sql
|
||
-- The taxonomy is now a fixed, closed set (no more user-created "kinds").
|
||
-- Any prior arbitrary kinds and their classification history are reset:
|
||
-- the concept they classified against no longer exists.
|
||
DELETE FROM kind_assignments;
|
||
DELETE FROM workout_kinds;
|
||
|
||
-- Placeholder rule: distance is never negative, so this never matches.
|
||
-- Every activity starts in needs_review for every type until real rules
|
||
-- are tuned (a follow-up plan, not this migration).
|
||
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active) VALUES
|
||
('Easy Run', '', '#22c55e', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('Long Run', '', '#3b82f6', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('Threshold 30''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('Threshold 60''', '', '#f59e0b', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('Tempo', '', '#eab308', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('Interval', '', '#ef4444', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1),
|
||
('MAS Test', '', '#a855f7', '{"match":"all","conditions":[{"metric":"distance_meters","op":"<","value":0}]}', 0, 1);
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Create `backend/internal/store/workoutkinds_taxonomy_test.go`:
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestWorkoutTaxonomy_SeededWithSevenFixedTypes(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
kinds, err := db.ListWorkoutKinds(ctx, true)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutKinds: %v", err)
|
||
}
|
||
if len(kinds) != 7 {
|
||
t.Fatalf("expected 7 seeded workout kinds, got %d: %+v", len(kinds), kinds)
|
||
}
|
||
|
||
wantNames := map[string]bool{
|
||
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
|
||
"Tempo": false, "Interval": false, "MAS Test": false,
|
||
}
|
||
for _, k := range kinds {
|
||
if _, ok := wantNames[k.Name]; !ok {
|
||
t.Errorf("unexpected seeded kind name %q", k.Name)
|
||
continue
|
||
}
|
||
wantNames[k.Name] = true
|
||
}
|
||
for name, found := range wantNames {
|
||
if !found {
|
||
t.Errorf("expected seeded kind %q, not found", name)
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v`
|
||
Expected: FAIL — `expected 7 seeded workout kinds, got 0`
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
The migration alone should make this pass once picked up by the `Open`/migrate path — no Go code changes needed for this task.
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestWorkoutTaxonomy -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Run the full store test suite to confirm nothing broke**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -v 2>&1 | tail -40`
|
||
Expected: all PASS. (`TestKindAssignment_AppendOnlyHistoryAndCurrentView` and similar tests create their own workout kinds via `db.CreateWorkoutKind` in a fresh temp DB per test, so the reseed migration doesn't interfere with them — each test gets its own SQLite file via `openTestDB`.)
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/store/migrations/0004_workout_taxonomy.sql internal/store/workoutkinds_taxonomy_test.go
|
||
git commit -m "feat: reseed workout_kinds with the fixed 7-type running taxonomy"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: `workout_type_paces` table + store layer
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/store/migrations/0005_workout_type_paces.sql`
|
||
- Create: `backend/internal/store/workoutpaces.go`
|
||
- Test: `backend/internal/store/workoutpaces_test.go`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `store.WorkoutKind.ID` (existing).
|
||
- Produces: `store.WorkoutTypePace` struct, `(db *DB) GetWorkoutTypePace(ctx, workoutKindID int64) (WorkoutTypePace, error)`, `(db *DB) UpdateWorkoutTypePace(ctx, WorkoutTypePace) error`, `(db *DB) ListWorkoutTypePaces(ctx) ([]WorkoutTypePace, error)`.
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
Create `backend/internal/store/migrations/0005_workout_type_paces.sql`:
|
||
|
||
```sql
|
||
-- Per-workout-type target pace range and expected HR zone. Informational
|
||
-- only: never read by the classification rule engine (see spec section 2).
|
||
-- No history -- overwritten in place when the user updates a value; the
|
||
-- synced activity log is the historical record.
|
||
CREATE TABLE workout_type_paces (
|
||
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
|
||
pace_min_sec_per_km REAL,
|
||
pace_max_sec_per_km REAL,
|
||
expected_hr_zone INTEGER CHECK (expected_hr_zone IS NULL OR expected_hr_zone BETWEEN 1 AND 5)
|
||
);
|
||
|
||
INSERT INTO workout_type_paces (workout_kind_id)
|
||
SELECT id FROM workout_kinds;
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
Create `backend/internal/store/workoutpaces_test.go`:
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
)
|
||
|
||
func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
|
||
db := openTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
all, err := db.ListWorkoutTypePaces(ctx)
|
||
if err != nil {
|
||
t.Fatalf("ListWorkoutTypePaces: %v", err)
|
||
}
|
||
if len(all) != 7 {
|
||
t.Fatalf("expected 7 seeded pace rows (one per taxonomy kind), got %d", len(all))
|
||
}
|
||
for _, p := range all {
|
||
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.ExpectedHRZone != nil {
|
||
t.Errorf("expected all-nil seeded pace row for kind %d, got %+v", p.WorkoutKindID, p)
|
||
}
|
||
}
|
||
|
||
target := all[0]
|
||
minPace, maxPace, zone := 330.0, 420.0, 2
|
||
target.PaceMinSecPerKm = &minPace
|
||
target.PaceMaxSecPerKm = &maxPace
|
||
target.ExpectedHRZone = &zone
|
||
|
||
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
|
||
t.Fatalf("UpdateWorkoutTypePace: %v", err)
|
||
}
|
||
|
||
got, err := db.GetWorkoutTypePace(ctx, target.WorkoutKindID)
|
||
if err != nil {
|
||
t.Fatalf("GetWorkoutTypePace: %v", err)
|
||
}
|
||
if got.PaceMinSecPerKm == nil || *got.PaceMinSecPerKm != 330 {
|
||
t.Errorf("PaceMinSecPerKm = %v, want 330", got.PaceMinSecPerKm)
|
||
}
|
||
if got.ExpectedHRZone == nil || *got.ExpectedHRZone != 2 {
|
||
t.Errorf("ExpectedHRZone = %v, want 2", got.ExpectedHRZone)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `cd backend && go test ./internal/store/... -run TestWorkoutTypePaces -v`
|
||
Expected: FAIL (compile error — `ListWorkoutTypePaces` etc. undefined).
|
||
|
||
- [ ] **Step 4: Write `workoutpaces.go`**
|
||
|
||
Create `backend/internal/store/workoutpaces.go`:
|
||
|
||
```go
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"fmt"
|
||
)
|
||
|
||
// WorkoutTypePace is a workout kind's user-declared target pace range and
|
||
// expected HR zone. Informational only -- never read by the classification
|
||
// rule engine. No history: fields are overwritten in place.
|
||
type WorkoutTypePace struct {
|
||
WorkoutKindID int64
|
||
PaceMinSecPerKm *float64
|
||
PaceMaxSecPerKm *float64
|
||
ExpectedHRZone *int
|
||
}
|
||
|
||
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
|
||
var p WorkoutTypePace
|
||
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone)
|
||
return p, err
|
||
}
|
||
|
||
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone`
|
||
|
||
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
|
||
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
|
||
row := db.QueryRowContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces WHERE workout_kind_id = ?`, workoutKindID)
|
||
p, err := scanWorkoutTypePace(row)
|
||
if err == sql.ErrNoRows {
|
||
return WorkoutTypePace{WorkoutKindID: workoutKindID}, nil
|
||
}
|
||
if err != nil {
|
||
return WorkoutTypePace{}, fmt.Errorf("get workout type pace for kind %d: %w", workoutKindID, err)
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
// UpdateWorkoutTypePace overwrites the pace/zone row for one workout kind.
|
||
func (db *DB) UpdateWorkoutTypePace(ctx context.Context, p WorkoutTypePace) error {
|
||
_, err := db.ExecContext(ctx, `
|
||
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, expected_hr_zone=?
|
||
WHERE workout_kind_id=?`,
|
||
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID)
|
||
if err != nil {
|
||
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ListWorkoutTypePaces returns every workout kind's pace/zone row.
|
||
func (db *DB) ListWorkoutTypePaces(ctx context.Context) ([]WorkoutTypePace, error) {
|
||
rows, err := db.QueryContext(ctx, `SELECT `+workoutTypePaceColumns+` FROM workout_type_paces ORDER BY workout_kind_id`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("list workout type paces: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
paces := []WorkoutTypePace{}
|
||
for rows.Next() {
|
||
p, err := scanWorkoutTypePace(rows)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("scan workout type pace row: %w", err)
|
||
}
|
||
paces = append(paces, p)
|
||
}
|
||
return paces, rows.Err()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd backend && gofmt -w internal/store/workoutpaces.go && go test ./internal/store/... -run TestWorkoutTypePaces -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/store/migrations/0005_workout_type_paces.sql internal/store/workoutpaces.go internal/store/workoutpaces_test.go
|
||
git commit -m "feat: add per-workout-type pace range and expected HR zone"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: `garmin.Client.UpdateCredentials`
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/garmin/client.go` (interface at lines 21-35, `mcpClient` struct/methods at lines 48-98)
|
||
- Modify: `backend/internal/garmin/mock/mock.go`
|
||
- Test: `backend/internal/garmin/client_test.go` (existing file — add a test)
|
||
|
||
**Interfaces:**
|
||
- Produces: `Client.UpdateCredentials(email, password string)` (no error return — it just updates in-memory state and closes any running subprocess; the next call that needs the subprocess respawns it and surfaces any connection error then, same as today).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Add to `backend/internal/garmin/client_test.go` (append a new test function; keep the existing `TestParseAuthResult`):
|
||
|
||
```go
|
||
func TestMcpClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
|
||
c := &mcpClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}}
|
||
c.started = true // simulate an already-spawned subprocess
|
||
|
||
c.UpdateCredentials("new@example.com", "new")
|
||
|
||
if c.cfg.GarminEmail != "new@example.com" || c.cfg.GarminPassword != "new" {
|
||
t.Errorf("cfg after update = %+v, want new@example.com/new", c.cfg)
|
||
}
|
||
if c.started {
|
||
t.Error("started should be reset to false so the next call respawns the subprocess")
|
||
}
|
||
if c.inner != nil {
|
||
t.Error("inner should be cleared so ensureStarted spawns a fresh client")
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && go test ./internal/garmin/... -run TestMcpClient_UpdateCredentials -v`
|
||
Expected: FAIL — `c.UpdateCredentials undefined (type *mcpClient has no field or method UpdateCredentials)`
|
||
|
||
- [ ] **Step 3: Add `UpdateCredentials` to the `Client` interface**
|
||
|
||
In `backend/internal/garmin/client.go`, modify the `Client` interface (currently lines 21-35) by adding the new method right after `CompleteMFA`:
|
||
|
||
```go
|
||
type Client interface {
|
||
// Authenticate triggers Garmin login using credentials the subprocess
|
||
// was started with. Spawns the subprocess on first call.
|
||
Authenticate(ctx context.Context) (AuthResult, error)
|
||
// CompleteMFA submits an MFA code for a login started by Authenticate.
|
||
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
|
||
// UpdateCredentials replaces the Garmin email/password used to spawn
|
||
// the subprocess, and terminates any already-running subprocess (which
|
||
// would otherwise still be authenticated under the old credentials).
|
||
// The next call that needs the subprocess spawns a fresh one with the
|
||
// new credentials.
|
||
UpdateCredentials(email, password string)
|
||
// GetActivities lists activities between start and end (YYYY-MM-DD).
|
||
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
|
||
// GetActivitySplits fetches lap/split summaries for one activity.
|
||
GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error)
|
||
// GetActivityDetails fetches raw per-second telemetry for one activity.
|
||
GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error)
|
||
// Close terminates the subprocess, if running.
|
||
Close() error
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Implement `UpdateCredentials` on `mcpClient`**
|
||
|
||
In `backend/internal/garmin/client.go`, add this method right after the existing `func (c *mcpClient) ensureStarted(...)` method (which ends around line 98):
|
||
|
||
```go
|
||
// UpdateCredentials implements Client.
|
||
func (c *mcpClient) UpdateCredentials(email, password string) {
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
|
||
c.cfg.GarminEmail = email
|
||
c.cfg.GarminPassword = password
|
||
|
||
if c.started {
|
||
c.inner.Close()
|
||
c.inner = nil
|
||
c.started = false
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add a no-op tracking implementation to the mock**
|
||
|
||
In `backend/internal/garmin/mock/mock.go`, add two fields to the `Client` struct (`LastEmail`, `LastPassword`) and the method. The struct currently reads:
|
||
|
||
```go
|
||
type Client struct {
|
||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||
Activities []garmin.Activity
|
||
Splits map[int64]garmin.ActivitySplits
|
||
Details map[int64]garmin.ActivityDetails
|
||
Err error // if set, every call returns this error
|
||
authResultCursor int
|
||
ClosedCalled bool
|
||
GetActivitiesCalls int
|
||
}
|
||
```
|
||
|
||
Change it to:
|
||
|
||
```go
|
||
type Client struct {
|
||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||
Activities []garmin.Activity
|
||
Splits map[int64]garmin.ActivitySplits
|
||
Details map[int64]garmin.ActivityDetails
|
||
Err error // if set, every call returns this error
|
||
authResultCursor int
|
||
ClosedCalled bool
|
||
GetActivitiesCalls int
|
||
LastEmail string
|
||
LastPassword string
|
||
}
|
||
```
|
||
|
||
Add the method anywhere after the struct definition:
|
||
|
||
```go
|
||
func (c *Client) UpdateCredentials(email, password string) {
|
||
c.LastEmail = email
|
||
c.LastPassword = password
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run test to verify it passes**
|
||
|
||
Run: `cd backend && gofmt -w internal/garmin/client.go internal/garmin/mock/mock.go && go build ./... && go test ./internal/garmin/... -v`
|
||
Expected: all PASS, including the new `TestMcpClient_UpdateCredentials_ResetsStartedState`.
|
||
|
||
- [ ] **Step 7: Run the full backend test suite to confirm nothing broke**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./... && go test ./...`
|
||
Expected: all PASS (the `garmin.Client` interface changed, so this also confirms every existing caller/mock still satisfies it).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/garmin/client.go internal/garmin/client_test.go internal/garmin/mock/mock.go
|
||
git commit -m "feat: support updating Garmin credentials at runtime"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Profile REST endpoints
|
||
|
||
**Files:**
|
||
- Create: `backend/internal/api/profile.go`
|
||
- Modify: `backend/internal/api/server.go` (add route inside `Router()`, currently lines 40-76)
|
||
- Test: `backend/internal/api/api_test.go` (append)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `store.Profile`, `db.GetProfile`/`db.UpdateProfile` (Task 1), `Server.Garmin.UpdateCredentials` (Task 4, via the existing `Server.Garmin garmin.Client` field).
|
||
- Produces: `GET /api/profile`, `PUT /api/profile`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Append to `backend/internal/api/api_test.go`:
|
||
|
||
```go
|
||
func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("get status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
var got store.Profile
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if got.RollingWindowDays != 90 {
|
||
t.Fatalf("RollingWindowDays = %d, want 90", got.RollingWindowDays)
|
||
}
|
||
|
||
got.GarminEmail = "runner@example.com"
|
||
got.GarminPassword = "hunter2"
|
||
got.RollingWindowDays = 120
|
||
rec = doJSON(t, router, http.MethodPut, "/api/profile", got)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||
var updated store.Profile
|
||
json.Unmarshal(rec.Body.Bytes(), &updated)
|
||
if updated.GarminEmail != "runner@example.com" || updated.RollingWindowDays != 120 {
|
||
t.Fatalf("updated = %+v, want new email/window", updated)
|
||
}
|
||
}
|
||
|
||
func TestProfile_RejectsInvalidHRZones(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
|
||
var p store.Profile
|
||
json.Unmarshal(rec.Body.Bytes(), &p)
|
||
|
||
maxHR, restingHR := 100.0, 150.0 // resting > max: invalid
|
||
p.MaxHeartRate = &maxHR
|
||
p.RestingHeartRate = &restingHR
|
||
|
||
rec = doJSON(t, router, http.MethodPut, "/api/profile", p)
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run TestProfile -v`
|
||
Expected: FAIL — 404 on `GET /api/profile` (route doesn't exist yet).
|
||
|
||
- [ ] **Step 3: Write the handlers**
|
||
|
||
Create `backend/internal/api/profile.go`:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
|
||
"smartrun/backend/internal/store"
|
||
)
|
||
|
||
func validateProfile(p store.Profile) error {
|
||
if p.RestingHeartRate != nil && p.MaxHeartRate != nil && *p.RestingHeartRate >= *p.MaxHeartRate {
|
||
return errors.New("resting heart rate must be less than max heart rate")
|
||
}
|
||
zones := [][2]float64{
|
||
{p.HRZone1MinPct, p.HRZone1MaxPct},
|
||
{p.HRZone2MinPct, p.HRZone2MaxPct},
|
||
{p.HRZone3MinPct, p.HRZone3MaxPct},
|
||
{p.HRZone4MinPct, p.HRZone4MaxPct},
|
||
{p.HRZone5MinPct, p.HRZone5MaxPct},
|
||
}
|
||
if zones[0][0] != 0 {
|
||
return errors.New("zone 1 must start at 0%")
|
||
}
|
||
if zones[len(zones)-1][1] != 100 {
|
||
return errors.New("zone 5 must end at 100%")
|
||
}
|
||
for i, z := range zones {
|
||
if z[0] >= z[1] {
|
||
return errors.New("each HR zone's min must be less than its max")
|
||
}
|
||
if i > 0 && z[0] != zones[i-1][1] {
|
||
return errors.New("HR zones must be contiguous and non-overlapping")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
|
||
p, err := s.DB.GetProfile(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, p)
|
||
}
|
||
|
||
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||
var p store.Profile
|
||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if err := validateProfile(p); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
if err := s.DB.UpdateProfile(r.Context(), p); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
s.Garmin.UpdateCredentials(p.GarminEmail, p.GarminPassword)
|
||
|
||
updated, err := s.DB.GetProfile(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, updated)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the route**
|
||
|
||
In `backend/internal/api/server.go`, inside `Router()`, add a new route registration. It currently reads (lines 40-42):
|
||
|
||
```go
|
||
r.Route("/api", func(r chi.Router) {
|
||
r.Get("/health", s.handleHealth)
|
||
|
||
```
|
||
|
||
Change to:
|
||
|
||
```go
|
||
r.Route("/api", func(r chi.Router) {
|
||
r.Get("/health", s.handleHealth)
|
||
|
||
r.Route("/profile", func(r chi.Router) {
|
||
r.Get("/", s.handleGetProfile)
|
||
r.Put("/", s.handleUpdateProfile)
|
||
})
|
||
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd backend && gofmt -w internal/api/profile.go internal/api/server.go && go test ./internal/api/... -run TestProfile -v`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 6: Run the full backend test suite**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./... && go test ./...`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go
|
||
git commit -m "feat: add profile REST endpoints with HR zone validation"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Fixed taxonomy in the API — drop create/delete, add pace fields
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/api/kinds.go` (full rewrite — small file, shown complete below)
|
||
- Modify: `backend/internal/api/server.go` (route group, currently lines 61-68)
|
||
- Modify: `backend/internal/api/api_test.go` (replace `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `store.WorkoutTypePace`, `db.GetWorkoutTypePace`/`db.UpdateWorkoutTypePace` (Task 3).
|
||
- Produces: `workoutKindResponse` (kind + pace fields combined), used by `GET /api/workout-kinds/`, `GET /api/workout-kinds/{id}`, `PUT /api/workout-kinds/{id}`. `POST /api/workout-kinds/` and `DELETE /api/workout-kinds/{id}` no longer exist.
|
||
|
||
- [ ] **Step 1: Update the tests first (they define the new contract)**
|
||
|
||
In `backend/internal/api/api_test.go`, replace the two functions `TestWorkoutKindCRUD` and `TestWorkoutKindCreate_RejectsInvalidRule` (currently lines 61-111) with:
|
||
|
||
```go
|
||
func TestWorkoutKindList_ReturnsSevenSeededTypesWithPaceFields(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
rec := doJSON(t, s.Router(), http.MethodGet, "/api/workout-kinds/", nil)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d", rec.Code)
|
||
}
|
||
var kinds []workoutKindResponse
|
||
if err := json.Unmarshal(rec.Body.Bytes(), &kinds); err != nil {
|
||
t.Fatalf("unmarshal: %v", err)
|
||
}
|
||
if len(kinds) != 7 {
|
||
t.Fatalf("expected 7 seeded kinds, got %d", len(kinds))
|
||
}
|
||
for _, k := range kinds {
|
||
if k.PaceMinSecPerKm != nil || k.PaceMaxSecPerKm != nil || k.ExpectedHRZone != nil {
|
||
t.Errorf("expected freshly-seeded kind %q to have nil pace fields, got %+v", k.Name, k)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestWorkoutKindUpdate_SetsRuleAndPace(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||
var kinds []workoutKindResponse
|
||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||
target := kinds[0]
|
||
|
||
minPace, maxPace, zone := 330.0, 420.0, 2
|
||
body := map[string]any{
|
||
"name": target.Name,
|
||
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
||
"pace_min_sec_per_km": minPace,
|
||
"pace_max_sec_per_km": maxPace,
|
||
"expected_hr_zone": zone,
|
||
}
|
||
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(target.ID), body)
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("update status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/"+itoa(target.ID), nil)
|
||
var updated workoutKindResponse
|
||
json.Unmarshal(rec.Body.Bytes(), &updated)
|
||
if updated.PaceMinSecPerKm == nil || *updated.PaceMinSecPerKm != 330 {
|
||
t.Errorf("PaceMinSecPerKm = %v, want 330", updated.PaceMinSecPerKm)
|
||
}
|
||
if updated.ExpectedHRZone == nil || *updated.ExpectedHRZone != 2 {
|
||
t.Errorf("ExpectedHRZone = %v, want 2", updated.ExpectedHRZone)
|
||
}
|
||
}
|
||
|
||
func TestWorkoutKindUpdate_RejectsInvalidRule(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||
var kinds []workoutKindResponse
|
||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||
|
||
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
||
"name": kinds[0].Name,
|
||
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
||
})
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestWorkoutKindUpdate_RejectsInvalidHRZone(t *testing.T) {
|
||
s, _ := newTestServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||
var kinds []workoutKindResponse
|
||
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||
|
||
rec = doJSON(t, router, http.MethodPut, "/api/workout-kinds/"+itoa(kinds[0].ID), map[string]any{
|
||
"name": kinds[0].Name,
|
||
"rule": json.RawMessage(`{"match":"all","conditions":[]}`),
|
||
"expected_hr_zone": 9,
|
||
})
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run TestWorkoutKind -v`
|
||
Expected: FAIL (compile error — `workoutKindResponse` undefined).
|
||
|
||
- [ ] **Step 3: Rewrite `kinds.go`**
|
||
|
||
Replace the full contents of `backend/internal/api/kinds.go` with:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"smartrun/backend/internal/classify"
|
||
"smartrun/backend/internal/store"
|
||
)
|
||
|
||
// workoutKindResponse combines a workout kind's rule/metadata with its pace
|
||
// range and expected HR zone (stored separately in workout_type_paces),
|
||
// since the frontend always edits and displays them together.
|
||
type workoutKindResponse struct {
|
||
store.WorkoutKind
|
||
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
||
PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"`
|
||
ExpectedHRZone *int `json:"expected_hr_zone"`
|
||
}
|
||
|
||
func (s *Server) toWorkoutKindResponse(r *http.Request, k store.WorkoutKind) (workoutKindResponse, error) {
|
||
pace, err := s.DB.GetWorkoutTypePace(r.Context(), k.ID)
|
||
if err != nil {
|
||
return workoutKindResponse{}, err
|
||
}
|
||
return workoutKindResponse{
|
||
WorkoutKind: k,
|
||
PaceMinSecPerKm: pace.PaceMinSecPerKm,
|
||
PaceMaxSecPerKm: pace.PaceMaxSecPerKm,
|
||
ExpectedHRZone: pace.ExpectedHRZone,
|
||
}, nil
|
||
}
|
||
|
||
type workoutKindRequest struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
Color string `json:"color"`
|
||
Rule json.RawMessage `json:"rule"`
|
||
Priority int `json:"priority"`
|
||
IsActive *bool `json:"is_active"`
|
||
PaceMinSecPerKm *float64 `json:"pace_min_sec_per_km"`
|
||
PaceMaxSecPerKm *float64 `json:"pace_max_sec_per_km"`
|
||
ExpectedHRZone *int `json:"expected_hr_zone"`
|
||
}
|
||
|
||
func (req workoutKindRequest) validate() (classify.Node, error) {
|
||
var node classify.Node
|
||
if req.Name == "" {
|
||
return node, errors.New("name is required")
|
||
}
|
||
if err := json.Unmarshal(req.Rule, &node); err != nil {
|
||
return node, errors.New("rule is not valid JSON: " + err.Error())
|
||
}
|
||
if err := node.Validate(); err != nil {
|
||
return node, errors.New("invalid rule: " + err.Error())
|
||
}
|
||
if req.ExpectedHRZone != nil && (*req.ExpectedHRZone < 1 || *req.ExpectedHRZone > 5) {
|
||
return node, errors.New("expected_hr_zone must be between 1 and 5")
|
||
}
|
||
return node, nil
|
||
}
|
||
|
||
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
||
kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
resp := make([]workoutKindResponse, 0, len(kinds))
|
||
for _, k := range kinds {
|
||
wr, err := s.toWorkoutKindResponse(r, k)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
resp = append(resp, wr)
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||
return
|
||
}
|
||
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||
return
|
||
}
|
||
resp, err := s.toWorkoutKindResponse(r, kind)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||
return
|
||
}
|
||
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if !ok {
|
||
writeError(w, http.StatusNotFound, "workout kind not found")
|
||
return
|
||
}
|
||
|
||
var req workoutKindRequest
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if _, err := req.validate(); err != nil {
|
||
writeError(w, http.StatusBadRequest, err.Error())
|
||
return
|
||
}
|
||
|
||
isActive := existing.IsActive
|
||
if req.IsActive != nil {
|
||
isActive = *req.IsActive
|
||
}
|
||
|
||
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
|
||
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if err := s.DB.UpdateWorkoutTypePace(r.Context(), store.WorkoutTypePace{
|
||
WorkoutKindID: id,
|
||
PaceMinSecPerKm: req.PaceMinSecPerKm,
|
||
PaceMaxSecPerKm: req.PaceMaxSecPerKm,
|
||
ExpectedHRZone: req.ExpectedHRZone,
|
||
}); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
||
resp, err := s.toWorkoutKindResponse(r, kind)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
writeJSON(w, http.StatusOK, resp)
|
||
}
|
||
|
||
// handleReclassifyKind re-runs the rule engine for every activity currently
|
||
// assigned to (or under review for) this kind. It's a synchronous, bounded
|
||
// operation (unlike sync), so it runs inline rather than in the background.
|
||
func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) {
|
||
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||
return
|
||
}
|
||
|
||
assignments, err := s.DB.AssignmentsForKind(r.Context(), id)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
reviewQueue, err := s.DB.ReviewQueue(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
seen := make(map[int64]bool)
|
||
var activityIDs []int64
|
||
for _, a := range assignments {
|
||
if !seen[a.ActivityID] {
|
||
seen[a.ActivityID] = true
|
||
activityIDs = append(activityIDs, a.ActivityID)
|
||
}
|
||
}
|
||
for _, a := range reviewQueue {
|
||
if !seen[a.ActivityID] {
|
||
seen[a.ActivityID] = true
|
||
activityIDs = append(activityIDs, a.ActivityID)
|
||
}
|
||
}
|
||
|
||
for _, activityID := range activityIDs {
|
||
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Remove the create/delete routes**
|
||
|
||
In `backend/internal/api/server.go`, the workout-kinds route group currently reads (lines 61-68):
|
||
|
||
```go
|
||
r.Route("/workout-kinds", func(r chi.Router) {
|
||
r.Get("/", s.handleListWorkoutKinds)
|
||
r.Post("/", s.handleCreateWorkoutKind)
|
||
r.Get("/{id}", s.handleGetWorkoutKind)
|
||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||
r.Delete("/{id}", s.handleDeleteWorkoutKind)
|
||
r.Post("/{id}/reclassify", s.handleReclassifyKind)
|
||
})
|
||
```
|
||
|
||
Change to:
|
||
|
||
```go
|
||
r.Route("/workout-kinds", func(r chi.Router) {
|
||
r.Get("/", s.handleListWorkoutKinds)
|
||
r.Get("/{id}", s.handleGetWorkoutKind)
|
||
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||
r.Post("/{id}/reclassify", s.handleReclassifyKind)
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && gofmt -w internal/api/kinds.go internal/api/server.go && go test ./internal/api/... -v 2>&1 | tail -60`
|
||
Expected: all PASS, including the four new/renamed tests. `TestReviewQueueResolve` and `TestProgression_ReturnsSortedTimeSeries` still pass unchanged (they create their own additively-named test kinds via `db.CreateWorkoutKind` directly, bypassing the now-removed API route, which remains fine since `store.CreateWorkoutKind` itself is untouched).
|
||
|
||
- [ ] **Step 6: Run the full backend test suite**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./... && go test ./...`
|
||
Expected: all PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/api/kinds.go internal/api/server.go internal/api/api_test.go
|
||
git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 7: Classification reads max HR from the profile, not static config
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
|
||
- Modify: `backend/internal/sync/service_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/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
|
||
|
||
```go
|
||
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||
```
|
||
|
||
Change to (drop `MaxHR` — the test's rule only checks `avg_pace_sec_per_km`, so this doesn't change its assertions; max HR now comes from the profile row, which defaults to `NULL`/unset and is simply omitted from the metric context in that case, same as before):
|
||
|
||
```go
|
||
svc := NewService(m, db, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails to compile**
|
||
|
||
Run: `cd backend && go test ./internal/sync/... -v 2>&1 | head -20`
|
||
Expected: passes as-is right now (we haven't removed the field yet) — this step just confirms the test file still compiles before the next change. Expected: PASS (no behavior change yet).
|
||
|
||
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
|
||
|
||
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
|
||
|
||
```go
|
||
// MinConfidence is the classify.Classify threshold below which even a
|
||
// single matching kind is sent to manual review.
|
||
MinConfidence float64
|
||
// MaxHR is used only to derive avg_hr_pct_max for the rule engine.
|
||
MaxHR float64
|
||
}
|
||
```
|
||
|
||
Change to (drop the `MaxHR` field and its comment):
|
||
|
||
```go
|
||
// MinConfidence is the classify.Classify threshold below which even a
|
||
// single matching kind is sent to manual review.
|
||
MinConfidence float64
|
||
}
|
||
```
|
||
|
||
Then, in the same file, `ClassifyActivity` currently reads (lines 279-301):
|
||
|
||
```go
|
||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||
activity, ok, err := s.db.GetActivity(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return fmt.Errorf("activity %d not found", activityID)
|
||
}
|
||
laps, err := s.db.LapsForActivity(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rules, err := loadRuleKinds(kindRows)
|
||
if err != nil {
|
||
return fmt.Errorf("parse workout kind rules: %w", err)
|
||
}
|
||
|
||
ctxMetrics := buildMetricContext(activity, laps, s.cfg.MaxHR)
|
||
result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence)
|
||
```
|
||
|
||
Change the middle to read the profile's max heart rate instead of `s.cfg.MaxHR`:
|
||
|
||
```go
|
||
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||
activity, ok, err := s.db.GetActivity(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return fmt.Errorf("activity %d not found", activityID)
|
||
}
|
||
laps, err := s.db.LapsForActivity(ctx, activityID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rules, err := loadRuleKinds(kindRows)
|
||
if err != nil {
|
||
return fmt.Errorf("parse workout kind rules: %w", err)
|
||
}
|
||
profile, err := s.db.GetProfile(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("load profile: %w", err)
|
||
}
|
||
var maxHR float64
|
||
if profile.MaxHeartRate != nil {
|
||
maxHR = *profile.MaxHeartRate
|
||
}
|
||
|
||
ctxMetrics := buildMetricContext(activity, laps, maxHR)
|
||
result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence)
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && gofmt -w internal/sync/service.go internal/sync/service_test.go && go build ./... && go test ./internal/sync/... -v`
|
||
Expected: all PASS. `TestBuildMetricContext_DerivesExpectedMetrics` is untouched and still passes — it calls `buildMetricContext` directly with a literal `190`, and that function's signature didn't change, only `ClassifyActivity`'s caller-side source of the value did.
|
||
|
||
- [ ] **Step 5: Run the full backend test suite**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./... && go test ./...`
|
||
Expected: all PASS. If `cmd/smartrund/main.go` fails to build because it still references `MaxHR: cfg.MaxHR` in its `appsync.Config{...}` literal, that's expected — Task 8 fixes `cmd/smartrund` and `internal/config` together; this step is allowed to show that build failure so you can confirm it's exactly (and only) that one line before moving to Task 8.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/sync/service.go internal/sync/service_test.go
|
||
git commit -m "feat: classification reads max HR from the profile instead of static config"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 8: `internal/config` and `cmd/smartrund` — credentials come from the profile
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
|
||
- Modify: `backend/cmd/smartrund/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`/`SMARTRUN_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/config.go` with:
|
||
|
||
```go
|
||
// Package config loads smartrund'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 smartrund'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("SMARTRUN_ADDR", ":8080"),
|
||
DBPath: getEnvDefault("SMARTRUN_DB_PATH", "smartrun.db"),
|
||
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
||
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
||
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
|
||
MinConfidence: getEnvFloat("SMARTRUN_MIN_CONFIDENCE", 0.6),
|
||
BackfillHorizonDays: getEnvInt("SMARTRUN_BACKFILL_HORIZON_DAYS", 3*365),
|
||
IncrementalSyncEvery: getEnvDuration("SMARTRUN_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/smartrund/main.go` to load credentials from the profile**
|
||
|
||
`backend/cmd/smartrund/main.go` currently reads (lines 21-46):
|
||
|
||
```go
|
||
func main() {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
log.Fatalf("config: %v", err)
|
||
}
|
||
|
||
db, err := store.Open(cfg.DBPath)
|
||
if err != nil {
|
||
log.Fatalf("open database: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
garminClient := garmin.NewClient(garmin.Config{
|
||
PythonPath: cfg.GarminPythonPath,
|
||
ServerPath: cfg.GarminServerPath,
|
||
GarminEmail: cfg.GarminEmail,
|
||
GarminPassword: cfg.GarminPassword,
|
||
TokenStorePath: cfg.GarminTokenStore,
|
||
})
|
||
defer garminClient.Close()
|
||
|
||
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
|
||
BackfillHorizonDays: cfg.BackfillHorizonDays,
|
||
MinConfidence: cfg.MinConfidence,
|
||
MaxHR: cfg.MaxHR,
|
||
}, nil)
|
||
```
|
||
|
||
Change to:
|
||
|
||
```go
|
||
func main() {
|
||
cfg, err := config.Load()
|
||
if err != nil {
|
||
log.Fatalf("config: %v", err)
|
||
}
|
||
|
||
db, err := store.Open(cfg.DBPath)
|
||
if err != nil {
|
||
log.Fatalf("open database: %v", err)
|
||
}
|
||
defer db.Close()
|
||
|
||
profile, err := db.GetProfile(context.Background())
|
||
if err != nil {
|
||
log.Fatalf("load profile: %v", err)
|
||
}
|
||
|
||
garminClient := garmin.NewClient(garmin.Config{
|
||
PythonPath: cfg.GarminPythonPath,
|
||
ServerPath: cfg.GarminServerPath,
|
||
GarminEmail: profile.GarminEmail,
|
||
GarminPassword: profile.GarminPassword,
|
||
TokenStorePath: cfg.GarminTokenStore,
|
||
})
|
||
defer garminClient.Close()
|
||
|
||
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
|
||
BackfillHorizonDays: cfg.BackfillHorizonDays,
|
||
MinConfidence: cfg.MinConfidence,
|
||
}, nil)
|
||
```
|
||
|
||
(`context` is already imported in this file for the later `signal.NotifyContext` call, so no import changes are needed.)
|
||
|
||
- [ ] **Step 3: Build and run the full backend test suite**
|
||
|
||
Run: `cd backend && gofmt -w internal/config/config.go cmd/smartrund/main.go && go build ./... && go vet ./... && go test ./...`
|
||
Expected: everything builds and all tests PASS.
|
||
|
||
- [ ] **Step 4: Manual smoke test — start the server without Garmin env vars**
|
||
|
||
```bash
|
||
cd backend
|
||
rm -f /tmp/smartrun_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 \
|
||
SMARTRUN_DB_PATH=/tmp/smartrun_task8.db \
|
||
SMARTRUN_ADDR=:8085 \
|
||
go run ./cmd/smartrund &
|
||
sleep 2
|
||
curl -s localhost:8085/api/profile
|
||
curl -s localhost:8085/api/workout-kinds/ | head -c 300
|
||
kill %1
|
||
```
|
||
|
||
Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` returns the default profile JSON (`rolling_window_days: 90`, etc.), and `/api/workout-kinds/` returns the 7 seeded types.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd backend && git add internal/config/config.go cmd/smartrund/main.go
|
||
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Frontend — types, API client, Profile page, WorkoutKinds page
|
||
|
||
Combined into a single task (rather than three separate ones) so every commit typechecks cleanly: splitting the API-client change (which removes `createWorkoutKind`/`deleteWorkoutKind`) from the `WorkoutKinds.tsx` rewrite that stops calling them would leave an intermediate commit with `tsc -b` failing.
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/types/api.ts` (add `Profile`, extend `WorkoutKind`)
|
||
- Modify: `frontend/src/api/client.ts` (add profile endpoints, remove create/delete, extend update)
|
||
- Create: `frontend/src/pages/Profile.tsx`
|
||
- Modify: `frontend/src/App.tsx` (add a "Profile" tab)
|
||
- Modify: `frontend/src/pages/WorkoutKinds.tsx` (full rewrite — shown complete below)
|
||
|
||
**Interfaces:**
|
||
- Produces: `Profile` TS type, `api.getProfile()`, `api.updateProfile(profile)`.
|
||
- Removes: `api.createWorkoutKind`, `api.deleteWorkoutKind` (routes no longer exist server-side).
|
||
|
||
- [ ] **Step 1: Extend `WorkoutKind` and add `Profile` to `types/api.ts`**
|
||
|
||
In `frontend/src/types/api.ts`, find the existing `WorkoutKind` interface:
|
||
|
||
```ts
|
||
export interface WorkoutKind {
|
||
ID: number;
|
||
Name: string;
|
||
Description: string;
|
||
Color: string;
|
||
RuleJSON: string;
|
||
Priority: number;
|
||
IsActive: boolean;
|
||
CreatedAt: string;
|
||
UpdatedAt: string;
|
||
}
|
||
```
|
||
|
||
Replace it with:
|
||
|
||
```ts
|
||
export interface WorkoutKind {
|
||
ID: number;
|
||
Name: string;
|
||
Description: string;
|
||
Color: string;
|
||
RuleJSON: string;
|
||
Priority: number;
|
||
IsActive: boolean;
|
||
CreatedAt: string;
|
||
UpdatedAt: string;
|
||
pace_min_sec_per_km: number | null;
|
||
pace_max_sec_per_km: number | null;
|
||
expected_hr_zone: number | null;
|
||
}
|
||
|
||
export interface Profile {
|
||
GarminEmail: string;
|
||
GarminPassword: string;
|
||
RollingWindowDays: number;
|
||
MaxHeartRate: number | null;
|
||
RestingHeartRate: number | null;
|
||
HRZone1MinPct: number;
|
||
HRZone1MaxPct: number;
|
||
HRZone2MinPct: number;
|
||
HRZone2MaxPct: number;
|
||
HRZone3MinPct: number;
|
||
HRZone3MaxPct: number;
|
||
HRZone4MinPct: number;
|
||
HRZone4MaxPct: number;
|
||
HRZone5MinPct: number;
|
||
HRZone5MaxPct: number;
|
||
EasyWarmupMinutes: number;
|
||
EasyCooldownMinutes: number;
|
||
LongWarmupMinutes: number;
|
||
LongCooldownMinutes: number;
|
||
TempoWarmupMinutes: number;
|
||
TempoCooldownMinutes: number;
|
||
Threshold30WarmupMinutes: number;
|
||
Threshold30CooldownMinutes: number;
|
||
Threshold60WarmupMinutes: number;
|
||
Threshold60CooldownMinutes: number;
|
||
MASTestWarmupMinutes: number;
|
||
MASTestCooldownMinutes: number;
|
||
IntervalWarmupMinutes: number;
|
||
IntervalCooldownMinutes: number;
|
||
CreatedAt: string;
|
||
UpdatedAt: string;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update `api/client.ts`**
|
||
|
||
In `frontend/src/api/client.ts`, add `Profile` to the type-only import at the top. It currently reads:
|
||
|
||
```ts
|
||
import type {
|
||
Activity,
|
||
AuthResponse,
|
||
KindAssignment,
|
||
ProgressionMetric,
|
||
ProgressionPoint,
|
||
ReviewQueueItem,
|
||
SyncRun,
|
||
SyncStatus,
|
||
WorkoutKind,
|
||
} from "../types/api";
|
||
```
|
||
|
||
Change to:
|
||
|
||
```ts
|
||
import type {
|
||
Activity,
|
||
AuthResponse,
|
||
KindAssignment,
|
||
Profile,
|
||
ProgressionMetric,
|
||
ProgressionPoint,
|
||
ReviewQueueItem,
|
||
SyncRun,
|
||
SyncStatus,
|
||
WorkoutKind,
|
||
} from "../types/api";
|
||
```
|
||
|
||
Then find the workout-kinds section of the `api` object:
|
||
|
||
```ts
|
||
// Workout kinds
|
||
listWorkoutKinds: (includeInactive = false) =>
|
||
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||
createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) =>
|
||
request<WorkoutKind>("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }),
|
||
updateWorkoutKind: (
|
||
id: number,
|
||
body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean },
|
||
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||
deleteWorkoutKind: (id: number) => request<void>(`/api/workout-kinds/${id}`, { method: "DELETE" }),
|
||
reclassifyWorkoutKind: (id: number) =>
|
||
request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }),
|
||
```
|
||
|
||
Replace it with:
|
||
|
||
```ts
|
||
// Workout kinds -- fixed taxonomy, no create/delete
|
||
listWorkoutKinds: (includeInactive = false) =>
|
||
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||
updateWorkoutKind: (
|
||
id: number,
|
||
body: {
|
||
name: string;
|
||
description?: string;
|
||
color?: string;
|
||
rule: unknown;
|
||
priority?: number;
|
||
is_active?: boolean;
|
||
pace_min_sec_per_km?: number | null;
|
||
pace_max_sec_per_km?: number | null;
|
||
expected_hr_zone?: number | null;
|
||
},
|
||
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||
reclassifyWorkoutKind: (id: number) =>
|
||
request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }),
|
||
|
||
// Profile
|
||
getProfile: () => request<Profile>("/api/profile"),
|
||
updateProfile: (profile: Profile) =>
|
||
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
|
||
```
|
||
|
||
- [ ] **Step 3: Confirm the expected (temporary) typecheck errors**
|
||
|
||
Run: `cd frontend && npx tsc -b`
|
||
Expected: fails, with errors *only* in `src/pages/WorkoutKinds.tsx` (still calling the now-removed `api.createWorkoutKind`/`api.deleteWorkoutKind`). Confirm there are no errors anywhere else before continuing — this file is rewritten later in this same task (Step 6 below), not committed separately.
|
||
|
||
- [ ] **Step 4: Write the Profile page**
|
||
|
||
Create `frontend/src/pages/Profile.tsx`:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { api } from "../api/client";
|
||
import type { Profile as ProfileType } from "../types/api";
|
||
|
||
function NumberField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
step = 1,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
onChange: (v: number) => void;
|
||
step?: number;
|
||
}) {
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
type="number"
|
||
step={step}
|
||
value={value}
|
||
onChange={(e) => onChange(Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function NullableNumberField({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number | null;
|
||
onChange: (v: number | null) => void;
|
||
}) {
|
||
return (
|
||
<label>
|
||
{label}
|
||
<input
|
||
type="number"
|
||
value={value ?? ""}
|
||
onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
export function Profile() {
|
||
const [profile, setProfile] = useState<ProfileType | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [saved, setSaved] = useState(false);
|
||
|
||
useEffect(() => {
|
||
api.getProfile().then(setProfile).catch((e) => setError(String(e)));
|
||
}, []);
|
||
|
||
function set<K extends keyof ProfileType>(key: K, value: ProfileType[K]) {
|
||
setProfile((p) => (p ? { ...p, [key]: value } : p));
|
||
setSaved(false);
|
||
}
|
||
|
||
async function save() {
|
||
if (!profile) return;
|
||
setError(null);
|
||
try {
|
||
const updated = await api.updateProfile(profile);
|
||
setProfile(updated);
|
||
setSaved(true);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
setSaved(false);
|
||
}
|
||
}
|
||
|
||
if (!profile) {
|
||
return (
|
||
<div className="page">
|
||
<h2>Profile</h2>
|
||
{error ? <p className="error">{error}</p> : <p>Loading...</p>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<h2>Profile</h2>
|
||
{error && <p className="error">{error}</p>}
|
||
{saved && <p className="garmin-connection-message">Saved.</p>}
|
||
|
||
<fieldset className="kind-editor">
|
||
<legend>Garmin account</legend>
|
||
<label>
|
||
Email
|
||
<input
|
||
type="text"
|
||
value={profile.GarminEmail}
|
||
onChange={(e) => set("GarminEmail", e.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
Password
|
||
<input
|
||
type="password"
|
||
value={profile.GarminPassword}
|
||
onChange={(e) => set("GarminPassword", e.target.value)}
|
||
/>
|
||
</label>
|
||
</fieldset>
|
||
|
||
<fieldset className="kind-editor">
|
||
<legend>Classification</legend>
|
||
<NumberField
|
||
label="Rolling window (days)"
|
||
value={profile.RollingWindowDays}
|
||
onChange={(v) => set("RollingWindowDays", v)}
|
||
/>
|
||
</fieldset>
|
||
|
||
<fieldset className="kind-editor">
|
||
<legend>Heart rate</legend>
|
||
<NullableNumberField
|
||
label="Max heart rate (bpm)"
|
||
value={profile.MaxHeartRate}
|
||
onChange={(v) => set("MaxHeartRate", v)}
|
||
/>
|
||
<NullableNumberField
|
||
label="Resting heart rate (bpm)"
|
||
value={profile.RestingHeartRate}
|
||
onChange={(v) => set("RestingHeartRate", v)}
|
||
/>
|
||
{([1, 2, 3, 4, 5] as const).map((zone) => (
|
||
<div key={zone} className="controls">
|
||
<NumberField
|
||
label={`Zone ${zone} min %HRR`}
|
||
value={profile[`HRZone${zone}MinPct` as keyof ProfileType] as number}
|
||
onChange={(v) => set(`HRZone${zone}MinPct` as keyof ProfileType, v as never)}
|
||
/>
|
||
<NumberField
|
||
label={`Zone ${zone} max %HRR`}
|
||
value={profile[`HRZone${zone}MaxPct` as keyof ProfileType] as number}
|
||
onChange={(v) => set(`HRZone${zone}MaxPct` as keyof ProfileType, v as never)}
|
||
/>
|
||
</div>
|
||
))}
|
||
</fieldset>
|
||
|
||
<fieldset className="kind-editor">
|
||
<legend>Phase detection (warm-up / cool-down minutes)</legend>
|
||
{(
|
||
[
|
||
["Easy", "EasyWarmupMinutes", "EasyCooldownMinutes"],
|
||
["Long", "LongWarmupMinutes", "LongCooldownMinutes"],
|
||
["Tempo", "TempoWarmupMinutes", "TempoCooldownMinutes"],
|
||
["Threshold 30'", "Threshold30WarmupMinutes", "Threshold30CooldownMinutes"],
|
||
["Threshold 60'", "Threshold60WarmupMinutes", "Threshold60CooldownMinutes"],
|
||
["MAS Test", "MASTestWarmupMinutes", "MASTestCooldownMinutes"],
|
||
] as const
|
||
).map(([label, warmupKey, cooldownKey]) => (
|
||
<div key={label} className="controls">
|
||
<NumberField
|
||
label={`${label} warm-up`}
|
||
value={profile[warmupKey]}
|
||
onChange={(v) => set(warmupKey, v)}
|
||
/>
|
||
<NumberField
|
||
label={`${label} cool-down`}
|
||
value={profile[cooldownKey]}
|
||
onChange={(v) => set(cooldownKey, v)}
|
||
/>
|
||
</div>
|
||
))}
|
||
<p className="empty-state">
|
||
Interval workouts detect warm-up/cool-down from lap data directly and don't use these settings.
|
||
</p>
|
||
</fieldset>
|
||
|
||
<button onClick={save}>Save</button>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add the "Profile" tab to `App.tsx`**
|
||
|
||
In `frontend/src/App.tsx`, the current tab list and imports read:
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import "./App.css";
|
||
import { GarminConnection } from "./components/GarminConnection";
|
||
import { Dashboard } from "./pages/Dashboard";
|
||
import { ReviewQueue } from "./pages/ReviewQueue";
|
||
import { WorkoutKinds } from "./pages/WorkoutKinds";
|
||
|
||
const TABS = [
|
||
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
||
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
||
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
||
] as const;
|
||
```
|
||
|
||
Change to:
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import "./App.css";
|
||
import { GarminConnection } from "./components/GarminConnection";
|
||
import { Dashboard } from "./pages/Dashboard";
|
||
import { Profile } from "./pages/Profile";
|
||
import { ReviewQueue } from "./pages/ReviewQueue";
|
||
import { WorkoutKinds } from "./pages/WorkoutKinds";
|
||
|
||
const TABS = [
|
||
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
||
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
||
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
||
{ key: "profile", label: "Profile", Component: Profile },
|
||
] as const;
|
||
```
|
||
|
||
- [ ] **Step 6: Confirm still only the same expected typecheck errors**
|
||
|
||
Run: `cd frontend && npx tsc -b`
|
||
Expected: still fails only on `WorkoutKinds.tsx` — confirm no new errors from `Profile.tsx` or `App.tsx`.
|
||
|
||
- [ ] **Step 7: Replace the full contents of `WorkoutKinds.tsx`**
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { api } from "../api/client";
|
||
import type { WorkoutKind } from "../types/api";
|
||
|
||
const EXAMPLE_RULE = `{
|
||
"match": "all",
|
||
"conditions": [
|
||
{ "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] },
|
||
{ "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 }
|
||
]
|
||
}`;
|
||
|
||
// Pace is entered/displayed as "m:ss" but sent to the API as whole seconds.
|
||
function parsePace(text: string): number | null {
|
||
const trimmed = text.trim();
|
||
if (trimmed === "") return null;
|
||
const match = /^(\d+):([0-5]?\d)$/.exec(trimmed);
|
||
if (!match) return null;
|
||
return Number(match[1]) * 60 + Number(match[2]);
|
||
}
|
||
|
||
function formatPace(seconds: number | null): string {
|
||
if (seconds == null) return "";
|
||
const m = Math.floor(seconds / 60);
|
||
const s = Math.round(seconds % 60);
|
||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
export function WorkoutKinds() {
|
||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||
const [editingId, setEditingId] = useState<number | null>(null);
|
||
const [name, setName] = useState("");
|
||
const [description, setDescription] = useState("");
|
||
const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
|
||
const [paceMinText, setPaceMinText] = useState("");
|
||
const [paceMaxText, setPaceMaxText] = useState("");
|
||
const [expectedZone, setExpectedZone] = useState<number | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [busyId, setBusyId] = useState<number | null>(null);
|
||
|
||
function reload() {
|
||
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||
}
|
||
|
||
useEffect(reload, []);
|
||
|
||
function startEdit(k: WorkoutKind) {
|
||
setEditingId(k.ID);
|
||
setName(k.Name);
|
||
setDescription(k.Description);
|
||
setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2));
|
||
setPaceMinText(formatPace(k.pace_min_sec_per_km));
|
||
setPaceMaxText(formatPace(k.pace_max_sec_per_km));
|
||
setExpectedZone(k.expected_hr_zone);
|
||
setError(null);
|
||
}
|
||
|
||
async function save() {
|
||
if (editingId === null) return;
|
||
|
||
let rule: unknown;
|
||
try {
|
||
rule = JSON.parse(ruleText);
|
||
} catch {
|
||
setError("Rule is not valid JSON");
|
||
return;
|
||
}
|
||
|
||
const paceMin = parsePace(paceMinText);
|
||
const paceMax = parsePace(paceMaxText);
|
||
if (paceMinText.trim() !== "" && paceMin === null) {
|
||
setError("Min pace must look like m:ss, e.g. 4:30");
|
||
return;
|
||
}
|
||
if (paceMaxText.trim() !== "" && paceMax === null) {
|
||
setError("Max pace must look like m:ss, e.g. 4:30");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await api.updateWorkoutKind(editingId, {
|
||
name,
|
||
description,
|
||
rule,
|
||
pace_min_sec_per_km: paceMin,
|
||
pace_max_sec_per_km: paceMax,
|
||
expected_hr_zone: expectedZone,
|
||
});
|
||
setEditingId(null);
|
||
reload();
|
||
} catch (e) {
|
||
setError(String(e));
|
||
}
|
||
}
|
||
|
||
async function reclassify(id: number) {
|
||
setBusyId(id);
|
||
try {
|
||
const res = await api.reclassifyWorkoutKind(id);
|
||
setError(`Reclassified ${res.reclassified} activities.`);
|
||
} catch (e) {
|
||
setError(String(e));
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="page">
|
||
<h2>Workout Kinds</h2>
|
||
{error && <p className="error">{error}</p>}
|
||
|
||
<table className="kinds-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Name</th>
|
||
<th>Description</th>
|
||
<th>Pace range</th>
|
||
<th>HR zone</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{kinds.map((k) => (
|
||
<tr key={k.ID}>
|
||
<td>{k.Name}</td>
|
||
<td>{k.Description}</td>
|
||
<td>
|
||
{k.pace_min_sec_per_km != null && k.pace_max_sec_per_km != null
|
||
? `${formatPace(k.pace_min_sec_per_km)}–${formatPace(k.pace_max_sec_per_km)}/km`
|
||
: "—"}
|
||
</td>
|
||
<td>{k.expected_hr_zone ?? "—"}</td>
|
||
<td className="kinds-table-actions">
|
||
<button onClick={() => startEdit(k)}>Edit</button>
|
||
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
|
||
Reclassify
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{editingId !== null && (
|
||
<div className="kind-editor">
|
||
<label>
|
||
Name
|
||
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||
</label>
|
||
<label>
|
||
Description
|
||
<input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||
</label>
|
||
<div className="controls">
|
||
<label>
|
||
Min pace (m:ss/km)
|
||
<input value={paceMinText} onChange={(e) => setPaceMinText(e.target.value)} placeholder="4:30" />
|
||
</label>
|
||
<label>
|
||
Max pace (m:ss/km)
|
||
<input value={paceMaxText} onChange={(e) => setPaceMaxText(e.target.value)} placeholder="4:45" />
|
||
</label>
|
||
<label>
|
||
Expected HR zone
|
||
<select
|
||
value={expectedZone ?? ""}
|
||
onChange={(e) => setExpectedZone(e.target.value === "" ? null : Number(e.target.value))}
|
||
>
|
||
<option value="">—</option>
|
||
{[1, 2, 3, 4, 5].map((z) => (
|
||
<option key={z} value={z}>
|
||
Zone {z}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
Rule (JSON condition tree)
|
||
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />
|
||
</label>
|
||
<div className="kind-editor-actions">
|
||
<button onClick={save}>Save</button>
|
||
<button onClick={() => setEditingId(null)}>Cancel</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Typecheck the whole frontend**
|
||
|
||
Run: `cd frontend && npx tsc -b`
|
||
Expected: no errors — this confirms Steps 1-7 together leave the frontend fully compiling.
|
||
|
||
- [ ] **Step 9: Commit everything from this task together**
|
||
|
||
```bash
|
||
cd frontend && git add src/types/api.ts src/api/client.ts src/pages/Profile.tsx src/App.tsx src/pages/WorkoutKinds.tsx
|
||
git commit -m "feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Fix `cmd/seedsample` for the fixed taxonomy, then full manual verification
|
||
|
||
`cmd/seedsample` currently calls `db.CreateWorkoutKind` with names `"Easy"`, `"Tempo"`, `"Interval"`. Migration 0004 (Task 2) already seeds a row named exactly `"Tempo"` and `"Interval"` (and `"Easy Run"`, not `"Easy"`) -- since `workout_kinds.name` is `UNIQUE`, `seedsample`'s `CreateWorkoutKind("Tempo", ...)`/`CreateWorkoutKind("Interval", ...)` calls would now fail with a unique-constraint error. It also still passes the now-removed `appsync.Config.MaxHR` field (Task 7 deleted it), which would fail to compile. Both must be fixed before this plan's changes are usable end-to-end.
|
||
|
||
**Files:**
|
||
- Modify: `backend/cmd/seedsample/main.go` (lines 32-64)
|
||
|
||
**Interfaces:**
|
||
- Consumes: `db.ListWorkoutKinds` (existing), `db.UpdateWorkoutKind` (existing), `db.GetProfile`/`db.UpdateProfile` (Task 1).
|
||
|
||
- [ ] **Step 1: Replace the workout-kind seeding block**
|
||
|
||
In `backend/cmd/seedsample/main.go`, replace lines 32-64 (from the comment `// Easy and Tempo's pace/HR ranges...` through the `must(err)` following the `Interval` block) with:
|
||
|
||
```go
|
||
// Set a max heart rate on the profile so avg_hr_pct_max is computed
|
||
// during classification below (it's nil/unset by default).
|
||
profile, err := db.GetProfile(ctx)
|
||
must(err)
|
||
maxHR := 190.0
|
||
profile.MaxHeartRate = &maxHR
|
||
must(db.UpdateProfile(ctx, profile))
|
||
|
||
// Easy Run and Tempo's pace/HR ranges deliberately overlap a little
|
||
// (330-340 sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap
|
||
// zone demonstrates the ambiguous-multi-match review path, not just a
|
||
// gap between disjoint ranges. The taxonomy migration already seeded
|
||
// these rows (by fixed name) -- update their rules in place rather than
|
||
// creating new ones, since names are unique.
|
||
easyID := mustFindKindID(ctx, db, "Easy Run")
|
||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
||
ID: easyID, Name: "Easy Run", Description: "Easy conversational runs", Color: "#22c55e",
|
||
RuleJSON: `{"match":"all","conditions":[
|
||
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
||
{"metric":"avg_hr_pct_max","op":"<=","value":0.85}
|
||
]}`,
|
||
IsActive: true,
|
||
}))
|
||
|
||
tempoID := mustFindKindID(ctx, db, "Tempo")
|
||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
||
ID: tempoID, Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
||
RuleJSON: `{"match":"all","conditions":[
|
||
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
||
{"metric":"avg_hr_pct_max","op":">=","value":0.70}
|
||
]}`,
|
||
IsActive: true,
|
||
}))
|
||
|
||
intervalID := mustFindKindID(ctx, db, "Interval")
|
||
must(db.UpdateWorkoutKind(ctx, store.WorkoutKind{
|
||
ID: intervalID, Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444",
|
||
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
||
IsActive: true,
|
||
}))
|
||
```
|
||
|
||
- [ ] **Step 2: Add the `mustFindKindID` helper**
|
||
|
||
In the same file, add this function next to `must` (at the end of the file):
|
||
|
||
```go
|
||
func mustFindKindID(ctx context.Context, db *store.DB, name string) int64 {
|
||
kinds, err := db.ListWorkoutKinds(ctx, false)
|
||
must(err)
|
||
for _, k := range kinds {
|
||
if k.Name == name {
|
||
return k.ID
|
||
}
|
||
}
|
||
log.Fatalf("seedsample: no workout kind named %q found (did migration 0004 run?)", name)
|
||
return 0
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Drop the removed `MaxHR` config field**
|
||
|
||
In the same file, find:
|
||
|
||
```go
|
||
m := &mock.Client{}
|
||
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil)
|
||
```
|
||
|
||
Change to:
|
||
|
||
```go
|
||
m := &mock.Client{}
|
||
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6}, nil)
|
||
```
|
||
|
||
- [ ] **Step 4: Build and run seedsample**
|
||
|
||
```bash
|
||
cd backend
|
||
gofmt -w cmd/seedsample/main.go
|
||
go build ./... && go vet ./...
|
||
rm -f /tmp/smartrun_verify.db
|
||
go run ./cmd/seedsample -db /tmp/smartrun_verify.db
|
||
```
|
||
|
||
Expected: builds clean, prints `Seeded 10 activities (kinds: Easy=<id>, Tempo=<id>) into /tmp/smartrun_verify.db` with no errors (no unique-constraint failure).
|
||
|
||
- [ ] **Step 5: Run the full backend test suite one more time**
|
||
|
||
Run: `cd backend && gofmt -l . && go build ./... && go vet ./... && go test ./... -race`
|
||
Expected: `gofmt -l .` prints nothing; everything else PASSes.
|
||
|
||
- [ ] **Step 6: Start the backend against the seeded DB**
|
||
|
||
```bash
|
||
cd backend
|
||
go build -o /tmp/smartrund ./cmd/smartrund
|
||
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 \
|
||
SMARTRUN_DB_PATH=/tmp/smartrun_verify.db \
|
||
SMARTRUN_ADDR=:8080 \
|
||
/tmp/smartrund &
|
||
sleep 1
|
||
curl -s localhost:8080/api/workout-kinds/ | python3 -m json.tool | head -30
|
||
curl -s localhost:8080/api/profile | python3 -m json.tool
|
||
```
|
||
|
||
Expected: 7 workout kinds returned (Easy Run, Long Run, Threshold 30', Threshold 60', Tempo, Interval, MAS Test), the "Easy Run" and "Tempo" entries showing their updated `rule_json`; `/api/profile` shows `rolling_window_days: 90`, `max_heart_rate: 190`, and the default HR zone/phase-minute values from the migration.
|
||
|
||
- [ ] **Step 7: Start the frontend and click through in a real browser**
|
||
|
||
```bash
|
||
cd frontend
|
||
VITE_API_BASE_URL=http://localhost:8080 npm run dev
|
||
```
|
||
|
||
Open the printed URL and check:
|
||
- **Profile** tab: shows the saved `max_heart_rate: 190`, rolling window 90, default HR zones and phase minutes; edit a value, save, reload the page, confirm it persisted.
|
||
- **Workout Kinds** tab: shows exactly 7 rows, no "+ New workout kind" button anywhere; edit "Long Run"'s pace range (e.g. `9:00`–`9:30`) and expected HR zone, save, confirm the table's "Pace range"/"HR zone" columns update.
|
||
- **Review Queue** / **Progression** tabs: still work as before (unaffected by this plan).
|
||
- Open the browser DevTools console: zero errors across all four tabs.
|
||
|
||
- [ ] **Step 8: Tear down**
|
||
|
||
```bash
|
||
kill %1 2>/dev/null
|
||
rm -f /tmp/smartrund /tmp/smartrun_verify.db
|
||
```
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
cd backend && git add cmd/seedsample/main.go
|
||
git commit -m "fix: update seedsample for the fixed workout taxonomy and profile-based max HR"
|
||
```
|
||
|
||
---
|
||
|
||
## Plan self-review
|
||
|
||
**Spec coverage:** Section 1 (profile, credential updates) → Tasks 1, 4, 5, 8. Section 2 (taxonomy + pace ranges) → Tasks 2, 3, 6, 9. Section 5's profile-screen/pace-editing UI → Task 9. The credential-update addition made during spec review → Task 4/5. Sections 3 (relative classification) and 4 (phase segmentation) are explicitly out of scope for this plan (Plans 2 and 3).
|
||
|
||
**Placeholder scan:** No TBD/TODO markers; every step shows complete code.
|
||
|
||
**Type consistency:** `store.Profile`, `store.WorkoutTypePace`, `workoutKindResponse`, and the frontend `Profile`/`WorkoutKind` TS types use matching field names throughout (verified field-by-field against the Go structs' zero-json-tag PascalCase serialization and the snake_case pace/zone fields). `garmin.Client.UpdateCredentials(email, password string)` signature matches across the interface, `mcpClient`, `mock.Client`, and its one caller in `internal/api/profile.go`.
|
||
|
||
## Execution
|
||
|
||
Plan complete and saved to `docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md`. Two execution options:
|
||
|
||
1. **Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||
2. **Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||
|
||
Which approach?
|