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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
backend/internal/store/migrations/0011_profile_name.sql
Normal file
3
backend/internal/store/migrations/0011_profile_name.sql
Normal 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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
10
backend/internal/store/migrations/0014_chart_colors.sql
Normal file
10
backend/internal/store/migrations/0014_chart_colors.sql
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user