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:
90
.claude/skills/smartrun-dev/SKILL.md
Normal file
90
.claude/skills/smartrun-dev/SKILL.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: smartrun-dev
|
||||||
|
description: Use when working on the smartrun repo (backend Go services, classification rule engine, mcp-garmin integration, or frontend) to stay consistent with established conventions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# smartrun-dev
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
smartrun is a personal web app that pulls running activities from Garmin Connect (via the `mcp-garmin` MCP server), classifies each run into a user-defined "workout kind" (Easy, Tempo, Threshold, Interval, ...), and charts progression over time per kind. Ambiguous runs (matching zero, multiple, or only weakly one kind) go to a manual review queue instead of being silently misclassified.
|
||||||
|
|
||||||
|
**MVP scope boundary**: sync + classification + review queue + progression charts. A future training-recommendation engine (analyzing aerobic/anaerobic training-effect balance across kinds to suggest what to train next) is explicitly deferred — the schema stores the metrics it would need, but nothing consumes them yet.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
cmd/smartrund/ main server entrypoint
|
||||||
|
cmd/seedsample/ inserts synthetic data for frontend dev/demoing without live Garmin creds
|
||||||
|
cmd/mcpspike/ throwaway MCP-client spike, safe to delete
|
||||||
|
internal/garmin/ MCP client wrapper (auth, get_activities, get_activity_splits, get_activity_details)
|
||||||
|
internal/garmin/mock/ fake Client for tests
|
||||||
|
internal/classify/ pure rule engine (condition tree eval, scoring, interval detection, HR drift/recovery)
|
||||||
|
internal/store/ SQLite layer + embedded migrations
|
||||||
|
internal/sync/ orchestrates fetch -> store -> classify
|
||||||
|
internal/api/ HTTP handlers (chi router)
|
||||||
|
internal/config/ env var config loading
|
||||||
|
frontend/
|
||||||
|
src/pages/ Dashboard, ReviewQueue, WorkoutKinds
|
||||||
|
src/components/charts/ Recharts wrappers
|
||||||
|
src/api/client.ts thin typed fetch client
|
||||||
|
src/types/api.ts hand-shared DTO types mirroring backend/internal/api's JSON responses
|
||||||
|
```
|
||||||
|
|
||||||
|
## Classification rule model
|
||||||
|
|
||||||
|
Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/classify.Node`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"match": "all",
|
||||||
|
"conditions": [
|
||||||
|
{ "metric": "avg_pace_sec_per_km", "op": "between", "value": [270, 300] },
|
||||||
|
{ "metric": "avg_hr_pct_max", "op": ">=", "value": 0.80 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `match`: `"all"` (AND) or `"any"` (OR), with nested `conditions`.
|
||||||
|
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
|
||||||
|
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
|
||||||
|
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
|
||||||
|
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default 0.6). All three populate `Candidates` for the review UI.
|
||||||
|
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance — the device already knows which laps were work vs rest when the activity was recorded as a structured workout.
|
||||||
|
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap (cardiac drift). Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
|
||||||
|
|
||||||
|
## mcp-garmin integration
|
||||||
|
|
||||||
|
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
|
||||||
|
|
||||||
|
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these.
|
||||||
|
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
|
||||||
|
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (default `~/.garth`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
|
||||||
|
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
|
||||||
|
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
|
||||||
|
- **`get_activity_splits()`** (added to mcp-garmin, wraps `garminconnect`'s existing `get_activity_splits`) is the one that returns actual lap/split summaries (`lapDTOs`).
|
||||||
|
|
||||||
|
## Data model conventions
|
||||||
|
|
||||||
|
- `kind_assignments` is **append-only** — always INSERT, never UPDATE. Re-classifying after a rule edit, or a manual override, keeps full history; `current_kind_assignment` (a view) picks the latest row per activity by `id`.
|
||||||
|
- `activities.raw_json`/`details_raw_json` hedge columns store the full original Garmin JSON, so fields not yet modeled in Go can be backfilled later without re-fetching from Garmin.
|
||||||
|
- `garmin_activity_id` is the natural idempotency key for `UpsertActivity` (`ON CONFLICT ... DO UPDATE`), safe to re-run on every sync pass.
|
||||||
|
- `sync_state` (singleton row) tracks a **backfill watermark** (`earliest_synced_date`, `backfill_complete`) — since Garmin history is immutable once recorded, `Service.Backfill` uses this to resume from where it left off (or no-op entirely if the configured horizon is already fully covered) instead of re-walking years of already-known history against Garmin's API on every call. Widening `BackfillHorizonDays` after a completed backfill correctly triggers resumption further back, not a full re-fetch.
|
||||||
|
- Live sync progress is exposed via `Service.Progress()` (in-memory, mutex-guarded `Done`/`Total` counters set by `FillPendingDetails`, reset to zero when idle) and surfaced through `GET /api/sync/status` (`detail_fill_progress`, plus `activities_pending_details` for the total remaining beyond the current batch). The frontend's `GarminConnection` banner polls this and shows "syncing: N/M activities" live.
|
||||||
|
|
||||||
|
## Dev workflow
|
||||||
|
|
||||||
|
- Backend: `cd backend && go run ./cmd/smartrund` (needs `MCP_GARMIN_PYTHON`, `MCP_GARMIN_SERVER`, `GARMIN_EMAIL`, `GARMIN_PASSWORD` env vars; see `internal/config/config.go` for all knobs).
|
||||||
|
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
|
||||||
|
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `smartrund` at that DB.
|
||||||
|
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
|
||||||
|
- Migrations: add a new numbered file under `internal/store/migrations/`, never edit an already-applied one (the runner tracks applied filenames in a `schema_migrations` table).
|
||||||
|
|
||||||
|
## Testing conventions
|
||||||
|
|
||||||
|
- Table-driven Go tests throughout; no separate fixture files needed yet given the codebase's size — test cases are inline.
|
||||||
|
- `internal/classify` tests are pure (no DB/network): construct a `MetricContext` + `RuleKind`s directly.
|
||||||
|
- `internal/store` and `internal/sync` tests open a real temp-file SQLite DB (`store.Open` against `t.TempDir()`) — this is deliberate, not mocked, since the migration/SQL correctness is exactly what needs catching.
|
||||||
|
- `internal/api` tests use `httptest` against a `Server` wired to a temp DB + `mock.Client`.
|
||||||
|
- The MCP/Garmin integration itself can't be safely automated (real account, MFA, rate limits) — it's a manual smoke test via `cmd/mcpspike` or the real `smartrund` auth endpoints.
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Go
|
||||||
|
backend/smartrund
|
||||||
|
backend/*.db
|
||||||
|
backend/*.db-journal
|
||||||
|
|
||||||
|
# Node
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/shelf/
|
||||||
|
|
||||||
|
# Env / secrets
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
backend/mcpspike
|
||||||
|
backend/cmd/mcpspike/mcpspike
|
||||||
10
backend/.idea/.gitignore
generated
vendored
Normal file
10
backend/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
9
backend/.idea/backend.iml
generated
Normal file
9
backend/.idea/backend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
10
backend/.idea/go.imports.xml
generated
Normal file
10
backend/.idea/go.imports.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
backend/.idea/modules.xml
generated
Normal file
8
backend/.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
243
backend/cmd/mcpspike/main.go
Normal file
243
backend/cmd/mcpspike/main.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
// Throwaway spike to validate that mark3labs/mcp-go's stdio client can drive
|
||||||
|
// mcp-garmin (spawn, initialize handshake, call tools, parse results).
|
||||||
|
// Not part of the production build — delete once internal/garmin is built.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mark3labs/mcp-go/client"
|
||||||
|
"github.com/mark3labs/mcp-go/client/transport"
|
||||||
|
"github.com/mark3labs/mcp-go/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pythonPath := flag.String("python", "", "path to mcp-garmin .venv python executable")
|
||||||
|
serverPath := flag.String("server", "", "path to mcp-garmin server.py")
|
||||||
|
limit := flag.Int("limit", 5, "activity limit for get_activities")
|
||||||
|
startDate := flag.String("start", "", "start date YYYY-MM-DD for get_activities (default: 365 days ago)")
|
||||||
|
endDate := flag.String("end", "", "end date YYYY-MM-DD for get_activities (default: today)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *pythonPath == "" || *serverPath == "" {
|
||||||
|
log.Fatal("usage: mcpspike -python <path to .venv/bin/python> -server <path to server.py>")
|
||||||
|
}
|
||||||
|
|
||||||
|
email := os.Getenv("GARMIN_EMAIL")
|
||||||
|
password := os.Getenv("GARMIN_PASSWORD")
|
||||||
|
if email == "" || password == "" {
|
||||||
|
log.Fatal("GARMIN_EMAIL and GARMIN_PASSWORD must be set in the environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
env := []string{
|
||||||
|
"GARMIN_EMAIL=" + email,
|
||||||
|
"GARMIN_PASSWORD=" + password,
|
||||||
|
"PYTHONUNBUFFERED=1",
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := client.NewStdioMCPClient(*pythonPath, env, *serverPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("spawn subprocess: %v", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
if stdio, ok := c.GetTransport().(*transport.Stdio); ok {
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := stdio.Stderr().Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
fmt.Fprint(os.Stderr, string(buf[:n]))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
log.Println("warning: could not get stdio transport to forward subprocess stderr")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
initReq := mcp.InitializeRequest{}
|
||||||
|
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
||||||
|
initReq.Params.ClientInfo = mcp.Implementation{Name: "mcpspike", Version: "0.0.1"}
|
||||||
|
|
||||||
|
initRes, err := c.Initialize(ctx, initReq)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("initialize handshake failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("initialized OK: server=%s version=%s protocol=%s\n",
|
||||||
|
initRes.ServerInfo.Name, initRes.ServerInfo.Version, initRes.ProtocolVersion)
|
||||||
|
|
||||||
|
tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list tools failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("server exposes %d tools:\n", len(tools.Tools))
|
||||||
|
for _, t := range tools.Tools {
|
||||||
|
fmt.Printf(" - %s: %s\n", t.Name, t.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\ncalling authenticate()...")
|
||||||
|
authRes, err := callTool(ctx, c, "authenticate", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("authenticate call failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("authenticate() -> %s\n", authRes)
|
||||||
|
|
||||||
|
if containsMFAPrompt(authRes) {
|
||||||
|
fmt.Print("MFA required. Enter code: ")
|
||||||
|
var code string
|
||||||
|
fmt.Scanln(&code)
|
||||||
|
mfaRes, err := callTool(ctx, c, "complete_mfa", map[string]any{"code": code})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("complete_mfa call failed: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("complete_mfa() -> %s\n", mfaRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := *startDate
|
||||||
|
if start == "" {
|
||||||
|
start = time.Now().AddDate(0, 0, -365).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
end := *endDate
|
||||||
|
if end == "" {
|
||||||
|
end = time.Now().Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\ncalling get_activities(start_date=%s, end_date=%s, limit=%d)...\n", start, end, *limit)
|
||||||
|
actRes, err := callTool(ctx, c, "get_activities", map[string]any{
|
||||||
|
"start_date": start,
|
||||||
|
"end_date": end,
|
||||||
|
"limit": *limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("get_activities call failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var activities []map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(actRes), &activities); err != nil {
|
||||||
|
fmt.Printf("get_activities() returned non-JSON (likely an error string): %s\n", actRes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pretty, _ := json.MarshalIndent(activities, "", " ")
|
||||||
|
fmt.Printf("get_activities() parsed OK, %d activities, %d bytes:\n%s\n", len(activities), len(actRes), truncate(string(pretty), 4000))
|
||||||
|
|
||||||
|
if len(activities) == 0 {
|
||||||
|
fmt.Println("\nno activities in range, skipping get_activity_details")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer a running activity if one is present, for the most relevant lap/split shape.
|
||||||
|
chosen := activities[0]
|
||||||
|
for _, a := range activities {
|
||||||
|
if at, ok := a["activityType"].(map[string]any); ok {
|
||||||
|
if tk, _ := at["typeKey"].(string); tk == "running" || tk == "trail_running" || tk == "treadmill_running" {
|
||||||
|
chosen = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var activityID string
|
||||||
|
switch v := chosen["activityId"].(type) {
|
||||||
|
case float64:
|
||||||
|
activityID = strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
default:
|
||||||
|
activityID = fmt.Sprint(v)
|
||||||
|
}
|
||||||
|
fmt.Printf("\ncalling get_activity_details(activity_id=%s) [activityName=%v]...\n", activityID, chosen["activityName"])
|
||||||
|
|
||||||
|
splitsRes, err := callTool(ctx, c, "get_activity_splits", map[string]any{"activity_id": activityID})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("get_activity_splits call failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var splits map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(splitsRes), &splits); err != nil {
|
||||||
|
fmt.Printf("get_activity_splits() returned non-JSON: %s\n", splitsRes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(splits))
|
||||||
|
for k := range splits {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
fmt.Printf("get_activity_splits() top-level keys: %v\n", keys)
|
||||||
|
prettySplits, _ := json.MarshalIndent(splits, "", " ")
|
||||||
|
fmt.Printf("get_activity_splits() parsed OK, %d bytes:\n%s\n", len(splitsRes), truncate(string(prettySplits), 8000))
|
||||||
|
|
||||||
|
fmt.Println("\ncalling get_activity_details() again to inspect metricDescriptors + a small metrics sample...")
|
||||||
|
detailsRes, err := callTool(ctx, c, "get_activity_details", map[string]any{"activity_id": activityID})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("get_activity_details call failed: %v", err)
|
||||||
|
}
|
||||||
|
var details map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(detailsRes), &details); err != nil {
|
||||||
|
fmt.Printf("get_activity_details() returned non-JSON: %s\n", detailsRes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if descriptors, ok := details["metricDescriptors"]; ok {
|
||||||
|
pretty, _ := json.MarshalIndent(descriptors, "", " ")
|
||||||
|
fmt.Printf("metricDescriptors:\n%s\n", pretty)
|
||||||
|
} else {
|
||||||
|
fmt.Println("no metricDescriptors key found")
|
||||||
|
}
|
||||||
|
if rows, ok := details["activityDetailMetrics"].([]any); ok && len(rows) > 0 {
|
||||||
|
sampleN := 5
|
||||||
|
if len(rows) < sampleN {
|
||||||
|
sampleN = len(rows)
|
||||||
|
}
|
||||||
|
pretty, _ := json.MarshalIndent(rows[:sampleN], "", " ")
|
||||||
|
fmt.Printf("activityDetailMetrics sample (first %d of %d rows):\n%s\n", sampleN, len(rows), pretty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func callTool(ctx context.Context, c *client.Client, name string, args map[string]any) (string, error) {
|
||||||
|
req := mcp.CallToolRequest{}
|
||||||
|
req.Params.Name = name
|
||||||
|
req.Params.Arguments = args
|
||||||
|
|
||||||
|
res, err := c.CallTool(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if res.IsError {
|
||||||
|
return "", fmt.Errorf("tool %s returned an error result", name)
|
||||||
|
}
|
||||||
|
var out string
|
||||||
|
for _, content := range res.Content {
|
||||||
|
if tc, ok := content.(mcp.TextContent); ok {
|
||||||
|
out += tc.Text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsMFAPrompt(s string) bool {
|
||||||
|
for _, needle := range []string{"MFA required", "MFA code", "complete_mfa"} {
|
||||||
|
if len(s) >= len(needle) {
|
||||||
|
for i := 0; i+len(needle) <= len(s); i++ {
|
||||||
|
if s[i:i+len(needle)] == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "...(truncated)"
|
||||||
|
}
|
||||||
221
backend/cmd/seedsample/main.go
Normal file
221
backend/cmd/seedsample/main.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// Command seedsample inserts synthetic activities, laps, and workout kinds
|
||||||
|
// directly into the SQLite database, then runs them through the real
|
||||||
|
// classification engine -- so the frontend (Dashboard/ReviewQueue/
|
||||||
|
// WorkoutKinds) can be visually verified with realistic data without a live
|
||||||
|
// Garmin account.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/classify"
|
||||||
|
"smartrun/backend/internal/garmin/mock"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
appsync "smartrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", "smartrun_sample.db", "path to the SQLite database to seed")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := store.Open(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Easy and Tempo's pace/HR ranges deliberately overlap a little (330-340
|
||||||
|
// sec/km, 0.70-0.85 HR%max) so a run that lands in that overlap zone
|
||||||
|
// demonstrates the ambiguous-multi-match review path, not just a gap
|
||||||
|
// between disjoint ranges.
|
||||||
|
easyID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||||
|
Name: "Easy", Description: "Easy conversational runs", Color: "#22c55e",
|
||||||
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[330,420]},
|
||||||
|
{"metric":"avg_hr_pct_max","op":"<=","value":0.85}
|
||||||
|
]}`,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
must(err)
|
||||||
|
|
||||||
|
tempoID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||||
|
Name: "Tempo", Description: "Comfortably hard sustained effort", Color: "#f59e0b",
|
||||||
|
RuleJSON: `{"match":"all","conditions":[
|
||||||
|
{"metric":"avg_pace_sec_per_km","op":"between","value":[300,340]},
|
||||||
|
{"metric":"avg_hr_pct_max","op":">=","value":0.70}
|
||||||
|
]}`,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
must(err)
|
||||||
|
|
||||||
|
_, err = db.CreateWorkoutKind(ctx, store.WorkoutKind{
|
||||||
|
Name: "Interval", Description: "Structured work/rest intervals", Color: "#ef4444",
|
||||||
|
RuleJSON: `{"match":"all","conditions":[{"metric":"lap_interval_pattern","op":"==","value":true}]}`,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
must(err)
|
||||||
|
|
||||||
|
m := &mock.Client{}
|
||||||
|
svc := appsync.NewService(m, db, appsync.Config{MinConfidence: 0.6, MaxHR: 190}, nil)
|
||||||
|
|
||||||
|
today := time.Now()
|
||||||
|
activityIDs := []int64{}
|
||||||
|
|
||||||
|
// 5 Easy runs, pace/HR trending slightly faster over time (progression).
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
start := today.AddDate(0, 0, -60+i*10)
|
||||||
|
speed := 1000.0 / (390 - float64(i)*5) // pace improving from 390 -> 370 sec/km
|
||||||
|
hr := 125.0 + float64(i) // pct-of-max stays comfortably under the 0.75 ceiling
|
||||||
|
id := seedActivity(ctx, db, seedParams{
|
||||||
|
garminID: 1000 + int64(i), name: "Easy morning run", start: start,
|
||||||
|
distance: 8000, duration: 8000 / (speed) * 1, speedMps: speed, avgHR: hr,
|
||||||
|
aerobicTE: 2.5, anaerobicTE: 0.3,
|
||||||
|
})
|
||||||
|
activityIDs = append(activityIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 Tempo runs.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
start := today.AddDate(0, 0, -45+i*15)
|
||||||
|
speed := 1000.0 / 320.0 // centered in Tempo's [300,340] range
|
||||||
|
id := seedActivity(ctx, db, seedParams{
|
||||||
|
garminID: 2000 + int64(i), name: "Tempo run", start: start,
|
||||||
|
distance: 6000, duration: 1800, speedMps: speed, avgHR: 168,
|
||||||
|
aerobicTE: 3.8, anaerobicTE: 1.2,
|
||||||
|
})
|
||||||
|
activityIDs = append(activityIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 Interval workout, with alternating ACTIVE/REST laps + HR samples
|
||||||
|
// showing drift on the work intervals and recovery on the rest ones.
|
||||||
|
{
|
||||||
|
id := seedActivity(ctx, db, seedParams{
|
||||||
|
garminID: 3000, name: "Track intervals", start: today.AddDate(0, 0, -5),
|
||||||
|
distance: 8000, duration: 2400, speedMps: 1000.0 / 240.0, avgHR: 165,
|
||||||
|
aerobicTE: 3.0, anaerobicTE: 3.5,
|
||||||
|
})
|
||||||
|
seedIntervalLapsAndSamples(ctx, db, id)
|
||||||
|
activityIDs = append(activityIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 ambiguous run: pace 335 sec/km sits inside both Easy's [330,420] and
|
||||||
|
// Tempo's [300,340] ranges, and HR% (0.78) satisfies both Easy's <=0.85
|
||||||
|
// and Tempo's >=0.70 -- so it should land in the review queue with two
|
||||||
|
// candidates, not a clean single match.
|
||||||
|
{
|
||||||
|
id := seedActivity(ctx, db, seedParams{
|
||||||
|
garminID: 4000, name: "Ambiguous run", start: today.AddDate(0, 0, -2),
|
||||||
|
distance: 7000, duration: 2200, speedMps: 1000.0 / 335.0, avgHR: 148,
|
||||||
|
aerobicTE: 3.0, anaerobicTE: 0.8,
|
||||||
|
})
|
||||||
|
activityIDs = append(activityIDs, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range activityIDs {
|
||||||
|
must(svc.ClassifyActivity(ctx, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Seeded %d activities (kinds: Easy=%d, Tempo=%d) into %s\n", len(activityIDs), easyID, tempoID, *dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
type seedParams struct {
|
||||||
|
garminID int64
|
||||||
|
name string
|
||||||
|
start time.Time
|
||||||
|
distance float64
|
||||||
|
duration float64
|
||||||
|
speedMps float64
|
||||||
|
avgHR float64
|
||||||
|
aerobicTE float64
|
||||||
|
anaerobicTE float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedActivity(ctx context.Context, db *store.DB, p seedParams) int64 {
|
||||||
|
speed := p.speedMps
|
||||||
|
hr := p.avgHR
|
||||||
|
aerobic := p.aerobicTE
|
||||||
|
anaerobic := p.anaerobicTE
|
||||||
|
id, err := db.UpsertActivity(ctx, store.Activity{
|
||||||
|
GarminActivityID: p.garminID,
|
||||||
|
ActivityName: p.name,
|
||||||
|
ActivityType: "running",
|
||||||
|
StartTimeUTC: p.start.Format("2006-01-02 15:04:05"),
|
||||||
|
BeginTimestampMs: p.start.UnixMilli(),
|
||||||
|
DurationSeconds: p.duration,
|
||||||
|
DistanceMeters: p.distance,
|
||||||
|
AvgSpeedMps: &speed,
|
||||||
|
AvgHR: &hr,
|
||||||
|
AerobicTrainingEffect: &aerobic,
|
||||||
|
AnaerobicTrainingEffect: &anaerobic,
|
||||||
|
RawJSON: "{}",
|
||||||
|
})
|
||||||
|
must(err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedIntervalLapsAndSamples gives one activity 6 alternating ACTIVE/REST
|
||||||
|
// laps plus per-second HR samples: rising HR within each ACTIVE lap (drift)
|
||||||
|
// and falling HR within each REST lap (recovery).
|
||||||
|
func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, activityID int64) {
|
||||||
|
var laps []store.Lap
|
||||||
|
var samples []store.Sample
|
||||||
|
elapsed := 0.0
|
||||||
|
baseHR := 140.0
|
||||||
|
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
isActive := i%2 == 0
|
||||||
|
lapDuration := 180.0
|
||||||
|
intensity := "REST"
|
||||||
|
if isActive {
|
||||||
|
intensity = "ACTIVE"
|
||||||
|
}
|
||||||
|
|
||||||
|
var lapSamples []classify.SampleInfo
|
||||||
|
for s := 0.0; s < lapDuration; s += 5 {
|
||||||
|
var hr float64
|
||||||
|
if isActive {
|
||||||
|
hr = baseHR + s/10 // rising through the work interval
|
||||||
|
} else {
|
||||||
|
hr = baseHR + 20 - s/8 // falling through the rest interval
|
||||||
|
}
|
||||||
|
samples = append(samples, store.Sample{
|
||||||
|
ElapsedSeconds: elapsed + s,
|
||||||
|
TimestampMs: int64((elapsed + s) * 1000),
|
||||||
|
HeartRate: &hr,
|
||||||
|
})
|
||||||
|
lapSamples = append(lapSamples, classify.SampleInfo{ElapsedSeconds: elapsed + s, HeartRate: &hr})
|
||||||
|
}
|
||||||
|
|
||||||
|
var drift, recovery *float64
|
||||||
|
if isActive {
|
||||||
|
if v, ok := classify.HRDrift(lapSamples); ok {
|
||||||
|
drift = &v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if v, ok := classify.HRRecovery(lapSamples); ok {
|
||||||
|
recovery = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
laps = append(laps, store.Lap{
|
||||||
|
LapIndex: i + 1, DurationSeconds: lapDuration,
|
||||||
|
DistanceMeters: 400, IntensityType: intensity, RawJSON: "{}",
|
||||||
|
HRDriftBpmPerMin: drift, HRRecoveryBpmPerMin: recovery,
|
||||||
|
})
|
||||||
|
elapsed += lapDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
must(db.ReplaceActivitySamples(ctx, activityID, samples))
|
||||||
|
must(db.ReplaceLaps(ctx, activityID, laps))
|
||||||
|
}
|
||||||
|
|
||||||
|
func must(err error) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/cmd/smartrund/main.go
Normal file
91
backend/cmd/smartrund/main.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// Command smartrund is smartrun's backend server: syncs runs from Garmin,
|
||||||
|
// classifies them into workout kinds, and serves the REST API the frontend
|
||||||
|
// talks to.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/api"
|
||||||
|
"smartrun/backend/internal/config"
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
appsync "smartrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := store.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
garminClient := garmin.NewClient(garmin.Config{
|
||||||
|
PythonPath: cfg.GarminPythonPath,
|
||||||
|
ServerPath: cfg.GarminServerPath,
|
||||||
|
GarminEmail: cfg.GarminEmail,
|
||||||
|
GarminPassword: cfg.GarminPassword,
|
||||||
|
TokenStorePath: cfg.GarminTokenStore,
|
||||||
|
})
|
||||||
|
defer garminClient.Close()
|
||||||
|
|
||||||
|
syncSvc := appsync.NewService(garminClient, db, appsync.Config{
|
||||||
|
BackfillHorizonDays: cfg.BackfillHorizonDays,
|
||||||
|
MinConfidence: cfg.MinConfidence,
|
||||||
|
MaxHR: cfg.MaxHR,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
server := api.NewServer(db, garminClient, syncSvc)
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
go runIncrementalSyncLoop(ctx, syncSvc, cfg.IncrementalSyncEvery)
|
||||||
|
|
||||||
|
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
|
||||||
|
go func() {
|
||||||
|
log.Printf("smartrund listening on %s", cfg.Addr)
|
||||||
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("http server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Println("shutting down...")
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := httpServer.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("http server shutdown: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runIncrementalSyncLoop periodically syncs new activities in the
|
||||||
|
// background so the frontend doesn't need to trigger every sync manually.
|
||||||
|
func runIncrementalSyncLoop(ctx context.Context, svc *appsync.Service, every time.Duration) {
|
||||||
|
ticker := time.NewTicker(every)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := svc.IncrementalSync(ctx); err != nil {
|
||||||
|
log.Printf("incremental sync: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := svc.FillPendingDetails(ctx, 50); err != nil {
|
||||||
|
log.Printf("fill pending details: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/go.mod
Normal file
24
backend/go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module smartrun/backend
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require github.com/mark3labs/mcp-go v0.56.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1 // indirect
|
||||||
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||||
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.53.0 // indirect
|
||||||
|
)
|
||||||
55
backend/go.sum
Normal file
55
backend/go.sum
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/mark3labs/mcp-go v0.56.0 h1:7aCj2wODCskMi08f923ADG+EfELZBdiKILny415cIS8=
|
||||||
|
github.com/mark3labs/mcp-go v0.56.0/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
67
backend/internal/api/activities.go
Normal file
67
backend/internal/api/activities.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
filter := store.ActivityFilter{
|
||||||
|
FromDate: q.Get("from"),
|
||||||
|
ToDate: q.Get("to"),
|
||||||
|
}
|
||||||
|
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
|
||||||
|
filter.Limit = limit
|
||||||
|
}
|
||||||
|
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
|
||||||
|
filter.Offset = offset
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := s.DB.ListActivities(r.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activity, ok, err := s.DB.GetActivity(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "activity not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
laps, err := s.DB.LapsForActivity(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{"activity": activity, "laps": laps}
|
||||||
|
if hasAssignment {
|
||||||
|
resp["assignment"] = assignment
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
187
backend/internal/api/api_test.go
Normal file
187
backend/internal/api/api_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/garmin/mock"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
appsync "smartrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newCtx() context.Context { return context.Background() }
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) (*Server, *store.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
m := &mock.Client{}
|
||||||
|
svc := appsync.NewService(m, db, appsync.Config{}, func() time.Time { return time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC) })
|
||||||
|
return NewServer(db, m, svc), db
|
||||||
|
}
|
||||||
|
|
||||||
|
func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var reader *bytes.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal request body: %v", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(b)
|
||||||
|
} else {
|
||||||
|
reader = bytes.NewReader(nil)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, path, reader)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealth(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/health", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkoutKindCRUD(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
router := s.Router()
|
||||||
|
|
||||||
|
createBody := map[string]any{
|
||||||
|
"name": "Tempo",
|
||||||
|
"rule": json.RawMessage(`{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[270,300]}]}`),
|
||||||
|
}
|
||||||
|
rec := doJSON(t, router, http.MethodPost, "/api/workout-kinds/", createBody)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var created store.WorkoutKind
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
|
||||||
|
t.Fatalf("unmarshal created kind: %v", err)
|
||||||
|
}
|
||||||
|
if created.ID == 0 {
|
||||||
|
t.Fatal("expected non-zero id")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var kinds []store.WorkoutKind
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||||||
|
if len(kinds) != 1 {
|
||||||
|
t.Fatalf("expected 1 kind, got %d", len(kinds))
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodDelete, "/api/workout-kinds/"+itoa(created.ID), nil)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/workout-kinds/", nil)
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &kinds)
|
||||||
|
if len(kinds) != 0 {
|
||||||
|
t.Fatalf("expected 0 active kinds after soft delete, got %d", len(kinds))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkoutKindCreate_RejectsInvalidRule(t *testing.T) {
|
||||||
|
s, _ := newTestServer(t)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/workout-kinds/", map[string]any{
|
||||||
|
"name": "Bad",
|
||||||
|
"rule": json.RawMessage(`{"match":"xor","conditions":[]}`),
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReviewQueueResolve(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
ctx := newCtx()
|
||||||
|
|
||||||
|
activityID, err := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
kindID, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||||
|
ActivityID: activityID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusNeedsReview,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := s.Router()
|
||||||
|
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("review queue status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var queue []map[string]any
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||||
|
if len(queue) != 1 {
|
||||||
|
t.Fatalf("expected 1 item in review queue, got %d: %s", len(queue), 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 {
|
||||||
|
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil)
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &queue)
|
||||||
|
if len(queue) != 0 {
|
||||||
|
t.Fatalf("expected empty review queue after resolve, got %d", len(queue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgression_ReturnsSortedTimeSeries(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
ctx := newCtx()
|
||||||
|
|
||||||
|
kindID, _ := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Easy", RuleJSON: `{}`, IsActive: true})
|
||||||
|
|
||||||
|
speed := 3.0
|
||||||
|
a1, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 1, ActivityType: "running", StartTimeUTC: "2026-07-01 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||||
|
a2, _ := db.UpsertActivity(ctx, store.Activity{GarminActivityID: 2, ActivityType: "running", StartTimeUTC: "2026-07-05 06:00:00", AvgSpeedMps: &speed, RawJSON: "{}"})
|
||||||
|
|
||||||
|
for _, id := range []int64{a2, a1} { // insert out of order on purpose
|
||||||
|
db.InsertKindAssignment(ctx, store.KindAssignment{ActivityID: id, WorkoutKindID: &kindID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/progression/"+itoa(kindID)+"?metric=pace", nil)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var points []progressionPoint
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &points); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if len(points) != 2 {
|
||||||
|
t.Fatalf("expected 2 points, got %d", len(points))
|
||||||
|
}
|
||||||
|
if points[0].Date > points[1].Date {
|
||||||
|
t.Errorf("points not sorted ascending by date: %+v", points)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(v int64) string {
|
||||||
|
return strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
72
backend/internal/api/auth.go
Normal file
72
backend/internal/api/auth.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type authResponse struct {
|
||||||
|
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func authStatusString(s garmin.AuthStatus) string {
|
||||||
|
switch s {
|
||||||
|
case garmin.AuthSuccess:
|
||||||
|
return "authenticated"
|
||||||
|
case garmin.AuthMFARequired:
|
||||||
|
return "mfa_required"
|
||||||
|
case garmin.AuthFailed:
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) recordAuthResult(res garmin.AuthResult) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.authStatus = res.Status
|
||||||
|
s.authMessage = res.Message
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
res, err := s.Garmin.Authenticate(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAuthResult(res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Code == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "code is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.Garmin.CompleteMFA(r.Context(), body.Code)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAuthResult(res)
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.mu.Lock()
|
||||||
|
status, msg := s.authStatus, s.authMessage
|
||||||
|
s.mu.Unlock()
|
||||||
|
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
|
||||||
|
}
|
||||||
192
backend/internal/api/kinds.go
Normal file
192
backend/internal/api/kinds.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/classify"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workoutKindRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
Rule json.RawMessage `json:"rule"`
|
||||||
|
Priority int `json:"priority"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (req workoutKindRequest) validate() (classify.Node, error) {
|
||||||
|
var node classify.Node
|
||||||
|
if req.Name == "" {
|
||||||
|
return node, errors.New("name is required")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(req.Rule, &node); err != nil {
|
||||||
|
return node, errors.New("rule is not valid JSON: " + err.Error())
|
||||||
|
}
|
||||||
|
if err := node.Validate(); err != nil {
|
||||||
|
return node, errors.New("invalid rule: " + err.Error())
|
||||||
|
}
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListWorkoutKinds(w http.ResponseWriter, r *http.Request) {
|
||||||
|
activeOnly := r.URL.Query().Get("include_inactive") != "true"
|
||||||
|
kinds, err := s.DB.ListWorkoutKinds(r.Context(), activeOnly)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, kinds)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "workout kind not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req workoutKindRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := req.validate(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive := true
|
||||||
|
if req.IsActive != nil {
|
||||||
|
isActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.DB.CreateWorkoutKind(r.Context(), store.WorkoutKind{
|
||||||
|
Name: req.Name, Description: req.Description, Color: req.Color,
|
||||||
|
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusCreated, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, ok, err := s.DB.GetWorkoutKind(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "workout kind not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req workoutKindRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := req.validate(); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive := existing.IsActive
|
||||||
|
if req.IsActive != nil {
|
||||||
|
isActive = *req.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.UpdateWorkoutKind(r.Context(), store.WorkoutKind{
|
||||||
|
ID: id, Name: req.Name, Description: req.Description, Color: req.Color,
|
||||||
|
RuleJSON: string(req.Rule), Priority: req.Priority, IsActive: isActive,
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind, _, _ := s.DB.GetWorkoutKind(r.Context(), id)
|
||||||
|
writeJSON(w, http.StatusOK, kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteWorkoutKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.DB.SoftDeleteWorkoutKind(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleReclassifyKind re-runs the rule engine for every activity currently
|
||||||
|
// assigned to (or under review for) this kind. It's a synchronous, bounded
|
||||||
|
// operation (unlike sync), so it runs inline rather than in the background.
|
||||||
|
func (s *Server) handleReclassifyKind(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
assignments, err := s.DB.AssignmentsForKind(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reviewQueue, err := s.DB.ReviewQueue(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int64]bool)
|
||||||
|
var activityIDs []int64
|
||||||
|
for _, a := range assignments {
|
||||||
|
if !seen[a.ActivityID] {
|
||||||
|
seen[a.ActivityID] = true
|
||||||
|
activityIDs = append(activityIDs, a.ActivityID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range reviewQueue {
|
||||||
|
if !seen[a.ActivityID] {
|
||||||
|
seen[a.ActivityID] = true
|
||||||
|
activityIDs = append(activityIDs, a.ActivityID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, activityID := range activityIDs {
|
||||||
|
if err := s.Sync.ClassifyActivity(r.Context(), activityID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]int{"reclassified": len(activityIDs)})
|
||||||
|
}
|
||||||
96
backend/internal/api/progression.go
Normal file
96
backend/internal/api/progression.go
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type progressionPoint struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
ActivityID int64 `json:"activity_id"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func metricValue(metric string, a store.Activity) (float64, bool) {
|
||||||
|
switch metric {
|
||||||
|
case "pace":
|
||||||
|
if a.AvgSpeedMps == nil || *a.AvgSpeedMps <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return 1000 / *a.AvgSpeedMps, true
|
||||||
|
case "hr":
|
||||||
|
if a.AvgHR == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *a.AvgHR, true
|
||||||
|
case "vo2max":
|
||||||
|
if a.VO2MaxValue == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *a.VO2MaxValue, true
|
||||||
|
case "aerobic_te":
|
||||||
|
if a.AerobicTrainingEffect == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *a.AerobicTrainingEffect, true
|
||||||
|
case "anaerobic_te":
|
||||||
|
if a.AnaerobicTrainingEffect == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return *a.AnaerobicTrainingEffect, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleProgression returns a time series of the requested metric for every
|
||||||
|
// activity currently assigned to a workout kind, for progression charts.
|
||||||
|
func (s *Server) handleProgression(w http.ResponseWriter, r *http.Request) {
|
||||||
|
kindID, err := strconv.ParseInt(chi.URLParam(r, "kindID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid workout kind id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
metric := r.URL.Query().Get("metric")
|
||||||
|
if metric == "" {
|
||||||
|
metric = "pace"
|
||||||
|
}
|
||||||
|
from, to := r.URL.Query().Get("from"), r.URL.Query().Get("to")
|
||||||
|
|
||||||
|
assignments, err := s.DB.AssignmentsForKind(r.Context(), kindID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
points := []progressionPoint{}
|
||||||
|
for _, a := range assignments {
|
||||||
|
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if from != "" && activity.StartTimeUTC < from {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if to != "" && activity.StartTimeUTC > to+" 23:59:59" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, ok := metricValue(metric, activity)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
points = append(points, progressionPoint{Date: activity.StartTimeUTC, ActivityID: activity.ID, Value: value})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(points, func(i, j int) bool { return points[i].Date < points[j].Date })
|
||||||
|
writeJSON(w, http.StatusOK, points)
|
||||||
|
}
|
||||||
78
backend/internal/api/review.go
Normal file
78
backend/internal/api/review.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
queue, err := s.DB.ReviewQueue(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type item struct {
|
||||||
|
store.KindAssignment
|
||||||
|
Activity store.Activity `json:"activity"`
|
||||||
|
}
|
||||||
|
items := make([]item, 0, len(queue))
|
||||||
|
for _, a := range queue {
|
||||||
|
activity, ok, err := s.DB.GetActivity(r.Context(), a.ActivityID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item{KindAssignment: a, Activity: activity})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
|
||||||
|
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid activity id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.WorkoutKindID == 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok, err := s.DB.GetWorkoutKind(r.Context(), body.WorkoutKindID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
} else if !ok {
|
||||||
|
writeError(w, http.StatusBadRequest, "workout kind not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kindID := body.WorkoutKindID
|
||||||
|
if _, err := s.DB.InsertKindAssignment(r.Context(), store.KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: &kindID,
|
||||||
|
AssignmentSource: store.AssignmentSourceManual,
|
||||||
|
Status: store.AssignmentStatusAssigned,
|
||||||
|
CandidateKindsJSON: "[]",
|
||||||
|
}); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
|
||||||
|
}
|
||||||
137
backend/internal/api/server.go
Normal file
137
backend/internal/api/server.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Package api is smartrun's HTTP layer: REST handlers over internal/store,
|
||||||
|
// internal/garmin, and internal/sync.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
appsync "smartrun/backend/internal/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server wires the HTTP handlers to the app's dependencies.
|
||||||
|
type Server struct {
|
||||||
|
DB *store.DB
|
||||||
|
Garmin garmin.Client
|
||||||
|
Sync *appsync.Service
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
authStatus garmin.AuthStatus
|
||||||
|
authMessage string
|
||||||
|
syncRunning bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer builds a Server.
|
||||||
|
func NewServer(db *store.DB, g garmin.Client, s *appsync.Service) *Server {
|
||||||
|
return &Server{DB: db, Garmin: g, Sync: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Router builds the HTTP routes.
|
||||||
|
func (s *Server) Router() http.Handler {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(corsMiddleware)
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Get("/health", s.handleHealth)
|
||||||
|
|
||||||
|
r.Route("/auth", func(r chi.Router) {
|
||||||
|
r.Post("/login", s.handleAuthLogin)
|
||||||
|
r.Post("/mfa", s.handleAuthMFA)
|
||||||
|
r.Get("/status", s.handleAuthStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/sync", func(r chi.Router) {
|
||||||
|
r.Post("/run", s.handleSyncRun)
|
||||||
|
r.Post("/backfill", s.handleSyncBackfill)
|
||||||
|
r.Get("/runs", s.handleSyncRuns)
|
||||||
|
r.Get("/status", s.handleSyncStatus)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/activities", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListActivities)
|
||||||
|
r.Get("/{id}", s.handleGetActivity)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/workout-kinds", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleListWorkoutKinds)
|
||||||
|
r.Post("/", s.handleCreateWorkoutKind)
|
||||||
|
r.Get("/{id}", s.handleGetWorkoutKind)
|
||||||
|
r.Put("/{id}", s.handleUpdateWorkoutKind)
|
||||||
|
r.Delete("/{id}", s.handleDeleteWorkoutKind)
|
||||||
|
r.Post("/{id}/reclassify", s.handleReclassifyKind)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Route("/review-queue", func(r chi.Router) {
|
||||||
|
r.Get("/", s.handleReviewQueue)
|
||||||
|
r.Post("/{activityID}/resolve", s.handleResolveReview)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/progression/{kindID}", s.handleProgression)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// corsMiddleware allows the frontend dev server (a different port) to call
|
||||||
|
// this API. Single-user local app, so reflecting any origin is fine --
|
||||||
|
// there's no session/cookie auth to protect against CSRF.
|
||||||
|
func corsMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if origin := r.Header.Get("Origin"); origin != "" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
log.Printf("api: encode response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// backgroundSync runs fn in a goroutine with a fresh context, guarded so
|
||||||
|
// only one sync operation runs at a time. Returns false if one is already
|
||||||
|
// in progress.
|
||||||
|
func (s *Server) backgroundSync(fn func(ctx context.Context) error) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.syncRunning {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.syncRunning = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.syncRunning = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
if err := fn(context.Background()); err != nil {
|
||||||
|
log.Printf("api: background sync error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
77
backend/internal/api/sync.go
Normal file
77
backend/internal/api/sync.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detailFillBatchSize bounds how many activities' details/splits are fetched
|
||||||
|
// per sync trigger, matching the sequential rate-limited fetch in
|
||||||
|
// internal/sync.Service.FillPendingDetails.
|
||||||
|
const detailFillBatchSize = 50
|
||||||
|
|
||||||
|
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||||
|
if err := s.Sync.IncrementalSync(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSyncBackfill(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ok := s.backgroundSync(func(ctx context.Context) error {
|
||||||
|
if err := s.Sync.Backfill(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.Sync.FillPendingDetails(ctx, detailFillBatchSize)
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusConflict, "a sync is already in progress")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
runs, err := s.DB.ListSyncRuns(r.Context(), 20)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, runs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, ok, err := s.DB.LatestSyncRun(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
inProgress := s.syncRunning
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
progress := s.Sync.Progress()
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"in_progress": inProgress,
|
||||||
|
"detail_fill_progress": progress,
|
||||||
|
"activities_pending_details": remaining,
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
resp["last_run"] = run
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
63
backend/internal/classify/engine.go
Normal file
63
backend/internal/classify/engine.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package classify
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusAssigned = "assigned"
|
||||||
|
StatusNeedsReview = "needs_review"
|
||||||
|
|
||||||
|
// DefaultMinConfidence is the score below which even a single matching
|
||||||
|
// kind is sent to manual review rather than auto-assigned.
|
||||||
|
DefaultMinConfidence = 0.6
|
||||||
|
)
|
||||||
|
|
||||||
|
// RuleKind is one active workout kind's rule, as loaded from the store.
|
||||||
|
type RuleKind struct {
|
||||||
|
WorkoutKindID int64
|
||||||
|
Name string
|
||||||
|
Rule Node
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScoredKind is one kind that matched an activity's metrics, with its
|
||||||
|
// confidence score.
|
||||||
|
type ScoredKind struct {
|
||||||
|
WorkoutKindID int64 `json:"workout_kind_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the outcome of classifying one activity.
|
||||||
|
type Result struct {
|
||||||
|
Status string
|
||||||
|
WorkoutKindID *int64
|
||||||
|
Confidence *float64
|
||||||
|
Candidates []ScoredKind // every kind that matched, sorted by score descending
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify evaluates every active kind's rule against ctx and decides
|
||||||
|
// whether the activity is cleanly assignable, or needs manual review
|
||||||
|
// because zero kinds matched, multiple kinds matched, or the single match's
|
||||||
|
// confidence fell below minConfidence.
|
||||||
|
func Classify(ctx MetricContext, kinds []RuleKind, minConfidence float64) Result {
|
||||||
|
candidates := []ScoredKind{}
|
||||||
|
for _, k := range kinds {
|
||||||
|
matched, score := k.Rule.Evaluate(ctx)
|
||||||
|
if matched {
|
||||||
|
candidates = append(candidates, ScoredKind{WorkoutKindID: k.WorkoutKindID, Name: k.Name, Score: score})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Score > candidates[j].Score })
|
||||||
|
|
||||||
|
if len(candidates) != 1 {
|
||||||
|
return Result{Status: StatusNeedsReview, Candidates: candidates}
|
||||||
|
}
|
||||||
|
|
||||||
|
only := candidates[0]
|
||||||
|
if only.Score < minConfidence {
|
||||||
|
return Result{Status: StatusNeedsReview, Candidates: candidates}
|
||||||
|
}
|
||||||
|
|
||||||
|
id := only.WorkoutKindID
|
||||||
|
score := only.Score
|
||||||
|
return Result{Status: StatusAssigned, WorkoutKindID: &id, Confidence: &score, Candidates: candidates}
|
||||||
|
}
|
||||||
190
backend/internal/classify/engine_test.go
Normal file
190
backend/internal/classify/engine_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package classify
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func easyRule() Node {
|
||||||
|
return Node{Match: MatchAll, Conditions: []Node{
|
||||||
|
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{330.0, 420.0}},
|
||||||
|
{Metric: "avg_hr_pct_max", Op: OpLte, Value: 0.75},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tempoRule() Node {
|
||||||
|
return Node{Match: MatchAll, Conditions: []Node{
|
||||||
|
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 330.0}},
|
||||||
|
{Metric: "avg_hr_pct_max", Op: OpGte, Value: 0.80},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKinds() []RuleKind {
|
||||||
|
return []RuleKind{
|
||||||
|
{WorkoutKindID: 1, Name: "Easy", Rule: easyRule()},
|
||||||
|
{WorkoutKindID: 2, Name: "Tempo", Rule: tempoRule()},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify_CleanSingleMatch(t *testing.T) {
|
||||||
|
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
|
||||||
|
result := Classify(ctx, testKinds(), DefaultMinConfidence)
|
||||||
|
|
||||||
|
if result.Status != StatusAssigned {
|
||||||
|
t.Fatalf("Status = %q, want %q", result.Status, StatusAssigned)
|
||||||
|
}
|
||||||
|
if result.WorkoutKindID == nil || *result.WorkoutKindID != 1 {
|
||||||
|
t.Fatalf("WorkoutKindID = %v, want 1 (Easy)", result.WorkoutKindID)
|
||||||
|
}
|
||||||
|
if result.Confidence == nil || *result.Confidence < DefaultMinConfidence {
|
||||||
|
t.Fatalf("Confidence = %v, want >= %v", result.Confidence, DefaultMinConfidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify_AmbiguousMultiMatch(t *testing.T) {
|
||||||
|
// Overlapping rule zone: a kind covering the same pace range as both
|
||||||
|
// Easy and Tempo, so a run in the overlap matches two kinds at once.
|
||||||
|
overlap := RuleKind{WorkoutKindID: 3, Name: "Overlap", Rule: Node{
|
||||||
|
Match: MatchAll, Conditions: []Node{
|
||||||
|
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{300.0, 400.0}},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
kinds := append(testKinds(), overlap)
|
||||||
|
|
||||||
|
ctx := MetricContext{"avg_pace_sec_per_km": 375, "avg_hr_pct_max": 0.65}
|
||||||
|
result := Classify(ctx, kinds, DefaultMinConfidence)
|
||||||
|
|
||||||
|
if result.Status != StatusNeedsReview {
|
||||||
|
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
|
||||||
|
}
|
||||||
|
if len(result.Candidates) < 2 {
|
||||||
|
t.Fatalf("expected >= 2 candidates for ambiguous match, got %d: %+v", len(result.Candidates), result.Candidates)
|
||||||
|
}
|
||||||
|
if result.WorkoutKindID != nil {
|
||||||
|
t.Fatalf("WorkoutKindID should be nil when needs_review, got %v", *result.WorkoutKindID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify_NoMatch(t *testing.T) {
|
||||||
|
// A very slow, low-HR run that fits neither Easy nor Tempo's pace range.
|
||||||
|
ctx := MetricContext{"avg_pace_sec_per_km": 600, "avg_hr_pct_max": 0.55}
|
||||||
|
result := Classify(ctx, testKinds(), DefaultMinConfidence)
|
||||||
|
|
||||||
|
if result.Status != StatusNeedsReview {
|
||||||
|
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
|
||||||
|
}
|
||||||
|
if len(result.Candidates) != 0 {
|
||||||
|
t.Fatalf("expected 0 candidates for no match, got %d: %+v", len(result.Candidates), result.Candidates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify_LowConfidenceSingleMatchNeedsReview(t *testing.T) {
|
||||||
|
// Right at the edge of Easy's pace window and right at the HR ceiling --
|
||||||
|
// technically matches but only barely, so confidence should be low.
|
||||||
|
ctx := MetricContext{"avg_pace_sec_per_km": 419, "avg_hr_pct_max": 0.75}
|
||||||
|
result := Classify(ctx, testKinds(), 0.9) // deliberately strict threshold
|
||||||
|
|
||||||
|
if result.Status != StatusNeedsReview {
|
||||||
|
t.Fatalf("Status = %q, want %q (low confidence should force review)", result.Status, StatusNeedsReview)
|
||||||
|
}
|
||||||
|
if len(result.Candidates) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 (low-confidence) candidate, got %d", len(result.Candidates))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassify_MissingMetricDoesNotMatch(t *testing.T) {
|
||||||
|
ctx := MetricContext{"avg_pace_sec_per_km": 375} // avg_hr_pct_max absent
|
||||||
|
result := Classify(ctx, testKinds(), DefaultMinConfidence)
|
||||||
|
|
||||||
|
if result.Status != StatusNeedsReview {
|
||||||
|
t.Fatalf("Status = %q, want %q", result.Status, StatusNeedsReview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectIntervalPattern(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
laps []LapInfo
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "clear interval workout: alternating active/rest",
|
||||||
|
laps: []LapInfo{
|
||||||
|
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
|
||||||
|
{IntensityType: "ACTIVE"}, {IntensityType: "REST"},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "long run with a single hill lap should not look like intervals",
|
||||||
|
laps: []LapInfo{
|
||||||
|
{IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"}, {IntensityType: "ACTIVE"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "warmup + single effort + cooldown is not repeated intervals",
|
||||||
|
laps: []LapInfo{
|
||||||
|
{IntensityType: "WARMUP"}, {IntensityType: "ACTIVE"}, {IntensityType: "COOLDOWN"},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := DetectIntervalPattern(c.laps)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("DetectIntervalPattern() = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hr(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
func TestHRDrift_RisingHeartRateDetected(t *testing.T) {
|
||||||
|
var samples []SampleInfo
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 10), HeartRate: hr(140 + float64(i))})
|
||||||
|
}
|
||||||
|
drift, ok := HRDrift(samples)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true with enough samples")
|
||||||
|
}
|
||||||
|
if drift <= 0 {
|
||||||
|
t.Errorf("drift = %v, want positive (rising HR)", drift)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHRDrift_TooFewSamplesIsNotOk(t *testing.T) {
|
||||||
|
samples := []SampleInfo{{ElapsedSeconds: 0, HeartRate: hr(140)}, {ElapsedSeconds: 10, HeartRate: hr(142)}}
|
||||||
|
_, ok := HRDrift(samples)
|
||||||
|
if ok {
|
||||||
|
t.Error("expected ok=false with too few samples")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHRRecovery_FallingHeartRateIsPositiveRecoveryRate(t *testing.T) {
|
||||||
|
var samples []SampleInfo
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(170 - float64(i)*2)})
|
||||||
|
}
|
||||||
|
recovery, ok := HRRecovery(samples)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true with enough samples")
|
||||||
|
}
|
||||||
|
if recovery <= 0 {
|
||||||
|
t.Errorf("recovery = %v, want positive (HR dropping)", recovery)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHRRecovery_StillRisingIsNegative(t *testing.T) {
|
||||||
|
var samples []SampleInfo
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
samples = append(samples, SampleInfo{ElapsedSeconds: float64(i * 5), HeartRate: hr(120 + float64(i))})
|
||||||
|
}
|
||||||
|
recovery, ok := HRRecovery(samples)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected ok=true with enough samples")
|
||||||
|
}
|
||||||
|
if recovery >= 0 {
|
||||||
|
t.Errorf("recovery = %v, want negative (HR still rising during recovery lap)", recovery)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/internal/classify/laps.go
Normal file
125
backend/internal/classify/laps.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package classify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LapInfo is the subset of a lap's fields needed for interval-pattern
|
||||||
|
// detection, independent of internal/store's row representation.
|
||||||
|
type LapInfo struct {
|
||||||
|
IntensityType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectIntervalPattern reports whether an activity's laps look like a
|
||||||
|
// structured interval workout, using Garmin's own per-lap IntensityType
|
||||||
|
// tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it
|
||||||
|
// from pace variance -- the device/app already knows which laps were work
|
||||||
|
// vs rest segments when the activity was recorded as a structured workout.
|
||||||
|
func DetectIntervalPattern(laps []LapInfo) bool {
|
||||||
|
active, rest := 0, 0
|
||||||
|
for _, l := range laps {
|
||||||
|
switch strings.ToUpper(l.IntensityType) {
|
||||||
|
case "ACTIVE":
|
||||||
|
active++
|
||||||
|
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
|
||||||
|
rest++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active >= 2 && rest >= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// LapPaceStdDev returns the standard deviation of per-lap pace (any
|
||||||
|
// consistent unit, e.g. sec/km), a fallback signal for "uneven pacing" on
|
||||||
|
// activities without formal interval tagging.
|
||||||
|
func LapPaceStdDev(paces []float64) float64 {
|
||||||
|
n := float64(len(paces))
|
||||||
|
if n == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
for _, p := range paces {
|
||||||
|
sum += p
|
||||||
|
}
|
||||||
|
mean := sum / n
|
||||||
|
var variance float64
|
||||||
|
for _, p := range paces {
|
||||||
|
variance += (p - mean) * (p - mean)
|
||||||
|
}
|
||||||
|
return math.Sqrt(variance / n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleInfo is one HR-bearing telemetry sample within a lap's time window.
|
||||||
|
type SampleInfo struct {
|
||||||
|
ElapsedSeconds float64
|
||||||
|
HeartRate *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// minSamplesForTrend is the fewest HR readings needed before a drift/recovery
|
||||||
|
// slope is considered meaningful rather than noise.
|
||||||
|
const minSamplesForTrend = 10
|
||||||
|
|
||||||
|
// HRTrend fits a line to heart rate vs elapsed time across samples and
|
||||||
|
// returns its slope in bpm/minute. Returns ok=false if there aren't enough
|
||||||
|
// HR readings to trust the result.
|
||||||
|
func HRTrend(samples []SampleInfo) (bpmPerMin float64, ok bool) {
|
||||||
|
var xs, ys []float64
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.HeartRate == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xs = append(xs, s.ElapsedSeconds)
|
||||||
|
ys = append(ys, *s.HeartRate)
|
||||||
|
}
|
||||||
|
if len(xs) < minSamplesForTrend {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
slope, ok := linregSlope(xs, ys)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return slope * 60, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HRDrift is HRTrend applied to an active/effort lap's samples: a positive
|
||||||
|
// result means heart rate is climbing over the interval (cardiac drift) for
|
||||||
|
// a comparable effort level.
|
||||||
|
func HRDrift(samples []SampleInfo) (bpmPerMin float64, ok bool) {
|
||||||
|
return HRTrend(samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HRRecovery is HRTrend applied to a recovery/rest lap's samples, sign-
|
||||||
|
// flipped so a positive result means heart rate is dropping (higher =
|
||||||
|
// better recovery) and a negative result flags heart rate still rising
|
||||||
|
// during what was supposed to be a rest interval.
|
||||||
|
func HRRecovery(samples []SampleInfo) (bpmDropPerMin float64, ok bool) {
|
||||||
|
slope, ok := HRTrend(samples)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return -slope, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func linregSlope(xs, ys []float64) (float64, bool) {
|
||||||
|
n := float64(len(xs))
|
||||||
|
if n < 2 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
var sumX, sumY float64
|
||||||
|
for i := range xs {
|
||||||
|
sumX += xs[i]
|
||||||
|
sumY += ys[i]
|
||||||
|
}
|
||||||
|
xbar, ybar := sumX/n, sumY/n
|
||||||
|
|
||||||
|
var num, den float64
|
||||||
|
for i := range xs {
|
||||||
|
dx := xs[i] - xbar
|
||||||
|
num += dx * (ys[i] - ybar)
|
||||||
|
den += dx * dx
|
||||||
|
}
|
||||||
|
if den == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return num / den, true
|
||||||
|
}
|
||||||
220
backend/internal/classify/rule.go
Normal file
220
backend/internal/classify/rule.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
// Package classify is smartrun's classification rule engine: it evaluates a
|
||||||
|
// user-editable AND/OR condition tree against a run's metrics to decide
|
||||||
|
// which "workout kind" (Easy, Tempo, Threshold, ...) it belongs to. Pure
|
||||||
|
// logic only -- no I/O, no database, no Garmin client -- so it's fully
|
||||||
|
// unit-testable against fixture data.
|
||||||
|
package classify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Node is one node of a workout kind's rule condition tree. A branch node
|
||||||
|
// sets Match ("all" or "any") and Conditions (children); a leaf node sets
|
||||||
|
// Metric, Op, and Value instead.
|
||||||
|
type Node struct {
|
||||||
|
Match string `json:"match,omitempty"`
|
||||||
|
Conditions []Node `json:"conditions,omitempty"`
|
||||||
|
|
||||||
|
Metric string `json:"metric,omitempty"`
|
||||||
|
Op string `json:"op,omitempty"`
|
||||||
|
Value any `json:"value,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
MatchAll = "all"
|
||||||
|
MatchAny = "any"
|
||||||
|
|
||||||
|
OpEq = "=="
|
||||||
|
OpNeq = "!="
|
||||||
|
OpGt = ">"
|
||||||
|
OpGte = ">="
|
||||||
|
OpLt = "<"
|
||||||
|
OpLte = "<="
|
||||||
|
OpBetween = "between"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate reports whether a rule tree is well-formed, without needing a
|
||||||
|
// MetricContext to evaluate against. Intended for the API layer to give
|
||||||
|
// immediate feedback when a user edits a workout kind's rule.
|
||||||
|
func (n Node) Validate() error {
|
||||||
|
if n.Match != "" {
|
||||||
|
if n.Match != MatchAll && n.Match != MatchAny {
|
||||||
|
return fmt.Errorf("invalid match %q, want %q or %q", n.Match, MatchAll, MatchAny)
|
||||||
|
}
|
||||||
|
if len(n.Conditions) == 0 {
|
||||||
|
return fmt.Errorf("branch node %q has no conditions", n.Match)
|
||||||
|
}
|
||||||
|
for i, c := range n.Conditions {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("condition %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.Metric == "" {
|
||||||
|
return fmt.Errorf("leaf node missing metric")
|
||||||
|
}
|
||||||
|
switch n.Op {
|
||||||
|
case OpEq, OpNeq, OpGt, OpGte, OpLt, OpLte:
|
||||||
|
if n.Value == nil {
|
||||||
|
return fmt.Errorf("metric %q: op %q requires a value", n.Metric, n.Op)
|
||||||
|
}
|
||||||
|
case OpBetween:
|
||||||
|
arr, ok := n.Value.([]any)
|
||||||
|
if !ok || len(arr) != 2 {
|
||||||
|
return fmt.Errorf("metric %q: op %q requires a 2-element array value", n.Metric, n.Op)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("metric %q: unsupported op %q", n.Metric, n.Op)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricContext is the set of computed metrics for one activity that a rule
|
||||||
|
// tree is evaluated against. Boolean metrics (e.g. has_interval_pattern) are
|
||||||
|
// represented as 1.0/0.0.
|
||||||
|
type MetricContext map[string]float64
|
||||||
|
|
||||||
|
// Evaluate recursively evaluates the tree against ctx, returning whether it
|
||||||
|
// matched and a confidence score. For branch nodes, "all" aggregates scores
|
||||||
|
// via min and requires every child matched; "any" aggregates via max and
|
||||||
|
// requires at least one child matched.
|
||||||
|
func (n Node) Evaluate(ctx MetricContext) (matched bool, score float64) {
|
||||||
|
if n.Match != "" {
|
||||||
|
switch n.Match {
|
||||||
|
case MatchAll:
|
||||||
|
matched = true
|
||||||
|
score = math.Inf(1)
|
||||||
|
for _, c := range n.Conditions {
|
||||||
|
m, s := c.Evaluate(ctx)
|
||||||
|
if !m {
|
||||||
|
matched = false
|
||||||
|
}
|
||||||
|
if s < score {
|
||||||
|
score = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case MatchAny:
|
||||||
|
matched = false
|
||||||
|
score = math.Inf(-1)
|
||||||
|
for _, c := range n.Conditions {
|
||||||
|
m, s := c.Evaluate(ctx)
|
||||||
|
if m {
|
||||||
|
matched = true
|
||||||
|
}
|
||||||
|
if s > score {
|
||||||
|
score = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
return matched, score
|
||||||
|
}
|
||||||
|
return evaluateLeaf(n, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateLeaf(n Node, ctx MetricContext) (matched bool, score float64) {
|
||||||
|
v, ok := ctx[n.Metric]
|
||||||
|
if !ok {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
switch n.Op {
|
||||||
|
case OpEq, OpNeq:
|
||||||
|
want, ok := toFloat(n.Value)
|
||||||
|
if !ok {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
eq := v == want
|
||||||
|
if n.Op == OpNeq {
|
||||||
|
eq = !eq
|
||||||
|
}
|
||||||
|
if eq {
|
||||||
|
return true, 1
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
|
||||||
|
case OpGt, OpGte, OpLt, OpLte:
|
||||||
|
threshold, ok := toFloat(n.Value)
|
||||||
|
if !ok {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
var margin float64
|
||||||
|
switch n.Op {
|
||||||
|
case OpGt:
|
||||||
|
matched = v > threshold
|
||||||
|
margin = v - threshold
|
||||||
|
case OpGte:
|
||||||
|
matched = v >= threshold
|
||||||
|
margin = v - threshold
|
||||||
|
case OpLt:
|
||||||
|
matched = v < threshold
|
||||||
|
margin = threshold - v
|
||||||
|
case OpLte:
|
||||||
|
matched = v <= threshold
|
||||||
|
margin = threshold - v
|
||||||
|
}
|
||||||
|
return matched, squash(margin, scaleFor(threshold))
|
||||||
|
|
||||||
|
case OpBetween:
|
||||||
|
arr, ok := n.Value.([]any)
|
||||||
|
if !ok || len(arr) != 2 {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
lo, ok1 := toFloat(arr[0])
|
||||||
|
hi, ok2 := toFloat(arr[1])
|
||||||
|
if !ok1 || !ok2 || lo > hi {
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
matched = v >= lo && v <= hi
|
||||||
|
mid := (lo + hi) / 2
|
||||||
|
halfRange := (hi - lo) / 2
|
||||||
|
if halfRange == 0 {
|
||||||
|
halfRange = 1
|
||||||
|
}
|
||||||
|
distance := math.Abs(v - mid)
|
||||||
|
score := 1 - distance/halfRange // 1.0 centered, 0 at boundary, negative outside
|
||||||
|
return matched, score
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toFloat(v any) (float64, bool) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return t, true
|
||||||
|
case bool:
|
||||||
|
if t {
|
||||||
|
return 1, true
|
||||||
|
}
|
||||||
|
return 0, true
|
||||||
|
case int:
|
||||||
|
return float64(t), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scaleFor picks a margin-to-score scaling factor proportional to the
|
||||||
|
// threshold's magnitude, so e.g. a 30-second margin on a ~300s threshold
|
||||||
|
// scores similarly to a 3-minute margin on a ~1800s threshold.
|
||||||
|
func scaleFor(threshold float64) float64 {
|
||||||
|
abs := math.Abs(threshold)
|
||||||
|
if abs < 1e-9 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 5 / abs
|
||||||
|
}
|
||||||
|
|
||||||
|
// squash maps a signed margin to a 0..1 score via a logistic curve: 0 margin
|
||||||
|
// (exactly at the threshold) scores 0.5, comfortably-matched margins
|
||||||
|
// approach 1, comfortably-unmatched margins approach 0.
|
||||||
|
func squash(margin, scale float64) float64 {
|
||||||
|
return 1 / (1 + math.Exp(-margin*scale))
|
||||||
|
}
|
||||||
61
backend/internal/classify/rule_test.go
Normal file
61
backend/internal/classify/rule_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package classify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNode_ValidateAcceptsWellFormedTree(t *testing.T) {
|
||||||
|
n := Node{Match: MatchAll, Conditions: []Node{
|
||||||
|
{Metric: "avg_pace_sec_per_km", Op: OpBetween, Value: []any{270.0, 300.0}},
|
||||||
|
{Match: MatchAny, Conditions: []Node{
|
||||||
|
{Metric: "aerobic_training_effect", Op: OpGte, Value: 3.5},
|
||||||
|
{Metric: "lap_interval_pattern", Op: OpEq, Value: true},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
if err := n.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate() = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNode_ValidateRejectsMalformed(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
n Node
|
||||||
|
}{
|
||||||
|
{"unknown match", Node{Match: "xor", Conditions: []Node{{Metric: "x", Op: OpGt, Value: 1.0}}}},
|
||||||
|
{"branch with no conditions", Node{Match: MatchAll}},
|
||||||
|
{"leaf with no metric", Node{Op: OpGt, Value: 1.0}},
|
||||||
|
{"between with non-array value", Node{Metric: "x", Op: OpBetween, Value: 5.0}},
|
||||||
|
{"between with wrong-length array", Node{Metric: "x", Op: OpBetween, Value: []any{1.0}}},
|
||||||
|
{"unsupported op", Node{Metric: "x", Op: "~=", Value: 1.0}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if err := c.n.Validate(); err == nil {
|
||||||
|
t.Error("Validate() = nil, want an error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNode_RoundTripsThroughJSON(t *testing.T) {
|
||||||
|
raw := `{
|
||||||
|
"match": "all",
|
||||||
|
"conditions": [
|
||||||
|
{"metric": "avg_pace_sec_per_km", "op": "between", "value": [270, 300]},
|
||||||
|
{"metric": "lap_interval_pattern", "op": "==", "value": true}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
var n Node
|
||||||
|
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||||
|
t.Fatalf("Unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if err := n.Validate(); err != nil {
|
||||||
|
t.Fatalf("Validate() after round-trip = %v", err)
|
||||||
|
}
|
||||||
|
matched, _ := n.Evaluate(MetricContext{"avg_pace_sec_per_km": 285, "lap_interval_pattern": 1})
|
||||||
|
if !matched {
|
||||||
|
t.Error("expected match after JSON round-trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/internal/config/config.go
Normal file
97
backend/internal/config/config.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Package config loads smartrund's runtime configuration from environment
|
||||||
|
// variables (a single-user local app has no need for a config file/flags
|
||||||
|
// beyond this).
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds everything smartrund needs to start.
|
||||||
|
type Config struct {
|
||||||
|
// Addr is the HTTP listen address, e.g. ":8080".
|
||||||
|
Addr string
|
||||||
|
// DBPath is the SQLite database file path.
|
||||||
|
DBPath string
|
||||||
|
|
||||||
|
// GarminPythonPath is mcp-garmin's venv python executable.
|
||||||
|
GarminPythonPath string
|
||||||
|
// GarminServerPath is mcp-garmin's server.py.
|
||||||
|
GarminServerPath string
|
||||||
|
GarminEmail string
|
||||||
|
GarminPassword string
|
||||||
|
// GarminTokenStore, if set, overrides mcp-garmin's default ~/.garth
|
||||||
|
// session cache location.
|
||||||
|
GarminTokenStore string
|
||||||
|
|
||||||
|
MaxHR float64
|
||||||
|
MinConfidence float64
|
||||||
|
BackfillHorizonDays int
|
||||||
|
IncrementalSyncEvery time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads configuration from environment variables, applying defaults
|
||||||
|
// for anything optional. Returns an error if a required variable is unset.
|
||||||
|
func Load() (Config, error) {
|
||||||
|
cfg := Config{
|
||||||
|
Addr: getEnvDefault("SMARTRUN_ADDR", ":8080"),
|
||||||
|
DBPath: getEnvDefault("SMARTRUN_DB_PATH", "smartrun.db"),
|
||||||
|
GarminPythonPath: os.Getenv("MCP_GARMIN_PYTHON"),
|
||||||
|
GarminServerPath: os.Getenv("MCP_GARMIN_SERVER"),
|
||||||
|
GarminEmail: os.Getenv("GARMIN_EMAIL"),
|
||||||
|
GarminPassword: os.Getenv("GARMIN_PASSWORD"),
|
||||||
|
GarminTokenStore: os.Getenv("GARMIN_TOKENSTORE"),
|
||||||
|
MaxHR: getEnvFloat("SMARTRUN_MAX_HR", 190),
|
||||||
|
MinConfidence: getEnvFloat("SMARTRUN_MIN_CONFIDENCE", 0.6),
|
||||||
|
BackfillHorizonDays: getEnvInt("SMARTRUN_BACKFILL_HORIZON_DAYS", 3*365),
|
||||||
|
IncrementalSyncEvery: getEnvDuration("SMARTRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.GarminPythonPath == "" {
|
||||||
|
return cfg, fmt.Errorf("MCP_GARMIN_PYTHON is required (path to mcp-garmin's venv python)")
|
||||||
|
}
|
||||||
|
if cfg.GarminServerPath == "" {
|
||||||
|
return cfg, fmt.Errorf("MCP_GARMIN_SERVER is required (path to mcp-garmin's server.py)")
|
||||||
|
}
|
||||||
|
if cfg.GarminEmail == "" || cfg.GarminPassword == "" {
|
||||||
|
return cfg, fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD are required")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvDefault(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvFloat(key string, def float64) float64 {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvDuration(key string, def time.Duration) time.Duration {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if d, err := time.ParseDuration(v); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
265
backend/internal/garmin/client.go
Normal file
265
backend/internal/garmin/client.go
Normal 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)"
|
||||||
|
}
|
||||||
22
backend/internal/garmin/client_test.go
Normal file
22
backend/internal/garmin/client_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend/internal/garmin/mock/mock.go
Normal file
76
backend/internal/garmin/mock/mock.go
Normal 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
|
||||||
|
}
|
||||||
157
backend/internal/garmin/types.go
Normal file
157
backend/internal/garmin/types.go
Normal 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
|
||||||
|
}
|
||||||
58
backend/internal/garmin/types_test.go
Normal file
58
backend/internal/garmin/types_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
260
backend/internal/store/activities.go
Normal file
260
backend/internal/store/activities.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Activity is smartrun's persisted view of a Garmin activity. Field mapping
|
||||||
|
// from the Garmin/MCP response happens in internal/sync, not here, so this
|
||||||
|
// package stays independent of internal/garmin.
|
||||||
|
type Activity struct {
|
||||||
|
ID int64
|
||||||
|
GarminActivityID int64
|
||||||
|
ActivityName string
|
||||||
|
ActivityType string
|
||||||
|
StartTimeUTC string
|
||||||
|
BeginTimestampMs int64
|
||||||
|
DurationSeconds float64
|
||||||
|
DistanceMeters float64
|
||||||
|
AvgHR *float64
|
||||||
|
MaxHR *float64
|
||||||
|
AvgSpeedMps *float64
|
||||||
|
MaxSpeedMps *float64
|
||||||
|
ElevationGainM *float64
|
||||||
|
ElevationLossM *float64
|
||||||
|
Calories *float64
|
||||||
|
LapCount int
|
||||||
|
AerobicTrainingEffect *float64
|
||||||
|
AnaerobicTrainingEffect *float64
|
||||||
|
TrainingEffectLabel string
|
||||||
|
VO2MaxValue *float64
|
||||||
|
HrTimeInZone1 *float64
|
||||||
|
HrTimeInZone2 *float64
|
||||||
|
HrTimeInZone3 *float64
|
||||||
|
HrTimeInZone4 *float64
|
||||||
|
HrTimeInZone5 *float64
|
||||||
|
RawJSON string
|
||||||
|
DetailsFetchedAt *string
|
||||||
|
DetailsRawJSON *string
|
||||||
|
SplitsFetchedAt *string
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertActivity inserts a new activity or updates the existing row for the
|
||||||
|
// same garmin_activity_id (idempotent, safe to call on every sync pass), and
|
||||||
|
// returns its internal id.
|
||||||
|
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, 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'))
|
||||||
|
ON CONFLICT(garmin_activity_id) DO UPDATE SET
|
||||||
|
activity_name=excluded.activity_name,
|
||||||
|
activity_type=excluded.activity_type,
|
||||||
|
start_time_utc=excluded.start_time_utc,
|
||||||
|
begin_timestamp_ms=excluded.begin_timestamp_ms,
|
||||||
|
duration_seconds=excluded.duration_seconds,
|
||||||
|
distance_meters=excluded.distance_meters,
|
||||||
|
avg_hr=excluded.avg_hr,
|
||||||
|
max_hr=excluded.max_hr,
|
||||||
|
avg_speed_mps=excluded.avg_speed_mps,
|
||||||
|
max_speed_mps=excluded.max_speed_mps,
|
||||||
|
elevation_gain_m=excluded.elevation_gain_m,
|
||||||
|
elevation_loss_m=excluded.elevation_loss_m,
|
||||||
|
calories=excluded.calories,
|
||||||
|
lap_count=excluded.lap_count,
|
||||||
|
aerobic_training_effect=excluded.aerobic_training_effect,
|
||||||
|
anaerobic_training_effect=excluded.anaerobic_training_effect,
|
||||||
|
training_effect_label=excluded.training_effect_label,
|
||||||
|
vo2max_value=excluded.vo2max_value,
|
||||||
|
hr_time_in_zone_1=excluded.hr_time_in_zone_1,
|
||||||
|
hr_time_in_zone_2=excluded.hr_time_in_zone_2,
|
||||||
|
hr_time_in_zone_3=excluded.hr_time_in_zone_3,
|
||||||
|
hr_time_in_zone_4=excluded.hr_time_in_zone_4,
|
||||||
|
hr_time_in_zone_5=excluded.hr_time_in_zone_5,
|
||||||
|
raw_json=excluded.raw_json,
|
||||||
|
updated_at=datetime('now')
|
||||||
|
`,
|
||||||
|
a.GarminActivityID, a.ActivityName, a.ActivityType, 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,
|
||||||
|
a.TrainingEffectLabel, a.VO2MaxValue,
|
||||||
|
a.HrTimeInZone1, a.HrTimeInZone2, a.HrTimeInZone3, a.HrTimeInZone4, a.HrTimeInZone5,
|
||||||
|
a.RawJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("upsert activity %d: %w", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT id FROM activities WHERE garmin_activity_id = ?`, a.GarminActivityID).Scan(&id); err != nil {
|
||||||
|
return 0, fmt.Errorf("fetch id for activity %d: %w", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
|
||||||
|
var a Activity
|
||||||
|
err := row.Scan(
|
||||||
|
&a.ID, &a.GarminActivityID, &a.ActivityName, &a.ActivityType, &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,
|
||||||
|
&a.TrainingEffectLabel, &a.VO2MaxValue,
|
||||||
|
&a.HrTimeInZone1, &a.HrTimeInZone2, &a.HrTimeInZone3, &a.HrTimeInZone4, &a.HrTimeInZone5,
|
||||||
|
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt,
|
||||||
|
&a.CreatedAt, &a.UpdatedAt,
|
||||||
|
)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityColumns = `
|
||||||
|
id, garmin_activity_id, activity_name, activity_type, 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, details_fetched_at, details_raw_json, splits_fetched_at,
|
||||||
|
created_at, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// GetActivity fetches one activity by its internal id.
|
||||||
|
func (db *DB) GetActivity(ctx context.Context, id int64) (Activity, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT `+activityColumns+` FROM activities WHERE id = ?`, id)
|
||||||
|
a, err := scanActivity(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return Activity{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return Activity{}, false, fmt.Errorf("get activity %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return a, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityFilter narrows ListActivities results. Zero values mean "no filter".
|
||||||
|
type ActivityFilter struct {
|
||||||
|
FromDate string // inclusive, "YYYY-MM-DD"
|
||||||
|
ToDate string // inclusive, "YYYY-MM-DD"
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActivities returns activities newest-first, optionally filtered by date range.
|
||||||
|
func (db *DB) ListActivities(ctx context.Context, f ActivityFilter) ([]Activity, error) {
|
||||||
|
query := `SELECT ` + activityColumns + ` FROM activities WHERE 1=1`
|
||||||
|
var args []any
|
||||||
|
if f.FromDate != "" {
|
||||||
|
query += ` AND start_time_utc >= ?`
|
||||||
|
args = append(args, f.FromDate)
|
||||||
|
}
|
||||||
|
if f.ToDate != "" {
|
||||||
|
query += ` AND start_time_utc <= ?`
|
||||||
|
args = append(args, f.ToDate+" 23:59:59")
|
||||||
|
}
|
||||||
|
query += ` ORDER BY start_time_utc DESC`
|
||||||
|
if f.Limit > 0 {
|
||||||
|
query += ` LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, f.Limit, f.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list activities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
activities := []Activity{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanActivity(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan activity row: %w", err)
|
||||||
|
}
|
||||||
|
activities = append(activities, a)
|
||||||
|
}
|
||||||
|
return activities, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestActivityStartTime returns the start_time_utc of the most recently
|
||||||
|
// started activity we have, used to compute the incremental sync window.
|
||||||
|
func (db *DB) LatestActivityStartTime(ctx context.Context) (string, bool, error) {
|
||||||
|
var t string
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT start_time_utc FROM activities ORDER BY start_time_utc DESC LIMIT 1`).Scan(&t)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("latest activity start time: %w", err)
|
||||||
|
}
|
||||||
|
return t, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActivityDetails records that get_activity_details has been fetched for
|
||||||
|
// this activity, storing the raw response for future reprocessing.
|
||||||
|
func (db *DB) SetActivityDetails(ctx context.Context, activityID int64, rawJSON string) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE activities SET details_fetched_at = datetime('now'), details_raw_json = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`, rawJSON, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set activity %d details: %w", activityID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetActivitySplitsFetched records that get_activity_splits has been fetched
|
||||||
|
// for this activity.
|
||||||
|
func (db *DB) SetActivitySplitsFetched(ctx context.Context, activityID int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE activities SET splits_fetched_at = datetime('now'), updated_at = datetime('now')
|
||||||
|
WHERE id = ?`, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("set activity %d splits fetched: %w", activityID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivitiesMissingDetails returns activities that haven't had
|
||||||
|
// get_activity_details/get_activity_splits fetched yet, for the lazy
|
||||||
|
// background detail-fill pass.
|
||||||
|
func (db *DB) ActivitiesMissingDetails(ctx context.Context, limit int) ([]Activity, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
|
||||||
|
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL
|
||||||
|
ORDER BY start_time_utc DESC LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list activities missing details: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
activities := []Activity{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanActivity(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan activity row: %w", err)
|
||||||
|
}
|
||||||
|
activities = append(activities, a)
|
||||||
|
}
|
||||||
|
return activities, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountActivitiesMissingDetails returns how many activities still need
|
||||||
|
// get_activity_details/get_activity_splits fetched, regardless of any
|
||||||
|
// per-call batch limit -- used to report overall remaining work.
|
||||||
|
func (db *DB) CountActivitiesMissingDetails(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
|
||||||
|
WHERE details_fetched_at IS NULL OR splits_fetched_at IS NULL`).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("count activities missing details: %w", err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
108
backend/internal/store/assignments.go
Normal file
108
backend/internal/store/assignments.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AssignmentSourceRuleEngine = "rule_engine"
|
||||||
|
AssignmentSourceManual = "manual"
|
||||||
|
|
||||||
|
AssignmentStatusAssigned = "assigned"
|
||||||
|
AssignmentStatusNeedsReview = "needs_review"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KindAssignment is one append-only classification decision for an
|
||||||
|
// activity. New assignments (rule re-run or manual override) are always
|
||||||
|
// inserted, never updated, so the full history survives.
|
||||||
|
type KindAssignment struct {
|
||||||
|
ID int64
|
||||||
|
ActivityID int64
|
||||||
|
WorkoutKindID *int64
|
||||||
|
AssignmentSource string
|
||||||
|
Status string
|
||||||
|
Confidence *float64
|
||||||
|
CandidateKindsJSON string
|
||||||
|
CreatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertKindAssignment appends a new assignment row for an activity.
|
||||||
|
func (db *DB) InsertKindAssignment(ctx context.Context, a KindAssignment) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO kind_assignments (activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json)
|
||||||
|
VALUES (?,?,?,?,?,?)`,
|
||||||
|
a.ActivityID, a.WorkoutKindID, a.AssignmentSource, a.Status, a.Confidence, a.CandidateKindsJSON)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("insert kind assignment for activity %d: %w", a.ActivityID, err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanKindAssignment(row interface{ Scan(...any) error }) (KindAssignment, error) {
|
||||||
|
var a KindAssignment
|
||||||
|
err := row.Scan(&a.ID, &a.ActivityID, &a.WorkoutKindID, &a.AssignmentSource, &a.Status, &a.Confidence, &a.CandidateKindsJSON, &a.CreatedAt)
|
||||||
|
return a, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const kindAssignmentColumns = `id, activity_id, workout_kind_id, assignment_source, status, confidence, candidate_kinds_json, created_at`
|
||||||
|
|
||||||
|
// CurrentAssignment returns the latest assignment for an activity, if any.
|
||||||
|
func (db *DB) CurrentAssignment(ctx context.Context, activityID int64) (KindAssignment, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT `+kindAssignmentColumns+` FROM current_kind_assignment WHERE activity_id = ?`, activityID)
|
||||||
|
a, err := scanKindAssignment(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return KindAssignment{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return KindAssignment{}, false, fmt.Errorf("current assignment for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
return a, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewQueue returns activities whose current assignment status is
|
||||||
|
// needs_review, newest first.
|
||||||
|
func (db *DB) ReviewQueue(ctx context.Context) ([]KindAssignment, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
||||||
|
WHERE status = ? ORDER BY created_at DESC`, AssignmentStatusNeedsReview)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("review queue: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
assignments := []KindAssignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanKindAssignment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||||
|
}
|
||||||
|
assignments = append(assignments, a)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignmentsForKind returns every historical assignment where the given
|
||||||
|
// workout kind was the resolved kind (regardless of source), oldest first --
|
||||||
|
// the basis for progression-over-time charts.
|
||||||
|
func (db *DB) AssignmentsForKind(ctx context.Context, workoutKindID int64) ([]KindAssignment, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT `+kindAssignmentColumns+` FROM current_kind_assignment
|
||||||
|
WHERE workout_kind_id = ? AND status = ? ORDER BY created_at ASC`,
|
||||||
|
workoutKindID, AssignmentStatusAssigned)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assignments for kind %d: %w", workoutKindID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
assignments := []KindAssignment{}
|
||||||
|
for rows.Next() {
|
||||||
|
a, err := scanKindAssignment(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan kind assignment row: %w", err)
|
||||||
|
}
|
||||||
|
assignments = append(assignments, a)
|
||||||
|
}
|
||||||
|
return assignments, rows.Err()
|
||||||
|
}
|
||||||
98
backend/internal/store/db.go
Normal file
98
backend/internal/store/db.go
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// Package store is smartrun's SQLite persistence layer: activities, laps,
|
||||||
|
// per-second samples, workout kind rule config, and classification history.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/*.sql
|
||||||
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// DB wraps a *sql.DB opened against a smartrun SQLite database file, with
|
||||||
|
// migrations already applied.
|
||||||
|
type DB struct {
|
||||||
|
*sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens (creating if needed) the SQLite database at path and applies
|
||||||
|
// any migrations that haven't run yet.
|
||||||
|
func Open(path string) (*DB, error) {
|
||||||
|
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite database: %w", err)
|
||||||
|
}
|
||||||
|
// SQLite only supports one writer at a time; a single connection avoids
|
||||||
|
// "database is locked" errors under any concurrent access from the app.
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
db := &DB{DB: sqlDB}
|
||||||
|
if err := db.migrate(); err != nil {
|
||||||
|
sqlDB.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) migrate() error {
|
||||||
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename TEXT PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create schema_migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
applied := make(map[string]bool)
|
||||||
|
rows, err := db.Query(`SELECT filename FROM schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("query applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
if err := rows.Scan(&name); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return fmt.Errorf("scan applied migration: %w", err)
|
||||||
|
}
|
||||||
|
applied[name] = true
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
entries, err := fs.Glob(migrationsFS, "migrations/*.sql")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("glob migrations: %w", err)
|
||||||
|
}
|
||||||
|
sort.Strings(entries)
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry[len("migrations/"):]
|
||||||
|
if applied[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content, err := migrationsFS.ReadFile(entry)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration tx for %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(string(content)); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("apply migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`INSERT INTO schema_migrations (filename) VALUES (?)`, name); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
85
backend/internal/store/laps.go
Normal file
85
backend/internal/store/laps.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Lap is one lap/split of an activity, from get_activity_splits, plus
|
||||||
|
// derived HR drift/recovery metrics computed from activity_samples.
|
||||||
|
type Lap struct {
|
||||||
|
ID int64
|
||||||
|
ActivityID int64
|
||||||
|
LapIndex int
|
||||||
|
StartTimeUTC string
|
||||||
|
DurationSeconds float64
|
||||||
|
DistanceMeters float64
|
||||||
|
AvgHR *float64
|
||||||
|
MaxHR *float64
|
||||||
|
AvgSpeedMps *float64
|
||||||
|
MaxSpeedMps *float64
|
||||||
|
ElevationGainM *float64
|
||||||
|
ElevationLossM *float64
|
||||||
|
IntensityType string
|
||||||
|
HRDriftBpmPerMin *float64
|
||||||
|
HRRecoveryBpmPerMin *float64
|
||||||
|
RawJSON string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceLaps deletes any existing laps for activityID and inserts the given
|
||||||
|
// set, so re-syncing an activity's splits is idempotent.
|
||||||
|
func (db *DB) ReplaceLaps(ctx context.Context, activityID int64, laps []Lap) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin replace laps tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil {
|
||||||
|
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range laps {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert lap %d for activity %d: %w", l.LapIndex, activityID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LapsForActivity returns all laps for an activity, ordered by lap_index.
|
||||||
|
func (db *DB) LapsForActivity(ctx context.Context, activityID int64) ([]Lap, error) {
|
||||||
|
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
|
||||||
|
FROM laps WHERE activity_id = ? ORDER BY lap_index`, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("laps for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
laps := []Lap{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l Lap
|
||||||
|
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,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan lap row: %w", err)
|
||||||
|
}
|
||||||
|
laps = append(laps, l)
|
||||||
|
}
|
||||||
|
return laps, rows.Err()
|
||||||
|
}
|
||||||
109
backend/internal/store/migrations/0001_init.sql
Normal file
109
backend/internal/store/migrations/0001_init.sql
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
CREATE TABLE activities (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
garmin_activity_id INTEGER NOT NULL UNIQUE,
|
||||||
|
activity_name TEXT NOT NULL DEFAULT '',
|
||||||
|
activity_type TEXT NOT NULL,
|
||||||
|
start_time_utc TEXT NOT NULL,
|
||||||
|
begin_timestamp_ms INTEGER NOT NULL,
|
||||||
|
duration_seconds REAL NOT NULL,
|
||||||
|
distance_meters REAL NOT NULL,
|
||||||
|
avg_hr REAL,
|
||||||
|
max_hr REAL,
|
||||||
|
avg_speed_mps REAL,
|
||||||
|
max_speed_mps REAL,
|
||||||
|
elevation_gain_m REAL,
|
||||||
|
elevation_loss_m REAL,
|
||||||
|
calories REAL,
|
||||||
|
lap_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
aerobic_training_effect REAL,
|
||||||
|
anaerobic_training_effect REAL,
|
||||||
|
training_effect_label TEXT NOT NULL DEFAULT '',
|
||||||
|
vo2max_value REAL,
|
||||||
|
hr_time_in_zone_1 REAL,
|
||||||
|
hr_time_in_zone_2 REAL,
|
||||||
|
hr_time_in_zone_3 REAL,
|
||||||
|
hr_time_in_zone_4 REAL,
|
||||||
|
hr_time_in_zone_5 REAL,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
details_fetched_at TEXT,
|
||||||
|
details_raw_json TEXT,
|
||||||
|
splits_fetched_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
|
||||||
|
CREATE INDEX idx_activities_activity_type ON activities(activity_type);
|
||||||
|
|
||||||
|
CREATE TABLE laps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
lap_index INTEGER NOT NULL,
|
||||||
|
start_time_utc TEXT NOT NULL,
|
||||||
|
duration_seconds REAL NOT NULL,
|
||||||
|
distance_meters REAL NOT NULL,
|
||||||
|
avg_hr REAL,
|
||||||
|
max_hr REAL,
|
||||||
|
avg_speed_mps REAL,
|
||||||
|
max_speed_mps REAL,
|
||||||
|
elevation_gain_m REAL,
|
||||||
|
elevation_loss_m REAL,
|
||||||
|
intensity_type TEXT NOT NULL DEFAULT '',
|
||||||
|
hr_drift_bpm_per_min REAL,
|
||||||
|
hr_recovery_bpm_per_min REAL,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
UNIQUE(activity_id, lap_index)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE activity_samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
elapsed_seconds REAL NOT NULL,
|
||||||
|
timestamp_ms INTEGER NOT NULL,
|
||||||
|
heart_rate REAL,
|
||||||
|
speed_mps REAL,
|
||||||
|
distance_m REAL,
|
||||||
|
elevation_m REAL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_activity_samples_activity ON activity_samples(activity_id, elapsed_seconds);
|
||||||
|
|
||||||
|
CREATE TABLE workout_kinds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
color TEXT NOT NULL DEFAULT '',
|
||||||
|
rule_json TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE kind_assignments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
workout_kind_id INTEGER REFERENCES workout_kinds(id),
|
||||||
|
assignment_source TEXT NOT NULL CHECK(assignment_source IN ('rule_engine','manual')),
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('assigned','needs_review')),
|
||||||
|
confidence REAL,
|
||||||
|
candidate_kinds_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_kind_assignments_activity ON kind_assignments(activity_id);
|
||||||
|
CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
|
||||||
|
|
||||||
|
CREATE VIEW current_kind_assignment AS
|
||||||
|
SELECT a.* FROM kind_assignments a
|
||||||
|
JOIN (
|
||||||
|
SELECT activity_id, MAX(id) AS max_id
|
||||||
|
FROM kind_assignments GROUP BY activity_id
|
||||||
|
) latest ON a.activity_id = latest.activity_id AND a.id = latest.max_id;
|
||||||
|
|
||||||
|
CREATE TABLE sync_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental')),
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
finished_at TEXT,
|
||||||
|
activities_fetched INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('running','success','error')),
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
11
backend/internal/store/migrations/0002_sync_state.sql
Normal file
11
backend/internal/store/migrations/0002_sync_state.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- Tracks how far back a full backfill has already reached. Garmin activity
|
||||||
|
-- history is immutable once recorded, so once we've backfilled a historical
|
||||||
|
-- window there is no need to ever re-fetch get_activities() for it again --
|
||||||
|
-- this is the "cache" that lets repeated backfills be cheap/fast instead of
|
||||||
|
-- re-walking years of history against Garmin's API every time.
|
||||||
|
CREATE TABLE sync_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
earliest_synced_date TEXT,
|
||||||
|
backfill_complete INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO sync_state (id, earliest_synced_date, backfill_complete) VALUES (1, NULL, 0);
|
||||||
66
backend/internal/store/samples.go
Normal file
66
backend/internal/store/samples.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sample is one ~1-second telemetry reading for an activity.
|
||||||
|
type Sample struct {
|
||||||
|
ElapsedSeconds float64
|
||||||
|
TimestampMs int64
|
||||||
|
HeartRate *float64
|
||||||
|
SpeedMps *float64
|
||||||
|
DistanceM *float64
|
||||||
|
ElevationM *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceActivitySamples deletes any existing samples for activityID and
|
||||||
|
// bulk-inserts the given set, so re-syncing an activity's details is idempotent.
|
||||||
|
func (db *DB) ReplaceActivitySamples(ctx context.Context, activityID int64, samples []Sample) error {
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin replace samples tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM activity_samples WHERE activity_id = ?`, activityID); err != nil {
|
||||||
|
return fmt.Errorf("delete existing samples for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO activity_samples (activity_id, elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m)
|
||||||
|
VALUES (?,?,?,?,?,?,?)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare insert sample: %w", err)
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, s := range samples {
|
||||||
|
if _, err := stmt.ExecContext(ctx, activityID, s.ElapsedSeconds, s.TimestampMs, s.HeartRate, s.SpeedMps, s.DistanceM, s.ElevationM); err != nil {
|
||||||
|
return fmt.Errorf("insert sample for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SamplesForActivity returns all samples for an activity, ordered by elapsed_seconds.
|
||||||
|
func (db *DB) SamplesForActivity(ctx context.Context, activityID int64) ([]Sample, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT elapsed_seconds, timestamp_ms, heart_rate, speed_mps, distance_m, elevation_m
|
||||||
|
FROM activity_samples WHERE activity_id = ? ORDER BY elapsed_seconds`, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("samples for activity %d: %w", activityID, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
samples := []Sample{}
|
||||||
|
for rows.Next() {
|
||||||
|
var s Sample
|
||||||
|
if err := rows.Scan(&s.ElapsedSeconds, &s.TimestampMs, &s.HeartRate, &s.SpeedMps, &s.DistanceM, &s.ElevationM); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan sample row: %w", err)
|
||||||
|
}
|
||||||
|
samples = append(samples, s)
|
||||||
|
}
|
||||||
|
return samples, rows.Err()
|
||||||
|
}
|
||||||
202
backend/internal/store/store_test.go
Normal file
202
backend/internal/store/store_test.go
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *DB {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "smartrun_test.db")
|
||||||
|
db, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func f(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
func TestMigrateIsIdempotent(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "smartrun_test.db")
|
||||||
|
db1, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Open: %v", err)
|
||||||
|
}
|
||||||
|
db1.Close()
|
||||||
|
|
||||||
|
db2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second Open (re-applying migrations): %v", err)
|
||||||
|
}
|
||||||
|
db2.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpsertActivity_InsertThenUpdateIsIdempotent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
a := Activity{
|
||||||
|
GarminActivityID: 23554066504,
|
||||||
|
ActivityName: "Auriol - W2-5-Base Endurance",
|
||||||
|
ActivityType: "running",
|
||||||
|
StartTimeUTC: "2026-07-11 05:02:35",
|
||||||
|
BeginTimestampMs: 1783746155000,
|
||||||
|
DurationSeconds: 1800,
|
||||||
|
DistanceMeters: 6858,
|
||||||
|
AvgHR: f(148),
|
||||||
|
RawJSON: `{"activityId":23554066504}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := db.UpsertActivity(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (insert): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.AvgHR = f(150) // simulate a re-sync with a corrected value
|
||||||
|
id2, err := db.UpsertActivity(ctx, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity (update): %v", err)
|
||||||
|
}
|
||||||
|
if id != id2 {
|
||||||
|
t.Fatalf("expected same internal id across upserts, got %d then %d", id, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok, err := db.GetActivity(ctx, id)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("GetActivity: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if *got.AvgHR != 150 {
|
||||||
|
t.Errorf("AvgHR = %v, want 150 (update should have applied)", *got.AvgHR)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := db.ListActivities(ctx, ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 activity after upsert-update, got %d", len(all))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||||
|
GarminActivityID: 1,
|
||||||
|
ActivityType: "running",
|
||||||
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
|
RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kindID, err := db.CreateWorkoutKind(ctx, WorkoutKind{
|
||||||
|
Name: "Tempo",
|
||||||
|
RuleJSON: `{"match":"all","conditions":[]}`,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorkoutKind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First: rule engine says needs_review (ambiguous).
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
AssignmentSource: AssignmentSourceRuleEngine,
|
||||||
|
Status: AssignmentStatusNeedsReview,
|
||||||
|
CandidateKindsJSON: `[{"kind_id":1,"score":0.5}]`,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment (rule engine): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queue, err := db.ReviewQueue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReviewQueue: %v", err)
|
||||||
|
}
|
||||||
|
if len(queue) != 1 {
|
||||||
|
t.Fatalf("expected 1 item in review queue, got %d", len(queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then: user manually resolves it.
|
||||||
|
if _, err := db.InsertKindAssignment(ctx, KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: &kindID,
|
||||||
|
AssignmentSource: AssignmentSourceManual,
|
||||||
|
Status: AssignmentStatusAssigned,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertKindAssignment (manual): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
queue, err = db.ReviewQueue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReviewQueue after resolve: %v", err)
|
||||||
|
}
|
||||||
|
if len(queue) != 0 {
|
||||||
|
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
|
||||||
|
}
|
||||||
|
|
||||||
|
current, ok, err := db.CurrentAssignment(ctx, activityID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if current.AssignmentSource != AssignmentSourceManual || current.Status != AssignmentStatusAssigned {
|
||||||
|
t.Errorf("current assignment = %+v, want manual/assigned", current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The original rule-engine assignment must still exist (append-only history).
|
||||||
|
var historyCount int
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID).Scan(&historyCount); err != nil {
|
||||||
|
t.Fatalf("count history: %v", err)
|
||||||
|
}
|
||||||
|
if historyCount != 2 {
|
||||||
|
t.Errorf("expected 2 historical assignment rows (rule engine + manual), got %d", historyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
forKind, err := db.AssignmentsForKind(ctx, kindID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AssignmentsForKind: %v", err)
|
||||||
|
}
|
||||||
|
if len(forKind) != 1 {
|
||||||
|
t.Fatalf("expected 1 assigned activity for kind %d, got %d", kindID, len(forKind))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReplaceLapsIsIdempotent(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
activityID, err := db.UpsertActivity(ctx, Activity{
|
||||||
|
GarminActivityID: 2,
|
||||||
|
ActivityType: "running",
|
||||||
|
StartTimeUTC: "2026-07-11 05:00:00",
|
||||||
|
RawJSON: "{}",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpsertActivity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
laps := []Lap{
|
||||||
|
{LapIndex: 1, StartTimeUTC: "2026-07-11 05:00:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(140), RawJSON: "{}"},
|
||||||
|
{LapIndex: 2, StartTimeUTC: "2026-07-11 05:05:00", DurationSeconds: 300, DistanceMeters: 1000, AvgHR: f(150), RawJSON: "{}"},
|
||||||
|
}
|
||||||
|
if err := db.ReplaceLaps(ctx, activityID, laps); err != nil {
|
||||||
|
t.Fatalf("ReplaceLaps (first): %v", err)
|
||||||
|
}
|
||||||
|
// Re-sync with a different set (e.g. corrected data) should fully replace, not append.
|
||||||
|
if err := db.ReplaceLaps(ctx, activityID, laps[:1]); err != nil {
|
||||||
|
t.Fatalf("ReplaceLaps (second): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := db.LapsForActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LapsForActivity: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("expected 1 lap after replace, got %d", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/internal/store/syncruns.go
Normal file
91
backend/internal/store/syncruns.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncKindBackfill = "backfill"
|
||||||
|
SyncKindIncremental = "incremental"
|
||||||
|
|
||||||
|
SyncStatusRunning = "running"
|
||||||
|
SyncStatusSuccess = "success"
|
||||||
|
SyncStatusError = "error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncRun records one backfill or incremental sync attempt.
|
||||||
|
type SyncRun struct {
|
||||||
|
ID int64
|
||||||
|
Kind string
|
||||||
|
StartedAt string
|
||||||
|
FinishedAt *string
|
||||||
|
ActivitiesFetched int
|
||||||
|
Status string
|
||||||
|
ErrorMessage *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSyncRun records a new in-progress sync run and returns its id.
|
||||||
|
func (db *DB) StartSyncRun(ctx context.Context, kind string) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO sync_runs (kind, started_at, status) VALUES (?, datetime('now'), ?)`,
|
||||||
|
kind, SyncStatusRunning)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("start sync run: %w", err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinishSyncRun marks a sync run as finished, recording how many activities
|
||||||
|
// were fetched and whether it succeeded.
|
||||||
|
func (db *DB) FinishSyncRun(ctx context.Context, id int64, activitiesFetched int, errMsg *string) error {
|
||||||
|
status := SyncStatusSuccess
|
||||||
|
if errMsg != nil {
|
||||||
|
status = SyncStatusError
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE sync_runs SET finished_at = datetime('now'), activities_fetched = ?, status = ?, error_message = ?
|
||||||
|
WHERE id = ?`, activitiesFetched, status, errMsg, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("finish sync run %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatestSyncRun returns the most recent sync run, if any.
|
||||||
|
func (db *DB) LatestSyncRun(ctx context.Context) (SyncRun, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||||
|
FROM sync_runs ORDER BY id DESC LIMIT 1`)
|
||||||
|
var r SyncRun
|
||||||
|
err := row.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return SyncRun{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return SyncRun{}, false, fmt.Errorf("latest sync run: %w", err)
|
||||||
|
}
|
||||||
|
return r, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSyncRuns returns recent sync runs, newest first.
|
||||||
|
func (db *DB) ListSyncRuns(ctx context.Context, limit int) ([]SyncRun, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, kind, started_at, finished_at, activities_fetched, status, error_message
|
||||||
|
FROM sync_runs ORDER BY id DESC LIMIT ?`, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sync runs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
runs := []SyncRun{}
|
||||||
|
for rows.Next() {
|
||||||
|
var r SyncRun
|
||||||
|
if err := rows.Scan(&r.ID, &r.Kind, &r.StartedAt, &r.FinishedAt, &r.ActivitiesFetched, &r.Status, &r.ErrorMessage); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan sync run row: %w", err)
|
||||||
|
}
|
||||||
|
runs = append(runs, r)
|
||||||
|
}
|
||||||
|
return runs, rows.Err()
|
||||||
|
}
|
||||||
37
backend/internal/store/syncstate.go
Normal file
37
backend/internal/store/syncstate.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncState tracks how far back into Garmin history a backfill has already
|
||||||
|
// reached. Since activities are immutable once recorded, this lets a repeat
|
||||||
|
// backfill skip everything already covered instead of re-fetching it.
|
||||||
|
type SyncState struct {
|
||||||
|
EarliestSyncedDate *string // "YYYY-MM-DD", nil if backfill has never run
|
||||||
|
BackfillComplete bool // true once backfill has reached the configured horizon (or Garmin's own history start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSyncState returns the current backfill watermark (the singleton row,
|
||||||
|
// created by migration 0002).
|
||||||
|
func (db *DB) GetSyncState(ctx context.Context) (SyncState, error) {
|
||||||
|
var s SyncState
|
||||||
|
err := db.QueryRowContext(ctx, `SELECT earliest_synced_date, backfill_complete FROM sync_state WHERE id = 1`).
|
||||||
|
Scan(&s.EarliestSyncedDate, &s.BackfillComplete)
|
||||||
|
if err != nil {
|
||||||
|
return SyncState{}, fmt.Errorf("get sync state: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSyncState records progress of a backfill run.
|
||||||
|
func (db *DB) UpdateSyncState(ctx context.Context, earliestSyncedDate string, complete bool) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE sync_state SET earliest_synced_date = ?, backfill_complete = ? WHERE id = 1`,
|
||||||
|
earliestSyncedDate, complete)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update sync state: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
backend/internal/store/syncstate_test.go
Normal file
31
backend/internal/store/syncstate_test.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncState_DefaultsAndUpdate(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
state, err := db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if state.EarliestSyncedDate != nil || state.BackfillComplete {
|
||||||
|
t.Fatalf("expected fresh DB to have no watermark, got %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.UpdateSyncState(ctx, "2023-01-01", true); err != nil {
|
||||||
|
t.Fatalf("UpdateSyncState: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err = db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState after update: %v", err)
|
||||||
|
}
|
||||||
|
if state.EarliestSyncedDate == nil || *state.EarliestSyncedDate != "2023-01-01" || !state.BackfillComplete {
|
||||||
|
t.Fatalf("state after update = %+v, want earliest=2023-01-01 complete=true", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/internal/store/workoutkinds.go
Normal file
102
backend/internal/store/workoutkinds.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkoutKind is a user-editable classification rule ("Easy", "Tempo", ...).
|
||||||
|
// RuleJSON holds the condition tree evaluated by internal/classify.
|
||||||
|
type WorkoutKind struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Color string
|
||||||
|
RuleJSON string
|
||||||
|
Priority int
|
||||||
|
IsActive bool
|
||||||
|
CreatedAt string
|
||||||
|
UpdatedAt string
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanWorkoutKind(row interface{ Scan(...any) error }) (WorkoutKind, error) {
|
||||||
|
var k WorkoutKind
|
||||||
|
err := row.Scan(&k.ID, &k.Name, &k.Description, &k.Color, &k.RuleJSON, &k.Priority, &k.IsActive, &k.CreatedAt, &k.UpdatedAt)
|
||||||
|
return k, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const workoutKindColumns = `id, name, description, color, rule_json, priority, is_active, created_at, updated_at`
|
||||||
|
|
||||||
|
// CreateWorkoutKind inserts a new workout kind and returns its id.
|
||||||
|
func (db *DB) CreateWorkoutKind(ctx context.Context, k WorkoutKind) (int64, error) {
|
||||||
|
res, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO workout_kinds (name, description, color, rule_json, priority, is_active)
|
||||||
|
VALUES (?,?,?,?,?,?)`,
|
||||||
|
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("create workout kind %q: %w", k.Name, err)
|
||||||
|
}
|
||||||
|
return res.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWorkoutKind updates an existing workout kind's editable fields.
|
||||||
|
func (db *DB) UpdateWorkoutKind(ctx context.Context, k WorkoutKind) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE workout_kinds SET name=?, description=?, color=?, rule_json=?, priority=?, is_active=?, updated_at=datetime('now')
|
||||||
|
WHERE id=?`,
|
||||||
|
k.Name, k.Description, k.Color, k.RuleJSON, k.Priority, k.IsActive, k.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update workout kind %d: %w", k.ID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkoutKind fetches one workout kind by id.
|
||||||
|
func (db *DB) GetWorkoutKind(ctx context.Context, id int64) (WorkoutKind, bool, error) {
|
||||||
|
row := db.QueryRowContext(ctx, `SELECT `+workoutKindColumns+` FROM workout_kinds WHERE id = ?`, id)
|
||||||
|
k, err := scanWorkoutKind(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return WorkoutKind{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return WorkoutKind{}, false, fmt.Errorf("get workout kind %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return k, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWorkoutKinds returns workout kinds. If activeOnly, soft-deleted
|
||||||
|
// (is_active=0) kinds are excluded.
|
||||||
|
func (db *DB) ListWorkoutKinds(ctx context.Context, activeOnly bool) ([]WorkoutKind, error) {
|
||||||
|
query := `SELECT ` + workoutKindColumns + ` FROM workout_kinds`
|
||||||
|
if activeOnly {
|
||||||
|
query += ` WHERE is_active = 1`
|
||||||
|
}
|
||||||
|
query += ` ORDER BY priority DESC, name`
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list workout kinds: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
kinds := []WorkoutKind{}
|
||||||
|
for rows.Next() {
|
||||||
|
k, err := scanWorkoutKind(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan workout kind row: %w", err)
|
||||||
|
}
|
||||||
|
kinds = append(kinds, k)
|
||||||
|
}
|
||||||
|
return kinds, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SoftDeleteWorkoutKind sets is_active=0, keeping history (kind_assignments
|
||||||
|
// referencing it) intact.
|
||||||
|
func (db *DB) SoftDeleteWorkoutKind(ctx context.Context, id int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `UPDATE workout_kinds SET is_active=0, updated_at=datetime('now') WHERE id=?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("soft delete workout kind %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
204
backend/internal/sync/mapping.go
Normal file
204
backend/internal/sync/mapping.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/classify"
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toActivityRow(a garmin.Activity) store.Activity {
|
||||||
|
return store.Activity{
|
||||||
|
GarminActivityID: a.ActivityID,
|
||||||
|
ActivityName: a.ActivityName,
|
||||||
|
ActivityType: a.ActivityType.TypeKey,
|
||||||
|
StartTimeUTC: a.StartTimeGMT,
|
||||||
|
BeginTimestampMs: a.BeginTimestamp,
|
||||||
|
DurationSeconds: a.Duration,
|
||||||
|
DistanceMeters: a.Distance,
|
||||||
|
AvgHR: nonZero(a.AverageHR),
|
||||||
|
MaxHR: nonZero(a.MaxHR),
|
||||||
|
AvgSpeedMps: nonZero(a.AverageSpeed),
|
||||||
|
MaxSpeedMps: nonZero(a.MaxSpeed),
|
||||||
|
ElevationGainM: a.ElevationGain,
|
||||||
|
ElevationLossM: a.ElevationLoss,
|
||||||
|
Calories: nonZero(a.Calories),
|
||||||
|
LapCount: a.LapCount,
|
||||||
|
AerobicTrainingEffect: nonZero(a.AerobicTrainingEffect),
|
||||||
|
AnaerobicTrainingEffect: nonZero(a.AnaerobicTrainingEffect),
|
||||||
|
TrainingEffectLabel: a.TrainingEffectLabel,
|
||||||
|
VO2MaxValue: a.VO2MaxValue,
|
||||||
|
HrTimeInZone1: nonZero(a.HrTimeInZone1),
|
||||||
|
HrTimeInZone2: nonZero(a.HrTimeInZone2),
|
||||||
|
HrTimeInZone3: nonZero(a.HrTimeInZone3),
|
||||||
|
HrTimeInZone4: nonZero(a.HrTimeInZone4),
|
||||||
|
HrTimeInZone5: nonZero(a.HrTimeInZone5),
|
||||||
|
RawJSON: string(a.Raw),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonZero returns nil for a zero value so store columns stay NULL instead of
|
||||||
|
// a misleading 0 when Garmin simply didn't report that field.
|
||||||
|
func nonZero(v float64) *float64 {
|
||||||
|
if v == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLapRows converts garmin lap DTOs into store rows, computing each lap's
|
||||||
|
// HR drift (active laps) or recovery rate (rest laps) from the samples that
|
||||||
|
// 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 {
|
||||||
|
rows := make([]store.Lap, 0, len(laps))
|
||||||
|
var elapsedStart float64
|
||||||
|
for _, l := range laps {
|
||||||
|
elapsedEnd := elapsedStart + l.ElapsedDuration
|
||||||
|
|
||||||
|
var driftPtr, recoveryPtr *float64
|
||||||
|
lapSamples := samplesInWindow(samples, elapsedStart, elapsedEnd)
|
||||||
|
switch l.IntensityType {
|
||||||
|
case "ACTIVE":
|
||||||
|
if v, ok := classify.HRDrift(lapSamples); ok {
|
||||||
|
driftPtr = &v
|
||||||
|
}
|
||||||
|
case "REST", "RECOVERY", "COOLDOWN", "WARMUP":
|
||||||
|
if v, ok := classify.HRRecovery(lapSamples); ok {
|
||||||
|
recoveryPtr = &v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(l)
|
||||||
|
rows = append(rows, store.Lap{
|
||||||
|
LapIndex: l.LapIndex,
|
||||||
|
StartTimeUTC: l.StartTimeGMT,
|
||||||
|
DurationSeconds: l.Duration,
|
||||||
|
DistanceMeters: l.Distance,
|
||||||
|
AvgHR: nonZero(l.AverageHR),
|
||||||
|
MaxHR: nonZero(l.MaxHR),
|
||||||
|
AvgSpeedMps: nonZero(l.AverageSpeed),
|
||||||
|
MaxSpeedMps: nonZero(l.MaxSpeed),
|
||||||
|
ElevationGainM: nonZero(l.ElevationGain),
|
||||||
|
ElevationLossM: nonZero(l.ElevationLoss),
|
||||||
|
IntensityType: l.IntensityType,
|
||||||
|
HRDriftBpmPerMin: driftPtr,
|
||||||
|
HRRecoveryBpmPerMin: recoveryPtr,
|
||||||
|
RawJSON: string(raw),
|
||||||
|
})
|
||||||
|
elapsedStart = elapsedEnd
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo {
|
||||||
|
var out []classify.SampleInfo
|
||||||
|
for _, s := range samples {
|
||||||
|
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, classify.SampleInfo{ElapsedSeconds: s.ElapsedSeconds, HeartRate: s.HeartRate})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSampleRows(samples []garmin.Sample) []store.Sample {
|
||||||
|
rows := make([]store.Sample, len(samples))
|
||||||
|
for i, s := range samples {
|
||||||
|
rows[i] = store.Sample{
|
||||||
|
ElapsedSeconds: s.ElapsedSeconds,
|
||||||
|
TimestampMs: s.TimestampMS,
|
||||||
|
HeartRate: s.HeartRate,
|
||||||
|
SpeedMps: s.SpeedMps,
|
||||||
|
DistanceM: s.DistanceM,
|
||||||
|
ElevationM: s.ElevationM,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMetricContext computes the classify.MetricContext for one activity
|
||||||
|
// from its stored summary and laps, ready to evaluate against workout kind
|
||||||
|
// rules. maxHR is the user's configured max heart rate, used only to derive
|
||||||
|
// avg_hr_pct_max (Garmin's activity/lap summaries don't include it directly).
|
||||||
|
func buildMetricContext(a store.Activity, laps []store.Lap, maxHR float64) classify.MetricContext {
|
||||||
|
ctx := classify.MetricContext{
|
||||||
|
"duration_seconds": a.DurationSeconds,
|
||||||
|
"distance_meters": a.DistanceMeters,
|
||||||
|
}
|
||||||
|
if a.AvgSpeedMps != nil && *a.AvgSpeedMps > 0 {
|
||||||
|
ctx["avg_pace_sec_per_km"] = 1000 / *a.AvgSpeedMps
|
||||||
|
}
|
||||||
|
if a.AvgHR != nil {
|
||||||
|
ctx["avg_hr"] = *a.AvgHR
|
||||||
|
if maxHR > 0 {
|
||||||
|
ctx["avg_hr_pct_max"] = *a.AvgHR / maxHR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a.MaxHR != nil {
|
||||||
|
ctx["max_hr"] = *a.MaxHR
|
||||||
|
}
|
||||||
|
if a.ElevationGainM != nil {
|
||||||
|
ctx["elevation_gain_m"] = *a.ElevationGainM
|
||||||
|
}
|
||||||
|
if a.AerobicTrainingEffect != nil {
|
||||||
|
ctx["aerobic_training_effect"] = *a.AerobicTrainingEffect
|
||||||
|
}
|
||||||
|
if a.AnaerobicTrainingEffect != nil {
|
||||||
|
ctx["anaerobic_training_effect"] = *a.AnaerobicTrainingEffect
|
||||||
|
}
|
||||||
|
if a.VO2MaxValue != nil {
|
||||||
|
ctx["vo2max_value"] = *a.VO2MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
lapInfos := make([]classify.LapInfo, len(laps))
|
||||||
|
var paces []float64
|
||||||
|
var maxDrift, maxRecovery float64
|
||||||
|
haveDrift, haveRecovery := false, false
|
||||||
|
for i, l := range laps {
|
||||||
|
lapInfos[i] = classify.LapInfo{IntensityType: l.IntensityType}
|
||||||
|
if l.AvgSpeedMps != nil && *l.AvgSpeedMps > 0 {
|
||||||
|
paces = append(paces, 1000 / *l.AvgSpeedMps)
|
||||||
|
}
|
||||||
|
if l.HRDriftBpmPerMin != nil && (!haveDrift || *l.HRDriftBpmPerMin > maxDrift) {
|
||||||
|
maxDrift, haveDrift = *l.HRDriftBpmPerMin, true
|
||||||
|
}
|
||||||
|
if l.HRRecoveryBpmPerMin != nil && (!haveRecovery || *l.HRRecoveryBpmPerMin > maxRecovery) {
|
||||||
|
maxRecovery, haveRecovery = *l.HRRecoveryBpmPerMin, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(laps) > 0 {
|
||||||
|
if classify.DetectIntervalPattern(lapInfos) {
|
||||||
|
ctx["lap_interval_pattern"] = 1
|
||||||
|
} else {
|
||||||
|
ctx["lap_interval_pattern"] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(paces) > 0 {
|
||||||
|
ctx["lap_pace_stddev"] = classify.LapPaceStdDev(paces)
|
||||||
|
}
|
||||||
|
if haveDrift {
|
||||||
|
ctx["lap_hr_drift_bpm_per_min"] = maxDrift
|
||||||
|
}
|
||||||
|
if haveRecovery {
|
||||||
|
ctx["lap_hr_recovery_bpm_per_min"] = maxRecovery
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRuleKinds(kinds []store.WorkoutKind) ([]classify.RuleKind, error) {
|
||||||
|
rules := make([]classify.RuleKind, 0, len(kinds))
|
||||||
|
for _, k := range kinds {
|
||||||
|
var node classify.Node
|
||||||
|
if err := json.Unmarshal([]byte(k.RuleJSON), &node); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rules = append(rules, classify.RuleKind{WorkoutKindID: k.ID, Name: k.Name, Rule: node})
|
||||||
|
}
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dateStr(t time.Time) string { return t.Format("2006-01-02") }
|
||||||
317
backend/internal/sync/service.go
Normal file
317
backend/internal/sync/service.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
// Package sync orchestrates fetching activities from Garmin (via
|
||||||
|
// internal/garmin), persisting them (via internal/store), and classifying
|
||||||
|
// them (via internal/classify). It's the only package that depends on all
|
||||||
|
// three, keeping garmin/store/classify decoupled from each other.
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/classify"
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config tunes sync behavior. Zero values fall back to sensible defaults in
|
||||||
|
// NewService.
|
||||||
|
type Config struct {
|
||||||
|
// BackfillHorizonDays bounds how far back a full backfill reaches.
|
||||||
|
BackfillHorizonDays int
|
||||||
|
// BackfillWindowDays is the page size for each get_activities call
|
||||||
|
// during backfill.
|
||||||
|
BackfillWindowDays int
|
||||||
|
// IncrementalOverlapDays re-fetches a small trailing window on every
|
||||||
|
// incremental sync, guarding against activities that were still
|
||||||
|
// uploading/processing at the time of the previous sync.
|
||||||
|
IncrementalOverlapDays int
|
||||||
|
// InterCallDelay is a pause between sequential Garmin calls during
|
||||||
|
// detail-fill, to avoid tripping Garmin/Cloudflare's rate limiting
|
||||||
|
// (observed firsthand during development).
|
||||||
|
InterCallDelay time.Duration
|
||||||
|
// MinConfidence is the classify.Classify threshold below which even a
|
||||||
|
// single matching kind is sent to manual review.
|
||||||
|
MinConfidence float64
|
||||||
|
// MaxHR is used only to derive avg_hr_pct_max for the rule engine.
|
||||||
|
MaxHR float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) withDefaults() Config {
|
||||||
|
if c.BackfillHorizonDays == 0 {
|
||||||
|
c.BackfillHorizonDays = 3 * 365
|
||||||
|
}
|
||||||
|
if c.BackfillWindowDays == 0 {
|
||||||
|
c.BackfillWindowDays = 90
|
||||||
|
}
|
||||||
|
if c.IncrementalOverlapDays == 0 {
|
||||||
|
c.IncrementalOverlapDays = 2
|
||||||
|
}
|
||||||
|
if c.InterCallDelay == 0 {
|
||||||
|
c.InterCallDelay = time.Second
|
||||||
|
}
|
||||||
|
if c.MinConfidence == 0 {
|
||||||
|
c.MinConfidence = classify.DefaultMinConfidence
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress reports how far a currently-running (or just-finished)
|
||||||
|
// FillPendingDetails pass has gotten, for a status banner to poll.
|
||||||
|
type Progress struct {
|
||||||
|
Done int
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service is the sync orchestrator.
|
||||||
|
type Service struct {
|
||||||
|
garmin garmin.Client
|
||||||
|
db *store.DB
|
||||||
|
cfg Config
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
progressMu sync.Mutex
|
||||||
|
progress Progress
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService builds a Service. now defaults to time.Now if nil (tests can
|
||||||
|
// override it for deterministic date windows).
|
||||||
|
func NewService(g garmin.Client, db *store.DB, cfg Config, now func() time.Time) *Service {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
return &Service{garmin: g, db: db, cfg: cfg.withDefaults(), now: now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress returns the current detail-fill progress (0/0 when idle).
|
||||||
|
func (s *Service) Progress() Progress {
|
||||||
|
s.progressMu.Lock()
|
||||||
|
defer s.progressMu.Unlock()
|
||||||
|
return s.progress
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) setProgress(done, total int) {
|
||||||
|
s.progressMu.Lock()
|
||||||
|
s.progress = Progress{Done: done, Total: total}
|
||||||
|
s.progressMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill pages backward in Config.BackfillWindowDays windows until
|
||||||
|
// Config.BackfillHorizonDays is reached or Garmin returns an empty page.
|
||||||
|
// Safe to re-run: activities are upserted by garmin_activity_id, and thanks
|
||||||
|
// to the sync_state watermark (Garmin history is immutable once recorded)
|
||||||
|
// a repeat call only fetches whatever's newer than the last completed
|
||||||
|
// backfill, or is a fast no-op if the configured horizon is already fully
|
||||||
|
// covered -- it does not re-walk years of already-known history.
|
||||||
|
func (s *Service) Backfill(ctx context.Context) error {
|
||||||
|
runID, err := s.db.StartSyncRun(ctx, store.SyncKindBackfill)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
horizon := s.now().AddDate(0, 0, -s.cfg.BackfillHorizonDays)
|
||||||
|
|
||||||
|
state, err := s.db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, 0, &msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
end := s.now()
|
||||||
|
if state.EarliestSyncedDate != nil {
|
||||||
|
if watermark, err := time.Parse("2006-01-02", *state.EarliestSyncedDate); err == nil {
|
||||||
|
if state.BackfillComplete && !watermark.After(horizon) {
|
||||||
|
// Already backfilled at least as far back as the configured
|
||||||
|
// horizon -- nothing new to fetch from Garmin at all.
|
||||||
|
return s.db.FinishSyncRun(ctx, runID, 0, nil)
|
||||||
|
}
|
||||||
|
end = watermark.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := 0
|
||||||
|
reachedStartOfHistory := false
|
||||||
|
for end.After(horizon) {
|
||||||
|
start := end.AddDate(0, 0, -s.cfg.BackfillWindowDays)
|
||||||
|
if start.Before(horizon) {
|
||||||
|
start = horizon
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(end))
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
||||||
|
return fmt.Errorf("backfill window %s..%s: %w", dateStr(start), dateStr(end), err)
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
|
||||||
|
if n == 0 {
|
||||||
|
// Empty page: reached the start of this account's history,
|
||||||
|
// regardless of the configured horizon.
|
||||||
|
reachedStartOfHistory = true
|
||||||
|
if err := s.db.UpdateSyncState(ctx, dateStr(start), true); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err := s.db.UpdateSyncState(ctx, dateStr(start), false); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
end = start.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !reachedStartOfHistory {
|
||||||
|
// Reached the configured horizon (not Garmin's actual history
|
||||||
|
// start) -- mark complete relative to that horizon.
|
||||||
|
if err := s.db.UpdateSyncState(ctx, dateStr(horizon), true); err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, total, &msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.FinishSyncRun(ctx, runID, total, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementalSync fetches activities from just before the latest known
|
||||||
|
// activity (or a short recent window if none exist yet) through today.
|
||||||
|
func (s *Service) IncrementalSync(ctx context.Context) error {
|
||||||
|
runID, err := s.db.StartSyncRun(ctx, store.SyncKindIncremental)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
|
if latest, ok, err := s.db.LatestActivityStartTime(ctx); err == nil && ok {
|
||||||
|
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
|
||||||
|
start = t.AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.fetchAndStoreWindow(ctx, dateStr(start), dateStr(s.now()))
|
||||||
|
if err != nil {
|
||||||
|
msg := err.Error()
|
||||||
|
s.db.FinishSyncRun(ctx, runID, n, &msg)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.FinishSyncRun(ctx, runID, n, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (int, error) {
|
||||||
|
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
|
||||||
|
}
|
||||||
|
for _, a := range activities {
|
||||||
|
if _, err := s.db.UpsertActivity(ctx, toActivityRow(a)); err != nil {
|
||||||
|
return 0, fmt.Errorf("store activity %d: %w", a.ActivityID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(activities), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillPendingDetails fetches get_activity_splits/get_activity_details for up
|
||||||
|
// to limit activities that don't have them yet, then (re)classifies each one.
|
||||||
|
// Calls are made sequentially with Config.InterCallDelay between them to
|
||||||
|
// avoid Garmin/Cloudflare rate limiting.
|
||||||
|
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
|
||||||
|
pending, err := s.db.ActivitiesMissingDetails(ctx, limit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.setProgress(0, len(pending))
|
||||||
|
defer s.setProgress(0, 0)
|
||||||
|
|
||||||
|
for i, a := range pending {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(s.cfg.InterCallDelay):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.fillActivityDetails(ctx, a); err != nil {
|
||||||
|
return fmt.Errorf("fill details for activity %d: %w", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
if err := s.ClassifyActivity(ctx, a.ID); err != nil {
|
||||||
|
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
|
||||||
|
}
|
||||||
|
s.setProgress(i+1, len(pending))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity) error {
|
||||||
|
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get_activity_splits: %w", err)
|
||||||
|
}
|
||||||
|
details, err := s.garmin.GetActivityDetails(ctx, a.GarminActivityID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get_activity_details: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
detailsRaw, _ := json.Marshal(details)
|
||||||
|
if err := s.db.SetActivityDetails(ctx, a.ID, string(detailsRaw)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.db.SetActivitySplitsFetched(ctx, a.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyActivity (re)runs the rule engine for one activity against the
|
||||||
|
// currently active workout kinds and appends a new kind_assignments row.
|
||||||
|
// Safe to call repeatedly (e.g. after editing a workout kind's rule).
|
||||||
|
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error {
|
||||||
|
activity, ok, err := s.db.GetActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("activity %d not found", activityID)
|
||||||
|
}
|
||||||
|
laps, err := s.db.LapsForActivity(ctx, activityID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kindRows, err := s.db.ListWorkoutKinds(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules, err := loadRuleKinds(kindRows)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parse workout kind rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxMetrics := buildMetricContext(activity, laps, s.cfg.MaxHR)
|
||||||
|
result := classify.Classify(ctxMetrics, rules, s.cfg.MinConfidence)
|
||||||
|
|
||||||
|
candidatesJSON, err := json.Marshal(result.Candidates)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.InsertKindAssignment(ctx, store.KindAssignment{
|
||||||
|
ActivityID: activityID,
|
||||||
|
WorkoutKindID: result.WorkoutKindID,
|
||||||
|
AssignmentSource: store.AssignmentSourceRuleEngine,
|
||||||
|
Status: result.Status,
|
||||||
|
Confidence: result.Confidence,
|
||||||
|
CandidateKindsJSON: string(candidatesJSON),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
284
backend/internal/sync/service_test.go
Normal file
284
backend/internal/sync/service_test.go
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"smartrun/backend/internal/classify"
|
||||||
|
"smartrun/backend/internal/garmin"
|
||||||
|
"smartrun/backend/internal/garmin/mock"
|
||||||
|
"smartrun/backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func f(v float64) *float64 { return &v }
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *store.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := store.Open(filepath.Join(t.TempDir(), "smartrun_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("store.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedNow(t time.Time) func() time.Time {
|
||||||
|
return func() time.Time { return t }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||||
|
}}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
activities, err := db.ListActivities(ctx, store.ActivityFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActivities: %v", err)
|
||||||
|
}
|
||||||
|
if len(activities) != 1 {
|
||||||
|
t.Fatalf("expected 1 stored activity, got %d", len(activities))
|
||||||
|
}
|
||||||
|
if activities[0].GarminActivityID != 1 {
|
||||||
|
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
|
||||||
|
}
|
||||||
|
|
||||||
|
runs, err := db.ListSyncRuns(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListSyncRuns: %v", err)
|
||||||
|
}
|
||||||
|
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
|
||||||
|
t.Fatalf("expected 1 successful sync run, got %+v", runs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const garminActivityID = 42
|
||||||
|
m := &mock.Client{
|
||||||
|
Activities: []garmin.Activity{
|
||||||
|
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
|
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
|
||||||
|
},
|
||||||
|
Splits: map[int64]garmin.ActivitySplits{
|
||||||
|
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
|
||||||
|
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
Details: map[int64]garmin.ActivityDetails{
|
||||||
|
garminActivityID: {
|
||||||
|
ActivityID: garminActivityID,
|
||||||
|
MetricDescriptors: []garmin.MetricDescriptor{
|
||||||
|
{Key: "directHeartRate", MetricsIndex: 0},
|
||||||
|
{Key: "sumElapsedDuration", MetricsIndex: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("Backfill: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A workout kind that should cleanly match the seeded activity's pace.
|
||||||
|
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
|
||||||
|
if _, err := db.CreateWorkoutKind(ctx, store.WorkoutKind{Name: "Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
|
||||||
|
t.Fatalf("CreateWorkoutKind: %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)
|
||||||
|
}
|
||||||
|
activityID := activities[0].ID
|
||||||
|
|
||||||
|
if activities[0].DetailsFetchedAt == nil {
|
||||||
|
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
|
||||||
|
}
|
||||||
|
if activities[0].SplitsFetchedAt == nil {
|
||||||
|
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
|
||||||
|
}
|
||||||
|
|
||||||
|
laps, err := db.LapsForActivity(ctx, activityID)
|
||||||
|
if err != nil || len(laps) != 1 {
|
||||||
|
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, ok, err := db.CurrentAssignment(ctx, activityID)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if assignment.Status != classify.StatusAssigned {
|
||||||
|
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
|
||||||
|
activity := store.Activity{
|
||||||
|
DurationSeconds: 1800,
|
||||||
|
DistanceMeters: 6000,
|
||||||
|
AvgSpeedMps: f(3.33),
|
||||||
|
AvgHR: f(152),
|
||||||
|
AerobicTrainingEffect: f(3.2),
|
||||||
|
}
|
||||||
|
laps := []store.Lap{
|
||||||
|
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
|
||||||
|
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := buildMetricContext(activity, laps, 190)
|
||||||
|
|
||||||
|
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
|
||||||
|
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
|
||||||
|
}
|
||||||
|
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
|
||||||
|
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
|
||||||
|
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
|
||||||
|
}
|
||||||
|
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
|
||||||
|
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
|
||||||
|
}
|
||||||
|
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
|
||||||
|
// structured interval workout.
|
||||||
|
if got := ctx["lap_interval_pattern"]; got != 0 {
|
||||||
|
t.Errorf("lap_interval_pattern = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
|
}}
|
||||||
|
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10},
|
||||||
|
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("first Backfill: %v", err)
|
||||||
|
}
|
||||||
|
firstCallCount := m.GetActivitiesCalls
|
||||||
|
if firstCallCount == 0 {
|
||||||
|
t.Fatal("expected first backfill to call GetActivities at least once")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if !state.BackfillComplete {
|
||||||
|
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("second Backfill: %v", err)
|
||||||
|
}
|
||||||
|
if m.GetActivitiesCalls != firstCallCount {
|
||||||
|
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
|
||||||
|
m.GetActivitiesCalls-firstCallCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{Activities: []garmin.Activity{
|
||||||
|
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
|
||||||
|
}}
|
||||||
|
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
svc := NewService(m, db, Config{BackfillHorizonDays: 10, BackfillWindowDays: 10}, now)
|
||||||
|
if err := svc.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("first Backfill: %v", err)
|
||||||
|
}
|
||||||
|
firstCallCount := m.GetActivitiesCalls
|
||||||
|
|
||||||
|
// Simulate the user widening the horizon later -- should resume from the
|
||||||
|
// watermark (not re-fetch the already-covered recent window) but still
|
||||||
|
// make progress toward the new, deeper horizon.
|
||||||
|
svc2 := NewService(m, db, Config{BackfillHorizonDays: 30, BackfillWindowDays: 10}, now)
|
||||||
|
if err := svc2.Backfill(ctx); err != nil {
|
||||||
|
t.Fatalf("second Backfill: %v", err)
|
||||||
|
}
|
||||||
|
if m.GetActivitiesCalls <= firstCallCount {
|
||||||
|
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := db.GetSyncState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if !state.BackfillComplete {
|
||||||
|
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
|
||||||
|
db := openTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
m := &mock.Client{
|
||||||
|
Activities: []garmin.Activity{},
|
||||||
|
Splits: map[int64]garmin.ActivitySplits{},
|
||||||
|
Details: map[int64]garmin.ActivityDetails{},
|
||||||
|
}
|
||||||
|
const n = 3
|
||||||
|
for i := int64(1); i <= n; i++ {
|
||||||
|
m.Activities = append(m.Activities, garmin.Activity{
|
||||||
|
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
|
||||||
|
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
|
||||||
|
})
|
||||||
|
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
|
||||||
|
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := NewService(m, db, Config{InterCallDelay: 150 * time.Millisecond},
|
||||||
|
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 p := svc.Progress(); p.Total != 0 {
|
||||||
|
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- svc.FillPendingDetails(ctx, n) }()
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
|
||||||
|
mid := svc.Progress()
|
||||||
|
if mid.Total != n {
|
||||||
|
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
|
||||||
|
}
|
||||||
|
if mid.Done <= 0 || mid.Done >= n {
|
||||||
|
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatalf("FillPendingDetails: %v", err)
|
||||||
|
}
|
||||||
|
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
|
||||||
|
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
|
||||||
|
}
|
||||||
|
}
|
||||||
2413
docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md
Normal file
2413
docs/superpowers/plans/2026-07-17-profile-and-taxonomy.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
|||||||
|
# Profile, workout taxonomy & analysis engine foundations
|
||||||
|
|
||||||
|
**Status:** Draft — approved by user, pending implementation planning
|
||||||
|
**Date:** 2026-07-17
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
smartrun currently classifies runs into arbitrary, user-created "workout
|
||||||
|
kinds" using absolute pace/HR thresholds, and has no concept of a user
|
||||||
|
profile — Garmin credentials are passed as environment variables at process
|
||||||
|
start, and there is exactly one hardcoded set of engine parameters.
|
||||||
|
|
||||||
|
This spec refines that into a fixed running-specific taxonomy, replaces
|
||||||
|
absolute-pace classification with structural and relative-to-self signals,
|
||||||
|
adds warm-up/work/cool-down phase detection with per-phase heart-rate
|
||||||
|
analysis, and introduces a single-profile settings model so all of this is
|
||||||
|
tunable without redeploying.
|
||||||
|
|
||||||
|
Two related features are explicitly **out of scope** for this spec, per the
|
||||||
|
user's direction to discuss them later once this foundation exists:
|
||||||
|
|
||||||
|
- **Deep per-phase workout analysis** beyond the HR metrics described in
|
||||||
|
section 5 (the user has more detailed expectations to describe once phase
|
||||||
|
segmentation exists to hang them on).
|
||||||
|
- **Adaptive pace recommendation** ("delta" between the user's declared pace
|
||||||
|
ranges and workout-derived ones) — still undecided whether this needs an
|
||||||
|
AI component or can be fully deterministic.
|
||||||
|
|
||||||
|
The pace ranges and expected HR zones introduced here exist as **data
|
||||||
|
capture** for that future delta feature; nothing in this spec computes or
|
||||||
|
displays a delta.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. A single-profile settings model: Garmin credentials + all tunable engine
|
||||||
|
parameters, stored in SQLite instead of environment variables, edited
|
||||||
|
through one settings screen.
|
||||||
|
2. A fixed, 7-type running workout taxonomy replacing the current
|
||||||
|
user-created "workout kinds," each with a user-editable target pace
|
||||||
|
range (no history — the synced activity log *is* the history).
|
||||||
|
3. Classification driven by structure and by an activity's *relative*
|
||||||
|
standing among its own recent history — never by matching the user's
|
||||||
|
declared target pace ranges.
|
||||||
|
4. Deterministic warm-up/work/cool-down phase detection per activity, with
|
||||||
|
per-phase heart-rate analysis (zone, drift, basic recovery signal),
|
||||||
|
configurable per workout type.
|
||||||
|
5. A reusable "classification preview" — every activity scored against
|
||||||
|
every workout type at once — as the shared tool for iteratively tuning
|
||||||
|
natural-language rule definitions against real history.
|
||||||
|
6. A "recompute" action that re-runs phase detection and reclassification
|
||||||
|
across all activities, since relative metrics and phase parameters can
|
||||||
|
change what a past activity should have been classified as.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Multi-profile switching / multi-tenant data (explicitly declined — one
|
||||||
|
active profile at a time; the schema does not need per-row profile
|
||||||
|
scoping, a singleton row is sufficient).
|
||||||
|
- The delta/recommendation calculation itself (section above).
|
||||||
|
- Deep per-phase analysis beyond HR zone/drift/basic recovery signal.
|
||||||
|
- Any AI/LLM involvement at runtime — classification and phase detection
|
||||||
|
are fully deterministic, tuned offline against real data before being
|
||||||
|
encoded as rules/parameters.
|
||||||
|
|
||||||
|
## 1. Profile
|
||||||
|
|
||||||
|
A new **`profile`** table, a singleton row (`id = 1`, following the existing
|
||||||
|
`sync_state` pattern), replacing environment-variable Garmin credentials and
|
||||||
|
consolidating all tunable engine parameters:
|
||||||
|
|
||||||
|
- `garmin_email`, `garmin_password` — moved out of env vars; entered once
|
||||||
|
via the profile screen.
|
||||||
|
- `rolling_window_days` (default `90`) — the population window for
|
||||||
|
relative-classification metrics (section 3).
|
||||||
|
- `max_heart_rate`, `resting_heart_rate` — inputs to the Heart Rate Reserve
|
||||||
|
(Karvonen) calculation used for zone detection (section 5).
|
||||||
|
- Five HR zone ranges, each a `(min_pct, max_pct)` pair of "% of heart rate
|
||||||
|
reserve," seeded with standard defaults (Z1 50–60, Z2 60–70, Z3 70–80,
|
||||||
|
Z4 80–90, Z5 90–100) and user-editable.
|
||||||
|
- Per-workout-type phase-detection parameters (section 4) — e.g.
|
||||||
|
`easy_warmup_minutes`, `easy_cooldown_minutes`, `interval_warmup_minutes`,
|
||||||
|
one pair per non-lap-based type.
|
||||||
|
|
||||||
|
`internal/config` (env-var loading) shrinks to just `SMARTRUN_ADDR`,
|
||||||
|
`SMARTRUN_DB_PATH`, and the mcp-garmin subprocess paths — everything
|
||||||
|
runtime-tunable moves into `profile`.
|
||||||
|
|
||||||
|
**Validation** (rejected at save time, not silently accepted):
|
||||||
|
resting HR < max HR; zone ranges ascending and non-overlapping,
|
||||||
|
collectively covering 0–100%.
|
||||||
|
|
||||||
|
**Credential updates & re-authentication.** `garmin.Client` currently takes
|
||||||
|
a fixed `Config` (including email/password) at construction and lazily
|
||||||
|
spawns the mcp-garmin subprocess on first use — credentials are only ever
|
||||||
|
whatever the process was launched with. Saving new Garmin credentials via
|
||||||
|
the profile screen must:
|
||||||
|
|
||||||
|
1. Update the running `garmin.Client`'s credentials in memory.
|
||||||
|
2. Reset its "started" state and terminate any already-spawned subprocess
|
||||||
|
(which would otherwise still be running under the *old* credentials).
|
||||||
|
|
||||||
|
The next "Connect to Garmin" click — the connect button and MFA-code entry
|
||||||
|
UI already built for the previous single-env-var-credential model — then
|
||||||
|
spawns a fresh subprocess with the newly-saved credentials and proceeds
|
||||||
|
through the existing authenticate/MFA flow unchanged. No new UI is needed;
|
||||||
|
saving the profile just makes that existing flow reachable at any time,
|
||||||
|
not only right after process launch.
|
||||||
|
|
||||||
|
## 2. Workout taxonomy & pace ranges
|
||||||
|
|
||||||
|
The existing `workout_kinds` table is reseeded with exactly seven fixed
|
||||||
|
rows and loses its "create new" / "delete" affordances in the UI — only
|
||||||
|
`rule_json` (and the new phase/HR fields below) remain editable per type:
|
||||||
|
|
||||||
|
`Easy Run`, `Long Run`, `Threshold 30'`, `Threshold 60'`, `Tempo`,
|
||||||
|
`Interval`, `MAS Test`.
|
||||||
|
|
||||||
|
A new **`workout_type_paces`** table, one row per workout kind:
|
||||||
|
|
||||||
|
- `pace_min_sec_per_km`, `pace_max_sec_per_km` — entered/displayed as
|
||||||
|
`m:ss–m:ss`.
|
||||||
|
- `expected_hr_zone` — which of the 5 zones this type should predominantly
|
||||||
|
sit in (e.g. Easy Run → Zone 2), used later by the per-phase HR check
|
||||||
|
(section 5), not by classification.
|
||||||
|
|
||||||
|
Neither field has history — overwritten in place when the user updates
|
||||||
|
them, per the user's explicit direction (the activity log is the history).
|
||||||
|
|
||||||
|
## 3. Classification: relative & structural metrics
|
||||||
|
|
||||||
|
New metrics available to `internal/classify`'s condition-tree engine,
|
||||||
|
computed fresh at classification time (no cached/materialized column):
|
||||||
|
|
||||||
|
- **`distance_percentile`, `duration_percentile`** — this activity's rank
|
||||||
|
(0–1) among all *running* activities (any type except MAS Test) whose
|
||||||
|
start date falls within `rolling_window_days` of *this activity's own
|
||||||
|
date* (not "today" — so reclassifying an old activity is stable and
|
||||||
|
doesn't depend on when reclassification happens to run). Computed via a
|
||||||
|
straightforward SQL ranking query over `activities`.
|
||||||
|
- **`lap_pace_consistency`** — coefficient of variation of pace across the
|
||||||
|
work phase specifically (depends on section 4's phase boundaries), a low
|
||||||
|
value indicating "constant pace" — the structural signal the user
|
||||||
|
described for Long Run, replacing any pace-target comparison.
|
||||||
|
- Existing metrics (`lap_interval_pattern`, `lap_hr_drift_bpm_per_min`,
|
||||||
|
`lap_hr_recovery_bpm_per_min`, `aerobic_training_effect`, etc.) remain
|
||||||
|
available unchanged.
|
||||||
|
|
||||||
|
**`ReclassifyAll`**: a new bulk operation alongside the existing per-activity
|
||||||
|
`ClassifyActivity`, iterating every stored activity and re-evaluating it.
|
||||||
|
Necessary because relative metrics depend on the whole population — a
|
||||||
|
rolling-window setting change (or a new activity altering others'
|
||||||
|
percentile rank) can change what an old activity should be classified as,
|
||||||
|
not just new ones. Per-activity failures are logged and counted, not fatal
|
||||||
|
to the batch.
|
||||||
|
|
||||||
|
**Classification preview**: a new read path (`GET
|
||||||
|
/api/classification-preview` or similar) returning, for every activity,
|
||||||
|
*every* workout type's evaluation — matched or not, with score — not just
|
||||||
|
the ones that crossed the confidence threshold (unlike `kind_assignments`,
|
||||||
|
which only records matched candidates). This is the shared tool for the
|
||||||
|
natural-language rule-tuning sessions: change a rule, hit preview, see the
|
||||||
|
whole table shift.
|
||||||
|
|
||||||
|
## 4. Phase segmentation
|
||||||
|
|
||||||
|
A new **`activity_phases`** table: one row per detected phase per activity
|
||||||
|
— `activity_id`, `phase_label` (`warmup` / `work` / `cooldown` for
|
||||||
|
continuous types; `warmup` / `work_1` / `rest_1` / `work_2` / ... /
|
||||||
|
`cooldown` for Interval), `start_elapsed_seconds`, `end_elapsed_seconds`.
|
||||||
|
|
||||||
|
Two phase-detection strategies, selected per workout type (not one
|
||||||
|
one-size-fits-all algorithm):
|
||||||
|
|
||||||
|
- **`fixed_duration`** (Easy, Long, Tempo, Threshold-30/60, first pass at
|
||||||
|
MAS Test): warm-up = first *N* configured minutes (`profile`'s
|
||||||
|
per-type setting), cool-down = last *M* configured minutes, work =
|
||||||
|
everything between.
|
||||||
|
- **`lap_intensity`** (Interval): reuses the existing Garmin lap
|
||||||
|
`IntensityType` tagging (ACTIVE/REST) built earlier — warm-up = before
|
||||||
|
the first ACTIVE lap, work = the ACTIVE/REST lap sequence itself (each
|
||||||
|
rep an individually labeled phase), cool-down = after the last one.
|
||||||
|
|
||||||
|
Phases are computed as part of the existing post-sync detail-fill step
|
||||||
|
(`internal/sync`), stored once, and only recomputed via the explicit
|
||||||
|
recompute action (section 6) — the same lifecycle as classification.
|
||||||
|
|
||||||
|
**Per-phase heart-rate analysis**, computed from `activity_samples` within
|
||||||
|
each phase's elapsed-time window:
|
||||||
|
|
||||||
|
- `avg_hr`, `max_hr` over the phase.
|
||||||
|
- `hr_zone` — the phase's average HR mapped to one of the 5 Karvonen zones
|
||||||
|
from `profile`, compared against that workout type's `expected_hr_zone`
|
||||||
|
(section 2) so a mismatch (e.g. an Easy Run run in Zone 4) is visible.
|
||||||
|
- `hr_drift_bpm_per_min` — reuses the existing lap-level drift regression
|
||||||
|
(already generic over any sample window), applied to the phase's window
|
||||||
|
instead of a lap's.
|
||||||
|
- A basic recovery signal for cool-down/rest phases (reusing the existing
|
||||||
|
HR-recovery regression). The more nuanced version the user described —
|
||||||
|
correlating HR fall against recovery *pace*, not HR alone — captures its
|
||||||
|
raw ingredients here (per-phase HR trend + per-phase pace) but the
|
||||||
|
composite "recovery quality" metric itself is deferred to the future deep
|
||||||
|
per-phase analysis discussion.
|
||||||
|
|
||||||
|
## 5. Settings panel & frontend
|
||||||
|
|
||||||
|
- **Profile screen**: Garmin credentials, rolling window, max/resting HR,
|
||||||
|
the 5 zone ranges, per-type phase parameters, and (from section 2)
|
||||||
|
per-type pace ranges + expected HR zone. One screen, one save, validated
|
||||||
|
per section 1.
|
||||||
|
- **Recompute action**: re-runs phase segmentation then `ReclassifyAll`
|
||||||
|
across all activities. Reuses the existing sync-progress-banner pattern
|
||||||
|
(live done/total) since this walks the whole history.
|
||||||
|
- **Classification preview page**: the table from section 3.
|
||||||
|
- **Activity detail page** (new — no per-activity view exists today):
|
||||||
|
pace/HR-vs-time chart (Recharts) with phase bands overlaid as
|
||||||
|
`ReferenceArea`s, plus a per-phase readout (HR zone, drift, avg pace).
|
||||||
|
|
||||||
|
## 6. Testing & error handling
|
||||||
|
|
||||||
|
- Phase detection and the new classification metrics are pure functions
|
||||||
|
tested against fixture sample/lap data, independent of the database or
|
||||||
|
Garmin — same pattern as the existing `internal/classify` tests.
|
||||||
|
- `ReclassifyAll` and the phase-recompute pass are per-activity fault
|
||||||
|
isolated: one activity's failure is logged and counted, not fatal to the
|
||||||
|
batch; the recompute status reports a failure count alongside progress.
|
||||||
|
- Profile save validates HR zone ranges (ascending, non-overlapping, full
|
||||||
|
0–100% coverage) and resting-HR-less-than-max-HR before persisting,
|
||||||
|
returning a specific error message rather than accepting invalid state.
|
||||||
|
|
||||||
|
## Open questions carried forward (not blocking this spec)
|
||||||
|
|
||||||
|
- Exact per-type natural-language rule definitions and phase parameters —
|
||||||
|
to be tuned interactively against the user's real Garmin history using
|
||||||
|
the classification preview (section 3) once this foundation is built.
|
||||||
|
- The deep per-phase analysis feature beyond HR zone/drift/recovery.
|
||||||
|
- Whether the pace/HR-zone delta recommendation is AI-assisted or fully
|
||||||
|
deterministic.
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
frontend/.oxlintrc.json
Normal file
8
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
32
frontend/README.md
Normal file
32
frontend/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1816
frontend/package-lock.json
generated
Normal file
1816
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"recharts": "^3.9.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
230
frontend/src/App.css
Normal file
230
frontend/src/App.css
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: #0f1115;
|
||||||
|
color: #e6e6e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
border-bottom: 1px solid #2a2d35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
color: #b7bcc7;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-connection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid #2a2d35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-connection-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.garmin-connection-message {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-authenticated {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-mfa_required {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.status-failed {
|
||||||
|
background: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls label,
|
||||||
|
.kind-editor label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: #1a1d24;
|
||||||
|
color: #e6e6e6;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: #9aa0ab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item {
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item-header span {
|
||||||
|
color: #9aa0ab;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidates {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9aa0ab;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kinds-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kinds-table th,
|
||||||
|
.kinds-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid #2a2d35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kinds-table-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid #2a2d35;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-editor textarea {
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kind-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
44
frontend/src/App.tsx
Normal file
44
frontend/src/App.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import "./App.css";
|
||||||
|
import { GarminConnection } from "./components/GarminConnection";
|
||||||
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
|
import { ReviewQueue } from "./pages/ReviewQueue";
|
||||||
|
import { WorkoutKinds } from "./pages/WorkoutKinds";
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ key: "dashboard", label: "Progression", Component: Dashboard },
|
||||||
|
{ key: "review", label: "Review Queue", Component: ReviewQueue },
|
||||||
|
{ key: "kinds", label: "Workout Kinds", Component: WorkoutKinds },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type TabKey = (typeof TABS)[number]["key"];
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [tab, setTab] = useState<TabKey>("dashboard");
|
||||||
|
const Active = TABS.find((t) => t.key === tab)!.Component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="app-header">
|
||||||
|
<h1>smartrun</h1>
|
||||||
|
<nav className="tabs">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
className={t.key === tab ? "tab active" : "tab"}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<GarminConnection />
|
||||||
|
<main>
|
||||||
|
<Active />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
81
frontend/src/api/client.ts
Normal file
81
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type {
|
||||||
|
Activity,
|
||||||
|
AuthResponse,
|
||||||
|
KindAssignment,
|
||||||
|
ProgressionMetric,
|
||||||
|
ProgressionPoint,
|
||||||
|
ReviewQueueItem,
|
||||||
|
SyncRun,
|
||||||
|
SyncStatus,
|
||||||
|
WorkoutKind,
|
||||||
|
} from "../types/api";
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status} ${body}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
login: () => request<AuthResponse>("/api/auth/login", { method: "POST" }),
|
||||||
|
submitMFA: (code: string) =>
|
||||||
|
request<AuthResponse>("/api/auth/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||||||
|
authStatus: () => request<AuthResponse>("/api/auth/status"),
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
syncRun: () => request<{ status: string }>("/api/sync/run", { method: "POST" }),
|
||||||
|
syncBackfill: () => request<{ status: string }>("/api/sync/backfill", { method: "POST" }),
|
||||||
|
syncRuns: () => request<SyncRun[]>("/api/sync/runs"),
|
||||||
|
syncStatus: () => request<SyncStatus>("/api/sync/status"),
|
||||||
|
|
||||||
|
// Activities
|
||||||
|
listActivities: (params?: { from?: string; to?: string; limit?: number }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.from) q.set("from", params.from);
|
||||||
|
if (params?.to) q.set("to", params.to);
|
||||||
|
if (params?.limit) q.set("limit", String(params.limit));
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<Activity[]>(`/api/activities/${qs ? `?${qs}` : ""}`);
|
||||||
|
},
|
||||||
|
getActivity: (id: number) =>
|
||||||
|
request<{ activity: Activity; laps: unknown[]; assignment?: KindAssignment }>(`/api/activities/${id}`),
|
||||||
|
|
||||||
|
// Workout kinds
|
||||||
|
listWorkoutKinds: (includeInactive = false) =>
|
||||||
|
request<WorkoutKind[]>(`/api/workout-kinds/${includeInactive ? "?include_inactive=true" : ""}`),
|
||||||
|
createWorkoutKind: (body: { name: string; description?: string; color?: string; rule: unknown; priority?: number }) =>
|
||||||
|
request<WorkoutKind>("/api/workout-kinds/", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
updateWorkoutKind: (
|
||||||
|
id: number,
|
||||||
|
body: { name: string; description?: string; color?: string; rule: unknown; priority?: number; is_active?: boolean },
|
||||||
|
) => request<WorkoutKind>(`/api/workout-kinds/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||||
|
deleteWorkoutKind: (id: number) => request<void>(`/api/workout-kinds/${id}`, { method: "DELETE" }),
|
||||||
|
reclassifyWorkoutKind: (id: number) =>
|
||||||
|
request<{ reclassified: number }>(`/api/workout-kinds/${id}/reclassify`, { method: "POST" }),
|
||||||
|
|
||||||
|
// Review queue
|
||||||
|
reviewQueue: () => request<ReviewQueueItem[]>("/api/review-queue/"),
|
||||||
|
resolveReview: (activityId: number, workoutKindId: number) =>
|
||||||
|
request<{ status: string }>(`/api/review-queue/${activityId}/resolve`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ workout_kind_id: workoutKindId }),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Progression
|
||||||
|
progression: (kindId: number, metric: ProgressionMetric = "pace", from?: string, to?: string) => {
|
||||||
|
const q = new URLSearchParams({ metric });
|
||||||
|
if (from) q.set("from", from);
|
||||||
|
if (to) q.set("to", to);
|
||||||
|
return request<ProgressionPoint[]>(`/api/progression/${kindId}?${q.toString()}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
143
frontend/src/components/GarminConnection.tsx
Normal file
143
frontend/src/components/GarminConnection.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { AuthResponse, SyncStatus } from "../types/api";
|
||||||
|
|
||||||
|
function syncProgressLabel(status: SyncStatus): string {
|
||||||
|
const { detail_fill_progress: fill, activities_pending_details: pending } = status;
|
||||||
|
if (fill.Total > 0) {
|
||||||
|
// pending includes the current batch, so subtract what's already
|
||||||
|
// counted in this batch's Total to avoid double-counting the "beyond
|
||||||
|
// this batch" remainder.
|
||||||
|
const beyondBatch = Math.max(0, pending - (fill.Total - fill.Done));
|
||||||
|
return `syncing: ${fill.Done}/${fill.Total} activities${beyondBatch > 0 ? ` (+${beyondBatch} more queued)` : ""}`;
|
||||||
|
}
|
||||||
|
return "syncing...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GarminConnection() {
|
||||||
|
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
||||||
|
const [code, setCode] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||||
|
|
||||||
|
function refreshStatus() {
|
||||||
|
api.authStatus().then(setAuth).catch((e) => setError(String(e)));
|
||||||
|
api.syncStatus().then(setSyncStatus).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll faster while a sync is actually running, so progress feels live;
|
||||||
|
// back off to a relaxed interval the rest of the time.
|
||||||
|
const syncStatusRef = useRef(syncStatus);
|
||||||
|
syncStatusRef.current = syncStatus;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStatus();
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
const tick = () => {
|
||||||
|
refreshStatus();
|
||||||
|
timeout = setTimeout(tick, syncStatusRef.current?.in_progress ? 1500 : 6000);
|
||||||
|
};
|
||||||
|
timeout = setTimeout(tick, 1500);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setAuth(await api.login());
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMFA() {
|
||||||
|
if (!code.trim()) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setAuth(await api.submitMFA(code.trim()));
|
||||||
|
setCode("");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sync(kind: "run" | "backfill") {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await (kind === "run" ? api.syncRun() : api.syncBackfill());
|
||||||
|
refreshStatus();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = auth?.status ?? "unknown";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="garmin-connection">
|
||||||
|
<div className="garmin-connection-row">
|
||||||
|
<span className={`status-dot status-${status}`} />
|
||||||
|
<span className="status-label">
|
||||||
|
{status === "authenticated" && "Connected to Garmin"}
|
||||||
|
{status === "mfa_required" && "MFA code required"}
|
||||||
|
{status === "failed" && "Connection failed"}
|
||||||
|
{status === "unknown" && "Not connected to Garmin"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{status !== "authenticated" && status !== "mfa_required" && (
|
||||||
|
<button disabled={busy} onClick={connect}>
|
||||||
|
Connect to Garmin
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "authenticated" && (
|
||||||
|
<>
|
||||||
|
<button disabled={busy} onClick={() => sync("run")}>
|
||||||
|
Sync now
|
||||||
|
</button>
|
||||||
|
<button disabled={busy} onClick={() => sync("backfill")}>
|
||||||
|
Full backfill
|
||||||
|
</button>
|
||||||
|
{syncStatus?.in_progress && (
|
||||||
|
<span className="status-label">{syncProgressLabel(syncStatus)}</span>
|
||||||
|
)}
|
||||||
|
{syncStatus?.last_run && !syncStatus.in_progress && (
|
||||||
|
<span className="status-label">
|
||||||
|
last sync: {syncStatus.last_run.Status} ({syncStatus.last_run.ActivitiesFetched} activities)
|
||||||
|
{syncStatus.activities_pending_details > 0 &&
|
||||||
|
` — ${syncStatus.activities_pending_details} still need details, click Sync now again`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === "mfa_required" && (
|
||||||
|
<div className="garmin-connection-row">
|
||||||
|
<input
|
||||||
|
placeholder="MFA code"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
|
||||||
|
/>
|
||||||
|
<button disabled={busy} onClick={submitMFA}>
|
||||||
|
Submit code
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{auth?.message && <p className="garmin-connection-message">{auth.message}</p>}
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
frontend/src/components/charts/ProgressionChart.tsx
Normal file
36
frontend/src/components/charts/ProgressionChart.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||||
|
import type { ProgressionPoint } from "../../types/api";
|
||||||
|
|
||||||
|
const METRIC_LABELS: Record<string, string> = {
|
||||||
|
pace: "Pace (sec/km)",
|
||||||
|
hr: "Avg HR (bpm)",
|
||||||
|
vo2max: "VO2max",
|
||||||
|
aerobic_te: "Aerobic Training Effect",
|
||||||
|
anaerobic_te: "Anaerobic Training Effect",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProgressionChart({ points, metric }: { points: ProgressionPoint[]; metric: string }) {
|
||||||
|
if (points.length === 0) {
|
||||||
|
return <p className="empty-state">No data yet for this metric.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = points.map((p) => ({ date: p.date.slice(0, 10), value: p.value }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={320}>
|
||||||
|
<LineChart data={data} margin={{ top: 8, right: 16, bottom: 8, left: 8 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
reversed={metric === "pace"}
|
||||||
|
label={{ value: METRIC_LABELS[metric] ?? metric, angle: -90, position: "insideLeft", fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [Number(value).toFixed(1), METRIC_LABELS[metric] ?? metric]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="value" stroke="#3b82f6" strokeWidth={2} dot={{ r: 3 }} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/main.tsx
Normal file
9
frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
86
frontend/src/pages/Dashboard.tsx
Normal file
86
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { ProgressionChart } from "../components/charts/ProgressionChart";
|
||||||
|
import type { ProgressionMetric, ProgressionPoint, WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
|
const METRICS: { key: ProgressionMetric; label: string }[] = [
|
||||||
|
{ key: "pace", label: "Pace" },
|
||||||
|
{ key: "hr", label: "Heart Rate" },
|
||||||
|
{ key: "vo2max", label: "VO2max" },
|
||||||
|
{ key: "aerobic_te", label: "Aerobic Training Effect" },
|
||||||
|
{ key: "anaerobic_te", label: "Anaerobic Training Effect" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [selectedKindId, setSelectedKindId] = useState<number | null>(null);
|
||||||
|
const [metric, setMetric] = useState<ProgressionMetric>("pace");
|
||||||
|
const [points, setPoints] = useState<ProgressionPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.listWorkoutKinds()
|
||||||
|
.then((k) => {
|
||||||
|
setKinds(k);
|
||||||
|
if (k.length > 0) setSelectedKindId(k[0].ID);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedKindId == null) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.progression(selectedKindId, metric)
|
||||||
|
.then(setPoints)
|
||||||
|
.catch((e) => setError(String(e)))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [selectedKindId, metric]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Progression</h2>
|
||||||
|
|
||||||
|
{kinds.length === 0 ? (
|
||||||
|
<p className="empty-state">
|
||||||
|
No workout kinds defined yet. Create one on the Workout Kinds tab to start seeing progression here.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="controls">
|
||||||
|
<label>
|
||||||
|
Workout kind
|
||||||
|
<select
|
||||||
|
value={selectedKindId ?? ""}
|
||||||
|
onChange={(e) => setSelectedKindId(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<option key={k.ID} value={k.ID}>
|
||||||
|
{k.Name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Metric
|
||||||
|
<select value={metric} onChange={(e) => setMetric(e.target.value as ProgressionMetric)}>
|
||||||
|
{METRICS.map((m) => (
|
||||||
|
<option key={m.key} value={m.key}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{loading ? <p>Loading...</p> : <ProgressionChart points={points} metric={metric} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
frontend/src/pages/ReviewQueue.tsx
Normal file
89
frontend/src/pages/ReviewQueue.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { ReviewQueueItem, ScoredKind, WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
|
function candidates(item: ReviewQueueItem): ScoredKind[] {
|
||||||
|
try {
|
||||||
|
return JSON.parse(item.CandidateKindsJSON) as ScoredKind[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewQueue() {
|
||||||
|
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||||
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [resolvingId, setResolvingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
Promise.all([api.reviewQueue(), api.listWorkoutKinds()])
|
||||||
|
.then(([reviewItems, workoutKinds]) => {
|
||||||
|
setItems(reviewItems);
|
||||||
|
setKinds(workoutKinds);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
async function resolve(activityId: number, kindId: number) {
|
||||||
|
setResolvingId(activityId);
|
||||||
|
try {
|
||||||
|
await api.resolveReview(activityId, kindId);
|
||||||
|
setItems((prev) => prev.filter((i) => i.ActivityID !== activityId));
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setResolvingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Review Queue</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="empty-state">Nothing needs review right now.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="review-list">
|
||||||
|
{items.map((item) => {
|
||||||
|
const scored = candidates(item);
|
||||||
|
return (
|
||||||
|
<li key={item.ActivityID} className="review-item">
|
||||||
|
<div className="review-item-header">
|
||||||
|
<strong>{item.activity.ActivityName || item.activity.ActivityType}</strong>
|
||||||
|
<span>{item.activity.StartTimeUTC}</span>
|
||||||
|
</div>
|
||||||
|
<div className="review-item-stats">
|
||||||
|
<span>{(item.activity.DistanceMeters / 1000).toFixed(2)} km</span>
|
||||||
|
<span>{Math.round(item.activity.DurationSeconds / 60)} min</span>
|
||||||
|
{item.activity.AvgHR != null && <span>{Math.round(item.activity.AvgHR)} bpm avg</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scored.length > 0 && (
|
||||||
|
<p className="candidates">
|
||||||
|
Rule engine candidates: {scored.map((c) => `${c.name} (${c.score.toFixed(2)})`).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="review-item-actions">
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<button
|
||||||
|
key={k.ID}
|
||||||
|
disabled={resolvingId === item.ActivityID}
|
||||||
|
onClick={() => resolve(item.ActivityID, k.ID)}
|
||||||
|
>
|
||||||
|
{k.Name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
frontend/src/pages/WorkoutKinds.tsx
Normal file
148
frontend/src/pages/WorkoutKinds.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import type { WorkoutKind } from "../types/api";
|
||||||
|
|
||||||
|
const EXAMPLE_RULE = `{
|
||||||
|
"match": "all",
|
||||||
|
"conditions": [
|
||||||
|
{ "metric": "avg_pace_sec_per_km", "op": "between", "value": [330, 420] },
|
||||||
|
{ "metric": "avg_hr_pct_max", "op": "<=", "value": 0.75 }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
export function WorkoutKinds() {
|
||||||
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
|
const [editingId, setEditingId] = useState<number | "new" | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [ruleText, setRuleText] = useState(EXAMPLE_RULE);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
api.listWorkoutKinds(true).then(setKinds).catch((e) => setError(String(e)));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, []);
|
||||||
|
|
||||||
|
function startCreate() {
|
||||||
|
setEditingId("new");
|
||||||
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setRuleText(EXAMPLE_RULE);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(k: WorkoutKind) {
|
||||||
|
setEditingId(k.ID);
|
||||||
|
setName(k.Name);
|
||||||
|
setDescription(k.Description);
|
||||||
|
setRuleText(JSON.stringify(JSON.parse(k.RuleJSON || "{}"), null, 2));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
let rule: unknown;
|
||||||
|
try {
|
||||||
|
rule = JSON.parse(ruleText);
|
||||||
|
} catch {
|
||||||
|
setError("Rule is not valid JSON");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId === "new") {
|
||||||
|
await api.createWorkoutKind({ name, description, rule });
|
||||||
|
} else if (typeof editingId === "number") {
|
||||||
|
await api.updateWorkoutKind(editingId, { name, description, rule });
|
||||||
|
}
|
||||||
|
setEditingId(null);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
await api.deleteWorkoutKind(id);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reclassify(id: number) {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
const res = await api.reclassifyWorkoutKind(id);
|
||||||
|
setError(`Reclassified ${res.reclassified} activities.`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<h2>Workout Kinds</h2>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
|
||||||
|
<table className="kinds-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Active</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{kinds.map((k) => (
|
||||||
|
<tr key={k.ID}>
|
||||||
|
<td>{k.Name}</td>
|
||||||
|
<td>{k.Description}</td>
|
||||||
|
<td>{k.IsActive ? "yes" : "no"}</td>
|
||||||
|
<td className="kinds-table-actions">
|
||||||
|
<button onClick={() => startEdit(k)}>Edit</button>
|
||||||
|
<button disabled={busyId === k.ID} onClick={() => reclassify(k.ID)}>
|
||||||
|
Reclassify
|
||||||
|
</button>
|
||||||
|
<button disabled={busyId === k.ID} onClick={() => remove(k.ID)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{editingId === null ? (
|
||||||
|
<button onClick={startCreate}>+ New workout kind</button>
|
||||||
|
) : (
|
||||||
|
<div className="kind-editor">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Description
|
||||||
|
<input value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rule (JSON condition tree)
|
||||||
|
<textarea rows={12} value={ruleText} onChange={(e) => setRuleText(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="kind-editor-actions">
|
||||||
|
<button onClick={save}>Save</button>
|
||||||
|
<button onClick={() => setEditingId(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
frontend/src/types/api.ts
Normal file
116
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// Hand-written types mirroring backend/internal/api's JSON DTOs. Kept in
|
||||||
|
// sync manually while the API surface is small; revisit codegen once it
|
||||||
|
// stabilizes post-MVP.
|
||||||
|
|
||||||
|
export interface Activity {
|
||||||
|
ID: number;
|
||||||
|
GarminActivityID: number;
|
||||||
|
ActivityName: string;
|
||||||
|
ActivityType: string;
|
||||||
|
StartTimeUTC: string;
|
||||||
|
BeginTimestampMs: number;
|
||||||
|
DurationSeconds: number;
|
||||||
|
DistanceMeters: number;
|
||||||
|
AvgHR: number | null;
|
||||||
|
MaxHR: number | null;
|
||||||
|
AvgSpeedMps: number | null;
|
||||||
|
MaxSpeedMps: number | null;
|
||||||
|
ElevationGainM: number | null;
|
||||||
|
ElevationLossM: number | null;
|
||||||
|
Calories: number | null;
|
||||||
|
LapCount: number;
|
||||||
|
AerobicTrainingEffect: number | null;
|
||||||
|
AnaerobicTrainingEffect: number | null;
|
||||||
|
TrainingEffectLabel: string;
|
||||||
|
VO2MaxValue: number | null;
|
||||||
|
DetailsFetchedAt: string | null;
|
||||||
|
SplitsFetchedAt: string | null;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lap {
|
||||||
|
ID: number;
|
||||||
|
ActivityID: number;
|
||||||
|
LapIndex: number;
|
||||||
|
StartTimeUTC: string;
|
||||||
|
DurationSeconds: number;
|
||||||
|
DistanceMeters: number;
|
||||||
|
AvgHR: number | null;
|
||||||
|
MaxHR: number | null;
|
||||||
|
AvgSpeedMps: number | null;
|
||||||
|
MaxSpeedMps: number | null;
|
||||||
|
ElevationGainM: number | null;
|
||||||
|
ElevationLossM: number | null;
|
||||||
|
IntensityType: string;
|
||||||
|
HRDriftBpmPerMin: number | null;
|
||||||
|
HRRecoveryBpmPerMin: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkoutKind {
|
||||||
|
ID: number;
|
||||||
|
Name: string;
|
||||||
|
Description: string;
|
||||||
|
Color: string;
|
||||||
|
RuleJSON: string;
|
||||||
|
Priority: number;
|
||||||
|
IsActive: boolean;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoredKind {
|
||||||
|
workout_kind_id: number;
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KindAssignment {
|
||||||
|
ID: number;
|
||||||
|
ActivityID: number;
|
||||||
|
WorkoutKindID: number | null;
|
||||||
|
AssignmentSource: "rule_engine" | "manual";
|
||||||
|
Status: "assigned" | "needs_review";
|
||||||
|
Confidence: number | null;
|
||||||
|
CandidateKindsJSON: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewQueueItem extends KindAssignment {
|
||||||
|
activity: Activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressionPoint {
|
||||||
|
date: string;
|
||||||
|
activity_id: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncRun {
|
||||||
|
ID: number;
|
||||||
|
Kind: "backfill" | "incremental";
|
||||||
|
StartedAt: string;
|
||||||
|
FinishedAt: string | null;
|
||||||
|
ActivitiesFetched: number;
|
||||||
|
Status: "running" | "success" | "error";
|
||||||
|
ErrorMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
status: "authenticated" | "mfa_required" | "failed" | "unknown";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailFillProgress {
|
||||||
|
Done: number;
|
||||||
|
Total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
in_progress: boolean;
|
||||||
|
detail_fill_progress: DetailFillProgress;
|
||||||
|
activities_pending_details: number;
|
||||||
|
last_run?: SyncRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProgressionMetric = "pace" | "hr" | "vo2max" | "aerobic_te" | "anaerobic_te";
|
||||||
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
23
frontend/tsconfig.node.json
Normal file
23
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
frontend/vite.config.ts
Normal file
7
frontend/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user