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:
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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 1–4.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"`):
|
||||
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
1017
docs/superpowers/plans/2026-08-03-configuration.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user