feat: add single-profile settings table and store layer
Add Profile table to store Garmin credentials and engine configuration parameters. Implements GetProfile() and UpdateProfile() store methods with comprehensive test coverage. The profile singleton row is automatically initialized on migration and persists all configuration state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
37
backend/internal/store/migrations/0003_profile.sql
Normal file
37
backend/internal/store/migrations/0003_profile.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- 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,
|
||||||
|
interval_warmup_minutes REAL NOT NULL DEFAULT 0,
|
||||||
|
interval_cooldown_minutes REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT INTO profile (id) VALUES (1);
|
||||||
110
backend/internal/store/profile.go
Normal file
110
backend/internal/store/profile.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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,
|
||||||
|
interval_warmup_minutes, interval_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.IntervalWarmupMinutes, &p.IntervalCooldownMinutes,
|
||||||
|
&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=?,
|
||||||
|
interval_warmup_minutes=?, interval_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,
|
||||||
|
p.IntervalWarmupMinutes, p.IntervalCooldownMinutes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update profile: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
backend/internal/store/profile_test.go
Normal file
48
backend/internal/store/profile_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user