refactor: merge internal/sync into internal/garmin, regroup api files and routes

Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes
garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes
internal/log; the test mock moves into the garmin package as MockClient
(breaking the test-only import cycle the merge created); stale test
URLs and type names updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 16:04:18 +02:00
parent 19c9aecdeb
commit e2b2bf9611
61 changed files with 2007 additions and 982 deletions

View File

@@ -1256,8 +1256,8 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
### Task 7: Classification reads max HR from the profile, not static config
**Files:**
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
- Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
**Interfaces:**
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1).
@@ -1265,7 +1265,7 @@ git commit -m "feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to
- [ ] **Step 1: Update the test first**
In `backend/internal/sync/service_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
In `../../../backend/internal/garmin/sync_test.go`, find this line inside `TestFillPendingDetailsAndClassify_EndToEnd` (currently constructs the service with `MaxHR: 190`):
```go
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
@@ -1284,7 +1284,7 @@ Expected: passes as-is right now (we haven't removed the field yet) — this ste
- [ ] **Step 3: Remove `MaxHR` from `Config` and source it from the profile in `ClassifyActivity`**
In `backend/internal/sync/service.go`, the `Config` struct currently ends with (lines 35-39):
In `../../../backend/internal/garmin/sync.go`, the `Config` struct currently ends with (lines 35-39):
```go
// MinConfidence is the classify.Classify threshold below which even a
@@ -1381,7 +1381,7 @@ Expected: all PASS. If `cmd/geniusrund/main.go` fails to build because it still
- [ ] **Step 6: Commit**
```bash
cd backend && git add internal/sync/service.go internal/sync/service_test.go
cd backend && git add internal/sync/sync.go internal/sync/sync_test.go
git commit -m "feat: classification reads max HR from the profile instead of static config"
```
@@ -1390,7 +1390,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
**Files:**
- Modify: `backend/internal/config/config.go` (full rewrite — small file, shown complete below)
- Modify: `../../../backend/internal/config/envconfig.go` (full rewrite — small file, shown complete below)
- Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
**Interfaces:**
@@ -1399,7 +1399,7 @@ git commit -m "feat: classification reads max HR from the profile instead of sta
- [ ] **Step 1: Rewrite `internal/config/config.go`**
Replace the full contents of `backend/internal/config/config.go` with:
Replace the full contents of `../../../backend/internal/config/envconfig.go` with:
```go
// Package config loads geniusrund's runtime infrastructure configuration
@@ -1590,7 +1590,7 @@ Expected: the server starts (no "GARMIN_EMAIL required" error), `/api/profile` r
- [ ] **Step 5: Commit**
```bash
cd backend && git add internal/config/config.go cmd/geniusrund/main.go
cd backend && git add internal/config/envconfig.go cmd/geniusrund/main.go
git commit -m "feat: source Garmin credentials from the profile instead of env vars"
```

View File

@@ -61,15 +61,15 @@ CLAUDE.md [MODIFY] document the new env vars / auth architectur
### Task 1: Config — OIDC/session settings
**Files:**
- Modify: `backend/internal/config/config.go`
- Create: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Create: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.OIDCIssuerURL/OIDCClientID/OIDCClientSecret/OIDCRedirectURL/OIDCRequiredRole string`, `Config.PublicBaseURL string`, `Config.SessionSecret []byte`, `Config.SessionDuration time.Duration`, `Config.SessionSecure bool` — consumed by Task 6 (`main.go`) to build `auth.OIDCConfig` and `api.SessionConfig`.
- [ ] **Step 1: Write the failing tests**
Create `backend/internal/config/config_test.go`:
Create `../../../backend/internal/config/envconfig_test.go`:
```go
package config
@@ -179,7 +179,7 @@ Expected: compile errors (`cfg.OIDCRedirectURL` etc. undefined) — that's the e
- [ ] **Step 3: Implement the config fields and validation**
In `backend/internal/config/config.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
In `../../../backend/internal/config/envconfig.go`, add `"strings"` to the imports, add these fields to `Config` (after `MinConfidence`/`IncrementalSyncEvery`):
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "config: add OIDC/session env vars for the login gate"
```

View File

@@ -17,25 +17,25 @@
- `GARMIN_WRAPPER_PYTHON` is optional, defaulting to `"python3"` (resolved via `PATH`) — `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are retired entirely, not renamed.
- `garminconnect`'s actual method kwargs must be used exactly: `get_activities_by_date(startdate, enddate)`, `get_activity_splits(activity_id)`, `get_activity_details(activity_id)`, `get_workout_by_id(workout_id)` (confirmed via `inspect.signature` against the installed package — note `startdate`/`enddate`, not `start_date`/`end_date`).
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, and `go test ./...` must all pass before the final commit.
- `pytest` must pass in `backend/internal/garmin/pyscript/`.
- `pytest` must pass in `../../../backend/internal/garmin/wrapper/`.
---
### Task 1: Python wrapper (`wrapper.py`) with its own test suite
**Files:**
- Create: `backend/internal/garmin/pyscript/wrapper.py`
- Create: `backend/internal/garmin/pyscript/pyproject.toml`
- Create: `backend/internal/garmin/pyscript/.gitignore`
- Test: `backend/internal/garmin/pyscript/tests/__init__.py`
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py`
- Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
- Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
- Create: `../../../backend/internal/garmin/wrapper/.gitignore`
- Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
- Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
**Interfaces:**
- Produces: `wrapper.dispatch(req: dict) -> dict` — the single entry point Go's subprocess talks to indirectly via stdin/stdout; also `wrapper._auth_state`, `wrapper._client`, `wrapper._mfa_input_queue`, `wrapper._login_result_queue`, `wrapper.Garmin` (re-exported name, patchable in tests), used only within this task's own test suite.
- [ ] **Step 1: Create the Python project manifest**
`backend/internal/garmin/pyscript/pyproject.toml`:
`../../../backend/internal/garmin/wrapper/pyproject.toml`:
```toml
[project]
@@ -55,7 +55,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
```
`backend/internal/garmin/pyscript/.gitignore`:
`../../../backend/internal/garmin/wrapper/.gitignore`:
```
.venv/
@@ -68,7 +68,7 @@ __pycache__/
Run:
```bash
cd backend/internal/garmin/pyscript
cd backend/internal/garmin/wrapper
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
```
@@ -76,9 +76,9 @@ Expected: installs `garminconnect` and `pytest` into `.venv` with no errors.
- [ ] **Step 3: Write the failing test suite**
`backend/internal/garmin/pyscript/tests/__init__.py`: empty file.
`../../../backend/internal/garmin/wrapper/tests/__init__.py`: empty file.
`backend/internal/garmin/pyscript/tests/test_wrapper.py`:
`../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
import os
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
- [ ] **Step 5: Write `wrapper.py`**
`backend/internal/garmin/pyscript/wrapper.py`:
`../../../backend/internal/garmin/wrapper/wrapper.py`:
```python
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
@@ -393,7 +393,7 @@ Expected: all tests PASS.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/garmin/pyscript
git add backend/internal/garmin/wrapper
git commit -m "$(cat <<'EOF'
feat(garmin): add direct garminconnect wrapper script
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
```go
// Package garmin wraps a direct garminconnect subprocess (see
// pyscript/wrapper.py) as a narrow Go client interface, so the rest of
// wrapper/wrapper.py) as a narrow Go client interface, so the rest of
// geniusrun never deals with the wire protocol directly.
package garmin
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF'
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client
subprocessClient spawns the embedded pyscript/wrapper.py over os/exec and
subprocessClient spawns the embedded wrapper/wrapper.py over os/exec and
speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only.
@@ -1370,8 +1370,8 @@ EOF
### Task 5: Update `internal/config` (drop server-path env var, default the python path)
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Consumes: nothing from Tasks 14.
@@ -1379,7 +1379,7 @@ EOF
- [ ] **Step 1: Write the failing tests**
In `backend/internal/config/config_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
In `../../../backend/internal/config/envconfig_test.go`, update `setRequiredEnv` (remove the two now-optional lines):
```go
func setRequiredEnv(t *testing.T) {
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/config/... -v`
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`GarminPythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
Expected: `TestLoad_GarminPythonPathDefaultsToPython3` fails (`PythonPath` is currently `""`, and `Load()` currently errors out entirely since `MCP_GARMIN_PYTHON`/`MCP_GARMIN_SERVER` are no longer set by the trimmed `setRequiredEnv`) — every other test in the package should also now fail with "MCP_GARMIN_PYTHON is required", confirming the removed env vars were the only thing missing.
- [ ] **Step 3: Update `config.go`**
- [ ] **Step 3: Update `envconfig.go`**
In `backend/internal/config/config.go`, replace the two Garmin subprocess-path fields:
In `../../../backend/internal/config/envconfig.go`, replace the two Garmin subprocess-path fields:
```go
// GarminPythonPath is mcp-garmin's venv python executable.
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "$(cat <<'EOF'
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
@@ -1528,7 +1528,7 @@ to:
}, appsync.Config{
```
- [ ] **Step 2: Update the `GarminBase` doc comment in `server.go`**
- [ ] **Step 2: Update the `ClientConfig` doc comment in `server.go`**
In `backend/internal/api/server.go`, change:
@@ -1749,4 +1749,4 @@ EOF
## Follow-up note (not a task in this plan)
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `backend/internal/garmin/pyscript/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.
The standalone `mcp-garmin` repo (`/Users/kriss/Dev/scm/scm.vilanet.fr/kriss/mcp-garmin`) is superseded by `../../../backend/internal/garmin/wrapper/` and can be archived once this plan lands — that's a separate-repo action for you to do directly (e.g. marking it archived on your SCM host), not something this plan's tasks touch.

View File

@@ -20,15 +20,15 @@
### Task 1: Add `FrontendURL` to `internal/config`
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `backend/internal/config/config_test.go`
- Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:**
- Produces: `Config.FrontendURL string` — set by `Load()`, defaults to `Config.PublicBaseURL` when `GENIUSRUN_FRONTEND_URL` is unset, trimmed of any trailing slash otherwise. Consumed by Task 2's `main.go` wiring.
- [ ] **Step 1: Write the failing tests**
Add to `backend/internal/config/config_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
Add to `../../../backend/internal/config/envconfig_test.go` (anywhere in the file, e.g. after `TestLoad_GarminPythonPathExplicitOverridesDefault`):
```go
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
@@ -65,7 +65,7 @@ Expected: compile failure — `Config.FrontendURL` doesn't exist yet.
- [ ] **Step 3: Add the field and default logic**
In `backend/internal/config/config.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
In `../../../backend/internal/config/envconfig.go`, add a new field right after `PublicBaseURL` in the `Config` struct:
```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -104,7 +104,7 @@ Expected: all tests PASS, including the two new ones.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go
git commit -m "$(cat <<'EOF'
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL

View File

@@ -2223,14 +2223,14 @@ EOF
## Task 10: `internal/sync.Service` becomes per-user
**Files:**
- Modify: `backend/internal/sync/service.go`
- Modify: `backend/internal/sync/service_test.go`
- Modify: `../../../backend/internal/garmin/sync.go`
- Modify: `../../../backend/internal/garmin/sync_test.go`
**Interfaces:**
- Consumes: every scoped store method from Tasks 4-8.
- Produces: `func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service`. Every other `Service` method (`Backfill`, `IncrementalSync`, `FullSync`, `ResetAll`, `FillPendingDetails`, `ClassifyActivity`, `Progress`) keeps its exact current signature — the userID is baked into the `Service` instance at construction (one `Service` per logged-in user, per the design), so callers in `internal/api` (Tasks 13/15) never pass a userID to these methods directly, only to `NewService` itself.
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `backend/internal/sync/service.go`**
- [ ] **Step 1: Add the `userID` field and thread it through every store call in `../../../backend/internal/garmin/sync.go`**
Change the `Service` struct and `NewService`:
@@ -2269,7 +2269,7 @@ Then, in every remaining method, prefix `s.userID` as the new argument to every
- `fillActivityDetails`: `s.db.SetActivityWorkout(ctx, a.ID, ...)``s.db.SetActivityWorkout(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceActivitySamples(ctx, a.ID, ...)``s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, ...)`; `s.db.ReplaceLaps(ctx, a.ID, ...)``s.db.ReplaceLaps(ctx, s.userID, a.ID, ...)`; `s.db.SetActivityDetails(ctx, a.ID, ...)``s.db.SetActivityDetails(ctx, s.userID, a.ID, ...)`; `s.db.SetActivitySplitsFetched(ctx, a.ID)``s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)`.
- `ClassifyActivity`: `s.db.GetActivity(ctx, activityID)``s.db.GetActivity(ctx, s.userID, activityID)`; `s.db.LapsForActivity(ctx, activityID)``s.db.LapsForActivity(ctx, s.userID, activityID)`; `s.db.ListWorkoutKinds(ctx, true)``s.db.ListWorkoutKinds(ctx, s.userID, true)`; `s.db.GetProfile(ctx)``s.db.GetProfile(ctx, s.userID)`; `s.db.InsertKindAssignment(ctx, store.KindAssignment{...})``s.db.InsertKindAssignment(ctx, s.userID, store.KindAssignment{...})`.
- [ ] **Step 2: Fix `backend/internal/sync/service_test.go`'s `NewService` calls**
- [ ] **Step 2: Fix `../../../backend/internal/garmin/sync_test.go`'s `NewService` calls**
Every `NewService(m, db, Config{...}, fixedNow(...))` call in this file needs a `userID` inserted as the third argument. Add a shared helper near the top of the file (next to `openTestDB`/`fixedNow`):
@@ -2355,7 +2355,7 @@ Run: `cd backend && gofmt -l internal/sync/`
Expected: no output.
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
sync: scope Service to one user per instance
@@ -2372,12 +2372,12 @@ EOF
## Task 11: `internal/config` additions
**Files:**
- Modify: `backend/internal/config/config.go`
- Modify: `../../../backend/internal/config/envconfig.go`
**Interfaces:**
- Produces: `Config.GarminTokenStoreRoot` (renamed from `GarminTokenStore`, same env var `GARMIN_TOKENSTORE`, now holds a root directory rather than a single path); `Config.LegacyOwnerOIDCSub` (new, optional, env var `GENIUSRUN_LEGACY_OWNER_OIDC_SUB`). Task 13's `main.go` wiring consumes both.
- [ ] **Step 1: Rename `GarminTokenStore` → `GarminTokenStoreRoot` and add `LegacyOwnerOIDCSub`**
- [ ] **Step 1: Rename `GarminTokenStore` → `TokenStoreRoot` and add `LegacyOwnerOIDCSub`**
In the `Config` struct, change:
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
Expected: no output.
```bash
git add backend/internal/config/config.go
git add backend/internal/config/envconfig.go
git commit -m "$(cat <<'EOF'
config: add per-user Garmin token store root and legacy-owner bootstrap var
@@ -2442,17 +2442,17 @@ EOF
## Task 12: User-resolution middleware + `POST /api/setup`
**Files:**
- Create: `backend/internal/api/usercontext.go`
- Create: `../../../backend/internal/api/user.go`
- Create: `backend/internal/api/setup.go`
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`)
- Create: `backend/internal/api/usercontext_test.go`
- Create: `../../../backend/internal/api/user_test.go`
- Create: `backend/internal/api/setup_test.go`
**Interfaces:**
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing).
- Produces: `func (s *Server) resolveUser(next http.Handler) http.Handler` (middleware); `func requireProvisionedUser(next http.Handler) http.Handler` (middleware); `func userFromContext(ctx context.Context) (resolvedUser, bool)`; `func userIDFromContext(ctx context.Context) int64`; `func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request)`. Task 13 wires all of these into the router; every handler task (14/15) calls `userIDFromContext`.
- [ ] **Step 1: Write the failing tests in `backend/internal/api/usercontext_test.go`**
- [ ] **Step 1: Write the failing tests in `../../../backend/internal/api/user_test.go`**
Since `auth`'s session context key is unexported, this test drives the real chain (`auth.RequireSession` then `s.resolveUser`) via a minted session cookie — the same `doJSON` helper `api_test.go` already uses for every other handler test — rather than trying to poke the context directly.
@@ -2521,7 +2521,7 @@ func TestRequireProvisionedUser_RejectsWhenNoUserResolved(t *testing.T) {
Run: `cd backend && go vet ./internal/api/...`
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`**
- [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
```go
package api
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/usercontext.go backend/internal/api/usercontext_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git add backend/internal/api/user.go backend/internal/api/user_test.go backend/internal/api/setup.go backend/internal/api/setup_test.go backend/internal/api/session.go backend/internal/api/server.go
git commit -m "$(cat <<'EOF'
api: add user-resolution middleware and POST /api/setup
@@ -3302,7 +3302,7 @@ func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
- [ ] **Step 4: Build everything and fix remaining call sites**
Run: `cd backend && go build ./... 2>&1 | head -50`
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `auth.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
Expected: remaining errors are all inside `internal/api/*.go` handler files (`profile.go`, `activities.go`, `sync.go`, `kinds.go`, `review.go`, `reclassify.go`, `progression.go`, `garmin.go`) and `cmd/seedsample/main.go` — referencing `s.Garmin`/`s.Sync` (now removed) or store methods missing `userID`. These are fixed in Tasks 14/15/17; confirm no errors remain in `server.go`, `main.go`, or `api_test.go` itself.
- [ ] **Step 5: `gofmt` and commit**
@@ -3325,12 +3325,12 @@ EOF
---
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `auth.go`, `progression.go`
## Task 14: Thread `userID` into `profile.go`, `kinds.go`, `garmin.go`, `progression.go`
**Files:**
- Modify: `backend/internal/api/profile.go`
- Modify: `backend/internal/api/kinds.go`
- Modify: `backend/internal/api/auth.go`
- Modify: `../../../backend/internal/api/garmin.go`
- Modify: `backend/internal/api/progression.go`
- Modify: `backend/internal/api/api_test.go` (no new call-site changes expected beyond what Task 13 already made — this task only touches handler bodies, not test bodies, since the HTTP-level test assertions are unchanged)
@@ -3508,7 +3508,7 @@ func (s *Server) handleUpdateWorkoutKind(w http.ResponseWriter, r *http.Request)
}
```
- [ ] **Step 3: Update `backend/internal/api/auth.go`**
- [ ] **Step 3: Update `../../../backend/internal/api/garmin.go`**
Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output.
```bash
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/auth.go backend/internal/api/progression.go
git add backend/internal/api/profile.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go
git commit -m "$(cat <<'EOF'
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID

View File

@@ -4,7 +4,7 @@
**Goal:** Make connecting a Garmin account a mandatory, persisted gate during onboarding (and on every subsequent login until it succeeds), instead of an optional step left to the Profile page.
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleAuthLogin`/`handleAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile``CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
**Architecture:** Add a nullable `garmin_connected_at` timestamp to `profile`, set once on the first successful Garmin authentication (`handleGarminAuthLogin`/`handleGarminAuthMFA`). Expose it via `GET /api/session/me`. `LoginGate.tsx` becomes a three-way fork (`!has_profile``CreateProfile`, `has_profile && !garmin_connected` → new `ConnectGarmin`, else → `App`), so a user who abandons the connect step re-enters at `ConnectGarmin` on their next login rather than skipping straight into the app.
**Tech Stack:** Go (`database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
@@ -246,7 +246,7 @@ git commit -m "feat(store): persist garmin_connected_at, set once on first succe
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
**Files:**
- Modify: `backend/internal/api/auth.go` (`recordAuthResult`, both call sites)
- Modify: `../../../backend/internal/api/garmin.go` (`recordAuthResult`, both call sites)
- Modify: `backend/internal/api/session.go` (`sessionMeResponse`, `handleSessionMe`)
- Modify: `backend/internal/api/api_test.go` (new tests)
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
@@ -342,7 +342,7 @@ func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: FAIL — `sessionMeResponse` has no field `GarminConnected`.
- [ ] **Step 3: Update `recordAuthResult` in `auth.go`**
- [ ] **Step 3: Update `recordAuthResult` in `garmin.go`**
Change the imports:
@@ -379,8 +379,8 @@ func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.
}
```
In `handleAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleGarminAuthLogin`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
In `handleGarminAuthMFA`, change `s.recordAuthResult(userID, res)` to `s.recordAuthResult(r.Context(), userID, res)`.
- [ ] **Step 4: Update `sessionMeResponse` and `handleSessionMe` in `session.go`**
@@ -446,7 +446,7 @@ Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/api/auth.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git add backend/internal/api/garmin.go backend/internal/api/session.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): persist and expose garmin_connected on successful auth"
```

View File

@@ -24,7 +24,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
- Frontend: `npm run build` (`tsc -b && vite build`) and `npm run lint` (oxlint) must be clean
before any commit. No frontend test suite exists -- verify new UI manually in a browser against
`cmd/seedsample` data.
- Reset all (`ResetAll`/`handleSyncReset`) is explicitly out of scope -- do not touch it beyond
- Reset all (`ResetAll`/`handleGarminSyncReset`) is explicitly out of scope -- do not touch it beyond
what's incidentally required (none is required).
- Follow this repo's existing JSON convention exactly: hand-built response maps use snake_case
keys (`in_progress`, `activities_pending_details`), but a Go struct serialized directly (like
@@ -40,7 +40,7 @@ frontend `SyncModal` component polls it and replaces all inline sync UI in `Garm
- Modify: `backend/internal/sync/service.go:99-123` (delete `Backfill`), `:194-209` (delete
`IncrementalSync`), `:224-232` (fix `FullSync`'s doc comment)
- Modify: `backend/internal/store/syncruns.go:9-11` (delete `SyncKindBackfill`/`SyncKindIncremental`)
- Modify: `backend/internal/sync/service_test.go` (rename/rewrite tests listed below)
- Modify: `../../../backend/internal/garmin/sync_test.go` (rename/rewrite tests listed below)
**Interfaces:**
- Consumes: nothing new.
@@ -58,7 +58,7 @@ check, not new information).
- [ ] **Step 2: Delete `Backfill` and `IncrementalSync`, fix `FullSync`'s doc comment**
In `backend/internal/sync/service.go`, delete this entire method (lines 99-123):
In `../../../backend/internal/garmin/sync.go`, delete this entire method (lines 99-123):
```go
// Backfill pages backward in Config.BackfillWindowDays windows until
@@ -187,7 +187,7 @@ const (
)
```
- [ ] **Step 4: Update `backend/internal/sync/service_test.go`'s callers**
- [ ] **Step 4: Update `../../../backend/internal/garmin/sync_test.go`'s callers**
Rename and rewrite (drop the redundant SyncRun assertion -- `TestFullSync_RecordsOneCombinedSyncRun`
already covers SyncRun recording thoroughly):
@@ -261,7 +261,7 @@ Expected: all packages pass, `gofmt -l .` prints nothing.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/store/syncruns.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/store/syncruns.go
git commit -m "$(cat <<'EOF'
refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
@@ -475,7 +475,7 @@ EOF
`fillActivityWorkout`)
- Modify: `backend/internal/sync/mapping.go:113-125` (`alignWorkoutTargets` signature)
- Modify: `backend/internal/garmin/mock/mock.go` (add `WorkoutErrByID`)
- Modify: `backend/internal/sync/service_test.go` (update `alignWorkoutTargets` call sites,
- Modify: `../../../backend/internal/garmin/sync_test.go` (update `alignWorkoutTargets` call sites,
rewrite `TestFillPendingDetails_ReportsLiveProgress`, add new tests)
**Interfaces:**
@@ -490,7 +490,7 @@ EOF
`alignWorkoutTargets` only ever uses `len(laps)`, never any lap's actual content -- change its
signature to take the count directly, since Task 3's `fillActivityWorkout` (added in Step 4)
needs to call it without holding a `[]garmin.Lap` (it only has `[]store.Lap` read back from the
DB). In `backend/internal/sync/service_test.go`, update all three call sites:
DB). In `../../../backend/internal/garmin/sync_test.go`, update all three call sites:
```go
targets := alignWorkoutTargets(len(laps), workout)
@@ -509,7 +509,7 @@ a `[]garmin.Lap`, not an `int` -- `len(laps)` is an `int`, mismatched argument t
- [ ] **Step 3: Change `alignWorkoutTargets`'s signature**
In `backend/internal/sync/mapping.go`, replace:
In `../../../backend/internal/garmin/mapping.go`, replace:
```go
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep {
@@ -553,7 +553,7 @@ one-extra-trailing-lap case stays accurate as-is.)
- [ ] **Step 4: Run to verify it passes**
Run: `cd backend && go test ./internal/sync/... -run TestAlignWorkoutTargets -v`
Expected: PASS (note: `fillActivityDetails` in `service.go` still calls
Expected: PASS (note: `fillActivityDetails` in `sync.go` still calls
`alignWorkoutTargets(splits.Laps, workout)` at this point -- that call site is rewritten in Step 6
below, so the package won't fully build until then; run this test with `-run
TestAlignWorkoutTargets` specifically, or expect a build failure from the not-yet-updated call
@@ -561,7 +561,7 @@ site if running the whole package).
- [ ] **Step 5: Add phase-aware `Progress` and update `FullSync`**
In `backend/internal/sync/service.go`, replace:
In `../../../backend/internal/garmin/sync.go`, replace:
```go
// Progress reports how far a currently-running (or just-finished)
@@ -843,7 +843,7 @@ func (c *Client) GetWorkoutByID(ctx context.Context, workoutID int64) (garmin.Wo
- [ ] **Step 8: Rewrite `TestFillPendingDetails_ReportsLiveProgress` for phase-awareness, add two new tests**
Replace `TestFillPendingDetails_ReportsLiveProgress` in `backend/internal/sync/service_test.go`
Replace `TestFillPendingDetails_ReportsLiveProgress` in `../../../backend/internal/garmin/sync_test.go`
with:
```go
@@ -1045,9 +1045,9 @@ func TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass(t
}
```
`fmt` is already imported in `service_test.go`'s package (`package sync` imports it transitively
`fmt` is already imported in `sync_test.go`'s package (`package sync` imports it transitively
via other test helpers)? Check: if `go vet`/`go build` complains `fmt` is undefined in
`service_test.go`, add `"fmt"` to that file's import block.
`sync_test.go`, add `"fmt"` to that file's import block.
- [ ] **Step 9: Run the full `internal/sync` test suite**
@@ -1063,7 +1063,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 11: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go backend/internal/sync/mapping.go backend/internal/garmin/mock/mock.go
git commit -m "$(cat <<'EOF'
feat(sync): split FillPendingDetails into activities/workouts phases
@@ -1095,7 +1095,7 @@ EOF
### Task 4: Reshape `GET /api/sync/status` and update frontend types
**Files:**
- Modify: `backend/internal/api/sync.go:69-102` (`handleSyncStatus`)
- Modify: `backend/internal/api/sync.go:69-102` (`handleGarminSyncStatus`)
- Modify: `backend/internal/api/api_test.go` (add a new test)
- Modify: `frontend/src/types/api.ts:197-207` (`DetailFillProgress`/`SyncStatus`, `SyncRun.Kind`)
@@ -1165,7 +1165,7 @@ handler doesn't produce `workouts_pending` or a nested `progress` object yet (st
- [ ] **Step 3: Update the handler**
In `backend/internal/api/sync.go`, replace `handleSyncStatus`:
In `backend/internal/api/sync.go`, replace `handleGarminSyncStatus`:
```go
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
@@ -1526,7 +1526,7 @@ EOF
- [ ] **Step 1: Replace the whole file**
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic completely unchanged. It loses:
`syncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `syncStatus` polling inside
`garminSyncStatus` state, `syncStatusRef`, the `syncProgressLabel` helper, `garminSyncStatus` polling inside
`refreshStatus`/the mount `useEffect`, and all inline sync-progress/last-sync JSX. It gains a
`showSyncModal` boolean and renders `<SyncModal>` when true.

View File

@@ -368,7 +368,7 @@ import (
)
```
Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
Add the `setupSession` field to `Server` and initialize it in `NewServer`:
```go
mu sync.Mutex
@@ -395,7 +395,7 @@ Add the `setupGarmin` field to `Server` and initialize it in `NewServer`:
}
```
Insert this block right after `NewServer` (before `garminFor`):
Insert this block right after `NewServer` (before `clientFor`):
```go

View File

@@ -270,7 +270,7 @@ git commit -m "feat(store): add DeleteUser with cascading account deletion"
### Task 2: `DELETE /api/profile` and Garmin client/tokenstore teardown
**Files:**
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeGarminClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/server.go:5-21` (imports), `:95-117` (new `removeUserClient` after `syncFor`), `:167-170` (route)
- Modify: `backend/internal/api/profile.go` (add `handleDeleteProfile`)
- Modify: `backend/internal/api/api_test.go` (imports + 3 new tests)
- Modify: `backend/internal/api/isolation_test.go` (1 new test)
@@ -427,7 +427,7 @@ func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: FAIL — 404s (no `DELETE /api/profile` route registered yet).
- [ ] **Step 3: Add the `os` import and `removeGarminClient` to `server.go`**
- [ ] **Step 3: Add the `os` import and `removeUserClient` to `server.go`**
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):

View File

@@ -4,7 +4,7 @@
**Goal:** Emit structured JSON logs on stdout for (1) every HTTP API request and (2) every Garmin wrapper subprocess call, correlated by a per-request id — without touching any existing `log.Printf` call site.
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `requestLoggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
**Architecture:** A new `internal/applog` package wraps `log/slog` with a JSON handler plus context helpers (`WithLogger`/`FromContext`). `internal/api` gains a `loggingMiddleware` that logs one line per HTTP request and stashes a request-id-tagged logger into the request context. `internal/garmin`'s `client.go` reads that same logger back out of context (via the `ctx` every `Client` method already receives) inside `roundTrip`, the single funnel point all six Garmin calls go through, logging one line per subprocess round-trip.
**Tech Stack:** Go stdlib `log/slog` (no new dependency), `github.com/go-chi/chi/v5/middleware` (already-available subpackage of the existing chi dependency).
@@ -20,7 +20,7 @@
**Files:**
- Create: `backend/internal/applog/applog.go`
- Create: `backend/internal/applog/applog_test.go`
- Create: `../../../backend/internal/log/log_test.go`
**Interfaces:**
- Produces: `func NewLogger(level string, w io.Writer) *slog.Logger`, `func WithLogger(ctx context.Context, logger *slog.Logger) context.Context`, `func FromContext(ctx context.Context) *slog.Logger` (never nil — falls back to `slog.Default()`).
@@ -91,7 +91,7 @@ func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
Run: `cd backend && go test ./internal/applog/... -v`
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
- [ ] **Step 3: Create `backend/internal/applog/applog.go`**
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
```go
// Package applog provides geniusrun's structured JSON logging: a
@@ -164,19 +164,19 @@ git commit -m "feat(applog): add JSON logger and context helpers"
### Task 2: HTTP access log middleware
**Files:**
- Modify: `backend/internal/config/config.go` (new `LogLevel` field)
- Modify: `backend/internal/config/config_test.go` (new tests)
- Modify: `../../../backend/internal/config/envconfig.go` (new `LogLevel` field)
- Modify: `../../../backend/internal/config/envconfig_test.go` (new tests)
- Modify: `backend/cmd/geniusrund/main.go` (wire up `slog.SetDefault`)
- Modify: `backend/internal/api/server.go` (`requestLoggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/server.go` (`loggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/api_test.go` (new tests)
**Interfaces:**
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
- Produces: `requestLoggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- [ ] **Step 1: Write the failing config tests**
Append to `backend/internal/config/config_test.go`:
Append to `../../../backend/internal/config/envconfig_test.go`:
```go
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
@@ -276,7 +276,7 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
Expected: FAIL — `LogLevel` field doesn't exist on `Config`; `applog` import unresolved; no log output produced (middleware doesn't exist).
- [ ] **Step 4: Add `LogLevel` to `config.go`**
- [ ] **Step 4: Add `LogLevel` to `envconfig.go`**
Change:
@@ -471,7 +471,7 @@ Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 9: Commit**
```bash
git add backend/internal/config/config.go backend/internal/config/config_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
git add backend/internal/config/envconfig.go backend/internal/config/envconfig_test.go backend/cmd/geniusrund/main.go backend/internal/api/server.go backend/internal/api/api_test.go
git commit -m "feat(api): add structured JSON access log with request-id correlation"
```

View File

@@ -37,8 +37,8 @@ missing" from "not yet fetched."
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
**Files:**
- Modify: `backend/internal/garmin/pyscript/wrapper.py` (imports, `dispatch`)
- Modify: `backend/internal/garmin/pyscript/tests/test_wrapper.py` (new test)
- Modify: `../../../backend/internal/garmin/wrapper/wrapper.py` (imports, `dispatch`)
- Modify: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py` (new test)
**Interfaces:**
- Consumes: `garminconnect.GarminConnectNotFoundError` (already installed as a dependency).
@@ -48,7 +48,7 @@ missing" from "not yet fetched."
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/garmin/pyscript/tests/test_wrapper.py`:
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
def test_call_marks_not_found_error_specifically():
@@ -77,14 +77,14 @@ def test_call_does_not_mark_other_errors_as_not_found():
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -k not_found -v`
(use whatever Python interpreter this repo's wrapper tests normally run under -- see
`backend/internal/garmin/pyscript/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
`../../../backend/internal/garmin/wrapper/.venv` if one exists, or the `GARMIN_WRAPPER_PYTHON` convention).
Expected: `test_call_marks_not_found_error_specifically` FAILS (`resp` has no `"not_found"` key
yet); `test_call_does_not_mark_other_errors_as_not_found` already passes (nothing to change for
that case).
- [ ] **Step 3: Update `dispatch()`**
In `backend/internal/garmin/pyscript/wrapper.py`, replace:
In `../../../backend/internal/garmin/wrapper/wrapper.py`, replace:
```python
from garminconnect import Garmin
@@ -144,7 +144,7 @@ Expected: all tests PASS, including both new ones and every existing test unchan
- [ ] **Step 5: Commit**
```bash
git add backend/internal/garmin/pyscript/wrapper.py backend/internal/garmin/pyscript/tests/test_wrapper.py
git add backend/internal/garmin/wrapper/wrapper.py backend/internal/garmin/wrapper/tests/test_wrapper.py
git commit -m "$(cat <<'EOF'
feat(garmin): mark a wrapper 404 with not_found in the error response
@@ -659,8 +659,8 @@ EOF
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
**Files:**
- Modify: `backend/internal/sync/service.go` (`fillPendingWorkouts`)
- Modify: `backend/internal/sync/service_test.go` (new test)
- Modify: `../../../backend/internal/garmin/sync.go` (`fillPendingWorkouts`)
- Modify: `../../../backend/internal/garmin/sync_test.go` (new test)
**Interfaces:**
- Consumes: `garmin.ErrNotFound` (Task 2), `db.SetActivityWorkoutNotFound` (Task 3).
@@ -668,7 +668,7 @@ EOF
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/sync/service_test.go`, right after
Add to `../../../backend/internal/garmin/sync_test.go`, right after
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
```go
@@ -743,7 +743,7 @@ regardless of the error's nature).
- [ ] **Step 3: Update `fillPendingWorkouts`**
In `backend/internal/sync/service.go`, replace:
In `../../../backend/internal/garmin/sync.go`, replace:
```go
for i, a := range pending {
@@ -794,7 +794,7 @@ with:
}
```
Add `"errors"` to `service.go`'s import block.
Add `"errors"` to `sync.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
@@ -817,7 +817,7 @@ Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 7: Commit**
```bash
git add backend/internal/sync/service.go backend/internal/sync/service_test.go
git add backend/internal/sync/sync.go backend/internal/sync/sync_test.go
git commit -m "$(cat <<'EOF'
fix(sync): stop retrying a workout Garmin confirms is gone

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@ This replaces the MCP transport with a small custom Python subprocess wrapper ar
- No change to what data geniusrun actually syncs/stores/classifies today — only the transport between Go and Python changes. `internal/sync`, `internal/classify`, `internal/store`, and the frontend are untouched.
- No security sandboxing of the generic dispatcher (see below) — the subprocess is spoken to only by geniusrun's own Go process over a private pipe it owns, not exposed to any other caller, so an open dispatch surface is an accepted risk, not a gap.
- No auto-restart-on-crash logic for the subprocess. Matches today's behavior: a dead subprocess surfaces as a Go error on next use; only `UpdateCredentials` explicitly tears down and respawns.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `garminFor` caching in `api.Server`.
- No change to the per-user token store layout (`{GarminTokenStoreRoot}/{userID}`) or the multi-tenant `clientFor` caching in `api.Server`.
## Wire protocol
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored.
- Existing per-user token stores under `GarminTokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Existing per-user token stores under `TokenStoreRoot` are unaffected (same `GARMIN_TOKENSTORE` env var name passed to the subprocess, same directory layout) — no re-login required for existing users after cutover.
- Deploy order: ship the new wrapper + Go client together as one change (interface unchanged, so this is an internal swap, not a rolling/compat-sensitive migration).

View File

@@ -85,7 +85,7 @@ A new middleware step runs immediately after the existing `RequireSession` (role
`internal/garmin.Client` and `internal/sync.Service` already take their config/dependencies as constructor params with no hidden global state, so per-user instantiation is mechanical:
- `api.Server`'s current single fixed `Garmin`/`Sync` fields and flat Garmin-auth-status fields (today: plain fields on `Server`, single-user assumption) become two mutex-guarded maps: `map[int64]*garmin.Client` and `map[int64]*sync.Service`, keyed by `user_id`, lazily constructed on first access for a given user.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `GarminTokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- A per-user `garmin.Client` is built from that user's own `profile` row (`GarminEmail`/`GarminPassword`) and a per-user token store path: `filepath.Join(cfg.GarminTokenStoreRoot, strconv.FormatInt(userID, 10))`. `GarminTokenStore` in `internal/config` is renamed/repurposed to `TokenStoreRoot` (a directory, not a single tokenstore path) to reflect this.
- `sync.Service.Progress()` becomes naturally per-user once each user has their own `Service` instance — no separate change needed there.
- `store.DB` stays a single shared connection against one SQLite file — this design keeps the single-shared-DB-with-`user_id`-columns approach, so no per-user DB file/connection is needed.
@@ -125,5 +125,5 @@ Every existing handler in `internal/api` that reads/writes `profile`, `activitie
## Rollout notes
- Before deploying: log into the current single-tenant build once, note your `sub` from `GET /api/session/me`, and set `GENIUSRUN_LEGACY_OWNER_OIDC_SUB` to it for the first startup after upgrading.
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`GarminTokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- `GARMIN_TOKENSTORE` env var / `GarminTokenStore` config field is repurposed as a root directory (`TokenStoreRoot`) rather than a single tokenstore path — existing deployments need to point it at a directory rather than mcp-garmin's old single-file/dir location, and any already-cached Garmin session under the old path is effectively invalidated (that one user will need to log into Garmin again post-migration, once, under their new per-user subdirectory).
- No frontend routing changes beyond the new setup screen — everything else (Dashboard, Review Queue, Plan stub, Profile) is unchanged in shape, just now reflecting whichever user is logged in.

View File

@@ -69,7 +69,7 @@ After the schema edit, regenerate `docs/DATABASE.md` via `go run
### Backend API
- **`handleAuthLogin`/`handleAuthMFA`** (`internal/api/auth.go`): right after
- **`handleGarminAuthLogin`/`handleGarminAuthMFA`** (`internal/api/auth.go`): right after
the existing `s.recordAuthResult(userID, res)` call, if `res.Status ==
garmin.AuthSuccess`, call `s.DB.MarkGarminConnected(r.Context(), userID)`.
A failure here is logged and does *not* fail the HTTP response -- the

View File

@@ -22,7 +22,7 @@ only production caller of the core logic) calls `backfillCore`/`incrementalSyncC
Only `internal/sync/service_test.go` still calls `Backfill`/`IncrementalSync`, which is the only
reason they're not already flagged as unused by the compiler.
Since this plan already restructures `service.go`'s progress model, remove these two dead
Since this plan already restructures `sync.go`'s progress model, remove these two dead
exported methods (and their now-inaccurate doc comments) as an early task, rewriting the tests
that called them to exercise the same behavior through `FullSync` or the `*Core` functions
directly (same package, so unexported functions are still directly testable) -- before any of
@@ -120,7 +120,7 @@ able to report it; the frontend type was just never updated to match).
## Frontend design
`GarminConnection.tsx` keeps its connect/MFA/Reset-all logic and buttons untouched, but loses
its `syncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
its `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
JSX.
A new `SyncModal.tsx`:

View File

@@ -31,7 +31,7 @@ commit); it just no longer gates anything on its own.
### The core problem: Garmin auth needs an account today
`garminFor` builds a per-user `garmin.Client` keyed by a DB user id and
`clientFor` builds a per-user `garmin.Client` keyed by a DB user id and
reads Garmin credentials from that user's `profile` row. To authenticate
with Garmin *before* an account exists, onboarding needs a **temporary,
not-yet-persisted** Garmin session -- keyed by the OIDC subject (already
@@ -45,7 +45,7 @@ finish onboarding.
### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing
`userGarmin`/`userSync`/etc. per-user maps):
`userClient`/`userSync`/etc. per-user maps):
```go
// setupSession is a temporary, not-yet-persisted Garmin authentication
@@ -81,7 +81,7 @@ Helper methods on `Server` (`server.go`):
spawn new" semantics as `garmin.Client.UpdateCredentials`), builds a
fresh ephemeral client whose `TokenStorePath` is namespaced under
`{root}/setup/{sub}` (distinct from the permanent `{root}/{userID}`
namespacing `garminFor` uses, so two people onboarding concurrently never
namespacing `clientFor` uses, so two people onboarding concurrently never
collide, and so the ephemeral session's persisted Garmin tokens have
*some* home even though no user id exists yet).
- `recordSetupAuthResult(sub string, res garmin.AuthResult)` -- updates a
@@ -140,7 +140,7 @@ r.Route("/setup", func(r chi.Router) {
resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error.
`internal/api/auth.go`'s existing `handleAuthLogin`/`handleAuthMFA`/`handleAuthStatus`
`internal/api/auth.go`'s existing `handleGarminAuthLogin`/`handleGarminAuthMFA`/`handleGarminAuthStatus`
(used by the Profile page's `GarminConnection.tsx` for reconnecting an
already-provisioned user) are untouched.
@@ -204,7 +204,7 @@ simpler than threading a separate `"mfa"` step through, and matches how
`/api/setup/complete` after a successful session creates the user with
`GarminEmail`/`GarminPassword`/`GarminConnectedAt` all set, and promotes
the *same* `*mock.Client` instance into `s.userGarmin[userID]` (asserted
via `garminFor` returning that exact pointer, and `ClosedCalled` still
via `clientFor` returning that exact pointer, and `ClosedCalled` still
false -- proving no redundant re-authentication happened);
`/api/setup/complete` with no prior login attempt → 409;
already-provisioned subject hitting either endpoint → 409; two different

View File

@@ -74,7 +74,7 @@ the `requireProvisionedUser` group (`server.go`). `userID` comes from
`client.UpdateCredentials` outside `s.mu`).
- Best-effort `os.RemoveAll` on `filepath.Join(s.GarminBase.TokenStorePath,
strconv.FormatInt(userID, 10))` when `TokenStorePath` is configured --
the same path `garminFor` computes when building a client. Log on
the same path `clientFor` computes when building a client. Log on
error; this is cleanup of an already-orphaned directory, not something
that should fail the request that already deleted the DB row.
4. Respond `204 No Content`.

View File

@@ -75,7 +75,7 @@ which falls back to this default.
### HTTP access log (`internal/api`)
A new `requestLoggingMiddleware`, registered as the **first** `r.Use(...)`
A new `loggingMiddleware`, registered as the **first** `r.Use(...)`
in `Router()` (ahead of `corsMiddleware`), so it wraps every request
including unauthenticated ones (login redirect, health check) and OPTIONS
preflights:

View File

@@ -128,7 +128,7 @@ Every current inline-error site, and what changes:
| `pages/Analysis.tsx` | Local `error` state, inline paragraph. | Same. |
| `components/TrainingTypesCard.tsx` | Local `error` state, inline paragraph. | Same. |
| `LoginGate.tsx` | Reads `?auth_error=` from the URL synchronously during render, shows `<p className="login-gate-error">` with a mapped message. | A mount effect reads `?auth_error=` once and calls `showError(mappedMessage)` (same `AUTH_ERROR_MESSAGES` mapping as today); the inline paragraph and its CSS class are removed. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `submitMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
| `OnboardingWizard.tsx` | Single `error` state used for both field validation ("Please enter a display name.", "Please enter your Garmin email and password.") **and** real failures (Garmin login rejected, MFA rejected, `complete()` failing after a successful Garmin auth) — all rendered via the same `onboarding-wizard-error` paragraph, in 4 places. | **Split.** Field-validation messages stay exactly as they are today (local state, inline paragraph, tied to a specific input the user needs to fix — a transient top-of-page banner is the wrong medium for "you left a required field blank"). Real failures (`submitGarmin`'s catch, `garminMFA`'s catch, `complete()`'s catch) call `showError(...)` instead of `setError(...)`. The `complete()`-failure Retry button, which today is conditionally rendered on `error &&`, switches to a new local boolean `completeFailed` (set `true` in that catch block) — the button's visibility no longer depends on the error text still being present, since that text now lives only in the (transient, auto-dismissing) banner. |
**Dead CSS removal:** once no `.tsx` file references them, delete the
`.error`, `.onboarding-wizard-error`, and `.login-gate-error` rules from