2414 lines
81 KiB
Markdown
2414 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
|
|||
|
|
|
|||
|
|
**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)
|
|||
|
|
|
|||
|
|
**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: Typecheck**
|
|||
|
|
|
|||
|
|
Run: `cd frontend && npx tsc -b`
|
|||
|
|
Expected: fails at this point with errors in `src/pages/WorkoutKinds.tsx` (still calling the now-removed `api.createWorkoutKind`/`api.deleteWorkoutKind`) — that's expected here; Task 11 fixes that page. Confirm the *only* errors are in `WorkoutKinds.tsx` before moving on.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && git add src/types/api.ts src/api/client.ts
|
|||
|
|
git commit -m "feat: add Profile type/endpoints, drop workout-kind create/delete from the API client"
|
|||
|
|
```
|
|||
|
|
(This commit leaves the frontend in a temporarily non-compiling state until Task 11 — that's expected for an in-progress feature branch; if your workflow requires main to always build, squash Tasks 9-11 into one commit instead by holding off `git commit` until Task 11's Step 5.)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 10: Profile settings page
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/src/pages/Profile.tsx`
|
|||
|
|
- Modify: `frontend/src/App.tsx` (add a "Profile" tab)
|
|||
|
|
|
|||
|
|
**Interfaces:**
|
|||
|
|
- Consumes: `api.getProfile`, `api.updateProfile` (Task 9).
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: Write the 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 2: 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 3: Typecheck**
|
|||
|
|
|
|||
|
|
Run: `cd frontend && npx tsc -b`
|
|||
|
|
Expected: still fails only on `WorkoutKinds.tsx` (Task 11) — confirm no new errors from `Profile.tsx` or `App.tsx`.
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && git add src/pages/Profile.tsx src/App.tsx
|
|||
|
|
git commit -m "feat: add profile settings page"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 11: `WorkoutKinds` page — drop create/delete, add pace range + expected HR zone
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/src/pages/WorkoutKinds.tsx` (full rewrite — shown complete below)
|
|||
|
|
|
|||
|
|
**Interfaces:**
|
|||
|
|
- Consumes: `api.listWorkoutKinds`, `api.updateWorkoutKind` (Task 9, extended), `api.reclassifyWorkoutKind` (existing, unchanged).
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 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 2: Typecheck**
|
|||
|
|
|
|||
|
|
Run: `cd frontend && npx tsc -b`
|
|||
|
|
Expected: no errors.
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: Commit**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd frontend && git add src/pages/WorkoutKinds.tsx
|
|||
|
|
git commit -m "feat: fixed workout-kind taxonomy UI with pace range and HR zone editing"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 12: 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, 11. Section 5's profile-screen/pace-editing UI → Tasks 10, 11. 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?
|