diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index c40d3d6..c6a0c38 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -164,6 +164,12 @@ func TestReviewQueueResolve(t *testing.T) { }); err != nil { t.Fatalf("InsertKindAssignment: %v", err) } + targetLow, targetHigh := 3.0, 3.5 + if err := db.ReplaceLaps(ctx, activityID, []store.Lap{ + {LapIndex: 1, TargetPaceLowMps: &targetLow, TargetPaceHighMps: &targetHigh}, + }); err != nil { + t.Fatalf("ReplaceLaps: %v", err) + } router := s.Router() rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) @@ -175,6 +181,10 @@ func TestReviewQueueResolve(t *testing.T) { if len(queue) != 1 { t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), rec.Body.String()) } + laps, _ := queue[0]["laps"].([]any) + if len(laps) != 1 { + t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String()) + } rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID}) if rec.Code != http.StatusOK { diff --git a/backend/internal/api/review.go b/backend/internal/api/review.go index 0ac0291..6353b26 100644 --- a/backend/internal/api/review.go +++ b/backend/internal/api/review.go @@ -21,6 +21,7 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { type item struct { store.KindAssignment Activity store.Activity `json:"activity"` + Laps []store.Lap `json:"laps"` } items := make([]item, 0, len(queue)) for _, a := range queue { @@ -32,7 +33,12 @@ func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) { if !ok { continue } - items = append(items, item{KindAssignment: a, Activity: activity}) + laps, err := s.DB.LapsForActivity(r.Context(), a.ActivityID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + items = append(items, item{KindAssignment: a, Activity: activity, Laps: laps}) } // Most recent run first, by when the activity actually happened (not diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go index 3df701f..381a54d 100644 --- a/backend/internal/garmin/client.go +++ b/backend/internal/garmin/client.go @@ -36,6 +36,8 @@ type Client interface { GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) // GetActivityDetails fetches raw per-second telemetry for one activity. GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) + // GetWorkoutByID fetches a structured workout's step-by-step plan. + GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) // Close terminates the subprocess, if running. Close() error } @@ -270,6 +272,27 @@ func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (A return details, nil } +func (c *mcpClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(ctx); err != nil { + return Workout{}, err + } + msg, err := c.callTool(ctx, "get_workout_by_id", map[string]any{ + "workout_id": strconv.FormatInt(workoutID, 10), + }) + if err != nil { + return Workout{}, err + } + + var workout Workout + if err := json.Unmarshal([]byte(msg), &workout); err != nil { + return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(msg, 200), err) + } + return workout, nil +} + func (c *mcpClient) Close() error { c.mu.Lock() defer c.mu.Unlock() diff --git a/backend/internal/garmin/mock/mock.go b/backend/internal/garmin/mock/mock.go index 4428eb5..9091d78 100644 --- a/backend/internal/garmin/mock/mock.go +++ b/backend/internal/garmin/mock/mock.go @@ -14,6 +14,7 @@ type Client struct { Activities []garmin.Activity Splits map[int64]garmin.ActivitySplits Details map[int64]garmin.ActivityDetails + Workouts map[int64]garmin.Workout Err error // if set, every call returns this error authResultCursor int ClosedCalled bool @@ -72,6 +73,13 @@ func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garm return c.Details[activityID], nil } +func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Workout, error) { + if c.Err != nil { + return garmin.Workout{}, c.Err + } + return c.Workouts[workoutID], nil +} + func (c *Client) UpdateCredentials(email, password string) { c.LastEmail = email c.LastPassword = password diff --git a/backend/internal/garmin/types.go b/backend/internal/garmin/types.go index 11c54e5..e805ae3 100644 --- a/backend/internal/garmin/types.go +++ b/backend/internal/garmin/types.go @@ -34,36 +34,39 @@ type EventType struct { // Activity mirrors the fields of interest from get_activities(); the full // original object is kept in Raw for fields not modeled here. type Activity struct { - ActivityID int64 `json:"activityId"` - ActivityName string `json:"activityName"` - ActivityType ActivityType `json:"activityType"` - EventType EventType `json:"eventType"` - BeginTimestamp int64 `json:"beginTimestamp"` - StartTimeGMT string `json:"startTimeGMT"` - StartTimeLocal string `json:"startTimeLocal"` - Distance float64 `json:"distance"` - Duration float64 `json:"duration"` - ElapsedDuration float64 `json:"elapsedDuration"` - MovingDuration float64 `json:"movingDuration"` - AverageHR float64 `json:"averageHR"` - MaxHR float64 `json:"maxHR"` - AverageSpeed float64 `json:"averageSpeed"` - MaxSpeed float64 `json:"maxSpeed"` - ElevationGain *float64 `json:"elevationGain"` - ElevationLoss *float64 `json:"elevationLoss"` - Calories float64 `json:"calories"` - LapCount int `json:"lapCount"` - AerobicTrainingEffect float64 `json:"aerobicTrainingEffect"` - AerobicTrainingEffectMessage string `json:"aerobicTrainingEffectMessage"` - AnaerobicTrainingEffect float64 `json:"anaerobicTrainingEffect"` - AnaerobicTrainingEffectMessage string `json:"anaerobicTrainingEffectMessage"` - TrainingEffectLabel string `json:"trainingEffectLabel"` - VO2MaxValue *float64 `json:"vO2MaxValue"` - HrTimeInZone1 float64 `json:"hrTimeInZone_1"` - HrTimeInZone2 float64 `json:"hrTimeInZone_2"` - HrTimeInZone3 float64 `json:"hrTimeInZone_3"` - HrTimeInZone4 float64 `json:"hrTimeInZone_4"` - HrTimeInZone5 float64 `json:"hrTimeInZone_5"` + ActivityID int64 `json:"activityId"` + ActivityName string `json:"activityName"` + ActivityType ActivityType `json:"activityType"` + EventType EventType `json:"eventType"` + // WorkoutID, when set, links this activity to the structured workout + // (get_workout_by_id) it was recorded from. + WorkoutID *int64 `json:"workoutId"` + BeginTimestamp int64 `json:"beginTimestamp"` + StartTimeGMT string `json:"startTimeGMT"` + StartTimeLocal string `json:"startTimeLocal"` + Distance float64 `json:"distance"` + Duration float64 `json:"duration"` + ElapsedDuration float64 `json:"elapsedDuration"` + MovingDuration float64 `json:"movingDuration"` + AverageHR float64 `json:"averageHR"` + MaxHR float64 `json:"maxHR"` + AverageSpeed float64 `json:"averageSpeed"` + MaxSpeed float64 `json:"maxSpeed"` + ElevationGain *float64 `json:"elevationGain"` + ElevationLoss *float64 `json:"elevationLoss"` + Calories float64 `json:"calories"` + LapCount int `json:"lapCount"` + AerobicTrainingEffect float64 `json:"aerobicTrainingEffect"` + AerobicTrainingEffectMessage string `json:"aerobicTrainingEffectMessage"` + AnaerobicTrainingEffect float64 `json:"anaerobicTrainingEffect"` + AnaerobicTrainingEffectMessage string `json:"anaerobicTrainingEffectMessage"` + TrainingEffectLabel string `json:"trainingEffectLabel"` + VO2MaxValue *float64 `json:"vO2MaxValue"` + HrTimeInZone1 float64 `json:"hrTimeInZone_1"` + HrTimeInZone2 float64 `json:"hrTimeInZone_2"` + HrTimeInZone3 float64 `json:"hrTimeInZone_3"` + HrTimeInZone4 float64 `json:"hrTimeInZone_4"` + HrTimeInZone5 float64 `json:"hrTimeInZone_5"` // Raw holds the full original JSON object for this activity, for fields // not modeled above (or discovered later) without needing to re-fetch. @@ -95,6 +98,85 @@ type ActivitySplits struct { Laps []Lap `json:"lapDTOs"` } +// WorkoutTargetType mirrors a workout step's "targetType" object, e.g. +// {"workoutTargetTypeKey": "pace.zone"}. Known keys include "no.target", +// "pace.zone", "heart.rate.zone", "cadence", "power.zone". +type WorkoutTargetType struct { + TypeKey string `json:"workoutTargetTypeKey"` +} + +// WorkoutEndCondition mirrors a workout step's "endCondition" object, e.g. +// {"conditionTypeKey": "time"} paired with EndConditionValue (seconds for +// "time", meters for "distance"). +type WorkoutEndCondition struct { + TypeKey string `json:"conditionTypeKey"` +} + +// WorkoutStep is one entry of a workout segment's "workoutSteps" list. It's +// either a leaf step (Type == "ExecutableStepDTO") or a repeat group (Type +// == "RepeatGroupDTO", holding its own nested Steps and NumberOfIterations +// instead of a target/end-condition). Workout.FlattenSteps expands repeat +// groups into their repeated leaf steps. +// +// TargetValueOne/TargetValueTwo are an unordered {low, high} pair (Garmin +// doesn't guarantee One < Two): meters/second for "pace.zone", bpm for +// "heart.rate.zone" with a custom range. ZoneNumber (1-5) is set instead of +// TargetValueOne/Two when the step targets a named HR zone rather than a +// custom bpm range -- resolving that to bpm bounds needs the user's Karvonen +// profile, not anything on the step itself. +type WorkoutStep struct { + Type string `json:"type"` + EndCondition WorkoutEndCondition `json:"endCondition"` + EndConditionValue float64 `json:"endConditionValue"` + TargetType WorkoutTargetType `json:"targetType"` + TargetValueOne *float64 `json:"targetValueOne"` + TargetValueTwo *float64 `json:"targetValueTwo"` + ZoneNumber *int `json:"zoneNumber"` + NumberOfIterations int `json:"numberOfIterations"` + Steps []WorkoutStep `json:"workoutSteps"` +} + +// WorkoutSegment mirrors one entry of a workout's "workoutSegments" list. +type WorkoutSegment struct { + SegmentOrder int `json:"segmentOrder"` + Steps []WorkoutStep `json:"workoutSteps"` +} + +// Workout mirrors get_workout_by_id()'s response: a structured workout's +// step-by-step plan. +type Workout struct { + WorkoutID int64 `json:"workoutId"` + WorkoutName string `json:"workoutName"` + Segments []WorkoutSegment `json:"workoutSegments"` +} + +// FlattenSteps expands repeat groups (RepeatGroupDTO) into their repeated +// leaf steps, in execution order across every segment, so the result can be +// zipped 1:1 against an activity's recorded laps. +func (w Workout) FlattenSteps() []WorkoutStep { + var out []WorkoutStep + var walk func(steps []WorkoutStep) + walk = func(steps []WorkoutStep) { + for _, s := range steps { + if s.Type == "RepeatGroupDTO" { + iterations := s.NumberOfIterations + if iterations < 1 { + iterations = 1 + } + for i := 0; i < iterations; i++ { + walk(s.Steps) + } + continue + } + out = append(out, s) + } + } + for _, seg := range w.Segments { + walk(seg.Steps) + } + return out +} + // MetricDescriptor maps a named metric to its index within each // ActivityDetailMetrics row. The index is NOT stable across activities or // devices and must always be read from this descriptor list at parse time. diff --git a/backend/internal/garmin/types_test.go b/backend/internal/garmin/types_test.go index 27e8c82..a455fc5 100644 --- a/backend/internal/garmin/types_test.go +++ b/backend/internal/garmin/types_test.go @@ -42,6 +42,55 @@ func TestExtractSamples_UsesDescriptorIndexNotPosition(t *testing.T) { } } +func TestWorkoutFlattenSteps_ExpandsRepeatGroupsInOrder(t *testing.T) { + workout := Workout{ + Segments: []WorkoutSegment{ + { + SegmentOrder: 1, + Steps: []WorkoutStep{ + {Type: "ExecutableStepDTO", EndConditionValue: 1}, + { + Type: "RepeatGroupDTO", + NumberOfIterations: 3, + Steps: []WorkoutStep{ + {Type: "ExecutableStepDTO", EndConditionValue: 2}, + {Type: "ExecutableStepDTO", EndConditionValue: 3}, + }, + }, + {Type: "ExecutableStepDTO", EndConditionValue: 4}, + }, + }, + }, + } + + flat := workout.FlattenSteps() + if len(flat) != 8 { + t.Fatalf("expected 8 flattened steps (1 + 3*2 + 1), got %d", len(flat)) + } + want := []float64{1, 2, 3, 2, 3, 2, 3, 4} + for i, s := range flat { + if s.EndConditionValue != want[i] { + t.Errorf("flat[%d].EndConditionValue = %v, want %v", i, s.EndConditionValue, want[i]) + } + } +} + +func TestWorkoutFlattenSteps_ZeroIterationsTreatedAsOne(t *testing.T) { + workout := Workout{ + Segments: []WorkoutSegment{ + {Steps: []WorkoutStep{ + {Type: "RepeatGroupDTO", NumberOfIterations: 0, Steps: []WorkoutStep{ + {Type: "ExecutableStepDTO", EndConditionValue: 1}, + }}, + }}, + }, + } + flat := workout.FlattenSteps() + if len(flat) != 1 { + t.Fatalf("expected 1 flattened step, got %d", len(flat)) + } +} + func TestExtractSamples_MissingDescriptorYieldsNilField(t *testing.T) { details := ActivityDetails{ MetricDescriptors: []MetricDescriptor{ diff --git a/backend/internal/store/activities.go b/backend/internal/store/activities.go index 2bd45a2..a6fed4e 100644 --- a/backend/internal/store/activities.go +++ b/backend/internal/store/activities.go @@ -15,6 +15,7 @@ type Activity struct { ActivityName string ActivityType string EventTypeKey string + WorkoutID *int64 StartTimeUTC string BeginTimestampMs int64 DurationSeconds float64 @@ -50,18 +51,19 @@ 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, start_time_utc, + 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, 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, @@ -86,7 +88,7 @@ func (db *DB) UpsertActivity(ctx context.Context, a Activity) (int64, error) { raw_json=excluded.raw_json, updated_at=datetime('now') `, - a.GarminActivityID, a.ActivityName, a.ActivityType, a.EventTypeKey, a.StartTimeUTC, + 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, @@ -108,7 +110,7 @@ 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.StartTimeUTC, + &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, @@ -121,7 +123,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) { } const activityColumns = ` - id, garmin_activity_id, activity_name, activity_type, event_type_key, start_time_utc, + 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, diff --git a/backend/internal/store/laps.go b/backend/internal/store/laps.go index 35d6191..effcab1 100644 --- a/backend/internal/store/laps.go +++ b/backend/internal/store/laps.go @@ -23,7 +23,17 @@ type Lap struct { IntensityType string HRDriftBpmPerMin *float64 HRRecoveryBpmPerMin *float64 - RawJSON string + // TargetPaceLowMps/High and TargetHRLowBpm/High hold the expected band + // for this lap, resolved from the activity's structured Garmin workout + // (see internal/sync's alignWorkoutTargets) when its steps line up 1:1 + // with the recorded laps. Nil when the activity has no structured + // workout, the step counts don't match, or the step targets neither + // pace nor heart rate. + TargetPaceLowMps *float64 + TargetPaceHighMps *float64 + TargetHRLowBpm *float64 + TargetHRHighBpm *float64 + RawJSON string } // ReplaceLaps deletes any existing laps for activityID and inserts the given @@ -44,11 +54,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) err 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, - intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + 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, - l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, l.RawJSON, + l.IntensityType, l.HRDriftBpmPerMin, l.HRRecoveryBpmPerMin, + l.TargetPaceLowMps, l.TargetPaceHighMps, l.TargetHRLowBpm, l.TargetHRHighBpm, l.RawJSON, ) if err != nil { return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err) @@ -62,7 +74,8 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err 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, - intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, raw_json + 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) if err != nil { return nil, fmt.Errorf("laps for activity %d: %w", activityID, err) @@ -75,7 +88,8 @@ func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, err 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.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, &l.RawJSON, + &l.IntensityType, &l.HRDriftBpmPerMin, &l.HRRecoveryBpmPerMin, + &l.TargetPaceLowMps, &l.TargetPaceHighMps, &l.TargetHRLowBpm, &l.TargetHRHighBpm, &l.RawJSON, ); err != nil { return nil, fmt.Errorf("scan lap row: %w", err) } diff --git a/backend/internal/store/migrations/0008_workout_targets.sql b/backend/internal/store/migrations/0008_workout_targets.sql new file mode 100644 index 0000000..6356808 --- /dev/null +++ b/backend/internal/store/migrations/0008_workout_targets.sql @@ -0,0 +1,12 @@ +-- Structured Garmin workouts (created in Garmin Connect or a training plan +-- tool) attach a per-step target pace/HR zone. An activity recorded from one +-- carries that workout's id; when its laps line up 1:1 with the workout's +-- flattened steps (see internal/sync's alignWorkoutTargets), the expected +-- band is resolved and stored per lap so the Review Queue can plot expected +-- vs actual pace/HR without a live Garmin call on every page view. +ALTER TABLE activities ADD COLUMN workout_id INTEGER; + +ALTER TABLE laps ADD COLUMN target_pace_low_mps REAL; +ALTER TABLE laps ADD COLUMN target_pace_high_mps REAL; +ALTER TABLE laps ADD COLUMN target_hr_low_bpm REAL; +ALTER TABLE laps ADD COLUMN target_hr_high_bpm REAL; diff --git a/backend/internal/sync/mapping.go b/backend/internal/sync/mapping.go index b7484ae..60d35fe 100644 --- a/backend/internal/sync/mapping.go +++ b/backend/internal/sync/mapping.go @@ -25,6 +25,7 @@ func toActivityRow(a garmin.Activity) store.Activity { ActivityName: a.ActivityName, ActivityType: a.ActivityType.TypeKey, EventTypeKey: a.EventType.TypeKey, + WorkoutID: a.WorkoutID, StartTimeUTC: a.StartTimeGMT, BeginTimestampMs: a.BeginTimestamp, DurationSeconds: a.Duration, @@ -64,10 +65,10 @@ func nonZero(v float64) *float64 { // fall within that lap's time window. Lap boundaries are derived from // cumulative elapsed duration rather than parsing StartTimeGMT, since laps // are contiguous and this sidesteps timezone parsing entirely. -func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap { +func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap { rows := make([]store.Lap, 0, len(laps)) var elapsedStart float64 - for _, l := range laps { + for i, l := range laps { elapsedEnd := elapsedStart + l.ElapsedDuration var driftPtr, recoveryPtr *float64 @@ -83,6 +84,12 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap { } } + var paceLow, paceHigh, hrLow, hrHigh *float64 + if i < len(targets) && targets[i] != nil { + paceLow, paceHigh = targetPaceRange(*targets[i]) + hrLow, hrHigh = targetHRRange(*targets[i], profile) + } + raw, _ := json.Marshal(l) rows = append(rows, store.Lap{ LapIndex: l.LapIndex, @@ -98,6 +105,10 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap { IntensityType: l.IntensityType, HRDriftBpmPerMin: driftPtr, HRRecoveryBpmPerMin: recoveryPtr, + TargetPaceLowMps: paceLow, + TargetPaceHighMps: paceHigh, + TargetHRLowBpm: hrLow, + TargetHRHighBpm: hrHigh, RawJSON: string(raw), }) elapsedStart = elapsedEnd @@ -105,6 +116,89 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample) []store.Lap { return rows } +// alignWorkoutTargets zips an activity's recorded laps against its +// structured workout's flattened steps, returning one *garmin.WorkoutStep +// per lap (nil where unavailable). Zipping only happens when the counts +// match exactly -- a mismatch (extra manual laps, auto-lap-by-distance also +// firing, etc.) means we can't trust the alignment, so every entry comes +// back nil rather than risk showing a target against the wrong lap. +func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep { + steps := workout.FlattenSteps() + if len(steps) != len(laps) { + return make([]*garmin.WorkoutStep, len(laps)) + } + out := make([]*garmin.WorkoutStep, len(steps)) + for i := range steps { + s := steps[i] + out[i] = &s + } + return out +} + +// targetPaceRange returns the (low, high) m/s bounds of a workout step's +// pace-zone target, or (nil, nil) if it doesn't target pace. +func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) { + if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil { + return nil, nil + } + lo, hi := *step.TargetValueOne, *step.TargetValueTwo + if lo > hi { + lo, hi = hi, lo + } + return &lo, &hi +} + +// targetHRRange returns the (low, high) bpm bounds of a workout step's +// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate. +// Steps that target a named zone (ZoneNumber) rather than a custom bpm +// range are resolved via the user's Karvonen profile. +func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) { + if step.TargetType.TypeKey != "heart.rate.zone" { + return nil, nil + } + if step.TargetValueOne != nil && step.TargetValueTwo != nil { + lo, hi := *step.TargetValueOne, *step.TargetValueTwo + if lo > hi { + lo, hi = hi, lo + } + return &lo, &hi + } + if step.ZoneNumber != nil { + if lo, hi, ok := karvonenBounds(profile, *step.ZoneNumber); ok { + return &lo, &hi + } + } + return nil, nil +} + +// karvonenBounds resolves a named HR zone (1-5) to bpm bounds using the +// user's max/resting heart rate and the zone's %HRR range, both from +// Profile. ok is false when max/resting heart rate aren't configured, or +// the zone number is out of range. +func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool) { + if p.MaxHeartRate == nil || p.RestingHeartRate == nil { + return 0, 0, false + } + maxHR, restHR := *p.MaxHeartRate, *p.RestingHeartRate + + var minPct, maxPct float64 + switch zone { + case 1: + minPct, maxPct = p.HRZone1MinPct, p.HRZone1MaxPct + case 2: + minPct, maxPct = p.HRZone2MinPct, p.HRZone2MaxPct + case 3: + minPct, maxPct = p.HRZone3MinPct, p.HRZone3MaxPct + case 4: + minPct, maxPct = p.HRZone4MinPct, p.HRZone4MaxPct + case 5: + minPct, maxPct = p.HRZone5MinPct, p.HRZone5MaxPct + default: + return 0, 0, false + } + return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true +} + func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo { var out []classify.SampleInfo for _, s := range samples { diff --git a/backend/internal/sync/service.go b/backend/internal/sync/service.go index ade295b..16bb1e1 100644 --- a/backend/internal/sync/service.go +++ b/backend/internal/sync/service.go @@ -8,6 +8,7 @@ import ( "context" "encoding/json" "fmt" + "log" "sync" "time" @@ -233,6 +234,10 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { if err != nil { return err } + profile, err := s.db.GetProfile(ctx) + if err != nil { + return fmt.Errorf("load profile: %w", err) + } s.setProgress(0, len(pending)) defer s.setProgress(0, 0) @@ -245,7 +250,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { case <-time.After(s.cfg.InterCallDelay): } } - if err := s.fillActivityDetails(ctx, a); err != nil { + if err := s.fillActivityDetails(ctx, a, profile); err != nil { return fmt.Errorf("fill details for activity %d: %w", a.GarminActivityID, err) } if err := s.ClassifyActivity(ctx, a.ID); err != nil { @@ -256,7 +261,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { return nil } -func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) error { +func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error { splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID) if err != nil { return fmt.Errorf("get_activity_splits: %w", err) @@ -266,11 +271,21 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) err return fmt.Errorf("get_activity_details: %w", err) } + targets := make([]*garmin.WorkoutStep, len(splits.Laps)) + if a.WorkoutID != nil { + workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID) + if err != nil { + log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err) + } else { + targets = alignWorkoutTargets(splits.Laps, workout) + } + } + samples := garmin.ExtractSamples(details) if err := s.db.ReplaceActivitySamples(ctx, a.ID, toSampleRows(samples)); err != nil { return err } - if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples)); err != nil { + if err := s.db.ReplaceLaps(ctx, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { return err } detailsRaw, _ := json.Marshal(details) diff --git a/backend/internal/sync/service_test.go b/backend/internal/sync/service_test.go index f9d978f..bbe6a03 100644 --- a/backend/internal/sync/service_test.go +++ b/backend/internal/sync/service_test.go @@ -62,6 +62,85 @@ func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) { } } +func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) { + laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}} + workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ + {Steps: []garmin.WorkoutStep{ + {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)}, + {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}}, + }}, + }} + + targets := alignWorkoutTargets(laps, workout) + if len(targets) != 2 { + t.Fatalf("expected 2 aligned targets, got %d", len(targets)) + } + if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" { + t.Errorf("targets[0] = %+v, want pace.zone step", targets[0]) + } + if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" { + t.Errorf("targets[1] = %+v, want no.target step", targets[1]) + } +} + +func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) { + laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}} + workout := garmin.Workout{Segments: []garmin.WorkoutSegment{ + {Steps: []garmin.WorkoutStep{ + {Type: "ExecutableStepDTO"}, + }}, + }} + + targets := alignWorkoutTargets(laps, workout) + if len(targets) != 3 { + t.Fatalf("expected 3 slots (one per lap), got %d", len(targets)) + } + for i, target := range targets { + if target != nil { + t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target) + } + } +} + +func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) { + lo, hi := targetPaceRange(garmin.WorkoutStep{ + TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3), + }) + if lo == nil || hi == nil || *lo != 3 || *hi != 4 { + t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi) + } + + lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)}) + if lo != nil || hi != nil { + t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi) + } +} + +func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) { + lo, hi := targetHRRange(garmin.WorkoutStep{ + TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150), + }, store.Profile{}) + if lo == nil || hi == nil || *lo != 150 || *hi != 160 { + t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi) + } + + zone := 3 + profile := store.Profile{ + MaxHeartRate: f(190), RestingHeartRate: f(50), + HRZone3MinPct: 70, HRZone3MaxPct: 80, + } + lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile) + // Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162 + if lo == nil || hi == nil || *lo != 148 || *hi != 162 { + t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi) + } + + lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{}) + if lo != nil || hi != nil { + t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi) + } +} + func TestBackfill_SkipsNonRunningActivities(t *testing.T) { db := openTestDB(t) ctx := context.Background() @@ -164,6 +243,106 @@ func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) { } } +func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + const garminActivityID = 55 + const workoutID = 999 + workoutIDPtr := int64(workoutID) + m := &mock.Client{ + Activities: []garmin.Activity{ + {ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr, + StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, + }, + Splits: map[int64]garmin.ActivitySplits{ + garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{ + {LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"}, + }}, + }, + Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}}, + Workouts: map[int64]garmin.Workout{ + workoutID: {Segments: []garmin.WorkoutSegment{ + {Steps: []garmin.WorkoutStep{ + {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)}, + }}, + }}, + }, + } + svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + + if err := svc.Backfill(ctx); err != nil { + t.Fatalf("Backfill: %v", err) + } + if err := svc.FillPendingDetails(ctx, 10); err != nil { + t.Fatalf("FillPendingDetails: %v", err) + } + + activities, err := db.ListActivities(ctx, store.ActivityFilter{}) + if err != nil || len(activities) != 1 { + t.Fatalf("ListActivities: %v, %+v", err, activities) + } + laps, err := db.LapsForActivity(ctx, activities[0].ID) + if err != nil || len(laps) != 1 { + t.Fatalf("LapsForActivity: %v, %+v", err, laps) + } + if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 { + t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps) + } + if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 { + t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps) + } +} + +func TestFillPendingDetails_MismatchedLapCountLeavesTargetsNil(t *testing.T) { + db := openTestDB(t) + ctx := context.Background() + + const garminActivityID = 56 + const workoutID = 1000 + workoutIDPtr := int64(workoutID) + m := &mock.Client{ + Activities: []garmin.Activity{ + {ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr, + StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500}, + }, + Splits: map[int64]garmin.ActivitySplits{ + garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{ + {LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"}, + {LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"}, + }}, + }, + Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}}, + Workouts: map[int64]garmin.Workout{ + // Only one step for two recorded laps -- counts don't match. + workoutID: {Segments: []garmin.WorkoutSegment{ + {Steps: []garmin.WorkoutStep{ + {Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)}, + }}, + }}, + }, + } + svc := NewService(m, db, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) + + if err := svc.Backfill(ctx); err != nil { + t.Fatalf("Backfill: %v", err) + } + if err := svc.FillPendingDetails(ctx, 10); err != nil { + t.Fatalf("FillPendingDetails: %v", err) + } + + activities, _ := db.ListActivities(ctx, store.ActivityFilter{}) + laps, err := db.LapsForActivity(ctx, activities[0].ID) + if err != nil || len(laps) != 2 { + t.Fatalf("LapsForActivity: %v, %+v", err, laps) + } + for i, l := range laps { + if l.TargetPaceLowMps != nil || l.TargetPaceHighMps != nil { + t.Errorf("laps[%d] target should be nil on lap/step count mismatch, got low=%v high=%v", i, l.TargetPaceLowMps, l.TargetPaceHighMps) + } + } +} + func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) { activity := store.Activity{ DurationSeconds: 1800, diff --git a/frontend/src/App.css b/frontend/src/App.css index c556417..5b07eb8 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -210,6 +210,25 @@ button:disabled { margin-top: 0.5rem; } +.expected-actual-charts { + display: flex; + gap: 1rem; + flex-wrap: wrap; + margin: 0.5rem 0; +} + +.mini-chart { + flex: 1 1 220px; + min-width: 180px; +} + +.mini-chart-label { + display: block; + font-size: 0.75rem; + color: #9aa0ab; + margin-bottom: 0.15rem; +} + .kinds-table { width: 100%; border-collapse: collapse; diff --git a/frontend/src/components/charts/ExpectedVsActualChart.tsx b/frontend/src/components/charts/ExpectedVsActualChart.tsx new file mode 100644 index 0000000..8783044 --- /dev/null +++ b/frontend/src/components/charts/ExpectedVsActualChart.tsx @@ -0,0 +1,95 @@ +import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import type { Lap } from "../../types/api"; + +function paceSecPerKm(mps: number | null): number | null { + if (mps == null || mps <= 0) return null; + return 1000 / mps; +} + +function formatPaceShort(secPerKm: number): string { + const m = Math.floor(secPerKm / 60); + const s = Math.round(secPerKm % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +function formatPace(secPerKm: number): string { + return `${formatPaceShort(secPerKm)}/km`; +} + +// Recharts' "dataMin - N" domain expressions don't combine reliably with +// reversed axes, so the padded numeric domain is computed directly instead. +function paddedDomain(values: Array, pad: number): [number, number] { + const nums = values.filter((v): v is number => v != null); + if (nums.length === 0) return [0, 1]; + return [Math.min(...nums) - pad, Math.max(...nums) + pad]; +} + +// Shows each lap's actual pace/HR against the expected band from the +// activity's structured Garmin workout (see backend's alignWorkoutTargets). +// Renders nothing if no lap has a resolved target -- most activities aren't +// from a structured workout, and an empty chart isn't useful. +export function ExpectedVsActualChart({ laps }: { laps: Lap[] }) { + const hasPaceTarget = laps.some((l) => l.TargetPaceLowMps != null && l.TargetPaceHighMps != null); + const hasHRTarget = laps.some((l) => l.TargetHRLowBpm != null && l.TargetHRHighBpm != null); + if (!hasPaceTarget && !hasHRTarget) return null; + + const data = laps.map((l, i) => ({ + lap: i + 1, + actualPace: paceSecPerKm(l.AvgSpeedMps), + // Pace (sec/km) is inverted vs speed (m/s): the *faster* (higher) speed + // bound is the *lower* (faster) pace bound. + targetPaceLow: l.TargetPaceHighMps != null ? paceSecPerKm(l.TargetPaceHighMps) : null, + targetPaceHigh: l.TargetPaceLowMps != null ? paceSecPerKm(l.TargetPaceLowMps) : null, + actualHR: l.AvgHR, + targetHRLow: l.TargetHRLowBpm, + targetHRHigh: l.TargetHRHighBpm, + })); + + const paceDomain = paddedDomain(data.flatMap((d) => [d.actualPace, d.targetPaceLow, d.targetPaceHigh]), 10); + const hrDomain = paddedDomain(data.flatMap((d) => [d.actualHR, d.targetHRLow, d.targetHRHigh]), 5); + + return ( +
+ {hasPaceTarget && ( +
+ Pace vs target + + + + formatPaceShort(Number(v))} + /> + formatPace(Number(v))} + labelFormatter={(l) => `Lap ${l}`} + contentStyle={{ fontSize: 12 }} + /> + + + + + +
+ )} + {hasHRTarget && ( +
+ HR vs target + + + + String(Math.round(Number(v)))} /> + `${Math.round(Number(v))} bpm`} labelFormatter={(l) => `Lap ${l}`} contentStyle={{ fontSize: 12 }} /> + + + + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ReviewQueue.tsx b/frontend/src/pages/ReviewQueue.tsx index 7fcfc9a..3c46bc9 100644 --- a/frontend/src/pages/ReviewQueue.tsx +++ b/frontend/src/pages/ReviewQueue.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { api } from "../api/client"; +import { ExpectedVsActualChart } from "../components/charts/ExpectedVsActualChart"; import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api"; const UNSORTED = "__unsorted__"; @@ -129,6 +130,8 @@ export function ReviewQueue() {

)} + +
{manuallyAssignableKinds.map((k) => (