diff --git a/backend/internal/store/migrations/0005_workout_type_paces.sql b/backend/internal/store/migrations/0005_workout_type_paces.sql new file mode 100644 index 0000000..9a2ead7 --- /dev/null +++ b/backend/internal/store/migrations/0005_workout_type_paces.sql @@ -0,0 +1,13 @@ +-- 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; diff --git a/backend/internal/store/workoutpaces.go b/backend/internal/store/workoutpaces.go new file mode 100644 index 0000000..b8e6bed --- /dev/null +++ b/backend/internal/store/workoutpaces.go @@ -0,0 +1,69 @@ +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() +} diff --git a/backend/internal/store/workoutpaces_test.go b/backend/internal/store/workoutpaces_test.go new file mode 100644 index 0000000..9bbd9b7 --- /dev/null +++ b/backend/internal/store/workoutpaces_test.go @@ -0,0 +1,45 @@ +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) + } +}