From 4748917a25a4b32263b6371ef61869741fa398bf Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Fri, 17 Jul 2026 18:41:37 +0200 Subject: [PATCH] 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 --- .../store/migrations/0003_profile.sql | 37 ++++++ backend/internal/store/profile.go | 110 ++++++++++++++++++ backend/internal/store/profile_test.go | 48 ++++++++ 3 files changed, 195 insertions(+) create mode 100644 backend/internal/store/migrations/0003_profile.sql create mode 100644 backend/internal/store/profile.go create mode 100644 backend/internal/store/profile_test.go diff --git a/backend/internal/store/migrations/0003_profile.sql b/backend/internal/store/migrations/0003_profile.sql new file mode 100644 index 0000000..b30798a --- /dev/null +++ b/backend/internal/store/migrations/0003_profile.sql @@ -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); diff --git a/backend/internal/store/profile.go b/backend/internal/store/profile.go new file mode 100644 index 0000000..5265588 --- /dev/null +++ b/backend/internal/store/profile.go @@ -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 +} diff --git a/backend/internal/store/profile_test.go b/backend/internal/store/profile_test.go new file mode 100644 index 0000000..cd2f171 --- /dev/null +++ b/backend/internal/store/profile_test.go @@ -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) + } +}