Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes

- Remove duplicated Garmin fields from storage; decode display-only fields
  (activity name/type, lap duration/HR, structured workout raw JSON) from
  RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
  effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
  Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
  Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
  use tight non-zero-based domains, m:ss/km pace formatting, and rounded
  ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
  alongside activity/lap/detail JSON; enlarge the modal and shrink array
  indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
  combined sync run per manual "Sync now" and count genuinely new
  activities instead of re-listing whatever Garmin returned for the queried
  window
- Let a Review Queue activity be manually cleared back to Unclassified, and
  make "Reset all" available even while disconnected from Garmin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 06:33:47 +02:00
parent 9818d35910
commit 518bccf5ab
56 changed files with 2334 additions and 890 deletions

View File

@@ -9,40 +9,44 @@ import (
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
// from the Garmin/MCP response happens in internal/sync, not here, so this
// package stays independent of internal/garmin.
//
// Deliberately NOT modeled here (though Garmin's response includes them):
// ActivityName/ActivityType, plus every field removed in the 2026-07
// dedup pass (BeginTimestampMs, MaxSpeedMps, ElevationLossM, Calories,
// LapCount, TrainingEffectLabel, HrTimeInZone1-5). None of them are read by
// any SQL query, the classify rule engine, or any other Go code -- they'd be
// pure duplicates of RawJSON with no purpose beyond being slightly more
// convenient to read than parsing JSON. ActivityName/ActivityType do have a
// real frontend display need, so the API layer (internal/api) decodes them
// from RawJSON at response time instead of storing a redundant copy -- see
// decodeActivityDisplayFields.
type Activity struct {
ID int64
GarminActivityID int64
ActivityName string
ActivityType string
EventTypeKey string
WorkoutID *int64
StartTimeUTC string
BeginTimestampMs int64
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
Calories *float64
LapCount int
AerobicTrainingEffect *float64
AnaerobicTrainingEffect *float64
TrainingEffectLabel string
VO2MaxValue *float64
HrTimeInZone1 *float64
HrTimeInZone2 *float64
HrTimeInZone3 *float64
HrTimeInZone4 *float64
HrTimeInZone5 *float64
RawJSON string
DetailsFetchedAt *string
DetailsRawJSON *string
SplitsFetchedAt *string
CreatedAt string
UpdatedAt string
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
CreatedAt string
UpdatedAt string
}
// UpsertActivity inserts a new activity or updates the existing row for the
@@ -51,49 +55,32 @@ type Activity struct {
func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (
garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, updated_at
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, datetime('now'))
ON CONFLICT(garmin_activity_id) DO UPDATE SET
activity_name=excluded.activity_name,
activity_type=excluded.activity_type,
event_type_key=excluded.event_type_key,
workout_id=excluded.workout_id,
start_time_utc=excluded.start_time_utc,
begin_timestamp_ms=excluded.begin_timestamp_ms,
duration_seconds=excluded.duration_seconds,
distance_meters=excluded.distance_meters,
avg_hr=excluded.avg_hr,
max_hr=excluded.max_hr,
avg_speed_mps=excluded.avg_speed_mps,
max_speed_mps=excluded.max_speed_mps,
elevation_gain_m=excluded.elevation_gain_m,
elevation_loss_m=excluded.elevation_loss_m,
calories=excluded.calories,
lap_count=excluded.lap_count,
aerobic_training_effect=excluded.aerobic_training_effect,
anaerobic_training_effect=excluded.anaerobic_training_effect,
training_effect_label=excluded.training_effect_label,
vo2max_value=excluded.vo2max_value,
hr_time_in_zone_1=excluded.hr_time_in_zone_1,
hr_time_in_zone_2=excluded.hr_time_in_zone_2,
hr_time_in_zone_3=excluded.hr_time_in_zone_3,
hr_time_in_zone_4=excluded.hr_time_in_zone_4,
hr_time_in_zone_5=excluded.hr_time_in_zone_5,
raw_json=excluded.raw_json,
updated_at=datetime('now')
`,
a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
a.BeginTimestampMs, a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.MaxSpeedMps, a.ElevationGainM, a.ElevationLossM,
a.Calories, a.LapCount, a.AerobicTrainingEffect, a.AnaerobicTrainingEffect,
a.TrainingEffectLabel, a.VO2MaxValue,
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
a.GarminActivityID, a.EventTypeKey, a.WorkoutID, a.StartTimeUTC,
a.DurationSeconds, a.DistanceMeters, a.AvgHR, a.MaxHR,
a.AvgSpeedMps, a.ElevationGainM,
a.AerobicTrainingEffect, a.AnaerobicTrainingEffect, a.VO2MaxValue,
a.RawJSON,
)
if err != nil {
@@ -110,26 +97,22 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) {
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.BeginTimestampMs, &a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.MaxSpeedMps, &a.ElevationGainM, &a.ElevationLossM,
&a.Calories, &a.LapCount, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect,
&a.TrainingEffectLabel, &a.VO2MaxValue,
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, activity_name, activity_type, event_type_key, workout_id, start_time_utc,
begin_timestamp_ms, duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
calories, lap_count, aerobic_training_effect, anaerobic_training_effect,
training_effect_label, vo2max_value,
hr_time_in_zone_1, hr_time_in_zone_2, hr_time_in_zone_3, hr_time_in_zone_4, hr_time_in_zone_5,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at,
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
`
@@ -189,6 +172,24 @@ func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity,
return activities, rows.Err()
}
// ActivityExists reports whether an activity with this garmin_activity_id is
// already stored, checked before each upsert during a sync pass so the
// reported "activities fetched" count reflects genuinely new activities, not
// every activity Garmin's API happens to return for the queried date range
// (which, thanks to the incremental overlap window and backfill's
// already-covered history, is almost always a re-listing of known ones).
func (db *DB) ActivityExists(ctx context.Context, garminActivityID int64) (bool, error) {
var id int64
err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, garminActivityID).Scan(&id)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("check activity %d exists: %w", garminActivityID, err)
}
return true, nil
}
// LatestActivityStartTime returns the start_time_utc of the most recently
// started activity we have, used to compute the incremental sync window.
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
@@ -215,6 +216,18 @@ func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON
return nil
}
// SetActivityWorkout stores the raw get_workout_by_id() response used to
// compute this activity's laps' target pace/HR bands.
func (db *DB) SetActivityWorkout(ctx context.Context, activityID int64, rawJSON string) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_raw_json = ?, updated_at = datetime('now')
WHERE id = ?`, rawJSON, activityID)
if err != nil {
return fmt.Errorf("set activity %d workout: %w", activityID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {

View File

@@ -7,19 +7,18 @@ import (
// Lap is one lap/split of an activity, from get_activity_splits, plus
// derived HR drift/recovery metrics computed from activity_samples.
//
// Deliberately NOT modeled here: StartTimeUTC, DistanceMeters, MaxHR,
// MaxSpeedMps, ElevationGainM, ElevationLossM (removed in the 2026-07
// dedup pass -- zero consumers anywhere, pure duplicates of RawJSON), plus
// DurationSeconds and AvgHR, which DO have a real frontend chart need but no
// backend one, so the API layer decodes them from RawJSON at response time
// instead of storing a redundant copy -- see decodeLapDisplayFields.
type Lap struct {
ID int64
ActivityID int64
LapIndex int
StartTimeUTC string
DurationSeconds float64
DistanceMeters float64
AvgHR *float64
MaxHR *float64
AvgSpeedMps *float64
MaxSpeedMps *float64
ElevationGainM *float64
ElevationLossM *float64
IntensityType string
HRDriftBpmPerMin *float64
HRRecoveryBpmPerMin *float64
@@ -52,13 +51,11 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
for _, l := range laps {
_, err := tx.ExecContext(ctx, `
INSERT INTO laps (
activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
activityID, l.LapIndex, l.StartTimeUTC, l.DurationSeconds, l.DistanceMeters,
l.AvgHR, l.MaxHR, l.AvgSpeedMps, l.MaxSpeedMps, l.ElevationGainM, l.ElevationLossM,
) VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
activityID, l.LapIndex, l.AvgSpeedMps,
l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin,
l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON,
)
@@ -72,8 +69,7 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err
// LapsForActivity returns all laps for an activity, ordered by lap_index.
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, activity_id, lap_index, start_time_utc, duration_seconds, distance_meters,
avg_hr, max_hr, avg_speed_mps, max_speed_mps, elevation_gain_m, elevation_loss_m,
SELECT id, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
@@ -86,8 +82,7 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err
for rows.Next() {
var l Lap
if err := rows.Scan(
&l.ID, &l.ActivityID, &l.LapIndex, &l.StartTimeUTC, &l.DurationSeconds, &l.DistanceMeters,
&l.AvgHR, &l.MaxHR, &l.AvgSpeedMps, &l.MaxSpeedMps, &l.ElevationGainM, &l.ElevationLossM,
&l.ID, &l.ActivityID, &l.LapIndex, &l.AvgSpeedMps,
&l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin,
&l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON,
); err != nil {

View File

@@ -0,0 +1,3 @@
-- Names the profile so a future multi-profile setup can show which one is
-- active. Only one profile row exists today (id=1), so this is just a label.
ALTER TABLE profile ADD COLUMN name TEXT NOT NULL DEFAULT 'Default';

View File

@@ -0,0 +1,6 @@
-- Replaces the named-zone-only expected_hr_zone with a custom %HRR range
-- per training type (e.g. "easy runs at 70-80% heart rate reserve"),
-- matching how pace range is already modeled. expected_hr_zone is left in
-- place but unused going forward -- nothing reads or writes it anymore.
ALTER TABLE workout_type_paces ADD COLUMN hr_min_pct_hrr REAL;
ALTER TABLE workout_type_paces ADD COLUMN hr_max_pct_hrr REAL;

View File

@@ -0,0 +1,35 @@
-- Phase 1 of the raw-JSON duplication cleanup: drops every Activity/Lap
-- column that was a pure untransformed copy of a value already present in
-- that row's own raw_json, with zero SQL/classify/functional consumer
-- anywhere in the app (see the 2026-07 field-by-field duplication audit).
-- Unlike this project's usual additive-only migrations, dropping these
-- columns outright is the whole point of this one -- leaving them inert
-- would keep the exact duplication being removed. activity_name/
-- activity_type (Activity) and duration_seconds/avg_hr (laps) also go here
-- even though the frontend still displays them: they're now decoded from
-- raw_json at API-response time instead of stored separately (see
-- internal/api's decodeActivityDisplayFields/decodeLapDisplayFields).
DROP INDEX idx_activities_activity_type;
ALTER TABLE activities DROP COLUMN activity_name;
ALTER TABLE activities DROP COLUMN activity_type;
ALTER TABLE activities DROP COLUMN begin_timestamp_ms;
ALTER TABLE activities DROP COLUMN max_speed_mps;
ALTER TABLE activities DROP COLUMN elevation_loss_m;
ALTER TABLE activities DROP COLUMN calories;
ALTER TABLE activities DROP COLUMN lap_count;
ALTER TABLE activities DROP COLUMN training_effect_label;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_1;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_2;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_3;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_4;
ALTER TABLE activities DROP COLUMN hr_time_in_zone_5;
ALTER TABLE laps DROP COLUMN start_time_utc;
ALTER TABLE laps DROP COLUMN duration_seconds;
ALTER TABLE laps DROP COLUMN distance_meters;
ALTER TABLE laps DROP COLUMN avg_hr;
ALTER TABLE laps DROP COLUMN max_hr;
ALTER TABLE laps DROP COLUMN max_speed_mps;
ALTER TABLE laps DROP COLUMN elevation_gain_m;
ALTER TABLE laps DROP COLUMN elevation_loss_m;

View File

@@ -0,0 +1,10 @@
-- User-configurable chart colors: 2 "main line" colors (one per metric) and
-- 4 "effort kind" colors (one per workout phase), used together to derive
-- the pace/HR chart's line, under-the-line fill, and phase-background fill
-- colors (see frontend's ExpectedVsActualChart).
ALTER TABLE profile ADD COLUMN pace_color TEXT NOT NULL DEFAULT '#3b82f6';
ALTER TABLE profile ADD COLUMN heart_rate_color TEXT NOT NULL DEFAULT '#ef4444';
ALTER TABLE profile ADD COLUMN warmup_color TEXT NOT NULL DEFAULT '#c2410c';
ALTER TABLE profile ADD COLUMN effort_color TEXT NOT NULL DEFAULT '#7c3aed';
ALTER TABLE profile ADD COLUMN recovery_color TEXT NOT NULL DEFAULT '#15803d';
ALTER TABLE profile ADD COLUMN cooldown_color TEXT NOT NULL DEFAULT '#fb923c';

View File

@@ -0,0 +1,18 @@
-- Renames the default training-type taxonomy to a fixed display convention
-- (drop the redundant "Run" suffix, numeral before "Threshold", "Intervals"
-- not "Interval") and assigns explicit priorities so kinds always list in
-- this exact order wherever they're shown (Activities filters, Progression's
-- kind picker, Profile's Training types card) -- existing ORDER BY priority
-- DESC, name already does the sorting, no query changes needed:
-- Easy, Long, 60' Threshold, 30' Threshold, Tempo, Intervals, MAS Test, Race.
--
-- Each UPDATE matches on the original seeded name, so a kind the user has
-- already renamed themselves (no longer matching) is left untouched.
UPDATE workout_kinds SET name = 'Easy', priority = 80 WHERE name = 'Easy Run';
UPDATE workout_kinds SET name = 'Long', priority = 70 WHERE name = 'Long Run';
UPDATE workout_kinds SET name = '60'' Threshold', priority = 60 WHERE name = 'Threshold 60''';
UPDATE workout_kinds SET name = '30'' Threshold', priority = 50 WHERE name = 'Threshold 30''';
UPDATE workout_kinds SET priority = 40 WHERE name = 'Tempo';
UPDATE workout_kinds SET name = 'Intervals', priority = 30 WHERE name = 'Interval';
UPDATE workout_kinds SET priority = 20 WHERE name = 'MAS Test';
UPDATE workout_kinds SET priority = 10 WHERE name = 'Race';

View File

@@ -0,0 +1,5 @@
-- How strongly the main line color (blue/red) tints the effort-kind fill
-- under the line, as a percentage (0-100) mixed in -- see frontend's
-- ExpectedVsActualChart mixColor(). The phase background above the line is
-- never tinted by the main line color regardless of this setting.
ALTER TABLE profile ADD COLUMN main_line_tint_pct REAL NOT NULL DEFAULT 20;

View File

@@ -0,0 +1,5 @@
-- How strongly (0-100) the effort-kind color is darkened for the phase
-- background above the line -- see frontend's ExpectedVsActualChart
-- darken(). Never mixed with the main line color, unlike the fill below the
-- line (see main_line_tint_pct).
ALTER TABLE profile ADD COLUMN background_darken_pct REAL NOT NULL DEFAULT 35;

View File

@@ -0,0 +1,5 @@
-- How strongly (0-100) a chart's main line color (and everything tinted
-- from it) is brightened when that chart actually has a structured-workout
-- target range to show -- a color-based cue that a target is present,
-- replacing a text label -- see frontend's ExpectedVsActualChart brighten().
ALTER TABLE profile ADD COLUMN target_brighten_pct REAL NOT NULL DEFAULT 20;

View File

@@ -0,0 +1,5 @@
-- Genuine raw JSON of the activity's structured Garmin workout (get_workout_by_id),
-- the source used to compute each lap's TargetPaceLowMps/HighMps and
-- TargetHRLowBpm/HighBpm (see internal/sync/mapping.go's alignWorkoutTargets).
-- Null for activities with no WorkoutID, or synced before this column existed.
ALTER TABLE activities ADD COLUMN workout_raw_json TEXT;

View File

@@ -0,0 +1,19 @@
-- Widens sync_runs.kind's CHECK constraint to also allow 'full' (a manual
-- "Sync now" pass recorded as one combined run instead of separate
-- backfill/incremental rows -- see internal/sync.Service.FullSync). SQLite
-- has no ALTER TABLE for CHECK constraints, so the table is rebuilt.
CREATE TABLE sync_runs_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL,
finished_at TEXT,
activities_fetched INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
error_message TEXT
);
INSERT INTO sync_runs_new (id, kind, started_at, finished_at, activities_fetched, status, error_message)
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message FROM sync_runs;
DROP TABLE sync_runs;
ALTER TABLE sync_runs_new RENAME TO sync_runs;

View File

@@ -8,6 +8,9 @@ import (
// Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct {
// Name labels this profile so a future multi-profile setup can show
// which one is active. Only one profile row exists today (id=1).
Name string
GarminEmail string
GarminPassword string
RollingWindowDays int
@@ -41,16 +44,40 @@ type Profile struct {
// right as recording starts, before the run itself begins).
MinRepresentativePaceSecPerKm, MinRepresentativeTimeSeconds float64
// Chart colors: PaceColor/HeartRateColor are the "main line" color for
// each metric's chart; Warmup/Effort/Recovery/CooldownColor are the
// "effort kind" colors. The frontend derives the under-the-line fill
// (effort color tinted by the main line color) and the phase background
// fill (effort color, darkened) from these six -- see
// frontend's ExpectedVsActualChart.
PaceColor, HeartRateColor string
WarmupColor, EffortColor, RecoveryColor, CooldownColor string
// MainLineTintPct (0-100) is how strongly the main line color mixes into
// the effort-kind fill under the line -- see frontend's mixColor(). Never
// affects the phase background above the line.
MainLineTintPct float64
// BackgroundDarkenPct (0-100) is how strongly the effort-kind color is
// darkened for the phase background above the line -- see frontend's
// darken(). Never mixed with the main line color.
BackgroundDarkenPct float64
// TargetBrightenPct (0-100) is how strongly a chart's main line color
// (and everything tinted from it) is brightened when that chart has a
// structured-workout target range to show -- a color cue for "this has a
// target" instead of a text label -- see frontend's brighten().
TargetBrightenPct float64
CreatedAt, UpdatedAt string
}
const profileColumns = `
garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_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,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
`
@@ -58,12 +85,14 @@ const profileColumns = `
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.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &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.WarmupMinutes, &p.CooldownMinutes,
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
&p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
&p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
@@ -78,20 +107,24 @@ func (db *DB) GetProfile(ctx context.Context) (Profile, error) {
func (db *DB) UpdateProfile(ctx context.Context, p Profile) error {
_, err := db.ExecContext(ctx, `
UPDATE profile SET
garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_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=?,
warmup_minutes=?, cooldown_minutes=?,
min_representative_pace_sec_per_km=?, min_representative_time_seconds=?,
pace_color=?, heart_rate_color=?, warmup_color=?, effort_color=?, recovery_color=?, cooldown_color=?,
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now')
WHERE id = 1`,
p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, 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.WarmupMinutes, p.CooldownMinutes,
p.MinRepresentativePaceSecPerKm, p.MinRepresentativeTimeSeconds,
p.PaceColor, p.HeartRateColor, p.WarmupColor, p.EffortColor, p.RecoveryColor, p.CooldownColor,
p.MainLineTintPct, p.BackgroundDarkenPct, p.TargetBrightenPct,
)
if err != nil {
return fmt.Errorf("update profile: %w", err)

View File

@@ -28,8 +28,32 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
}
if p.Name != "Default" {
t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
}
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
}
if p.WarmupColor != "#c2410c" || p.EffortColor != "#7c3aed" || p.RecoveryColor != "#15803d" || p.CooldownColor != "#fb923c" {
t.Errorf("effort color defaults = %+v", p)
}
if p.MainLineTintPct != 20 {
t.Errorf("MainLineTintPct = %v, want 20 (migration default)", p.MainLineTintPct)
}
if p.BackgroundDarkenPct != 35 {
t.Errorf("BackgroundDarkenPct = %v, want 35 (migration default)", p.BackgroundDarkenPct)
}
if p.TargetBrightenPct != 20 {
t.Errorf("TargetBrightenPct = %v, want 20 (migration default)", p.TargetBrightenPct)
}
maxHR, restingHR := 190.0, 50.0
p.Name = "Kriss"
p.PaceColor = "#111111"
p.EffortColor = "#222222"
p.MainLineTintPct = 45
p.BackgroundDarkenPct = 60
p.TargetBrightenPct = 50
p.GarminEmail = "runner@example.com"
p.GarminPassword = "hunter2"
p.RollingWindowDays = 120
@@ -51,6 +75,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got)
}
if got.Name != "Kriss" {
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
}
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
}
@@ -63,4 +90,16 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.MinRepresentativePaceSecPerKm != 600 || got.MinRepresentativeTimeSeconds != 5 {
t.Errorf("pace artifact filter after update = %+v, want pace=600, time=5", got)
}
if got.PaceColor != "#111111" || got.EffortColor != "#222222" {
t.Errorf("chart colors after update = %+v, want pace=#111111, effort=#222222", got)
}
if got.MainLineTintPct != 45 {
t.Errorf("MainLineTintPct after update = %v, want 45", got.MainLineTintPct)
}
if got.BackgroundDarkenPct != 60 {
t.Errorf("BackgroundDarkenPct after update = %v, want 60", got.BackgroundDarkenPct)
}
if got.TargetBrightenPct != 50 {
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
}
}

View File

@@ -9,7 +9,7 @@ func TestResetAllSyncedData_DeletesActivitiesCascadesAndRewindsWatermark(t *test
db := openTestDB(t)
ctx := context.Background()
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
activityID, err := db.UpsertActivity(ctx, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}

View File

@@ -40,14 +40,11 @@ func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
a := Activity{
GarminActivityID: 23554066504,
ActivityName: "Auriol - W2-5-Base Endurance",
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:02:35",
BeginTimestampMs: 1783746155000,
DurationSeconds: 1800,
DistanceMeters: 6858,
AvgHR: f(148),
RawJSON: `{"activityId":23554066504}`,
RawJSON: `{"activityId":23554066504,"activityName":"Auriol - W2-5-Base Endurance","activityType":{"typeKey":"running"}}`,
}
id, err := db.UpsertActivity(ctx, a)
@@ -87,7 +84,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 1,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -172,7 +168,6 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
activityID, err := db.UpsertActivity(ctx, Activity{
GarminActivityID: 2,
ActivityType: "running",
StartTimeUTC: "2026-07-11 05:00:00",
RawJSON: "{}",
})
@@ -181,8 +176,8 @@ func TestReplaceLapsIsIdempotent(t *testing.T) {
}
laps := []Lap{
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
{LapIndex: 1, RawJSON: "{}"},
{LapIndex: 2, RawJSON: "{}"},
}
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
t.Fatalf("ReplaceLaps (first): %v", err)

View File

@@ -9,6 +9,11 @@ import (
const (
SyncKindBackfill = "backfill"
SyncKindIncremental = "incremental"
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed
// by IncrementalSync followed by FillPendingDetails, recorded as one run
// so the reported activity count covers the whole action instead of only
// whichever stage happened to finish last.
SyncKindFull = "full"
SyncStatusRunning = "running"
SyncStatusSuccess = "success"

View File

@@ -18,8 +18,8 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
wantNames := map[string]bool{
"Easy Run": false, "Long Run": false, "Threshold 30'": false, "Threshold 60'": false,
"Tempo": false, "Interval": false, "MAS Test": false, "Race": false,
"Easy": false, "Long": false, "60' Threshold": false, "30' Threshold": false,
"Tempo": false, "Intervals": false, "MAS Test": false, "Race": false,
}
for _, k := range kinds {
if _, ok := wantNames[k.Name]; !ok {
@@ -34,3 +34,31 @@ func TestWorkoutTaxonomy_SeededWithEightFixedTypes(t *testing.T) {
}
}
}
// Every list of training types (Activities filters, Progression's kind
// picker, Profile's Training types card) relies on ListWorkoutKinds' own
// ORDER BY priority DESC, name to already be in this exact fixed order --
// none of them re-sort client-side.
func TestWorkoutTaxonomy_ListedInFixedDisplayOrder(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
kinds, err := db.ListWorkoutKinds(ctx, true)
if err != nil {
t.Fatalf("ListWorkoutKinds: %v", err)
}
want := []string{"Easy", "Long", "60' Threshold", "30' Threshold", "Tempo", "Intervals", "MAS Test", "Race"}
if len(kinds) != len(want) {
t.Fatalf("expected %d kinds, got %d", len(want), len(kinds))
}
for i, name := range want {
if kinds[i].Name != name {
got := make([]string, len(kinds))
for j, k := range kinds {
got[j] = k.Name
}
t.Fatalf("position %d: got %q, want %q (full order: %v)", i, kinds[i].Name, name, got)
}
}
}

View File

@@ -6,23 +6,25 @@ import (
"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.
// WorkoutTypePace is a workout kind's user-declared target pace range and HR
// range (percent of heart rate reserve). 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
HRMinPctHRR *float64
HRMaxPctHRR *float64
}
func scanWorkoutTypePace(row interface{ Scan(...any) error }) (WorkoutTypePace, error) {
var p WorkoutTypePace
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.ExpectedHRZone)
err := row.Scan(&p.WorkoutKindID, &p.PaceMinSecPerKm, &p.PaceMaxSecPerKm, &p.HRMinPctHRR, &p.HRMaxPctHRR)
return p, err
}
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, expected_hr_zone`
const workoutTypePaceColumns = `workout_kind_id, pace_min_sec_per_km, pace_max_sec_per_km, hr_min_pct_hrr, hr_max_pct_hrr`
// GetWorkoutTypePace fetches the pace/zone row for one workout kind.
func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (WorkoutTypePace, error) {
@@ -40,9 +42,9 @@ func (db *DB) GetWorkoutTypePace(ctx context.Context, workoutKindID int64) (Work
// 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=?
UPDATE workout_type_paces SET pace_min_sec_per_km=?, pace_max_sec_per_km=?, hr_min_pct_hrr=?, hr_max_pct_hrr=?
WHERE workout_kind_id=?`,
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.ExpectedHRZone, p.WorkoutKindID)
p.PaceMinSecPerKm, p.PaceMaxSecPerKm, p.HRMinPctHRR, p.HRMaxPctHRR, p.WorkoutKindID)
if err != nil {
return fmt.Errorf("update workout type pace for kind %d: %w", p.WorkoutKindID, err)
}

View File

@@ -17,16 +17,17 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
t.Fatalf("expected 8 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 {
if p.PaceMinSecPerKm != nil || p.PaceMaxSecPerKm != nil || p.HRMinPctHRR != nil || p.HRMaxPctHRR != 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
minPace, maxPace, hrMin, hrMax := 330.0, 420.0, 70.0, 80.0
target.PaceMinSecPerKm = &minPace
target.PaceMaxSecPerKm = &maxPace
target.ExpectedHRZone = &zone
target.HRMinPctHRR = &hrMin
target.HRMaxPctHRR = &hrMax
if err := db.UpdateWorkoutTypePace(ctx, target); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
@@ -39,7 +40,10 @@ func TestWorkoutTypePaces_SeededOnePerKindThenUpdate(t *testing.T) {
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)
if got.HRMinPctHRR == nil || *got.HRMinPctHRR != 70 {
t.Errorf("HRMinPctHRR = %v, want 70", got.HRMinPctHRR)
}
if got.HRMaxPctHRR == nil || *got.HRMaxPctHRR != 80 {
t.Errorf("HRMaxPctHRR = %v, want 80", got.HRMaxPctHRR)
}
}