From 8c94c94c3ff30a69d21a0850a94ff3e26652ff1a Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sat, 25 Jul 2026 21:02:49 +0200 Subject: [PATCH] feat(garmin): implement data-fetch methods via generic call dispatch GetActivities/GetActivitySplits/GetActivityDetails/GetWorkoutByID now send {"cmd":"call","params":{"method":...,"args":...}} instead of named MCP tools. subprocessClient fully implements Client. Co-Authored-By: Claude Sonnet 5 --- backend/internal/garmin/client.go | 125 +++++++++++++++++++++++++ backend/internal/garmin/client_test.go | 118 +++++++++++++++++++++++ 2 files changed, 243 insertions(+) diff --git a/backend/internal/garmin/client.go b/backend/internal/garmin/client.go index fb4f9bb..1f370d4 100644 --- a/backend/internal/garmin/client.go +++ b/backend/internal/garmin/client.go @@ -12,6 +12,7 @@ import ( "io" "os" "os/exec" + "strconv" "sync" ) @@ -311,3 +312,127 @@ func truncate(s string, n int) string { } return s[:n] + "...(truncated)" } + +var _ Client = (*subprocessClient)(nil) + +// NewClient builds a Client. The subprocess is not spawned until the first +// call that needs it (Authenticate, or any data call once authenticated). +func NewClient(cfg Config) Client { + return &subprocessClient{cfg: cfg} +} + +func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return nil, err + } + raw, err := c.roundTrip("call", callParams{ + Method: "get_activities_by_date", + Args: map[string]any{"startdate": startDate, "enddate": endDate}, + }) + if err != nil { + return nil, err + } + + var rawActivities []json.RawMessage + if err := json.Unmarshal(raw, &rawActivities); err != nil { + return nil, fmt.Errorf("get_activities_by_date did not return a JSON array (%q): %w", truncate(string(raw), 200), err) + } + if limit > 0 && limit < len(rawActivities) { + rawActivities = rawActivities[:limit] + } + + activities := make([]Activity, 0, len(rawActivities)) + for _, raw := range rawActivities { + var a Activity + if err := json.Unmarshal(raw, &a); err != nil { + return nil, fmt.Errorf("parse activity: %w", err) + } + a.Raw = raw + activities = append(activities, a) + } + return activities, nil +} + +func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return ActivitySplits{}, err + } + raw, err := c.roundTrip("call", callParams{ + Method: "get_activity_splits", + Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, + }) + if err != nil { + return ActivitySplits{}, err + } + + var envelope struct { + ActivityID int64 `json:"activityId"` + Laps []json.RawMessage `json:"lapDTOs"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(string(raw), 200), err) + } + + splits := ActivitySplits{ActivityID: envelope.ActivityID, Laps: make([]Lap, 0, len(envelope.Laps))} + for _, raw := range envelope.Laps { + var l Lap + if err := json.Unmarshal(raw, &l); err != nil { + return ActivitySplits{}, fmt.Errorf("parse lap: %w", err) + } + l.Raw = raw + splits.Laps = append(splits.Laps, l) + } + return splits, nil +} + +func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return ActivityDetails{}, err + } + raw, err := c.roundTrip("call", callParams{ + Method: "get_activity_details", + Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, + }) + if err != nil { + return ActivityDetails{}, err + } + + var details ActivityDetails + if err := json.Unmarshal(raw, &details); err != nil { + return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(string(raw), 200), err) + } + details.Raw = json.RawMessage(raw) + return details, nil +} + +func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if err := c.ensureStarted(); err != nil { + return Workout{}, err + } + raw, err := c.roundTrip("call", callParams{ + Method: "get_workout_by_id", + Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)}, + }) + if err != nil { + return Workout{}, err + } + + var workout Workout + if err := json.Unmarshal(raw, &workout); err != nil { + return Workout{}, fmt.Errorf("get_workout_by_id did not return valid JSON (%q): %w", truncate(string(raw), 200), err) + } + workout.Raw = json.RawMessage(raw) + return workout, nil +} diff --git a/backend/internal/garmin/client_test.go b/backend/internal/garmin/client_test.go index 2261f06..3d8e328 100644 --- a/backend/internal/garmin/client_test.go +++ b/backend/internal/garmin/client_test.go @@ -276,3 +276,121 @@ func TestSubprocessClient_CompleteMFA_Failed(t *testing.T) { t.Errorf("CompleteMFA status = %v, want AuthFailed", res.Status) } } + +func TestSubprocessClient_GetActivities_UsesGarminconnectKwargNames(t *testing.T) { + var gotMethod string + var gotArgs map[string]any + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + var p callParams + if err := json.Unmarshal(params, &p); err != nil { + t.Fatalf("unmarshal params: %v", err) + } + gotMethod, gotArgs = p.Method, p.Args + return fakeResult([]map[string]any{ + {"activityId": float64(123), "activityType": map[string]any{"typeKey": "running"}}, + }) + }) + + activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 0) + if err != nil { + t.Fatalf("GetActivities: %v", err) + } + if gotMethod != "get_activities_by_date" { + t.Errorf("method = %q, want get_activities_by_date", gotMethod) + } + if gotArgs["startdate"] != "2026-07-01" || gotArgs["enddate"] != "2026-07-25" { + t.Errorf("args = %+v, want startdate/enddate matching garminconnect's real kwargs", gotArgs) + } + if len(activities) != 1 || activities[0].ActivityID != 123 { + t.Fatalf("activities = %+v", activities) + } +} + +func TestSubprocessClient_GetActivities_AppliesLimitClientSide(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + raw := make([]map[string]any, 5) + for i := range raw { + raw[i] = map[string]any{"activityId": float64(i)} + } + return fakeResult(raw) + }) + + activities, err := c.GetActivities(context.Background(), "2026-07-01", "2026-07-25", 2) + if err != nil { + t.Fatalf("GetActivities: %v", err) + } + if len(activities) != 2 { + t.Fatalf("len(activities) = %d, want 2", len(activities)) + } +} + +func TestSubprocessClient_GetActivitySplits_ParsesLapDTOsEnvelope(t *testing.T) { + var gotMethod string + var gotArgs map[string]any + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + var p callParams + json.Unmarshal(params, &p) + gotMethod, gotArgs = p.Method, p.Args + return fakeResult(map[string]any{ + "activityId": float64(111), + "lapDTOs": []map[string]any{{"lapIndex": float64(1), "distance": float64(1000)}}, + }) + }) + + splits, err := c.GetActivitySplits(context.Background(), 111) + if err != nil { + t.Fatalf("GetActivitySplits: %v", err) + } + if gotMethod != "get_activity_splits" || gotArgs["activity_id"] != "111" { + t.Errorf("method/args = %q/%+v, want get_activity_splits with activity_id=\"111\"", gotMethod, gotArgs) + } + if splits.ActivityID != 111 || len(splits.Laps) != 1 || splits.Laps[0].LapIndex != 1 { + t.Fatalf("splits = %+v", splits) + } +} + +func TestSubprocessClient_GetActivityDetails_ParsesRawTelemetry(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + var p callParams + json.Unmarshal(params, &p) + if p.Method != "get_activity_details" || p.Args["activity_id"] != "222" { + t.Fatalf("unexpected call: %+v", p) + } + return fakeResult(map[string]any{ + "activityId": float64(222), + "metricDescriptors": []map[string]any{{"key": "directHeartRate", "metricsIndex": float64(0)}}, + "activityDetailMetrics": []map[string]any{{"metrics": []any{float64(101)}}}, + }) + }) + + details, err := c.GetActivityDetails(context.Background(), 222) + if err != nil { + t.Fatalf("GetActivityDetails: %v", err) + } + if details.ActivityID != 222 || len(details.MetricDescriptors) != 1 { + t.Fatalf("details = %+v", details) + } +} + +func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) { + c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload { + var p callParams + json.Unmarshal(params, &p) + if p.Method != "get_workout_by_id" || p.Args["workout_id"] != "333" { + t.Fatalf("unexpected call: %+v", p) + } + return fakeResult(map[string]any{ + "workoutId": float64(333), + "workoutName": "Tempo", + "workoutSegments": []map[string]any{{"segmentOrder": float64(1), "workoutSteps": []any{}}}, + }) + }) + + workout, err := c.GetWorkoutByID(context.Background(), 333) + if err != nil { + t.Fatalf("GetWorkoutByID: %v", err) + } + if workout.WorkoutID != 333 || workout.WorkoutName != "Tempo" || len(workout.Segments) != 1 { + t.Fatalf("workout = %+v", workout) + } +}