Show expected vs actual pace/HR per lap in the Review Queue

Activities recorded from a structured Garmin workout carry a workoutId;
when its steps line up 1:1 with the recorded laps, the per-step target
pace/HR zone is resolved (via a Karvonen lookup for named zones) and stored
on each lap. The Review Queue plots it against the actual per-lap pace/HR
so the user can eyeball whether a run matches its plan while sorting it.
This commit is contained in:
2026-07-19 11:55:13 +02:00
parent 0610897541
commit af41aa0f7f
16 changed files with 666 additions and 47 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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.

View File

@@ -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{