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