feat: add per-workout-type pace range and expected HR zone

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:53:55 +02:00
parent 6e8bde7854
commit 684b026ff4
3 changed files with 127 additions and 0 deletions

View File

@@ -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;

View File

@@ -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()
}

View File

@@ -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)
}
}