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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user