Initial commit: smartrun MVP

Garmin run classification and progression tracker. Go backend (MCP
client to mcp-garmin, SQLite store, deterministic rule engine, REST
API) and React/TS frontend (Dashboard, Review Queue, Workout Kinds).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 18:33:06 +02:00
commit f689f74ae0
69 changed files with 10666 additions and 0 deletions

View File

@@ -0,0 +1,265 @@
// Package garmin wraps the mcp-garmin MCP server as a narrow Go client
// interface, so the rest of smartrun never deals with MCP/JSON-RPC directly.
package garmin
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// Client is the interface the rest of smartrun depends on. The real
// implementation drives mcp-garmin over stdio; internal/garmin/mock provides
// a fake for tests and frontend-only development.
type Client interface {
// Authenticate triggers Garmin login using credentials the subprocess
// was started with. Spawns the subprocess on first call.
Authenticate(ctx context.Context) (AuthResult, error)
// CompleteMFA submits an MFA code for a login started by Authenticate.
CompleteMFA(ctx context.Context, code string) (AuthResult, error)
// GetActivities lists activities between start and end (YYYY-MM-DD).
GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error)
// GetActivitySplits fetches lap/split summaries for one activity.
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)
// Close terminates the subprocess, if running.
Close() error
}
// Config configures how the mcp-garmin subprocess is spawned.
type Config struct {
PythonPath string // path to mcp-garmin's venv python executable
ServerPath string // path to mcp-garmin's server.py
GarminEmail string
GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so mcp-garmin
// persists/resumes a Garmin session there instead of the default ~/.garth.
TokenStorePath string
}
// mcpClient is the real Client implementation, backed by an mcp-garmin
// subprocess spoken to over stdio MCP.
type mcpClient struct {
cfg Config
mu sync.Mutex // serializes calls; mcp-garmin holds a single shared Garmin client
inner *mcpclient.Client
started bool
}
// 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 &mcpClient{cfg: cfg}
}
func (c *mcpClient) ensureStarted(ctx context.Context) error {
if c.started {
return nil
}
env := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"PYTHONUNBUFFERED=1",
}
if c.cfg.TokenStorePath != "" {
env = append(env, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
inner, err := mcpclient.NewStdioMCPClient(c.cfg.PythonPath, env, c.cfg.ServerPath)
if err != nil {
return fmt.Errorf("spawn mcp-garmin subprocess: %w", err)
}
if stdio, ok := inner.GetTransport().(*transport.Stdio); ok {
go drainStderr(stdio)
}
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{Name: "smartrund", Version: "0.0.1"}
if _, err := inner.Initialize(ctx, initReq); err != nil {
inner.Close()
return fmt.Errorf("mcp initialize handshake: %w", err)
}
c.inner = inner
c.started = true
return nil
}
// drainStderr forwards the subprocess's debug/log output so it isn't
// silently dropped (mcp-garmin logs auth/rate-limit diagnostics there).
func drainStderr(stdio *transport.Stdio) {
buf := make([]byte, 4096)
for {
n, err := stdio.Stderr().Read(buf)
if n > 0 {
fmt.Print(string(buf[:n]))
}
if err != nil {
return
}
}
}
func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (string, error) {
req := mcp.CallToolRequest{}
req.Params.Name = name
req.Params.Arguments = args
res, err := c.inner.CallTool(ctx, req)
if err != nil {
return "", fmt.Errorf("call tool %s: %w", name, err)
}
if res.IsError {
return "", fmt.Errorf("tool %s returned an error result", name)
}
var out strings.Builder
for _, content := range res.Content {
if tc, ok := content.(mcp.TextContent); ok {
out.WriteString(tc.Text)
}
}
return out.String(), nil
}
func (c *mcpClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "authenticate", nil)
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func (c *mcpClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
msg, err := c.callTool(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil {
return AuthResult{}, err
}
return parseAuthResult(msg), nil
}
func parseAuthResult(msg string) AuthResult {
switch {
case strings.Contains(msg, "Authenticated successfully") || strings.Contains(msg, "MFA accepted"):
return AuthResult{Status: AuthSuccess, Message: msg}
case strings.Contains(msg, "MFA required"):
return AuthResult{Status: AuthMFARequired, Message: msg}
default:
return AuthResult{Status: AuthFailed, Message: msg}
}
}
func (c *mcpClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
msg, err := c.callTool(ctx, "get_activities", map[string]any{
"start_date": startDate,
"end_date": endDate,
"limit": limit,
})
if err != nil {
return nil, err
}
var rawActivities []json.RawMessage
if err := json.Unmarshal([]byte(msg), &rawActivities); err != nil {
return nil, fmt.Errorf("get_activities did not return a JSON array (%q): %w", truncate(msg, 200), err)
}
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 *mcpClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err
}
msg, err := c.callTool(ctx, "get_activity_splits", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
})
if err != nil {
return ActivitySplits{}, err
}
var splits ActivitySplits
if err := json.Unmarshal([]byte(msg), &splits); err != nil {
return ActivitySplits{}, fmt.Errorf("get_activity_splits did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
return splits, nil
}
func (c *mcpClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
c.mu.Lock()
defer c.mu.Unlock()
if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err
}
msg, err := c.callTool(ctx, "get_activity_details", map[string]any{
"activity_id": strconv.FormatInt(activityID, 10),
})
if err != nil {
return ActivityDetails{}, err
}
var details ActivityDetails
if err := json.Unmarshal([]byte(msg), &details); err != nil {
return ActivityDetails{}, fmt.Errorf("get_activity_details did not return valid JSON (%q): %w", truncate(msg, 200), err)
}
return details, nil
}
func (c *mcpClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if !c.started {
return nil
}
return c.inner.Close()
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}

View File

@@ -0,0 +1,22 @@
package garmin
import "testing"
func TestParseAuthResult(t *testing.T) {
cases := []struct {
msg string
want AuthStatus
}{
{"Authenticated successfully.", AuthSuccess},
{"MFA accepted. Authenticated successfully.", AuthSuccess},
{"MFA required. Garmin has sent a verification code...", AuthMFARequired},
{"Authentication failed: 401 Unauthorized (Invalid Username or Password)", AuthFailed},
{"Authentication failed after MFA: bad code", AuthFailed},
}
for _, c := range cases {
got := parseAuthResult(c.msg)
if got.Status != c.want {
t.Errorf("parseAuthResult(%q).Status = %v, want %v", c.msg, got.Status, c.want)
}
}
}

View File

@@ -0,0 +1,76 @@
// Package mock provides a fake garmin.Client for tests and frontend/dev
// work without a live Garmin account or the mcp-garmin subprocess.
package mock
import (
"context"
"smartrun/backend/internal/garmin"
)
// Client is a fake garmin.Client returning data supplied by the test/caller.
type Client struct {
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []garmin.Activity
Splits map[int64]garmin.ActivitySplits
Details map[int64]garmin.ActivityDetails
Err error // if set, every call returns this error
authResultCursor int
ClosedCalled bool
GetActivitiesCalls int
}
var _ garmin.Client = (*Client)(nil)
func (c *Client) nextAuthResult() garmin.AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return garmin.AuthResult{Status: garmin.AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) CompleteMFA(ctx context.Context, code string) (garmin.AuthResult, error) {
if c.Err != nil {
return garmin.AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *Client) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]garmin.Activity, error) {
c.GetActivitiesCalls++
if c.Err != nil {
return nil, c.Err
}
if limit > 0 && limit < len(c.Activities) {
return c.Activities[:limit], nil
}
return c.Activities, nil
}
func (c *Client) GetActivitySplits(ctx context.Context, activityID int64) (garmin.ActivitySplits, error) {
if c.Err != nil {
return garmin.ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (garmin.ActivityDetails, error) {
if c.Err != nil {
return garmin.ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *Client) Close() error {
c.ClosedCalled = true
return nil
}

View File

@@ -0,0 +1,157 @@
package garmin
import "encoding/json"
// AuthStatus is the outcome of an authenticate()/complete_mfa() call.
// mcp-garmin's tools return a plain human-readable string rather than a
// structured status, so the client pattern-matches known phrases into this.
type AuthStatus int
const (
AuthUnknown AuthStatus = iota
AuthSuccess
AuthMFARequired
AuthFailed
)
type AuthResult struct {
Status AuthStatus
Message string
}
// ActivityType mirrors the nested "activityType" object in get_activities().
type ActivityType struct {
TypeKey string `json:"typeKey"`
}
// 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"`
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.
Raw json.RawMessage `json:"-"`
}
// Lap mirrors one entry of get_activity_splits()'s "lapDTOs".
type Lap struct {
LapIndex int `json:"lapIndex"`
StartTimeGMT string `json:"startTimeGMT"`
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"`
IntensityType string `json:"intensityType"`
Raw json.RawMessage `json:"-"`
}
// ActivitySplits mirrors the full get_activity_splits() response.
type ActivitySplits struct {
ActivityID int64 `json:"activityId"`
Laps []Lap `json:"lapDTOs"`
}
// 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.
type MetricDescriptor struct {
Key string `json:"key"`
MetricsIndex int `json:"metricsIndex"`
}
type activityDetailMetricsRow struct {
Metrics []*float64 `json:"metrics"`
}
// ActivityDetails mirrors the full get_activity_details() response: raw
// per-second telemetry, position-mapped via MetricDescriptors.
type ActivityDetails struct {
ActivityID int64 `json:"activityId"`
MetricDescriptors []MetricDescriptor `json:"metricDescriptors"`
ActivityDetailMetrics []activityDetailMetricsRow `json:"activityDetailMetrics"`
}
// Sample is one ~1-second telemetry reading extracted from ActivityDetails
// using its MetricDescriptors mapping. Pointer fields are nil when that
// channel wasn't reported for this sample (common for the first few seconds
// of an activity, e.g. before ground contact time can be computed).
type Sample struct {
ElapsedSeconds float64
TimestampMS int64
HeartRate *float64
SpeedMps *float64
DistanceM *float64
ElevationM *float64
}
// ExtractSamples converts ActivityDetails' raw metrics rows into named
// Samples, resolving each field by looking up its key in MetricDescriptors
// rather than assuming a fixed array position.
func ExtractSamples(d ActivityDetails) []Sample {
index := make(map[string]int, len(d.MetricDescriptors))
for _, md := range d.MetricDescriptors {
index[md.Key] = md.MetricsIndex
}
get := func(row []*float64, key string) *float64 {
i, ok := index[key]
if !ok || i < 0 || i >= len(row) {
return nil
}
return row[i]
}
samples := make([]Sample, 0, len(d.ActivityDetailMetrics))
for _, m := range d.ActivityDetailMetrics {
row := m.Metrics
s := Sample{
HeartRate: get(row, "directHeartRate"),
SpeedMps: get(row, "directSpeed"),
DistanceM: get(row, "sumDistance"),
ElevationM: get(row, "directElevation"),
}
if elapsed := get(row, "sumElapsedDuration"); elapsed != nil {
s.ElapsedSeconds = *elapsed
}
if ts := get(row, "directTimestamp"); ts != nil {
s.TimestampMS = int64(*ts)
}
samples = append(samples, s)
}
return samples
}

View File

@@ -0,0 +1,58 @@
package garmin
import "testing"
func f(v float64) *float64 { return &v }
func TestExtractSamples_UsesDescriptorIndexNotPosition(t *testing.T) {
// Deliberately out-of-order / non-contiguous indices, mirroring the real
// server response where metricDescriptors order is not guaranteed.
details := ActivityDetails{
MetricDescriptors: []MetricDescriptor{
{Key: "directSpeed", MetricsIndex: 2},
{Key: "directHeartRate", MetricsIndex: 0},
{Key: "sumElapsedDuration", MetricsIndex: 1},
{Key: "sumDistance", MetricsIndex: 3},
{Key: "directElevation", MetricsIndex: 4},
},
ActivityDetailMetrics: []activityDetailMetricsRow{
{Metrics: []*float64{f(101), f(0), f(1.2), f(0), f(237.6)}},
{Metrics: []*float64{f(105), f(1), f(1.3), f(1.2), nil}},
},
}
samples := ExtractSamples(details)
if len(samples) != 2 {
t.Fatalf("expected 2 samples, got %d", len(samples))
}
if *samples[0].HeartRate != 101 {
t.Errorf("sample 0 heart rate = %v, want 101", *samples[0].HeartRate)
}
if samples[0].ElapsedSeconds != 0 {
t.Errorf("sample 0 elapsed seconds = %v, want 0", samples[0].ElapsedSeconds)
}
if *samples[1].HeartRate != 105 {
t.Errorf("sample 1 heart rate = %v, want 105", *samples[1].HeartRate)
}
if samples[1].ElapsedSeconds != 1 {
t.Errorf("sample 1 elapsed seconds = %v, want 1", samples[1].ElapsedSeconds)
}
if samples[1].ElevationM != nil {
t.Errorf("sample 1 elevation should be nil (missing channel), got %v", *samples[1].ElevationM)
}
}
func TestExtractSamples_MissingDescriptorYieldsNilField(t *testing.T) {
details := ActivityDetails{
MetricDescriptors: []MetricDescriptor{
{Key: "directHeartRate", MetricsIndex: 0},
},
ActivityDetailMetrics: []activityDetailMetricsRow{
{Metrics: []*float64{f(120)}},
},
}
samples := ExtractSamples(details)
if samples[0].SpeedMps != nil {
t.Errorf("expected nil SpeedMps when directSpeed descriptor absent, got %v", *samples[0].SpeedMps)
}
}