Compare commits

..

95 Commits

Author SHA1 Message Date
5b238b3f54 docs: align historical plans/specs with renamed identifiers (roundTrip -> execute, log schema)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:24:05 +02:00
5ebb0f756d feat(frontend): pathname-addressable views, deep links survive login, config page layout
Every view (/activities, /analysis, /plan, /profile, /config) is now
driven by the URL -- one pushState/popstate navigate() replaces the
tab/profile/config state flags, so all views deep-link, survive reload,
and honor Back/Forward. A logged-out visit to any path is stashed by
LoginGate and restored (replaceState) on the first render after the
OIDC round-trip, which otherwise always lands on '/'. The config page
gets Profile-style card spacing and a wider card so environment values
fit on one line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:23:05 +02:00
49ebd289a4 chore: untrack Garmin token store, ignore token/db-sidecar/IDE files
backend/.garmin (live Garmin OAuth tokens) was accidentally swept into
1ba7724 by a broad git add; stop tracking it and ignore it, along with
SQLite -shm/-wal sidecars and the rest of .idea.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 20:21:03 +02:00
10cdc4176a refactor(log): split location into package/file/class/method, auto-derived
Per the log schema: 'package' is the Go package (absent on
Python-emitted lines), 'file' the Go/Python source basename, 'class' the
Go receiver or Python class (omitted when there is none -- free
functions no longer masquerade under a package-as-class), 'method' the
emitting function. applog.App() now takes no arguments and derives all
of it from runtime.Caller, so labels can never drift from the code; the
manual http/wrapper emitters and forwarded wrapper.py lines (file=
wrapper.py, no package) carry the same fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:59:48 +02:00
6effb79097 refactor(log): unified schema — mandatory type/class/method, request_id removed
Every log record now carries type ('http' for the request middleware
line, 'wrapper' for garmin execute() calls and forwarded wrapper.py
stderr, 'app' for everything else), class (Go type with package, or the
package/module for free functions), and method (the emitting Go/Python
function -- wrapper.py stamps it automatically, so its msg prefixes are
gone). msg is optional and omitted when empty; the redundant messages
('HTTP request', 'wrapper call', 'garmin wrapper stderr') are dropped,
the HTTP verb moves to http_method, source=garmin-wrapper is replaced by
type=wrapper, and the request_id middleware plumbing (applog
WithLogger/FromContext) is removed. applog.App(class, method) is the
tagging helper for app records.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 19:45:30 +02:00
78c494affb fix(garmin): startup tokenstore resume recovers after its 10s timeout
The background login thread now flips _auth_state to authenticated
itself when a slow tokenstore resume succeeds after the 10s wait --
previously its result landed in an abandoned private queue and the
docstring's promised late recovery never happened, leaving every
subsequent call failing until an explicit authenticate. Guarded to only
ever transition from unauthenticated, so an explicit authenticate/MFA
flow that took over meanwhile is never clobbered; the timeout branch no
longer re-writes the state (a redundant write that could race a success
at the boundary). Structured-log messages reworked alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
0e6cf00dba refactor(config): mandatory app-config rows seeded in DB; rename to session.setup_timeout
All registry keys must exist as rows in the config table: main seeds
missing keys with their defaults at startup, LoadApp fails fast on a
missing key, and the code-side fallback const/helper for the onboarding
setup timeout is gone -- the value rides in SessionConfig.SetupTimeout.
The key is renamed session.idle_timeout -> session.setup_timeout, and
the /config page's 'overridden' now means 'differs from the default'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 18:17:24 +02:00
8c9285f33c fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import
session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:44:37 +02:00
dcbb1d8bb0 feat(garmin): all-JSON wrapper protocol and log stream
Error responses from wrapper.py now carry error_type and traceback in
the protocol itself, logged as attrs on the Go side's per-call record.
wrapper.py's free-text stderr debug prints become JSON log lines
({level,msg,...attrs}); client.go parses that stream and re-emits it
through the application logger (level-mapped, source=garmin-wrapper),
wrapping any non-JSON line instead of passing it through raw. The
wrapper also survives malformed request lines and unserializable
results with JSON responses instead of crashing with a raw traceback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:42:25 +02:00
431315bac3 refactor(log): replace stdlib log with the application JSON logger
Every log.Printf/Fatalf becomes a structured slog call through
internal/log: request handlers use the request-scoped logger
(applog.FromContext, carrying request_id), startup failures exit via a
fatal() helper that still emits JSON, and the seedsample/dumpschema CLIs
bootstrap the same JSON logger. Stale client_test expectations aligned
with the refactored wrapper-call logging (message casing, result attr,
ERROR level).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:33:26 +02:00
042579a396 refactor(store): single account name on users; rename profile/laps tables
users.display_name becomes users.name and is now the only human-facing
name -- profiles.name is dropped (it duplicated the account name; the
Profile page's Name field now edits users.name through PUT /api/profile,
which carries Name alongside the profiles columns). The profile table
becomes profiles and laps becomes activity_laps, homogeneous with
activity_samples. Onboarding pre-fills the display name from the OIDC
claim's name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:11:23 +02:00
e2b2bf9611 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>
2026-08-04 16:04:18 +02:00
19c9aecdeb docs(skill): update renamed env vars in geniusrun-dev skill
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 06:46:10 +02:00
bee3d7e493 feat(frontend): /config page + icon-only header (profile/settings/logout)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:20:02 +02:00
d45489714d feat(api): GET/PUT /api/config serving app + env configuration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:16:26 +02:00
e6cb21c65a feat(config): app-config registry, env-var renames, wire session.duration from DB
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:11:56 +02:00
f6712497ba feat(store): config table for instance-global app-config overrides
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:06:22 +02:00
8508e89ad7 chore: onboarding/MFA-modal WIP, dev-workflow guidance, ideas updates
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
27af77418e feat(store): default backfill horizon to 90 days
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a2f24273ce feat(frontend): error banners persist until manually dismissed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
a394bbb770 refactor(api): merge review-queue into /api/activities, rename resolve to assign
Removes the unused GET /api/activities list/detail endpoints, moves the
review-queue list and resolve/unlock/unassign actions under
/api/activities, renames resolve to assign end-to-end, and drops the
now-unused store.ReviewQueue helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:04:03 +02:00
eb24dcd89d docs: spec for application vs environment configuration
Design for the 'configuration' backlog idea: instance-global key/value
config table (overrides only, cold reload), env-var renames, /api/config
GET/PUT, and the /config page with header icon buttons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:47:57 +02:00
c40f6634f2 test(store): cover SetActivityWorkoutNotFound adversarially for user isolation
SetActivityWorkoutNotFound is a per-user-scoped store method but never got
the adversarial cross-user coverage CLAUDE.md's testing conventions require
for such features. Extend the existing isolation test with a mismatched-user
call (must no-op) and a correct-user call (must exclude the activity from
that user's own ActivitiesMissingWorkout/count without touching the other
user's pending activity).
2026-07-27 19:16:43 +02:00
6f6d1ea5e0 fix(sync): stop retrying a workout Garmin confirms is gone
fillPendingWorkouts previously treated every get_workout_by_id
failure identically -- log it, leave workout_raw_json null, retry
next sync -- which is correct for a transient failure but means a
definitive 404 (the workout deleted on Garmin's side after being
linked to an activity) got silently retried forever, with the only
symptom being an unexplained, permanent "1 more workout pending"
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
that case, calls SetActivityWorkoutNotFound instead of retrying.
2026-07-27 19:03:09 +02:00
7d371219b7 feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
2026-07-27 18:58:21 +02:00
c6e2c5c00e feat(garmin): expose ErrNotFound for a wrapper-reported 404
roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
2026-07-27 18:53:59 +02:00
1d878e0f46 feat(garmin): mark a wrapper 404 with not_found in the error response
garminconnect's own GarminConnectNotFoundError already exists
specifically for this (its docstring: "so callers can now catch a
missing resource specifically, e.g. deleting an already-deleted
workout"), raised by connectapi() for any real HTTP 404. dispatch()
now surfaces that distinction as an extra not_found: true field
alongside the existing error string, so internal/garmin/client.go
(next commit) can tell a definitive 404 apart from a transient
failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:50:43 +02:00
b5cd47c219 docs: add implementation plan for permanently-missing-workout handling
4 tasks: mark a wrapper-level 404 as not_found, expose it as
garmin.ErrNotFound in the Go client, add a workout_not_found_at
column + store query updates, then have fillPendingWorkouts stop
retrying a confirmed-404 workout instead of retrying forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:45:11 +02:00
67f4ec27ab docs: add design spec for permanently-missing-workout handling
A workout deleted on Garmin's side after being linked to an activity
404s forever on get_workout_by_id, and today's fillPendingWorkouts
retries it every sync indefinitely with no user-visible feedback --
just a perpetual "1 more workout pending" nudge. Distinguishes a
definitive 404 (via garminconnect's own GarminConnectNotFoundError)
from a transient failure, and stops retrying the former.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:00:38 +02:00
fe344867c0 fix(frontend): show a banner when LoginGate can't reach the backend
LoginGate's initial session check swallowed every failure into the
same "unauthenticated" branch, so a genuinely unreachable backend
looked identical to a normal logged-out state -- the one screen in
the app where "backend not here" showed no feedback at all, since
every banner-migrated component only renders after authentication.

api/client.ts's request() now throws a dedicated NetworkError (a
distinct type, not just a distinguishable message) for a fetch()
failure specifically, so LoginGate.tsx's catch can tell that apart
from a real 401 via instanceof and show a banner before falling
through to "unauthenticated" either way.

Verified with a real headless-browser run against the dev server
with no backend: the banner renders correctly and coexists with the
existing ?auth_error= banner without conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 11:00:00 +02:00
0e3295b746 fix(auth): stop flipLastChar's tamper tests from flaking
Corrupting the true last character of a JWT's base64url-encoded
HMAC-SHA256 signature is unreliable: that character encodes only 4
real bits plus 2 unused padding bits, and Go's encoding/base64
ignores those padding bits by default -- about 1 in 4 replacement
characters decode to byte-identical signature bytes, so the
"tampered" cookie still verifies and the test spuriously passes.
Corrupting the second-to-last character instead is deterministic,
since HMAC-SHA256's fixed 32-byte digest length means that position
is always fully significant.

Confirmed via 20 repeated runs (previously ~1/3 failure rate).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 10:59:49 +02:00
f9b1d454bc fix(frontend): dedupe identical banners in banner.ts
Multiple independent requests that fail with the same message (e.g.
Activities.tsx's mount effect firing loadFirstPage/listWorkoutKinds/getProfile
separately) each call showError() independently, previously stacking
2-3 textually-identical banners for a single root cause (backend down,
brief network blip). show() now skips adding a banner if one with the
same severity+message is already visible, and skips scheduling a
redundant auto-dismiss timer for it -- the original banner's timer
keeps running untouched.
2026-07-27 10:34:57 +02:00
83a59d0fdd chore(frontend): remove the now-dead .error CSS class
Every inline error paragraph that used it has been migrated to the
banner system across the preceding tasks.
2026-07-27 10:20:14 +02:00
3cf5d4b747 refactor(frontend): split OnboardingWizard's validation vs real failures
Field-validation messages ("please fill this in") stay local and
inline. Real failures (Garmin login rejected, MFA rejected, complete()
failing) now call showError. The complete()-failure Retry button's
visibility switches from the error text's presence to a new
completeFailed boolean, since the error text itself now lives only in
the (transient) banner.
2026-07-27 10:11:18 +02:00
273731442f refactor(frontend): migrate LoginGate.tsx's auth_error to the banner system 2026-07-27 10:07:00 +02:00
7e960e3ce1 refactor(frontend): migrate TrainingTypesCard.tsx to the banner system 2026-07-27 10:01:56 +02:00
687dc2bca2 refactor(frontend): migrate Analysis.tsx to the banner system 2026-07-27 09:59:32 +02:00
668fb9efb8 refactor(frontend): migrate Activities.tsx to the banner system
Every load/classify/unlock failure now calls showError instead of
setting local error state; the inline error paragraph is removed.
2026-07-27 09:56:33 +02:00
940295510e refactor(frontend): migrate Profile.tsx to the banner system
Load/save/delete failures now call showError instead of setting local
error state. The profile-not-loaded-yet fallback gains a
profileLoadFailed boolean so it can still distinguish "loading" from
"failed to load" without the error text itself, which now lives only
in the (transient) banner.
2026-07-27 09:53:28 +02:00
a5fffff737 refactor(frontend): SyncModal reports its result via a banner, auto-closes
The modal is now purely the progress bar/spinner. The instant a poll
reports the sync finished, it builds one banner message (the
success/error line plus any pending-activities/pending-workouts
nudge as extra lines) and closes itself -- no more manual Close click
to dismiss a result the user already saw. The Close button stays as a
manual escape hatch for a still-running sync.
2026-07-27 09:47:53 +02:00
d48bbfd43e refactor(frontend): migrate GarminConnection.tsx to the banner system
Removes the local error state and its inline paragraph; every failure
(connect, MFA, sync trigger, reset-all) now calls showError directly.
2026-07-27 09:44:53 +02:00
a147f5cffa fix(frontend): show a friendly message when the backend is unreachable
request()'s fetch() call throwing (network down, connection refused,
offline) previously propagated the raw browser error text (e.g.
"TypeError: Failed to fetch"). Centralizing the friendly message here,
rather than in each call site, means every existing
catch((e) => showError(String(e))) pattern gets it for free.
2026-07-27 09:41:34 +02:00
aef58fd529 feat(frontend): add shared banner system (error/warning/notice/success)
A plain module-level singleton store (banner.ts) plus a single renderer
(BannerStack, mounted once above LoginGate in main.tsx) -- no React
Context, since this codebase has none today and a plain exported
function is callable from anywhere, including api/client.ts (a plain
module, not a component) in a later task. Nothing calls it yet; that's
each of this plan's remaining tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:37:24 +02:00
3bf11ae6bf docs: add implementation plan for modern error displays
11 tasks: the shared banner store + renderer, a friendlier
network-unreachable message in client.ts, then migrating every
existing inline error site (GarminConnection, SyncModal, Profile,
Activities, Analysis, TrainingTypesCard, LoginGate, OnboardingWizard)
to it, finishing with dead-CSS cleanup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:31:57 +02:00
b2c705ce29 docs: add design spec for modern error displays
Replaces every page/component's ad-hoc inline error paragraph with a
shared, module-level banner store (red/orange/blue/green, stacking,
auto-dismiss + manual close) -- from docs/IDEAS.md's backlog item.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:22:47 +02:00
6e01037832 fix(frontend): stop SyncModal polling once sync has finished
It polled /api/sync/status every 1.5s indefinitely even after
in_progress became false and the final result was already shown,
only stopping on unmount (clicking Close). Skip scheduling the next
poll once a fetched status reports the sync is no longer running.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:38 +02:00
f7e7b68078 test(sync): cover FullSync's phase transitions end-to-end
TestFillPendingDetails_ReportsPhaseAwareProgressThroughActivitiesAndWorkoutsPhases
only ever called FillPendingDetails directly, never exercising
FullSync's own setProgress(PhaseDiscovering, ...) / deferred
setProgress(PhaseIdle, ...) wiring around backfillCore/
incrementalSyncCore -- what handleSyncRun actually runs in production
and what SyncModal's "Discovering activities..." spinner depends on.

Add a mock.Client.Delay field (slept, ctx-cancellable, at the start of
GetActivities) so a test can give the discovering phase real
wall-clock duration, then add
TestFullSync_ReportsPhaseTransitionsThroughDiscoveringToIdle, which
runs FullSync in a goroutine and polls Progress() to confirm it
observes PhaseDiscovering mid-flight and PhaseIdle after completion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:30 +02:00
3a7f305221 docs(store): update stale SyncRun doc comment
Every run recorded today is SyncKindFull (the single-pass "Sync now"
action) -- "backfill"/"incremental" only ever appear in historical
rows predating that consolidation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:17 +02:00
0a7aa3a5e0 test(store): adversarially cover ActivitiesMissingWorkout isolation
Follow this file's existing pattern (two real provisioned users,
identical-shaped rows, assert on empty-result/unaffected-by rather
than just two independently-created rows not colliding) for the two
methods touched by the details_fetched_at gating fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:10 +02:00
1046e9f7a0 fix(store): gate ActivitiesMissingWorkout on details already fetched
fillPendingDetails and fillPendingWorkouts are each independently
LIMIT-bounded over different candidate sets, so on a large first
backfill a structured-workout activity could fall inside the workouts
pass's window while still outside the details pass's window.
fillActivityWorkout would then align workout targets against zero laps
(details/laps never written yet) and unconditionally mark
workout_raw_json non-NULL, permanently losing that activity's target
pace/HR bands once its real laps arrived later -- silently, with no
error.

Add details_fetched_at IS NOT NULL to ActivitiesMissingWorkout's and
CountActivitiesMissingWorkout's WHERE clause: an activity is only
eligible for a workout fetch once its laps actually exist to align
against. This still covers the intended retry case (an activity whose
workout fetch previously failed always has details_fetched_at already
set) while excluding never-yet-processed activities.

Rename/rewrite the store test to assert the corrected exclusion
(count=1, not 2) as an explicit regression test, and fix the
TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending API fixture,
which exercised the same buggy shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 09:01:02 +02:00
101ef639ab feat(frontend): replace inline sync progress with SyncModal
GarminConnection.tsx no longer polls sync status itself or renders any
inline "syncing.../last sync..." text -- SyncModal (previous commit) is
now the only place sync progress and results are shown. Auth-status
polling is simplified to a fixed interval now that it no longer needs
to speed up while a sync is running.
2026-07-27 08:36:31 +02:00
1fb9271aaa feat(frontend): add SyncModal component
Blocking overlay polling /api/sync/status, rendering by progress.Phase
(indeterminate spinner while discovering, a real progress bar for the
activities/workouts phases), then the final sync result (success count,
or the error message that was already fetched but never rendered
before) plus the existing pending-details/pending-workouts nudge. Never
auto-closes -- not yet wired into GarminConnection.tsx (next commit).
2026-07-27 08:31:01 +02:00
39aa121420 feat(api): reshape /api/sync/status for phase-aware progress
Replaces detail_fill_progress ({Done, Total}) with progress ({Phase,
Done, Total}), and adds workouts_pending (mirroring
activities_pending_details) from the new CountActivitiesMissingWorkout.
Frontend types updated to match -- GarminConnection.tsx is intentionally
left broken by this commit alone; it's fixed in the next commit that
adds SyncModal.
2026-07-27 08:27:34 +02:00
35d9933d1b feat(sync): split FillPendingDetails into activities/workouts phases
Progress becomes phase-aware ({Phase, Done, Total} instead of a flat
{Done, Total}), with FullSync reporting an indeterminate "discovering"
phase during backfill/incremental sync (there's no meaningful total
before those calls have already happened), then FillPendingDetails
reporting a real Done/Total for its activities pass, then its new,
independent workouts pass.

alignWorkoutTargets now takes a lap count instead of a []garmin.Lap
slice, since it never used lap content, only length -- this lets the
new workouts pass call it against laps read back from the DB rather
than needing the original Garmin lap data again.

Splitting the passes also fixes a latent bug: an activity whose
details were fetched successfully but whose workout fetch failed in
that same run previously had no way to ever retry the workout fetch,
since ActivitiesMissingDetails stops returning it once
details_fetched_at/splits_fetched_at are set. ActivitiesMissingWorkout
queries workout_id/workout_raw_json independently, so it keeps
surfacing that activity until its workout is actually fetched.
2026-07-27 06:55:23 +02:00
32fdfc1c72 feat(store): add ActivitiesMissingWorkout/CountActivitiesMissingWorkout
Mirrors ActivitiesMissingDetails/CountActivitiesMissingDetails, but
queries workout_id IS NOT NULL AND workout_raw_json IS NULL --
independent of whether details/splits were ever fetched, since
workout_id is known at initial upsert time. This also fixes a latent
gap: an activity whose details were fetched successfully but whose
workout fetch failed in the same run previously had no way to ever be
retried, since ActivitiesMissingDetails stops returning it the moment
details_fetched_at/splits_fetched_at are set.
2026-07-27 06:49:44 +02:00
7204a48c1c refactor(sync): remove dead Backfill/IncrementalSync exported wrappers
Both were only reachable via the periodic background sync loop removed
in 4d2cbe4 -- nothing in production calls them anymore, only tests did.
FullSync already calls backfillCore/incrementalSyncCore directly.
Rewrite the affected tests to call the *Core functions (still exported
within the package) instead, dropping the now-redundant standalone
SyncRun-recording assertion covered by TestFullSync_RecordsOneCombinedSyncRun.
2026-07-27 06:44:39 +02:00
9d65c50068 chore: ignore .worktrees/ directory
Local worktree scratch space for isolated feature branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 06:37:25 +02:00
aac9359b02 docs: add implementation plan for improved synchronization UX
Six tasks: remove dead Backfill/IncrementalSync wrappers, add
ActivitiesMissingWorkout/CountActivitiesMissingWorkout, split
FillPendingDetails into phase-aware activities/workouts passes,
reshape GET /api/sync/status, add SyncModal, wire it into
GarminConnection.tsx.
2026-07-26 23:05:43 +02:00
a828055c30 docs: fold dead Backfill/IncrementalSync wrapper cleanup into sync spec
Discovered while verifying the sync design with the user: these two
exported methods are dead in production (only FullSync's *Core calls
are actually used), kept alive only by tests, and their doc comments
still reference the background sync loop removed in 4d2cbe4.
2026-07-26 22:45:59 +02:00
7f5618ce49 docs: add design spec for improved synchronization UX
Phase-aware sync progress (discovering/activities/workouts), a new
/api/sync/status shape, and a blocking SyncModal replacing the Profile
page's inline sync text -- implementing the "improve synchronization"
idea from docs/IDEAS.md.
2026-07-26 22:09:49 +02:00
d3b26d7d41 fix(frontend): fix off-by-one date shown in the backfill horizon picker
daysAgoToISODate built the date at local midnight but formatted it with
toISOString(), which converts to UTC first -- in any timezone ahead of
UTC (e.g. Europe/Paris), local midnight is still the previous day in
UTC, so the picker displayed a date one day earlier than the one that
was actually selected/stored. Format from the Date's local
year/month/day components instead of going through UTC.
2026-07-26 21:52:38 +02:00
0211dffa1e fix(config): stop deriving GarminTokenStoreRoot's default from DBPath
There's no reason the Garmin token-store root and the SQLite database
file need to live near each other -- they're independent env vars
(GARMIN_TOKENSTORE, GENIUSRUN_DB_PATH) and should stay independently
configurable, including in their defaults. Revert the "next to DBPath"
default introduced in 77f9588 back to a plain ".garmin" relative to
the working directory, so pointing GENIUSRUN_DB_PATH elsewhere never
silently drags the token-store default along with it.
2026-07-26 21:45:39 +02:00
12cbfdbbee docs(ideas): refine backlog notes on sync UX and error banners
Merge the old "improve activities/workouts download" and "better UX
when backend is not available" bullets into one clearer "modern error
displays" idea (ephemeral, severity-colored banners reused across
login/logout/sync/backend-down states), and flesh out the sync-modal
idea with the activities/workouts split and progress-bar prerequisite.
2026-07-26 21:26:24 +02:00
92284d3b96 polish(frontend): tighten the onboarding wizard's UX
- Drop "Logout" everywhere in the flow: nothing is persisted to the
  database until Garmin actually connects, so closing the tab is
  already a clean escape hatch -- no separate control needed.
- Display-name step: replace the "Next" button with a small checkmark
  icon button next to the input (Enter still submits).
- Garmin credentials step: drop "Previous" (changing the display name
  just means quitting and relaunching, since nothing is persisted yet)
  and rename "Next" to "Login".
- MFA step: drop "Previous" for the same reason.
- Once Garmin reports success (MFA or not), finish setup immediately
  instead of waiting on a "Continue to geniusrun" click.
2026-07-26 21:26:14 +02:00
45f2921971 fix(garmin): make wrapper.py's startup tokenstore login lazy
_startup_login() used to run unconditionally at process boot, before
main()'s stdin dispatch loop started. Whenever the very first real
command turned out to be an explicit authenticate, this was actively
counterproductive: on success it duplicated a login authenticate was
about to redo anyway, and on failure it was a wasted, unauthenticated
hit against Garmin's servers moments before the real attempt --
exactly the kind of extra load that worsens rate-limiting risk. It
also never handled MFA, so it couldn't stand in for authenticate
regardless.

Make it lazy instead: only _handle_call falls back to it, at most once
per subprocess lifetime, and only if authenticate was never explicitly
attempted first. This is what it was actually for -- silently resuming
a cached tokenstore session for a data call that never goes through
the explicit authenticate command (e.g. "Sync now" reaching an
already-connected user's client right after a backend restart cleared
the in-memory client cache).
2026-07-26 21:25:53 +02:00
77f9588c2d refactor(config): rename default Garmin token-store dir to .garmin
Shorter, more conventional dotdir name than garmin-tokenstores. Restore
the "next to DBPath" join that got dropped when the default was
inlined via getEnvDefault -- an explicit GARMIN_TOKENSTORE still wins
verbatim, but the implicit default must still resolve relative to the
DB file's directory, not the process's CWD.

internal/garmin/client.go's GARMIN_TOKENSTORE env var is now always
set (TokenStorePath can no longer be empty), so the conditional that
only appended it when non-empty is dead code.
2026-07-26 21:25:39 +02:00
a12fe3e810 fix(api): stop promoting the ephemeral Garmin client after setup completes
handleSetupComplete reused the onboarding client object as-is in the
permanent per-user cache, but its garmin.Config.TokenStorePath was fixed
at construction to the ephemeral setup/{hash} directory and never
corrected after that directory was renamed to the permanent {userID}
path. The next respawn of that same client (e.g. any Profile-page save,
which unconditionally calls UpdateCredentials) wrote a fresh token file
back under setup/{hash}, forcing a real re-login/MFA on the next Garmin
connect even though a valid session already existed under {userID}.

Close the ephemeral client instead and let the next garminFor call build
a fresh one against the correct, already-renamed directory.
2026-07-26 21:23:24 +02:00
4d2cbe4883 refactor: remove automatic background incremental sync
Garmin's rate-limiting means every unattended sync attempt risks a
ban; syncing should only ever happen when explicitly triggered via
"Sync now" (POST /api/sync/run), never on an unattended timer.

Removes the periodic background loop (main.go's runIncrementalSyncLoop,
api.Server.RunIncrementalSyncForAllUsers), its GENIUSRUN_INCREMENTAL_SYNC_EVERY
config, and store.DB.ListUsers (which existed solely to feed it). The
manual "Sync now" flow (Backfill/IncrementalSync/FillPendingDetails via
FullSync) is untouched.
2026-07-26 18:01:41 +02:00
7346e2eadd docs: add implementation plan for structured JSON logging 2026-07-26 14:36:04 +02:00
c9e089a2b2 feat(garmin): log every wrapper subprocess call as structured JSON 2026-07-26 14:33:20 +02:00
1d4b1cccfd feat(api): add structured JSON access log with request-id correlation 2026-07-26 14:30:39 +02:00
379e7cc990 feat(applog): add JSON logger and context helpers 2026-07-26 14:28:22 +02:00
501d951e5b docs: add design spec for structured JSON logging 2026-07-26 14:22:28 +02:00
2fbe762290 docs: add implementation plan for deferred-commit onboarding wizard 2026-07-26 14:12:44 +02:00
84a7417781 feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard 2026-07-26 13:32:32 +02:00
7d68af5b3c feat(api): defer account creation until Garmin actually connects 2026-07-26 13:30:51 +02:00
a1eaa42d42 docs: add design spec for deferred-commit onboarding wizard 2026-07-26 13:17:59 +02:00
35eff03509 docs: add implementation plan for improved first-connection flow 2026-07-26 12:29:26 +02:00
a622af1bfa Merge branch 'worktree-improve-first-connection' 2026-07-26 12:28:45 +02:00
7f4f6d3493 docs: remove improve-first-connection-page from IDEAS backlog (implemented) 2026-07-26 12:26:33 +02:00
049ed16b69 feat(onboarding): add mandatory ConnectGarmin gate to first login 2026-07-26 12:25:09 +02:00
002858c1cb feat(api): persist and expose garmin_connected on successful auth 2026-07-26 12:23:39 +02:00
6a3f0201af feat(store): persist garmin_connected_at, set once on first successful auth 2026-07-26 12:21:42 +02:00
50d65b40c2 docs: add design spec for improved first-connection flow 2026-07-26 12:09:35 +02:00
d1d2d612a5 added ideas 2026-07-26 12:00:10 +02:00
4c939be6d6 added go work files 2026-07-26 12:00:00 +02:00
f7bf7e9c79 removed unwanted ide files 2026-07-26 11:57:33 +02:00
8353cd148b feat(auth): pass id_token_hint on Keycloak logout
Carries the raw ID token in the session cookie so logout can hand it back
to Keycloak as id_token_hint, letting it skip its own logout-confirmation
prompt -- otherwise a user could cancel out of it and land back in the app
with a Keycloak SSO session but no geniusrun profile (e.g. right after
deleting their account).
2026-07-26 11:55:13 +02:00
d7202eb9bb docs: add implementation plan for profile deletion 2026-07-26 10:23:32 +02:00
ce5057a309 Merge branch 'worktree-profile-deletion' 2026-07-26 10:22:52 +02:00
efbe6a6760 docs: remove profile deletion from IDEAS backlog (implemented) 2026-07-26 10:21:31 +02:00
e2533bd1a8 feat(profile): add Danger zone account deletion UI 2026-07-26 10:20:56 +02:00
ab8cdea214 feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown 2026-07-26 10:19:00 +02:00
e8c340a00e feat(store): add DeleteUser with cascading account deletion 2026-07-26 10:17:21 +02:00
c5faaf5a17 docs: add design spec for profile deletion 2026-07-26 10:06:04 +02:00
117 changed files with 17637 additions and 2882 deletions

View File

@@ -58,8 +58,8 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching. - **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason). - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. - **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var (the Go→Python subprocess protocol variable — the process-level config var is `GENIUSRUN_TOKENSTORE_PATH`) passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. - **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
@@ -74,7 +74,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
## Dev workflow ## Dev workflow
- Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`. - Backend: `cd backend && go run ./cmd/geniusrund`. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed) — see `internal/config/config.go` for all knobs. Garmin credentials are **not** env vars — they live in the `profile` singleton row (`internal/store.Profile`), set via the frontend's Profile page or directly through `PUT /api/profile`.
- Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`). - Frontend: `cd frontend && npm run dev` (set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`).
- No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB. - No live Garmin account needed for frontend/UI work: `go run ./cmd/seedsample -db /tmp/sample.db` seeds realistic activities/laps/kinds and runs them through the real classification engine, then point `geniusrund` at that DB.
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. - `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.

9
.gitignore vendored
View File

@@ -20,6 +20,15 @@ frontend/dist/
backend/mcpspike backend/mcpspike
backend/cmd/mcpspike/mcpspike backend/cmd/mcpspike/mcpspike
.superpowers/ .superpowers/
.worktrees/
# Claude Code session-local runtime state # Claude Code session-local runtime state
.claude/*.lock .claude/*.lock
# Garmin token stores hold live OAuth tokens -- never track them.
backend/.garmin/
# SQLite sidecar files
*.db-shm
*.db-wal
# IDE state beyond the shared bits already ignored above
.idea/

View File

@@ -13,7 +13,7 @@ The "Training plan" tab (`frontend/src/pages/Plan.tsx`) is an intentional empty
## Commands ## Commands
Backend (from `backend/`): Backend (from `backend/`):
- Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GARMIN_WRAPPER_PYTHON` if you need a specific one (e.g. a venv with `garminconnect` installed). - Run the server: `./start.sh` (wraps `go run ./cmd/geniusrund` with the DB path already set) or `go run ./cmd/geniusrund` directly. The Garmin wrapper's Python interpreter defaults to `python3` on `PATH`; set `GENIUSRUN_PYTHON_PATH` if you need a specific one (e.g. a venv with `garminconnect` installed).
- Build/vet: `go build ./...` && `go vet ./...` - Build/vet: `go build ./...` && `go vet ./...`
- Format: `gofmt -l .` must report nothing before committing. - Format: `gofmt -l .` must report nothing before committing.
- All tests: `go test ./...` - All tests: `go test ./...`
@@ -21,6 +21,7 @@ Backend (from `backend/`):
- Single test: `go test ./internal/store/... -run TestProfile -v` - Single test: `go test ./internal/store/... -run TestProfile -v`
- Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`. - Seed sample data (no live Garmin account needed): `go run ./cmd/seedsample -db /tmp/sample.db`, then point `geniusrund` at that DB via `GENIUSRUN_DB_PATH`.
- The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync). - The schema lives in one file, `internal/store/schema.sql`, applied in full on every `Open()` (idempotent -- skipped if the `users` table already exists). There is no migration history: this is a pre-production app with no compatibility obligation to older database files, so edit `schema.sql` directly rather than appending a migration. After changing it, regenerate the schema doc: `go run ./cmd/dumpschema` (writes `docs/DATABASE.md` from the live schema, so it can't drift out of sync).
- **This app is still under active development, not yet in production.** A breaking schema change (new non-nullable column, renamed/removed column, changed constraint, etc.) is expected to require deleting the existing local DB file and letting `Open()` recreate it fresh from the current `schema.sql` -- this is the normal, accepted remedy during this phase, not a workaround to avoid. Do not add `ALTER TABLE` migration logic, backfill scripts, or any other backward-compatibility shim for an existing DB file to accommodate a schema change. Real migration tooling (Flyway) is planned once the first version ships to production; until then, every schema change is free to be breaking.
Frontend (from `frontend/`): Frontend (from `frontend/`):
- Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`. - Run dev server: `./start.sh` (puts Homebrew's `node@22` on `PATH` and runs `npm install` if `node_modules` is missing) or `npm run dev` directly. Set `VITE_API_BASE_URL` if the backend isn't on `localhost:8080`.
@@ -36,7 +37,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install. A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer). Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`. Session duration is application configuration, not an env var: `session.duration` (hours, default `720`), editable on the `/config` page and applied on the next backend restart. See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
## Repo layout ## Repo layout
@@ -79,7 +80,7 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
- Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array). - Leaf conditions: `{metric, op, value}`. `op` is `==`, `!=`, `>`, `>=`, `<`, `<=`, or `between` (value is a 2-element array).
- Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`. - Supported metrics (see `internal/sync/mapping.go`'s `buildMetricContext`): `avg_pace_sec_per_km`, `avg_hr`, `avg_hr_pct_max`, `max_hr`, `duration_seconds`, `distance_meters`, `elevation_gain_m`, `aerobic_training_effect`, `anaerobic_training_effect`, `vo2max_value`, `is_race`, `lap_interval_pattern` (0/1), `lap_pace_stddev`, `lap_hr_drift_bpm_per_min`, `lap_hr_recovery_bpm_per_min`.
- **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable. - **Scoring**: each leaf gets a margin-based confidence in ~[0,1] (`between` = distance from center; comparisons = logistic squash of margin past the threshold). Branches aggregate via `min` (AND) / `max` (OR) — no ML, fully explainable.
- **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (default from `GENIUSRUN_MIN_CONFIDENCE`, 0.6). All three populate `Candidates` for the review UI. - **`needs_review` triggers** (in `classify.Classify`): zero kinds matched, 2+ kinds matched, or exactly one matched below `min_confidence` (hard-coded `classify.DefaultMinConfidence`, 0.6). All three populate `Candidates` for the review UI.
- **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance. - **Interval detection** (`classify.DetectIntervalPattern`) trusts Garmin's own per-lap `IntensityType` tagging (ACTIVE vs REST/RECOVERY/WARMUP/COOLDOWN) rather than inferring it from pace variance.
- **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery. - **HR drift/recovery** (`classify.HRDrift`/`HRRecovery`): linear regression of heart rate vs elapsed time within a lap's sample window. Drift = rising HR during an active lap. Recovery = HR decay rate during a rest lap, sign-flipped so positive = good recovery.
- Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring. - Max HR for `avg_hr_pct_max` comes from `profile.max_heart_rate`, not a static config value — nil/unset omits that metric from the context rather than erroring.
@@ -87,11 +88,11 @@ Each `workout_kinds.rule_json` is a recursive AND/OR condition tree (`internal/c
## Garmin integration (direct wrapper, no MCP) ## Garmin integration (direct wrapper, no MCP)
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout (stderr carries JSON log lines, re-emitted through the Go application logger): `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching. - **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching.
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason). - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` emits a JSON log line for exactly this reason, forwarded into the Go log stream by `forwardWrapperStderr`).
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped. - **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, this token-store root (`config.GarminTokenStoreRoot`, read from `GENIUSRUN_TOKENSTORE_PATH`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory relative to the working directory -- deliberately independent of `GENIUSRUN_DB_PATH`, so the DB file and the token-store root can each be pointed at their own directory -- rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. - **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
@@ -116,6 +117,7 @@ See `docs/DATABASE.md` for the full, always-current schema (every table/column/i
- `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess. - `internal/garmin/mock` provides a fake `Client` for tests that need to exercise `internal/sync`/`internal/api` without a live subprocess.
- No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine. - No live Garmin account needed for frontend/UI work: `cmd/seedsample` provisions one fixed `"seedsample-user"` account (via `store.ProvisionUser`) then seeds realistic activities/laps/kinds under it through the real classification engine.
- **Verify UI/frontend changes with a live click-through against the actual running dev servers (real backend + `npm run dev`), never by mocking network requests (e.g. Playwright route interception) to fake a logged-in session.** This repo's OIDC login gate makes a fully-mocked session tempting, but it's fragile in exactly the way that matters: a real backend is often already running (e.g. started from an IDE) on the same port a mocked test assumes is free, so any request the mock doesn't cover falls through to that real, live backend instead of erroring cleanly -- discovered when an incomplete route-mock's unmocked calls 401'd against a real GoLand-launched `geniusrund` and triggered `client.ts`'s 401-redirect-reload loop. If a live click-through isn't possible in the current environment (no real credentials, no browser tool available), say so explicitly rather than substituting a mocked simulation.
## Testing conventions ## Testing conventions

10
backend/.idea/.gitignore generated vendored
View File

@@ -1,10 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/backend.iml" filepath="$PROJECT_DIR$/.idea/backend.iml" />
</modules>
</component>
</project>

6
backend/.idea/vcs.xml generated
View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -10,11 +10,12 @@ package main
import ( import (
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -26,6 +27,7 @@ type schemaEntry struct {
} }
func main() { func main() {
slog.SetDefault(applog.NewLogger("info", os.Stdout))
tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema") tmpDir, err := os.MkdirTemp("", "geniusrun-dumpschema")
must(err) must(err)
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
@@ -91,11 +93,12 @@ func main() {
outPath := "../docs/DATABASE.md" outPath := "../docs/DATABASE.md"
must(os.WriteFile(outPath, []byte(b.String()), 0644)) must(os.WriteFile(outPath, []byte(b.String()), 0644))
log.Printf("wrote %s", outPath) applog.App().Info("wrote schema doc", "path", outPath)
} }
func must(err error) { func must(err error) {
if err != nil { if err != nil {
log.Fatal(err) applog.App().Error("dumpschema failed", "error", err)
os.Exit(1)
} }
} }

View File

@@ -5,8 +5,9 @@ package main
import ( import (
"context" "context"
"log" "log/slog"
"net/http" "net/http"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
@@ -15,80 +16,104 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config" "geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { // fatal logs through the application JSON logger and exits -- the
cfg, err := config.Load() // structured replacement for log.Fatalf, so even startup failures come
if err != nil { // out as JSON lines.
log.Fatalf("config: %v", err) func fatal(msg string, err error) {
applog.App().Error(msg, "error", err)
os.Exit(1)
} }
db, err := store.Open(cfg.DBPath) func main() {
// Bootstrap logger at info so failures loading the env config itself
// (which carries the real log level) are still emitted as JSON.
slog.SetDefault(applog.NewLogger("warn", os.Stdout))
envCfg, err := config.LoadEnv()
if err != nil { if err != nil {
log.Fatalf("open database: %v", err) fatal("load env config", err)
}
slog.SetDefault(applog.NewLogger(envCfg.LogLevel, os.Stdout))
db, err := store.Open(envCfg.DBPath)
if err != nil {
fatal("open database", err)
} }
defer db.Close() defer db.Close()
authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{ values, err := db.ConfigValues(context.Background())
IssuerURL: cfg.OIDCIssuerURL,
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
RedirectURL: cfg.OIDCRedirectURL,
RequiredRole: cfg.OIDCRequiredRole,
})
if err != nil { if err != nil {
log.Fatalf("oidc: %v", err) fatal("read app config", err)
}
// Every registry key is mandatory in the DB: seed missing ones with
// their defaults so the config table is always fully populated (no
// code-side fallbacks anywhere downstream).
for _, k := range config.AppRegistry() {
if _, ok := values[k.Key]; !ok {
if err := db.SetConfigValue(context.Background(), k.Key, k.Default); err != nil {
fatal("seed app config default", err)
}
values[k.Key] = k.Default
}
}
appCfg, err := config.LoadApp(values)
if err != nil {
fatal("load app config", err)
} }
server := api.NewServer(db, garmin.NewClient, garmin.Config{ authVerifier, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCConfig{
PythonPath: cfg.GarminPythonPath, IssuerURL: envCfg.OIDCIssuerURL,
TokenStorePath: cfg.GarminTokenStoreRoot, ClientID: envCfg.OIDCClientID,
}, appsync.Config{ ClientSecret: envCfg.OIDCClientSecret,
MinConfidence: cfg.MinConfidence, RedirectURL: envCfg.OIDCRedirectURL,
}, authVerifier, api.SessionConfig{ RequiredRole: envCfg.OIDCRequiredRole,
Secret: cfg.SessionSecret,
Duration: cfg.SessionDuration,
Secure: cfg.SessionSecure,
BackendURL: cfg.BackendURL,
FrontendURL: cfg.FrontendURL,
}) })
if err != nil {
fatal("oidc verifier setup", err)
}
server := api.NewServer(
db,
garmin.NewClient,
garmin.ClientConfig{
PythonPath: envCfg.PythonPath,
TokenStorePath: envCfg.TokenStoreRoot,
},
garmin.SyncConfig{},
authVerifier,
api.SessionConfig{
Secret: envCfg.SessionSecret,
Duration: appCfg.SessionDuration,
SetupTimeout: appCfg.SetupTimeout,
Secure: envCfg.SessionSecure,
BackendURL: envCfg.BackendURL,
FrontendURL: envCfg.FrontendURL,
})
for _, e := range envCfg.DisplayEnv() {
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
go runIncrementalSyncLoop(ctx, server, cfg.IncrementalSyncEvery) httpServer := &http.Server{Addr: envCfg.BackendAddr, Handler: server.Router()}
httpServer := &http.Server{Addr: cfg.Addr, Handler: server.Router()}
go func() { go func() {
log.Printf("geniusrund listening on %s", cfg.Addr) applog.App().Info("geniusrund listening", "addr", envCfg.BackendAddr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err) fatal("http server", err)
} }
}() }()
<-ctx.Done() <-ctx.Done()
log.Println("shutting down...") applog.App().Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil { if err := httpServer.Shutdown(shutdownCtx); err != nil {
log.Printf("http server shutdown: %v", err) applog.App().Error("http server shutdown", "error", err)
}
}
// runIncrementalSyncLoop periodically syncs new activities for every
// provisioned user in the background so the frontend doesn't need to
// trigger every sync manually.
func runIncrementalSyncLoop(ctx context.Context, server *api.Server, every time.Duration) {
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
server.RunIncrementalSyncForAllUsers(ctx)
}
} }
} }

View File

@@ -9,23 +9,25 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"log" "log/slog"
"os"
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func main() { func main() {
slog.SetDefault(applog.NewLogger("info", os.Stdout))
dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed") dbPath := flag.String("db", "geniusrun_sample.db", "path to the SQLite database to seed")
flag.Parse() flag.Parse()
ctx := context.Background() ctx := context.Background()
db, err := store.Open(*dbPath) db, err := store.Open(*dbPath)
if err != nil { if err != nil {
log.Fatalf("open db: %v", err) fatal("open db", err)
} }
defer db.Close() defer db.Close()
@@ -73,8 +75,8 @@ func main() {
IsActive: true, IsActive: true,
})) }))
m := &mock.Client{} m := &garmin.MockClient{}
svc := appsync.NewService(m, db, userID, appsync.Config{MinConfidence: 0.6}, nil) svc := garmin.NewSync(m, db, userID, garmin.SyncConfig{MinConfidence: 0.6}, nil)
today := time.Now() today := time.Now()
activityIDs := []int64{} activityIDs := []int64{}
@@ -234,10 +236,17 @@ func seedIntervalLapsAndSamples(ctx context.Context, db *store.DB, userID, activ
func must(err error) { func must(err error) {
if err != nil { if err != nil {
log.Fatal(err) fatal("seedsample", err)
} }
} }
// fatal logs through the application JSON logger and exits -- the
// structured replacement for log.Fatal.
func fatal(msg string, err error) {
applog.App().Error(msg, "error", err)
os.Exit(1)
}
func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 { func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string) int64 {
kinds, err := db.ListWorkoutKinds(ctx, userID, false) kinds, err := db.ListWorkoutKinds(ctx, userID, false)
must(err) must(err)
@@ -246,6 +255,7 @@ func mustFindKindID(ctx context.Context, db *store.DB, userID int64, name string
return k.ID return k.ID
} }
} }
log.Fatalf("seedsample: no workout kind named %q found for the seeded user (did ProvisionUser seed the taxonomy?)", name) applog.App().Error("no workout kind with that name for the seeded user (did ProvisionUser seed the taxonomy?)", "kind", name)
os.Exit(1)
return 0 return 0
} }

View File

@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -9,109 +11,248 @@ import (
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
// activityListItem is one row of the activities list, enriched with its // defaultActivitiesPageSize matches the frontend's initial/incremental
// current classification so the frontend can show what kind it's assigned // page size for its infinite-scroll list.
// to and whether that assignment is locked (see Locked's doc comment). const defaultActivitiesPageSize = 10
type activityListItem struct {
activityResponse type activityItem struct {
WorkoutKindID *int64 `json:"workout_kind_id"` store.KindAssignment
WorkoutKindName *string `json:"workout_kind_name"` Activity activityResponse `json:"activity"`
AssignmentSource *string `json:"assignment_source"` Laps []lapResponse `json:"laps"`
AssignmentStatus *string `json:"assignment_status"` Samples []store.Sample `json:"samples"`
// Locked is true when a global reclassify pass will never touch this
// activity again: a manual assignment is the user's definitive word, and
// a Race assignment comes from a hard Garmin fact, not a retunable rule.
Locked bool `json:"locked"`
} }
// handleListActivities backs the Activities page: every activity that has been
// classified at least once, not just ones still needing review, so the page
// can show each activity's current kind (or "Unclassified") and let the user
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
// (especially) per-second samples is expensive once there are many of them,
// so that work only happens for the requested page, not the full backlog.
// Sorting/cursor filtering still needs each activity's summary row (a cheap
// indexed lookup, no laps/samples), which happens for the whole backlog --
// only the heavy per-item fetches are deferred to the page actually being
// returned. The optional kind_id/unclassified filters are applied before
// that cursor slicing too, so a filtered view still only loads (and
// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) { func (s *Server) handleListActivities(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
q := r.URL.Query() limit := defaultActivitiesPageSize
filter := store.ActivityFilter{ if v := r.URL.Query().Get("limit"); v != "" {
FromDate: q.Get("from"), if n, err := strconv.Atoi(v); err == nil && n > 0 {
ToDate: q.Get("to"), limit = n
} }
if limit, err := strconv.Atoi(q.Get("limit")); err == nil {
filter.Limit = limit
}
if offset, err := strconv.Atoi(q.Get("offset")); err == nil {
filter.Offset = offset
} }
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
activities, err := s.DB.ListActivities(r.Context(), userID, filter) var kindIDFilter *int64
if v := r.URL.Query().Get("kind_id"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
kindIDFilter = &n
}
}
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
kinds, err := s.DB.ListWorkoutKinds(r.Context(), userID, false) type withActivity struct {
if err != nil { assignment store.KindAssignment
writeError(w, http.StatusInternalServerError, err.Error()) activity store.Activity
return
} }
kindNames := make(map[int64]string, len(kinds)) all := make([]withActivity, 0, len(queue))
for _, k := range kinds { for _, a := range queue {
kindNames[k.ID] = k.Name if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
} }
if unclassifiedOnly && a.WorkoutKindID != nil {
resp := make([]activityListItem, 0, len(activities)) continue
for _, a := range activities {
item := activityListItem{activityResponse: toActivityResponse(a)}
assignment, ok, err := s.DB.CurrentAssignment(r.Context(), userID, a.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} }
if ok { activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
source, status := assignment.AssignmentSource, assignment.Status
item.AssignmentSource, item.AssignmentStatus = &source, &status
if assignment.WorkoutKindID != nil {
item.WorkoutKindID = assignment.WorkoutKindID
if name, found := kindNames[*assignment.WorkoutKindID]; found {
item.WorkoutKindName = &name
}
}
item.Locked = source == store.AssignmentSourceManual ||
(item.WorkoutKindName != nil && *item.WorkoutKindName == "Race")
}
resp = append(resp, item)
}
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
activity, ok, err := s.DB.GetActivity(r.Context(), userID, id)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if !ok { if !ok {
writeError(w, http.StatusNotFound, "activity not found") continue
return }
all = append(all, withActivity{assignment: a, activity: activity})
} }
laps, err := s.DB.LapsForActivity(r.Context(), userID, id) // Most recent run first, by when the activity actually happened (not
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(all, func(i, j int) bool {
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
})
total := len(all)
if cursor != "" {
idx := 0
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
idx++
}
all = all[idx:]
}
hasMore := len(all) > limit
if len(all) > limit {
all = all[:limit]
}
items := make([]activityItem, 0, len(all))
for _, wa := range all {
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
// Per-second telemetry, not just per-lap averages, so the chart can
assignment, hasAssignment, err := s.DB.CurrentAssignment(r.Context(), userID, id) // show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
items = append(items, activityItem{
KindAssignment: wa.assignment,
Activity: toActivityResponse(wa.activity),
Laps: toLapResponses(laps),
Samples: samples,
})
}
resp := map[string]any{"activity": toActivityResponse(activity), "laps": toLapResponses(laps)} var nextCursor *string
if hasAssignment { if hasMore && len(items) > 0 {
resp["assignment"] = assignment c := items[len(items)-1].Activity.StartTimeUTC
nextCursor = &c
} }
writeJSON(w, http.StatusOK, resp)
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"next_cursor": nextCursor,
"total": total,
})
}
func (s *Server) handleAssignActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
var body struct {
WorkoutKindID int64 `json:"workout_kind_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.WorkoutKindID == 0 {
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
if kind.Name == "Race" {
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "assigned"})
}
// handleUnassignActivity manually clears an activity's kind back to
// Unclassified. Like handleAssignActivity, it's a deliberate manual choice --
// recorded as source=manual (with no kind) so the rule engine leaves it alone
// on a later reclassify pass, exactly as it would for a manual kind
// assignment. The frontend only offers this while the activity is unlocked
// (locked activities must be unlocked first, same precondition as picking a
// different kind), but the backend doesn't re-enforce that here, matching
// handleAssignActivity's own lack of a lock precondition check.
func (s *Server) handleUnassignActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
}
// handleUnlockActivity reverts a manual assignment back to rule_engine
// sourcing, keeping the same kind, so a later reclassify pass (which always
// skips manual assignments) is free to change it again. It's the inverse of
// handleAssignActivity, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockActivity(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity has no assignment yet")
return
}
if current.AssignmentSource != store.AssignmentSourceManual {
writeError(w, http.StatusBadRequest, "activity is not manually locked")
return
}
status := store.AssignmentStatusAssigned
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
} }

View File

@@ -5,9 +5,11 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"testing" "testing"
@@ -16,9 +18,8 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock" "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func newCtx() context.Context { return context.Background() } func newCtx() context.Context { return context.Background() }
@@ -26,6 +27,7 @@ func newCtx() context.Context { return context.Background() }
var testSessionConfig = SessionConfig{ var testSessionConfig = SessionConfig{
Secret: []byte("test-session-secret-at-least-32-bytes-long"), Secret: []byte("test-session-secret-at-least-32-bytes-long"),
Duration: time.Hour, Duration: time.Hour,
SetupTimeout: 15 * time.Minute,
Secure: false, Secure: false,
BackendURL: "https://geniusrun.example.com", BackendURL: "https://geniusrun.example.com",
FrontendURL: "https://app.geniusrun.example.com", FrontendURL: "https://app.geniusrun.example.com",
@@ -44,9 +46,9 @@ func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
t.Fatalf("ProvisionUser: %v", err) t.Fatalf("ProvisionUser: %v", err)
} }
m := &mock.Client{} m := &garmin.MockClient{}
garminFactory := func(garmin.Config) garmin.Client { return m } garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, userID return s, db, userID
} }
@@ -64,7 +66,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) *
} }
req := httptest.NewRequest(method, path, reader) req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil { if err != nil {
t.Fatalf("mint test session cookie: %v", err) t.Fatalf("mint test session cookie: %v", err)
} }
@@ -194,7 +196,7 @@ func TestWorkoutKindUpdate_RejectsInvertedPaceRange(t *testing.T) {
} }
} }
func TestReviewQueueResolve(t *testing.T) { func TestActivitiesAssign(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -226,9 +228,9 @@ func TestReviewQueueResolve(t *testing.T) {
} }
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) rec := doJSON(t, router, http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("review queue status = %d", rec.Code) t.Fatalf("activities list status = %d", rec.Code)
} }
var page struct { var page struct {
Items []map[string]any `json:"items"` Items []map[string]any `json:"items"`
@@ -237,43 +239,43 @@ func TestReviewQueueResolve(t *testing.T) {
} }
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected 1 item in review queue (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String()) t.Fatalf("expected 1 item in activities list (total=1), got %d items, total=%d: %s", len(page.Items), page.Total, rec.Body.String())
} }
laps, _ := page.Items[0]["laps"].([]any) laps, _ := page.Items[0]["laps"].([]any)
if len(laps) != 1 { if len(laps) != 1 {
t.Fatalf("expected 1 lap in review queue item, got %d: %s", len(laps), rec.Body.String()) t.Fatalf("expected 1 lap in activities list item, got %d: %s", len(laps), rec.Body.String())
} }
samples, _ := page.Items[0]["samples"].([]any) samples, _ := page.Items[0]["samples"].([]any)
if len(samples) != 1 { if len(samples) != 1 {
t.Fatalf("expected 1 sample in review queue item, got %d: %s", len(samples), rec.Body.String()) t.Fatalf("expected 1 sample in activities list item, got %d: %s", len(samples), rec.Body.String())
} }
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": kindID}) rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindID})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("resolve status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("assign status = %d, body = %s", rec.Code, rec.Body.String())
} }
// The activity stays listed after resolving -- the Activities page shows // The activity stays listed after resolving -- the Activities page shows
// every activity, locked or not, not just ones still needing review. // every activity, locked or not, not just ones still needing review.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected activity to remain listed after resolve, got %d items, total=%d", len(page.Items), page.Total) t.Fatalf("expected activity to remain listed after assign, got %d items, total=%d", len(page.Items), page.Total)
} }
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after resolve, got %v", page.Items[0]["AssignmentSource"]) t.Fatalf("expected AssignmentSource=manual after assign, got %v", page.Items[0]["AssignmentSource"])
} }
if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) { if got := page.Items[0]["WorkoutKindID"]; got != float64(kindID) {
t.Fatalf("expected WorkoutKindID=%d after resolve, got %v", kindID, got) t.Fatalf("expected WorkoutKindID=%d after assign, got %v", kindID, got)
} }
// Unlocking reverts the source to rule_engine but keeps the same kind, // Unlocking reverts the source to rule_engine but keeps the same kind,
// so a later reclassify pass is free to change it again. // so a later reclassify pass is free to change it again.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("unlock status = %d, body = %s", rec.Code, rec.Body.String())
} }
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
@@ -283,18 +285,18 @@ func TestReviewQueueResolve(t *testing.T) {
} }
// Unlocking an already-unlocked activity is rejected. // Unlocking an already-unlocked activity is rejected.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code) t.Fatalf("expected 400 unlocking a non-manual assignment, got %d", rec.Code)
} }
// Manually unassigning clears the kind back to Unclassified and locks // Manually unassigning clears the kind back to Unclassified and locks
// that decision (source=manual), same as resolving to a specific kind. // that decision (source=manual), same as resolving to a specific kind.
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unassign", nil) rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unassign", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("unassign status = %d, body = %s", rec.Code, rec.Body.String())
} }
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceManual {
t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"]) t.Fatalf("expected AssignmentSource=manual after unassign, got %v", page.Items[0]["AssignmentSource"])
@@ -304,7 +306,7 @@ func TestReviewQueueResolve(t *testing.T) {
} }
// It also shows up under the Unclassified filter now. // It also shows up under the Unclassified filter now.
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/?unclassified=true", nil) rec = doJSON(t, router, http.MethodGet, "/api/activities/?unclassified=true", nil)
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if len(page.Items) != 1 || page.Total != 1 { if len(page.Items) != 1 || page.Total != 1 {
t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total) t.Fatalf("expected unassigned activity to show up as unclassified, got %d items, total=%d", len(page.Items), page.Total)
@@ -312,18 +314,18 @@ func TestReviewQueueResolve(t *testing.T) {
// Unassigning locks it, so unlocking works again (reverting to // Unassigning locks it, so unlocking works again (reverting to
// rule_engine sourcing with no kind, i.e. needs_review). // rule_engine sourcing with no kind, i.e. needs_review).
rec = doJSON(t, router, http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/unlock", nil) rec = doJSON(t, router, http.MethodPost, "/api/activities/"+itoa(activityID)+"/unlock", nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("unlock after unassign status = %d, body = %s", rec.Code, rec.Body.String())
} }
rec = doJSON(t, router, http.MethodGet, "/api/review-queue/", nil) rec = doJSON(t, router, http.MethodGet, "/api/activities/", nil)
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine { if page.Items[0]["AssignmentSource"] != store.AssignmentSourceRuleEngine {
t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"]) t.Fatalf("expected AssignmentSource=rule_engine after unlock, got %v", page.Items[0]["AssignmentSource"])
} }
} }
func TestReviewQueue_PaginatesByCursor(t *testing.T) { func TestActivities_PaginatesByCursor(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -353,7 +355,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
} }
getPage := func(query string) page { getPage := func(query string) page {
t.Helper() t.Helper()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil) rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
} }
@@ -401,7 +403,7 @@ func TestReviewQueue_PaginatesByCursor(t *testing.T) {
} }
} }
func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) { func TestActivities_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -445,7 +447,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
} }
getPage := func(query string) page { getPage := func(query string) page {
t.Helper() t.Helper()
rec := doJSON(t, router, http.MethodGet, "/api/review-queue/"+query, nil) rec := doJSON(t, router, http.MethodGet, "/api/activities/"+query, nil)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
} }
@@ -488,7 +490,7 @@ func TestReviewQueue_FiltersByKindAndUnclassifiedStayPaginated(t *testing.T) {
} }
} }
func TestResolveReview_RejectsRaceKind(t *testing.T) { func TestAssignActivity_RejectsRaceKind(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -501,9 +503,9 @@ func TestResolveReview_RejectsRaceKind(t *testing.T) {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err) t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
} }
rec := doJSON(t, s.Router(), http.MethodPost, "/api/review-queue/"+itoa(activityID)+"/resolve", map[string]any{"workout_kind_id": raceKind.ID}) rec := doJSON(t, s.Router(), http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": raceKind.ID})
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("resolve to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String()) t.Fatalf("assign to Race status = %d, want 400, body = %s", rec.Code, rec.Body.String())
} }
} }
@@ -574,57 +576,6 @@ func TestReclassifyAll_SkipsManualAndRaceAssignments(t *testing.T) {
} }
} }
func TestListActivities_ReportsLockedForManualAndRaceAssignments(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
easyID, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Easy Locked", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
raceKind, ok, err := db.GetWorkoutKindByName(ctx, userID, "Race")
if err != nil || !ok {
t.Fatalf("GetWorkoutKindByName(Race): ok=%v err=%v", ok, err)
}
ruleEngineActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}"})
manualActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 2, StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}"})
raceActivity, _ := db.UpsertActivity(ctx, userID, store.Activity{GarminActivityID: 3, EventTypeKey: "race", StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: ruleEngineActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: manualActivity, WorkoutKindID: &easyID, AssignmentSource: store.AssignmentSourceManual, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
db.InsertKindAssignment(ctx, userID, store.KindAssignment{ActivityID: raceActivity, WorkoutKindID: &raceKind.ID, AssignmentSource: store.AssignmentSourceRuleEngine, Status: store.AssignmentStatusAssigned, CandidateKindsJSON: "[]"})
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var items []activityListItem
if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil {
t.Fatalf("unmarshal: %v", err)
}
locked := map[int64]bool{}
kindName := map[int64]string{}
for _, it := range items {
locked[it.ID] = it.Locked
if it.WorkoutKindName != nil {
kindName[it.ID] = *it.WorkoutKindName
}
}
if locked[ruleEngineActivity] {
t.Errorf("rule-engine activity should not be locked")
}
if !locked[manualActivity] {
t.Errorf("manual activity should be locked")
}
if !locked[raceActivity] {
t.Errorf("race activity should be locked")
}
if kindName[raceActivity] != "Race" {
t.Errorf("race activity workout_kind_name = %q, want %q", kindName[raceActivity], "Race")
}
}
func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) { func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
s, db, userID := newTestServer(t) s, db, userID := newTestServer(t)
ctx := newCtx() ctx := newCtx()
@@ -634,7 +585,7 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
} }
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/sync/reset", nil) rec := doJSON(t, router, http.MethodPost, "/api/garmin/sync/reset", nil)
if rec.Code != http.StatusAccepted { if rec.Code != http.StatusAccepted {
t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("reset status = %d, body = %s", rec.Code, rec.Body.String())
} }
@@ -655,9 +606,59 @@ func TestSyncReset_DeletesActivitiesAndBackfillEndpointIsGone(t *testing.T) {
} }
// "Full backfill" no longer exists as an endpoint -- superseded by reset. // "Full backfill" no longer exists as an endpoint -- superseded by reset.
rec = doJSON(t, router, http.MethodPost, "/api/sync/backfill", nil) rec = doJSON(t, router, http.MethodPost, "/api/garmin/sync/backfill", nil)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("/api/sync/backfill status = %d, want 404 (removed)", rec.Code) t.Errorf("/api/garmin/sync/backfill status = %d, want 404 (removed)", rec.Code)
}
}
func TestSyncStatus_ReportsPhaseAwareProgressAndWorkoutsPending(t *testing.T) {
s, db, userID := newTestServer(t)
ctx := newCtx()
workoutID := int64(555)
activityID, err := db.UpsertActivity(ctx, userID, store.Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
// ActivitiesMissingWorkout only surfaces activities whose details have
// already been fetched (see store.ActivitiesMissingWorkout) -- an
// activity is only eligible for a workout fetch once fillActivityDetails
// has given it laps to align target pace/HR bands against.
if err := db.SetActivityDetails(ctx, userID, activityID, "{}"); err != nil {
t.Fatalf("SetActivityDetails: %v", err)
}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/garmin/sync/status", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp struct {
InProgress bool `json:"in_progress"`
Progress struct {
Phase string `json:"Phase"`
Done int `json:"Done"`
Total int `json:"Total"`
} `json:"progress"`
ActivitiesPendingDetails int `json:"activities_pending_details"`
WorkoutsPending int `json:"workouts_pending"`
}
unmarshalBody(t, rec, &resp)
if resp.InProgress {
t.Error("in_progress = true, want false (nothing running)")
}
if resp.Progress.Phase != "idle" {
t.Errorf("progress.Phase = %q, want %q", resp.Progress.Phase, "idle")
}
if resp.ActivitiesPendingDetails != 1 {
t.Errorf("activities_pending_details = %d, want 1", resp.ActivitiesPendingDetails)
}
if resp.WorkoutsPending != 1 {
t.Errorf("workouts_pending = %d, want 1", resp.WorkoutsPending)
} }
} }
@@ -715,7 +716,7 @@ func itoa(v int64) string {
} }
func TestProfile_GetDefaultsThenUpdate(t *testing.T) { func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
s, _, _ := newTestServer(t) s, db, _ := newTestServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/profile", nil) rec := doJSON(t, router, http.MethodGet, "/api/profile", nil)
@@ -733,10 +734,18 @@ func TestProfile_GetDefaultsThenUpdate(t *testing.T) {
got.GarminEmail = "runner@example.com" got.GarminEmail = "runner@example.com"
got.GarminPassword = "hunter2" got.GarminPassword = "hunter2"
got.RollingWindowDays = 120 got.RollingWindowDays = 120
rec = doJSON(t, router, http.MethodPut, "/api/profile", got) // Name rides along in the profile payload but lives on the users row.
rec = doJSON(t, router, http.MethodPut, "/api/profile", struct {
store.Profile
Name string
}{got, "Renamed Runner"})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("put status = %d, body = %s", rec.Code, rec.Body.String())
} }
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.Name != "Renamed Runner" {
t.Fatalf("users.name after profile PUT = %q (found=%v err=%v), want Renamed Runner", u.Name, found, err)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) rec = doJSON(t, router, http.MethodGet, "/api/profile", nil)
var updated store.Profile var updated store.Profile
@@ -764,6 +773,135 @@ func TestProfile_RejectsInvalidHRZones(t *testing.T) {
} }
} }
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var before sessionMeResponse
unmarshalBody(t, rec, &before)
if before.GarminConnected {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // garmin.MockClient defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var after sessionMeResponse
unmarshalBody(t, rec, &after)
if !after.GarminConnected {
t.Fatal("expected garmin_connected=true after a successful auth")
}
}
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.GarminConnected {
t.Fatal("expected garmin_connected=false after mfa_required")
}
}
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.clientFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*garmin.MockClient)
if !ok {
t.Fatalf("expected *garmin.MockClient, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &garmin.MockClient{}
garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) { func newTestServerWithAuth(t *testing.T, verifier auth.Verifier) (*Server, *store.DB) {
t.Helper() t.Helper()
s, db, _ := newTestServer(t) s, db, _ := newTestServer(t)
@@ -933,3 +1071,136 @@ func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/") t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
} }
} }
// TestSessionLogout_PassesIDTokenHintFromSessionCookie confirms the raw ID
// token carried in the session cookie (minted at callback time) is handed
// back to EndSessionURL on logout, so Keycloak can skip its own
// logout-confirmation prompt instead of leaving the user a chance to cancel
// out of it after their geniusrun account is already deleted.
func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) {
verifier := &authmock.Verifier{EndSessionResult: "https://keycloak.example.com/logout"}
s, _ := newTestServerWithAuth(t, verifier)
cookie, err := auth.MintSessionCookie(
auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"},
"raw-id-token-jwt", // travels in the cookie apart from Claims
testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure,
)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/session/logout", nil)
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
if verifier.LastIDTokenHint != "raw-id-token-jwt" {
t.Errorf("LastIDTokenHint = %q, want %q", verifier.LastIDTokenHint, "raw-id-token-jwt")
}
}
// TestSessionCallback_MintsSessionCookieCarryingIDToken confirms the raw ID
// token from a completed OIDC callback ends up in the session cookie (not
// just Sub/Name/Email), since that's the only place logout can later read
// it back from to build id_token_hint.
func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) {
verifier := &authmock.Verifier{
CallbackResult: auth.LoginResult{
Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"},
Authorized: true,
IDToken: "raw-id-token-jwt",
},
}
s, _ := newTestServerWithAuth(t, verifier)
txnCookie, err := auth.MintTxnCookie(auth.TxnState{State: "s1", CodeVerifier: "v1"}, testSessionConfig.Secret, false)
if err != nil {
t.Fatalf("mint txn cookie: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/session/callback?code=abc&state=s1", nil)
req.AddCookie(txnCookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var sessionCookie *http.Cookie
for _, c := range rec.Result().Cookies() {
if c.Name == auth.SessionCookieName {
sessionCookie = c
}
}
if sessionCookie == nil {
t.Fatal("expected a session cookie to be set")
}
idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret)
if err != nil {
t.Fatalf("read id token from session cookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt")
}
}
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t)
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["type"] != "http" || entry["package"] != "api" || entry["file"] != "server.go" || entry["method"] != "loggingMiddleware" {
t.Errorf("schema fields = %v, want type=http package=api file=server.go method=loggingMiddleware", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the http record, got %v", entry["msg"])
}
if entry["http_method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("http_method/path = %v/%v, want GET//api/health", entry["http_method"], entry["path"])
}
if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"])
}
if _, ok := entry["duration_ms"]; !ok {
t.Error("expected a duration_ms field")
}
}
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
s, db, _ := newTestServer(t)
db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
}
}

View File

@@ -1,85 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"geniusrun/backend/internal/garmin"
)
type authResponse struct {
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
Message string `json:"message"`
}
func authStatusString(s garmin.AuthStatus) string {
switch s {
case garmin.AuthSuccess:
return "authenticated"
case garmin.AuthMFARequired:
return "mfa_required"
case garmin.AuthFailed:
return "failed"
default:
return "unknown"
}
}
func (s *Server) recordAuthResult(userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
}
func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
client, err := s.garminFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}

View File

@@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"net/http"
"geniusrun/backend/internal/config"
)
// EnvVar is one read-only environment-configuration entry, already
// display-safe (masking happens in config.Config.DisplayEnv).
type EnvVar struct {
Name string `json:"name"`
Value string `json:"value"`
}
type configEntry struct {
Key string `json:"key"`
Value string `json:"value"`
Default string `json:"default"`
Overridden bool `json:"overridden"`
Description string `json:"description"`
}
// configView assembles the GET/PUT response: every registry key with its
// stored value (the DB is fully seeded with defaults at startup; the
// registry default only fills in here for a key added since the last
// boot, e.g. under httptest where main's seeding never ran), plus the env
// snapshot. Overridden means "differs from the default", since every key
// always has a row.
func (s *Server) configView(r *http.Request) (map[string]any, error) {
values, err := s.DB.ConfigValues(r.Context())
if err != nil {
return nil, err
}
registry := config.AppRegistry()
app := make([]configEntry, 0, len(registry))
for _, k := range registry {
value, ok := values[k.Key]
if !ok {
value = k.Default
}
app = append(app, configEntry{
Key: k.Key, Value: value, Default: k.Default,
Overridden: value != k.Default, Description: k.Description,
})
}
envVars := s.EnvVars
if envVars == nil {
envVars = []EnvVar{}
}
return map[string]any{"application": app, "environment": envVars}, nil
}
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
resp, err := s.configView(r)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}
// handlePutConfig updates application configuration. Cold: saved values
// take effect on the next backend restart; the response is just the
// refreshed view, same shape as GET.
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
// Validate every pair before writing anything -- all-or-nothing.
for key, value := range body {
if err := config.ValidateAppValue(key, value); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
for key, value := range body {
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
}
resp, err := s.configView(r)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -0,0 +1,111 @@
package api
import (
"encoding/json"
"net/http"
"testing"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
)
type configTestResponse struct {
Application []struct {
Key string `json:"key"`
Value string `json:"value"`
Default string `json:"default"`
Overridden bool `json:"overridden"`
Description string `json:"description"`
} `json:"application"`
Environment []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"environment"`
}
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
s, _, _ := newTestServer(t)
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp configTestResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(resp.Application) != 2 {
t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application)
}
byKey := map[string]struct {
value, def string
overridden bool
}{}
for _, e := range resp.Application {
if e.Description == "" {
t.Errorf("entry %q has no description", e.Key)
}
byKey[e.Key] = struct {
value, def string
overridden bool
}{e.Value, e.Default, e.Overridden}
}
if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden {
t.Fatalf("session.duration default entry = %+v", e)
}
if e := byKey["session.setup_timeout"]; e.value != "15" || e.def != "15" || e.overridden {
t.Fatalf("session.setup_timeout default entry = %+v", e)
}
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
}
}
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
if rec.Code != http.StatusOK {
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp configTestResponse
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
t.Fatalf("override not reflected: %+v", resp.Application[0])
}
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400", rec.Code)
}
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("invalid value status = %d, want 400", rec.Code)
}
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Application[0].Value != "168" {
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
}
}
// Same gate as every data route: an unprovisioned session gets 403, per
// the repo's adversarial-isolation testing convention.
func TestConfig_RequiresProvisionedUser(t *testing.T) {
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer db.Close()
m := &garmin.MockClient{}
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
t.Fatalf("GET status = %d, want 403", rec.Code)
}
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
t.Fatalf("PUT status = %d, want 403", rec.Code)
}
}

View File

@@ -0,0 +1,200 @@
package api
import (
"context"
"encoding/json"
"net/http"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
)
// detailFillBatchSize bounds how many activities' details/splits are fetched
// per sync trigger, matching the sequential rate-limited fetch in
// internal/sync.Service.FillPendingDetails.
const detailFillBatchSize = 50
type authResponse struct {
Status string `json:"status"` // "authenticated" | "mfa_required" | "failed"
Message string `json:"message"`
}
func authStatusString(s garmin.AuthStatus) string {
switch s {
case garmin.AuthSuccess:
return "authenticated"
case garmin.AuthMFARequired:
return "mfa_required"
case garmin.AuthFailed:
return "failed"
default:
return "unknown"
}
}
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (the in-memory auth status resets on
// every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
applog.App().Error("mark garmin connected", "user_id", userID, "error", err)
}
}
}
func (s *Server) handleGarminAuthLogin(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthMFA(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
client, err := s.clientFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
res, err := client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordAuthResult(r.Context(), userID, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
func (s *Server) handleGarminAuthStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
status, msg := s.userAuthStatus[userID], s.userAuthMessage[userID]
s.mu.Unlock()
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(status), Message: msg})
}
// handleGarminSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleGarminSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleGarminSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleGarminSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleGarminSyncRuns(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleGarminSyncStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingDetails, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
pendingWorkouts, err := s.DB.CountActivitiesMissingWorkout(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"progress": progress,
"activities_pending_details": pendingDetails,
"workouts_pending": pendingWorkouts,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -10,9 +10,7 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
// doJSONAs is doJSON but for an explicit session Sub, for tests that need // doJSONAs is doJSON but for an explicit session Sub, for tests that need
@@ -33,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
} }
req := httptest.NewRequest(method, path, reader) req := httptest.NewRequest(method, path, reader)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil { if err != nil {
t.Fatalf("mint test session cookie: %v", err) t.Fatalf("mint test session cookie: %v", err)
} }
@@ -43,7 +41,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body
return rec return rec
} }
func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) { func TestIsolation_AssignCannotTargetOtherUsersActivity(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA) s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B") userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil { if err != nil {
@@ -58,21 +56,26 @@ func TestIsolation_ActivityDetailNotVisibleToOtherUser(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("UpsertActivity: %v", err) t.Fatalf("UpsertActivity: %v", err)
} }
_ = userB
router := s.Router() kindsB, err := db.ListWorkoutKinds(newCtx(), userB, false)
if err != nil || len(kindsB) == 0 {
// userA (the default doJSON identity) can see it. t.Fatalf("ListWorkoutKinds(b): len=%d err=%v", len(kindsB), err)
rec := doJSON(t, router, http.MethodGet, "/api/activities/"+itoa(activityID), nil) }
if rec.Code != http.StatusOK { kindB := kindsB[0]
t.Fatalf("userA GetActivity status = %d, body = %s", rec.Code, rec.Body.String()) if kindB.Name == "Race" { // Race is rejected before the ownership check
kindB = kindsB[1]
} }
// userB, given the exact same activity id, gets 404 -- not another // userB, given userA's real activity id (and a kind userB legitimately
// user's data, and not a 500 that would leak existence either way. // owns), must not be able to write an assignment onto it.
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/activities/"+itoa(activityID), nil) rec := doJSONAs(t, s.Router(), "user-b", http.MethodPost, "/api/activities/"+itoa(activityID)+"/assign", map[string]any{"workout_kind_id": kindB.ID})
if rec.Code != http.StatusNotFound { if rec.Code == http.StatusOK {
t.Fatalf("userB GetActivity(userA's id) status = %d, want 404, body = %s", rec.Code, rec.Body.String()) t.Fatalf("userB assigning userA's activity succeeded (status %d), body = %s", rec.Code, rec.Body.String())
}
if _, ok, err := db.CurrentAssignment(newCtx(), userA.ID, activityID); err != nil {
t.Fatalf("CurrentAssignment: %v", err)
} else if ok {
t.Fatalf("userB's rejected assign still created an assignment on userA's activity")
} }
} }
@@ -106,7 +109,7 @@ func TestIsolation_WorkoutKindUpdateCannotTargetOtherUsersKind(t *testing.T) {
} }
} }
func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) { func TestIsolation_ActivitiesListOnlyShowsOwnActivities(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA) s, db, _ := newTestServer(t) // provisions "test-user" (userA)
userB, err := db.ProvisionUser(newCtx(), "user-b", "B") userB, err := db.ProvisionUser(newCtx(), "user-b", "B")
if err != nil { if err != nil {
@@ -125,7 +128,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
t.Fatalf("InsertKindAssignment(b): %v", err) t.Fatalf("InsertKindAssignment(b): %v", err)
} }
rec := doJSON(t, s.Router(), http.MethodGet, "/api/review-queue/", nil) // as userA rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // as userA
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
} }
@@ -135,7 +138,7 @@ func TestIsolation_ReviewQueueOnlyShowsOwnActivities(t *testing.T) {
} }
json.Unmarshal(rec.Body.Bytes(), &page) json.Unmarshal(rec.Body.Bytes(), &page)
if page.Total != 0 || len(page.Items) != 0 { if page.Total != 0 || len(page.Items) != 0 {
t.Fatalf("expected userA's review queue to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items)) t.Fatalf("expected userA's activities list to be empty (the only assignment belongs to userB), got total=%d items=%d", page.Total, len(page.Items))
} }
} }
@@ -145,11 +148,82 @@ func TestIsolation_RequireProvisionedUser_BlocksDataRoutesUntilSetup(t *testing.
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
defer db.Close() defer db.Close()
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned rec := doJSON(t, s.Router(), http.MethodGet, "/api/activities/", nil) // "test-user" sub, never provisioned
if rec.Code != http.StatusForbidden { if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 403 (no profile provisioned yet), body = %s", rec.Code, rec.Body.String())
} }
} }
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/garmin/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
// subject's pending ephemeral Garmin session is invisible to another
// subject -- e.g. subject B completing MFA must not accidentally continue
// subject A's in-progress attempt.
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "a@example.com", "garmin_password": "pw-a",
})
if rec.Code != http.StatusOK {
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
if rec.Code != http.StatusConflict {
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
"strings"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -35,6 +36,14 @@ func validateProfile(p store.Profile) error {
return nil return nil
} }
// profilePayload is the profile API's request/response shape: the profiles
// row plus Name, which lives on the users row (the account's single
// human-facing name) but is edited from the same Profile screen.
type profilePayload struct {
store.Profile
Name string
}
func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
p, err := s.DB.GetProfile(r.Context(), userID) p, err := s.DB.GetProfile(r.Context(), userID)
@@ -42,26 +51,35 @@ func (s *Server) handleGetProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, p) u, _ := userFromContext(r.Context())
writeJSON(w, http.StatusOK, profilePayload{Profile: p, Name: u.Name})
} }
func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context()) userID := userIDFromContext(r.Context())
var p store.Profile var p profilePayload
if err := json.NewDecoder(r.Body).Decode(&p); err != nil { if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body") writeError(w, http.StatusBadRequest, "invalid request body")
return return
} }
if err := validateProfile(p); err != nil { if strings.TrimSpace(p.Name) == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := validateProfile(p.Profile); err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
if err := s.DB.UpdateProfile(r.Context(), userID, p); err != nil { if err := s.DB.UpdateProfile(r.Context(), userID, p.Profile); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
client, err := s.garminFor(r.Context(), userID) if err := s.DB.UpdateUserName(r.Context(), userID, strings.TrimSpace(p.Name)); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
client, err := s.clientFor(r.Context(), userID)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -73,5 +91,31 @@ func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, profilePayload{Profile: updated, Name: strings.TrimSpace(p.Name)})
}
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeUserClient(userID)
w.WriteHeader(http.StatusNoContent)
} }

View File

@@ -1,258 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/store"
)
// defaultReviewQueuePageSize matches the frontend's initial/incremental
// page size for its infinite-scroll list.
const defaultReviewQueuePageSize = 10
type reviewQueueItem struct {
store.KindAssignment
Activity activityResponse `json:"activity"`
Laps []lapResponse `json:"laps"`
Samples []store.Sample `json:"samples"`
}
// handleReviewQueue backs the Activities page: every activity that has been
// classified at least once, not just ones still needing review, so the page
// can show each activity's current kind (or "Unclassified") and let the user
// lock/unlock it. It's cursor-paginated: fetching every activity's laps and
// (especially) per-second samples is expensive once there are many of them,
// so that work only happens for the requested page, not the full backlog.
// Sorting/cursor filtering still needs each activity's summary row (a cheap
// indexed lookup, no laps/samples), which happens for the whole backlog --
// only the heavy per-item fetches are deferred to the page actually being
// returned. The optional kind_id/unclassified filters are applied before
// that cursor slicing too, so a filtered view still only loads (and
// chart-renders) one page at a time instead of the whole matching backlog.
func (s *Server) handleReviewQueue(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
limit := defaultReviewQueuePageSize
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
cursor := r.URL.Query().Get("before") // activity StartTimeUTC of the last item on the previous page
var kindIDFilter *int64
if v := r.URL.Query().Get("kind_id"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
kindIDFilter = &n
}
}
unclassifiedOnly := r.URL.Query().Get("unclassified") == "true"
queue, err := s.DB.AllCurrentAssignments(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
type withActivity struct {
assignment store.KindAssignment
activity store.Activity
}
all := make([]withActivity, 0, len(queue))
for _, a := range queue {
if kindIDFilter != nil && (a.WorkoutKindID == nil || *a.WorkoutKindID != *kindIDFilter) {
continue
}
if unclassifiedOnly && a.WorkoutKindID != nil {
continue
}
activity, ok, err := s.DB.GetActivity(r.Context(), userID, a.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
continue
}
all = append(all, withActivity{assignment: a, activity: activity})
}
// Most recent run first, by when the activity actually happened (not
// when the rule engine flagged it), so the queue reads like a log.
sort.Slice(all, func(i, j int) bool {
return all[i].activity.StartTimeUTC > all[j].activity.StartTimeUTC
})
total := len(all)
if cursor != "" {
idx := 0
for idx < len(all) && all[idx].activity.StartTimeUTC >= cursor {
idx++
}
all = all[idx:]
}
hasMore := len(all) > limit
if len(all) > limit {
all = all[:limit]
}
items := make([]reviewQueueItem, 0, len(all))
for _, wa := range all {
laps, err := s.DB.LapsForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// Per-second telemetry, not just per-lap averages, so the chart can
// show real within-lap variation instead of one flat segment per lap
// (most activities only have a handful of laps).
samples, err := s.DB.SamplesForActivity(r.Context(), userID, wa.assignment.ActivityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
items = append(items, reviewQueueItem{
KindAssignment: wa.assignment,
Activity: toActivityResponse(wa.activity),
Laps: toLapResponses(laps),
Samples: samples,
})
}
var nextCursor *string
if hasMore && len(items) > 0 {
c := items[len(items)-1].Activity.StartTimeUTC
nextCursor = &c
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"next_cursor": nextCursor,
"total": total,
})
}
func (s *Server) handleResolveReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
var body struct {
WorkoutKindID int64 `json:"workout_kind_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.WorkoutKindID == 0 {
writeError(w, http.StatusBadRequest, "workout_kind_id is required")
return
}
kind, ok, err := s.DB.GetWorkoutKind(r.Context(), userID, body.WorkoutKindID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
} else if !ok {
writeError(w, http.StatusBadRequest, "workout kind not found")
return
}
if kind.Name == "Race" {
writeError(w, http.StatusBadRequest, "Race is assigned automatically from Garmin metadata and cannot be set manually")
return
}
kindID := body.WorkoutKindID
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: &kindID,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusAssigned,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// handleUnassignReview manually clears an activity's kind back to
// Unclassified. Like handleResolveReview, it's a deliberate manual choice --
// recorded as source=manual (with no kind) so the rule engine leaves it alone
// on a later reclassify pass, exactly as it would for a manual kind
// assignment. The frontend only offers this while the activity is unlocked
// (locked activities must be unlocked first, same precondition as picking a
// different kind), but the backend doesn't re-enforce that here, matching
// handleResolveReview's own lack of a lock precondition check.
func (s *Server) handleUnassignReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: nil,
AssignmentSource: store.AssignmentSourceManual,
Status: store.AssignmentStatusNeedsReview,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unassigned"})
}
// handleUnlockReview reverts a manual assignment back to rule_engine
// sourcing, keeping the same kind, so a later reclassify pass (which always
// skips manual assignments) is free to change it again. It's the inverse of
// handleResolveReview, not a delete: the kind stays visible as-is until
// something actually reclassifies it.
func (s *Server) handleUnlockReview(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
activityID, err := strconv.ParseInt(chi.URLParam(r, "activityID"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid activity id")
return
}
current, ok, err := s.DB.CurrentAssignment(r.Context(), userID, activityID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !ok {
writeError(w, http.StatusNotFound, "activity has no assignment yet")
return
}
if current.AssignmentSource != store.AssignmentSourceManual {
writeError(w, http.StatusBadRequest, "activity is not manually locked")
return
}
status := store.AssignmentStatusAssigned
if current.WorkoutKindID == nil {
status = store.AssignmentStatusNeedsReview
}
if _, err := s.DB.InsertKindAssignment(r.Context(), userID, store.KindAssignment{
ActivityID: activityID,
WorkoutKindID: current.WorkoutKindID,
AssignmentSource: store.AssignmentSourceRuleEngine,
Status: status,
CandidateKindsJSON: "[]",
}); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "unlocked"})
}

View File

@@ -4,20 +4,25 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" applog "geniusrun/backend/internal/log"
"log/slog"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"sync" "sync"
"time"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
) )
// Server wires the HTTP handlers to the app's dependencies. garmin.Client // Server wires the HTTP handlers to the app's dependencies. garmin.Client
@@ -26,124 +31,58 @@ import (
type Server struct { type Server struct {
DB *store.DB DB *store.DB
Auth auth.Verifier Auth auth.Verifier
Session SessionConfig SessionConfig SessionConfig
// GarminFactory builds a real (or fake, in tests) garmin.Client from a // GarminFactory builds a real (or fake, in tests) garmin.Client from a
// fully-resolved per-user Config. Production wiring passes // fully-resolved per-user Config. Production wiring passes
// garmin.NewClient; tests inject a factory returning a shared // garmin.NewClient; tests inject a factory returning a shared
// *mock.Client (see newTestServer in api_test.go). // *mock.Client (see newTestServer in api_test.go).
GarminFactory func(garmin.Config) garmin.Client GarminFactory func(garmin.ClientConfig) garmin.Client
// GarminBase holds the plumbing shared by every user's garmin.Config
// ClientConfig holds the plumbing shared by every user's garmin.Config
// (the python interpreter path + the token-store root directory); only // (the python interpreter path + the token-store root directory); only
// GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by // GarminEmail/GarminPassword/TokenStorePath vary per user, filled in by
// garminFor. // garminFor.
GarminBase garmin.Config ClientConfig garmin.ClientConfig
SyncConfig appsync.Config SyncConfig garmin.SyncConfig
// EnvVars is the read-only, display-safe environment-configuration
// snapshot served by GET /api/config -- built once in main.go from
// config.Config.DisplayEnv() (secrets already masked there); handlers
// never call os.Getenv.
EnvVars []EnvVar
mu sync.Mutex mu sync.Mutex
userGarmin map[int64]garmin.Client userClient map[int64]garmin.Client
userSync map[int64]*appsync.Service userSync map[int64]*garmin.Sync
userAuthStatus map[int64]garmin.AuthStatus userAuthStatus map[int64]garmin.AuthStatus
userAuthMessage map[int64]string userAuthMessage map[int64]string
userSyncRunning map[int64]bool userSyncRunning map[int64]bool
setupSession map[string]*setupSession
} }
// NewServer builds a Server. // NewServer builds a Server.
func NewServer(db *store.DB, garminFactory func(garmin.Config) garmin.Client, garminBase garmin.Config, syncConfig appsync.Config, authVerifier auth.Verifier, session SessionConfig) *Server { func NewServer(db *store.DB, garminFactory func(garmin.ClientConfig) garmin.Client, garminBase garmin.ClientConfig, syncConfig garmin.SyncConfig, authVerifier auth.Verifier, sessionConfig SessionConfig) *Server {
return &Server{ return &Server{
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig, DB: db,
Auth: authVerifier, Session: session, GarminFactory: garminFactory,
userGarmin: map[int64]garmin.Client{}, ClientConfig: garminBase,
userSync: map[int64]*appsync.Service{}, SyncConfig: syncConfig,
Auth: authVerifier,
SessionConfig: sessionConfig,
userClient: map[int64]garmin.Client{},
userSync: map[int64]*garmin.Sync{},
userAuthStatus: map[int64]garmin.AuthStatus{}, userAuthStatus: map[int64]garmin.AuthStatus{},
userAuthMessage: map[int64]string{}, userAuthMessage: map[int64]string{},
userSyncRunning: map[int64]bool{}, userSyncRunning: map[int64]bool{},
} setupSession: map[string]*setupSession{},
}
// garminFor returns userID's garmin.Client, building and caching it (from
// userID's own profile row) on first use.
func (s *Server) garminFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userGarmin[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.GarminBase
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
}
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.userGarmin[userID]; ok {
return c, nil // built concurrently by another request between our unlock and re-lock
}
client := s.GarminFactory(cfg)
s.userGarmin[userID] = client
return client, nil
}
// syncFor returns userID's sync.Service, building and caching it on first use.
func (s *Server) syncFor(ctx context.Context, userID int64) (*appsync.Service, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.garminFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := appsync.NewService(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
}
// RunIncrementalSyncForAllUsers is called on a timer (see main.go) to sync
// every provisioned user in turn, replacing the old single-global-Service
// background loop.
func (s *Server) RunIncrementalSyncForAllUsers(ctx context.Context) {
users, err := s.DB.ListUsers(ctx)
if err != nil {
log.Printf("api: list users for incremental sync: %v", err)
return
}
for _, u := range users {
svc, err := s.syncFor(ctx, u.ID)
if err != nil {
log.Printf("api: sync service for user %d: %v", u.ID, err)
continue
}
if err := svc.IncrementalSync(ctx); err != nil {
log.Printf("api: incremental sync for user %d: %v", u.ID, err)
continue
}
if err := svc.FillPendingDetails(ctx, 50); err != nil {
log.Printf("api: fill pending details for user %d: %v", u.ID, err)
}
} }
} }
// Router builds the HTTP routes. // Router builds the HTTP routes.
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(loggingMiddleware)
r.Use(corsMiddleware) r.Use(corsMiddleware)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/health", s.handleHealth) r.Get("/health", s.handleHealth)
@@ -154,12 +93,19 @@ func (s *Server) Router() http.Handler {
r.Get("/session/callback", s.handleSessionCallback) r.Get("/session/callback", s.handleSessionCallback)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(auth.RequireSession(s.Session.Secret)) r.Use(auth.RequireSession(s.SessionConfig.Secret))
r.Use(s.resolveUser) r.Use(s.resolveUser)
r.Get("/session/me", s.handleSessionMe) r.Get("/session/me", s.handleSessionMe)
r.Post("/session/logout", s.handleSessionLogout) r.Post("/session/logout", s.handleSessionLogout)
r.Post("/setup", s.handleSetup)
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(requireProvisionedUser) r.Use(requireProvisionedUser)
@@ -167,24 +113,30 @@ func (s *Server) Router() http.Handler {
r.Route("/profile", func(r chi.Router) { r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile) r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile) r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
}) })
r.Route("/garmin", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login", s.handleAuthLogin) r.Post("/login", s.handleGarminAuthLogin)
r.Post("/mfa", s.handleAuthMFA) r.Post("/mfa", s.handleGarminAuthMFA)
r.Get("/status", s.handleAuthStatus) r.Get("/status", s.handleGarminAuthStatus)
}) })
r.Route("/sync", func(r chi.Router) { r.Route("/sync", func(r chi.Router) {
r.Post("/run", s.handleSyncRun) r.Post("/run", s.handleGarminSyncRun)
r.Post("/reset", s.handleSyncReset) r.Post("/reset", s.handleGarminSyncReset)
r.Get("/runs", s.handleSyncRuns) r.Get("/runs", s.handleGarminSyncRuns)
r.Get("/status", s.handleSyncStatus) r.Get("/status", s.handleGarminSyncStatus)
})
}) })
r.Route("/activities", func(r chi.Router) { r.Route("/activities", func(r chi.Router) {
r.Get("/", s.handleListActivities) r.Get("/", s.handleListActivities)
r.Get("/{id}", s.handleGetActivity) r.Post("/{activityID}/assign", s.handleAssignActivity)
r.Post("/{activityID}/unlock", s.handleUnlockActivity)
r.Post("/{activityID}/unassign", s.handleUnassignActivity)
}) })
r.Route("/workout-kinds", func(r chi.Router) { r.Route("/workout-kinds", func(r chi.Router) {
@@ -195,12 +147,8 @@ func (s *Server) Router() http.Handler {
r.Post("/reclassify", s.handleReclassifyAll) r.Post("/reclassify", s.handleReclassifyAll)
r.Route("/review-queue", func(r chi.Router) { r.Get("/config", s.handleGetConfig)
r.Get("/", s.handleReviewQueue) r.Put("/config", s.handlePutConfig)
r.Post("/{activityID}/resolve", s.handleResolveReview)
r.Post("/{activityID}/unlock", s.handleUnlockReview)
r.Post("/{activityID}/unassign", s.handleUnassignReview)
})
r.Get("/progression/{kindID}", s.handleProgression) r.Get("/progression/{kindID}", s.handleProgression)
}) })
@@ -209,6 +157,37 @@ func (s *Server) Router() http.Handler {
return r return r
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// loggingMiddleware logs one type=http JSON line per HTTP request. The
// record's meaning is fully carried by its fields (http_method, path,
// status, duration_ms), so it has no msg; "method" stays reserved for the
// emitting function per the log schema, hence http_method for the verb.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
slog.Default().LogAttrs(r.Context(), level, "",
slog.String("type", "http"),
slog.String("package", "api"),
slog.String("file", "server.go"),
slog.String("method", "loggingMiddleware"),
slog.String("http_method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
// corsMiddleware allows the frontend dev server (a different port) to call // corsMiddleware allows the frontend dev server (a different port) to call
// this API. Reflecting any origin back is safe even with credentials // this API. Reflecting any origin back is safe even with credentials
// enabled: this remains a single-operator app whose real access control is // enabled: this remains a single-operator app whose real access control is
@@ -229,20 +208,89 @@ func corsMiddleware(next http.Handler) http.Handler {
}) })
} }
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { // clientFor returns userID's garmin.Client, building and caching it (from
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) // userID's own profile row) on first use.
func (s *Server) clientFor(ctx context.Context, userID int64) (garmin.Client, error) {
s.mu.Lock()
if c, ok := s.userClient[userID]; ok {
s.mu.Unlock()
return c, nil
}
s.mu.Unlock()
profile, err := s.DB.GetProfile(ctx, userID)
if err != nil {
return nil, fmt.Errorf("load profile for garmin client (user %d): %w", userID, err)
}
cfg := s.ClientConfig
cfg.GarminEmail = profile.GarminEmail
cfg.GarminPassword = profile.GarminPassword
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = filepath.Join(cfg.TokenStorePath, strconv.FormatInt(userID, 10))
} }
func writeJSON(w http.ResponseWriter, status int, v any) { s.mu.Lock()
w.Header().Set("Content-Type", "application/json") defer s.mu.Unlock()
w.WriteHeader(status) if c, ok := s.userClient[userID]; ok {
if err := json.NewEncoder(w).Encode(v); err != nil { return c, nil // built concurrently by another request between our unlock and re-lock
log.Printf("api: encode response: %v", err) }
client := s.GarminFactory(cfg)
s.userClient[userID] = client
return client, nil
}
// removeUserClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeUserClient(userID int64) {
s.mu.Lock()
client, ok := s.userClient[userID]
delete(s.userClient, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
applog.App().Error("close garmin client for deleted user", "user_id", userID, "error", err)
}
}
if s.ClientConfig.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
applog.App().Error("remove token store dir for deleted user", "user_id", userID, "error", err)
} }
} }
func writeError(w http.ResponseWriter, status int, msg string) { // syncFor returns userID's sync.Service, building and caching it on first use.
writeJSON(w, status, map[string]string{"error": msg}) func (s *Server) syncFor(ctx context.Context, userID int64) (*garmin.Sync, error) {
s.mu.Lock()
if svc, ok := s.userSync[userID]; ok {
s.mu.Unlock()
return svc, nil
}
s.mu.Unlock()
client, err := s.clientFor(ctx, userID)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if svc, ok := s.userSync[userID]; ok {
return svc, nil
}
svc := garmin.NewSync(client, s.DB, userID, s.SyncConfig, nil)
s.userSync[userID] = svc
return svc, nil
} }
// backgroundSync runs fn in a goroutine with a fresh context, guarded so // backgroundSync runs fn in a goroutine with a fresh context, guarded so
@@ -264,8 +312,30 @@ func (s *Server) backgroundSync(userID int64, fn func(ctx context.Context) error
s.mu.Unlock() s.mu.Unlock()
}() }()
if err := fn(context.Background()); err != nil { if err := fn(context.Background()); err != nil {
log.Printf("api: background sync error (user %d): %v", userID, err) applog.App().Error("background sync failed", "user_id", userID, "error", err)
} }
}() }()
return true return true
} }
// setupTokenStoreDir returns the token-store directory an ephemeral setup
// session for sub should use -- hashed rather than sub itself, since sub is
// an opaque string from the identity provider and using it verbatim in a
// filesystem path would be a directory-traversal risk if it ever contained
// path separators.
func setupTokenStoreDir(root, sub string) string {
h := sha256.Sum256([]byte(sub))
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
applog.App().Error("encode response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}

View File

@@ -1,10 +1,11 @@
package api package api
import ( import (
"log"
"net/http" "net/http"
"time" "time"
applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
) )
@@ -14,6 +15,12 @@ import (
type SessionConfig struct { type SessionConfig struct {
Secret []byte Secret []byte
Duration time.Duration Duration time.Duration
// SetupTimeout evicts an unfinished onboarding Garmin setup session
// once idle this long (app-config key session.setup_timeout, minutes;
// distinct from Duration, the login cookie lifetime). Mandatory --
// there is no code fallback; the default lives in the DB, seeded at
// startup.
SetupTimeout time.Duration
Secure bool Secure bool
// BackendURL is this app's own externally reachable origin (e.g. // BackendURL is this app's own externally reachable origin (e.g.
// "https://geniusrun.example.com", no trailing slash) -- derives // "https://geniusrun.example.com", no trailing slash) -- derives
@@ -36,6 +43,7 @@ type sessionMeResponse struct {
Email string `json:"email"` Email string `json:"email"`
HasProfile bool `json:"has_profile"` HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
GarminConnected bool `json:"garmin_connected"`
} }
func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
@@ -44,7 +52,7 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadGateway, err.Error()) writeError(w, http.StatusBadGateway, err.Error())
return return
} }
cookie, err := auth.MintTxnCookie(txn, s.Session.Secret, s.Session.Secure) cookie, err := auth.MintTxnCookie(txn, s.SessionConfig.Secret, s.SessionConfig.Secure)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
@@ -56,42 +64,58 @@ func (s *Server) handleSessionLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
txnCookie, err := r.Cookie(auth.TxnCookieName) txnCookie, err := r.Cookie(auth.TxnCookieName)
if err != nil { if err != nil {
log.Printf("session callback: missing txn cookie: %v", err) applog.App().Warn("missing txn cookie", "error", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.Session.Secure)) http.SetCookie(w, auth.ClearCookie(auth.TxnCookieName, s.SessionConfig.Secure))
txn, err := auth.ParseTxnCookie(txnCookie, s.Session.Secret) txn, err := auth.ParseTxnCookie(txnCookie, s.SessionConfig.Secret)
if err != nil { if err != nil {
log.Printf("session callback: failed to parse txn cookie: %v", err) applog.App().Warn("failed to parse txn cookie", "error", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query()) result, err := s.Auth.HandleCallback(r.Context(), txn, r.URL.Query())
if err != nil { if err != nil {
log.Printf("session callback: HandleCallback failed (state mismatch, code exchange, or ID-token verification): %v", err) applog.App().Error("callback failed (state mismatch, code exchange, or ID-token verification)", "error", err)
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=failed", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=failed", http.StatusFound)
return return
} }
if !result.Authorized { if !result.Authorized {
http.Redirect(w, r, s.Session.FrontendURL+"/?auth_error=forbidden", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/?auth_error=forbidden", http.StatusFound)
return return
} }
sessionCookie, err := auth.MintSessionCookie(result.Claims, s.Session.Secret, s.Session.Duration, s.Session.Secure) sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
http.SetCookie(w, sessionCookie) http.SetCookie(w, sessionCookie)
http.Redirect(w, r, s.Session.FrontendURL+"/", http.StatusFound) http.Redirect(w, r, s.SessionConfig.FrontendURL+"/", http.StatusFound)
} }
// handleSessionLogout clears geniusrun's own session cookie and redirects
// through Keycloak's end-session endpoint, passing the session's ID token
// as id_token_hint (read back from the cookie via
// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims)
// so Keycloak can skip its own logout-confirmation prompt -- otherwise a
// user could cancel out of it and land back on the app with a Keycloak SSO
// session but no geniusrun profile (already deleted, in the
// profile-deletion case this exists for).
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure)) claims, _ := auth.ClaimsFromContext(r.Context())
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/"), http.StatusFound) s.removeSetupSession(claims.Sub)
// Best-effort: an unreadable cookie just means logging out without the
// hint, at worst showing Keycloak's own confirmation screen.
var idToken string
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret)
}
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure))
http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound)
} }
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
@@ -105,7 +129,13 @@ func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email} resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found { if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true resp.HasProfile = true
resp.DisplayName = u.DisplayName resp.DisplayName = u.Name
profile, err := s.DB.GetProfile(r.Context(), u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.GarminConnected = profile.GarminConnectedAt != nil
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }

View File

@@ -3,11 +3,120 @@ package api
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"path/filepath"
"strconv"
"time"
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
applog "geniusrun/backend/internal/log"
) )
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { // setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Closed (never promoted into Server.userClient) once
// /api/setup/complete actually creates the account -- its Client's
// garmin.Config.TokenStorePath is permanently pinned to the ephemeral
// setup/{hash} directory, so reusing the object after that directory is
// renamed to the permanent {userID} one would respawn against a stale path
// the next time anything closes and restarts its subprocess; a later
// garminFor(ctx, userID) call builds a fresh client with the correct path
// instead. Otherwise evicted lazily (the next setup-endpoint touch for that
// subject checks staleness first) once idle past SessionConfig.SetupTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
// row exists yet at this point (see setupSession in server.go).
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
if _, found := userFromContext(r.Context()); found {
writeError(w, http.StatusConflict, "profile already exists for this account")
return
}
var body struct {
GarminEmail string `json:"garmin_email"`
GarminPassword string `json:"garmin_password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.GarminEmail == "" || body.GarminPassword == "" {
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
return
}
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
res, err := sess.Client.Authenticate(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
// (started by handleSetupGarminLogin) with an MFA code, on the same
// session/subprocess -- never replaces it.
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if body.Code == "" {
writeError(w, http.StatusBadRequest, "code is required")
return
}
sess, ok := s.setupSessionFor(claims.Sub)
if !ok {
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
return
}
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
s.recordSetupAuthResult(claims.Sub, res)
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
}
// handleSetupComplete is the single atomic commit point: only reachable
// once the ephemeral session for this subject last reported
// garmin.AuthSuccess. Provisions the account, persists the Garmin
// credentials, marks it connected, closes the ephemeral client, and
// renames its token-store directory into the permanent per-user path --
// the next real clientFor(ctx, userID) builds a fresh client from scratch
// against that now-permanent directory, whose subprocess's lazy
// startup-login resumes the just-renamed, still-valid session without
// needing to re-authenticate (a cheap local token-store resume, not a
// fresh Garmin login).
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context()) claims, ok := auth.ClaimsFromContext(r.Context())
if !ok { if !ok {
writeError(w, http.StatusUnauthorized, "not authenticated") writeError(w, http.StatusUnauthorized, "not authenticated")
@@ -30,10 +139,145 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
return return
} }
sess, ok := s.setupSessionFor(claims.Sub)
if !ok || sess.Status != garmin.AuthSuccess {
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
return
}
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName) userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
profile, err := s.DB.GetProfile(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
profile.GarminEmail = sess.Email
profile.GarminPassword = sess.Password
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
// The ephemeral client's own garmin.Config.TokenStorePath was set once,
// at construction time in replaceSetupSession, to the ephemeral
// setup/{hash} directory being renamed below -- there's no setter to
// correct it in place, so promoting this object into s.userGarmin would
// leave a client whose subprocess respawns (e.g. on the very next
// UpdateCredentials call from a Profile save) using that now-stale
// path, recreating a setup/{hash} directory next to the real one.
// Closing it here and leaving s.userGarmin empty for this user makes
// the next garminFor(ctx, userID) call build a fresh client against the
// correct, just-renamed {userID} directory instead -- its subprocess's
// lazy startup-login resumes that session without a real Garmin
// re-authentication.
sess.Client.Close()
s.mu.Lock()
delete(s.setupSession, claims.Sub)
s.userAuthStatus[userID] = sess.Status
s.userAuthMessage[userID] = sess.Message
s.mu.Unlock()
if s.ClientConfig.TokenStorePath != "" {
oldDir := setupTokenStoreDir(s.ClientConfig.TokenStorePath, claims.Sub)
newDir := filepath.Join(s.ClientConfig.TokenStorePath, strconv.FormatInt(userID, 10))
// A stale {userID} directory can survive from a previous account
// with the same id -- pre-production the DB file is freely deleted
// and recreated (ids restart at 1) while the token-store root lives
// on, and os.Rename refuses to replace a non-empty directory. The
// just-validated setup session must win, so clear the target first.
if err := os.RemoveAll(newDir); err != nil {
applog.App().Error("remove stale token store dir", "user_id", userID, "error", err)
}
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
applog.App().Error("rename setup token store dir", "user_id", userID, "error", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName}) writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
} }
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
// any and not stale. A stale session is closed and evicted first, so the
// caller always either gets a fresh, live session or none.
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.setupSession[sub]
if !ok {
return nil, false
}
if time.Since(sess.LastUsed) > s.SessionConfig.SetupTimeout {
sess.Client.Close()
delete(s.setupSession, sub)
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
return nil, false
}
return sess, true
}
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
// (if any) with a freshly built one for the given credentials -- same
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
s.mu.Lock()
old, ok := s.setupSession[sub]
s.mu.Unlock()
if ok {
old.Client.Close()
}
cfg := s.ClientConfig
cfg.GarminEmail = email
cfg.GarminPassword = password
if cfg.TokenStorePath != "" {
cfg.TokenStorePath = setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub)
}
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
s.mu.Lock()
s.setupSession[sub] = sess
s.mu.Unlock()
return sess
}
// recordSetupAuthResult updates sub's ephemeral session after a login or
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
s.mu.Lock()
defer s.mu.Unlock()
if sess, ok := s.setupSession[sub]; ok {
sess.Status = res.Status
sess.Message = res.Message
sess.LastUsed = time.Now()
}
}
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
// any, and best-effort removes its token-store directory. Used when
// abandoning onboarding (logout). handleSetupComplete closes the client the
// same way but keeps (renames) the directory instead of removing it, since
// that's the real, now-permanent session.
func (s *Server) removeSetupSession(sub string) {
s.mu.Lock()
sess, ok := s.setupSession[sub]
delete(s.setupSession, sub)
s.mu.Unlock()
if !ok {
return
}
sess.Client.Close()
if s.ClientConfig.TokenStorePath != "" {
os.RemoveAll(setupTokenStoreDir(s.ClientConfig.TokenStorePath, sub))
}
}

View File

@@ -4,82 +4,314 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestSetup_ProvisionsNewUserWithDisplayName(t *testing.T) { // newUnprovisionedServer builds a Server whose "test-user" OIDC subject
// This test specifically needs an *unprovisioned* session, unlike every // (the identity doJSON's cookie always mints) has no users/profile row yet
// other test in this package -- build the server without the // -- every test in this file needs that starting state, unlike
// newTestServer helper's automatic ProvisionUser call. // newTestServer's auto-provisioned default.
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *garmin.MockClient) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db")) db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) garminFactory := func(garmin.ClientConfig) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
return s, db, m
}
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
router := s.Router() router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil) rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
var me sessionMeResponse "garmin_email": "runner@example.com", "garmin_password": "hunter2",
unmarshalBody(t, rec, &me) })
if me.HasProfile {
t.Fatal("expected a brand-new session to have no profile yet")
}
rec = doJSON(t, router, http.MethodPost, "/api/setup", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("setup status = %d, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status = %q, want authenticated", resp.Status)
} }
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil) if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
unmarshalBody(t, rec, &me) t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
if !me.HasProfile || me.DisplayName != "Lucie" { }
t.Fatalf("session/me after setup = %+v, want HasProfile=true DisplayName=Lucie", me)
} }
u, found, err := db.GetUserBySub(newCtx(), "test-user") // doJSON's test cookie's Sub func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
s, db, m := newUnprovisionedServer(t)
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp authResponse
unmarshalBody(t, rec, &resp)
if resp.Status != "mfa_required" {
t.Fatalf("status = %q, want mfa_required", resp.Status)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusOK {
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
}
unmarshalBody(t, rec, &resp)
if resp.Status != "authenticated" {
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
}
}
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndClosesEphemeralClient(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
var factoryConfigs []garmin.ClientConfig
garminFactory := func(cfg garmin.ClientConfig) garmin.Client {
factoryConfigs = append(factoryConfigs, cfg)
return m
}
s := NewServer(db, garminFactory, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found { if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err) t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
} }
if u.DisplayName != "Lucie" { profile, err := db.GetProfile(newCtx(), u.ID)
t.Errorf("stored DisplayName = %q, want Lucie", u.DisplayName)
}
}
func TestSetup_RejectsEmptyDisplayName(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil { if err != nil {
t.Fatalf("store.Open: %v", err) t.Fatalf("GetProfile: %v", err)
}
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
}
if profile.GarminConnectedAt == nil {
t.Error("expected GarminConnectedAt to be set")
} }
t.Cleanup(func() { db.Close() })
m := &mock.Client{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": ""}) if m.AuthenticateCalls != 1 {
t.Errorf("AuthenticateCalls = %d, want 1 (only the original login, no redundant re-authentication)", m.AuthenticateCalls)
}
if !m.ClosedCalled {
t.Error("expected the ephemeral client to be Close()d at setup completion, not promoted as-is -- its cfg.TokenStorePath still points at the ephemeral setup/{hash} dir, which would go stale the moment anything (e.g. a Profile save) later respawns it")
}
// A later real use must build a genuinely fresh client, configured
// against the permanent {userID} token store directory -- never the
// stale ephemeral setup/{hash} one the closed client was carrying.
if _, err := s.clientFor(newCtx(), u.ID); err != nil {
t.Fatalf("garminFor: %v", err)
}
wantTokenStorePath := filepath.Join(tokenStoreRoot, itoa(u.ID))
var gotTokenStorePath string
for _, cfg := range factoryConfigs {
if cfg.GarminEmail == "runner@example.com" {
gotTokenStorePath = cfg.TokenStorePath
}
}
if gotTokenStorePath != wantTokenStorePath {
t.Errorf("garminFor built client with TokenStorePath = %q, want %q", gotTokenStorePath, wantTokenStorePath)
}
}
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
s, db, _ := newUnprovisionedServer(t)
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
t.Fatalf("expected no user created, found=%v err=%v", found, err)
}
}
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
s, _, _ := newUnprovisionedServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
if rec.Code != http.StatusBadRequest { if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code) t.Fatalf("status = %d, want 400", rec.Code)
} }
} }
func TestSetup_RejectsWhenAlreadyProvisioned(t *testing.T) { func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup", map[string]any{"display_name": "Someone Else"}) rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusConflict { if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String()) t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
} }
} }
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
}
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
// Simulate what a real subprocess would have written under the
// ephemeral (subject-keyed) directory during that login call.
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(oldDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
}
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
}
}
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper() t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err) t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
} }
} }
// Regression: os.Rename refuses to replace an existing directory, so a
// stale {userID} token-store dir (left behind when the DB file was
// recreated -- ids restart at 1 -- while the token-store root survived)
// used to make setup completion silently keep the OLD tokens in place.
// The fresh, just-validated session's directory must win.
func TestSetupComplete_ReplacesStaleTokenStoreDir(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "setup_stale_dir_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
m := &garmin.MockClient{}
tokenStoreRoot := t.TempDir()
s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{TokenStorePath: tokenStoreRoot}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
router := s.Router()
// The ephemeral setup dir the wrapper would have written tokens into.
setupDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
if err := os.MkdirAll(setupDir, 0o755); err != nil {
t.Fatalf("mkdir setup dir: %v", err)
}
if err := os.WriteFile(filepath.Join(setupDir, "oauth_token"), []byte("fresh"), 0o600); err != nil {
t.Fatalf("write fresh token: %v", err)
}
// A stale dir already occupying the permanent {userID} path (fresh DB
// starts ids at 1).
staleDir := filepath.Join(tokenStoreRoot, "1")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatalf("mkdir stale dir: %v", err)
}
if err := os.WriteFile(filepath.Join(staleDir, "oauth_token"), []byte("stale"), 0o600); err != nil {
t.Fatalf("write stale token: %v", err)
}
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
})
if rec.Code != http.StatusOK {
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
if rec.Code != http.StatusOK {
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
}
token, err := os.ReadFile(filepath.Join(tokenStoreRoot, itoa(u.ID), "oauth_token"))
if err != nil {
t.Fatalf("read token after complete: %v", err)
}
if string(token) != "fresh" {
t.Fatalf("token content = %q, want the fresh setup session to replace the stale dir", token)
}
if _, err := os.Stat(setupDir); !os.IsNotExist(err) {
t.Errorf("ephemeral setup dir still present after rename: %v", err)
}
}

View File

@@ -1,102 +0,0 @@
package api
import (
"context"
"net/http"
)
// detailFillBatchSize bounds how many activities' details/splits are fetched
// per sync trigger, matching the sequential rate-limited fetch in
// internal/sync.Service.FillPendingDetails.
const detailFillBatchSize = 50
// handleSyncRun does a full sync pass: Backfill first (resumes from the
// watermark, so widening the configured history horizon between clicks is
// picked up automatically), then IncrementalSync (catches anything new since
// the latest known activity), then fills in details for whatever's still
// missing them. Activities already fully processed are left untouched --
// see internal/sync.Service.FillPendingDetails. Recorded as a single
// FullSync run so "last sync" reports the combined activity count, not just
// whichever of Backfill/IncrementalSync happened to finish last.
func (s *Server) handleSyncRun(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.FullSync(ctx, detailFillBatchSize)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
// handleSyncReset wipes every synced activity (and its laps/samples/kind
// assignments) and rewinds the backfill watermark, so the next Sync Now
// performs a genuinely fresh pull from Garmin. Destructive -- the frontend
// gates this behind a confirmation.
func (s *Server) handleSyncReset(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
ok := s.backgroundSync(userID, func(ctx context.Context) error {
return svc.ResetAll(ctx)
})
if !ok {
writeError(w, http.StatusConflict, "a sync is already in progress")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "started"})
}
func (s *Server) handleSyncRuns(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
runs, err := s.DB.ListSyncRuns(r.Context(), userID, 20)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleSyncStatus(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
run, ok, err := s.DB.LatestSyncRun(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
remaining, err := s.DB.CountActivitiesMissingDetails(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
svc, err := s.syncFor(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
progress := svc.Progress()
resp := map[string]any{
"in_progress": inProgress,
"detail_fill_progress": progress,
"activities_pending_details": remaining,
}
if ok {
resp["last_run"] = run
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -15,7 +15,7 @@ const resolvedUserContextKey userContextKey = iota
// session's OIDC subject. // session's OIDC subject.
type resolvedUser struct { type resolvedUser struct {
ID int64 ID int64
DisplayName string Name string
} }
// resolveUser runs after auth.RequireSession on every request and looks up // resolveUser runs after auth.RequireSession on every request and looks up
@@ -40,7 +40,7 @@ func (s *Server) resolveUser(next http.Handler) http.Handler {
} }
ctx := r.Context() ctx := r.Context()
if found { if found {
ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, DisplayName: u.DisplayName}) ctx = context.WithValue(ctx, resolvedUserContextKey, resolvedUser{ID: u.ID, Name: u.Name})
} }
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

View File

@@ -9,9 +9,7 @@ import (
"geniusrun/backend/internal/auth" "geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock" authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin" "geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
) )
func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) { func TestResolveUser_AttachesResolvedUserWhenProvisioned(t *testing.T) {
@@ -41,8 +39,8 @@ func TestResolveUser_LeavesContextEmptyWhenNotProvisioned(t *testing.T) {
t.Fatalf("store.Open: %v", err) t.Fatalf("store.Open: %v", err)
} }
t.Cleanup(func() { db.Close() }) t.Cleanup(func() { db.Close() })
m := &mock.Client{} m := &garmin.MockClient{}
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig) s := NewServer(db, func(garmin.ClientConfig) garmin.Client { return m }, garmin.ClientConfig{}, garmin.SyncConfig{}, &authmock.Verifier{}, testSessionConfig)
var gotOK bool var gotOK bool
handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := auth.RequireSession(testSessionConfig.Secret)(s.resolveUser(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

View File

@@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) {
} }
func TestRequireSession_ValidCookie(t *testing.T) { func TestRequireSession_ValidCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) {
} }
func TestRequireSession_ExpiredCookie(t *testing.T) { func TestRequireSession_ExpiredCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -59,11 +59,11 @@ func TestRequireSession_ExpiredCookie(t *testing.T) {
} }
func TestRequireSession_TamperedCookie(t *testing.T) { func TestRequireSession_TamperedCookie(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
cookie.Value = flipLastChar(cookie.Value) cookie.Value = flipSignatureChar(cookie.Value)
req := httptest.NewRequest(http.MethodGet, "/", nil) req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie) req.AddCookie(cookie)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()

View File

@@ -18,6 +18,7 @@ type Verifier struct {
CallbackResult auth.LoginResult CallbackResult auth.LoginResult
CallbackErr error CallbackErr error
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
LastIDTokenHint string // records the idTokenHint passed to the last EndSessionURL call
} }
var _ auth.Verifier = (*Verifier)(nil) var _ auth.Verifier = (*Verifier)(nil)
@@ -33,7 +34,8 @@ func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query
return v.CallbackResult, nil return v.CallbackResult, nil
} }
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string { func (v *Verifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
v.LastIDTokenHint = idTokenHint
if v.EndSessionResult != "" { if v.EndSessionResult != "" {
return v.EndSessionResult return v.EndSessionResult
} }

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"net/url" "net/url"
"slices"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -24,13 +25,21 @@ type Verifier interface {
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
// EndSessionURL builds the identity provider's logout URL, redirecting // EndSessionURL builds the identity provider's logout URL, redirecting
// back to postLogoutRedirectURL once Keycloak's own session is cleared. // back to postLogoutRedirectURL once Keycloak's own session is cleared.
EndSessionURL(postLogoutRedirectURL string) string // idTokenHint, if non-empty, is passed as id_token_hint so Keycloak can
// positively identify the session being ended and skip its own
// logout-confirmation prompt (which would otherwise let the user cancel
// out of logout after their geniusrun account is already deleted).
EndSessionURL(postLogoutRedirectURL, idTokenHint string) string
} }
// LoginResult is what a completed callback exchange resolves to. // LoginResult is what a completed callback exchange resolves to.
type LoginResult struct { type LoginResult struct {
Claims Claims Claims Claims
Authorized bool Authorized bool
// IDToken is the raw Keycloak ID token JWT, kept apart from Claims:
// it's only ever needed once more, at logout (id_token_hint), so it
// rides in the session cookie but never in the per-request Claims.
IDToken string
} }
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the // OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
@@ -55,12 +64,7 @@ type idTokenClaims struct {
} }
func (c idTokenClaims) hasRole(required string) bool { func (c idTokenClaims) hasRole(required string) bool {
for _, r := range c.RealmAccess.Roles { return slices.Contains(c.RealmAccess.Roles, required)
if r == required {
return true
}
}
return false
} }
type oidcVerifier struct { type oidcVerifier struct {
@@ -137,10 +141,11 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u
return LoginResult{ return LoginResult{
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
Authorized: claims.hasRole(v.requiredRole), Authorized: claims.hasRole(v.requiredRole),
IDToken: rawIDToken,
}, nil }, nil
} }
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string { func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL, idTokenHint string) string {
var discovery struct { var discovery struct {
EndSessionEndpoint string `json:"end_session_endpoint"` EndSessionEndpoint string `json:"end_session_endpoint"`
} }
@@ -154,6 +159,9 @@ func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
q := u.Query() q := u.Query()
q.Set("client_id", v.oauth2Config.ClientID) q.Set("client_id", v.oauth2Config.ClientID)
q.Set("post_logout_redirect_uri", postLogoutRedirectURL) q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
if idTokenHint != "" {
q.Set("id_token_hint", idTokenHint)
}
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
return u.String() return u.String()
} }

View File

@@ -1,10 +1,83 @@
package auth package auth
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing" "testing"
) )
// newTestOIDCVerifier spins up a fake OIDC discovery endpoint (just enough
// for oidc.NewProvider's discovery GET to succeed) and returns a real
// oidcVerifier backed by it, for tests that exercise EndSessionURL without
// a live Keycloak.
func newTestOIDCVerifier(t *testing.T) Verifier {
t.Helper()
var issuerURL string
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{
"issuer": %[1]q,
"authorization_endpoint": "%[1]s/auth",
"token_endpoint": "%[1]s/token",
"end_session_endpoint": "%[1]s/logout",
"jwks_uri": "%[1]s/certs"
}`, issuerURL)
})
mux.HandleFunc("/certs", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"keys":[]}`)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
issuerURL = srv.URL
verifier, err := NewOIDCVerifier(context.Background(), OIDCConfig{
IssuerURL: issuerURL, ClientID: "geniusrun", ClientSecret: "secret", RedirectURL: issuerURL + "/callback",
})
if err != nil {
t.Fatalf("NewOIDCVerifier: %v", err)
}
return verifier
}
func TestEndSessionURL_IncludesIDTokenHintWhenProvided(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "raw-id-token-jwt")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
q := u.Query()
if q.Get("id_token_hint") != "raw-id-token-jwt" {
t.Errorf("id_token_hint = %q, want %q", q.Get("id_token_hint"), "raw-id-token-jwt")
}
if q.Get("client_id") != "geniusrun" {
t.Errorf("client_id = %q, want geniusrun", q.Get("client_id"))
}
if q.Get("post_logout_redirect_uri") != "https://app.example.com/" {
t.Errorf("post_logout_redirect_uri = %q, want https://app.example.com/", q.Get("post_logout_redirect_uri"))
}
}
func TestEndSessionURL_OmitsIDTokenHintWhenEmpty(t *testing.T) {
verifier := newTestOIDCVerifier(t)
got := verifier.EndSessionURL("https://app.example.com/", "")
u, err := url.Parse(got)
if err != nil {
t.Fatalf("parse EndSessionURL result %q: %v", got, err)
}
if u.Query().Has("id_token_hint") {
t.Errorf("expected no id_token_hint param when idTokenHint is empty, got %q", got)
}
}
func TestIDTokenClaims_HasRole(t *testing.T) { func TestIDTokenClaims_HasRole(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,9 +1,17 @@
// Package auth implements geniusrun's own login gate: an OIDC Authorization // Package auth implements geniusrun's own login gate: an OIDC Authorization
// Code flow against an existing Keycloak realm (see oidc.go), backed by a // Code flow against an existing Keycloak realm (see oidc.go), backed by a
// signed session cookie geniusrun mints itself (this file) and a chi // signed session cookie geniusrun mints itself (this file) and a chi
// middleware that checks it (middleware.go). Keycloak's own tokens are never // middleware that checks it (middleware.go). Keycloak's own access/refresh
// stored or refreshed -- once HandleCallback verifies the ID token and role, // tokens are never stored or refreshed -- once HandleCallback verifies the
// only this package's own cookie matters for subsequent requests. // ID token and role, only this package's own cookie matters for subsequent
// requests. The one exception is the raw ID token itself, carried opaquely
// inside the signed session cookie (apart from Claims -- see
// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie)
// solely so a later logout can pass it back to Keycloak as
// id_token_hint -- letting Keycloak
// skip its own logout-confirmation prompt for a session it can positively
// identify, rather than leaving the user a chance to cancel out of it after
// their geniusrun account (and profile) is already gone.
package auth package auth
import ( import (
@@ -26,7 +34,11 @@ const (
) )
// Claims identifies the authenticated user, carried in the signed session // Claims identifies the authenticated user, carried in the signed session
// cookie. // cookie and stashed in every request's context. Deliberately does NOT
// carry the raw Keycloak ID token: that JWT lives in the cookie payload
// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's
// only ever needed once more, at logout, and has no business riding
// through every handler's context.
type Claims struct { type Claims struct {
Sub string Sub string
Name string Name string
@@ -37,6 +49,7 @@ type sessionClaims struct {
Sub string `json:"sub"` Sub string `json:"sub"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
IDToken string `json:"id_token,omitempty"`
jwt.RegisteredClaims jwt.RegisteredClaims
} }
@@ -55,12 +68,15 @@ type txnClaims struct {
// MintSessionCookie signs claims into a JWT valid for duration and wraps it // MintSessionCookie signs claims into a JWT valid for duration and wraps it
// in a cookie. secure should be true whenever the app is served over HTTPS. // in a cookie. secure should be true whenever the app is served over HTTPS.
func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) { // idToken is the raw Keycloak ID token to carry for the eventual logout's
// id_token_hint (empty is fine, e.g. in tests -- the hint is optional).
func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) {
now := time.Now() now := time.Now()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{
Sub: claims.Sub, Sub: claims.Sub,
Name: claims.Name, Name: claims.Name,
Email: claims.Email, Email: claims.Email,
IDToken: idToken,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(duration)), ExpiresAt: jwt.NewNumericDate(now.Add(duration)),
@@ -94,6 +110,20 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) {
return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil
} }
// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak
// ID token it carries, for logout's id_token_hint. Only the logout handler
// needs this -- everything else uses ParseSessionCookie's Claims.
func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) {
if cookie == nil {
return "", fmt.Errorf("no session cookie")
}
var sc sessionClaims
if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil {
return "", fmt.Errorf("parse session token: %w", err)
}
return sc.IDToken, nil
}
// MintTxnCookie signs an OIDC login transaction into a short-lived cookie. // MintTxnCookie signs an OIDC login transaction into a short-lived cookie.
func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) { func MintTxnCookie(txn TxnState, secret []byte, secure bool) (*http.Cookie, error) {
now := time.Now() now := time.Now()

View File

@@ -9,7 +9,7 @@ const testSecret = "test-secret-at-least-32-bytes-long!"
func TestMintAndParseSessionCookie(t *testing.T) { func TestMintAndParseSessionCookie(t *testing.T) {
claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}
cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true) cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) {
if got != claims { if got != claims {
t.Fatalf("got %+v, want %+v", got, claims) t.Fatalf("got %+v, want %+v", got, claims)
} }
// The ID token rides in the cookie apart from Claims, retrievable only
// through the dedicated logout-path helper.
idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret))
if err != nil {
t.Fatalf("IDTokenFromSessionCookie: %v", err)
}
if idToken != "raw-id-token-jwt" {
t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken)
}
} }
func TestParseSessionCookie_Expired(t *testing.T) { func TestParseSessionCookie_Expired(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
@@ -37,33 +47,46 @@ func TestParseSessionCookie_Expired(t *testing.T) {
} }
func TestParseSessionCookie_Tampered(t *testing.T) { func TestParseSessionCookie_Tampered(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }
cookie.Value = flipLastChar(cookie.Value) cookie.Value = flipSignatureChar(cookie.Value)
if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil { if _, err := ParseSessionCookie(cookie, []byte(testSecret)); err == nil {
t.Fatal("expected error for tampered cookie") t.Fatal("expected error for tampered cookie")
} }
} }
// flipLastChar corrupts a signed token for tamper tests, guaranteeing the // flipSignatureChar corrupts a signed JWT for tamper tests by changing the
// last character actually changes -- blindly overwriting it with a fixed // second-to-last character of its base64url signature, guaranteeing the
// character (e.g. "x") would occasionally be a no-op if that character // decoded signature bytes actually change. Two pitfalls to avoid here:
// already happened to be there (it's derived from the token's embedded // 1. Blindly overwriting a character with a fixed replacement (e.g. "x")
// timestamp, so this isn't as rare as it sounds), silently passing the // would occasionally be a no-op if that character was already there --
// test without having tampered with anything. // it's derived from the token's embedded timestamp, so this isn't as
func flipLastChar(s string) string { // rare as it sounds.
last := s[len(s)-1] // 2. Flipping the *last* character of the signature specifically (as this
// helper used to) is flaky in a subtler way: HMAC-SHA256 produces a
// 32-byte digest, which base64url-encodes to 43 characters with a
// final 3-character group covering only a 2-byte remainder -- the
// true last character encodes 4 real bits plus 2 unused padding bits.
// Go's encoding/base64 ignores those padding bits when decoding
// (non-strict by default), so about 1 in 4 replacement characters for
// that position decode to byte-identical signature bytes, silently
// passing the test without having tampered with anything. The
// second-to-last character of that final group has no such unused
// bits, so corrupting it is deterministic.
func flipSignatureChar(s string) string {
pos := len(s) - 2
orig := s[pos]
replacement := byte('x') replacement := byte('x')
if last == replacement { if orig == replacement {
replacement = 'y' replacement = 'y'
} }
return s[:len(s)-1] + string(replacement) return s[:pos] + string(replacement) + s[pos+1:]
} }
func TestParseSessionCookie_WrongSecret(t *testing.T) { func TestParseSessionCookie_WrongSecret(t *testing.T) {
cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false)
if err != nil { if err != nil {
t.Fatalf("mint: %v", err) t.Fatalf("mint: %v", err)
} }

View File

@@ -0,0 +1,103 @@
package config
import (
"fmt"
"strconv"
"time"
)
// AppKey describes one application-configuration key: instance-global,
// stored (as an override) in the DB `config` table, read once at startup.
// Cold -- a changed value applies on the next backend restart.
type AppKey struct {
Key string
Default string
Description string
Validate func(value string) error
}
// KeySessionDuration is the session cookie lifetime, in integer hours.
const (
KeySessionDuration = "session.duration"
// KeySessionSetupTimeout bounds an *onboarding Garmin setup session*
// (the ephemeral, pre-account login attempt), not the login cookie --
// session.duration is how long a signed-in user stays signed in
// (hours); session.setup_timeout is how long an unfinished setup
// attempt survives untouched before its subprocess is torn down
// (minutes).
KeySessionSetupTimeout = "session.setup_timeout"
)
var appRegistry = []AppKey{
{
Key: KeySessionDuration,
Default: "720",
Description: "Session cookie lifetime in hours",
Validate: validatePositiveInt,
},
{
Key: KeySessionSetupTimeout,
Default: "15",
Description: "Idle timeout in minutes for an unfinished onboarding Garmin setup session",
Validate: validatePositiveInt,
},
}
// AppRegistry returns every known application-configuration key, in
// display order.
func AppRegistry() []AppKey { return appRegistry }
func validatePositiveInt(v string) error {
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
return fmt.Errorf("must be a positive integer, got %q", v)
}
return nil
}
// ValidateAppValue rejects unknown keys and invalid values -- every write
// path's guard, so the config table can never accumulate junk.
func ValidateAppValue(key, value string) error {
for _, k := range appRegistry {
if k.Key == key {
if err := k.Validate(value); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
return nil
}
}
return fmt.Errorf("unknown configuration key %q", key)
}
// AppConfig is the typed application configuration, built from the DB's
// config rows.
type AppConfig struct {
SessionDuration time.Duration
SetupTimeout time.Duration
}
// LoadApp builds the typed AppConfig from the config table's rows. Every
// registry key is mandatory: main seeds missing keys with their defaults
// at startup (see cmd/geniusrund), so a missing key here means that
// seeding didn't run -- fail fast, same posture as LoadEnv() for env
// vars, and likewise for an unknown or invalid stored value. Pure -- it
// takes the raw map instead of a *store.DB so this package needs no store
// dependency and the logic tests without a database.
func LoadApp(values map[string]string) (AppConfig, error) {
for key, value := range values {
if err := ValidateAppValue(key, value); err != nil {
return AppConfig{}, fmt.Errorf("app config: %w", err)
}
}
for _, k := range appRegistry {
if _, ok := values[k.Key]; !ok {
return AppConfig{}, fmt.Errorf("app config: missing key %q (defaults are seeded into the DB at startup)", k.Key)
}
}
hours, _ := strconv.Atoi(values[KeySessionDuration])
setupMinutes, _ := strconv.Atoi(values[KeySessionSetupTimeout])
return AppConfig{
SessionDuration: time.Duration(hours) * time.Hour,
SetupTimeout: time.Duration(setupMinutes) * time.Minute,
}, nil
}

View File

@@ -0,0 +1,88 @@
package config
import (
"strings"
"testing"
"time"
)
func TestLoadApp(t *testing.T) {
tests := []struct {
name string
values map[string]string
want time.Duration
wantIdle time.Duration
wantErr string
}{
// Every registry key is mandatory: main seeds the DB with defaults
// at startup, so LoadApp always receives a complete map.
{name: "seeded defaults", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15"}, want: 720 * time.Hour, wantIdle: 15 * time.Minute},
{name: "custom values", values: map[string]string{"session.duration": "168", "session.setup_timeout": "30"}, want: 168 * time.Hour, wantIdle: 30 * time.Minute},
{name: "missing key fails fast", values: map[string]string{"session.duration": "720"}, wantErr: "missing key"},
{name: "nil map fails fast", values: nil, wantErr: "missing key"},
{name: "invalid stored value", values: map[string]string{"session.duration": "zero", "session.setup_timeout": "15"}, wantErr: "session.duration"},
{name: "invalid setup timeout", values: map[string]string{"session.duration": "720", "session.setup_timeout": "0"}, wantErr: "session.setup_timeout"},
{name: "unknown stored key", values: map[string]string{"session.duration": "720", "session.setup_timeout": "15", "bogus.key": "1"}, wantErr: "unknown configuration key"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LoadApp(tt.values)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("LoadApp: %v", err)
}
if got.SessionDuration != tt.want {
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
}
if got.SetupTimeout != tt.wantIdle {
t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupTimeout, tt.wantIdle)
}
})
}
}
func TestValidateAppValue(t *testing.T) {
if err := ValidateAppValue("session.duration", "24"); err != nil {
t.Fatalf("valid value rejected: %v", err)
}
if err := ValidateAppValue("session.duration", "-1"); err == nil {
t.Fatal("negative hours accepted")
}
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
t.Fatal("non-integer accepted")
}
if err := ValidateAppValue("nope", "1"); err == nil {
t.Fatal("unknown key accepted")
}
}
func TestDisplayEnv_MasksSecrets(t *testing.T) {
cfg := EnvConfig{
BackendAddr: ":8080", OIDCClientSecret: "hunter2",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
}
entries := map[string]string{}
for _, e := range cfg.DisplayEnv() {
entries[e.Name] = e.Value
}
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
}
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
}
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
}
empty := EnvConfig{}
for _, e := range empty.DisplayEnv() {
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
t.Errorf("unset secret = %q, want (unset)", e.Value)
}
}
}

View File

@@ -7,36 +7,34 @@ package config
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"strconv"
"strings" "strings"
"time"
) )
// Config holds geniusrund's process-level configuration. // EnvConfig holds geniusrund's process-level configuration.
type Config struct { type EnvConfig struct {
// Addr is the HTTP listen address, e.g. ":8080". // BackendAddr is the HTTP listen address, e.g. ":8080".
Addr string BackendAddr string
// DBPath is the SQLite database file path. // DBPath is the SQLite database file path.
DBPath string DBPath string
// GarminPythonPath is the python3 interpreter used to run the embedded // PythonPath is the python3 interpreter used to run the embedded
// Garmin wrapper script (internal/garmin's go:embed'd wrapper.py). // Garmin wrapper script (internal/garmin's go:embed'd wrapper.py).
// Defaults to "python3" resolved via PATH if unset. // Defaults to "python3" resolved via PATH if unset.
GarminPythonPath string PythonPath string
// GarminTokenStoreRoot is the root directory under which each user's // TokenStoreRoot is the root directory under which each user's
// Garmin session cache lives (one subdirectory per user id, e.g. // Garmin session cache lives (one subdirectory per user id, e.g.
// "<root>/3"). Read from the GARMIN_TOKENSTORE env var; if unset, it // "<root>/3"). Read from the GENIUSRUN_TOKENSTORE_PATH env var, configured
// defaults to a "garmin-tokenstores" directory next to DBPath so every // independently of DBPath -- if unset, it defaults to a ".garmin"
// deployment gets per-user isolation automatically -- multi-tenant // directory relative to the working directory the process is started
// operation always relies on this being a real, distinct-per-user path // from, not derived from DBPath in any way, so every deployment gets
// (see api.Server.garminFor), so it can never be silently left empty. // per-user isolation automatically -- multi-tenant operation always
GarminTokenStoreRoot string // relies on this being a real, distinct-per-user path (see
// api.Server.garminFor), so it can never be silently left empty.
TokenStoreRoot string
MinConfidence float64 // LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
IncrementalSyncEvery time.Duration LogLevel string
// OIDC login gate (Keycloak). BackendURL is this app's own externally // OIDC login gate (Keycloak). BackendURL is this app's own externally
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives // reachable origin (e.g. "https://geniusrun.example.com") -- it derives
@@ -61,37 +59,30 @@ type Config struct {
OIDCRedirectURL string OIDCRedirectURL string
OIDCRequiredRole string OIDCRequiredRole string
SessionSecret []byte SessionSecret []byte
SessionDuration time.Duration
SessionSecure bool SessionSecure bool
} }
// Load reads configuration from environment variables, applying defaults // LoadEnv reads configuration from environment variables, applying defaults
// for anything optional. Returns an error if a required variable is unset. // for anything optional. Returns an error if a required variable is unset.
func Load() (Config, error) { func LoadEnv() (EnvConfig, error) {
cfg := Config{ cfg := EnvConfig{
Addr: getEnvDefault("GENIUSRUN_ADDR", ":8080"), BackendAddr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"), DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
GarminPythonPath: getEnvDefault("GARMIN_WRAPPER_PYTHON", "python3"), PythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"), TokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6), LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"), BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"), OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"), OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"), OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
SessionDuration: getEnvDuration("GENIUSRUN_SESSION_DURATION", 720*time.Hour),
}
if cfg.GarminTokenStoreRoot == "" {
cfg.GarminTokenStoreRoot = filepath.Join(filepath.Dir(cfg.DBPath), "garmin-tokenstores")
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
} }
if cfg.BackendURL == "" { if cfg.BackendURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)") return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
} }
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/") cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
if cfg.OIDCIssuerURL == "" { if cfg.OIDCIssuerURL == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required") return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
} }
@@ -101,12 +92,13 @@ func Load() (Config, error) {
if cfg.OIDCClientSecret == "" { if cfg.OIDCClientSecret == "" {
return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required") return cfg, fmt.Errorf("GENIUSRUN_OIDC_CLIENT_SECRET is required")
} }
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET") sessionSecret := os.Getenv("GENIUSRUN_SESSION_SECRET")
if len(sessionSecret) < 32 { if len(sessionSecret) < 32 {
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters") return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
} }
cfg.SessionSecret = []byte(sessionSecret) cfg.SessionSecret = []byte(sessionSecret)
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://") cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
return cfg, nil return cfg, nil
@@ -119,20 +111,35 @@ func getEnvDefault(key, def string) string {
return def return def
} }
func getEnvFloat(key string, def float64) float64 { // EnvEntry is one environment-configuration variable as displayed on the
if v := os.Getenv(key); v != "" { // config page. Display-only: secrets are masked here, so raw values never
if f, err := strconv.ParseFloat(v, 64); err == nil { // leave the process.
return f type EnvEntry struct {
} Name string
} Value string
return def
} }
func getEnvDuration(key string, def time.Duration) time.Duration { // DisplayEnv returns the environment configuration as a display-safe
if v := os.Getenv(key); v != "" { // list, in stable order, secrets masked.
if d, err := time.ParseDuration(v); err == nil { func (c EnvConfig) DisplayEnv() []EnvEntry {
return d mask := func(set bool) string {
if set {
return "•••• (set)"
}
return "(unset)"
}
return []EnvEntry{
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.BackendAddr},
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.PythonPath},
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.TokenStoreRoot},
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
} }
} }
return def
}

View File

@@ -1,9 +1,7 @@
package config package config
import ( import (
"path/filepath"
"testing" "testing"
"time"
) )
func setRequiredEnv(t *testing.T) { func setRequiredEnv(t *testing.T) {
@@ -18,7 +16,7 @@ func setRequiredEnv(t *testing.T) {
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) { func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
@@ -31,16 +29,13 @@ func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
if cfg.OIDCRequiredRole != "geniusrun-user" { if cfg.OIDCRequiredRole != "geniusrun-user" {
t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole) t.Errorf("OIDCRequiredRole = %q, want default", cfg.OIDCRequiredRole)
} }
if cfg.SessionDuration != 720*time.Hour {
t.Errorf("SessionDuration = %v, want default 720h", cfg.SessionDuration)
}
} }
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) { func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080") t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
@@ -63,7 +58,7 @@ func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
t.Run(missing, func(t *testing.T) { t.Run(missing, func(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv(missing, "") t.Setenv(missing, "")
if _, err := Load(); err == nil { if _, err := LoadEnv(); err == nil {
t.Fatalf("expected error when %s is unset", missing) t.Fatalf("expected error when %s is unset", missing)
} }
}) })
@@ -74,63 +69,88 @@ func TestLoad_SessionSecretTooShort(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short") t.Setenv("GENIUSRUN_SESSION_SECRET", "too-short")
if _, err := Load(); err == nil { if _, err := LoadEnv(); err == nil {
t.Fatal("expected error for a session secret under 32 characters") t.Fatal("expected error for a session secret under 32 characters")
} }
} }
func TestLoad_GarminTokenStoreRootDefaultsNextToDBPath(t *testing.T) { func TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "") t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
want := filepath.Join("/var/lib/geniusrun", "garmin-tokenstores") if cfg.TokenStoreRoot != ".garmin" {
if cfg.GarminTokenStoreRoot != want { t.Errorf("GarminTokenStoreRoot = %q, want %q (never derived from DBPath's directory)", cfg.TokenStoreRoot, ".garmin")
t.Errorf("GarminTokenStoreRoot = %q, want %q", cfg.GarminTokenStoreRoot, want)
} }
} }
func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) { func TestLoad_GarminTokenStoreRootExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GARMIN_TOKENSTORE", "/custom/tokenstores") t.Setenv("GENIUSRUN_TOKENSTORE_PATH", "/custom/tokenstores")
t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db") t.Setenv("GENIUSRUN_DB_PATH", "/var/lib/geniusrun/geniusrun.db")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.GarminTokenStoreRoot != "/custom/tokenstores" { if cfg.TokenStoreRoot != "/custom/tokenstores" {
t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.GarminTokenStoreRoot) t.Errorf("GarminTokenStoreRoot = %q, want explicit override to take precedence", cfg.TokenStoreRoot)
}
}
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
}
}
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
cfg, err := LoadEnv()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
} }
} }
func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) { func TestLoad_GarminPythonPathDefaultsToPython3(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GARMIN_WRAPPER_PYTHON", "") t.Setenv("GENIUSRUN_PYTHON_PATH", "")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.GarminPythonPath != "python3" { if cfg.PythonPath != "python3" {
t.Errorf("GarminPythonPath = %q, want default %q", cfg.GarminPythonPath, "python3") t.Errorf("GarminPythonPath = %q, want default %q", cfg.PythonPath, "python3")
} }
} }
func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) { func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GARMIN_WRAPPER_PYTHON", "/opt/venv/bin/python3") t.Setenv("GENIUSRUN_PYTHON_PATH", "/opt/venv/bin/python3")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.GarminPythonPath != "/opt/venv/bin/python3" { if cfg.PythonPath != "/opt/venv/bin/python3" {
t.Errorf("GarminPythonPath = %q, want explicit override", cfg.GarminPythonPath) t.Errorf("GarminPythonPath = %q, want explicit override", cfg.PythonPath)
} }
} }
@@ -138,7 +158,7 @@ func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "") t.Setenv("GENIUSRUN_FRONTEND_URL", "")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
@@ -151,7 +171,7 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/") t.Setenv("GENIUSRUN_FRONTEND_URL", "http://localhost:5173/")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
@@ -160,19 +180,15 @@ func TestLoad_FrontendURLExplicitOverridesDefault(t *testing.T) {
} }
} }
func TestLoad_CustomRoleAndDuration(t *testing.T) { func TestLoad_CustomRole(t *testing.T) {
setRequiredEnv(t) setRequiredEnv(t)
t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin") t.Setenv("GENIUSRUN_OIDC_REQUIRED_ROLE", "admin")
t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")
cfg, err := Load() cfg, err := LoadEnv()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if cfg.OIDCRequiredRole != "admin" { if cfg.OIDCRequiredRole != "admin" {
t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole) t.Errorf("OIDCRequiredRole = %q", cfg.OIDCRequiredRole)
} }
if cfg.SessionDuration != 24*time.Hour {
t.Errorf("SessionDuration = %v", cfg.SessionDuration)
}
} }

View File

@@ -1,5 +1,5 @@
// Package garmin wraps a direct garminconnect subprocess (see // 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. // geniusrun never deals with the wire protocol directly.
package garmin package garmin
@@ -8,15 +8,18 @@ import (
"context" "context"
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"strconv" "strconv"
"sync" "sync"
"time"
) )
//go:embed pyscript/wrapper.py //go:embed wrapper/wrapper.py
var wrapperScript string var wrapperScript string
// maxWrapperLineBytes bounds one JSON-line response from the wrapper // maxWrapperLineBytes bounds one JSON-line response from the wrapper
@@ -52,15 +55,15 @@ type Client interface {
Close() error Close() error
} }
// Config configures how the Garmin wrapper subprocess is spawned. // ClientConfig configures how the Garmin wrapper subprocess is spawned.
type Config struct { type ClientConfig struct {
// PythonPath is the python3 interpreter to run the embedded wrapper // PythonPath is the python3 interpreter to run the embedded wrapper
// script with. Empty defaults to "python3" resolved via PATH. // script with. Empty defaults to "python3" resolved via PATH.
PythonPath string PythonPath string
GarminEmail string GarminEmail string
GarminPassword string GarminPassword string
// TokenStorePath, if set, is passed as GARMIN_TOKENSTORE so the wrapper // TokenStorePath is passed as GARMIN_TOKENSTORE so the wrapper
// persists/resumes a Garmin session there instead of the default ~/.garth. // persists/resumes a Garmin session there.
TokenStorePath string TokenStorePath string
} }
@@ -72,12 +75,26 @@ type wireRequest struct {
} }
// wireResponse is one line read from the wrapper subprocess's stdout. // wireResponse is one line read from the wrapper subprocess's stdout.
// ErrorType/Traceback carry the Python-side failure detail in the protocol
// itself (see wrapper.py's dispatch), so the Go side logs one complete
// structured record per failed call instead of correlating stderr noise.
type wireResponse struct { type wireResponse struct {
ID int `json:"id"` ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"` Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
Traceback string `json:"traceback,omitempty"`
NotFound bool `json:"not_found,omitempty"`
} }
// ErrNotFound wraps any error a Client method returns when the wrapper
// reported a definitive HTTP 404 (garminconnect's own
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
// on Garmin's side after being linked to an activity. Callers use
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
// worth retrying.
var ErrNotFound = errors.New("garmin: resource not found")
// callParams is the Params payload for a generic "call" request: dispatches // callParams is the Params payload for a generic "call" request: dispatches
// to any garminconnect.Garmin method by name. // to any garminconnect.Garmin method by name.
type callParams struct { type callParams struct {
@@ -107,7 +124,7 @@ func mapAuthStatus(s string) AuthStatus {
// subprocessClient is the real Client implementation, backed by a wrapper // subprocessClient is the real Client implementation, backed by a wrapper
// subprocess spoken to over newline-delimited JSON on stdio. // subprocess spoken to over newline-delimited JSON on stdio.
type subprocessClient struct { type subprocessClient struct {
cfg Config cfg ClientConfig
mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client mu sync.Mutex // serializes calls; the wrapper holds a single shared Garmin client
cmd *exec.Cmd cmd *exec.Cmd
@@ -122,7 +139,7 @@ type subprocessClient struct {
// ensureStarted spawns the wrapper subprocess if it isn't already running. // ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu. // Callers must hold c.mu.
func (c *subprocessClient) ensureStarted() error { func (c *subprocessClient) ensureStarted(ctx context.Context) error {
if c.started { if c.started {
return nil return nil
} }
@@ -130,14 +147,14 @@ func (c *subprocessClient) ensureStarted() error {
if c.scriptPath == "" { if c.scriptPath == "" {
f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py") f, err := os.CreateTemp("", "geniusrun-garmin-wrapper-*.py")
if err != nil { if err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
if _, err := f.WriteString(wrapperScript); err != nil { if _, err := f.WriteString(wrapperScript); err != nil {
f.Close() f.Close()
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
return fmt.Errorf("write embedded wrapper script: %w", err) return fmt.Errorf("write wrapper script: %w", err)
} }
c.scriptPath = f.Name() c.scriptPath = f.Name()
} }
@@ -151,11 +168,9 @@ func (c *subprocessClient) ensureStarted() error {
extraEnv := []string{ extraEnv := []string{
"GARMIN_EMAIL=" + c.cfg.GarminEmail, "GARMIN_EMAIL=" + c.cfg.GarminEmail,
"GARMIN_PASSWORD=" + c.cfg.GarminPassword, "GARMIN_PASSWORD=" + c.cfg.GarminPassword,
"GARMIN_TOKENSTORE=" + c.cfg.TokenStorePath,
"PYTHONUNBUFFERED=1", "PYTHONUNBUFFERED=1",
} }
if c.cfg.TokenStorePath != "" {
extraEnv = append(extraEnv, "GARMIN_TOKENSTORE="+c.cfg.TokenStorePath)
}
cmd.Env = append(os.Environ(), extraEnv...) cmd.Env = append(os.Environ(), extraEnv...)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
@@ -172,15 +187,18 @@ func (c *subprocessClient) ensureStarted() error {
} }
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("spawn garmin wrapper subprocess: %w", err) return fmt.Errorf("spawn wrapper subprocess: %w", err)
} }
// wrapper.py logs auth/rate-limit diagnostics to stderr. Per os/exec's // wrapper.py logs auth/rate-limit diagnostics to stderr as JSON lines
// StderrPipe docs, it's incorrect to call Wait before all reads from the // (see its _log helper); forwardWrapperStderr re-emits them through the
// pipe have completed, so close() waits on stderrDone before Wait-ing. // application logger so the backend's output stays one JSON stream. Per
// os/exec's StderrPipe docs, it's incorrect to call Wait before all
// reads from the pipe have completed, so close() waits on stderrDone
// before Wait-ing.
c.stderrDone = make(chan struct{}) c.stderrDone = make(chan struct{})
stderrDone := c.stderrDone stderrDone := c.stderrDone
go func() { go func() {
io.Copy(os.Stderr, stderr) forwardWrapperStderr(stderr)
close(stderrDone) close(stderrDone)
}() }()
@@ -192,48 +210,95 @@ func (c *subprocessClient) ensureStarted() error {
c.scanner = scanner c.scanner = scanner
c.started = true c.started = true
c.nextID = 0 c.nextID = 0
return nil return nil
} }
// roundTrip sends one request and returns its result payload, or an error // execute sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already // if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted. // called ensureStarted. Logs exactly one type=wrapper line regardless of
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) { // outcome (the record's meaning is carried by its fields, no msg) --
// cmd/params are always safe to log in full here: Garmin credentials only
// ever reach the subprocess via env vars at spawn time (see
// ensureStarted), never through these wire params.
func (c *subprocessClient) execute(ctx context.Context, cmd string, params any) (result json.RawMessage, err error) {
start := time.Now()
var errorType, traceback string // Python-side failure detail, set from the error response
defer func() {
attrs := []slog.Attr{
slog.String("type", "wrapper"),
slog.String("package", "garmin"),
slog.String("file", "client.go"),
slog.String("class", "subprocessClient"),
slog.String("method", "execute"),
slog.String("cmd", cmd),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelError
attrs = append(attrs, slog.String("error", err.Error()))
if errorType != "" {
attrs = append(attrs, slog.String("error_type", errorType))
}
if traceback != "" {
attrs = append(attrs, slog.String("traceback", traceback))
}
}
slog.Default().LogAttrs(ctx, level, "", attrs...)
}()
c.nextID++ c.nextID++
id := c.nextID id := c.nextID
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil { if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmd, Params: params}); err != nil {
return nil, fmt.Errorf("write %s request: %w", cmdName, err) err = fmt.Errorf("write %s request: %w", cmd, err)
return nil, err
} }
if !c.scanner.Scan() { if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil { if serr := c.scanner.Err(); serr != nil {
return nil, fmt.Errorf("read %s response: %w", cmdName, err) err = fmt.Errorf("read %s response: %w", cmd, serr)
} else {
err = fmt.Errorf("read %s response: subprocess closed its output", cmd)
} }
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName) return nil, err
} }
var resp wireResponse var resp wireResponse
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil { if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
return nil, fmt.Errorf("parse %s response: %w", cmdName, err) err = fmt.Errorf("parse %s response: %w", cmd, uerr)
return nil, err
} }
if resp.ID != id { if resp.ID != id {
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id) err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmd, resp.ID, id)
return nil, err
} }
if resp.Error != "" { if resp.Error != "" {
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error) errorType, traceback = resp.ErrorType, resp.Traceback
if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmd, resp.Error, ErrNotFound)
} else {
err = fmt.Errorf("%s: %s", cmd, resp.Error)
} }
return resp.Result, nil return nil, err
}
result = resp.Result
return result, nil
} }
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) { func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
raw, err := c.roundTrip("authenticate", nil) raw, err := c.execute(ctx, "authenticate", nil)
if err != nil { if err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
@@ -248,10 +313,10 @@ func (c *subprocessClient) CompleteMFA(ctx context.Context, code string) (AuthRe
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code}) raw, err := c.execute(ctx, "complete_mfa", map[string]any{"code": code})
if err != nil { if err != nil {
return AuthResult{}, err return AuthResult{}, err
} }
@@ -306,6 +371,55 @@ func (c *subprocessClient) close() error {
return err return err
} }
// forwardWrapperStderr re-emits the wrapper subprocess's stderr through
// the application logger. wrapper.py writes JSON lines ({"level","msg",
// ...attrs} -- see its _log helper), which are decoded and re-logged at
// the corresponding level with their attrs preserved. Anything that isn't
// such a line (a Python startup crash before _log exists, a chatty
// third-party library printing directly) is wrapped as a warn-level
// type=wrapper record carrying the raw "line" rather than passed through, so the
// backend's combined output is JSON no matter what the subprocess does.
func forwardWrapperStderr(r io.Reader) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // tracebacks can exceed the default token size
for scanner.Scan() {
line := scanner.Text()
var entry map[string]any
if err := json.Unmarshal([]byte(line), &entry); err != nil || entry["msg"] == nil {
slog.Warn("", "type", "wrapper", "package", "garmin", "file", "client.go", "method", "forwardWrapperStderr", "line", line)
continue
}
msg, _ := entry["msg"].(string)
levelName, _ := entry["level"].(string)
delete(entry, "msg")
delete(entry, "level")
// Python-emitted lines carry no "package" (a Go-only field) and no
// "class" (wrapper.py has none); method arrives in the entry itself
// (wrapper.py's _log stamps the emitting function).
attrs := make([]slog.Attr, 0, len(entry)+2)
attrs = append(attrs, slog.String("type", "wrapper"), slog.String("file", "wrapper.py"))
for k, v := range entry {
attrs = append(attrs, slog.Any(k, v))
}
slog.Default().LogAttrs(context.Background(), wrapperLogLevel(levelName), msg, attrs...)
}
}
// wrapperLogLevel maps wrapper.py's level strings onto slog levels,
// defaulting to info for anything unrecognized.
func wrapperLogLevel(level string) slog.Level {
switch level {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func truncate(s string, n int) string { func truncate(s string, n int) string {
if len(s) <= n { if len(s) <= n {
return s return s
@@ -317,7 +431,7 @@ var _ Client = (*subprocessClient)(nil)
// NewClient builds a Client. The subprocess is not spawned until the first // NewClient builds a Client. The subprocess is not spawned until the first
// call that needs it (Authenticate, or any data call once authenticated). // call that needs it (Authenticate, or any data call once authenticated).
func NewClient(cfg Config) Client { func NewClient(cfg ClientConfig) Client {
return &subprocessClient{cfg: cfg} return &subprocessClient{cfg: cfg}
} }
@@ -325,10 +439,10 @@ func (c *subprocessClient) GetActivities(ctx context.Context, startDate, endDate
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return nil, err return nil, err
} }
raw, err := c.roundTrip("call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activities_by_date", Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate}, Args: map[string]any{"startdate": startDate, "enddate": endDate},
}) })
@@ -360,10 +474,10 @@ func (c *subprocessClient) GetActivitySplits(ctx context.Context, activityID int
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err return ActivitySplits{}, err
} }
raw, err := c.roundTrip("call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_splits", Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
@@ -395,10 +509,10 @@ func (c *subprocessClient) GetActivityDetails(ctx context.Context, activityID in
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err return ActivityDetails{}, err
} }
raw, err := c.roundTrip("call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_activity_details", Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)}, Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
}) })
@@ -418,10 +532,10 @@ func (c *subprocessClient) GetWorkoutByID(ctx context.Context, workoutID int64)
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
if err := c.ensureStarted(); err != nil { if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err return Workout{}, err
} }
raw, err := c.roundTrip("call", callParams{ raw, err := c.execute(ctx, "call", callParams{
Method: "get_workout_by_id", Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)}, Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
}) })

View File

@@ -2,13 +2,18 @@ package garmin
import ( import (
"bufio" "bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"io" "io"
"log/slog"
"os/exec" "os/exec"
"strings" "strings"
"testing" "testing"
"time" "time"
"geniusrun/backend/internal/log"
) )
// wireResponsePayload is what a fake wrapper handler returns for one // wireResponsePayload is what a fake wrapper handler returns for one
@@ -16,6 +21,7 @@ import (
type wireResponsePayload struct { type wireResponsePayload struct {
result json.RawMessage result json.RawMessage
err string err string
notFound bool
} }
func fakeResult(v any) wireResponsePayload { func fakeResult(v any) wireResponsePayload {
@@ -30,6 +36,10 @@ func fakeError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg} return wireResponsePayload{err: msg}
} }
func fakeNotFoundError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg, notFound: true}
}
// newFakeWrapperClient wires a subprocessClient to an in-process goroutine // newFakeWrapperClient wires a subprocessClient to an in-process goroutine
// that plays the Python wrapper's role, so protocol-level Go logic can be // that plays the Python wrapper's role, so protocol-level Go logic can be
// tested without python3/garminconnect installed. // tested without python3/garminconnect installed.
@@ -57,7 +67,7 @@ func newFakeWrapperClient(t *testing.T, handle func(cmd string, params json.RawM
continue continue
} }
payload := handle(req.Cmd, req.Params) payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err} resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
if err := enc.Encode(resp); err != nil { if err := enc.Encode(resp); err != nil {
return return
} }
@@ -91,7 +101,7 @@ func TestSubprocessClient_RoundTrip_DetectsIDMismatch(t *testing.T) {
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "id mismatch") { if err == nil || !strings.Contains(err.Error(), "id mismatch") {
t.Fatalf("roundTrip error = %v, want an id mismatch error", err) t.Fatalf("roundTrip error = %v, want an id mismatch error", err)
} }
@@ -113,7 +123,7 @@ func TestSubprocessClient_RoundTrip_SubprocessClosedIsError(t *testing.T) {
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes) scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner} c := &subprocessClient{started: true, enc: json.NewEncoder(reqW), scanner: scanner}
_, err := c.roundTrip("authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "closed") { if err == nil || !strings.Contains(err.Error(), "closed") {
t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err) t.Fatalf("roundTrip error = %v, want a subprocess-closed error", err)
} }
@@ -124,14 +134,39 @@ func TestSubprocessClient_RoundTrip_WrapperErrorPropagates(t *testing.T) {
return fakeError("boom") return fakeError("boom")
}) })
_, err := c.roundTrip("authenticate", nil) _, err := c.execute(context.Background(), "authenticate", nil)
if err == nil || !strings.Contains(err.Error(), "boom") { if err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom") t.Fatalf("roundTrip error = %v, want it to mention %q", err, "boom")
} }
} }
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeNotFoundError("API Error 404")
})
_, err := c.execute(context.Background(), "call", nil)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
}
if !strings.Contains(err.Error(), "API Error 404") {
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
}
}
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.execute(context.Background(), "call", nil)
if errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
}
}
func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) { func TestSubprocessClient_UpdateCredentials_ResetsStartedState(t *testing.T) {
c := &subprocessClient{cfg: Config{GarminEmail: "old@example.com", GarminPassword: "old"}} c := &subprocessClient{cfg: ClientConfig{GarminEmail: "old@example.com", GarminPassword: "old"}}
c.started = true // simulate an already-spawned subprocess, no real cmd/pipes c.started = true // simulate an already-spawned subprocess, no real cmd/pipes
c.UpdateCredentials("new@example.com", "new") c.UpdateCredentials("new@example.com", "new")
@@ -394,3 +429,108 @@ func TestSubprocessClient_GetWorkoutByID_ParsesSegments(t *testing.T) {
t.Fatalf("workout = %+v", workout) t.Fatalf("workout = %+v", workout)
} }
} }
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
})
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(context.Background()); err != nil {
t.Fatalf("Authenticate: %v", err)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["type"] != "wrapper" || entry["package"] != "garmin" || entry["file"] != "client.go" || entry["class"] != "subprocessClient" || entry["method"] != "execute" {
t.Errorf("schema fields = %v, want type=wrapper package=garmin file=client.go class=subprocessClient method=execute", entry)
}
if _, hasMsg := entry["msg"]; hasMsg {
t.Errorf("expected no msg on the wrapper-call record, got %v", entry["msg"])
}
if entry["cmd"] != "authenticate" {
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
}
preview, _ := entry["result"].(string)
if !strings.Contains(preview, "mfa_required") {
t.Errorf("result = %q, want it to contain mfa_required", preview)
}
if _, hasError := entry["error"]; hasError {
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
}
}
func TestSubprocessClient_RoundTrip_LogsFailedCallAtErrorLevel(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("info", &buf))
defer slog.SetDefault(prev)
if _, err := c.Authenticate(context.Background()); err == nil {
t.Fatal("expected Authenticate to return an error")
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "ERROR" {
t.Errorf("level = %v, want ERROR for a failed call", entry["level"])
}
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
}
}
func TestForwardWrapperStderr_ReemitsJSONAndWrapsFreeText(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(applog.NewLogger("debug", &buf))
defer slog.SetDefault(prev)
stderr := strings.NewReader(
`{"level":"warn","msg":"startup tokenstore login failed","error":"boom"}` + "\n" +
"Traceback (most recent call last): free text\n",
)
forwardWrapperStderr(stderr)
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 log lines, got %d: %q", len(lines), buf.String())
}
var first map[string]any
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
t.Fatalf("first line not JSON: %v", err)
}
if first["level"] != "WARN" || first["msg"] != "startup tokenstore login failed" {
t.Errorf("first = %v, want the wrapper's level and msg preserved", first)
}
if first["error"] != "boom" || first["type"] != "wrapper" || first["file"] != "wrapper.py" {
t.Errorf("first = %v, want error attr preserved and type=wrapper file=wrapper.py", first)
}
var second map[string]any
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
t.Fatalf("second line not JSON: %v", err)
}
if _, hasMsg := second["msg"]; hasMsg {
t.Errorf("second = %v, want no msg on a wrapped free-text record", second)
}
if second["type"] != "wrapper" || second["level"] != "WARN" {
t.Errorf("second = %v, want free text wrapped as a warn type=wrapper record", second)
}
if line, _ := second["line"].(string); !strings.Contains(line, "Traceback") {
t.Errorf("second line attr = %q, want the raw text preserved", line)
}
}

View File

@@ -1,4 +1,4 @@
package sync package garmin
import ( import (
"encoding/json" "encoding/json"
@@ -6,7 +6,6 @@ import (
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
@@ -19,7 +18,7 @@ func isRunningActivityType(typeKey string) bool {
return strings.Contains(strings.ToLower(typeKey), "run") return strings.Contains(strings.ToLower(typeKey), "run")
} }
func toActivityRow(a garmin.Activity) store.Activity { func toActivityRow(a Activity) store.Activity {
return store.Activity{ return store.Activity{
GarminActivityID: a.ActivityID, GarminActivityID: a.ActivityID,
EventTypeKey: a.EventType.TypeKey, EventTypeKey: a.EventType.TypeKey,
@@ -52,7 +51,7 @@ func nonZero(v float64) *float64 {
// fall within that lap's time window. Lap boundaries are derived from // fall within that lap's time window. Lap boundaries are derived from
// cumulative elapsed duration rather than parsing StartTimeGMT, since laps // cumulative elapsed duration rather than parsing StartTimeGMT, since laps
// are contiguous and this sidesteps timezone parsing entirely. // are contiguous and this sidesteps timezone parsing entirely.
func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.WorkoutStep, profile store.Profile) []store.Lap { func toLapRows(laps []Lap, samples []Sample, targets []*WorkoutStep, profile store.Profile) []store.Lap {
rows := make([]store.Lap, 0, len(laps)) rows := make([]store.Lap, 0, len(laps))
var elapsedStart float64 var elapsedStart float64
for i, l := range laps { for i, l := range laps {
@@ -94,9 +93,9 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
return rows return rows
} }
// alignWorkoutTargets zips an activity's recorded laps against its // alignWorkoutTargets zips an activity's lap count against its structured
// structured workout's flattened steps, returning one *garmin.WorkoutStep // workout's flattened steps, returning one *garmin.WorkoutStep per lap (nil
// per lap (nil where unavailable). // where unavailable).
// //
// Confirmed against a real activity (via Garmin Connect's own workout view) // Confirmed against a real activity (via Garmin Connect's own workout view)
// that recording sometimes continues one lap past the end of the workout's // that recording sometimes continues one lap past the end of the workout's
@@ -110,11 +109,11 @@ func toLapRows(laps []garmin.Lap, samples []garmin.Sample, targets []*garmin.Wor
// Any other mismatch (extra manual laps, auto-lap-by-distance also firing, // Any other mismatch (extra manual laps, auto-lap-by-distance also firing,
// etc.) can't be trusted at all, so every entry comes back nil rather than // etc.) can't be trusted at all, so every entry comes back nil rather than
// risk showing a target against the wrong lap. // risk showing a target against the wrong lap.
func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.WorkoutStep { func alignWorkoutTargets(lapCount int, workout Workout) []*WorkoutStep {
steps := workout.FlattenSteps() steps := workout.FlattenSteps()
out := make([]*garmin.WorkoutStep, len(laps)) out := make([]*WorkoutStep, lapCount)
switch len(laps) - len(steps) { switch lapCount - len(steps) {
case 0, 1: case 0, 1:
for i := range steps { for i := range steps {
s := steps[i] s := steps[i]
@@ -126,7 +125,7 @@ func alignWorkoutTargets(laps []garmin.Lap, workout garmin.Workout) []*garmin.Wo
// targetPaceRange returns the (low, high) m/s bounds of a workout step's // targetPaceRange returns the (low, high) m/s bounds of a workout step's
// pace-zone target, or (nil, nil) if it doesn't target pace. // pace-zone target, or (nil, nil) if it doesn't target pace.
func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) { func targetPaceRange(step WorkoutStep) (*float64, *float64) {
if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil { if step.TargetType.TypeKey != "pace.zone" || step.TargetValueOne == nil || step.TargetValueTwo == nil {
return nil, nil return nil, nil
} }
@@ -141,7 +140,7 @@ func targetPaceRange(step garmin.WorkoutStep) (*float64, *float64) {
// heart-rate-zone target, or (nil, nil) if it doesn't target heart rate. // heart-rate-zone target, or (nil, nil) if it doesn't target heart rate.
// Steps that target a named zone (ZoneNumber) rather than a custom bpm // Steps that target a named zone (ZoneNumber) rather than a custom bpm
// range are resolved via the user's Karvonen profile. // range are resolved via the user's Karvonen profile.
func targetHRRange(step garmin.WorkoutStep, profile store.Profile) (*float64, *float64) { func targetHRRange(step WorkoutStep, profile store.Profile) (*float64, *float64) {
if step.TargetType.TypeKey != "heart.rate.zone" { if step.TargetType.TypeKey != "heart.rate.zone" {
return nil, nil return nil, nil
} }
@@ -188,7 +187,7 @@ func karvonenBounds(p store.Profile, zone int) (lowBpm, highBpm float64, ok bool
return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true return restHR + (minPct/100)*(maxHR-restHR), restHR + (maxPct/100)*(maxHR-restHR), true
} }
func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.SampleInfo { func samplesInWindow(samples []Sample, start, end float64) []classify.SampleInfo {
var out []classify.SampleInfo var out []classify.SampleInfo
for _, s := range samples { for _, s := range samples {
if s.ElapsedSeconds < start || s.ElapsedSeconds >= end { if s.ElapsedSeconds < start || s.ElapsedSeconds >= end {
@@ -199,7 +198,7 @@ func samplesInWindow(samples []garmin.Sample, start, end float64) []classify.Sam
return out return out
} }
func toSampleRows(samples []garmin.Sample) []store.Sample { func toSampleRows(samples []Sample) []store.Sample {
rows := make([]store.Sample, len(samples)) rows := make([]store.Sample, len(samples))
for i, s := range samples { for i, s := range samples {
rows[i] = store.Sample{ rows[i] = store.Sample{

View File

@@ -0,0 +1,114 @@
// MockClient support: a fake Client for tests and frontend/dev
// work without a live Garmin account or the wrapper subprocess.
package garmin
import (
"context"
"time"
)
// MockClient is a fake Client returning data supplied by the test/caller.
type MockClient struct {
AuthResults []AuthResult // consumed in order by Authenticate/CompleteMFA calls
Activities []Activity
Splits map[int64]ActivitySplits
Details map[int64]ActivityDetails
Workouts map[int64]Workout
// WorkoutErrByID, if set for a given workout ID, makes GetWorkoutByID
// return that error for that ID specifically -- independent of the
// all-calls-fail Err field below -- so a test can simulate one
// activity's workout fetch failing while others in the same batch
// succeed.
WorkoutErrByID map[int64]error
// Delay, if set, is slept (ctx-cancellable) at the start of every
// GetActivities call -- lets a test give sync.Service's discovering
// phase (backfillCore/incrementalSyncCore) real wall-clock duration, so
// a concurrent goroutine can observe Progress() mid-flight instead of
// the call returning instantly.
Delay time.Duration
Err error // if set, every call returns this error
authResultCursor int
ClosedCalled bool
GetActivitiesCalls int
AuthenticateCalls int
LastEmail string
LastPassword string
}
var _ Client = (*MockClient)(nil)
func (c *MockClient) nextAuthResult() AuthResult {
if c.authResultCursor >= len(c.AuthResults) {
return AuthResult{Status: AuthSuccess, Message: "Authenticated successfully."}
}
r := c.AuthResults[c.authResultCursor]
c.authResultCursor++
return r
}
func (c *MockClient) Authenticate(ctx context.Context) (AuthResult, error) {
c.AuthenticateCalls++
if c.Err != nil {
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *MockClient) CompleteMFA(ctx context.Context, code string) (AuthResult, error) {
if c.Err != nil {
return AuthResult{}, c.Err
}
return c.nextAuthResult(), nil
}
func (c *MockClient) GetActivities(ctx context.Context, startDate, endDate string, limit int) ([]Activity, error) {
c.GetActivitiesCalls++
if c.Delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(c.Delay):
}
}
if c.Err != nil {
return nil, c.Err
}
if limit > 0 && limit < len(c.Activities) {
return c.Activities[:limit], nil
}
return c.Activities, nil
}
func (c *MockClient) GetActivitySplits(ctx context.Context, activityID int64) (ActivitySplits, error) {
if c.Err != nil {
return ActivitySplits{}, c.Err
}
return c.Splits[activityID], nil
}
func (c *MockClient) GetActivityDetails(ctx context.Context, activityID int64) (ActivityDetails, error) {
if c.Err != nil {
return ActivityDetails{}, c.Err
}
return c.Details[activityID], nil
}
func (c *MockClient) GetWorkoutByID(ctx context.Context, workoutID int64) (Workout, error) {
if c.Err != nil {
return Workout{}, c.Err
}
if err, ok := c.WorkoutErrByID[workoutID]; ok {
return Workout{}, err
}
return c.Workouts[workoutID], nil
}
func (c *MockClient) UpdateCredentials(email, password string) {
c.LastEmail = email
c.LastPassword = password
}
func (c *MockClient) Close() error {
c.ClosedCalled = true
return nil
}

View File

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

View File

@@ -1,202 +0,0 @@
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
JSON on stdin/stdout by geniusrund's internal/garmin package. See
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
import json
import os
import queue
import sys
import threading
import traceback
from garminconnect import Garmin
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garth"))
_client = None
_auth_state = "unauthenticated"
_mfa_input_queue = queue.Queue()
_login_result_queue = queue.Queue()
def _debug(msg):
print(f"[garmin-wrapper debug] {msg}", file=sys.stderr, flush=True)
def _prompt_mfa():
_debug("garminconnect invoked prompt_mfa() -- this is a REAL MFA challenge from Garmin")
code = _mfa_input_queue.get(timeout=300)
_debug(f"prompt_mfa() handing code of length {len(code)} back to garminconnect")
return code
def _startup_login():
"""Silently resume a cached tokenstore session at process start, so a
freshly (re)spawned subprocess is already authenticated for background
syncs that never call the explicit authenticate command.
Bounded to the same 10s timeout as _handle_authenticate: the actual
login runs on a background daemon thread, and this function waits up
to 10s for it before returning either way. This runs before main()
enters its stdin dispatch loop, so a slow/rate-limited Garmin login
must never block indefinitely here -- if it times out, the thread
keeps running and will update _auth_state whenever it eventually
finishes (success or failure), same as today, just without wedging
the whole subprocess unresponsive in the meantime.
Deliberately uses its own private, function-local result queue rather
than the module-level _login_result_queue that _handle_authenticate and
_handle_complete_mfa share -- those two are legitimately two halves of
one explicit, MFA-capable login flow and must share a queue for the MFA
handoff to work, but this is a plain background tokenstore resume with
no MFA involved. Sharing the queue here would let this thread's stale
result (arriving after the 10s timeout below) be dequeued by an
unrelated, later authenticate/complete_mfa call instead of that call's
own fresh result."""
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL")
password = os.environ.get("GARMIN_PASSWORD")
if not email or not password:
return
_client = Garmin(email, password)
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_login():
try:
_client.login(tokenstore=TOKENSTORE)
result_queue.put(("success", None))
except Exception as exc:
result_queue.put(("error", str(exc)))
threading.Thread(target=_do_login, daemon=True).start()
try:
status, err = result_queue.get(timeout=10)
if status == "success":
_auth_state = "authenticated"
else:
_debug(f"startup tokenstore login failed: {err}")
_auth_state = "unauthenticated"
except queue.Empty:
_debug(
"startup tokenstore login hit the 10s timeout -- still running in the "
"background and will update auth state whenever it finishes"
)
_auth_state = "unauthenticated"
def _handle_authenticate(_params):
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL", "")
password = os.environ.get("GARMIN_PASSWORD", "")
if not email or not password:
return {
"status": "failed",
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
}
def _do_login():
_debug(f"background login thread starting _client.login(tokenstore={TOKENSTORE})")
try:
_client.login(tokenstore=TOKENSTORE)
_debug("_client.login() returned successfully")
_login_result_queue.put(("success", None))
except Exception as exc:
_debug(f"_client.login() raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
_login_result_queue.put(("error", str(exc)))
_client = Garmin(email, password)
_client.prompt_mfa = _prompt_mfa
threading.Thread(target=_do_login, daemon=True).start()
try:
status, err = _login_result_queue.get(timeout=10)
_debug(f"authenticate got result within 10s timeout: status={status}")
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty:
_debug(
"authenticate hit the 10s timeout with no result yet -- reporting mfa_required, "
"but this does NOT necessarily mean prompt_mfa() was actually invoked; check "
"whether the 'REAL MFA challenge' debug line above appears to tell real MFA "
"apart from a merely slow login."
)
_auth_state = "mfa_pending"
return {
"status": "mfa_required",
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
}
def _handle_complete_mfa(params):
global _auth_state
if _auth_state != "mfa_pending":
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"]
_debug(f"complete_mfa received a code of length {len(code)}, pushing to mfa queue")
_mfa_input_queue.put(code)
try:
status, err = _login_result_queue.get(timeout=30)
_debug(f"complete_mfa got result: status={status} err={err}")
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
except queue.Empty:
_auth_state = "unauthenticated"
return {
"status": "failed",
"message": "Timed out waiting for authentication to complete. Call authenticate again.",
}
def _handle_call(params):
if _auth_state != "authenticated":
raise RuntimeError("Not authenticated. Call authenticate first.")
method = params["method"]
args = params.get("args") or {}
fn = getattr(_client, method)
return fn(**args)
_HANDLERS = {
"authenticate": _handle_authenticate,
"complete_mfa": _handle_complete_mfa,
"call": _handle_call,
}
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
return {"id": req.get("id"), "error": str(exc)}
def main():
_startup_login()
for line in sys.stdin:
line = line.strip()
if not line:
continue
req = json.loads(line)
resp = dispatch(req)
print(json.dumps(resp), flush=True)
if __name__ == "__main__":
main()

View File

@@ -1,27 +1,27 @@
// Package sync orchestrates fetching activities from Garmin (via // Package garmin orchestrates fetching activities from Garmin (via
// internal/garmin), persisting them (via internal/store), and classifying // internal/garmin), persisting them (via internal/store), and classifying
// them (via internal/classify). It's the only package that depends on all // them (via internal/classify). It's the only package that depends on all
// three, keeping garmin/store/classify decoupled from each other. // three, keeping garmin/store/classify decoupled from each other.
package sync package garmin
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
"geniusrun/backend/internal/classify" "geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin" applog "geniusrun/backend/internal/log"
"geniusrun/backend/internal/store" "geniusrun/backend/internal/store"
) )
// Config tunes sync behavior. Zero values fall back to sensible defaults in // Config tunes sync behavior. Zero values fall back to sensible defaults in
// NewService. How far back Backfill reaches is not here -- it's // NewSync. How far back Backfill reaches is not here -- it's
// Profile.BackfillHorizonDays, read fresh on every call so a user-edited // Profile.BackfillHorizonDays, read fresh on every call so a user-edited
// value takes effect on the next sync without a server restart. // value takes effect on the next sync without a server restart.
type Config struct { type SyncConfig struct {
// BackfillWindowDays is the page size for each get_activities call // BackfillWindowDays is the page size for each get_activities call
// during backfill. // during backfill.
BackfillWindowDays int BackfillWindowDays int
@@ -38,7 +38,7 @@ type Config struct {
MinConfidence float64 MinConfidence float64
} }
func (c Config) withDefaults() Config { func (c SyncConfig) withDefaults() SyncConfig {
if c.BackfillWindowDays == 0 { if c.BackfillWindowDays == 0 {
c.BackfillWindowDays = 90 c.BackfillWindowDays = 90
} }
@@ -54,49 +54,64 @@ func (c Config) withDefaults() Config {
return c return c
} }
// Progress reports how far a currently-running (or just-finished) // Phase values reported by Progress.Phase.
// FillPendingDetails pass has gotten, for a status banner to poll. const (
PhaseIdle = "idle"
PhaseDiscovering = "discovering"
PhaseActivities = "activities"
PhaseWorkouts = "workouts"
)
// Progress reports how far a currently-running (or just-finished) FullSync
// has gotten, for a status modal to poll. PhaseDiscovering (backfillCore/
// incrementalSyncCore) has no meaningful Total -- discovering how many
// activities exist IS the act of fetching them, so it's reported as an
// indeterminate step (Done/Total both 0) rather than a fake percentage.
// PhaseActivities/PhaseWorkouts do have a real Total, taken once from a
// local DB count at the start of each pass (see FillPendingDetails).
type Progress struct { type Progress struct {
Phase string
Done int Done int
Total int Total int
} }
// Service is the sync orchestrator, scoped to one user -- every store call // Sync is the sync orchestrator, scoped to one user -- every store call
// it makes is for userID's data only. // it makes is for userID's data only.
type Service struct { type Sync struct {
garmin garmin.Client garmin Client
db *store.DB db *store.DB
userID int64 userID int64
cfg Config cfg SyncConfig
now func() time.Time now func() time.Time
progressMu sync.Mutex progressMu sync.Mutex
progress Progress progress Progress
} }
// NewService builds a Service scoped to userID. now defaults to time.Now if // NewSync builds a Sync scoped to userID. now defaults to time.Now if
// nil (tests can override it for deterministic date windows). // nil (tests can override it for deterministic date windows).
func NewService(g garmin.Client, db *store.DB, userID int64, cfg Config, now func() time.Time) *Service { func NewSync(g Client, db *store.DB, userID int64, cfg SyncConfig, now func() time.Time) *Sync {
if now == nil { if now == nil {
now = time.Now now = time.Now
} }
return &Service{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now} return &Sync{garmin: g, db: db, userID: userID, cfg: cfg.withDefaults(), now: now, progress: Progress{Phase: PhaseIdle}}
} }
// Progress returns the current detail-fill progress (0/0 when idle). // Progress returns the current sync progress (phase idle, 0/0 when nothing
func (s *Service) Progress() Progress { // is running).
func (s *Sync) Progress() Progress {
s.progressMu.Lock() s.progressMu.Lock()
defer s.progressMu.Unlock() defer s.progressMu.Unlock()
return s.progress return s.progress
} }
func (s *Service) setProgress(done, total int) { func (s *Sync) setProgress(phase string, done, total int) {
s.progressMu.Lock() s.progressMu.Lock()
s.progress = Progress{Done: done, Total: total} s.progress = Progress{Phase: phase, Done: done, Total: total}
s.progressMu.Unlock() s.progressMu.Unlock()
} }
// Backfill pages backward in Config.BackfillWindowDays windows until // backfillCore pages backward in Config.BackfillWindowDays windows until
// Profile.BackfillHorizonDays is reached or Garmin returns an empty page. // Profile.BackfillHorizonDays is reached or Garmin returns an empty page.
// The horizon is read fresh from the profile on every call (not fixed at // The horizon is read fresh from the profile on every call (not fixed at
// server startup), so a user-edited value takes effect on the very next // server startup), so a user-edited value takes effect on the very next
@@ -106,28 +121,11 @@ func (s *Service) setProgress(done, total int) {
// completed backfill, or is a fast no-op if the configured horizon is // completed backfill, or is a fast no-op if the configured horizon is
// already fully covered -- it does not re-walk years of already-known // already fully covered -- it does not re-walk years of already-known
// history. Widening the horizon between calls resumes further back instead // history. Widening the horizon between calls resumes further back instead
// of re-fetching everything. // of re-fetching everything. Used by FullSync as one step of its single
func (s *Service) Backfill(ctx context.Context) error { // combined SyncRun; there is no standalone entrypoint for this anymore
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindBackfill) // (the periodic background sync loop that used to call one is gone -- see
if err != nil { // 4d2cbe4 refactor: remove automatic background incremental sync).
return err func (s *Sync) backfillCore(ctx context.Context) (int, error) {
}
total, err := s.backfillCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, s.userID, runID, total, &msg)
return err
}
return s.db.FinishSyncRun(ctx, s.userID, runID, total, nil)
}
// backfillCore holds Backfill's actual fetch logic, without the SyncRun
// bookkeeping, so FullSync can run it as one step of a single combined run
// instead of its own separately-recorded one. The returned count reflects
// whatever was fetched even when an error is also returned, matching
// Backfill's own partial-progress-on-error behavior.
func (s *Service) backfillCore(ctx context.Context) (int, error) {
profile, err := s.db.GetProfile(ctx, s.userID) profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil { if err != nil {
return 0, fmt.Errorf("load profile: %w", err) return 0, fmt.Errorf("load profile: %w", err)
@@ -191,26 +189,11 @@ func (s *Service) backfillCore(ctx context.Context) (int, error) {
return total, nil return total, nil
} }
// IncrementalSync fetches activities from just before the latest known // incrementalSyncCore fetches activities from just before the latest known
// activity (or a short recent window if none exist yet) through today. // activity (or a short recent window if none exist yet) through today. Used
func (s *Service) IncrementalSync(ctx context.Context) error { // by FullSync as one step of its single combined SyncRun -- see
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindIncremental) // backfillCore's comment for why there's no standalone entrypoint.
if err != nil { func (s *Sync) incrementalSyncCore(ctx context.Context) (int, error) {
return err
}
n, err := s.incrementalSyncCore(ctx)
if err != nil {
msg := err.Error()
s.db.FinishSyncRun(ctx, s.userID, runID, n, &msg)
return err
}
return s.db.FinishSyncRun(ctx, s.userID, runID, n, nil)
}
// incrementalSyncCore holds IncrementalSync's actual fetch logic, without
// the SyncRun bookkeeping -- see backfillCore.
func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays) start := s.now().AddDate(0, 0, -s.cfg.IncrementalOverlapDays)
if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok { if latest, ok, err := s.db.LatestActivityStartTime(ctx, s.userID); err == nil && ok {
if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil { if t, err := time.Parse("2006-01-02 15:04:05", latest); err == nil {
@@ -221,20 +204,21 @@ func (s *Service) incrementalSyncCore(ctx context.Context) (int, error) {
return newCount, err return newCount, err
} }
// FullSync performs a complete manual "Sync now" pass -- Backfill (resumes // FullSync performs a complete manual "Sync now" pass -- backfillCore
// from the watermark), then IncrementalSync (catches anything new since the // (resumes from the watermark), then incrementalSyncCore (catches anything
// latest known activity), then FillPendingDetails -- recorded as a single // new since the latest known activity), then FillPendingDetails --
// SyncRun. Backfill and IncrementalSync each record their own SyncRun when // recorded as a single SyncRun so the reported activity count covers the
// called on their own (used by the periodic background loop), but a manual // whole action instead of only whichever stage happened to finish last.
// sync runs both back to back, and FillPendingDetails records no run at all; func (s *Sync) FullSync(ctx context.Context, detailFillLimit int) error {
// showing the user only the most recently *recorded* run (IncrementalSync's)
// would silently hide however many activities Backfill fetched. Recording
// one combined run makes the reported count match the whole action.
func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull) runID, err := s.db.StartSyncRun(ctx, s.userID, store.SyncKindFull)
if err != nil { if err != nil {
return err return err
} }
// Reset to idle on every return path, including an early error return
// from backfillCore/incrementalSyncCore before FillPendingDetails (which
// otherwise owns its own idle-reset) ever runs.
s.setProgress(PhaseDiscovering, 0, 0)
defer s.setProgress(PhaseIdle, 0, 0)
backfillCount, err := s.backfillCore(ctx) backfillCount, err := s.backfillCore(ctx)
if err != nil { if err != nil {
@@ -264,7 +248,7 @@ func (s *Service) FullSync(ctx context.Context, detailFillLimit int) error {
// assignments) and rewinds the backfill watermark, so the next Backfill // assignments) and rewinds the backfill watermark, so the next Backfill
// call performs a genuinely fresh pull from Garmin instead of resuming from // call performs a genuinely fresh pull from Garmin instead of resuming from
// wherever the previous one left off. Workout kinds are left untouched. // wherever the previous one left off. Workout kinds are left untouched.
func (s *Service) ResetAll(ctx context.Context) error { func (s *Sync) ResetAll(ctx context.Context) error {
return s.db.ResetAllSyncedData(ctx, s.userID) return s.db.ResetAllSyncedData(ctx, s.userID)
} }
@@ -280,7 +264,7 @@ func (s *Service) ResetAll(ctx context.Context) error {
// produced a confusing, meaningless number (e.g. "2 activities" on a sync // produced a confusing, meaningless number (e.g. "2 activities" on a sync
// that found nothing new, just because 2 already-known activities happened // that found nothing new, just because 2 already-known activities happened
// to fall inside the queried window). // to fall inside the queried window).
func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) { func (s *Sync) fetchAndStoreWindow(ctx context.Context, startDate, endDate string) (rawCount, newCount int, err error) {
activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500) activities, err := s.garmin.GetActivities(ctx, startDate, endDate, 500)
if err != nil { if err != nil {
return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err) return 0, 0, fmt.Errorf("get_activities(%s, %s): %w", startDate, endDate, err)
@@ -306,11 +290,22 @@ func (s *Service) fetchAndStoreWindow(ctx context.Context, startDate, endDate st
return len(activities), newCount, nil return len(activities), newCount, nil
} }
// FillPendingDetails fetches get_activity_splits/get_activity_details for up // FillPendingDetails fetches activity details/splits for up to limit
// to limit activities that don't have them yet, then (re)classifies each one. // activities missing them, then fetches workouts for up to limit activities
// Calls are made sequentially with Config.InterCallDelay between them to // missing those (independently -- see ActivitiesMissingWorkout), then
// (re)classifies every activity touched by the first pass. Each pass makes
// its Garmin calls sequentially with Config.InterCallDelay between them to
// avoid Garmin/Cloudflare rate limiting. // avoid Garmin/Cloudflare rate limiting.
func (s *Service) FillPendingDetails(ctx context.Context, limit int) error { func (s *Sync) FillPendingDetails(ctx context.Context, limit int) error {
defer s.setProgress(PhaseIdle, 0, 0)
if err := s.fillPendingActivityDetails(ctx, limit); err != nil {
return err
}
return s.fillPendingWorkouts(ctx, limit)
}
func (s *Sync) fillPendingActivityDetails(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit) pending, err := s.db.ActivitiesMissingDetails(ctx, s.userID, limit)
if err != nil { if err != nil {
return err return err
@@ -320,8 +315,7 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
return fmt.Errorf("load profile: %w", err) return fmt.Errorf("load profile: %w", err)
} }
s.setProgress(0, len(pending)) s.setProgress(PhaseActivities, 0, len(pending))
defer s.setProgress(0, 0)
for i, a := range pending { for i, a := range pending {
if i > 0 { if i > 0 {
@@ -337,12 +331,57 @@ func (s *Service) FillPendingDetails(ctx context.Context, limit int) error {
if err := s.ClassifyActivity(ctx, a.ID); err != nil { if err := s.ClassifyActivity(ctx, a.ID); err != nil {
return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err) return fmt.Errorf("classify activity %d: %w", a.GarminActivityID, err)
} }
s.setProgress(i+1, len(pending)) s.setProgress(PhaseActivities, i+1, len(pending))
} }
return nil return nil
} }
func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error { // fillPendingWorkouts fetches get_workout_by_id for up to limit activities
// missing it. Unlike fillPendingActivityDetails, one activity's workout
// fetch failing is non-fatal (logged, loop continues) -- workout target
// bands are enrichment, not core activity data, and workout_raw_json
// staying NULL means ActivitiesMissingWorkout will naturally retry it on
// the next sync.
func (s *Sync) fillPendingWorkouts(ctx context.Context, limit int) error {
pending, err := s.db.ActivitiesMissingWorkout(ctx, s.userID, limit)
if err != nil {
return err
}
profile, err := s.db.GetProfile(ctx, s.userID)
if err != nil {
return fmt.Errorf("load profile: %w", err)
}
s.setProgress(PhaseWorkouts, 0, len(pending))
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
if errors.Is(err, ErrNotFound) {
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever.
applog.App().Warn("workout not found on Garmin, marking as such (will not retry)", "garmin_activity_id", a.GarminActivityID, "error", err)
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
}
} else {
applog.App().Warn("fill workout failed, will retry next sync", "garmin_activity_id", a.GarminActivityID, "error", err)
}
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
func (s *Sync) fillActivityDetails(ctx context.Context, a store.Activity, profile store.Profile) error {
splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID) splits, err := s.garmin.GetActivitySplits(ctx, a.GarminActivityID)
if err != nil { if err != nil {
return fmt.Errorf("get_activity_splits: %w", err) return fmt.Errorf("get_activity_splits: %w", err)
@@ -352,23 +391,14 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
return fmt.Errorf("get_activity_details: %w", err) return fmt.Errorf("get_activity_details: %w", err)
} }
targets := make([]*garmin.WorkoutStep, len(splits.Laps)) samples := ExtractSamples(details)
if a.WorkoutID != nil {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
log.Printf("sync: get_workout_by_id(%d) for activity %d failed, continuing without target zones: %v", *a.WorkoutID, a.GarminActivityID, err)
} else {
targets = alignWorkoutTargets(splits.Laps, workout)
if err := s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw)); err != nil {
return err
}
}
}
samples := garmin.ExtractSamples(details)
if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil { if err := s.db.ReplaceActivitySamples(ctx, s.userID, a.ID, toSampleRows(samples)); err != nil {
return err return err
} }
// No workout-target alignment here -- that's fillActivityWorkout's job,
// run as its own later pass (see fillPendingWorkouts). targets is an
// all-nil placeholder the same length as splits.Laps.
targets := make([]*WorkoutStep, len(splits.Laps))
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil { if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, toLapRows(splits.Laps, samples, targets, profile)); err != nil {
return err return err
} }
@@ -378,10 +408,39 @@ func (s *Service) fillActivityDetails(ctx context.Context, a store.Activity, pro
return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID) return s.db.SetActivitySplitsFetched(ctx, s.userID, a.ID)
} }
// fillActivityWorkout fetches a's structured workout and re-derives its
// laps' target pace/HR bands from it. Requires a.WorkoutID to be set --
// only ever called for activities ActivitiesMissingWorkout returned, which
// already filters on that. Reads laps back from the DB (already written by
// fillActivityDetails, in some earlier pass or run) rather than needing the
// original garmin.Lap data again, since alignWorkoutTargets only needs a
// count.
func (s *Sync) fillActivityWorkout(ctx context.Context, a store.Activity, profile store.Profile) error {
workout, err := s.garmin.GetWorkoutByID(ctx, *a.WorkoutID)
if err != nil {
return fmt.Errorf("get_workout_by_id: %w", err)
}
laps, err := s.db.LapsForActivity(ctx, s.userID, a.ID)
if err != nil {
return err
}
targets := alignWorkoutTargets(len(laps), workout)
for i := range laps {
if i < len(targets) && targets[i] != nil {
laps[i].TargetPaceLowMps, laps[i].TargetPaceHighMps = targetPaceRange(*targets[i])
laps[i].TargetHRLowBpm, laps[i].TargetHRHighBpm = targetHRRange(*targets[i], profile)
}
}
if err := s.db.ReplaceLaps(ctx, s.userID, a.ID, laps); err != nil {
return err
}
return s.db.SetActivityWorkout(ctx, s.userID, a.ID, string(workout.Raw))
}
// ClassifyActivity (re)runs the rule engine for one activity against the // ClassifyActivity (re)runs the rule engine for one activity against the
// currently active workout kinds and appends a new kind_assignments row. // currently active workout kinds and appends a new kind_assignments row.
// Safe to call repeatedly (e.g. after editing a workout kind's rule). // Safe to call repeatedly (e.g. after editing a workout kind's rule).
func (s *Service) ClassifyActivity(ctx context.Context, activityID int64) error { func (s *Sync) ClassifyActivity(ctx context.Context, activityID int64) error {
activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID) activity, ok, err := s.db.GetActivity(ctx, s.userID, activityID)
if err != nil { if err != nil {
return err return err

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,6 @@
import json
import os import os
import time
import queue import queue
import threading import threading
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@@ -12,15 +14,18 @@ import wrapper
def reset_state(): def reset_state():
original_state = wrapper._auth_state original_state = wrapper._auth_state
original_client = wrapper._client original_client = wrapper._client
original_startup_attempted = wrapper._startup_login_attempted
for q in (wrapper._mfa_input_queue, wrapper._login_result_queue): for q in (wrapper._mfa_input_queue, wrapper._login_result_queue):
while not q.empty(): while not q.empty():
try: try:
q.get_nowait() q.get_nowait()
except queue.Empty: except queue.Empty:
break break
wrapper._startup_login_attempted = False
yield yield
wrapper._auth_state = original_state wrapper._auth_state = original_state
wrapper._client = original_client wrapper._client = original_client
wrapper._startup_login_attempted = original_startup_attempted
def test_authenticate_success(): def test_authenticate_success():
@@ -93,7 +98,15 @@ def test_call_dispatches_to_named_garminconnect_method():
def test_call_unauthenticated_is_error(): def test_call_unauthenticated_is_error():
# Explicitly absent GARMIN_EMAIL/PASSWORD: this now indirectly triggers
# the lazy _startup_login fallback (see _handle_call), which must
# return immediately with nothing to resume, leaving this the same
# "Not authenticated" error as before -- not dependent on whatever
# happens to be in the ambient shell environment.
wrapper._auth_state = "unauthenticated" wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("GARMIN_EMAIL", None)
os.environ.pop("GARMIN_PASSWORD", None)
resp = wrapper.dispatch({ resp = wrapper.dispatch({
"id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}}, "id": 7, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
}) })
@@ -118,7 +131,13 @@ def test_call_propagates_garminconnect_exception_as_error():
resp = wrapper.dispatch({ resp = wrapper.dispatch({
"id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}}, "id": 9, "cmd": "call", "params": {"method": "get_activity_splits", "args": {"activity_id": "999"}},
}) })
assert resp == {"id": 9, "error": "not found"} assert resp["id"] == 9
assert resp["error"] == "not found"
# Failure detail travels in the protocol so the Go side can log one
# complete structured record (no stderr correlation needed).
assert resp["error_type"] == "Exception"
assert "Exception: not found" in resp["traceback"]
assert "not_found" not in resp
def test_dispatch_unknown_cmd_is_error(): def test_dispatch_unknown_cmd_is_error():
@@ -206,3 +225,142 @@ def test_startup_login_does_not_leak_into_shared_authenticate_queue():
assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}} assert resp == {"id": 20, "result": {"status": "success", "message": "Authenticated successfully."}}
assert wrapper._auth_state == "authenticated" assert wrapper._auth_state == "authenticated"
def test_call_triggers_startup_login_lazily_on_first_call():
"""A data 'call' with no prior explicit authenticate on this subprocess
-- e.g. "Sync now" reaching an already-connected user's client right
after a backend restart -- gets exactly one lazy attempt to silently
resume a cached tokenstore session before giving up."""
wrapper._auth_state = "unauthenticated"
wrapper._client = MagicMock()
wrapper._client.get_activities_by_date.return_value = []
def fake_startup_login():
wrapper._auth_state = "authenticated"
with patch("wrapper._startup_login", side_effect=fake_startup_login) as mock_startup:
resp = wrapper.dispatch({
"id": 30, "cmd": "call", "params": {"method": "get_activities_by_date", "args": {}},
})
mock_startup.assert_called_once()
assert resp == {"id": 30, "result": []}
def test_call_does_not_retry_startup_login_after_first_attempt():
"""If the lazy startup-login attempt doesn't actually authenticate, a
second 'call' must not try it again -- one attempt per subprocess
lifetime, not once per call."""
wrapper._auth_state = "unauthenticated"
with patch("wrapper._startup_login") as mock_startup: # no-op: leaves state unauthenticated
wrapper.dispatch({"id": 31, "cmd": "call", "params": {"method": "x", "args": {}}})
wrapper.dispatch({"id": 32, "cmd": "call", "params": {"method": "x", "args": {}}})
mock_startup.assert_called_once()
def test_authenticate_prevents_later_lazy_startup_login():
"""Once authenticate has been explicitly called (regardless of
outcome), a later 'call' must never fall back to _startup_login -- that
would either duplicate a login authenticate already did, or waste an
extra hit against Garmin after a failure."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
with patch.dict("os.environ", env):
with patch("wrapper.Garmin") as mock_garmin_cls:
mock_garmin_cls.return_value = MagicMock()
wrapper.dispatch({"id": 33, "cmd": "authenticate"})
wrapper._auth_state = "unauthenticated" # simulate a later, separate call finding no session
with patch("wrapper._startup_login") as mock_startup:
wrapper.dispatch({"id": 34, "cmd": "call", "params": {"method": "x", "args": {}}})
mock_startup.assert_not_called()
def test_call_marks_not_found_error_specifically():
from garminconnect import GarminConnectNotFoundError
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
resp = wrapper.dispatch({
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp["id"] == 11
assert resp["error"] == "API Error 404"
assert resp["not_found"] is True
assert resp["error_type"] == "GarminConnectNotFoundError"
def test_call_does_not_mark_other_errors_as_not_found():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
resp = wrapper.dispatch({
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp["id"] == 12
assert resp["error"] == "rate limited"
assert "not_found" not in resp
def test_startup_login_late_success_flips_auth_state_for_later_calls():
"""A tokenstore resume that outlives the 10s wait must still recover
the subprocess: the background thread itself flips _auth_state on
success, so the next `call` command passes the auth check without an
explicit authenticate."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
# Simulate the 10s timeout: the reader gives up immediately,
# while the (still-blocked) login thread keeps running.
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
assert wrapper._auth_state == "unauthenticated"
release.set() # the slow login now completes, after the timeout
for _ in range(100):
if wrapper._auth_state == "authenticated":
break
time.sleep(0.01)
assert wrapper._auth_state == "authenticated"
def test_startup_login_late_success_never_overrides_explicit_auth_flow():
"""If an explicit authenticate/MFA flow moved the state while the
startup resume was still in flight, the late success must not clobber
it -- the explicit flow owns the state once it has moved it."""
env = {"GARMIN_EMAIL": "test@example.com", "GARMIN_PASSWORD": "secret"}
release = threading.Event()
mock_client = MagicMock()
mock_client.login.side_effect = lambda **kwargs: release.wait(timeout=5)
wrapper._auth_state = "unauthenticated"
with patch.dict("os.environ", env):
with patch("wrapper.Garmin", return_value=mock_client):
with patch("wrapper.queue.Queue") as mock_queue_cls:
mock_queue_cls.return_value.get.side_effect = queue.Empty
wrapper._startup_login()
wrapper._auth_state = "mfa_pending" # an explicit flow took over meanwhile
release.set()
time.sleep(0.2) # give the thread ample time to (wrongly) flip it
assert wrapper._auth_state == "mfa_pending"
def test_log_stamps_emitting_method(capsys):
"""Every wrapper log line carries the emitting function as "method" --
the Go side forwards it into the unified log schema."""
def some_emitter():
wrapper._log("info", "hello", extra=1)
some_emitter()
entry = json.loads(capsys.readouterr().err.strip())
assert entry == {"level": "info", "msg": "hello", "method": "some_emitter", "extra": 1}

View File

@@ -0,0 +1,312 @@
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited
JSON on stdin/stdout by geniusrund's internal/garmin package. See
docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md."""
import json
import os
import queue
import sys
import threading
import traceback
from garminconnect import Garmin, GarminConnectNotFoundError
TOKENSTORE = os.path.expanduser(os.environ.get("GARMIN_TOKENSTORE", "~/.garmin"))
_client = None
_auth_state = "unauthenticated"
_mfa_input_queue = queue.Queue()
_login_result_queue = queue.Queue()
# Set the first time this subprocess attempts any login, whichever path
# gets there first (see _handle_call's lazy call and _handle_authenticate) --
# guarantees _startup_login runs at most once per subprocess lifetime, and
# never at all once an explicit authenticate has been attempted.
_startup_login_attempted = False
def _log(level, msg, **attrs):
"""Emit one JSON log line on stderr. internal/garmin/client.go reads
stderr line by line and re-emits these through the Go application
logger, so the backend's combined output stays a single JSON stream --
never print free text to stderr or stdout from this process (stdout is
reserved for the request/response protocol). The emitting function is
stamped as "method" automatically; the Go side adds the rest of the
log schema (type=wrapper, file=wrapper.py)."""
entry = {"level": level, "msg": msg, "method": sys._getframe(1).f_code.co_name}
entry.update(attrs)
print(json.dumps(entry), file=sys.stderr, flush=True)
def _prompt_mfa():
_log("debug", "invoked")
code = _mfa_input_queue.get(timeout=300)
_log("debug", "returning MFA code", code_length=len(code))
return code
def _startup_login():
"""Silently resume a cached tokenstore session, so a freshly
(re)spawned subprocess can already be authenticated for a data 'call'
that never goes through the explicit authenticate command -- e.g.
"Sync now" reaching an already-connected user's client right after a
backend restart cleared the in-memory cache.
Called lazily (see _handle_call), at most once per subprocess lifetime,
and only if nothing has explicitly called authenticate first. Running
it unconditionally at process start used to be actively counterproductive
whenever the very first command actually was authenticate: on success it
just duplicated a login _handle_authenticate was about to redo anyway
(it always rebuilds _client from scratch), and on failure it was a
wasted, unauthenticated hit against Garmin's servers moments before the
real attempt -- extra load that only makes rate-limiting worse.
Bounded to the same 10s timeout as _handle_authenticate: the actual
login runs on a background daemon thread, and this function waits up
to 10s for it before returning either way, so a slow/rate-limited
Garmin login can never block the caller indefinitely. On timeout the
triggering call fails ("Not authenticated"), but the thread keeps
running and, if the login eventually succeeds, flips _auth_state to
"authenticated" itself -- only ever from "unauthenticated", never
overriding an explicit authenticate/MFA flow that ran in the
meantime -- so subsequent calls recover without user action. A late
failure changes nothing (the state is already "unauthenticated").
Deliberately uses its own private, function-local result queue rather
than the module-level _login_result_queue that _handle_authenticate and
_handle_complete_mfa share -- those two are legitimately two halves of
one explicit, MFA-capable login flow and must share a queue for the MFA
handoff to work, but this is a plain background tokenstore resume with
no MFA involved. Sharing the queue here would let this thread's stale
result (arriving after the 10s timeout below) be dequeued by an
unrelated, later authenticate/complete_mfa call instead of that call's
own fresh result."""
global _client, _auth_state
email = os.environ.get("GARMIN_EMAIL")
password = os.environ.get("GARMIN_PASSWORD")
if not email or not password:
return
_client = Garmin(email, password)
result_queue = queue.Queue() # private to this call, never shared with authenticate/complete_mfa
def _do_startup_login():
global _auth_state
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try:
_client.login(tokenstore=TOKENSTORE)
_log("debug", "_client.login() returned successfully")
result_queue.put(("success", None))
# A success arriving after the 10s timeout below would land in
# an abandoned queue -- flip the state here too, so later calls
# benefit from the resume. Only from "unauthenticated": a
# concurrent explicit authenticate/MFA flow owns the state once
# it has moved it anywhere else.
if _auth_state == "unauthenticated":
_auth_state = "authenticated"
except Exception as exc:
_log(
"error",
"_client.login() failed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
result_queue.put(("error", str(exc)))
threading.Thread(target=_do_startup_login, daemon=True).start()
try:
status, err = result_queue.get(timeout=10)
_log("debug", "got result within 10s timeout", status=status)
if status == "success":
_auth_state = "authenticated"
else:
_log("error", "login failed", error=err)
_auth_state = "unauthenticated"
except queue.Empty:
# No assignment here: the state is already "unauthenticated", and
# writing it again could clobber a success the background thread
# records right around the timeout boundary (see _do_startup_login).
_log(
"warn",
"hit the 10s timeout but still running in the "
"background and will update auth state if it eventually succeeds",
)
def _handle_authenticate(_params):
global _client, _auth_state, _startup_login_attempted
# An explicit authenticate is happening (successful or not) -- the lazy
# startup-login fallback in _handle_call must never fire after this, it
# would be redundant at best and a wasted extra hit against Garmin at
# worst.
_startup_login_attempted = True
email = os.environ.get("GARMIN_EMAIL", "")
password = os.environ.get("GARMIN_PASSWORD", "")
if not email or not password:
return {
"status": "failed",
"message": "GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required.",
}
def _do_authenticate_login():
_log("debug", "background thread starting _client.login()", tokenstore=TOKENSTORE)
try:
_client.login(tokenstore=TOKENSTORE)
_log("debug", "_client.login() returned successfully")
_login_result_queue.put(("success", None))
except Exception as exc:
_log(
"error",
"_client.login() failed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
_login_result_queue.put(("error", str(exc)))
_client = Garmin(email, password)
_client.prompt_mfa = _prompt_mfa
threading.Thread(target=_do_authenticate_login, daemon=True).start()
try:
status, err = _login_result_queue.get(timeout=10)
_log("debug", "got result within 10s timeout", status=status)
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "Authenticated successfully."}
else:
_log("error", "login failed", error=err)
return {"status": "failed", "message": f"Authentication failed: {err}"}
except queue.Empty:
_log(
"warn",
"hit the 10s timeout with no result yet, reporting mfa_required",
)
_auth_state = "mfa_pending"
return {
"status": "mfa_required",
"message": "MFA required. Garmin has sent a verification code to your registered email or phone.",
}
def _handle_complete_mfa(params):
global _auth_state
if _auth_state != "mfa_pending":
return {"status": "failed", "message": "No MFA in progress. Call authenticate first."}
code = params["code"]
_log("debug", "received a code, pushing to mfa queue", code_length=len(code))
_mfa_input_queue.put(code)
try:
status, err = _login_result_queue.get(timeout=30)
_log("debug", "got result", status=status, error=err)
if status == "success":
_auth_state = "authenticated"
return {"status": "success", "message": "MFA accepted. Authenticated successfully."}
return {"status": "failed", "message": f"Authentication failed after MFA: {err}"}
except queue.Empty:
_log(
"error",
"hit the 30s timeout with no result yet, reporting unauthenticated",
)
_auth_state = "unauthenticated"
return {
"status": "failed",
"message": "Timed out waiting for authentication to complete.",
}
def _handle_call(params):
global _startup_login_attempted
if _auth_state != "authenticated" and not _startup_login_attempted:
_startup_login_attempted = True
_startup_login()
if _auth_state != "authenticated":
raise RuntimeError("Not authenticated. Call authenticate first.")
method = params["method"]
args = params.get("args") or {}
fn = getattr(_client, method)
return fn(**args)
_HANDLERS = {
"authenticate": _handle_authenticate,
"complete_mfa": _handle_complete_mfa,
"call": _handle_call,
}
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
# The full failure detail travels IN the response -- error text,
# exception class, and traceback -- so the Go side can log one
# complete structured record instead of correlating stderr noise.
resp = {
"id": req.get("id"),
"error": str(exc),
"error_type": type(exc).__name__,
"traceback": traceback.format_exc(),
}
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
# side after being linked to an activity) is definitive, not a
# transient failure worth retrying forever -- marked specifically so
# internal/garmin/client.go can tell the two apart (see
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
if isinstance(exc, GarminConnectNotFoundError):
resp["not_found"] = True
return resp
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError as exc:
# A malformed request has no id to answer to -- log and keep
# serving rather than crashing the subprocess.
_log("error", "malformed request line", error=str(exc))
continue
resp = dispatch(req)
try:
print(json.dumps(resp), flush=True)
except (TypeError, ValueError) as exc:
# A non-JSON-serializable handler result must still produce a
# protocol response, or the Go side would block on a reply.
print(
json.dumps(
{
"id": req.get("id"),
"error": f"unserializable result: {exc}",
"error_type": type(exc).__name__,
}
),
flush=True,
)
if __name__ == "__main__":
try:
main()
except Exception as exc: # last resort: die loudly, but still in JSON
_log(
"error",
"wrapper crashed",
error=str(exc),
error_type=type(exc).__name__,
traceback=traceback.format_exc(),
)
raise SystemExit(1)

View File

@@ -0,0 +1,87 @@
// Package applog provides geniusrun's structured JSON logging: a
// log/slog-based logger writing to stdout, plus the App helper that tags
// records with the schema fields every geniusrun log line carries --
// "type" ("app" | "http" | "wrapper"), "package" (Go package; absent on
// Python-emitted lines), "file" (Go/Python source file basename), "class"
// (Go receiver type or Python class, omitted when there is none), and
// "method" (the emitting Go/Python function). "msg" is optional:
// NewLogger's handler drops it when empty.
package applog
import (
"io"
"log/slog"
"path/filepath"
"runtime"
"strings"
)
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
// defaults to info). Empty msg values are omitted from the output --
// records whose meaning is fully carried by type/class/method and attrs
// (e.g. the HTTP request line) don't need one.
func NewLogger(level string, w io.Writer) *slog.Logger {
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: parseLevel(level),
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.MessageKey && a.Value.String() == "" {
return slog.Attr{}
}
return a
},
}))
}
// App returns the default logger tagged with the schema fields for an
// ordinary application record (type=app), deriving the emitting location
// from the caller via the runtime: package, file, class (the receiver
// type, omitted for free functions), method. Derivation instead of
// hand-typed strings means the labels can never drift from the code. The
// http and wrapper emitters (internal/api's logging middleware,
// internal/garmin's execute/forwardWrapperStderr) tag their own type and
// location instead.
func App() *slog.Logger {
pkg, file, class, method := location(1)
logger := slog.Default().With("type", "app", "package", pkg, "file", file)
if class != "" {
logger = logger.With("class", class)
}
return logger.With("method", method)
}
// location resolves the caller (skip frames above this function's caller)
// into the log schema's package/file/class/method fields. A method's
// runtime name looks like "geniusrun/backend/internal/api.(*Server).X";
// a free function's like "geniusrun/backend/internal/api.X"; a closure
// gets its parent's name plus ".funcN", kept verbatim in method.
func location(skip int) (pkg, file, class, method string) {
pc, path, _, ok := runtime.Caller(skip + 1)
if !ok {
return "unknown", "unknown", "", "unknown"
}
file = filepath.Base(path)
full := runtime.FuncForPC(pc).Name()
base := full[strings.LastIndex(full, "/")+1:]
pkg, rest, _ := strings.Cut(base, ".")
if strings.HasPrefix(rest, "(*") {
if end := strings.Index(rest, ")."); end != -1 {
class = rest[2:end]
rest = rest[end+2:]
}
}
return pkg, file, class, rest
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}

View File

@@ -0,0 +1,78 @@
package applog
import (
"bytes"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("warn", &buf)
logger.Info("should be dropped")
if buf.Len() != 0 {
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
}
logger.Warn("should appear")
if !strings.Contains(buf.String(), "should appear") {
t.Fatalf("expected the Warn message in output, got %q", buf.String())
}
}
func TestNewLogger_WritesValidJSON(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("hello", "key", "value")
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
}
if decoded["msg"] != "hello" || decoded["key"] != "value" {
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
}
}
func TestNewLogger_OmitsEmptyMsg(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("", "type", "http", "status", 200)
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v (%q)", err, buf.String())
}
if _, present := decoded["msg"]; present {
t.Errorf("empty msg should be omitted, got %+v", decoded)
}
if decoded["type"] != "http" {
t.Errorf("attrs lost alongside dropped msg: %+v", decoded)
}
}
func TestApp_TagsMandatoryFields(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(NewLogger("info", &buf))
defer slog.SetDefault(prev)
App().Info("did it", "extra", 1)
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output not JSON: %v", err)
}
if decoded["type"] != "app" || decoded["package"] != "log" || decoded["file"] != "log_test.go" {
t.Errorf("fields = %+v, want type=app package=log (import-path element) file=log_test.go", decoded)
}
if decoded["method"] != "TestApp_TagsMandatoryFields" {
t.Errorf("method = %v, want the emitting function name", decoded["method"])
}
if _, hasClass := decoded["class"]; hasClass {
t.Errorf("class should be omitted for a free function, got %v", decoded["class"])
}
}

View File

@@ -45,6 +45,11 @@ type Activity struct {
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity // internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed. // has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string WorkoutRawJSON *string
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
// HTTP 404 for this activity's WorkoutID -- distinct from
// WorkoutRawJSON staying nil for "not yet fetched" (see
// SetActivityWorkoutNotFound).
WorkoutNotFoundAt *string
CreatedAt string CreatedAt string
UpdatedAt string UpdatedAt string
} }
@@ -102,7 +107,7 @@ func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
&a.AvgSpeedMps, &a.ElevationGainM, &a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue, &a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON, &a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt, &a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
) )
return a, err return a, err
} }
@@ -113,7 +118,7 @@ const activityColumns = `
avg_speed_mps, elevation_gain_m, avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value, aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json, raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at workout_not_found_at, created_at, updated_at
` `
// GetActivity fetches one activity by its internal id, scoped to userID. // GetActivity fetches one activity by its internal id, scoped to userID.
@@ -225,6 +230,21 @@ func (db *DB) SetActivityWorkout(ctx context.Context, userID, activityID int64,
return nil return nil
} }
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
// deleted on Garmin's side after being linked to this activity.
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND user_id = ?`, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
}
return nil
}
// SetActivitySplitsFetched records that get_activity_splits has been fetched // SetActivitySplitsFetched records that get_activity_splits has been fetched
// for this activity. // for this activity.
func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error { func (db *DB) SetActivitySplitsFetched(ctx context.Context, userID, activityID int64) error {
@@ -272,3 +292,58 @@ func (db *DB) CountActivitiesMissingDetails(ctx context.Context, userID int64) (
} }
return n, nil return n, nil
} }
// ActivitiesMissingWorkout returns userID's activities that have a
// structured workout (WorkoutID set at initial upsert time, straight from
// Garmin's activity summary) but haven't had get_workout_by_id fetched yet.
// Gated on details_fetched_at IS NOT NULL: fillActivityWorkout resolves each
// lap's target pace/HR band via alignWorkoutTargets, which needs the
// activity's laps already written by fillActivityDetails -- without this
// gate, a structured-workout activity whose details/laps haven't been
// fetched yet would fall into a separate, independently LIMIT-bounded batch
// than fillPendingDetails's, get its workout_raw_json set against zero laps
// (a silent no-op alignment), and then never be retried once its laps
// finally arrive, since workout_raw_json IS NULL is the only signal this
// query has left. This is still independent of splits_fetched_at (unlike
// ActivitiesMissingDetails, which requires both): splits/laps aren't needed
// to resolve workout targets, only details_fetched_at is. It also still
// covers the intended retry case -- an activity whose workout fetch
// previously failed after details succeeded always has details_fetched_at
// already set, so it's still surfaced here -- while excluding activities
// that simply haven't been processed by fillActivityDetails at all yet.
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, fmt.Errorf("list activities missing workout for user %d: %w", userID, err)
}
defer rows.Close()
activities := []Activity{}
for rows.Next() {
a, err := scanActivity(rows)
if err != nil {
return nil, fmt.Errorf("scan activity row: %w", err)
}
activities = append(activities, a)
}
return activities, rows.Err()
}
// CountActivitiesMissingWorkout returns how many of userID's activities
// still need get_workout_by_id fetched, regardless of any per-call batch
// limit -- used to report overall remaining work, mirroring
// CountActivitiesMissingDetails. See ActivitiesMissingWorkout for why this
// is additionally gated on details_fetched_at IS NOT NULL.
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count activities missing workout for user %d: %w", userID, err)
}
return n, nil
}

View File

@@ -76,31 +76,6 @@ func (db *DB) CurrentAssignment(ctx context.Context, userID, activityID int64) (
return a, true, nil return a, true, nil
} }
// ReviewQueue returns userID's activities whose current assignment status
// is needs_review, newest first.
func (db *DB) ReviewQueue(ctx context.Context, userID int64) ([]KindAssignment, error) {
rows, err := db.QueryContext(ctx, `
SELECT `+kindAssignmentColumns+`
FROM current_kind_assignment a
JOIN activities ON activities.id = a.activity_id
WHERE activities.user_id = ? AND a.status = ?
ORDER BY a.created_at DESC`, userID, AssignmentStatusNeedsReview)
if err != nil {
return nil, fmt.Errorf("review queue for user %d: %w", userID, err)
}
defer rows.Close()
assignments := []KindAssignment{}
for rows.Next() {
a, err := scanKindAssignment(rows)
if err != nil {
return nil, fmt.Errorf("scan kind assignment row: %w", err)
}
assignments = append(assignments, a)
}
return assignments, rows.Err()
}
// AllCurrentAssignments returns the latest assignment for every one of // AllCurrentAssignments returns the latest assignment for every one of
// userID's activities that has one, regardless of status or source -- the // userID's activities that has one, regardless of status or source -- the
// basis for deciding which activities a global reclassify pass may touch. // basis for deciding which activities a global reclassify pass may touch.

View File

@@ -0,0 +1,40 @@
package store
import (
"context"
"fmt"
)
// ConfigValues returns every application-configuration row as key ->
// value. The table holds a row for every registry key: main seeds missing
// keys with their defaults at startup, so downstream code never needs a
// code-side fallback.
func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) {
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
if err != nil {
return nil, fmt.Errorf("config values: %w", err)
}
defer rows.Close()
values := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, fmt.Errorf("scan config row: %w", err)
}
values[k] = v
}
return values, rows.Err()
}
// SetConfigValue upserts one override row. Key validation is the caller's
// job (config.ValidateAppValue) -- the store stays a dumb K/V layer.
func (db *DB) SetConfigValue(ctx context.Context, key, value string) error {
if _, err := db.ExecContext(ctx, `
INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
key, value); err != nil {
return fmt.Errorf("set config %q: %w", key, err)
}
return nil
}

View File

@@ -0,0 +1,35 @@
package store
import (
"context"
"testing"
)
func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
values, err := db.ConfigValues(ctx)
if err != nil {
t.Fatalf("ConfigValues (empty): %v", err)
}
if len(values) != 0 {
t.Fatalf("expected no overrides in a fresh DB, got %v", values)
}
if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil {
t.Fatalf("SetConfigValue: %v", err)
}
// Same key again: upsert must overwrite, not error or duplicate.
if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil {
t.Fatalf("SetConfigValue (overwrite): %v", err)
}
values, err = db.ConfigValues(ctx)
if err != nil {
t.Fatalf("ConfigValues: %v", err)
}
if len(values) != 1 || values["session.duration"] != "24" {
t.Fatalf("expected {session.duration: 24}, got %v", values)
}
}

View File

@@ -137,7 +137,7 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB) t.Fatalf("userA's UpdateSyncState leaked into userB's sync_state: %+v", stateB)
} }
runID, err := db.StartSyncRun(ctx, userA, SyncKindBackfill) runID, err := db.StartSyncRun(ctx, userA, SyncKindFull)
if err != nil { if err != nil {
t.Fatalf("StartSyncRun(a): %v", err) t.Fatalf("StartSyncRun(a): %v", err)
} }
@@ -161,3 +161,213 @@ func TestIsolation_SyncStateAndRunsNeverLeakAcrossUsers(t *testing.T) {
t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run") t.Fatal("userB's FinishSyncRun call was able to mutate userA's sync run")
} }
} }
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
if _, err := db.GetProfile(ctx, userB); err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
userBRow, found, err := db.GetUserBySub(ctx, "sub-b")
if err != nil || !found || userBRow.Name != "B" {
t.Errorf("userB's account changed after deleting userA: %+v (found=%v err=%v)", userBRow, found, err)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}
// TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers confirms
// ActivitiesMissingWorkout/CountActivitiesMissingWorkout only ever surface
// userID's own pending-workout activities, never another user's, even
// though both users have activities in the exact shape (workout_id set,
// details fetched, workout_raw_json still NULL) the query selects for.
func TestIsolation_ActivitiesMissingWorkoutNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
workoutID := int64(999)
idA, err := db.UpsertActivity(ctx, userA, Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity(a): %v", err)
}
if err := db.SetActivityDetails(ctx, userA, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails(a): %v", err)
}
idB, err := db.UpsertActivity(ctx, userB, Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
})
if err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.SetActivityDetails(ctx, userB, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails(b): %v", err)
}
pendingA, err := db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a): %v", err)
}
if len(pendingA) != 1 || pendingA[0].ID != idA {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want only userA's own activity (id %d)", pendingA, idA)
}
countA, err := db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a): %v", err)
}
if countA != 1 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (userB's identical-looking activity must not be counted)", countA)
}
// Calling SetActivityWorkoutNotFound with the WRONG user's ID against
// userA's activity (idA) must be a no-op: the WHERE id = ? AND user_id = ?
// won't match any row, so it should neither error nor mark idA not-found.
// (This runs before the SetActivityWorkout resolution below, while idA
// is still pending -- once workout_raw_json is set, idA drops out of
// ActivitiesMissingWorkout regardless of workout_not_found_at, which
// would make the "still appears" assertion vacuous.)
if err := db.SetActivityWorkoutNotFound(ctx, userB, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound(b, idA): %v", err)
}
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
}
if len(pendingA) != 1 || pendingA[0].ID != idA {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want idA still pending -- userB's mismatched call must not mark it not-found", pendingA)
}
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a) after userB's mismatched SetActivityWorkoutNotFound call: %v", err)
}
if countA != 1 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 1 (unaffected by userB's mismatched-user SetActivityWorkoutNotFound call)", countA)
}
// Now the CORRECT user marks their own activity not-found: idA must
// disappear from userA's own results, and userB's own idB (still
// pending) must remain wholly unaffected.
if err := db.SetActivityWorkoutNotFound(ctx, userA, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound(a, idA): %v", err)
}
pendingA, err = db.ActivitiesMissingWorkout(ctx, userA, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
}
if len(pendingA) != 0 {
t.Fatalf("ActivitiesMissingWorkout(a) = %+v, want empty after userA marked their own idA not-found", pendingA)
}
countA, err = db.CountActivitiesMissingWorkout(ctx, userA)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(a) after userA marked idA not-found: %v", err)
}
if countA != 0 {
t.Fatalf("CountActivitiesMissingWorkout(a) = %d, want 0 after userA marked their own idA not-found", countA)
}
pendingB, err := db.ActivitiesMissingWorkout(ctx, userB, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
}
if len(pendingB) != 1 || pendingB[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's idB still pending, unaffected by userA's SetActivityWorkoutNotFound call", pendingB)
}
countB, err := db.CountActivitiesMissingWorkout(ctx, userB)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(b) after userA marked idA not-found: %v", err)
}
if countB != 1 {
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkoutNotFound call)", countB)
}
// Resolving userA's workout (now moot for idA specifically, since it was
// already marked not-found above, but still exercises SetActivityWorkout
// itself) must not affect userB's pending activity.
if err := db.SetActivityWorkout(ctx, userA, idA, `{"segments":[]}`); err != nil {
t.Fatalf("SetActivityWorkout(a): %v", err)
}
pendingB, err = db.ActivitiesMissingWorkout(ctx, userB, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout(b): %v", err)
}
if len(pendingB) != 1 || pendingB[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout(b) = %+v, want userB's activity unaffected by userA's SetActivityWorkout", pendingB)
}
countB, err = db.CountActivitiesMissingWorkout(ctx, userB)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout(b): %v", err)
}
if countB != 1 {
t.Fatalf("CountActivitiesMissingWorkout(b) = %d, want 1 (unaffected by userA's SetActivityWorkout call)", countB)
}
}
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
// one user's Garmin connection never sets another user's flag.
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if err := db.MarkGarminConnected(ctx, userA); err != nil {
t.Fatalf("MarkGarminConnected(a): %v", err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b): %v", err)
}
if profileB.GarminConnectedAt != nil {
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
}
}

View File

@@ -55,13 +55,13 @@ func (db *DB) ReplaceLaps(ctx context.Context, userID, activityID int64, laps []
} }
defer tx.Rollback() defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM laps WHERE activity_id = ?`, activityID); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM activity_laps WHERE activity_id = ?`, activityID); err != nil {
return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err) return fmt.Errorf("delete existing laps for activity %d: %w", activityID, err)
} }
for _, l := range laps { for _, l := range laps {
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT INTO laps ( INSERT INTO activity_laps (
activity_id, lap_index, avg_speed_mps, activity_id, lap_index, avg_speed_mps,
intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min, intensity_type, hr_drift_bpm_per_min, hr_recovery_bpm_per_min,
target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json target_pace_low_mps, target_pace_high_mps, target_hr_low_bpm, target_hr_high_bpm, raw_json
@@ -84,7 +84,7 @@ func (db *DB) LapsForActivity(ctx context.Context, userID, activityID int64) ([]
SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps, SELECT laps.id, laps.activity_id, laps.lap_index, laps.avg_speed_mps,
laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min, laps.intensity_type, laps.hr_drift_bpm_per_min, laps.hr_recovery_bpm_per_min,
laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json laps.target_pace_low_mps, laps.target_pace_high_mps, laps.target_hr_low_bpm, laps.target_hr_high_bpm, laps.raw_json
FROM laps FROM activity_laps AS laps
JOIN activities ON activities.id = laps.activity_id JOIN activities ON activities.id = laps.activity_id
WHERE laps.activity_id = ? AND activities.user_id = ? WHERE laps.activity_id = ? AND activities.user_id = ?
ORDER BY laps.lap_index`, activityID, userID) ORDER BY laps.lap_index`, activityID, userID)

View File

@@ -8,11 +8,14 @@ import (
// Profile is the single active user's Garmin credentials plus every // Profile is the single active user's Garmin credentials plus every
// tunable analysis-engine parameter. Always exactly one row (id=1). // tunable analysis-engine parameter. Always exactly one row (id=1).
type Profile struct { type Profile struct {
// Name labels this profile so a future multi-profile setup can show
// which one is active. Only one profile row exists today (id=1).
Name string
GarminEmail string GarminEmail string
GarminPassword string GarminPassword string
// GarminConnectedAt is nil until this user's first successful Garmin
// authentication (see store.MarkGarminConnected) -- the login gate uses
// it, not UpdateProfile, so it's deliberately excluded from
// UpdateProfile's SET clause below and can only ever move from nil to
// set, never reset by a normal profile save.
GarminConnectedAt *string
RollingWindowDays int RollingWindowDays int
// BackfillHorizonDays bounds how far back "Sync now" reaches when // BackfillHorizonDays bounds how far back "Sync now" reaches when
// walking backward from today; it's read fresh on every sync (not fixed // walking backward from today; it's read fresh on every sync (not fixed
@@ -70,7 +73,7 @@ type Profile struct {
} }
const profileColumns = ` const profileColumns = `
name, garmin_email, garmin_password, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate, garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct, hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct, hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct, hr_zone5_min_pct, hr_zone5_max_pct,
@@ -84,8 +87,8 @@ const profileColumns = `
// GetProfile returns the profile row for userID. // GetProfile returns the profile row for userID.
func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) { func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
var p Profile var p Profile
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan( err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profiles WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct, &p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct, &p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct, &p.HRZone5MinPct, &p.HRZone5MaxPct,
@@ -106,8 +109,8 @@ func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error) {
// replaces every column. // replaces every column.
func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error { func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
UPDATE profile SET UPDATE profiles SET
name=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?, garmin_email=?, garmin_password=?, rolling_window_days=?, backfill_horizon_days=?, max_heart_rate=?, resting_heart_rate=?,
hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?, hr_zone1_min_pct=?, hr_zone1_max_pct=?, hr_zone2_min_pct=?, hr_zone2_max_pct=?,
hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?, hr_zone3_min_pct=?, hr_zone3_max_pct=?, hr_zone4_min_pct=?, hr_zone4_max_pct=?,
hr_zone5_min_pct=?, hr_zone5_max_pct=?, hr_zone5_min_pct=?, hr_zone5_max_pct=?,
@@ -117,7 +120,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?, main_line_tint_pct=?, background_darken_pct=?, target_brighten_pct=?,
updated_at=datetime('now') updated_at=datetime('now')
WHERE user_id = ?`, WHERE user_id = ?`,
p.Name, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate, p.GarminEmail, p.GarminPassword, p.RollingWindowDays, p.BackfillHorizonDays, p.MaxHeartRate, p.RestingHeartRate,
p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct, p.HRZone1MinPct, p.HRZone1MaxPct, p.HRZone2MinPct, p.HRZone2MaxPct,
p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct, p.HRZone3MinPct, p.HRZone3MaxPct, p.HRZone4MinPct, p.HRZone4MaxPct,
p.HRZone5MinPct, p.HRZone5MaxPct, p.HRZone5MinPct, p.HRZone5MaxPct,
@@ -132,3 +135,14 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
} }
return nil return nil
} }
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `UPDATE profiles SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
if err != nil {
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
}
return nil
}

View File

@@ -26,15 +26,12 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 { if p.WarmupMinutes != 10 || p.CooldownMinutes != 5 {
t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p) t.Errorf("phase-minute defaults = %+v, want warmup=10, cooldown=5", p)
} }
if p.BackfillHorizonDays != 1095 { if p.BackfillHorizonDays != 90 {
t.Errorf("BackfillHorizonDays = %d, want 1095 (migration default)", p.BackfillHorizonDays) t.Errorf("BackfillHorizonDays = %d, want 90 (schema default)", p.BackfillHorizonDays)
} }
if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 { if p.MinRepresentativePaceSecPerKm != 720 || p.MinRepresentativeTimeSeconds != 3 {
t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p) t.Errorf("pace artifact filter defaults = %+v, want pace=720, time=3", p)
} }
if p.Name != "Default" {
t.Errorf("Name = %q, want %q (migration default)", p.Name, "Default")
}
if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" { if p.PaceColor != "#3b82f6" || p.HeartRateColor != "#ef4444" {
t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p) t.Errorf("main line color defaults = %+v, want pace=#3b82f6, heartRate=#ef4444", p)
} }
@@ -52,7 +49,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
} }
maxHR, restingHR := 190.0, 50.0 maxHR, restingHR := 190.0, 50.0
p.Name = "Kriss"
p.PaceColor = "#111111" p.PaceColor = "#111111"
p.EffortColor = "#222222" p.EffortColor = "#222222"
p.MainLineTintPct = 45 p.MainLineTintPct = 45
@@ -79,9 +75,6 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 { if got.GarminEmail != "runner@example.com" || got.RollingWindowDays != 120 {
t.Errorf("got = %+v, want updated email/window", got) t.Errorf("got = %+v, want updated email/window", got)
} }
if got.Name != "Kriss" {
t.Errorf("Name = %q, want %q after update", got.Name, "Kriss")
}
if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 { if got.MaxHeartRate == nil || *got.MaxHeartRate != 190 {
t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate) t.Errorf("MaxHeartRate = %v, want 190", got.MaxHeartRate)
} }
@@ -107,3 +100,44 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct) t.Errorf("TargetBrightenPct after update = %v, want 50", got.TargetBrightenPct)
} }
} }
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
before, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if before.GarminConnectedAt != nil {
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
}
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected: %v", err)
}
afterFirst, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after first mark: %v", err)
}
if afterFirst.GarminConnectedAt == nil {
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
}
firstValue := *afterFirst.GarminConnectedAt
// A second call must not change the recorded first-connection time.
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected (second call): %v", err)
}
afterSecond, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after second mark: %v", err)
}
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
}
}

View File

@@ -8,15 +8,16 @@
-- here rather than appending an ALTER TABLE migration. -- here rather than appending an ALTER TABLE migration.
-- One geniusrun account per OIDC subject. Every other table below is scoped -- One geniusrun account per OIDC subject. Every other table below is scoped
-- to a user_id, directly (profile/workout_kinds/activities/sync_state/ -- to a user_id, directly (profiles/workout_kinds/activities/sync_state/
-- sync_runs) or transitively through a JOIN to the owning row (laps/ -- sync_runs) or transitively through a JOIN to the owning row (activity_laps/
-- activity_samples/kind_assignments/workout_type_paces, which have no -- activity_samples/kind_assignments/workout_type_paces, which have no
-- user_id column of their own since they're never queried except through a -- user_id column of their own since they're never queried except through a
-- specific activity or workout kind). -- specific activity or workout kind). name is the account's single
-- human-facing name: set at onboarding, editable from the Profile page.
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE, oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
@@ -24,17 +25,22 @@ CREATE TABLE users (
-- parameter (HR zones, phase-detection minutes, pace-artifact filtering, -- parameter (HR zones, phase-detection minutes, pace-artifact filtering,
-- chart colors). store.ProvisionUser creates this (and everything else -- chart colors). store.ProvisionUser creates this (and everything else
-- below) in one transaction when a new account signs up. -- below) in one transaction when a new account signs up.
CREATE TABLE profile ( CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90, rolling_window_days INTEGER NOT NULL DEFAULT 90,
-- BackfillHorizonDays bounds how far back "Sync now" reaches when -- BackfillHorizonDays bounds how far back "Sync now" reaches when
-- walking backward from today; read fresh on every sync (not cached at -- walking backward from today; read fresh on every sync (not cached at
-- server startup), so changing it here takes effect on the next click. -- server startup), so changing it here takes effect on the next click.
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
max_heart_rate REAL, max_heart_rate REAL,
resting_heart_rate REAL, resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50, hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
@@ -82,7 +88,7 @@ CREATE TABLE profile (
-- recursive AND/OR condition tree evaluated by internal/classify. -- recursive AND/OR condition tree evaluated by internal/classify.
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -100,7 +106,7 @@ CREATE TABLE workout_kinds (
-- synced activity log is the history. No user_id column of its own -- -- synced activity log is the history. No user_id column of its own --
-- ownership is checked via a JOIN to workout_kinds. -- ownership is checked via a JOIN to workout_kinds.
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -118,7 +124,7 @@ CREATE TABLE workout_type_paces (
-- instead of storing a redundant copy. -- instead of storing a redundant copy.
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -145,6 +151,14 @@ CREATE TABLE activities (
-- Genuine raw get_workout_by_id() response, the source used to compute -- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id. -- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT, workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, garmin_activity_id) UNIQUE(user_id, garmin_activity_id)
@@ -154,7 +168,7 @@ CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
-- One row per lap/split (from get_activity_splits), plus HR drift/recovery -- One row per lap/split (from get_activity_splits), plus HR drift/recovery
-- derived from activity_samples. No user_id column -- always accessed -- derived from activity_samples. No user_id column -- always accessed
-- through a specific owning activity. -- through a specific owning activity.
CREATE TABLE laps ( CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL, lap_index INTEGER NOT NULL,
@@ -219,7 +233,7 @@ JOIN (
-- re-walking years of already-known history on every call. -- re-walking years of already-known history on every call.
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -229,7 +243,7 @@ CREATE TABLE sync_state (
-- status the frontend polls. -- status the frontend polls.
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,
@@ -237,3 +251,16 @@ CREATE TABLE sync_runs (
status TEXT NOT NULL CHECK(status IN ('running','success','error')), status TEXT NOT NULL CHECK(status IN ('running','success','error')),
error_message TEXT error_message TEXT
); );
-- Application configuration: instance-global key/value settings, shared
-- by every user -- deliberately the one table with no user_id, because
-- application configuration is common to all users by definition. Every
-- key in internal/config's app-key registry is mandatory here: missing
-- rows are seeded with their defaults at startup (cmd/geniusrund), so
-- there are no code-side fallbacks. Cold: read once at startup, a change
-- applies on the next backend restart.
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View File

@@ -149,12 +149,12 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (rule engine): %v", err) t.Fatalf("InsertKindAssignment (rule engine): %v", err)
} }
queue, err := db.ReviewQueue(ctx, userID) pending, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil { if err != nil || !ok {
t.Fatalf("ReviewQueue: %v", err) t.Fatalf("CurrentAssignment (needs_review): ok=%v err=%v", ok, err)
} }
if len(queue) != 1 { if pending.Status != AssignmentStatusNeedsReview {
t.Fatalf("expected 1 item in review queue, got %d", len(queue)) t.Fatalf("current status = %q, want needs_review", pending.Status)
} }
// Then: user manually resolves it. // Then: user manually resolves it.
@@ -167,14 +167,6 @@ func TestKindAssignment_AppendOnlyHistoryAndCurrentView(t *testing.T) {
t.Fatalf("InsertKindAssignment (manual): %v", err) t.Fatalf("InsertKindAssignment (manual): %v", err)
} }
queue, err = db.ReviewQueue(ctx, userID)
if err != nil {
t.Fatalf("ReviewQueue after resolve: %v", err)
}
if len(queue) != 0 {
t.Fatalf("expected 0 items in review queue after manual resolve, got %d", len(queue))
}
current, ok, err := db.CurrentAssignment(ctx, userID, activityID) current, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok { if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err) t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
@@ -244,7 +236,7 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// UNIQUE(user_id, name) allows the same name across two different users. // UNIQUE(user_id, name) allows the same name across two different users.
if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil { if _, err := db.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES ('sub-a', 'A'), ('sub-b', 'B')`); err != nil {
t.Fatalf("insert users: %v", err) t.Fatalf("insert users: %v", err)
} }
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
@@ -255,10 +247,10 @@ func TestSchema_PerUserUniqueConstraints(t *testing.T) {
} }
// profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate. // profile/sync_state UNIQUE(user_id) still rejects a genuine duplicate.
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil { if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err != nil {
t.Fatalf("insert profile for sub-a: %v", err) t.Fatalf("insert profile for sub-a: %v", err)
} }
if _, err := db.ExecContext(ctx, `INSERT INTO profile (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil { if _, err := db.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES ((SELECT id FROM users WHERE oidc_sub='sub-a'))`); err == nil {
t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)") t.Fatal("expected a second profile row for the same user_id to violate UNIQUE(user_id)")
} }
} }
@@ -356,6 +348,183 @@ func TestCurrentAssignment_ScopedToOwningUser(t *testing.T) {
} }
} }
// TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched
// is a regression test for a bug where ActivitiesMissingWorkout selected
// activities purely on workout_id IS NOT NULL AND workout_raw_json IS NULL,
// with no regard for whether get_activity_details had ever run for that
// activity. Since fillPendingDetails and fillPendingWorkouts are each
// independently LIMIT-bounded over different candidate sets, a
// structured-workout activity could fall inside the workouts pass's window
// while still outside the details pass's window on a large first backfill.
// fillActivityWorkout would then call LapsForActivity on an activity with no
// laps yet written, compute alignWorkoutTargets against zero laps (a
// no-op), and unconditionally call SetActivityWorkout anyway -- permanently
// marking workout_raw_json non-NULL before the activity ever had a chance
// to get real target pace/HR bands once its laps finally arrived. The fix
// gates the query on details_fetched_at IS NOT NULL, so an activity is only
// eligible for a workout fetch once fillActivityDetails has actually given
// it laps to align against.
func TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Activity a: has a workout_id, but details/splits have never been
// fetched (the exact shape of the bug above). Must now be EXCLUDED --
// this is the regression assertion for the bug.
withWorkoutNoDetails := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, withWorkoutNoDetails)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
// Activity b: has a workout_id, and its details/splits have already
// been fetched (e.g. its workout fetch failed in some prior run after
// details succeeded). This is the one case ActivitiesMissingWorkout must
// still surface, since ActivitiesMissingDetails would never pick this
// activity up again once details_fetched_at/splits_fetched_at are set.
withWorkoutAndDetails := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, withWorkoutAndDetails)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
// Activity c: no workout_id at all. Must be excluded.
noWorkout := Activity{
GarminActivityID: 3, StartTimeUTC: "2026-07-03 06:00:00", RawJSON: "{}",
}
if _, err := db.UpsertActivity(ctx, userID, noWorkout); err != nil {
t.Fatalf("UpsertActivity (c): %v", err)
}
// Activity d: already has its workout_raw_json set. Must be excluded.
alreadyHasWorkout := Activity{
GarminActivityID: 4, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-04 06:00:00", RawJSON: "{}",
}
idD, err := db.UpsertActivity(ctx, userID, alreadyHasWorkout)
if err != nil {
t.Fatalf("UpsertActivity (d): %v", err)
}
if err := db.SetActivityWorkout(ctx, userID, idD, `{"segments":[]}`); err != nil {
t.Fatalf("SetActivityWorkout (d): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (activity b only -- activity a must be excluded since its details were never fetched)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 {
t.Fatalf("ActivitiesMissingWorkout returned %d activities, want 1", len(pending))
}
if pending[0].ID != idB {
t.Errorf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
for _, a := range pending {
if a.ID == idA {
t.Errorf("ActivitiesMissingWorkout incorrectly included activity a (workout_id set but details never fetched) -- regression for the silent-loss bug")
}
}
}
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Details already fetched, workout confirmed 404 on Garmin -- must not
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
notFound := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, notFound)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails (a): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
}
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
}
// A genuinely still-pending activity (details fetched, workout not yet
// attempted) must still be included, for contrast.
stillPending := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, stillPending)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 || pending[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
confirmed, _, err := db.GetActivity(ctx, userID, idA)
if err != nil {
t.Fatalf("GetActivity (a): %v", err)
}
if confirmed.WorkoutNotFoundAt == nil {
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
}
if confirmed.WorkoutRawJSON != nil {
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
}
func contains(s string, substrs ...string) bool { func contains(s string, substrs ...string) bool {
lower := strings.ToLower(s) lower := strings.ToLower(s)
for _, substr := range substrs { for _, substr := range substrs {

View File

@@ -7,12 +7,10 @@ import (
) )
const ( const (
SyncKindBackfill = "backfill" // SyncKindFull is a manually-triggered "Sync now" pass: backfillCore
SyncKindIncremental = "incremental" // followed by incrementalSyncCore followed by FillPendingDetails,
// SyncKindFull is a manually-triggered "Sync now" pass: Backfill followed // recorded as one run so the reported activity count covers the whole
// by IncrementalSync followed by FillPendingDetails, recorded as one run // action instead of only whichever stage happened to finish last.
// so the reported activity count covers the whole action instead of only
// whichever stage happened to finish last.
SyncKindFull = "full" SyncKindFull = "full"
SyncStatusRunning = "running" SyncStatusRunning = "running"
@@ -20,7 +18,10 @@ const (
SyncStatusError = "error" SyncStatusError = "error"
) )
// SyncRun records one backfill or incremental sync attempt. // SyncRun records one sync attempt. Every run recorded today is
// SyncKindFull (the "Sync now" action, covering backfill + incremental sync
// + detail/workout fill in a single pass) -- "backfill"/"incremental" only
// ever appear in historical rows predating that consolidation.
type SyncRun struct { type SyncRun struct {
ID int64 ID int64
Kind string Kind string

View File

@@ -13,7 +13,7 @@ import (
type User struct { type User struct {
ID int64 ID int64
OIDCSub string OIDCSub string
DisplayName string Name string
CreatedAt string CreatedAt string
} }
@@ -21,8 +21,8 @@ type User struct {
// the session-resolution middleware ever uses. // the session-resolution middleware ever uses.
func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) { func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, error) {
var u User var u User
err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users WHERE oidc_sub = ?`, oidcSub). err := db.QueryRowContext(ctx, `SELECT id, oidc_sub, name, created_at FROM users WHERE oidc_sub = ?`, oidcSub).
Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt) Scan(&u.ID, &u.OIDCSub, &u.Name, &u.CreatedAt)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return User{}, false, nil return User{}, false, nil
} }
@@ -32,26 +32,6 @@ func (db *DB) GetUserBySub(ctx context.Context, oidcSub string) (User, bool, err
return u, true, nil return u, true, nil
} }
// ListUsers returns every provisioned user, for the background incremental
// sync loop to iterate.
func (db *DB) ListUsers(ctx context.Context) ([]User, error) {
rows, err := db.QueryContext(ctx, `SELECT id, oidc_sub, display_name, created_at FROM users ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
users := []User{}
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.OIDCSub, &u.DisplayName, &u.CreatedAt); err != nil {
return nil, fmt.Errorf("scan user row: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
// neverMatchRule is the placeholder every fresh install's rule-engine kinds // neverMatchRule is the placeholder every fresh install's rule-engine kinds
// start with -- every activity lands in needs_review until the user tunes // start with -- every activity lands in needs_review until the user tunes
// real rules. // real rules.
@@ -76,14 +56,14 @@ var defaultWorkoutKindSeeds = []WorkoutKind{
// default workout kinds (with their paired workout_type_paces rows), and an // default workout kinds (with their paired workout_type_paces rows), and an
// initial sync_state row -- all in one transaction, so a partially // initial sync_state row -- all in one transaction, so a partially
// provisioned user is never observable. // provisioned user is never observable.
func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (int64, error) { func (db *DB) ProvisionUser(ctx context.Context, oidcSub, name string) (int64, error) {
tx, err := db.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, fmt.Errorf("begin provision user tx: %w", err) return 0, fmt.Errorf("begin provision user tx: %w", err)
} }
defer tx.Rollback() defer tx.Rollback()
res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, display_name) VALUES (?, ?)`, oidcSub, displayName) res, err := tx.ExecContext(ctx, `INSERT INTO users (oidc_sub, name) VALUES (?, ?)`, oidcSub, name)
if err != nil { if err != nil {
return 0, fmt.Errorf("create user %q: %w", oidcSub, err) return 0, fmt.Errorf("create user %q: %w", oidcSub, err)
} }
@@ -92,7 +72,7 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return 0, err return 0, err
} }
if _, err := tx.ExecContext(ctx, `INSERT INTO profile (user_id, name) VALUES (?, ?)`, userID, displayName); err != nil { if _, err := tx.ExecContext(ctx, `INSERT INTO profiles (user_id) VALUES (?)`, userID); err != nil {
return 0, fmt.Errorf("create profile for user %d: %w", userID, err) return 0, fmt.Errorf("create profile for user %d: %w", userID, err)
} }
@@ -119,3 +99,26 @@ func (db *DB) ProvisionUser(ctx context.Context, oidcSub, displayName string) (i
return userID, tx.Commit() return userID, tx.Commit()
} }
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}
// UpdateUserName renames userID's account -- the single human-facing name
// (shown in the header and session info), editable from the Profile page.
func (db *DB) UpdateUserName(ctx context.Context, userID int64, name string) error {
if _, err := db.ExecContext(ctx, `UPDATE users SET name = ? WHERE id = ?`, name, userID); err != nil {
return fmt.Errorf("update name for user %d: %w", userID, err)
}
return nil
}

View File

@@ -29,17 +29,14 @@ func TestProvisionUser_SeedsProfileTaxonomyAndSyncState(t *testing.T) {
if err != nil || !found { if err != nil || !found {
t.Fatalf("GetUserBySub: found=%v err=%v", found, err) t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
} }
if u.ID != userID || u.DisplayName != "Lucie" { if u.ID != userID || u.Name != "Lucie" {
t.Fatalf("got %+v, want ID=%d DisplayName=Lucie", u, userID) t.Fatalf("got %+v, want ID=%d Name=Lucie", u, userID)
} }
profile, err := db.GetProfile(ctx, userID) profile, err := db.GetProfile(ctx, userID)
if err != nil { if err != nil {
t.Fatalf("GetProfile: %v", err) t.Fatalf("GetProfile: %v", err)
} }
if profile.Name != "Lucie" {
t.Errorf("profile.Name = %q, want %q", profile.Name, "Lucie")
}
if profile.RollingWindowDays != 90 { if profile.RollingWindowDays != 90 {
t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays) t.Errorf("profile.RollingWindowDays = %d, want 90 (default)", profile.RollingWindowDays)
} }
@@ -89,3 +86,76 @@ func TestProvisionUser_TwoUsersGetIndependentTaxonomies(t *testing.T) {
t.Fatal("expected each user's seeded kinds to be distinct rows") t.Fatal("expected each user's seeded kinds to be distinct rows")
} }
} }
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindFull); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profiles WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM activity_laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}

View File

@@ -1,728 +0,0 @@
package sync
import (
"context"
"path/filepath"
"testing"
"time"
"geniusrun/backend/internal/classify"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
)
func f(v float64) *float64 { return &v }
func openTestDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func fixedNow(t time.Time) func() time.Time {
return func() time.Time { return t }
}
func provisionTestUser(t *testing.T, db *store.DB) int64 {
t.Helper()
userID, err := db.ProvisionUser(context.Background(), "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
return userID
}
// setBackfillHorizon sets Profile.BackfillHorizonDays, which Backfill reads
// fresh on every call (it's no longer part of Config).
func setBackfillHorizon(t *testing.T, db *store.DB, userID int64, days int) {
t.Helper()
ctx := context.Background()
profile, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
profile.BackfillHorizonDays = days
if err := db.UpdateProfile(ctx, userID, profile); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
}
func TestBackfill_StoresActivitiesAndRecordsSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityName: "Morning Run", ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected 1 stored activity, got %d", len(activities))
}
if activities[0].GarminActivityID != 1 {
t.Errorf("GarminActivityID = %d, want 1", activities[0].GarminActivityID)
}
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
if len(runs) != 1 || runs[0].Status != store.SyncStatusSuccess {
t.Fatalf("expected 1 successful sync run, got %+v", runs)
}
}
func TestAlignWorkoutTargets_MatchesLapsToFlattenedSteps(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "no.target"}},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 2 {
t.Fatalf("expected 2 aligned targets, got %d", len(targets))
}
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[0] = %+v, want pace.zone step", targets[0])
}
if targets[1] == nil || targets[1].TargetType.TypeKey != "no.target" {
t.Errorf("targets[1] = %+v, want no.target step", targets[1])
}
}
func TestAlignWorkoutTargets_OneExtraTrailingLapKeepsOtherTargets(t *testing.T) {
// Confirmed via Garmin Connect against a real activity: recording
// sometimes continues one lap past the workout's last step (e.g. a
// 5-minute prescribed cool-down followed by another 6:46 the athlete
// just kept running). The extra lap should have no target, but every
// other lap's real target must still come through.
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)},
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(2), TargetValueTwo: f(2.5)},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 3 {
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
}
if targets[0] == nil || targets[0].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[0] = %+v, want the first step's pace.zone target", targets[0])
}
if targets[1] == nil || targets[1].TargetType.TypeKey != "pace.zone" {
t.Errorf("targets[1] = %+v, want the second step's pace.zone target", targets[1])
}
if targets[2] != nil {
t.Errorf("targets[2] = %+v, want nil for the trailing unplanned continuation lap", targets[2])
}
}
func TestAlignWorkoutTargets_MismatchedCountYieldsAllNil(t *testing.T) {
laps := []garmin.Lap{{LapIndex: 1}, {LapIndex: 2}, {LapIndex: 3}}
workout := garmin.Workout{Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO"},
}},
}}
targets := alignWorkoutTargets(laps, workout)
if len(targets) != 3 {
t.Fatalf("expected 3 slots (one per lap), got %d", len(targets))
}
for i, target := range targets {
if target != nil {
t.Errorf("targets[%d] = %+v, want nil on count mismatch", i, target)
}
}
}
func TestTargetPaceRange_OnlyForPaceZoneAndOrdersLowHigh(t *testing.T) {
lo, hi := targetPaceRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(4), TargetValueTwo: f(3),
})
if lo == nil || hi == nil || *lo != 3 || *hi != 4 {
t.Errorf("targetPaceRange = (%v, %v), want (3, 4) reordered", lo, hi)
}
lo, hi = targetPaceRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(3), TargetValueTwo: f(4)})
if lo != nil || hi != nil {
t.Errorf("targetPaceRange for a non-pace step = (%v, %v), want (nil, nil)", lo, hi)
}
}
func TestTargetHRRange_CustomRangeAndZoneNumberViaKarvonen(t *testing.T) {
lo, hi := targetHRRange(garmin.WorkoutStep{
TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, TargetValueOne: f(160), TargetValueTwo: f(150),
}, store.Profile{})
if lo == nil || hi == nil || *lo != 150 || *hi != 160 {
t.Errorf("custom bpm range = (%v, %v), want (150, 160) reordered", lo, hi)
}
zone := 3
profile := store.Profile{
MaxHeartRate: f(190), RestingHeartRate: f(50),
HRZone3MinPct: 70, HRZone3MaxPct: 80,
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, profile)
// Karvonen: restHR + pct * (maxHR - restHR) = 50 + 0.70*140 = 148, 50 + 0.80*140 = 162
if lo == nil || hi == nil || *lo != 148 || *hi != 162 {
t.Errorf("zone-based bpm range = (%v, %v), want (148, 162)", lo, hi)
}
lo, hi = targetHRRange(garmin.WorkoutStep{TargetType: garmin.WorkoutTargetType{TypeKey: "heart.rate.zone"}, ZoneNumber: &zone}, store.Profile{})
if lo != nil || hi != nil {
t.Errorf("zone-based range without max/resting HR configured = (%v, %v), want (nil, nil)", lo, hi)
}
}
func TestBackfill_SkipsNonRunningActivities(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "trail_running"},
StartTimeGMT: "2026-07-02 06:00:00", Distance: 8000, Duration: 2400},
{ActivityID: 3, ActivityType: garmin.ActivityType{TypeKey: "paddelball"},
StartTimeGMT: "2026-07-03 06:00:00", Distance: 0, Duration: 1800},
{ActivityID: 4, ActivityType: garmin.ActivityType{TypeKey: "indoor_cycling"},
StartTimeGMT: "2026-07-04 06:00:00", Distance: 0, Duration: 1800},
}}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("expected 2 stored (running-only) activities, got %d: %+v", len(activities), activities)
}
for _, a := range activities {
if a.GarminActivityID != 1 && a.GarminActivityID != 2 {
t.Errorf("unexpected non-running activity stored: %+v", a)
}
}
}
func TestFillPendingDetailsAndClassify_EndToEnd(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 42
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500, AverageSpeed: 3.33, AverageHR: 145},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, AverageHR: 150, AverageSpeed: 3.33, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{
garminActivityID: {
ActivityID: garminActivityID,
MetricDescriptors: []garmin.MetricDescriptor{
{Key: "directHeartRate", MetricsIndex: 0},
{Key: "sumElapsedDuration", MetricsIndex: 1},
},
},
},
}
svc := NewService(m, db, userID, Config{MinConfidence: 0.5}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
// A workout kind that should cleanly match the seeded activity's pace.
ruleJSON := `{"match":"all","conditions":[{"metric":"avg_pace_sec_per_km","op":"between","value":[280,320]}]}`
if _, err := db.CreateWorkoutKind(ctx, userID, store.WorkoutKind{Name: "Test Classification Tempo", RuleJSON: ruleJSON, IsActive: true}); err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
activityID := activities[0].ID
if activities[0].DetailsFetchedAt == nil {
t.Error("expected DetailsFetchedAt to be set after FillPendingDetails")
}
if activities[0].SplitsFetchedAt == nil {
t.Error("expected SplitsFetchedAt to be set after FillPendingDetails")
}
laps, err := db.LapsForActivity(ctx, userID, activityID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
assignment, ok, err := db.CurrentAssignment(ctx, userID, activityID)
if err != nil || !ok {
t.Fatalf("CurrentAssignment: ok=%v err=%v", ok, err)
}
if assignment.Status != classify.StatusAssigned {
t.Fatalf("assignment.Status = %q, want %q (candidates: %s)", assignment.Status, classify.StatusAssigned, assignment.CandidateKindsJSON)
}
}
func TestFillPendingDetails_ResolvesWorkoutTargetsOntoLaps(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 55
const workoutID = 999
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil || len(activities) != 1 {
t.Fatalf("ListActivities: %v, %+v", err, activities)
}
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 1 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
t.Errorf("TargetPaceLowMps = %v, want 3.0", laps[0].TargetPaceLowMps)
}
if laps[0].TargetPaceHighMps == nil || *laps[0].TargetPaceHighMps != 3.5 {
t.Errorf("TargetPaceHighMps = %v, want 3.5", laps[0].TargetPaceHighMps)
}
}
func TestFillPendingDetails_OneExtraTrailingLapKeepsOtherLapsTargets(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const garminActivityID = 56
const workoutID = 1000
workoutIDPtr := int64(workoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: garminActivityID, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &workoutIDPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
garminActivityID: {ActivityID: garminActivityID, Laps: []garmin.Lap{
{LapIndex: 1, Duration: 900, ElapsedDuration: 900, Distance: 3000, IntensityType: "ACTIVE"},
{LapIndex: 2, Duration: 600, ElapsedDuration: 600, Distance: 2000, IntensityType: "REST"},
}},
},
Details: map[int64]garmin.ActivityDetails{garminActivityID: {ActivityID: garminActivityID}},
Workouts: map[int64]garmin.Workout{
// One step for two recorded laps -- the second lap is an
// unplanned continuation past the workout's end (confirmed via
// Garmin Connect against a real activity), not a genuine
// mismatch, so the first lap should still get a real target.
workoutID: {Segments: []garmin.WorkoutSegment{
{Steps: []garmin.WorkoutStep{
{Type: "ExecutableStepDTO", TargetType: garmin.WorkoutTargetType{TypeKey: "pace.zone"}, TargetValueOne: f(3.0), TargetValueTwo: f(3.5)},
}},
}},
},
}
svc := NewService(m, db, userID, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
activities, _ := db.ListActivities(ctx, userID, store.ActivityFilter{})
laps, err := db.LapsForActivity(ctx, userID, activities[0].ID)
if err != nil || len(laps) != 2 {
t.Fatalf("LapsForActivity: %v, %+v", err, laps)
}
if laps[0].TargetPaceLowMps == nil || *laps[0].TargetPaceLowMps != 3.0 {
t.Errorf("laps[0].TargetPaceLowMps = %v, want 3.0 (the one defined step's target)", laps[0].TargetPaceLowMps)
}
if laps[1].TargetPaceLowMps != nil || laps[1].TargetPaceHighMps != nil {
t.Errorf("laps[1] target should be nil (the trailing unplanned continuation lap), got low=%v high=%v", laps[1].TargetPaceLowMps, laps[1].TargetPaceHighMps)
}
}
func TestBuildMetricContext_DerivesExpectedMetrics(t *testing.T) {
activity := store.Activity{
DurationSeconds: 1800,
DistanceMeters: 6000,
AvgSpeedMps: f(3.33),
AvgHR: f(152),
AerobicTrainingEffect: f(3.2),
}
laps := []store.Lap{
{IntensityType: "ACTIVE", AvgSpeedMps: f(3.33), HRDriftBpmPerMin: f(2.5)},
{IntensityType: "REST", AvgSpeedMps: f(1.5), HRRecoveryBpmPerMin: f(4.0)},
}
ctx := buildMetricContext(activity, laps, 190)
if got := ctx["avg_pace_sec_per_km"]; got < 300 || got > 301 {
t.Errorf("avg_pace_sec_per_km = %v, want ~300.3 (1000/3.33)", got)
}
if got, want := ctx["avg_hr_pct_max"], 152.0/190.0; got != want {
t.Errorf("avg_hr_pct_max = %v, want %v", got, want)
}
if got := ctx["lap_hr_drift_bpm_per_min"]; got != 2.5 {
t.Errorf("lap_hr_drift_bpm_per_min = %v, want 2.5", got)
}
if got := ctx["lap_hr_recovery_bpm_per_min"]; got != 4.0 {
t.Errorf("lap_hr_recovery_bpm_per_min = %v, want 4.0", got)
}
// Only one ACTIVE + one REST lap, not repeated -- should not look like a
// structured interval workout.
if got := ctx["lap_interval_pattern"]; got != 0 {
t.Errorf("lap_interval_pattern = %v, want 0", got)
}
if got := ctx["is_race"]; got != 0 {
t.Errorf("is_race = %v, want 0 (EventTypeKey not set)", got)
}
}
func TestBuildMetricContext_DerivesIsRace(t *testing.T) {
ctx := buildMetricContext(store.Activity{EventTypeKey: "race"}, nil, 0)
if got := ctx["is_race"]; got != 1 {
t.Errorf("is_race = %v, want 1 when EventTypeKey is \"race\"", got)
}
ctx = buildMetricContext(store.Activity{EventTypeKey: "training"}, nil, 0)
if got := ctx["is_race"]; got != 0 {
t.Errorf("is_race = %v, want 0 when EventTypeKey is not \"race\"", got)
}
}
func TestBackfill_SecondRunIsANoOpOnceHorizonFullyCovered(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
if firstCallCount == 0 {
t.Fatal("expected first backfill to call GetActivities at least once")
}
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
if !state.BackfillComplete {
t.Fatalf("expected backfill_complete=true after covering the full horizon, got %+v", state)
}
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
if m.GetActivitiesCalls != firstCallCount {
t.Errorf("second Backfill made %d more GetActivities call(s); want 0 (should be a no-op once horizon is covered)",
m.GetActivitiesCalls-firstCallCount)
}
}
func TestResetAll_AllowsFreshBackfillAfterwards(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
if err := svc.ResetAll(ctx); err != nil {
t.Fatalf("ResetAll: %v", err)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 0 {
t.Fatalf("expected 0 activities after ResetAll, got %d", len(activities))
}
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill after reset: %v", err)
}
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected Backfill after ResetAll to call GetActivities again (fresh pull), call count stayed at %d", m.GetActivitiesCalls)
}
activities, err = db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities after re-backfill: %v", err)
}
if len(activities) != 1 {
t.Fatalf("expected 1 activity re-fetched after reset+backfill, got %d", len(activities))
}
}
func TestBackfill_ResumesFromWatermarkWhenHorizonGrows(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
}}
now := fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
setBackfillHorizon(t, db, userID, 10)
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("first Backfill: %v", err)
}
firstCallCount := m.GetActivitiesCalls
// Simulate the user widening the horizon later -- should resume from the
// watermark (not re-fetch the already-covered recent window) but still
// make progress toward the new, deeper horizon.
setBackfillHorizon(t, db, userID, 30)
svc2 := NewService(m, db, userID, Config{BackfillWindowDays: 10}, now)
if err := svc2.Backfill(ctx); err != nil {
t.Fatalf("second Backfill: %v", err)
}
if m.GetActivitiesCalls <= firstCallCount {
t.Errorf("expected additional GetActivities calls when horizon grows, got %d total (was %d)", m.GetActivitiesCalls, firstCallCount)
}
state, err := db.GetSyncState(ctx, userID)
if err != nil {
t.Fatalf("GetSyncState: %v", err)
}
if !state.BackfillComplete {
t.Fatalf("expected backfill_complete=true after covering the new horizon, got %+v", state)
}
}
func TestFullSync_RecordsOneCombinedSyncRun(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-05 06:00:00", Distance: 5000, Duration: 1500},
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-08 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
Details: map[int64]garmin.ActivityDetails{
1: {ActivityID: 1}, 2: {ActivityID: 2},
},
}
svc := NewService(m, db, userID, Config{BackfillWindowDays: 10},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
setBackfillHorizon(t, db, userID, 10)
if err := svc.FullSync(ctx, 10); err != nil {
t.Fatalf("FullSync: %v", err)
}
runs, err := db.ListSyncRuns(ctx, userID, 10)
if err != nil {
t.Fatalf("ListSyncRuns: %v", err)
}
if len(runs) != 1 {
t.Fatalf("expected exactly 1 sync run recorded by FullSync (not one per stage), got %d: %+v", len(runs), runs)
}
run := runs[0]
if run.Kind != store.SyncKindFull {
t.Errorf("Kind = %q, want %q", run.Kind, store.SyncKindFull)
}
if run.Status != store.SyncStatusSuccess {
t.Errorf("Status = %q, want success", run.Status)
}
// Backfill (one window covering the whole horizon) stores both of the
// mock's activities as genuinely new (2). IncrementalSync then runs its
// own separate fetch and -- since the fake client ignores the date range
// it's called with -- sees the exact same 2 activities again, but they're
// already stored by then, so it contributes 0 new ones. The combined
// run's count (2) must reflect that dedup, not naively sum each stage's
// raw fetch count (which would double-count to 4) or report only
// whichever stage happened to run last (which would silently drop
// Backfill's count) -- both are bugs this test guards against.
if run.ActivitiesFetched != 2 {
t.Errorf("ActivitiesFetched = %d, want 2 (genuinely new activities, deduped across stages)", run.ActivitiesFetched)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 2 {
t.Fatalf("expected 2 stored activities (upserted, not duplicated), got %d", len(activities))
}
}
func TestFillPendingDetails_ReportsLiveProgress(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
m := &mock.Client{
Activities: []garmin.Activity{},
Splits: map[int64]garmin.ActivitySplits{},
Details: map[int64]garmin.ActivityDetails{},
}
const n = 3
for i := int64(1); i <= n; i++ {
m.Activities = append(m.Activities, garmin.Activity{
ActivityID: i, ActivityType: garmin.ActivityType{TypeKey: "running"},
StartTimeGMT: "2026-07-0" + string(rune('0'+i)) + " 06:00:00", Distance: 5000, Duration: 1500,
})
m.Splits[i] = garmin.ActivitySplits{ActivityID: i}
m.Details[i] = garmin.ActivityDetails{ActivityID: i}
}
svc := NewService(m, db, userID, Config{InterCallDelay: 150 * time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svc.Backfill(ctx); err != nil {
t.Fatalf("Backfill: %v", err)
}
if p := svc.Progress(); p.Total != 0 {
t.Fatalf("Progress before FillPendingDetails = %+v, want zero value", p)
}
done := make(chan error, 1)
go func() { done <- svc.FillPendingDetails(ctx, n) }()
time.Sleep(200 * time.Millisecond) // into the delay before the 2nd or 3rd item
mid := svc.Progress()
if mid.Total != n {
t.Errorf("mid-flight Progress().Total = %d, want %d", mid.Total, n)
}
if mid.Done <= 0 || mid.Done >= n {
t.Errorf("mid-flight Progress().Done = %d, want strictly between 0 and %d (i.e. actually in progress)", mid.Done, n)
}
if err := <-done; err != nil {
t.Fatalf("FillPendingDetails: %v", err)
}
if final := svc.Progress(); final.Total != 0 || final.Done != 0 {
t.Errorf("Progress after completion = %+v, want zero value (idle)", final)
}
}
func TestService_TwoUsersSyncIndependently(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
mA := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
}}
mB := &mock.Client{Activities: []garmin.Activity{
{ActivityID: 2, ActivityType: garmin.ActivityType{TypeKey: "running"}, StartTimeGMT: "2026-07-01 06:00:00", Distance: 8000, Duration: 2400},
}}
svcA := NewService(mA, db, userA, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
svcB := NewService(mB, db, userB, Config{}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if err := svcA.Backfill(ctx); err != nil {
t.Fatalf("Backfill(a): %v", err)
}
if err := svcB.Backfill(ctx); err != nil {
t.Fatalf("Backfill(b): %v", err)
}
activitiesA, err := db.ListActivities(ctx, userA, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(a): %v", err)
}
activitiesB, err := db.ListActivities(ctx, userB, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities(b): %v", err)
}
if len(activitiesA) != 1 || activitiesA[0].GarminActivityID != 1 {
t.Fatalf("userA's activities = %+v, want exactly garmin id 1", activitiesA)
}
if len(activitiesB) != 1 || activitiesB[0].GarminActivityID != 2 {
t.Fatalf("userB's activities = %+v, want exactly garmin id 2", activitiesB)
}
}

View File

@@ -5,15 +5,16 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
## Tables ## Tables
- [`users`](#users) - [`users`](#users)
- [`profile`](#profile) - [`profiles`](#profiles)
- [`workout_kinds`](#workout_kinds) - [`workout_kinds`](#workout_kinds)
- [`workout_type_paces`](#workout_type_paces) - [`workout_type_paces`](#workout_type_paces)
- [`activities`](#activities) - [`activities`](#activities)
- [`laps`](#laps) - [`activity_laps`](#activity_laps)
- [`activity_samples`](#activity_samples) - [`activity_samples`](#activity_samples)
- [`kind_assignments`](#kind_assignments) - [`kind_assignments`](#kind_assignments)
- [`sync_state`](#sync_state) - [`sync_state`](#sync_state)
- [`sync_runs`](#sync_runs) - [`sync_runs`](#sync_runs)
- [`config`](#config)
## `users` ## `users`
@@ -21,25 +22,30 @@ Generated from the live schema via `go run ./cmd/dumpschema` -- do not hand-edit
CREATE TABLE users ( CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
oidc_sub TEXT NOT NULL UNIQUE, oidc_sub TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL, name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
``` ```
## `profile` ## `profiles`
```sql ```sql
CREATE TABLE profile ( CREATE TABLE profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
garmin_email TEXT NOT NULL DEFAULT '', garmin_email TEXT NOT NULL DEFAULT '',
garmin_password TEXT NOT NULL DEFAULT '', garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90, rolling_window_days INTEGER NOT NULL DEFAULT 90,
-- BackfillHorizonDays bounds how far back "Sync now" reaches when -- BackfillHorizonDays bounds how far back "Sync now" reaches when
-- walking backward from today; read fresh on every sync (not cached at -- walking backward from today; read fresh on every sync (not cached at
-- server startup), so changing it here takes effect on the next click. -- server startup), so changing it here takes effect on the next click.
backfill_horizon_days INTEGER NOT NULL DEFAULT 1095, backfill_horizon_days INTEGER NOT NULL DEFAULT 90,
max_heart_rate REAL, max_heart_rate REAL,
resting_heart_rate REAL, resting_heart_rate REAL,
hr_zone1_min_pct REAL NOT NULL DEFAULT 50, hr_zone1_min_pct REAL NOT NULL DEFAULT 50,
@@ -87,7 +93,7 @@ CREATE TABLE profile (
```sql ```sql
CREATE TABLE workout_kinds ( CREATE TABLE workout_kinds (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT '',
@@ -104,7 +110,7 @@ CREATE TABLE workout_kinds (
```sql ```sql
CREATE TABLE workout_type_paces ( CREATE TABLE workout_type_paces (
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id), workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
pace_min_sec_per_km REAL, pace_min_sec_per_km REAL,
pace_max_sec_per_km REAL, pace_max_sec_per_km REAL,
hr_min_pct_hrr REAL, hr_min_pct_hrr REAL,
@@ -117,7 +123,7 @@ CREATE TABLE workout_type_paces (
```sql ```sql
CREATE TABLE activities ( CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
garmin_activity_id INTEGER NOT NULL, garmin_activity_id INTEGER NOT NULL,
-- Derived at sync time from Garmin's own eventType.typeKey=="race" -- -- Derived at sync time from Garmin's own eventType.typeKey=="race" --
-- a hard fact, not a rule the user tunes (see internal/classify's -- a hard fact, not a rule the user tunes (see internal/classify's
@@ -144,6 +150,14 @@ CREATE TABLE activities (
-- Genuine raw get_workout_by_id() response, the source used to compute -- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id. -- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT, workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(user_id, garmin_activity_id) UNIQUE(user_id, garmin_activity_id)
@@ -156,10 +170,10 @@ Indexes:
CREATE INDEX idx_activities_start_time ON activities(start_time_utc); CREATE INDEX idx_activities_start_time ON activities(start_time_utc);
``` ```
## `laps` ## `activity_laps`
```sql ```sql
CREATE TABLE laps ( CREATE TABLE activity_laps (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE, activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
lap_index INTEGER NOT NULL, lap_index INTEGER NOT NULL,
@@ -227,7 +241,7 @@ CREATE INDEX idx_kind_assignments_status ON kind_assignments(status);
```sql ```sql
CREATE TABLE sync_state ( CREATE TABLE sync_state (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
earliest_synced_date TEXT, earliest_synced_date TEXT,
backfill_complete INTEGER NOT NULL DEFAULT 0, backfill_complete INTEGER NOT NULL DEFAULT 0,
UNIQUE(user_id) UNIQUE(user_id)
@@ -239,7 +253,7 @@ CREATE TABLE sync_state (
```sql ```sql
CREATE TABLE sync_runs ( CREATE TABLE sync_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')), kind TEXT NOT NULL CHECK(kind IN ('backfill','incremental','full')),
started_at TEXT NOT NULL, started_at TEXT NOT NULL,
finished_at TEXT, finished_at TEXT,
@@ -249,6 +263,16 @@ CREATE TABLE sync_runs (
); );
``` ```
## `config`
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
## Views ## Views
### `current_kind_assignment` ### `current_kind_assignment`

View File

@@ -7,17 +7,68 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/`
for that history) and remove it from here once a spec exists. for that history) and remove it from here once a spec exists.
## Backlog ## Backlog
- new workout kinds
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on - add "Recovery", "Quick", and "Sprint" workout kinds
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation) - order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon) - adapt the color palette from blue to purple according to this order (starting with blue for recovery -> green -> yellow -> orange -> red until purple for race), put the color code in DB (UI shall not resolve it with kindColor based on workout kind name)
- improve activities/workouts download: make it modal, add progress bar, error management - workout templates
- better UX when backend is not available (instead of "TypeError failed to fetch") - provide workout templates in YAML (see docs/workouts.yaml for the structure)
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout) - first time setup page
- once connection is validated, add a step to ask user its best race results on 5k, 10k, half-marathon and marathon (at least one shall be provided) and store it in the profile. these values will be updated later during synchronization (as we can detect race kinds with their distance)
- add another step to enter latest known MAS (in mm:ss/km) and store it in the profile. this value will be updated later during synchronization (as we can assign an activity with "MAS test" kind)
- profile page
- move "Heart rate" card before "Activity analysis" card and rename it to "Characteristics"
- add genre (male/female) and age in "Characteristics"
- rename "Activity analysis" card to "Activities"
- in activity analysis, remove "rolling window" and consider the future AI-assisted activity kind analysis will be done on all the synced activities
- in activity analysis, remove the "warm-up" and "cool-down" as we'll detect them during activities synchronization and block identification
- Move all "Chart colors" items inside "Activities" card (and remove "Chart colors" card)
- add a personal records cards for 1k, 5k, 10k, half-marathon, marathon (we'll have at least one value coming from "first time setup page", or more after synchronization). show the date if it comes from an activity
- add a performance card showing latest know MAS (we'll have at least one value coming from "first time setup page", or a true one after synchronization). show the date if it comes from an activity
- rename "Training types" card to "Workouts"
- for each workout kind in "Workout kinds" card
- add a radio button to declare a warm-up or not (when checked, add a field to enter duration in minutes)
- add a radio button to declare a cool-down or not (when checked, add a field to enter duration in minutes)
- add a way to choose its primary objective (either "heart rate", either "% MAS")
- if "heart rate" is selected, make appear the "min heart rate", "max heart rate", "min pace", "max pace" fields like before (heart rate fields are mandatory, but not the pace ones)
- if "% MAS" is selected, make appear a "% MAS" field requiring a value between 0 and 150, and a "pace amplitude (s/km)" field requiring a number between 0 and 60
- add Claude API key (keep it optional, this will be for AI-assisted services)
- analysis page
- refactor /api/progress in /api/analysis
- there should now be history graphs per workout kind, like today (remove VO2max metric as we'll move it elsewhere) and general history graphs to monitor new metrics not linked to a workout kind
- general metrics are :
- VO2max
- (as soon as if we have a 5k race in the profile) "Endurance Index (5k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 5k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 5k race in minutes
- (as soon as if we have a 10k race in the profile) "Endurance Index (10k)" which is (100-%MAS)/(ln(6/t)) where %MAS is 10k pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of 10k race in minutes
- (as soon as if we have a half-marathon race in the profile) "Endurance Index (HM)" which is (100-%MAS)/(ln(6/t)) where %MAS is half-marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of half-marathon race in minutes
- (as soon as if we have a marathon race in the profile) "Endurance Index (M)" which is (100-%MAS)/(ln(6/t)) where %MAS is marathon pace divided by MAS speed (get it from the profile) multiplied by 100, and t the duration of marathon race in minutes
- general metrics history policy is :
- VO2max, one history point for all activities (whatever their kind)
- Endurance Index metrics, one history point as soon as one formula value is changing (aka race time or MAS)
- create a "Status" card with following elements before the history graphs
- a line scale indicating current VO2max (declare and use application configuration vo2max.gxxyyy where g could be "m" for male or "f" for female, xx could be 20,30,40,50,60,70 and yyy could be "fair", "good", "excellent", "superior", get default values from https://www8.garmin.com/manuals/webhelp/venu/EN-US/GUID-1FBCCD9E-19E1-4E4C-BD60-1793B5B97EB3.html, for example vo2max.m40good=42.4)
- (as soon as if we have a 5k race in the profile) a line scale indicating current "Endurance Index (5k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a 10k race in the profile) a line scale indicating current "Endurance Index (10k)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a half-marathon race in the profile) a line scale indicating current "Endurance Index (HM)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- (as soon as if we have a marathon race in the profile) a line scale indicating current "Endurance Index (M)" (declare and use application configuration ei.min with default value -15 and ei.max with default value 0)
- training plan page
- we need to store our workouts and dedicated /api/workouts (these are not the garmin workouts)
- this page will list all the planned workouts of the plan, no consideration of schedule for now, just a list of workouts
- for each workout, we have to see
- kind (with same color policy as for the activities)
- objective (as given in workout type profile)
- min pace / max pace ()
- activities page
- add an indicator if a personal record has been beaten (with a cup icon)
- add an indicator for the maximum heart rate (just after the average heart rate)
- AI-assisted ranking button that will set workout kind on unlocked activities (based on workout kind description in profile page)
- discovery of warm-up, cool-down phases, so we can read activity per blocks (with detection of extension of a block compared to linked workout)
- discovery of additional blocks done but not requested by linked workout (for example, additional small sprints at the end of an easy run)
- add icon to indicate if an activity has an associated workout - add icon to indicate if an activity has an associated workout
- manage 2 layouts
- list layout with smaller graphs (no scale, no targets)
- detailed layout (once clicked on details...) showing graphs for each block incl. delta with workout)
## Someday / maybe ## Someday / maybe
- dedicated tab for past workouts (or find a way to show them in the "training plan" tab)
- training programs with phases (tapering, easier weeks, post-race recovery) - training programs with phases (tapering, easier weeks, post-race recovery)
-

View File

@@ -22,8 +22,8 @@
**Files:** **Files:**
- Create: `backend/internal/store/migrations/0003_profile.sql` - Create: `backend/internal/store/migrations/0003_profile.sql`
- Create: `backend/internal/store/profile.go` - Create: `../../../backend/internal/store/profiles.go`
- Test: `backend/internal/store/profile_test.go` - Test: `../../../backend/internal/store/profiles_test.go`
**Interfaces:** **Interfaces:**
- Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`. - Produces: `store.Profile` struct, `(db *DB) GetProfile(ctx context.Context) (Profile, error)`, `(db *DB) UpdateProfile(ctx context.Context, p Profile) error`.
@@ -72,7 +72,7 @@ INSERT INTO profile (id) VALUES (1);
- [ ] **Step 2: Write the failing test** - [ ] **Step 2: Write the failing test**
Create `backend/internal/store/profile_test.go`: Create `../../../backend/internal/store/profiles_test.go`:
```go ```go
package store package store
@@ -130,9 +130,9 @@ func TestProfile_DefaultsThenUpdate(t *testing.T) {
Run: `cd backend && go test ./internal/store/... -run TestProfile -v` Run: `cd backend && go test ./internal/store/... -run TestProfile -v`
Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet). Expected: FAIL — `db.GetProfile undefined` (compile error, `Profile` type/methods don't exist yet).
- [ ] **Step 4: Write `profile.go`** - [ ] **Step 4: Write `profiles.go`**
Create `backend/internal/store/profile.go`: Create `../../../backend/internal/store/profiles.go`:
```go ```go
package store package store
@@ -253,7 +253,7 @@ Expected: PASS
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profile.go internal/store/profile_test.go cd backend && git add internal/store/migrations/0003_profile.sql internal/store/profiles.go internal/store/profiles_test.go
git commit -m "feat: add single-profile settings table and store layer" git commit -m "feat: add single-profile settings table and store layer"
``` ```
(If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.) (If this is the first commit in the repo, run `git init` first and skip any `git add` of files outside `backend/` — later tasks add frontend files separately.)
@@ -876,7 +876,7 @@ Expected: all PASS.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
cd backend && git add internal/api/profile.go internal/api/server.go internal/api/api_test.go cd backend && git add internal/api/profiles.go internal/api/server.go internal/api/api_test.go
git commit -m "feat: add profile REST endpoints with HR zone validation" git commit -m "feat: add profile REST endpoints with HR zone validation"
``` ```
@@ -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 ### Task 7: Classification reads max HR from the profile, not static config
**Files:** **Files:**
- Modify: `backend/internal/sync/service.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310) - Modify: `../../../backend/internal/garmin/sync.go` (`Config` struct lines 21-40, `ClassifyActivity` lines 279-310)
- Modify: `backend/internal/sync/service_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`) - Modify: `../../../backend/internal/garmin/sync_test.go` (`TestFillPendingDetailsAndClassify_EndToEnd`)
**Interfaces:** **Interfaces:**
- Consumes: `store.Profile.MaxHeartRate` (Task 1), `db.GetProfile` (Task 1). - 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** - [ ] **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 ```go
svc := NewService(m, db, Config{MinConfidence: 0.5, MaxHR: 190}, fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC))) 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`** - [ ] **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 ```go
// MinConfidence is the classify.Classify threshold below which even a // 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** - [ ] **Step 6: Commit**
```bash ```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" 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 ### Task 8: `internal/config` and `cmd/geniusrund` — credentials come from the profile
**Files:** **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) - Modify: `backend/cmd/geniusrund/main.go` (lines 21-46)
**Interfaces:** **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`** - [ ] **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 ```go
// Package config loads geniusrund's runtime infrastructure configuration // 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** - [ ] **Step 5: Commit**
```bash ```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" 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 ### Task 1: Config — OIDC/session settings
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Create: `backend/internal/config/config_test.go` - Create: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **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`. - 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** - [ ] **Step 1: Write the failing tests**
Create `backend/internal/config/config_test.go`: Create `../../../backend/internal/config/envconfig_test.go`:
```go ```go
package config 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** - [ ] **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 ```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
@@ -256,7 +256,7 @@ Expected: PASS (all `TestLoad_*` cases).
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```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" 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. - `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`). - `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. - `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 ### Task 1: Python wrapper (`wrapper.py`) with its own test suite
**Files:** **Files:**
- Create: `backend/internal/garmin/pyscript/wrapper.py` - Create: `../../../backend/internal/garmin/wrapper/wrapper.py`
- Create: `backend/internal/garmin/pyscript/pyproject.toml` - Create: `../../../backend/internal/garmin/wrapper/pyproject.toml`
- Create: `backend/internal/garmin/pyscript/.gitignore` - Create: `../../../backend/internal/garmin/wrapper/.gitignore`
- Test: `backend/internal/garmin/pyscript/tests/__init__.py` - Test: `../../../backend/internal/garmin/wrapper/tests/__init__.py`
- Test: `backend/internal/garmin/pyscript/tests/test_wrapper.py` - Test: `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`
**Interfaces:** **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. - 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** - [ ] **Step 1: Create the Python project manifest**
`backend/internal/garmin/pyscript/pyproject.toml`: `../../../backend/internal/garmin/wrapper/pyproject.toml`:
```toml ```toml
[project] [project]
@@ -55,7 +55,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
``` ```
`backend/internal/garmin/pyscript/.gitignore`: `../../../backend/internal/garmin/wrapper/.gitignore`:
``` ```
.venv/ .venv/
@@ -68,7 +68,7 @@ __pycache__/
Run: Run:
```bash ```bash
cd backend/internal/garmin/pyscript cd backend/internal/garmin/wrapper
python3 -m venv .venv python3 -m venv .venv
.venv/bin/pip install -e ".[dev]" .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** - [ ] **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 ```python
import os import os
@@ -216,7 +216,7 @@ Expected: `ModuleNotFoundError: No module named 'wrapper'` (or collection failur
- [ ] **Step 5: Write `wrapper.py`** - [ ] **Step 5: Write `wrapper.py`**
`backend/internal/garmin/pyscript/wrapper.py`: `../../../backend/internal/garmin/wrapper/wrapper.py`:
```python ```python
"""Subprocess wrapper around garminconnect, spoken to over newline-delimited """Subprocess wrapper around garminconnect, spoken to over newline-delimited
@@ -393,7 +393,7 @@ Expected: all tests PASS.
- [ ] **Step 7: Commit** - [ ] **Step 7: Commit**
```bash ```bash
git add backend/internal/garmin/pyscript git add backend/internal/garmin/wrapper
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
feat(garmin): add direct garminconnect wrapper script feat(garmin): add direct garminconnect wrapper script
@@ -415,7 +415,7 @@ EOF
**Interfaces:** **Interfaces:**
- Consumes: nothing from other tasks. - Consumes: nothing from other tasks.
- Produces: `Config{PythonPath, GarminEmail, GarminPassword, TokenStorePath string}`, `NewClient(cfg Config) Client`, `subprocessClient` (unexported struct implementing `Client`), `wireRequest{ID int; Cmd string; Params any}`, `wireResponse{ID int; Result json.RawMessage; Error string}`, `callParams{Method string; Args map[string]any}`, `(*subprocessClient).roundTrip(cmd string, params any) (json.RawMessage, error)`, `(*subprocessClient).ensureStarted() error`, `(*subprocessClient).close() error` (unexported, no-lock; `Close()`/`UpdateCredentials` call it while already holding `c.mu`). Later tasks (3, 4) add methods to `subprocessClient` using `roundTrip`/`ensureStarted` and must not redefine these types. - Produces: `Config{PythonPath, GarminEmail, GarminPassword, TokenStorePath string}`, `NewClient(cfg Config) Client`, `subprocessClient` (unexported struct implementing `Client`), `wireRequest{ID int; Cmd string; Params any}`, `wireResponse{ID int; Result json.RawMessage; Error string}`, `callParams{Method string; Args map[string]any}`, `(*subprocessClient).roundTrip(cmd string, params any) (json.RawMessage, error)`, `(*subprocessClient).ensureStarted() error`, `(*subprocessClient).close() error` (unexported, no-lock; `Close()`/`UpdateCredentials` call it while already holding `c.mu`). Later tasks (3, 4) add methods to `subprocessClient` using `execute`/`ensureStarted` and must not redefine these types.
- [ ] **Step 1: Write the failing tests for the transport layer** - [ ] **Step 1: Write the failing tests for the transport layer**
@@ -576,7 +576,7 @@ func TestSubprocessClient_Close_NoOpWhenNotStarted(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail** - [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient -v` Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient -v`
Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `maxWrapperLineBytes`, `Config` (new shape) don't exist yet. Expected: compile failure — `subprocessClient`, `wireResponse`, `execute`, `maxWrapperLineBytes`, `Config` (new shape) don't exist yet.
- [ ] **Step 3: Rewrite `client.go`** - [ ] **Step 3: Rewrite `client.go`**
@@ -584,7 +584,7 @@ Expected: compile failure — `subprocessClient`, `wireResponse`, `roundTrip`, `
```go ```go
// Package garmin wraps a direct garminconnect subprocess (see // 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. // geniusrun never deals with the wire protocol directly.
package garmin package garmin
@@ -861,7 +861,7 @@ git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
feat(garmin): replace mcp-go transport with a JSON-lines subprocess client 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 speaks newline-delimited JSON instead of MCP. Auth/data methods land in
follow-up commits; this is the transport + lifecycle plumbing only. follow-up commits; this is the transport + lifecycle plumbing only.
@@ -879,7 +879,7 @@ EOF
- Modify: `backend/internal/garmin/client_test.go` - Modify: `backend/internal/garmin/client_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `subprocessClient`, `roundTrip`, `ensureStarted`, `mapAuthStatus`, `authResultWire`, `newFakeWrapperClient`/`fakeResult`/`fakeError` from Task 2. - Consumes: `subprocessClient`, `execute`, `ensureStarted`, `mapAuthStatus`, `authResultWire`, `newFakeWrapperClient`/`fakeResult`/`fakeError` from Task 2.
- Produces: `(*subprocessClient).Authenticate(ctx) (AuthResult, error)`, `(*subprocessClient).CompleteMFA(ctx, code) (AuthResult, error)`. - Produces: `(*subprocessClient).Authenticate(ctx) (AuthResult, error)`, `(*subprocessClient).CompleteMFA(ctx, code) (AuthResult, error)`.
- [ ] **Step 1: Write the failing tests** - [ ] **Step 1: Write the failing tests**
@@ -992,7 +992,7 @@ Expected: compile failure — `Authenticate`/`CompleteMFA` methods don't exist o
- [ ] **Step 3: Implement `Authenticate`/`CompleteMFA`** - [ ] **Step 3: Implement `Authenticate`/`CompleteMFA`**
Append to `backend/internal/garmin/client.go` (after `roundTrip`): Append to `backend/internal/garmin/client.go` (after `execute`):
```go ```go
func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) { func (c *subprocessClient) Authenticate(ctx context.Context) (AuthResult, error) {
@@ -1370,8 +1370,8 @@ EOF
### Task 5: Update `internal/config` (drop server-path env var, default the python path) ### Task 5: Update `internal/config` (drop server-path env var, default the python path)
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `backend/internal/config/config_test.go` - Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **Interfaces:**
- Consumes: nothing from Tasks 14. - Consumes: nothing from Tasks 14.
@@ -1379,7 +1379,7 @@ EOF
- [ ] **Step 1: Write the failing tests** - [ ] **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 ```go
func setRequiredEnv(t *testing.T) { func setRequiredEnv(t *testing.T) {
@@ -1425,11 +1425,11 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail** - [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && go test ./internal/config/... -v` 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 ```go
// GarminPythonPath is mcp-garmin's venv python executable. // GarminPythonPath is mcp-garmin's venv python executable.
@@ -1479,7 +1479,7 @@ Expected: all tests PASS.
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON feat(config): drop MCP_GARMIN_SERVER, default GARMIN_WRAPPER_PYTHON
@@ -1528,7 +1528,7 @@ to:
}, appsync.Config{ }, 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: In `backend/internal/api/server.go`, change:
@@ -1596,16 +1596,37 @@ Replace the entire `## mcp-garmin integration` section:
```markdown ```markdown
## mcp-garmin integration ## mcp-garmin integration
`internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`'s `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` is a Go MCP **client** (via `github.com/mark3labs/mcp-go`'s stdio transport) that spawns `mcp-garmin`
's `server.py` as a subprocess. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`, `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult` pattern-matches these. - **`authenticate()`/`complete_mfa()` return plain strings**, not structured JSON (`"Authenticated successfully."`,
- **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason). `"MFA required. ..."`, `"Authentication failed: ..."`). `internal/garmin/client.go`'s `parseAuthResult`
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped. pattern-matches these.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **The 10s "MFA required" timeout in mcp-garmin's `authenticate()` is a false-positive trap.** A login that's merely
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether
`prompt_mfa()` was actually invoked (mcp-garmin now logs this to stderr for exactly this reason).
- **mcp-garmin persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)`
without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated
testing. Since geniusrun became multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root
directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under
`{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` now defaults it to a
`.garmin` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or
it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details()` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not
lap/split summaries, despite what its docstring used to say. Its `metricDescriptors` index-to-field mapping is **not
stable across activities/devices** — `garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed
array position.
- **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`). - **`get_activity_splits()`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band. - **`get_workout_by_id()`** returns a structured Garmin workout's flattened steps (target pace/HR per step);
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session. `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the
activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each
lap's expected pace/HR band.
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by
`userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only
that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns
fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of
one user's credential change touching another's session.
``` ```
with: with:
@@ -1613,16 +1634,40 @@ with:
```markdown ```markdown
## Garmin integration (direct wrapper, no MCP) ## Garmin integration (direct wrapper, no MCP)
`internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports `garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}` in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call` (`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin` repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered: `internal/garmin` spawns a small Python wrapper script (`internal/garmin/pyscript/wrapper.py`, embedded into the Go
binary via `go:embed` — see `docs/superpowers/specs/2026-07-25-garmin-direct-wrapper-design.md`) that imports
`garminconnect` directly and speaks a minimal newline-delimited-JSON protocol over stdin/stdout: `{"id","cmd","params"}`
in, `{"id","result"}` or `{"id","error"}` out. `cmd` is `authenticate`, `complete_mfa`, or a fully generic `call`
(`{"method": "<any garminconnect.Garmin method>", "args": {...}}`) — there's no MCP layer, and the separate `mcp-garmin`
repo this used to depend on is retired. Key things learned the hard way, that would otherwise get rediscovered:
- **`authenticate`/`complete_mfa` return structured JSON** (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go` maps `status` directly to `AuthStatus`, no string pattern-matching. - **`authenticate`/`complete_mfa` return structured JSON**
- **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether `prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason). (`{"status": "success"|"mfa_required"|"failed", "message": "..."}`), not plain strings — `internal/garmin/client.go`
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)` — without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**, not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `garmin-tokenstores` directory next to the DB file (logged at startup) rather than leaving per-user namespacing silently skipped. maps `status` directly to `AuthStatus`, no string pattern-matching.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`). - **The 10s "MFA required" timeout in `wrapper.py`'s `authenticate` handler is a false-positive trap.** A login that's
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position. merely slow (e.g. Garmin/Cloudflare rate-limiting) looks identical to a real MFA challenge unless you check whether
`prompt_mfa()` was actually invoked (`wrapper.py` logs this to stderr for exactly this reason).
- **`wrapper.py` persists Garmin sessions** via a `GARMIN_TOKENSTORE` env var passed to `Garmin.login(tokenstore=...)`
without this, every process start does a full SSO login, which is what trips Garmin's rate limiting under repeated
testing. Since geniusrun is multi-tenant, `GARMIN_TOKENSTORE` (`config.GarminTokenStoreRoot`) is a **root directory**,
not a single session path: `api.Server.garminFor` namespaces each user's subprocess under `{root}/{userID}` so
concurrent users never share a Garmin session. If unset, `config.Load()` defaults it to a `.garmin` directory next to
the DB file (logged at startup) rather than leaving per-user namespacing silently skipped.
- **`activityId` is a large int64** — never round-trip it through `float64`/generic `map[string]any` JSON decoding, or
it corrupts into scientific notation. Decode into typed structs (`garmin.Activity`, not `map[string]any`).
- **`get_activity_details` returns raw per-second telemetry** (`activityDetailMetrics` + `metricDescriptors`), not
lap/split summaries. Its `metricDescriptors` index-to-field mapping is **not stable across activities/devices**
`garmin.ExtractSamples` always resolves fields by descriptor key, never by fixed array position.
- **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`). - **`get_activity_splits`** returns the actual lap/split summaries (`lapDTOs`).
- **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step); `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each lap's expected pace/HR band. - **`get_workout_by_id`** returns a structured Garmin workout's flattened steps (target pace/HR per step);
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by `userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of one user's credential change touching another's session. `internal/sync/mapping.go`'s `alignWorkoutTargets` zips these 1:1 against an activity's recorded laps (when the
activity carries a `workout_id` and the lap count matches, tolerating exactly one extra trailing lap) to resolve each
lap's expected pace/HR band.
- Each user has their own lazily-built, cached `garmin.Client` (`api.Server.garminFor`, double-checked-locked, keyed by
`userID`). Saving that user's profile calls `UpdateCredentials` on their own cached client instance, which resets only
that client's "started" state and terminates only that user's already-spawned subprocess so the next call respawns
fresh under the new credentials — no separate reconnect UI needed beyond the existing connect/MFA flow, and no risk of
one user's credential change touching another's session.
``` ```
- [ ] **Step 5: Verify the whole backend builds** - [ ] **Step 5: Verify the whole backend builds**
@@ -1704,4 +1749,4 @@ EOF
## Follow-up note (not a task in this plan) ## 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` ### Task 1: Add `FrontendURL` to `internal/config`
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
- Modify: `backend/internal/config/config_test.go` - Modify: `../../../backend/internal/config/envconfig_test.go`
**Interfaces:** **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. - 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** - [ ] **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 ```go
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) { 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** - [ ] **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 ```go
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally // 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** - [ ] **Step 5: Commit**
```bash ```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' git commit -m "$(cat <<'EOF'
feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL feat(config): add GENIUSRUN_FRONTEND_URL, defaulting to PublicBaseURL

View File

@@ -765,16 +765,16 @@ EOF
--- ---
## Task 4: Scope `profile.go` to `user_id` ## Task 4: Scope `profiles.go` to `user_id`
**Files:** **Files:**
- Modify: `backend/internal/store/profile.go` - Modify: `../../../backend/internal/store/profiles.go`
- Modify: `backend/internal/store/profile_test.go` - Modify: `../../../backend/internal/store/profiles_test.go`
**Interfaces:** **Interfaces:**
- Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on. - Produces: `func (db *DB) GetProfile(ctx context.Context, userID int64) (Profile, error)`; `func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error`. Every other task/caller of these two methods must pass `userID` as the second positional argument from here on.
- [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `backend/internal/store/profile.go`** - [ ] **Step 1: Update `GetProfile`/`UpdateProfile` in `../../../backend/internal/store/profiles.go`**
Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged): Replace the two function bodies (the `Profile` struct and `profileColumns` constant are unchanged):
@@ -832,7 +832,7 @@ func (db *DB) UpdateProfile(ctx context.Context, userID int64, p Profile) error
} }
``` ```
- [ ] **Step 2: Fix `backend/internal/store/profile_test.go`'s call sites** - [ ] **Step 2: Fix `../../../backend/internal/store/profiles_test.go`'s call sites**
The existing `TestProfile_DefaultsThenUpdate` calls `db.GetProfile(ctx)` and `db.UpdateProfile(ctx, p)` directly against a fresh DB with no provisioned user — since Task 1 made `profile` rows always belong to a user, this test must provision one first. Replace the test's opening two lines: The existing `TestProfile_DefaultsThenUpdate` calls `db.GetProfile(ctx)` and `db.UpdateProfile(ctx, p)` directly against a fresh DB with no provisioned user — since Task 1 made `profile` rows always belong to a user, this test must provision one first. Replace the test's opening two lines:
@@ -866,7 +866,7 @@ Run: `cd backend && gofmt -l internal/store/`
Expected: no output. Expected: no output.
```bash ```bash
git add backend/internal/store/profile.go backend/internal/store/profile_test.go git add backend/internal/store/profiles.go backend/internal/store/profiles_test.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
store: scope GetProfile/UpdateProfile to a user_id store: scope GetProfile/UpdateProfile to a user_id
@@ -2223,14 +2223,14 @@ EOF
## Task 10: `internal/sync.Service` becomes per-user ## Task 10: `internal/sync.Service` becomes per-user
**Files:** **Files:**
- Modify: `backend/internal/sync/service.go` - Modify: `../../../backend/internal/garmin/sync.go`
- Modify: `backend/internal/sync/service_test.go` - Modify: `../../../backend/internal/garmin/sync_test.go`
**Interfaces:** **Interfaces:**
- Consumes: every scoped store method from Tasks 4-8. - 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. - 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`: 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)`. - `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{...})`. - `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`): 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. Expected: no output.
```bash ```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' git commit -m "$(cat <<'EOF'
sync: scope Service to one user per instance sync: scope Service to one user per instance
@@ -2372,12 +2372,12 @@ EOF
## Task 11: `internal/config` additions ## Task 11: `internal/config` additions
**Files:** **Files:**
- Modify: `backend/internal/config/config.go` - Modify: `../../../backend/internal/config/envconfig.go`
**Interfaces:** **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. - 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: In the `Config` struct, change:
@@ -2427,7 +2427,7 @@ Run: `cd backend && gofmt -l internal/config/`
Expected: no output. Expected: no output.
```bash ```bash
git add backend/internal/config/config.go git add backend/internal/config/envconfig.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
config: add per-user Garmin token store root and legacy-owner bootstrap var 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` ## Task 12: User-resolution middleware + `POST /api/setup`
**Files:** **Files:**
- Create: `backend/internal/api/usercontext.go` - Create: `../../../backend/internal/api/user.go`
- Create: `backend/internal/api/setup.go` - Create: `backend/internal/api/setup.go`
- Modify: `backend/internal/api/session.go` (extend `sessionMeResponse`, `handleSessionMe`) - 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` - Create: `backend/internal/api/setup_test.go`
**Interfaces:** **Interfaces:**
- Consumes: `store.GetUserBySub`/`store.ProvisionUser` (Task 2), `auth.ClaimsFromContext` (existing). - 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`. - 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. 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/...` Run: `cd backend && go vet ./internal/api/...`
Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined. Expected: FAIL — `s.resolveUser`/`userFromContext`/`requireProvisionedUser` undefined.
- [ ] **Step 3: Write `backend/internal/api/usercontext.go`** - [ ] **Step 3: Write `../../../backend/internal/api/user.go`**
```go ```go
package api package api
@@ -2790,7 +2790,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output. Expected: no output.
```bash ```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' git commit -m "$(cat <<'EOF'
api: add user-resolution middleware and POST /api/setup 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** - [ ] **Step 4: Build everything and fix remaining call sites**
Run: `cd backend && go build ./... 2>&1 | head -50` 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 (`profiles.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** - [ ] **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 `profiles.go`, `kinds.go`, `garmin.go`, `progression.go`
**Files:** **Files:**
- Modify: `backend/internal/api/profile.go` - Modify: `backend/internal/api/profile.go`
- Modify: `backend/internal/api/kinds.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/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) - 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: Keep `authResponse`, `authStatusString` unchanged; replace `recordAuthResult` and the three handlers:
@@ -3636,7 +3636,7 @@ Run: `cd backend && gofmt -l internal/api/`
Expected: no output. Expected: no output.
```bash ```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/profiles.go backend/internal/api/kinds.go backend/internal/api/garmin.go backend/internal/api/progression.go
git commit -m "$(cat <<'EOF' git commit -m "$(cat <<'EOF'
api: scope profile, workout-kind, Garmin auth, and progression handlers to userID api: scope profile, workout-kind, Garmin auth, and progression handlers to userID

View File

@@ -0,0 +1,822 @@
# Improve First Connection Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**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 (`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.
## Global Constraints
- `internal/store/schema.sql` is edited directly, never migrated (pre-production app, see `CLAUDE.md`).
- Every store/API method touching per-user data takes an explicit `userID` and filters by it.
- Cross-user isolation is tested adversarially (two real users, real IDs), not just checked for non-collision.
- After the `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (from `backend/`).
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
- This local dev DB predates schema changes made this session (`ON DELETE CASCADE`) but was already fixed up in place. A brand-new nullable column (`garmin_connected_at TEXT`) needs no such fix-up: SQLite's idempotent-skip `applySchema` never re-applies `schema.sql` to an existing DB file, but adding a nullable column to `profile`'s Go-side handling doesn't require the on-disk table to already have it selected correctly... **actually it does**`GetProfile`'s `SELECT` will fail against the live dev DB once it lists `garmin_connected_at` in `profileColumns`, since that column doesn't exist there yet. Task 1 includes a real `ALTER TABLE` against the live dev DB (safe, additive, no rebuild needed this time) to keep manual testing working.
---
### Task 1: Persist `garmin_connected_at` in the store layer
**Files:**
- Modify: `backend/internal/store/schema.sql` (add column to the `profile` table)
- Modify: `../../../backend/internal/store/profiles.go` (`Profile` struct, `profileColumns`, `GetProfile`, new `MarkGarminConnected`)
- Modify: `../../../backend/internal/store/profiles_test.go` (new tests)
- Modify: `backend/internal/store/isolation_test.go` (new adversarial test)
- Modify: `docs/DATABASE.md` (regenerated)
- Modify (real DB, not version-controlled): `backend/geniusrun.db` — additive `ALTER TABLE`
**Interfaces:**
- Produces: `Profile.GarminConnectedAt *string` (nil until first successful Garmin auth); `func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error`.
- [ ] **Step 1: Write the failing tests**
Add to `../../../backend/internal/store/profiles_test.go`:
```go
func TestMarkGarminConnected_SetsTimestampOnceOnFirstCall(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
before, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if before.GarminConnectedAt != nil {
t.Fatalf("expected a fresh profile to have nil GarminConnectedAt, got %v", *before.GarminConnectedAt)
}
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected: %v", err)
}
afterFirst, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after first mark: %v", err)
}
if afterFirst.GarminConnectedAt == nil {
t.Fatal("expected GarminConnectedAt to be set after MarkGarminConnected")
}
firstValue := *afterFirst.GarminConnectedAt
// A second call must not change the recorded first-connection time.
if err := db.MarkGarminConnected(ctx, userID); err != nil {
t.Fatalf("MarkGarminConnected (second call): %v", err)
}
afterSecond, err := db.GetProfile(ctx, userID)
if err != nil {
t.Fatalf("GetProfile after second mark: %v", err)
}
if afterSecond.GarminConnectedAt == nil || *afterSecond.GarminConnectedAt != firstValue {
t.Fatalf("GarminConnectedAt changed on second call: first=%q second=%v", firstValue, afterSecond.GarminConnectedAt)
}
}
```
Add to `backend/internal/store/isolation_test.go`:
```go
// TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers confirms marking
// one user's Garmin connection never sets another user's flag.
func TestIsolation_MarkGarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if err := db.MarkGarminConnected(ctx, userA); err != nil {
t.Fatalf("MarkGarminConnected(a): %v", err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b): %v", err)
}
if profileB.GarminConnectedAt != nil {
t.Fatal("userA's MarkGarminConnected call leaked into userB's profile")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
Expected: FAIL — `db.MarkGarminConnected undefined` / `Profile has no field GarminConnectedAt`.
- [ ] **Step 3: Add the column to `schema.sql`**
In `backend/internal/store/schema.sql`, in the `profile` table (right after `garmin_password`), change:
```sql
garmin_password TEXT NOT NULL DEFAULT '',
rolling_window_days INTEGER NOT NULL DEFAULT 90,
```
to:
```sql
garmin_password TEXT NOT NULL DEFAULT '',
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
rolling_window_days INTEGER NOT NULL DEFAULT 90,
```
- [ ] **Step 4: Update `Profile`, `profileColumns`, and `GetProfile` in `profiles.go`**
Change the struct (add the field right after `GarminPassword`):
```go
Name string
GarminEmail string
GarminPassword string
// GarminConnectedAt is nil until this user's first successful Garmin
// authentication (see store.MarkGarminConnected) -- the login gate uses
// it, not UpdateProfile, so it's deliberately excluded from
// UpdateProfile's SET clause below and can only ever move from nil to
// set, never reset by a normal profile save.
GarminConnectedAt *string
RollingWindowDays int
```
Change `profileColumns`:
```go
const profileColumns = `
name, garmin_email, garmin_password, garmin_connected_at, rolling_window_days, backfill_horizon_days, max_heart_rate, resting_heart_rate,
hr_zone1_min_pct, hr_zone1_max_pct, hr_zone2_min_pct, hr_zone2_max_pct,
hr_zone3_min_pct, hr_zone3_max_pct, hr_zone4_min_pct, hr_zone4_max_pct,
hr_zone5_min_pct, hr_zone5_max_pct,
warmup_minutes, cooldown_minutes,
min_representative_pace_sec_per_km, min_representative_time_seconds,
pace_color, heart_rate_color, warmup_color, effort_color, recovery_color, cooldown_color,
main_line_tint_pct, background_darken_pct, target_brighten_pct,
created_at, updated_at
`
```
Change `GetProfile`'s `Scan` call (add `&p.GarminConnectedAt` right after `&p.GarminPassword`):
```go
err := db.QueryRowContext(ctx, `SELECT `+profileColumns+` FROM profile WHERE user_id = ?`, userID).Scan(
&p.Name, &p.GarminEmail, &p.GarminPassword, &p.GarminConnectedAt, &p.RollingWindowDays, &p.BackfillHorizonDays, &p.MaxHeartRate, &p.RestingHeartRate,
&p.HRZone1MinPct, &p.HRZone1MaxPct, &p.HRZone2MinPct, &p.HRZone2MaxPct,
&p.HRZone3MinPct, &p.HRZone3MaxPct, &p.HRZone4MinPct, &p.HRZone4MaxPct,
&p.HRZone5MinPct, &p.HRZone5MaxPct,
&p.WarmupMinutes, &p.CooldownMinutes,
&p.MinRepresentativePaceSecPerKm, &p.MinRepresentativeTimeSeconds,
&p.PaceColor, &p.HeartRateColor, &p.WarmupColor, &p.EffortColor, &p.RecoveryColor, &p.CooldownColor,
&p.MainLineTintPct, &p.BackgroundDarkenPct, &p.TargetBrightenPct,
&p.CreatedAt, &p.UpdatedAt,
)
```
Do **not** touch `UpdateProfile``garmin_connected_at` must stay out of its `SET`/args list so a normal Profile-page save can never reset it.
- [ ] **Step 5: Add `MarkGarminConnected`**
Append to `../../../backend/internal/store/profiles.go`:
```go
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `UPDATE profile SET garmin_connected_at = datetime('now') WHERE user_id = ? AND garmin_connected_at IS NULL`, userID)
if err != nil {
return fmt.Errorf("mark garmin connected for user %d: %w", userID, err)
}
return nil
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && go test ./internal/store/... -run 'TestMarkGarminConnected|TestIsolation_MarkGarminConnected' -v`
Expected: PASS.
- [ ] **Step 7: Run the full store test suite**
Run: `cd backend && go test ./internal/store/...`
Expected: PASS.
- [ ] **Step 8: Regenerate `docs/DATABASE.md`**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` shows the new `garmin_connected_at` column in `profile`.
- [ ] **Step 9: Additively update the live local dev DB**
The real `backend/geniusrun.db` predates this column (schema.sql changes never retrofit an existing DB file — see `applySchema`'s idempotent skip in `db.go`). Unlike the earlier `ON DELETE CASCADE` fix, this is purely additive (a new nullable column), so a plain `ALTER TABLE` is sufficient — no rebuild-and-copy dance needed:
```bash
sqlite3 backend/geniusrun.db "ALTER TABLE profile ADD COLUMN garmin_connected_at TEXT;"
sqlite3 backend/geniusrun.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='profile';" | grep garmin_connected_at
```
Confirm the backend isn't running before touching the file (`ps aux | grep geniusrund`), same care as before.
- [ ] **Step 10: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/profiles.go backend/internal/store/profiles_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
git commit -m "feat(store): persist garmin_connected_at, set once on first successful auth"
```
---
### Task 2: Wire `MarkGarminConnected` into the auth handlers and expose it via session/me
**Files:**
- 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)
**Interfaces:**
- Consumes: `store.DB.MarkGarminConnected(ctx, userID) error` (Task 1); `store.DB.GetProfile(ctx, userID) (store.Profile, error)` (existing).
- Produces: `sessionMeResponse.GarminConnected bool` (JSON `garmin_connected`).
- [ ] **Step 1: Write the failing tests**
Append to `backend/internal/api/api_test.go`:
```go
func TestSessionMe_ReportsGarminConnectedAfterSuccessfulAuth(t *testing.T) {
s, _, _ := newTestServer(t)
router := s.Router()
rec := doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var before sessionMeResponse
unmarshalBody(t, rec, &before)
if before.GarminConnected {
t.Fatal("expected a fresh account to report garmin_connected=false")
}
rec = doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // mock.Client defaults to AuthSuccess
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var after sessionMeResponse
unmarshalBody(t, rec, &after)
if !after.GarminConnected {
t.Fatal("expected garmin_connected=true after a successful auth")
}
}
func TestSessionMe_GarminConnectedStaysFalseOnMFARequiredOrFailed(t *testing.T) {
s, _, userID := newTestServer(t)
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
mockClient.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSON(t, router, http.MethodGet, "/api/session/me", nil)
var me sessionMeResponse
unmarshalBody(t, rec, &me)
if me.GarminConnected {
t.Fatal("expected garmin_connected=false after mfa_required")
}
}
```
Append to `backend/internal/api/isolation_test.go`:
```go
// TestIsolation_GarminConnectedNeverLeaksAcrossUsers confirms one user's
// successful Garmin auth never flips garmin_connected for another user.
func TestIsolation_GarminConnectedNeverLeaksAcrossUsers(t *testing.T) {
s, db, _ := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
rec = doJSONAs(t, router, "user-b", http.MethodGet, "/api/session/me", nil)
var meB sessionMeResponse
unmarshalBody(t, rec, &meB)
if meB.GarminConnected {
t.Fatal("userA's successful Garmin auth leaked into userB's garmin_connected flag")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
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 `garmin.go`**
Change the imports:
```go
import (
"context"
"encoding/json"
"log"
"net/http"
"geniusrun/backend/internal/garmin"
)
```
Change `recordAuthResult` and both call sites:
```go
// recordAuthResult updates the in-memory auth status/message for userID,
// and -- on a successful authentication -- persists that this account has
// connected to Garmin at least once (store.MarkGarminConnected), which is
// what the login gate actually checks (session cookie's in-memory auth
// status resets on every backend restart; this doesn't).
func (s *Server) recordAuthResult(ctx context.Context, userID int64, res garmin.AuthResult) {
s.mu.Lock()
s.userAuthStatus[userID] = res.Status
s.userAuthMessage[userID] = res.Message
s.mu.Unlock()
if res.Status == garmin.AuthSuccess {
if err := s.DB.MarkGarminConnected(ctx, userID); err != nil {
log.Printf("api: mark garmin connected for user %d: %v", userID, err)
}
}
}
```
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`**
Change:
```go
type sessionMeResponse struct {
Name string `json:"name"`
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
}
```
to:
```go
type sessionMeResponse struct {
Name string `json:"name"`
Email string `json:"email"`
HasProfile bool `json:"has_profile"`
DisplayName string `json:"display_name,omitempty"`
GarminConnected bool `json:"garmin_connected"`
}
```
Change `handleSessionMe`:
```go
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
// Unreachable in practice -- RequireSession already 401s before this
// handler runs -- but fail closed rather than panic if that ever changes.
writeError(w, http.StatusUnauthorized, "not authenticated")
return
}
resp := sessionMeResponse{Name: claims.Name, Email: claims.Email}
if u, found := userFromContext(r.Context()); found {
resp.HasProfile = true
resp.DisplayName = u.DisplayName
profile, err := s.DB.GetProfile(r.Context(), u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
resp.GarminConnected = profile.GarminConnectedAt != nil
}
writeJSON(w, http.StatusOK, resp)
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -run 'TestSessionMe_ReportsGarminConnected|TestSessionMe_GarminConnectedStaysFalse|TestIsolation_GarminConnected' -v`
Expected: PASS.
- [ ] **Step 6: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 7: Commit**
```bash
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"
```
---
### Task 3: Frontend — three-way login gate and the new ConnectGarmin screen
**Files:**
- Modify: `frontend/src/types/api.ts` (`SessionInfo`)
- Create: `frontend/src/ConnectGarmin.tsx`
- Modify: `frontend/src/CreateProfile.tsx` (add Log out link)
- Modify: `frontend/src/CreateProfile.css` (one new class, shared with `ConnectGarmin.tsx`)
- Modify: `frontend/src/LoginGate.tsx` (three-way fork)
**Interfaces:**
- Consumes: `api.getProfile()`, `api.updateProfile(profile)`, `api.login()`, `api.submitMFA(code)` (all existing, unchanged signatures); `SessionInfo.garmin_connected: boolean` (new).
- Produces: `ConnectGarmin({ onConnected: () => void })`, a React component.
- [ ] **Step 1: Add `garmin_connected` to `SessionInfo`**
In `frontend/src/types/api.ts`, change:
```ts
export interface SessionInfo {
name: string;
email: string;
has_profile: boolean;
display_name?: string;
}
```
to:
```ts
export interface SessionInfo {
name: string;
email: string;
has_profile: boolean;
display_name?: string;
garmin_connected: boolean;
}
```
- [ ] **Step 2: Add a shared status-message class to `CreateProfile.css`**
Append to `frontend/src/CreateProfile.css`:
```css
.create-profile-message {
margin: 0;
color: #9ca3af;
}
.create-profile-mfa {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
```
- [ ] **Step 3: Create `frontend/src/ConnectGarmin.tsx`**
```tsx
import { useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./CreateProfile.css";
import type { AuthResponse } from "./types/api";
// Shown after CreateProfile (or on any later login, until it succeeds --
// see LoginGate) since connecting Garmin is mandatory: the account exists
// but this is the last gate before the main app. Deliberately not a reuse
// of GarminConnection.tsx (the Profile page's version), which also renders
// Sync now/Disconnect/Reset all -- none of which make sense here.
export function ConnectGarmin({ onConnected }: { onConnected: () => void }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [auth, setAuth] = useState<AuthResponse | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function connect(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !password) {
setError("Please enter your Garmin email and password.");
return;
}
setBusy(true);
setError(null);
try {
const profile = await api.getProfile();
await api.updateProfile({ ...profile, GarminEmail: email.trim(), GarminPassword: password });
setAuth(await api.login());
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
} finally {
setBusy(false);
}
}
async function submitMFA() {
if (!code.trim()) return;
setBusy(true);
setError(null);
try {
setAuth(await api.submitMFA(code.trim()));
setCode("");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
} finally {
setBusy(false);
}
}
const status = auth?.status;
return (
<div className="create-profile">
<h1>🧞 Connect your Garmin account</h1>
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
{status !== "authenticated" && status !== "mfa_required" && (
<form onSubmit={connect}>
<input
type="text"
placeholder="Garmin email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={busy}
autoFocus
/>
<input
type="password"
placeholder="Garmin password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={busy}
/>
<button type="submit" disabled={busy}>
{busy ? "Connecting…" : "Connect"}
</button>
</form>
)}
{status === "mfa_required" && (
<div className="create-profile-mfa">
<input
placeholder="MFA code"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
disabled={busy}
autoFocus
/>
<button type="button" disabled={busy} onClick={submitMFA}>
Submit code
</button>
</div>
)}
{status === "authenticated" && (
<>
<p className="create-profile-message">Connected to Garmin.</p>
<button type="button" onClick={onConnected}>
Continue to geniusrun
</button>
</>
)}
{auth?.message && status !== "authenticated" && <p className="create-profile-message">{auth.message}</p>}
{error && <p className="create-profile-error">{error}</p>}
<form method="post" action={`${BASE_URL}/api/session/logout`}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
);
}
```
- [ ] **Step 4: Add a Log out link to `CreateProfile.tsx`**
Change:
```tsx
{error && <p className="create-profile-error">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Continue"}
</button>
</form>
</div>
);
}
```
to:
```tsx
{error && <p className="create-profile-error">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Continue"}
</button>
</form>
<form method="post" action={`${BASE_URL}/api/session/logout`}>
<button type="submit" className="logout-link">
Log out
</button>
</form>
</div>
);
}
```
And change the import line at the top of `CreateProfile.tsx`:
```tsx
import { api } from "./api/client";
```
to:
```tsx
import { api, BASE_URL } from "./api/client";
```
- [ ] **Step 5: Wire the three-way fork into `LoginGate.tsx`**
Change the imports:
```tsx
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
import { CreateProfile } from "./CreateProfile";
import type { SessionInfo } from "./types/api";
```
to:
```tsx
import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client";
import "./LoginGate.css";
import App from "./App";
import { ConnectGarmin } from "./ConnectGarmin";
import { CreateProfile } from "./CreateProfile";
import type { SessionInfo } from "./types/api";
```
Change the final part of the component body:
```tsx
if (!session!.has_profile) {
return (
<CreateProfile
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
return <App session={session!} />;
}
```
to:
```tsx
if (!session!.has_profile) {
return (
<CreateProfile
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/>
);
}
// Connecting Garmin is mandatory, re-checked on every login (not just
// right after signup) -- see docs/superpowers/specs/2026-07-26-improve-first-connection-design.md
// for why this can't be a purely in-session check.
if (!session!.garmin_connected) {
return <ConnectGarmin onConnected={() => setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />;
}
return <App session={session!} />;
}
```
Also update the doc comment above the `LoginGate` function (currently describes only a two-way fork) -- change:
```tsx
// Wraps App: on mount, asks the backend whether this browser already has a
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
// this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees CreateProfile instead of App until it submits one.
```
to:
```tsx
// Wraps App: on mount, asks the backend whether this browser already has a
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
// this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees CreateProfile instead of App until it submits one. A third fork,
// once provisioned, is whether Garmin has ever been successfully connected
// (session.garmin_connected) -- mandatory, and re-checked on every login,
// not just immediately after signup.
```
- [ ] **Step 6: Build and lint**
Run: `cd frontend && npm run build && npm run lint`
Expected: build succeeds; lint reports no new warnings beyond the 3 pre-existing `PaceField.tsx` ones.
- [ ] **Step 7: Manual browser verification**
With the backend (`cd backend && ./start.sh`) and frontend (`cd frontend && ./start.sh`) running:
1. Delete your profile (or use a fresh OIDC account) so you land on `CreateProfile`.
2. Submit a display name — confirm you're immediately routed to `ConnectGarmin`, not the main app.
3. Try an obviously wrong Garmin password — confirm the error shows and the form stays editable.
4. Enter correct credentials (resolve MFA if prompted) — confirm "Connected to Garmin." appears with a "Continue to geniusrun" button, and clicking it lands in the main app.
5. Log out, log back in with the same account — confirm you land straight in the app (not back through `ConnectGarmin`), proving `garmin_connected_at` persisted.
6. On the `ConnectGarmin` screen specifically, confirm the "Log out" link works.
- [ ] **Step 8: Commit**
```bash
git add frontend/src/types/api.ts frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.tsx frontend/src/CreateProfile.css frontend/src/LoginGate.tsx
git commit -m "feat(onboarding): add mandatory ConnectGarmin gate to first login"
```
---
### Task 4: Final verification
**Files:** none (verification + cleanup only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Full frontend check**
Run: `cd frontend && npm run build && npm run lint`
Expected: both succeed, no new lint warnings.
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
Expected: no output from `git status` (already committed in Task 1, unchanged since).
- [ ] **Step 4: Update `docs/IDEAS.md`**
Remove the now-implemented line from the Backlog section:
```
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
```
- [ ] **Step 5: Commit**
```bash
git add docs/IDEAS.md
git commit -m "docs: remove improve-first-connection-page from IDEAS backlog (implemented)"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,766 @@
# Profile Deletion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let a signed-in geniusrun user permanently delete their entire account (Garmin credentials, custom rules, every synced activity) from a "Danger zone" button on the Profile page, and log them out afterward.
**Architecture:** Add `ON DELETE CASCADE` to every FK pointing at `users(id)` (and `workout_kinds(id)`) in `schema.sql`, so a single `DELETE FROM users` removes everything. Wrap that in a new `store.DeleteUser`, expose it via `DELETE /api/profile`, and have that handler also tear down the deleted user's cached Garmin subprocess/client and on-disk token-store directory. The frontend gates the call behind a type-`DELETE`-to-confirm UI, then performs a real POST navigation to the existing `/api/session/logout` route (the same one the header's Log out button already uses) so the OIDC session ends too.
**Tech Stack:** Go (`net/http`, `database/sql` via `modernc.org/sqlite`, chi router), React + TypeScript (Vite), no frontend test framework.
## Global Constraints
- `internal/store/schema.sql` is edited directly, never migrated — this is a pre-production app (see `CLAUDE.md`).
- Every store/API method that touches per-user data takes an explicit `userID` and must filter/scope by it — the entire cross-user isolation boundary depends on this (see `CLAUDE.md`'s Authentication section).
- Cross-user isolation must be tested adversarially (two real users, real IDs), not just checked for non-collision — matches `internal/store/isolation_test.go` / `internal/api/isolation_test.go` convention.
- After any `schema.sql` change, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema` (run from `backend/`).
- `gofmt -l .` must report nothing before committing; `go vet ./...` and `go build ./...` must pass.
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
---
### Task 1: Cascading deletes in the store layer
**Files:**
- Modify: `backend/internal/store/schema.sql:29,85,103,121,222,232`
- Modify: `backend/internal/store/users.go` (add `DeleteUser`)
- Modify: `backend/internal/store/users_test.go` (add `TestDeleteUser_RemovesUserAndCascadesEverything`)
- Modify: `backend/internal/store/isolation_test.go` (add `TestIsolation_DeleteUserLeavesOtherUsersDataIntact`)
- Modify: `docs/DATABASE.md` (regenerated, not hand-edited)
**Interfaces:**
- Produces: `func (db *DB) DeleteUser(ctx context.Context, userID int64) error` — deletes the `users` row for `userID`; every owned row (profile, workout_kinds, workout_type_paces, activities, laps, activity_samples, kind_assignments, sync_state, sync_runs) cascades away via the schema FKs added in this task.
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/store/users_test.go`:
```go
func TestDeleteUser_RemovesUserAndCascadesEverything(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "delete-me", "Delete Me")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
activityID, err := db.UpsertActivity(ctx, userID, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"})
if err != nil {
t.Fatalf("UpsertActivity: %v", err)
}
kindID, err := db.CreateWorkoutKind(ctx, userID, WorkoutKind{Name: "Test Delete Kind", RuleJSON: `{"match":"all","conditions":[]}`, IsActive: true})
if err != nil {
t.Fatalf("CreateWorkoutKind: %v", err)
}
if err := db.ReplaceLaps(ctx, userID, activityID, []Lap{{LapIndex: 1}}); err != nil {
t.Fatalf("ReplaceLaps: %v", err)
}
if err := db.ReplaceActivitySamples(ctx, userID, activityID, []Sample{{ElapsedSeconds: 0}}); err != nil {
t.Fatalf("ReplaceActivitySamples: %v", err)
}
if _, err := db.InsertKindAssignment(ctx, userID, KindAssignment{
ActivityID: activityID, WorkoutKindID: &kindID, AssignmentSource: AssignmentSourceRuleEngine,
Status: AssignmentStatusAssigned, CandidateKindsJSON: "[]",
}); err != nil {
t.Fatalf("InsertKindAssignment: %v", err)
}
minPace := 300.0
if err := db.UpdateWorkoutTypePace(ctx, userID, WorkoutTypePace{WorkoutKindID: kindID, PaceMinSecPerKm: &minPace}); err != nil {
t.Fatalf("UpdateWorkoutTypePace: %v", err)
}
if err := db.UpdateSyncState(ctx, userID, "2020-01-01", true); err != nil {
t.Fatalf("UpdateSyncState: %v", err)
}
if _, err := db.StartSyncRun(ctx, userID, SyncKindBackfill); err != nil {
t.Fatalf("StartSyncRun: %v", err)
}
if err := db.DeleteUser(ctx, userID); err != nil {
t.Fatalf("DeleteUser: %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "delete-me"); err != nil || found {
t.Fatalf("expected user gone after DeleteUser, found=%v err=%v", found, err)
}
checks := []struct {
query string
arg int64
}{
{`SELECT COUNT(*) FROM profile WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_kinds WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM workout_type_paces WHERE workout_kind_id = ?`, kindID},
{`SELECT COUNT(*) FROM activities WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM laps WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM activity_samples WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM kind_assignments WHERE activity_id = ?`, activityID},
{`SELECT COUNT(*) FROM sync_state WHERE user_id = ?`, userID},
{`SELECT COUNT(*) FROM sync_runs WHERE user_id = ?`, userID},
}
for _, c := range checks {
var count int
if err := db.QueryRowContext(ctx, c.query, c.arg).Scan(&count); err != nil {
t.Fatalf("count query %q: %v", c.query, err)
}
if count != 0 {
t.Errorf("query %q: got %d rows, want 0 after DeleteUser", c.query, count)
}
}
}
```
Add to `backend/internal/store/isolation_test.go`:
```go
// TestIsolation_DeleteUserLeavesOtherUsersDataIntact confirms deleting one
// user's account never touches another user's profile, taxonomy, or
// activities, even though DeleteUser is a single blunt DELETE FROM users.
func TestIsolation_DeleteUserLeavesOtherUsersDataIntact(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userA, err := db.ProvisionUser(ctx, "sub-a", "A")
if err != nil {
t.Fatalf("ProvisionUser(a): %v", err)
}
userB, err := db.ProvisionUser(ctx, "sub-b", "B")
if err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
if _, err := db.UpsertActivity(ctx, userB, Activity{GarminActivityID: 1, StartTimeUTC: "2026-07-11 06:00:00", RawJSON: "{}"}); err != nil {
t.Fatalf("UpsertActivity(b): %v", err)
}
if err := db.DeleteUser(ctx, userA); err != nil {
t.Fatalf("DeleteUser(a): %v", err)
}
if _, found, err := db.GetUserBySub(ctx, "sub-b"); err != nil || !found {
t.Fatalf("expected userB to survive userA's deletion, found=%v err=%v", found, err)
}
profileB, err := db.GetProfile(ctx, userB)
if err != nil {
t.Fatalf("GetProfile(b) after deleting a: %v", err)
}
if profileB.Name != "B" {
t.Errorf("userB's profile changed after deleting userA: %+v", profileB)
}
kindsB, err := db.ListWorkoutKinds(ctx, userB, false)
if err != nil || len(kindsB) != 8 {
t.Fatalf("userB's taxonomy affected by deleting userA: len=%d err=%v", len(kindsB), err)
}
activitiesB, err := db.ListActivities(ctx, userB, ActivityFilter{})
if err != nil || len(activitiesB) != 1 {
t.Fatalf("userB's activities affected by deleting userA: len=%d err=%v", len(activitiesB), err)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: FAIL — `db.DeleteUser undefined (type *DB has no field or method DeleteUser)`.
- [ ] **Step 3: Add `ON DELETE CASCADE` to schema.sql**
In `backend/internal/store/schema.sql`, change these six lines (each currently ends `REFERENCES users(id),` or `REFERENCES workout_kinds(id),`):
Line 29 (`profile.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 85 (`workout_kinds.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 103 (`workout_type_paces.workout_kind_id`), from:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id),
```
to:
```sql
workout_kind_id INTEGER PRIMARY KEY REFERENCES workout_kinds(id) ON DELETE CASCADE,
```
Line 121 (`activities.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 222 (`sync_state.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
Line 232 (`sync_runs.user_id`), from:
```sql
user_id INTEGER NOT NULL REFERENCES users(id),
```
to:
```sql
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
```
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities(id)` — no change needed there. SQLite's `foreign_keys` pragma is already on for every connection (`db.go`), so these cascades (including the two-level `users → workout_kinds → workout_type_paces` chain) take effect immediately, no code change required for the pragma itself.
- [ ] **Step 4: Add `DeleteUser` to `backend/internal/store/users.go`**
Append after `ProvisionUser` (end of file):
```go
// DeleteUser permanently deletes userID's account. Every row that belongs
// to it -- profile, workout kinds (and their paces), activities (and their
// laps/samples/kind_assignments), sync_state, and sync_runs -- cascades
// away via the ON DELETE CASCADE foreign keys in schema.sql, so this is a
// single statement rather than per-table deletes. Irreversible; the API
// layer gates this behind a UI confirmation (see
// docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
if _, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID); err != nil {
return fmt.Errorf("delete user %d: %w", userID, err)
}
return nil
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `cd backend && go test ./internal/store/... -run 'TestDeleteUser|TestIsolation_DeleteUser' -v`
Expected: PASS.
- [ ] **Step 6: Run the full store test suite**
Run: `cd backend && go test ./internal/store/...`
Expected: PASS (confirms the new `ON DELETE CASCADE` clauses didn't break `ResetAllSyncedData` or any other existing behavior).
- [ ] **Step 7: Regenerate `docs/DATABASE.md`**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` updates to show `ON DELETE CASCADE` on the six changed columns.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/users.go backend/internal/store/users_test.go backend/internal/store/isolation_test.go docs/DATABASE.md
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 `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)
**Interfaces:**
- Consumes: `store.DB.DeleteUser(ctx, userID) error` (Task 1); `garmin.Client.Close() error` and `.UpdateCredentials` (existing); `Server.userGarmin/userSync/userAuthStatus/userAuthMessage/userSyncRunning map[int64]...` and `Server.mu sync.Mutex` (existing fields); `Server.GarminBase garmin.Config` (existing, has `.TokenStorePath string`).
- Produces: `func (s *Server) removeGarminClient(userID int64)` — used only within this task's handler, not exported further. `DELETE /api/profile` route → `handleDeleteProfile`, responding `204` on success, `409` if a sync is running for that user.
- [ ] **Step 1: Write the failing tests**
Add `"os"` to `backend/internal/api/api_test.go`'s import block (needed for Step 1's token-store test):
```go
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"geniusrun/backend/internal/auth"
authmock "geniusrun/backend/internal/auth/mock"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/garmin/mock"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Append to `backend/internal/api/api_test.go`:
```go
func TestDeleteProfile_DeletesUserAndTerminatesGarminClient(t *testing.T) {
s, db, userID := newTestServer(t)
router := s.Router()
// Force the per-user Garmin client to be built and cached.
rec := doJSON(t, router, http.MethodPost, "/api/auth/login", nil)
if rec.Code != http.StatusOK {
t.Fatalf("auth/login status = %d, body = %s", rec.Code, rec.Body.String())
}
client, err := s.garminFor(context.Background(), userID)
if err != nil {
t.Fatalf("garminFor: %v", err)
}
mockClient, ok := client.(*mock.Client)
if !ok {
t.Fatalf("expected *mock.Client, got %T", client)
}
rec = doJSON(t, router, http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || found {
t.Fatalf("expected user gone after delete, found=%v err=%v", found, err)
}
if !mockClient.ClosedCalled {
t.Error("expected the cached garmin client to be Close()d on profile deletion")
}
}
func TestDeleteProfile_RejectsWhileSyncInProgress(t *testing.T) {
s, db, userID := newTestServer(t)
s.mu.Lock()
s.userSyncRunning[userID] = true
s.mu.Unlock()
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(context.Background(), "test-user"); err != nil || !found {
t.Fatalf("expected user to survive a rejected delete, found=%v err=%v", found, err)
}
}
func TestDeleteProfile_RemovesTokenStoreDirectory(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
userID, err := db.ProvisionUser(context.Background(), "test-user", "Test User")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
tokenStoreRoot := t.TempDir()
userTokenDir := filepath.Join(tokenStoreRoot, strconv.FormatInt(userID, 10))
if err := os.MkdirAll(userTokenDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join(userTokenDir, "session.json"), []byte("{}"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
m := &mock.Client{}
garminFactory := func(garmin.Config) garmin.Client { return m }
s := NewServer(db, garminFactory, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
rec := doJSON(t, s.Router(), http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(userTokenDir); !os.IsNotExist(err) {
t.Fatalf("expected token store dir %q to be removed, stat err = %v", userTokenDir, err)
}
}
```
Append to `backend/internal/api/isolation_test.go`:
```go
// TestIsolation_DeleteProfileOnlyDeletesOwnAccount confirms one user
// deleting their own profile never touches another user's account, even
// though DeleteUser is keyed purely by the session-resolved userID.
func TestIsolation_DeleteProfileOnlyDeletesOwnAccount(t *testing.T) {
s, db, userA := newTestServer(t) // provisions "test-user" (userA)
if _, err := db.ProvisionUser(newCtx(), "user-b", "B"); err != nil {
t.Fatalf("ProvisionUser(b): %v", err)
}
router := s.Router()
rec := doJSONAs(t, router, "user-b", http.MethodDelete, "/api/profile", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("userB delete status = %d, body = %s", rec.Code, rec.Body.String())
}
if _, found, err := db.GetUserBySub(newCtx(), "user-b"); err != nil || found {
t.Fatalf("expected userB gone after their own delete, found=%v err=%v", found, err)
}
u, found, err := db.GetUserBySub(newCtx(), "test-user")
if err != nil || !found || u.ID != userA {
t.Fatalf("expected userA to survive userB's deletion, found=%v err=%v id=%d want=%d", found, err, u.ID, userA)
}
rec = doJSON(t, router, http.MethodGet, "/api/profile", nil) // as userA
if rec.Code != http.StatusOK {
t.Fatalf("userA profile status after userB deleted themselves = %d, body = %s", rec.Code, rec.Body.String())
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
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 `removeUserClient` to `server.go`**
Change the import block (add `"os"` between `"net/http"` and `"path/filepath"`):
```go
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Insert this new method right after `syncFor` (i.e. between the existing `syncFor` closing brace and the `RunIncrementalSyncForAllUsers` doc comment):
```go
// removeGarminClient drops userID's cached garmin.Client/sync.Service (if
// any) and every other per-user in-memory entry for userID, terminating the
// client's subprocess and best-effort removing its on-disk token-store
// directory. Called when a user's account has just been deleted from the
// DB, so nothing in memory keeps referencing a userID that no longer
// exists.
func (s *Server) removeGarminClient(userID int64) {
s.mu.Lock()
client, ok := s.userGarmin[userID]
delete(s.userGarmin, userID)
delete(s.userSync, userID)
delete(s.userAuthStatus, userID)
delete(s.userAuthMessage, userID)
delete(s.userSyncRunning, userID)
s.mu.Unlock()
if ok {
if err := client.Close(); err != nil {
log.Printf("api: close garmin client for deleted user %d: %v", userID, err)
}
}
if s.GarminBase.TokenStorePath == "" {
return
}
tokenStoreDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
if err := os.RemoveAll(tokenStoreDir); err != nil {
log.Printf("api: remove token store dir for deleted user %d: %v", userID, err)
}
}
```
- [ ] **Step 4: Register the route in `Router()`**
In `server.go`, change:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
})
```
to:
```go
r.Route("/profile", func(r chi.Router) {
r.Get("/", s.handleGetProfile)
r.Put("/", s.handleUpdateProfile)
r.Delete("/", s.handleDeleteProfile)
})
```
- [ ] **Step 5: Add `handleDeleteProfile` to `profiles.go`**
Append to `backend/internal/api/profile.go`:
```go
// handleDeleteProfile permanently deletes the signed-in user's entire
// geniusrun account (profile, workout kinds/paces, activities and
// everything under them, sync state/runs -- see schema.sql's ON DELETE
// CASCADE from users(id)) and tears down their cached Garmin client and
// token-store directory. It does not touch the session cookie itself --
// the frontend follows a successful call with a real logout navigation
// (see docs/superpowers/specs/2026-07-26-profile-deletion-design.md).
func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) {
userID := userIDFromContext(r.Context())
s.mu.Lock()
inProgress := s.userSyncRunning[userID]
s.mu.Unlock()
if inProgress {
writeError(w, http.StatusConflict, "a sync is in progress for this account; wait for it to finish before deleting your profile")
return
}
if err := s.DB.DeleteUser(r.Context(), userID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeGarminClient(userID)
w.WriteHeader(http.StatusNoContent)
}
```
- [ ] **Step 6: Run tests to verify they pass**
Run: `cd backend && go test ./internal/api/... -run 'TestDeleteProfile|TestIsolation_DeleteProfile' -v`
Expected: PASS.
- [ ] **Step 7: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` prints nothing; `go build`/`go vet`/`go test` all succeed.
- [ ] **Step 8: Commit**
```bash
git add backend/internal/api/server.go backend/internal/api/profiles.go backend/internal/api/api_test.go backend/internal/api/isolation_test.go
git commit -m "feat(api): add DELETE /api/profile with Garmin client/tokenstore teardown"
```
---
### Task 3: Frontend Danger zone UI
**Files:**
- Modify: `frontend/src/api/client.ts` (add `deleteProfile`)
- Modify: `frontend/src/pages/Profile.tsx` (add Danger zone fieldset + confirm flow)
- Modify: `frontend/src/App.css` (small `.danger-zone-confirm` rule)
**Interfaces:**
- Consumes: `DELETE /api/profile` (Task 2, returns `204` or throws via the existing `request()` helper's non-2xx handling); `BASE_URL` (already exported from `client.ts`).
- Produces: `api.deleteProfile(): Promise<void>`, used only from `Profile.tsx`.
- [ ] **Step 1: Add `deleteProfile` to the API client**
In `frontend/src/api/client.ts`, in the `// Profile` section, change:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
```
to:
```ts
// Profile
getProfile: () => request<Profile>("/api/profile"),
updateProfile: (profile: Profile) =>
request<Profile>("/api/profile", { method: "PUT", body: JSON.stringify(profile) }),
// Permanently deletes the signed-in user's entire account. The caller is
// responsible for following a successful call with a real logout
// navigation -- this does not touch the session cookie (see Profile.tsx).
deleteProfile: () => request<void>("/api/profile", { method: "DELETE" }),
```
- [ ] **Step 2: Add the Danger zone section to `Profile.tsx`**
Change the import line:
```tsx
import { api } from "../api/client";
```
to:
```tsx
import { api, BASE_URL } from "../api/client";
```
Add these three state variables inside `Profile`, right after the existing `saveTimeoutRef` declaration:
```tsx
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [deleting, setDeleting] = useState(false);
```
Add this function right after `flushPendingSave`:
```tsx
async function deleteAccount() {
setDeleting(true);
setError(null);
try {
await api.deleteProfile();
// Deletion doesn't clear the session cookie -- follow with a real
// logout navigation (must be a POST, same as the header's Log out
// button in App.tsx) so Keycloak's own SSO session ends too, not just
// geniusrun's local one.
const form = document.createElement("form");
form.method = "post";
form.action = `${BASE_URL}/api/session/logout`;
document.body.appendChild(form);
form.submit();
} catch (e) {
setError(String(e));
setDeleting(false);
}
}
```
Add this fieldset right after `<TrainingTypesCard />` (still inside the closing `</div>` of `.profile-page`):
```tsx
<fieldset className="kind-editor danger-zone">
<legend>Danger zone</legend>
{!deleteConfirmOpen ? (
<button type="button" className="button-danger" onClick={() => setDeleteConfirmOpen(true)}>
Delete profile
</button>
) : (
<div className="danger-zone-confirm">
<p>
This permanently deletes your account -- Garmin credentials, every custom rule, and every synced
activity -- and logs you out. This cannot be undone.
</p>
<label>
Type DELETE to confirm
<input
type="text"
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
disabled={deleting}
/>
</label>
<div className="controls">
<button
type="button"
disabled={deleting}
onClick={() => {
setDeleteConfirmOpen(false);
setDeleteConfirmText("");
}}
>
Cancel
</button>
<button
type="button"
className="button-danger"
disabled={deleting || deleteConfirmText !== "DELETE"}
onClick={deleteAccount}
>
Permanently delete
</button>
</div>
</div>
)}
</fieldset>
```
- [ ] **Step 3: Add the confirmation block's CSS**
In `frontend/src/App.css`, add right after the `.button-danger:hover:not(:disabled)` rule:
```css
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
```
- [ ] **Step 4: Build and lint**
Run: `cd frontend && npm run build && npm run lint`
Expected: build succeeds; lint reports no new warnings (the 3 pre-existing `PaceField.tsx` `only-export-components` warnings are unrelated and expected to remain).
- [ ] **Step 5: Manual browser verification**
With the backend running (`cd backend && ./start.sh`) and frontend dev server running (`cd frontend && ./start.sh`), in a browser:
1. Log in, go to the Profile page, scroll to "Danger zone".
2. Click "Delete profile" — the confirm block appears; "Permanently delete" is disabled.
3. Type `delete` (lowercase) — button stays disabled. Type `DELETE` — button enables.
4. Click "Cancel" — block collapses, typed text is cleared if reopened.
5. Reopen, type `DELETE`, click "Permanently delete" — expect a full-page navigation ending on the login screen (Keycloak or geniusrun's own login gate, matching what the existing "Log out" button already does).
6. Log back in with the same account — expect the "Create your profile" screen (`CreateProfile.tsx`), confirming the account was actually deleted, not just logged out.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/api/client.ts frontend/src/pages/Profile.tsx frontend/src/App.css
git commit -m "feat(profile): add Danger zone account deletion UI"
```
---
### Task 4: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Full frontend check**
Run: `cd frontend && npm run build && npm run lint`
Expected: both succeed, no new lint warnings.
- [ ] **Step 3: Confirm `docs/DATABASE.md` is current**
Run: `cd backend && go run ./cmd/dumpschema && git status --short docs/DATABASE.md`
Expected: no output from `git status` (already committed in Task 1, and hasn't drifted since).
- [ ] **Step 4: Update `docs/IDEAS.md`**
Remove the now-implemented line from `docs/IDEAS.md`'s Backlog section:
```
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
```
- [ ] **Step 5: Commit**
```bash
git add docs/IDEAS.md
git commit -m "docs: remove profile deletion from IDEAS backlog (implemented)"
```

View File

@@ -0,0 +1,961 @@
# Structured JSON Logging Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**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 `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 `execute`, 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).
## Global Constraints
- Existing `log.Printf`/`log.Fatalf` call sites are untouched — they keep going to stderr, unstructured, exactly as today.
- Garmin credentials must never be logged. Confirmed safe: `authenticate`/`complete_mfa`/`call` wire params never carry email/password (only env vars at subprocess spawn do) — logging `params` in full is safe everywhere in `execute`.
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
---
### Task 1: `internal/applog` — JSON logger + context helpers
**Files:**
- Create: `backend/internal/applog/applog.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()`).
- [ ] **Step 1: Write the failing tests**
Create `backend/internal/applog/applog_test.go`:
```go
package applog
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
)
func TestNewLogger_FiltersBelowConfiguredLevel(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("warn", &buf)
logger.Info("should be dropped")
if buf.Len() != 0 {
t.Fatalf("expected no output for an Info message under a warn-level logger, got %q", buf.String())
}
logger.Warn("should appear")
if !strings.Contains(buf.String(), "should appear") {
t.Fatalf("expected the Warn message in output, got %q", buf.String())
}
}
func TestNewLogger_WritesValidJSON(t *testing.T) {
var buf bytes.Buffer
logger := NewLogger("info", &buf)
logger.Info("hello", "key", "value")
var decoded map[string]any
if err := json.Unmarshal(buf.Bytes(), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v (%q)", err, buf.String())
}
if decoded["msg"] != "hello" || decoded["key"] != "value" {
t.Errorf("decoded = %+v, want msg=hello key=value", decoded)
}
}
func TestWithLogger_FromContext_RoundTrip(t *testing.T) {
logger := slog.New(slog.NewJSONHandler(&bytes.Buffer{}, nil))
ctx := WithLogger(context.Background(), logger)
if got := FromContext(ctx); got != logger {
t.Errorf("FromContext returned a different logger than what was stashed")
}
}
func TestFromContext_DefaultsWhenNoneSet(t *testing.T) {
if got := FromContext(context.Background()); got == nil {
t.Fatal("FromContext on a bare context returned nil, want slog.Default()")
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/applog/... -v`
Expected: FAIL — package `internal/applog` doesn't exist yet (build failure).
- [ ] **Step 3: Create `../../../backend/internal/log/log.go`**
```go
// Package applog provides geniusrun's structured JSON logging: a
// log/slog-based logger writing to stdout, plus context helpers so a
// logger enriched in one layer (e.g. internal/api's HTTP middleware,
// attaching a request_id) is picked up by another (e.g. internal/garmin's
// wrapper-call logging) without either package depending on the other.
package applog
import (
"context"
"io"
"log/slog"
"strings"
)
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive; anything else
// defaults to info).
func NewLogger(level string, w io.Writer) *slog.Logger {
return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: parseLevel(level)}))
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
type ctxKey struct{}
// WithLogger returns a context carrying logger, retrievable via FromContext.
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, logger)
}
// FromContext returns the logger stashed by WithLogger, or slog.Default()
// if none was -- callers (e.g. the background incremental-sync loop, or
// tests that don't bother injecting one) always get a working logger,
// never nil.
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && go test ./internal/applog/... -v`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add backend/internal/applog
git commit -m "feat(applog): add JSON logger and context helpers"
```
---
### Task 2: HTTP access log middleware
**Files:**
- 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` (`loggingMiddleware`, registered in `Router()`)
- Modify: `backend/internal/api/api_test.go` (new tests)
**Interfaces:**
- Consumes: `applog.NewLogger`, `applog.WithLogger`, `applog.FromContext` (Task 1).
- Produces: `loggingMiddleware` (unexported chi middleware in `internal/api`), `config.Config.LogLevel string`.
- [ ] **Step 1: Write the failing config tests**
Append to `../../../backend/internal/config/envconfig_test.go`:
```go
func TestLoad_LogLevelDefaultsToInfo(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want default %q", cfg.LogLevel, "info")
}
}
func TestLoad_LogLevelExplicitOverridesDefault(t *testing.T) {
setRequiredEnv(t)
t.Setenv("GENIUSRUN_LOG_LEVEL", "debug")
cfg, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.LogLevel != "debug" {
t.Errorf("LogLevel = %q, want debug", cfg.LogLevel)
}
}
```
- [ ] **Step 2: Write the failing API tests**
Add `"log/slog"` to `backend/internal/api/api_test.go`'s stdlib import group, and `"geniusrun/backend/internal/applog"` to its internal import group.
Append to `backend/internal/api/api_test.go`:
```go
func TestRequestLoggingMiddleware_LogsMethodPathStatusDuration(t *testing.T) {
s, _, _ := newTestServer(t)
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["msg"] != "http request" {
t.Errorf("msg = %v, want \"http request\"", entry["msg"])
}
if entry["method"] != "GET" || entry["path"] != "/api/health" {
t.Errorf("method/path = %v/%v, want GET//api/health", entry["method"], entry["path"])
}
if entry["status"] != float64(http.StatusOK) {
t.Errorf("status = %v, want 200", entry["status"])
}
if _, ok := entry["duration_ms"]; !ok {
t.Error("expected a duration_ms field")
}
}
func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) {
s, db, _ := newTestServer(t)
db.Close() // force a downstream DB call to fail with a 500
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
req := httptest.NewRequest(http.MethodGet, "/api/profile", nil)
req = req.WithContext(applog.WithLogger(req.Context(), logger))
cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure)
if err != nil {
t.Fatalf("mint session cookie: %v", err)
}
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected the request itself to 500 after closing the DB, got %d", rec.Code)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a 5xx response", entry["level"])
}
}
```
- [ ] **Step 3: Run tests to verify they fail**
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 `envconfig.go`**
Change:
```go
MinConfidence float64
IncrementalSyncEvery time.Duration
```
to:
```go
MinConfidence float64
IncrementalSyncEvery time.Duration
// LogLevel controls internal/applog's JSON logger ("debug"|"info"|"warn"|"error").
LogLevel string
```
Change:
```go
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
```
to:
```go
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
```
- [ ] **Step 5: Add the middleware to `server.go`**
Change the import block:
```go
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Insert this above `// Router builds the HTTP routes.`:
```go
var requestIDCounter atomic.Int64
// requestLoggingMiddleware logs one JSON line per HTTP request (method,
// path, status, duration) and attaches a per-request logger (tagged with a
// request_id) to the request context, so any downstream call this request
// triggers -- e.g. a Garmin wrapper round-trip -- logs with the same
// correlating id (see internal/applog, internal/garmin's roundTrip).
func requestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := fmt.Sprintf("req-%d", requestIDCounter.Add(1))
logger := applog.FromContext(r.Context()).With("request_id", id)
r = r.WithContext(applog.WithLogger(r.Context(), logger))
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
start := time.Now()
next.ServeHTTP(ww, r)
level := slog.LevelInfo
if ww.Status() >= 500 {
level = slog.LevelWarn
}
logger.LogAttrs(r.Context(), level, "http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", ww.Status()),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
)
})
}
```
Change `Router()`'s opening lines:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(corsMiddleware)
```
to:
```go
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(requestLoggingMiddleware)
r.Use(corsMiddleware)
```
- [ ] **Step 6: Wire `slog.SetDefault` into `main.go`**
Change the import block:
```go
import (
"context"
"log"
"net/http"
"os/signal"
"syscall"
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
to:
```go
import (
"context"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"geniusrun/backend/internal/api"
"geniusrun/backend/internal/applog"
"geniusrun/backend/internal/auth"
"geniusrun/backend/internal/config"
"geniusrun/backend/internal/garmin"
"geniusrun/backend/internal/store"
appsync "geniusrun/backend/internal/sync"
)
```
Change:
```go
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
db, err := store.Open(cfg.DBPath)
```
to:
```go
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
db, err := store.Open(cfg.DBPath)
```
- [ ] **Step 7: Run tests to verify they pass**
Run: `cd backend && go test ./internal/config/... ./internal/api/... -run 'TestLoad_LogLevel|TestRequestLoggingMiddleware' -v`
Expected: PASS.
- [ ] **Step 8: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 9: Commit**
```bash
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"
```
---
### Task 3: Garmin wrapper call log
**Files:**
- Modify: `backend/internal/garmin/client.go` (`execute`, `ensureStarted`, all six call sites)
- Modify: `backend/internal/garmin/client_test.go` (update 3 direct-`execute` calls, add 2 new tests)
**Interfaces:**
- Consumes: `applog.FromContext` (Task 1).
- Produces: `func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)` (signature change — `ctx` added as first param), `func (c *subprocessClient) ensureStarted(ctx context.Context) error` (signature change — `ctx` added).
- [ ] **Step 1: Update the 3 existing direct-`execute` tests and write the 2 new failing tests**
In `backend/internal/garmin/client_test.go`, add `"bytes"` and `"log/slog"` to the stdlib import block, and add `"geniusrun/backend/internal/applog"` as a new import group.
Change (in `TestSubprocessClient_RoundTrip_DetectsIDMismatch`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Change (in `TestSubprocessClient_RoundTrip_SubprocessClosedIsError`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Change (in `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`):
```go
_, err := c.roundTrip("authenticate", nil)
```
to:
```go
_, err := c.roundTrip(context.Background(), "authenticate", nil)
```
Append two new tests:
```go
func TestSubprocessClient_RoundTrip_LogsCallWithResultPreview(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeResult(authResultWire{Status: "mfa_required", Message: "MFA required."})
})
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
ctx := applog.WithLogger(context.Background(), logger)
if _, err := c.Authenticate(ctx); err != nil {
t.Fatalf("Authenticate: %v", err)
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["msg"] != "garmin wrapper call" {
t.Errorf("msg = %v, want \"garmin wrapper call\"", entry["msg"])
}
if entry["cmd"] != "authenticate" {
t.Errorf("cmd = %v, want authenticate", entry["cmd"])
}
preview, _ := entry["result_preview"].(string)
if !strings.Contains(preview, "mfa_required") {
t.Errorf("result_preview = %q, want it to contain mfa_required", preview)
}
if _, hasError := entry["error"]; hasError {
t.Errorf("expected no error field on a successful call, got %v", entry["error"])
}
}
func TestSubprocessClient_RoundTrip_LogsErrorAtWarnLevel(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
ctx := applog.WithLogger(context.Background(), logger)
if _, err := c.Authenticate(ctx); err == nil {
t.Fatal("expected Authenticate to return an error")
}
var entry map[string]any
if err := json.Unmarshal(buf.Bytes(), &entry); err != nil {
t.Fatalf("log output is not valid JSON: %v (%q)", err, buf.String())
}
if entry["level"] != "WARN" {
t.Errorf("level = %v, want WARN for a failed call", entry["level"])
}
if errMsg, _ := entry["error"].(string); !strings.Contains(errMsg, "boom") {
t.Errorf("error field = %q, want it to mention \"boom\"", errMsg)
}
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd backend && go test ./internal/garmin/... -run 'TestSubprocessClient_RoundTrip' -v`
Expected: FAIL — `execute` still takes 2 args, not 3; `applog` import unresolved.
- [ ] **Step 3: Update `client.go`**
Change the import block:
```go
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strconv"
"sync"
)
```
to:
```go
import (
"bufio"
"context"
_ "embed"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strconv"
"sync"
"time"
"geniusrun/backend/internal/applog"
)
```
Change `ensureStarted`'s signature and add the spawn log. From:
```go
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted() error {
if c.started {
return nil
}
```
to:
```go
// ensureStarted spawns the wrapper subprocess if it isn't already running.
// Callers must hold c.mu.
func (c *subprocessClient) ensureStarted(ctx context.Context) error {
if c.started {
return nil
}
```
And change its ending, from:
```go
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
return nil
}
```
to:
```go
c.cmd = cmd
c.stdin = stdin
c.enc = json.NewEncoder(stdin)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), maxWrapperLineBytes)
c.scanner = scanner
c.started = true
c.nextID = 0
applog.FromContext(ctx).Info("garmin wrapper spawning",
"python_path", pythonPath,
"token_store_configured", c.cfg.TokenStorePath != "",
)
return nil
}
```
Replace `execute` entirely. From:
```go
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted.
func (c *subprocessClient) roundTrip(cmdName string, params any) (json.RawMessage, error) {
c.nextID++
id := c.nextID
if err := c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
return nil, fmt.Errorf("write %s request: %w", cmdName, err)
}
if !c.scanner.Scan() {
if err := c.scanner.Err(); err != nil {
return nil, fmt.Errorf("read %s response: %w", cmdName, err)
}
return nil, fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
var resp wireResponse
if err := json.Unmarshal(c.scanner.Bytes(), &resp); err != nil {
return nil, fmt.Errorf("parse %s response: %w", cmdName, err)
}
if resp.ID != id {
return nil, fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
}
if resp.Error != "" {
return nil, fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return resp.Result, nil
}
```
to:
```go
// roundTrip sends one request and returns its result payload, or an error
// if the wrapper reported one. Callers must hold c.mu and have already
// called ensureStarted. Logs exactly one "garmin wrapper call" line
// regardless of outcome (see internal/applog) -- cmd/params are always
// safe to log in full here: Garmin credentials only ever reach the
// subprocess via env vars at spawn time (see ensureStarted), never through
// these wire params.
func (c *subprocessClient) roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error) {
start := time.Now()
defer func() {
attrs := []slog.Attr{
slog.String("cmd", cmdName),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelWarn
attrs = append(attrs, slog.String("error", err.Error()))
}
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
}()
c.nextID++
id := c.nextID
if err = c.enc.Encode(wireRequest{ID: id, Cmd: cmdName, Params: params}); err != nil {
err = fmt.Errorf("write %s request: %w", cmdName, err)
return nil, err
}
if !c.scanner.Scan() {
if serr := c.scanner.Err(); serr != nil {
err = fmt.Errorf("read %s response: %w", cmdName, serr)
} else {
err = fmt.Errorf("read %s response: subprocess closed its output", cmdName)
}
return nil, err
}
var resp wireResponse
if uerr := json.Unmarshal(c.scanner.Bytes(), &resp); uerr != nil {
err = fmt.Errorf("parse %s response: %w", cmdName, uerr)
return nil, err
}
if resp.ID != id {
err = fmt.Errorf("%s response id mismatch: got %d, want %d", cmdName, resp.ID, id)
return nil, err
}
if resp.Error != "" {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
return nil, err
}
result = resp.Result
return result, nil
}
```
Update the six call sites. In `Authenticate`:
```go
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("authenticate", nil)
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip(ctx, "authenticate", nil)
```
In `CompleteMFA`:
```go
if err := c.ensureStarted(); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip("complete_mfa", map[string]any{"code": code})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return AuthResult{}, err
}
raw, err := c.roundTrip(ctx, "complete_mfa", map[string]any{"code": code})
```
In `GetActivities`:
```go
if err := c.ensureStarted(); err != nil {
return nil, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return nil, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activities_by_date",
Args: map[string]any{"startdate": startDate, "enddate": endDate},
})
```
In `GetActivitySplits`:
```go
if err := c.ensureStarted(); err != nil {
return ActivitySplits{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return ActivitySplits{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activity_splits",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
In `GetActivityDetails`:
```go
if err := c.ensureStarted(); err != nil {
return ActivityDetails{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return ActivityDetails{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_activity_details",
Args: map[string]any{"activity_id": strconv.FormatInt(activityID, 10)},
})
```
In `GetWorkoutByID`:
```go
if err := c.ensureStarted(); err != nil {
return Workout{}, err
}
raw, err := c.roundTrip("call", callParams{
Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
})
```
to:
```go
if err := c.ensureStarted(ctx); err != nil {
return Workout{}, err
}
raw, err := c.roundTrip(ctx, "call", callParams{
Method: "get_workout_by_id",
Args: map[string]any{"workout_id": strconv.FormatInt(workoutID, 10)},
})
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `cd backend && go test ./internal/garmin/... -v`
Expected: PASS (all existing tests still pass with the new `ctx` param threaded through; the 2 new logging tests pass).
- [ ] **Step 5: Run the full backend test suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: `gofmt -l .` empty; everything passes.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "feat(garmin): log every wrapper subprocess call as structured JSON"
```
---
### Task 4: Final verification
**Files:** none (verification only)
- [ ] **Step 1: Full backend check**
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
Expected: `gofmt -l .` empty; everything else passes.
- [ ] **Step 2: Manual smoke check**
Run the real server briefly (`cd backend && ./start.sh`, or `go run ./cmd/geniusrund` with the required env vars set) and confirm stdout shows JSON lines for at least a health-check request (`curl localhost:8080/api/health`) — one `"msg":"http request"` line with a `request_id`. This doesn't require a real Garmin account; it only confirms the JSON handler is actually wired to stdout in the real binary (as opposed to only passing in tests).
- [ ] **Step 3: Commit (if anything drifted)**
```bash
git add -A
git commit -m "fix: address final verification findings"
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,844 @@
# Stop Retrying a Permanently-Missing Garmin Workout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop `fillPendingWorkouts` from retrying a `get_workout_by_id` call forever when Garmin
returns a definitive 404 (the workout was deleted after being linked to an activity), per
`docs/superpowers/specs/2026-07-27-workout-not-found-design.md`.
**Architecture:** `garminconnect`'s own `GarminConnectNotFoundError` (raised for any HTTP 404) is
caught specifically in `wrapper.py`'s dispatch loop and marked with `"not_found": true` in the
JSON error response. `internal/garmin/client.go` turns that into a Go sentinel error
(`ErrNotFound`) any caller can `errors.Is` against. `internal/sync/service.go`'s
`fillPendingWorkouts` checks for it and, only for that specific case, calls a new store method
(`SetActivityWorkoutNotFound`) that permanently excludes the activity from
`ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` -- `workout_raw_json` stays `NULL`
forever (no fabricated data), but a new `workout_not_found_at` column distinguishes "confirmed
missing" from "not yet fetched."
**Tech Stack:** Go (backend, `internal/garmin`/`internal/store`/`internal/sync`), Python
(`internal/garmin/pyscript/wrapper.py`). No frontend changes.
## Global Constraints
- `gofmt -l .` must report nothing; `go build ./...`, `go vet ./...`, `go test ./...` must all
pass before any commit.
- Every store method takes an explicit `userID` and uses it in a real `WHERE`/`JOIN` clause (per
this repo's per-user isolation convention) -- `SetActivityWorkoutNotFound` follows the exact
pattern of the existing `SetActivitySplitsFetched`.
- No migration history: `internal/store/schema.sql` is edited directly, then
`go run ./cmd/dumpschema` regenerates `docs/DATABASE.md`.
- `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (abort-the-batch-and-report)
is explicitly out of scope -- this fix only changes `fillPendingWorkouts`'s behavior.
- No frontend changes -- `workouts_pending` simply stops counting a confirmed-missing workout.
---
### Task 1: `wrapper.py` marks a 404 with `not_found: true`
**Files:**
- 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).
- Produces: `dispatch()`'s returned error dict gains an additional `"not_found": true` key
whenever the caught exception is (or is a subclass of) `GarminConnectNotFoundError` -- Task 2
reads this field on the Go side.
- [ ] **Step 1: Write the failing test**
Add to `../../../backend/internal/garmin/wrapper/tests/test_wrapper.py`:
```python
def test_call_marks_not_found_error_specifically():
from garminconnect import GarminConnectNotFoundError
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = GarminConnectNotFoundError("API Error 404")
resp = wrapper.dispatch({
"id": 11, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp == {"id": 11, "error": "API Error 404", "not_found": True}
def test_call_does_not_mark_other_errors_as_not_found():
wrapper._auth_state = "authenticated"
wrapper._client = MagicMock()
wrapper._client.get_workout_by_id.side_effect = Exception("rate limited")
resp = wrapper.dispatch({
"id": 12, "cmd": "call", "params": {"method": "get_workout_by_id", "args": {"workout_id": "999"}},
})
assert resp == {"id": 12, "error": "rate limited"}
```
- [ ] **Step 2: Run it to verify it fails**
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/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/wrapper/wrapper.py`, replace:
```python
from garminconnect import Garmin
```
with:
```python
from garminconnect import Garmin, GarminConnectNotFoundError
```
Replace:
```python
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
return {"id": req.get("id"), "error": str(exc)}
```
with:
```python
def dispatch(req):
handler = _HANDLERS.get(req.get("cmd"))
if handler is None:
return {"id": req.get("id"), "error": f"unknown cmd {req.get('cmd')!r}"}
try:
result = handler(req.get("params") or {})
return {"id": req["id"], "result": result}
except Exception as exc:
_debug(f"{req.get('cmd')} raised {type(exc).__name__}: {exc}")
_debug(traceback.format_exc())
resp = {"id": req.get("id"), "error": str(exc)}
# A 404 (e.g. get_workout_by_id for a workout deleted on Garmin's
# side after being linked to an activity) is definitive, not a
# transient failure worth retrying forever -- marked specifically so
# internal/garmin/client.go can tell the two apart (see
# docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
if isinstance(exc, GarminConnectNotFoundError):
resp["not_found"] = True
return resp
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/test_wrapper.py -v`
Expected: all tests PASS, including both new ones and every existing test unchanged.
- [ ] **Step 5: Commit**
```bash
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
garminconnect's own GarminConnectNotFoundError already exists
specifically for this (its docstring: "so callers can now catch a
missing resource specifically, e.g. deleting an already-deleted
workout"), raised by connectapi() for any real HTTP 404. dispatch()
now surfaces that distinction as an extra not_found: true field
alongside the existing error string, so internal/garmin/client.go
(next commit) can tell a definitive 404 apart from a transient
failure.
EOF
)"
```
---
### Task 2: `internal/garmin/client.go` exposes `ErrNotFound`
**Files:**
- Modify: `backend/internal/garmin/client.go` (`wireResponse`, new `ErrNotFound`, `execute`)
- Modify: `backend/internal/garmin/client_test.go` (`wireResponsePayload`/harness, new test)
**Interfaces:**
- Consumes: `wireResponse.NotFound` (wire field written by Task 1's `wrapper.py` change).
- Produces: `var ErrNotFound error` -- Task 4 (`fillPendingWorkouts`) checks
`errors.Is(err, garmin.ErrNotFound)` against errors returned by `GetWorkoutByID` (and, since
this is wired at the generic `execute` level, any other `Client` method too).
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/garmin/client_test.go`, right after `fakeError`:
```go
func fakeNotFoundError(msg string) wireResponsePayload {
return wireResponsePayload{err: msg, notFound: true}
}
```
Update the `wireResponsePayload` struct (near the top of the file) from:
```go
type wireResponsePayload struct {
result json.RawMessage
err string
}
```
to:
```go
type wireResponsePayload struct {
result json.RawMessage
err string
notFound bool
}
```
Update `newFakeWrapperClient`'s harness -- replace:
```go
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err}
```
with:
```go
payload := handle(req.Cmd, req.Params)
resp := wireResponse{ID: req.ID, Result: payload.result, Error: payload.err, NotFound: payload.notFound}
```
Add a new test, right after `TestSubprocessClient_RoundTrip_WrapperErrorPropagates`:
```go
func TestSubprocessClient_RoundTrip_NotFoundWrapsErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeNotFoundError("API Error 404")
})
_, err := c.roundTrip(context.Background(), "call", nil)
if !errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound)", err)
}
if !strings.Contains(err.Error(), "API Error 404") {
t.Errorf("roundTrip error = %v, want it to still contain the original message", err)
}
}
func TestSubprocessClient_RoundTrip_OrdinaryErrorDoesNotWrapErrNotFound(t *testing.T) {
c := newFakeWrapperClient(t, func(cmd string, params json.RawMessage) wireResponsePayload {
return fakeError("boom")
})
_, err := c.roundTrip(context.Background(), "call", nil)
if errors.Is(err, ErrNotFound) {
t.Fatalf("roundTrip error = %v, want errors.Is(err, ErrNotFound) to be false", err)
}
}
```
Add `"errors"` to this file's import block (alongside the existing `"strings"` etc.).
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/garmin/... -run TestSubprocessClient_RoundTrip_NotFound -v`
Expected: FAIL to compile (`wireResponsePayload` has no field `notFound` yet -- wait, Step 1 above
already added it to the test file; the actual compile failure is `wireResponse` has no field
`NotFound` yet, and `ErrNotFound` is undefined) -- confirms the test exercises code that doesn't
exist yet.
- [ ] **Step 3: Add `ErrNotFound` and wire it through `execute`**
In `backend/internal/garmin/client.go`, replace:
```go
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
```
with:
```go
// wireResponse is one line read from the wrapper subprocess's stdout.
type wireResponse struct {
ID int `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
NotFound bool `json:"not_found,omitempty"`
}
// ErrNotFound wraps any error a Client method returns when the wrapper
// reported a definitive HTTP 404 (garminconnect's own
// GarminConnectNotFoundError) -- e.g. GetWorkoutByID for a workout deleted
// on Garmin's side after being linked to an activity. Callers use
// errors.Is(err, ErrNotFound) to distinguish this from a transient failure
// worth retrying.
var ErrNotFound = errors.New("garmin: resource not found")
```
Replace, in `execute`:
```go
if resp.Error != "" {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
return nil, err
}
```
with:
```go
if resp.Error != "" {
if resp.NotFound {
err = fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)
} else {
err = fmt.Errorf("%s: %s", cmdName, resp.Error)
}
return nil, err
}
```
Add `"errors"` to `client.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend && go test ./internal/garmin/... -v`
Expected: all tests PASS, including the two new ones and every existing test unchanged.
- [ ] **Step 5: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 6: Commit**
```bash
git add backend/internal/garmin/client.go backend/internal/garmin/client_test.go
git commit -m "$(cat <<'EOF'
feat(garmin): expose ErrNotFound for a wrapper-reported 404
roundTrip now wraps the returned error with the new ErrNotFound
sentinel whenever the wrapper's response set not_found (Task 1),
letting any Client method's caller distinguish a definitive 404 from
a transient failure via errors.Is, regardless of which garminconnect
method was called.
EOF
)"
```
---
### Task 3: Store gains `workout_not_found_at`
**Files:**
- Modify: `backend/internal/store/schema.sql` (`activities` table)
- Modify: `backend/internal/store/activities.go` (`Activity` struct, `activityColumns`,
`scanActivity`, new `SetActivityWorkoutNotFound`, `ActivitiesMissingWorkout`/
`CountActivitiesMissingWorkout`)
- Modify: `backend/internal/store/store_test.go` (extend the existing test, add a new one)
- Regenerate: `docs/DATABASE.md` (via `go run ./cmd/dumpschema`)
**Interfaces:**
- Consumes: nothing new.
- Produces: `db.SetActivityWorkoutNotFound(ctx, userID, activityID int64) error` -- Task 4
(`fillPendingWorkouts`) calls this. `Activity.WorkoutNotFoundAt *string` -- available to any
caller of `GetActivity`/`ListActivities`/etc. that wants to check it (none currently do, besides
this task's own test).
- [ ] **Step 1: Write the failing test**
Add to `backend/internal/store/store_test.go`, a new test (don't modify the existing
`TestActivitiesMissingWorkout_OnlyIncludesActivitiesWithDetailsAlreadyFetched` -- this is
additive):
```go
func TestActivitiesMissingWorkout_ExcludesConfirmedNotFound(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID, err := db.ProvisionUser(ctx, "test-sub", "Test")
if err != nil {
t.Fatalf("ProvisionUser: %v", err)
}
workoutID := int64(999)
// Details already fetched, workout confirmed 404 on Garmin -- must not
// be retried, so must not appear in ActivitiesMissingWorkout/Count.
notFound := Activity{
GarminActivityID: 1, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-01 06:00:00", RawJSON: "{}",
}
idA, err := db.UpsertActivity(ctx, userID, notFound)
if err != nil {
t.Fatalf("UpsertActivity (a): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idA, "{}"); err != nil {
t.Fatalf("SetActivityDetails (a): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idA); err != nil {
t.Fatalf("SetActivitySplitsFetched (a): %v", err)
}
if err := db.SetActivityWorkoutNotFound(ctx, userID, idA); err != nil {
t.Fatalf("SetActivityWorkoutNotFound (a): %v", err)
}
// A genuinely still-pending activity (details fetched, workout not yet
// attempted) must still be included, for contrast.
stillPending := Activity{
GarminActivityID: 2, WorkoutID: &workoutID,
StartTimeUTC: "2026-07-02 06:00:00", RawJSON: "{}",
}
idB, err := db.UpsertActivity(ctx, userID, stillPending)
if err != nil {
t.Fatalf("UpsertActivity (b): %v", err)
}
if err := db.SetActivityDetails(ctx, userID, idB, "{}"); err != nil {
t.Fatalf("SetActivityDetails (b): %v", err)
}
if err := db.SetActivitySplitsFetched(ctx, userID, idB); err != nil {
t.Fatalf("SetActivitySplitsFetched (b): %v", err)
}
n, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if n != 1 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 1 (only activity b)", n)
}
pending, err := db.ActivitiesMissingWorkout(ctx, userID, 10)
if err != nil {
t.Fatalf("ActivitiesMissingWorkout: %v", err)
}
if len(pending) != 1 || pending[0].ID != idB {
t.Fatalf("ActivitiesMissingWorkout = %+v, want only activity b (id %d)", pending, idB)
}
confirmed, _, err := db.GetActivity(ctx, userID, idA)
if err != nil {
t.Fatalf("GetActivity (a): %v", err)
}
if confirmed.WorkoutNotFoundAt == nil {
t.Error("activity a's WorkoutNotFoundAt is nil, want it set")
}
if confirmed.WorkoutRawJSON != nil {
t.Error("activity a's WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout_ExcludesConfirmedNotFound -v`
Expected: FAIL to compile (`db.SetActivityWorkoutNotFound` and `Activity.WorkoutNotFoundAt`
undefined).
- [ ] **Step 3: Add the schema column**
In `backend/internal/store/schema.sql`, replace:
```sql
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
```
with:
```sql
-- Genuine raw get_workout_by_id() response, the source used to compute
-- alignWorkoutTargets. Null when the activity has no workout_id.
workout_raw_json TEXT,
-- Set when get_workout_by_id returned a definitive HTTP 404 (the
-- workout was deleted on Garmin's side after being linked to this
-- activity) -- distinct from workout_raw_json staying null for "not yet
-- fetched": this activity is excluded from ActivitiesMissingWorkout so
-- it stops being retried forever (see
-- docs/superpowers/specs/2026-07-27-workout-not-found-design.md).
-- workout_raw_json itself is never fabricated; it just stays null.
workout_not_found_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
```
- [ ] **Step 4: Update the Go struct, column list, and scan**
In `backend/internal/store/activities.go`, replace:
```go
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
CreatedAt string
UpdatedAt string
}
```
with:
```go
// WorkoutRawJSON is the genuine raw get_workout_by_id() response for this
// activity's structured workout -- the source used to compute each lap's
// TargetPaceLowMps/HighMps and TargetHRLowBpm/HighBpm (see
// internal/sync/mapping.go's alignWorkoutTargets). Nil when the activity
// has no WorkoutID, or was synced before this column existed.
WorkoutRawJSON *string
// WorkoutNotFoundAt is set when get_workout_by_id returned a definitive
// HTTP 404 for this activity's WorkoutID -- distinct from
// WorkoutRawJSON staying nil for "not yet fetched" (see
// SetActivityWorkoutNotFound).
WorkoutNotFoundAt *string
CreatedAt string
UpdatedAt string
}
```
Replace:
```go
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
created_at, updated_at
`
```
with:
```go
func scanActivity(row interface{ Scan(...any) error }) (Activity, error) {
var a Activity
err := row.Scan(
&a.ID, &a.GarminActivityID, &a.EventTypeKey, &a.WorkoutID, &a.StartTimeUTC,
&a.DurationSeconds, &a.DistanceMeters, &a.AvgHR, &a.MaxHR,
&a.AvgSpeedMps, &a.ElevationGainM,
&a.AerobicTrainingEffect, &a.AnaerobicTrainingEffect, &a.VO2MaxValue,
&a.RawJSON, &a.DetailsFetchedAt, &a.DetailsRawJSON, &a.SplitsFetchedAt, &a.WorkoutRawJSON,
&a.WorkoutNotFoundAt, &a.CreatedAt, &a.UpdatedAt,
)
return a, err
}
const activityColumns = `
id, garmin_activity_id, event_type_key, workout_id, start_time_utc,
duration_seconds, distance_meters, avg_hr, max_hr,
avg_speed_mps, elevation_gain_m,
aerobic_training_effect, anaerobic_training_effect, vo2max_value,
raw_json, details_fetched_at, details_raw_json, splits_fetched_at, workout_raw_json,
workout_not_found_at, created_at, updated_at
`
```
- [ ] **Step 5: Add `SetActivityWorkoutNotFound` and update the two query methods**
In `backend/internal/store/activities.go`, right after `SetActivityWorkout`, add:
```go
// SetActivityWorkoutNotFound records that get_workout_by_id returned a
// definitive HTTP 404 for this activity's WorkoutID -- the workout was
// deleted on Garmin's side after being linked to this activity.
// WorkoutRawJSON is deliberately left nil (never fabricated); this is a
// separate marker so ActivitiesMissingWorkout stops retrying it forever.
func (db *DB) SetActivityWorkoutNotFound(ctx context.Context, userID, activityID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE activities SET workout_not_found_at = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND user_id = ?`, activityID, userID)
if err != nil {
return fmt.Errorf("set activity %d workout not found for user %d: %w", activityID, userID, err)
}
return nil
}
```
Replace:
```go
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
```
with:
```go
func (db *DB) ActivitiesMissingWorkout(ctx context.Context, userID int64, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `SELECT `+activityColumns+` FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL
ORDER BY start_time_utc DESC LIMIT ?`, userID, limit)
```
Replace:
```go
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL AND details_fetched_at IS NOT NULL`, userID).Scan(&n)
```
with:
```go
func (db *DB) CountActivitiesMissingWorkout(ctx context.Context, userID int64) (int, error) {
var n int
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM activities
WHERE user_id = ? AND workout_id IS NOT NULL AND workout_raw_json IS NULL
AND details_fetched_at IS NOT NULL AND workout_not_found_at IS NULL`, userID).Scan(&n)
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `cd backend && go test ./internal/store/... -run TestActivitiesMissingWorkout -v`
Expected: PASS, including both the existing test and the new one.
- [ ] **Step 7: Regenerate the schema doc**
Run: `cd backend && go run ./cmd/dumpschema`
Expected: `docs/DATABASE.md` updates to include `workout_not_found_at` in the `activities` table.
- [ ] **Step 8: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 9: Commit**
```bash
git add backend/internal/store/schema.sql backend/internal/store/activities.go backend/internal/store/store_test.go docs/DATABASE.md
git commit -m "$(cat <<'EOF'
feat(store): add workout_not_found_at, exclude it from missing-workout queries
A confirmed-404 workout must stop being retried forever, but
workout_raw_json should never be fabricated -- it stays null exactly
as it does for "not yet fetched." workout_not_found_at is the
separate marker ActivitiesMissingWorkout/CountActivitiesMissingWorkout
now check for, mirroring the existing pattern of the other _fetched_at
columns. UpsertActivity's ON CONFLICT clause already never touches
these columns, so the marker persists across every later re-sync.
EOF
)"
```
---
### Task 4: `fillPendingWorkouts` stops retrying a confirmed 404
**Files:**
- 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).
- Produces: nothing new -- this is the final integration point for this fix.
- [ ] **Step 1: Write the failing test**
Add to `../../../backend/internal/garmin/sync_test.go`, right after
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass`:
```go
func TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
userID := provisionTestUser(t, db)
const missingWorkoutID = 300
missingPtr := int64(missingWorkoutID)
m := &mock.Client{
Activities: []garmin.Activity{
{ActivityID: 1, ActivityType: garmin.ActivityType{TypeKey: "running"}, WorkoutID: &missingPtr,
StartTimeGMT: "2026-07-01 06:00:00", Distance: 5000, Duration: 1500},
},
Splits: map[int64]garmin.ActivitySplits{
1: {ActivityID: 1, Laps: []garmin.Lap{{LapIndex: 1, IntensityType: "ACTIVE"}}},
},
Details: map[int64]garmin.ActivityDetails{1: {ActivityID: 1}},
Workouts: map[int64]garmin.Workout{},
WorkoutErrByID: map[int64]error{missingWorkoutID: fmt.Errorf("API Error 404: %w", garmin.ErrNotFound)},
}
svc := NewService(m, db, userID, Config{InterCallDelay: time.Millisecond},
fixedNow(time.Date(2026, 7, 11, 0, 0, 0, 0, time.UTC)))
if _, err := svc.backfillCore(ctx); err != nil {
t.Fatalf("backfillCore: %v", err)
}
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("FillPendingDetails should not return an error for a confirmed-404 workout: %v", err)
}
remaining, err := db.CountActivitiesMissingWorkout(ctx, userID)
if err != nil {
t.Fatalf("CountActivitiesMissingWorkout: %v", err)
}
if remaining != 0 {
t.Fatalf("CountActivitiesMissingWorkout = %d, want 0 (confirmed-404 activity must not be retried)", remaining)
}
activities, err := db.ListActivities(ctx, userID, store.ActivityFilter{})
if err != nil {
t.Fatalf("ListActivities: %v", err)
}
if len(activities) != 1 {
t.Fatalf("ListActivities returned %d activities, want 1", len(activities))
}
got, _, err := db.GetActivity(ctx, userID, activities[0].ID)
if err != nil {
t.Fatalf("GetActivity: %v", err)
}
if got.WorkoutNotFoundAt == nil {
t.Error("WorkoutNotFoundAt is nil, want it set")
}
if got.WorkoutRawJSON != nil {
t.Error("WorkoutRawJSON should stay nil -- not-found is recorded separately, not faked")
}
// A second FillPendingDetails call must not attempt this workout again
// (it's no longer in ActivitiesMissingWorkout's result set at all).
if err := svc.FillPendingDetails(ctx, 10); err != nil {
t.Fatalf("second FillPendingDetails: %v", err)
}
}
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
Expected: FAIL -- `CountActivitiesMissingWorkout` still returns 1 (today's code retries forever
regardless of the error's nature).
- [ ] **Step 3: Update `fillPendingWorkouts`**
In `../../../backend/internal/garmin/sync.go`, replace:
```go
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
```
with:
```go
for i, a := range pending {
if i > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.cfg.InterCallDelay):
}
}
if err := s.fillActivityWorkout(ctx, a, profile); err != nil {
if errors.Is(err, garmin.ErrNotFound) {
// A definitive 404 (the workout was deleted on Garmin's side
// after being linked to this activity) will never succeed on
// retry -- mark it so ActivitiesMissingWorkout stops
// surfacing it, instead of retrying forever.
log.Printf("sync: workout for activity %d not found on Garmin, marking as such (will not retry): %v", a.GarminActivityID, err)
if serr := s.db.SetActivityWorkoutNotFound(ctx, s.userID, a.ID); serr != nil {
return fmt.Errorf("mark activity %d workout not found: %w", a.GarminActivityID, serr)
}
} else {
log.Printf("sync: fill workout for activity %d failed, will retry next sync: %v", a.GarminActivityID, err)
}
}
s.setProgress(PhaseWorkouts, i+1, len(pending))
}
return nil
}
```
Add `"errors"` to `sync.go`'s import block.
- [ ] **Step 4: Run the test to verify it passes**
Run: `cd backend && go test ./internal/sync/... -run TestFillPendingDetails_NotFoundWorkoutStopsBeingRetried -v`
Expected: PASS.
- [ ] **Step 5: Run the full `internal/sync` test suite**
Run: `cd backend && go test ./internal/sync/... -v 2>&1 | tail -60`
Expected: every test passes, including
`TestFillPendingDetails_OneWorkoutFetchFailureDoesNotAbortTheWorkoutsPass` unchanged (its failure
is a plain `fmt.Errorf("garmin says no")`, not `ErrNotFound`-wrapped, so it still hits the
retry-next-sync branch exactly as before).
- [ ] **Step 6: Run the full backend suite**
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
Expected: all pass, `gofmt -l .` prints nothing.
- [ ] **Step 7: Commit**
```bash
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
fillPendingWorkouts previously treated every get_workout_by_id
failure identically -- log it, leave workout_raw_json null, retry
next sync -- which is correct for a transient failure but means a
definitive 404 (the workout deleted on Garmin's side after being
linked to an activity) got silently retried forever, with the only
symptom being an unexplained, permanent "1 more workout pending"
nudge. Now checks errors.Is(err, garmin.ErrNotFound) and, only for
that case, calls SetActivityWorkoutNotFound instead of retrying.
EOF
)"
```
---
## Final verification
- [ ] Run the full backend suite one more time: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
- [ ] Run the Python wrapper tests one more time: `cd backend/internal/garmin/pyscript && python3 -m pytest tests/ -v`
- [ ] Confirm `docs/DATABASE.md` reflects the new `workout_not_found_at` column.
- [ ] Use superpowers:finishing-a-development-branch to wrap up (tests green -> present the
merge/PR/keep-as-is menu).

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 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 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 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 ## Wire protocol
@@ -99,5 +99,5 @@ Go's line reader uses a raised buffer size (well above the default 64KB `bufio.S
## Rollout notes ## Rollout notes
- No data migration involved — this only changes how the Go backend talks to Garmin, not what's stored. - 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). - 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: `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. - `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. - `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. - `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 ## 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. - 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. - 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

@@ -0,0 +1,163 @@
# Improve the first connection page
Status: approved, not yet implemented.
## Problem
`docs/IDEAS.md` backlog item: "improve first connection page: in
CreateProfile.tsx, in addition to the display name, we should directly ask
garmin login/password and manage MFA from there, so the synchronization
experience in profile page will be easier later on".
Today, `CreateProfile.tsx` only asks for a display name (`POST /api/setup`)
and drops the user straight into the main app -- Garmin credentials are
filled in later, whenever the user happens to visit the Profile page. This
means a brand-new account can sit around fully "set up" but never actually
synced, with no prompt nudging the user to connect Garmin.
## Design
### Scope decision: mandatory, persisted gate
Connecting Garmin becomes a **hard requirement** to enter the app, enforced
on every login, not just the first one -- confirmed explicitly, including
the corollary that this can't be a purely in-session check: account
provisioning (`POST /api/setup`) must happen before Garmin login can even be
attempted (the per-user Garmin client is keyed by an already-existing
account), so a user who abandons the flow mid-connect already has a
`users`/`profile` row. Enforcing the gate on *every subsequent login* (not
just immediately after signup) requires a persisted "has this account ever
successfully connected" flag -- the in-memory `Server.userAuthStatus` map
resets on every backend restart and can't be trusted for this.
Also confirmed: both `CreateProfile` and the new gate screen get a "Log out"
link (neither has one today), since a mandatory gate a user can get
genuinely stuck on (no Garmin credentials handy right now) needs an escape
hatch back to the login screen.
### Data model
`schema.sql`, `profile` table -- one new nullable column:
```sql
-- Set once, the first time this user successfully authenticates with
-- Garmin (handleAuthLogin/handleAuthMFA). Null means never connected --
-- the login gate uses this (not the in-memory auth status, which
-- resets on restart) to decide whether a returning user must
-- reconnect before entering the app.
garmin_connected_at TEXT,
```
`store.Profile` gets a matching `GarminConnectedAt *string` field (same
nullable-TEXT-pointer pattern as `sync_state.EarliestSyncedDate`).
New store method (`internal/store/users.go` or a new small file, e.g.
`profiles.go` wherever `UpdateProfile`/`GetProfile` already live):
```go
// MarkGarminConnected records the first time userID successfully
// authenticates with Garmin. A no-op if already set, so it always reflects
// the first connection, not the most recent one.
func (db *DB) MarkGarminConnected(ctx context.Context, userID int64) error
```
Implemented as `UPDATE profile SET garmin_connected_at = datetime('now')
WHERE user_id = ? AND garmin_connected_at IS NULL`.
After the schema edit, regenerate `docs/DATABASE.md` via `go run
./cmd/dumpschema` (per project convention).
### Backend API
- **`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
Garmin authentication itself already succeeded; failing to persist the
flag is a non-fatal side effect (the next successful login attempt would
just set it then).
- **`sessionMeResponse`** (`internal/api/session.go`): add `GarminConnected
bool \`json:"garmin_connected"\``.
- **`handleSessionMe`**: when the session already has a profile, fetch it
(`s.DB.GetProfile`) and set `resp.GarminConnected = profile.GarminConnectedAt
!= nil`.
- No other endpoint changes. The new frontend screen calls the exact same
`PUT /api/profile`, `POST /api/auth/login`, `POST /api/auth/mfa` the
existing Profile page already uses.
### Frontend flow
- **`types/api.ts`**: `SessionInfo` gains `garmin_connected: boolean`.
- **`LoginGate.tsx`**: becomes a three-way fork instead of two:
```
!authenticated -> login screen
authenticated && !has_profile -> CreateProfile
authenticated && has_profile && !garmin_connected -> ConnectGarmin
authenticated && has_profile && garmin_connected -> App
```
`CreateProfile.onCreated` and the new `ConnectGarmin.onConnected` both just
flip the relevant boolean in the local `session` state object (same
pattern `has_profile` already uses).
- **`CreateProfile.tsx`**: behavior unchanged (display name ->
`POST /api/setup`); it's simply no longer the last screen. Gains a
"Log out" link (`<form method="post" action="{BASE_URL}/api/session/logout">`,
same pattern as `App.tsx`'s header).
- **New `frontend/src/ConnectGarmin.tsx`**: a dedicated, self-contained
onboarding screen (not a reuse of `GarminConnection.tsx`, to avoid
coupling that shared Profile-page component to an onboarding-only special
case):
- Email + password fields.
- "Connect" button: `PUT /api/profile` (persist credentials into the
profile row created by setup) then `POST /api/auth/login`.
- If the result is `mfa_required`, show an MFA code field -> `POST
/api/auth/mfa`.
- On `authenticated`, show a brief confirmation and a "Continue to
geniusrun" button that calls `onConnected()` -- no sync is
auto-triggered here; the existing background incremental-sync loop and
the Profile page's "Sync now" handle actual data fetching, keeping this
screen scoped to connecting only.
- On `failed` (wrong password, Garmin unreachable, rate-limited), show the
returned message and leave the fields editable for a retry -- never a
dead end.
- Also gains a "Log out" link.
### Error handling
- Wrong credentials / Garmin down / rate-limited: surfaced via the same
`authResponse.message` field `GarminConnection.tsx` already displays;
fields stay editable, retry is always available.
- `MarkGarminConnected` DB failure: logged server-side, never surfaced to
the user and never fails the auth response (see Backend API above).
- Abandoning the flow (closing the tab) leaves the account provisioned but
not connected -- the *next* login re-enters at `ConnectGarmin` (that's the
whole point of persisting the flag), not at `CreateProfile` again.
### Testing
- `internal/store`: `MarkGarminConnected` sets the timestamp exactly once
(a second call is a no-op, preserving the first-connection time);
adversarial isolation test that marking user A connected never affects
user B's `GarminConnectedAt`.
- `internal/api`: `POST /api/auth/login` (via `mock.Client` returning
`AuthSuccess`) followed by `GET /api/session/me` reports `garmin_connected:
true`; an `mfa_required` or `failed` result does *not* set it; a freshly
provisioned user (via `newTestServer`, no Garmin attempt yet) reports
`garmin_connected: false`.
- No frontend test suite exists yet (per `CLAUDE.md`) -- manual smoke test:
fresh account -> display name -> forced into `ConnectGarmin` -> wrong
password shows an error and allows retry -> correct password (or MFA)
resolved -> lands in `<App>` -> log out -> log back in with the same
account -> confirm it goes straight to `<App>` (not back through
`ConnectGarmin`), proving `garmin_connected_at` actually persisted.
## Out of scope
- Auto-triggering a first sync from `ConnectGarmin` -- deferred to the
existing "Sync now" / background incremental-sync mechanisms.
- Any "reconnect required" re-gating if Garmin credentials later go stale
(e.g. password changed on Garmin's side) -- this feature only covers the
*first-ever* successful connection; subsequent reconnects still go through
the normal Profile page flow.
- Combining the display-name and Garmin-credential steps into a single
form/request -- kept as two sequential screens since Garmin login
requires an already-provisioned account (see Scope decision above).

View File

@@ -0,0 +1,149 @@
# Improve Synchronization UX — Design
**Source idea:** `docs/IDEAS.md` — "improve synchronization: make it modal, split downloads of
activities and workouts (as workouts are currently downloaded from their ID in related
activities), add progress bar (which requires to know in advance how many activities or
workouts will have to be downloaded)."
## Goal
Replace the Profile page's single inline "syncing: N/M activities" text line with a blocking
modal that shows real progress through sync, broken into named phases the user can actually
follow: discovering new activities, fetching activity details, then fetching workouts.
## Cleanup folded into this work: dead `Backfill`/`IncrementalSync` wrappers
`Service.Backfill(ctx)` and `Service.IncrementalSync(ctx)` are exported methods that each record
their own `SyncRun`, wrapping `backfillCore`/`incrementalSyncCore`. Their doc comments say
they're used "by the periodic background loop" -- but that loop was removed in an earlier
session (`4d2cbe4 refactor: remove automatic background incremental sync`), and grepping
`internal/api/` and `cmd/` confirms nothing in production calls them anymore. `FullSync` (the
only production caller of the core logic) calls `backfillCore`/`incrementalSyncCore` directly.
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 `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
the progress-model changes below, so later tasks aren't touching code that's about to be
deleted.
## Current state (for reference)
- `sync.Service.Progress()` returns a flat `{Done, Total}`, written only by
`FillPendingDetails``Backfill`/`IncrementalSync` (the "discover which activities exist"
phase) report no progress at all today.
- `FillPendingDetails` fetches, per activity, in one pass: `GetActivitySplits`,
`GetActivityDetails`, and (if the activity has a `workout_id`) `GetWorkoutByID` — then writes
samples, laps (with workout-derived target pace/HR bands baked in), activity details, and
marks splits fetched.
- `GET /api/sync/status` returns `{in_progress, detail_fill_progress: {Done, Total},
activities_pending_details, last_run?}`. The frontend (`GarminConnection.tsx`) polls this and
renders an inline `<p>` line, both while syncing and once idle (a static "last sync: ..."
summary).
- `sync_runs` stores one row per `Backfill`/`IncrementalSync`/`FullSync` call
(`kind`/`started_at`/`finished_at`/`activities_fetched`/`status`/`error_message`).
`GET /api/sync/runs` exists but nothing in the frontend calls it.
## Scope
**In scope:** "Sync now" (`FullSync`) gets the new modal and phase-aware progress. The static
idle "last sync" summary on the Profile page is removed — the modal becomes the only place
sync status/results are shown; closing it means nothing is shown again until the next sync.
**Out of scope:** "Reset all" keeps its current behavior unchanged (confirm dialog, fire, poll
via a blocking `while` loop, reload the page) — no modal, no progress UI. The "modern error
displays" idea (ephemeral banners, backend-down handling) from the same backlog section is a
separate, later feature — this one only makes sync's own errors visible via the modal's final
state, reusing the `sync_runs.error_message` field that's already fetched but never rendered
today.
## Backend design
### Phase-aware progress
```go
type Progress struct {
Phase string // "idle" | "discovering" | "activities" | "workouts"
Done int
Total int
}
```
`FullSync` drives the phase transitions:
1. **`discovering`** — set (Done=0, Total=0, indeterminate) while `Backfill`/`IncrementalSync`
run. These still report no count; the modal shows a spinner, not a bar, during this phase.
2. **`activities`** — `FillPendingDetails` is split into two sequential passes. The first pass
fetches `GetActivitySplits`+`GetActivityDetails`+samples for every activity missing details
(`ActivitiesMissingDetails`/`CountActivitiesMissingDetails`, unchanged queries), writing laps
*without* workout-target alignment yet. `Total` is the pending-details count taken once at
the start of this pass; `Done` advances per activity.
3. **`workouts`** — a new second pass. `workout_id` is decoded from Garmin's activity summary
and stored on the `activities` row at upsert time, well before any detail fetch — so
"activities needing a workout fetched" is an independently queryable condition
(`workout_id IS NOT NULL AND workout_raw_json IS NULL`), needing no live Garmin call to
count. Two new store methods, `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout`
(mirroring the naming of the existing `ActivitiesMissingDetails`/
`CountActivitiesMissingDetails`), list/count these. For each: fetch `GetWorkoutByID`,
re-derive lap target bands from the just-stored lap data (`alignWorkoutTargets`), update
the laps and set `workout_raw_json`. This condition also picks up any older activity that
has a `workout_id` but never got its workout aligned in some prior run (e.g. a past
transient failure) — not just ones touched in this run's `activities` pass — a small
latent-bug fix as a side effect.
4. **`idle`** — reset to `{Phase: "idle", Done: 0, Total: 0}` once `FullSync` returns, same
`defer`-based reset as today.
Restructuring `fillActivityDetails` to decouple lap-writing from workout-target alignment
means an activity with a workout gets its laps written twice (once plain in the `activities`
pass, once updated with target bands in the `workouts` pass) — this is a second local SQL
delete+insert, not a second Garmin API call, so it costs nothing against rate limits.
### API
`GET /api/sync/status` changes shape:
```json
{
"in_progress": true,
"progress": { "phase": "workouts", "done": 3, "total": 8 },
"activities_pending_details": 0,
"last_run": { "...": "SyncRun, unchanged fields" }
}
```
This replaces `detail_fill_progress`/`DetailFillProgress` outright (no other consumer exists).
`SyncRun.Kind`'s frontend type also gains its missing `"full"` value (backend has always been
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 `garminSyncStatus` polling, `syncProgressLabel` helper, and all inline sync-progress/last-sync
JSX.
A new `SyncModal.tsx`:
- Renders as a blocking overlay (page behind it non-interactive) the moment `api.syncRun()`
resolves successfully.
- Polls `api.syncStatus()` every 1500ms while open (same cadence as today's polling).
- Renders by `progress.phase`:
- `discovering` → spinner + "Discovering activities…"
- `activities` → progress bar + "Activities: {done}/{total}"
- `workouts` → progress bar + "Workouts: {done}/{total}"
- Once `in_progress` becomes `false`: shows `last_run.Status`, `ActivitiesFetched`, and
`ErrorMessage` (if the run errored) plus the existing "N more pending — sync again" nudge if
`activities_pending_details > 0`, and a **Close** button. No auto-close, no timeout — the
user decides when to dismiss it (this is the one place sync errors are visible, so nothing
should hide it automatically).
## Testing
- `internal/sync`: new/updated tests for the two-phase `FillPendingDetails` split (an activity
with a workout gets its laps written in both passes, target bands only present after the
`workouts` pass; an activity without a workout is untouched by the `workouts` pass), and for
phase-aware `Progress()` transitions.
- `internal/api`: `sync_test.go` updated for the new `/api/sync/status` JSON shape.
- Frontend: no test suite exists (per CLAUDE.md) — manual smoke test against `seedsample` data,
watching the modal move through all four phases.

View File

@@ -0,0 +1,232 @@
# Onboarding wizard with deferred DB commit
Status: approved, not yet implemented.
## Problem
Feedback on the just-shipped mandatory Garmin-connect onboarding
(`docs/superpowers/specs/2026-07-26-improve-first-connection-design.md`):
1. UX: a wizard-style Previous/Next pair reads better than the current
Continue/Log out pairing across `CreateProfile``ConnectGarmin`.
2. If the user quits mid-flow without logging out, the account is left in a
provisioned-but-not-Garmin-connected state -- confirmed this should
instead mean **nothing is written to the database at all** until Garmin
authentication actually succeeds, at which point display name + Garmin
credentials + the connected flag are all committed together, atomically.
(A third piece of feedback, structured JSON logging, is unrelated and gets
its own design afterward.)
## Design
### Consequence: the three-way LoginGate fork collapses
Since account provisioning and Garmin-connection now happen together in one
atomic step, `has_profile=true && garmin_connected=false` becomes
structurally unreachable -- confirmed to collapse `LoginGate` back to a
two-way fork (`has_profile ? App : OnboardingWizard`). `profile.garmin_connected_at`
stays as an informational timestamp (still written by the new atomic
commit); it just no longer gates anything on its own.
### The core problem: Garmin auth needs an account today
`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
known from the session cookie, before any `users` row exists) instead of a
user id. A long-held-open SQL transaction across the wizard's multiple
HTTP round trips is not an option: SQLite's single connection
(`SetMaxOpenConns(1)`, see `db.go`) means one held-open transaction would
block every other request in the app for as long as the user takes to
finish onboarding.
### Backend: ephemeral pre-account Garmin sessions
New type and `Server` field (`server.go`, alongside the existing
`userClient`/`userSync`/etc. per-user maps):
```go
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Promoted into Server.userGarmin once
// /api/setup/complete actually creates the account; evicted lazily (the
// next setup-endpoint touch for that subject checks staleness first) once
// idle past setupSessionIdleTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
const setupSessionIdleTimeout = 15 * time.Minute
```
`Server` gains `setupGarmin map[string]*setupSession` (keyed by OIDC
subject string), initialized alongside the other maps in `NewServer`.
Helper methods on `Server` (`server.go`):
- `setupSessionFor(sub string) (*setupSession, bool)` -- returns the
session if present and not stale; a stale one is closed and evicted
first (lazy cleanup, no background sweep goroutine -- an abandoned
session otherwise leaks one idle subprocess until the backend restarts,
an accepted trade-off for a single-operator app).
- `replaceSetupSession(sub, email, password string) *setupSession` --
closes and replaces any existing session for `sub` (same "close old,
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 `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
session's `Status`/`Message`/`LastUsed` after a login or MFA attempt.
- `removeSetupSession(sub string)` -- closes and drops `sub`'s session
(best-effort removes its `{root}/setup/{sub}` token-store directory too).
Called both when `/api/setup/complete` promotes a session into the
permanent cache, and from `handleSessionLogout` so abandoning mid-wizard
via logout doesn't linger until the idle timeout.
### Backend: three new endpoints replace `/api/setup`
Registered where `r.Post("/setup", s.handleSetup)` used to be (same
middleware group: `RequireSession` + `resolveUser`, but *not*
`requireProvisionedUser` -- these must work before a profile exists):
```go
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
```
- **`POST /api/setup/garmin/login`** `{garmin_email, garmin_password}`:
401 if unauthenticated; 409 if a profile already exists for this subject
(mirrors old `handleSetup`'s existing check); 400 on missing fields.
Otherwise `replaceSetupSession` + `Authenticate()` + `recordSetupAuthResult`,
responding with the same `{status, message}` shape `/api/auth/login`
already uses.
- **`POST /api/setup/garmin/mfa`** `{code}`: 409 if no session exists for
this subject (never started, or expired) -- frontend resets to the
Garmin step and asks the user to reconnect. Otherwise
`CompleteMFA()` + `recordSetupAuthResult` on the *same* session (never
replaces it -- MFA continues an in-progress attempt on the same
subprocess).
- **`POST /api/setup/complete`** `{display_name}`: 409 if already
provisioned; 409 if no session exists for this subject or its `Status !=
garmin.AuthSuccess` ("Garmin isn't connected yet"). Otherwise, in order:
1. `ProvisionUser(sub, displayName)` (unchanged).
2. `GetProfile` + set `GarminEmail`/`GarminPassword` from the session +
`UpdateProfile` (unchanged method, just called here instead of from
the Profile page).
3. `MarkGarminConnected(userID)`.
4. Promote: move the session's already-authenticated `garmin.Client`
into `s.userGarmin[userID]` and its recorded status into
`s.userAuthStatus[userID]`/`userAuthMessage[userID]` (avoids a
redundant re-authentication -- and possibly a repeat MFA prompt --
immediately after signup), then `delete(s.setupGarmin, sub)` without
closing the client (ownership transferred, not discarded).
5. Best-effort `os.Rename` the ephemeral token-store directory
(`{root}/setup/{sub}`) to the permanent one (`{root}/{userID}`), so a
later respawn of this user's subprocess (e.g. after a server restart)
resumes the already-established Garmin session instead of requiring a
fresh login. Logged, not fatal, on error.
`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.
### Frontend: one wizard component instead of two screens
`CreateProfile.tsx` and `ConnectGarmin.tsx` are deleted, replaced by
`frontend/src/OnboardingWizard.tsx` (and `OnboardingWizard.css`, replacing
`CreateProfile.css`). Two top-level steps (`"name" | "garmin"`); the
in-progress-MFA and already-authenticated states are just conditions on
`auth?.status` *within* the `"garmin"` step, not a third step value --
simpler than threading a separate `"mfa"` step through, and matches how
`GarminConnection.tsx` already branches on `status` today:
- **`name` step**: display name field, held in local state only -- no API
call. "Next" just validates non-empty and advances `step` to `"garmin"`.
- **`garmin` step**, branching on `auth?.status`:
- `undefined` / `"failed"`: email/password fields. "Previous" returns to
the `name` step (pure local state, nothing to undo server-side).
"Next" calls `api.setupGarminLogin(email, password)`.
- `"mfa_required"`: MFA code field. "Previous" clears `auth` back to
`null`, which re-shows the credentials form (submitting it again just
replaces the ephemeral session, same as the backend's "close old,
spawn new" semantics). "Submit code" calls `api.setupGarminMFA(code)`.
- `"authenticated"`: a confirmation message and "Continue to geniusrun",
which calls `api.setupComplete(displayName)` and then `onCreated(...)`.
- "Log out" (`<form method="post" action=".../session/logout">`) stays
visible throughout, the same escape hatch as before.
`frontend/src/api/client.ts`: remove `setup`; add `setupGarminLogin`,
`setupGarminMFA`, `setupComplete` (bodies/shapes as described above).
`frontend/src/LoginGate.tsx`: collapse back to
`!session.has_profile ? <OnboardingWizard onCreated={...}> : <App>` --
`garmin_connected` is no longer read here at all (still present on
`SessionInfo`/`session/me`, just unused by the gate).
### Error handling
- Garmin login/MFA failure: same as today -- message shown, fields stay
editable, retry always available, nothing persisted regardless of
outcome.
- `/api/setup/complete` with no session, or session not yet `authenticated`:
`409`, frontend falls back to the `garmin` step (credentials form) so the
user can (re)connect.
- `/api/setup/complete`/`/api/setup/garmin/login` when already provisioned
(double-submit, duplicate tab): `409` "profile already exists"; frontend
treats this as success and refreshes session info rather than showing an
error.
- Idle sessions (15 minutes unused) are evicted lazily on next touch --
`/api/setup/garmin/mfa` or `/api/setup/complete` against an expired
session behaves identically to "never connected" (409), prompting a
fresh `/api/setup/garmin/login`.
- Logging out mid-wizard closes and drops that subject's ephemeral session
and its token-store directory.
### Testing
- `internal/api`: `POST /api/setup/garmin/login` (mock `AuthSuccess`)
creates no DB user yet (`GetUserBySub` still not found); the
`mfa_required` path via `/api/setup/garmin/mfa` on the same session;
`/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 `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
OIDC subjects' ephemeral sessions never interfere (isolation test,
matching this repo's adversarial-isolation convention). A token-store
rename test mirroring the existing `TestDeleteProfile_RemovesTokenStoreDirectory`
pattern (set `GarminBase.TokenStorePath`, confirm the directory ends up
under the new user id's path after `/api/setup/complete`, not under
`setup/{sub}`).
- No frontend test suite -- manual smoke test: fresh account → name step →
Previous/Next between name and Garmin steps preserves typed input →
wrong password shows an error and stays editable → MFA prompt → Previous
from MFA returns to credentials → correct credentials (or MFA resolved)
→ "Continue to geniusrun" → lands in the app → confirm `GetUserBySub`
now exists with everything set. Separately: start the wizard, enter
Garmin credentials, then just close the tab without finishing --
confirm no `users`/`profile` row was created at all.
## Out of scope
- A background sweep goroutine for abandoned ephemeral sessions -- accepted
as a known, low-stakes trade-off (see Error handling).
- Structured JSON logging (the third piece of feedback) -- separate design.
- Any change to the Profile page's own reconnect flow
(`GarminConnection.tsx`, `/api/auth/*`) -- untouched.

View File

@@ -0,0 +1,142 @@
# Profile deletion
Status: approved, not yet implemented.
## Problem
`docs/IDEAS.md` backlog item: "profile deletion: add a way to delete our
profile with a dedicated button on the profile page (which by extension will
also logout the user, to be consistent with the logic that login will trigger
the profile creation)".
Today there is no way for a user to remove their geniusrun account. The
closest existing feature, "Reset all" (`GarminConnection.tsx` /
`POST /api/sync/reset`), only wipes synced activity data and rewinds the
backfill watermark -- it leaves the `users` row, `profile`, Garmin
credentials, and custom workout-kind rules untouched. Profile deletion is a
strictly bigger, irreversible operation: the entire account for the signed-in
OIDC subject, gone, followed by a real logout.
## Design
### Data model: cascading deletes
The schema has FK columns pointing at `users(id)` (`profile.user_id`,
`workout_kinds.user_id`, `activities.user_id`, `sync_state.user_id`,
`sync_runs.user_id`) and at `workout_kinds(id)`
(`workout_type_paces.workout_kind_id`), none of which currently cascade.
`laps`/`activity_samples`/`kind_assignments` already cascade off `activities`
(`ON DELETE CASCADE`), which is what lets `ResetAllSyncedData` get away with
a single `DELETE FROM activities`. SQLite's `foreign_keys` pragma is already
enabled on every connection (`db.go`'s `_pragma=foreign_keys(1)`), so the same
mechanism works for the rest of the schema.
`schema.sql` is edited directly (no migration history, per project
convention) to add `ON DELETE CASCADE` to:
- `profile.user_id REFERENCES users(id)`
- `workout_kinds.user_id REFERENCES users(id)`
- `workout_type_paces.workout_kind_id REFERENCES workout_kinds(id)`
- `activities.user_id REFERENCES users(id)`
- `sync_state.user_id REFERENCES users(id)`
- `sync_runs.user_id REFERENCES users(id)`
With that in place, deleting a user becomes one statement:
```go
// store/users.go
func (db *DB) DeleteUser(ctx context.Context, userID int64) error {
_, err := db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID)
return err
}
```
After this change, regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`).
### API: `DELETE /api/profile`
New handler alongside the existing `GET`/`PUT /api/profile` routes, inside
the `requireProvisionedUser` group (`server.go`). `userID` comes from
`userIDFromContext`, same as every other handler -- no client-supplied id.
1. If a sync is currently running for this user (`s.userSyncRunning[userID]`,
the same flag `backgroundSync` sets), respond `409 Conflict` ("a sync is
in progress for this account; wait for it to finish before deleting your
profile") rather than deleting rows out from under an in-flight write.
2. Call `s.DB.DeleteUser(ctx, userID)`.
3. Tear down this user's cached Garmin state (new `Server` method, e.g.
`removeGarminClient(userID)`):
- Under `s.mu`, pop `s.userGarmin[userID]` and delete the userID entry
from `userSync`, `userAuthStatus`, `userAuthMessage`, and
`userSyncRunning`.
- If a client existed, call `client.Close()` outside the lock to
terminate its subprocess (mirrors how `handleUpdateProfile` calls
`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 `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`.
### Frontend
- `api/client.ts`: `deleteProfile: () => request<void>("/api/profile", {
method: "DELETE" })`.
- `Profile.tsx`: a final `<fieldset className="kind-editor danger-zone">`
("Danger zone") containing a `button-danger` "Delete profile" button
(same class `GarminConnection`'s "Reset all" uses). Clicking it reveals an
inline confirmation block (local component state, no separate modal
component -- same "reveal inline" shape `ClassifyControl` already uses for
its dropdown):
- Explanatory copy: this permanently deletes the account -- Garmin
credentials, every custom rule, every synced activity -- and logs the
user out. Cannot be undone.
- A text input; a "Permanently delete" button stays disabled until the
input's value is exactly `DELETE`.
- A "Cancel" button collapses the block back.
- On confirm: call `api.deleteProfile()`. On success, build a real
`<form method="post" action="{BASE_URL}/api/session/logout">` via the DOM
and submit it immediately -- the same POST-navigation the header's Log out
button already performs (`App.tsx`), required because logout must be a
real browser navigation through Keycloak's end-session redirect, not a
`fetch`. On failure, surface the error via the same `error` state /
`<p className="error">` pattern the rest of `Profile.tsx` already uses,
and leave the confirmation block open so the user can retry.
### Error handling
- Sync-in-progress: `409`, clear message, surfaced like every other API
error on this page.
- DB/teardown errors: `500` with `err.Error()`, same as the rest of this
codebase's handlers.
- The type-`DELETE`-to-confirm gate is UI-only -- same trust boundary as
every other destructive action in this app (e.g. Reset all's
`window.confirm`). The backend does not require a second confirmation
token.
### Testing
- `backend/internal/store`: `TestDeleteUser` -- provision a user, give them
activities (with laps/samples/kind_assignments), workout kinds (with
paces), sync_state, and sync_runs; delete; assert every row is gone.
Adversarial isolation check per this repo's convention
(`isolation_test.go`): provision two users, delete one, assert the other's
rows (profile, workout kinds, activities, etc.) are untouched.
- `backend/internal/api`: handler test for `DELETE /api/profile` against
`newTestServer` + `mock.Client` -- success path (204, user actually gone
from `GetUserBySub`), and the sync-in-progress 409 (set
`userSyncRunning[userID] = true` first).
- No frontend test suite exists yet (per `CLAUDE.md`) -- manually
smoke-tested in the browser: delete flow ends up back at the login/create
profile screen, a fresh login for that OIDC subject lands on "Create your
profile" again (proving the account is truly gone, not just logged out).
## Out of scope
- Any "export my data before deleting" flow -- not requested.
- Re-authentication (password/MFA re-entry) before deletion -- this app has
no such step anywhere else (e.g. Reset all), so it isn't introduced here
either.
- Admin-initiated deletion of another user's account -- there is no admin UI
in this app.

View File

@@ -0,0 +1,173 @@
# Structured JSON logging (API calls + Garmin wrapper calls)
Status: approved, not yet implemented.
## Problem
Motivated by a real debugging need: after implementing the deferred-commit
onboarding wizard, a login attempt reported `mfa_required` but no MFA code
ever arrived by email. There is currently no visibility into what actually
happened between geniusrun and the Garmin wrapper subprocess beyond raw,
unstructured stderr output.
**Likely root cause of that specific incident** (found while surveying the
code for this design, worth recording even though this task doesn't fix
it): `internal/garmin/pyscript/wrapper.py`'s `authenticate` handler blocks
on a 10-second queue read and, on timeout, unconditionally returns
`{"status": "mfa_required"}` -- regardless of whether `garminconnect` ever
actually called `prompt_mfa()`. A login that's merely slow (Garmin/Cloudflare
rate-limiting, already a documented trap in this codebase) looks identical
to a real MFA challenge. The only current way to tell them apart is a raw
stderr line (`"garminconnect invoked prompt_mfa()..."`, only emitted when
MFA is real) that's easy to miss. This design doesn't change that timeout
logic -- it makes the distinction visible in structured logs instead of
buried in stderr, which is the requested first step.
The user asked, specifically: JSON logs on stdout (for an existing
observability stack), for now covering only two things -- calls made to
geniusrun's own APIs, and calls made to external components (the Python
wrapper subprocess).
## Design
### Scope
- Two new log categories: an HTTP access log (one line per API request) and
a Garmin-wrapper call log (one line per subprocess round-trip).
- Existing ad-hoc `log.Printf` calls throughout the codebase are **not**
touched or migrated -- they keep going to stderr, unstructured, exactly as
today. This is deliberate, matching "for now" -- a wholesale migration is
a separate, later concern.
- `log/slog` (stdlib, available with no new dependency given this repo's Go
version) with a JSON handler writing to stdout -- a separate stream from
the existing stderr output, matching "JSON logs on stdout" literally.
### New package: `internal/applog`
```go
package applog
// NewLogger builds a JSON-handler *slog.Logger writing to w at the given
// level ("debug"|"info"|"warn"|"error", case-insensitive, defaulting to
// info for anything unrecognized).
func NewLogger(level string, w io.Writer) *slog.Logger
// WithLogger/FromContext thread a *slog.Logger through request-scoped
// context.Context, so a logger enriched with (e.g.) a request_id in one
// layer is picked up by another (e.g. internal/garmin, downstream of
// internal/api) without either package depending on the other.
// FromContext never returns nil -- it falls back to slog.Default() so
// callers with no request context (the background incremental-sync loop,
// tests that don't bother injecting one) still get a working logger.
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context
func FromContext(ctx context.Context) *slog.Logger
```
`internal/config`: new `LogLevel string` field, `getEnvDefault("GENIUSRUN_LOG_LEVEL", "info")`,
same pattern as every other optional config value.
`cmd/geniusrund/main.go`: once at startup,
```go
slog.SetDefault(applog.NewLogger(cfg.LogLevel, os.Stdout))
```
No other wiring needed -- every consumer reads via `applog.FromContext`,
which falls back to this default.
### HTTP access log (`internal/api`)
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:
- Generates a per-request id from an in-process monotonic counter (e.g.
`req-42`) -- simple, no new randomness dependency, resets on restart
(acceptable; log aggregation timestamps disambiguate across restarts).
- Builds `logger := applog.FromContext(r.Context()).With("request_id", id)`
and re-stashes it via `r = r.WithContext(applog.WithLogger(...))` *before*
calling `next.ServeHTTP` -- this is what lets every downstream layer
(including a Garmin wrapper call the handler triggers) log with the same
`request_id`.
- Wraps the `http.ResponseWriter` with chi's own
`middleware.NewWrapResponseWriter` (already available -- `go-chi/chi/v5`
is already a dependency, this is just a different subpackage of it, no
new module) to capture the status code.
- After `next.ServeHTTP` returns, logs one line:
`msg: "http request"`, fields `request_id`, `method`, `path` (`r.URL.Path`
only, deliberately excluding the query string, to stay conservative about
anything unexpected ending up in a log line), `status`, `duration_ms`.
Level `Info`, or `Warn` if `status >= 500`.
### Garmin wrapper call log (`internal/garmin`)
`client.go`'s `execute` is the single funnel point every one of the six
`Client` methods already goes through (confirmed while surveying: none of
them ever put Garmin credentials in the wire `params` -- email/password
only ever reach the subprocess via env vars at spawn time, so logging
`params` in full, for every command, is safe). Change its signature to
`roundTrip(ctx context.Context, cmdName string, params any) (result json.RawMessage, err error)`
(named returns), and wrap the whole body in a single `defer` that logs
exactly once regardless of which branch returned:
```go
defer func() {
attrs := []slog.Attr{
slog.String("cmd", cmdName),
slog.Any("params", params),
slog.Int64("duration_ms", time.Since(start).Milliseconds()),
}
level := slog.LevelInfo
if result != nil {
attrs = append(attrs, slog.String("result_preview", truncate(string(result), 500)))
}
if err != nil {
level = slog.LevelWarn
attrs = append(attrs, slog.String("error", err.Error()))
}
applog.FromContext(ctx).LogAttrs(context.Background(), level, "garmin wrapper call", attrs...)
}()
```
Reusing the existing `truncate()` helper for `result_preview` (500 chars) is
what keeps this safe for the huge per-second-telemetry responses
(`get_activity_details` can run to several MB, per `maxWrapperLineBytes`)
while still showing tiny auth results (`{"status":"mfa_required",...}`) in
full -- exactly the detail needed for the motivating incident.
All six `Client` methods pass their own `ctx` through to `execute`
(currently they call it without one). `ensureStarted` also gains a `ctx
context.Context` param and logs one `Info` line on actual spawn
(`python_path`, whether a token store is configured) -- spawning the
subprocess is itself a call to the external component.
### Out of scope
- Migrating existing `log.Printf` call sites to `slog`/JSON.
- Fixing `wrapper.py`'s 10-second MFA-timeout ambiguity itself (see
Problem section) -- this design only makes the distinction visible.
- Logging request/response bodies for HTTP calls, or full (non-preview)
Garmin call results.
- `user_id` on the HTTP access log line (would need threading it out of
`resolveUser`'s context back to the outermost middleware, a bigger change
than "for now" calls for) -- `request_id` correlation covers the
motivating need (one login attempt <-> its Garmin calls) without it.
## Testing
- `internal/applog`: level filtering (a below-threshold message doesn't
appear in the output writer); `WithLogger`/`FromContext` round-trip
(same logger comes back out); `FromContext` on a bare `context.Background()`
returns a non-nil logger.
- `internal/garmin`: reuse the existing in-process fake-wrapper harness
(`newFakeWrapperClient`) -- inject a captor logger via
`applog.WithLogger`, call `Authenticate`/`CompleteMFA`, assert the emitted
JSON line's `cmd`/`duration_ms`/`result_preview` fields, and that a
wrapper-reported error surfaces at `Warn` with an `error` field. Update
the three existing tests that call `execute` directly
(`TestSubprocessClient_RoundTrip_DetectsIDMismatch`,
`_SubprocessClosedIsError`, `_WrapperErrorPropagates`) to pass
`context.Background()` as the new first argument.
- `internal/api`: one focused test building a request with a pre-seeded
context logger (writing to a `bytes.Buffer`), confirming the access-log
line's fields, and that a handler returning a 5xx bumps the log level to
`Warn`.

View File

@@ -0,0 +1,167 @@
# Modern Error Displays — Design
**Status:** Approved, ready for implementation planning.
**Origin:** `docs/IDEAS.md`'s "modern error displays" backlog item.
## Goal
Replace every page/component's ad-hoc inline `error`-state paragraph
(`<p className="error">{error}</p>` and its several near-duplicates) with a
single, consistent banner system: ephemeral, color-coded, full-width bars
(red for error, orange for warning, blue for notice, green for success)
that stack, auto-dismiss, and can be closed manually.
This is a full replacement, not an addition alongside the old pattern —
every existing inline error display in the frontend is migrated to the new
system, with one deliberate exception (OnboardingWizard's field-validation
messages — see below).
## Non-goals
- No backend changes. This is purely a frontend presentation change; no API
shape changes anything about how errors are already communicated to the
frontend.
- No new "logout failed" error path. `handleSessionLogout` on the backend
has no failure mode today (unconditional cookie-clear + redirect) — the
banner system will be *capable* of showing a logout error if one is ever
added, but nothing is invented here just to exercise it.
## Architecture
### `frontend/src/banner.ts` — the store
A plain module, not a React Context/Provider. The codebase has no existing
`createContext`/`useContext` usage anywhere, and — critically —
`frontend/src/api/client.ts` is a plain module (not a component), so a
Context wouldn't be reachable from it without extra plumbing. A
module-level singleton store is simpler and callable from anywhere:
```ts
type Severity = "error" | "warning" | "notice" | "success";
type Banner = { id: number; severity: Severity; message: string };
// newest-on-top; auto-dismisses after 8000ms; manually dismissible early.
export function showError(message: string): void;
export function showWarning(message: string): void;
export function showNotice(message: string): void;
export function showSuccess(message: string): void;
export function dismiss(id: number): void;
// useSyncExternalStore plumbing for the render side.
export function subscribe(listener: () => void): () => void;
export function getSnapshot(): Banner[];
```
Multiple banners stack (newest on top); each dismisses independently on its
own timer. A banner's `message` may contain embedded newlines (`\n`) for
the sync-result-plus-nudges case (see below) — the renderer treats them as
line breaks within one banner, not separate banners.
### `frontend/src/components/BannerStack.tsx` + `BannerStack.css` — the renderer
- `useSyncExternalStore(subscribe, getSnapshot)` to read the current list.
- Renders one `<div className="banner banner-{severity}">` per active
banner: the message text (respecting embedded newlines) plus a `×` close
button that calls `dismiss(id)`.
- Four CSS variants — `banner-error` (red bg), `banner-warning` (orange
bg), `banner-notice` (blue bg), `banner-success` (green bg) — each with
readable foreground text/icon color against its background.
- **Mounted exactly once**, in `frontend/src/main.tsx`, as a sibling above
`<LoginGate />`:
```tsx
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BannerStack />
<LoginGate />
</StrictMode>,
);
```
Rendered in normal document flow (not `position: fixed`), so it
naturally pushes down whatever's currently showing below it — the login
screen, the onboarding wizard, or the full app (header + tabs + content)
— without needing a separate mount point wired into each of those three
top-level layouts individually. When no banners are active, it renders
nothing (no reserved empty space).
### `frontend/src/api/client.ts` — friendlier network-failure messages
`request()`'s `fetch()` call is wrapped so a network-level failure (the
`fetch()` promise itself rejecting — offline, connection refused, CORS,
etc.) throws a friendly, fixed message instead of the raw browser error
text:
```ts
let res: Response;
try {
res = await fetch(`${BASE_URL}${path}`, { ... });
} catch {
throw new Error("Can't reach the server — check your connection.");
}
```
A real HTTP response that just happens to be non-2xx (4xx/5xx) is
unaffected — that branch keeps its existing `${status} ${body}` message,
since that's a legitimate API-level error, not a "the backend isn't there"
condition.
This means every call site's existing pattern —
`.catch((e) => setError(String(e)))` — becomes
`.catch((e) => showError(String(e)))`, and the friendly message for a truly
unreachable backend falls out for free, with no per-call-site
network-error special-casing needed. This is what satisfies IDEAS.md's
"used on all pages/tabs in case of backend is not here": every page's
existing catch-and-display path already covers this, once it displays via
`showError` instead of local state.
## Migration inventory
Every current inline-error site, and what changes:
| File | Before | After |
|---|---|---|
| `components/GarminConnection.tsx` | Local `error` state, all catch blocks `setError(String(e))`, inline `{error && <p className="error">{error}</p>}` | Remove the state and the paragraph; every catch block calls `showError(String(e))` directly. |
| `components/SyncModal.tsx` | Never auto-closes; on completion renders a "final result" block inline (success/error line + optional pending-activities/pending-workouts nudges) with a manual Close button; a status-poll fetch failure renders inline too. | The "final result" JSX block is deleted entirely — **the modal now purely shows the progress bar/spinner and nothing else**. On the poll tick where `status.in_progress` is first observed to be `false`, build one message: the success/error line, followed by a newline-separated line for each applicable pending nudge (`"N more activities pending — click Sync now again"` / `"N more workouts pending — click Sync now again"`), call `showSuccess(...)` or `showError(...)` with that combined message, then call `onClose()` immediately — the modal disappears the instant sync finishes, and the banner carries the result. A transient status-poll fetch failure (modal still open, sync still presumably running) calls `showError(String(e))` but does **not** close the modal — polling continues as it does today. |
| `pages/Profile.tsx` | Local `error` state (profile load/save failures), inline paragraph. | Same pattern as GarminConnection — state and paragraph removed, catch blocks call `showError`. |
| `pages/Activities.tsx` | Local `error` state (multiple load/action failures), inline paragraph. | Same. |
| `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, `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
their respective CSS files.
## Data flow example (sync completion)
1. User clicks "Sync now" → `GarminConnection.sync()` calls
`api.syncRun()`, sets `showSyncModal(true)`.
2. `SyncModal` polls `/api/sync/status` every 1.5s, rendering the
spinner/progress bar per `progress.Phase` (unchanged from the existing
implementation).
3. A poll response shows `in_progress: false` for the first time.
`SyncModal` builds a message from `last_run.Status`/`ErrorMessage`/
`ActivitiesFetched` plus `activities_pending_details`/
`workouts_pending`, calls `showSuccess(message)` or `showError(message)`
accordingly, then calls `onClose()`.
4. `GarminConnection` unmounts `SyncModal` (its normal `showSyncModal`
toggle, unchanged). The banner, now living in the shared store
independent of any unmounted component, continues to display and
auto-dismiss on its own schedule.
## Testing
No frontend test suite exists in this repo (established convention —
verification is `tsc -b`/`vite build`, `oxlint`, and manual browser
checks). Verification for this feature is:
- `npm run build` / `npm run lint` clean.
- Manual smoke test covering each severity: trigger a real API error (e.g.
an invalid save), a network failure (stop the backend, attempt any
action), a successful sync, a sync ending in error, a sync with pending
activities/workouts remaining, and the login screen's `auth_error`
redirect — confirming banners stack, auto-dismiss, and can be closed
manually, and that `SyncModal` now closes itself the instant sync
finishes rather than waiting for a manual Close click.

View File

@@ -0,0 +1,108 @@
# Stop retrying a permanently-missing Garmin workout forever — Design
**Status:** Approved, ready for implementation planning.
**Origin:** user report -- `/api/sync/status`'s `workouts_pending` count stuck at 1 forever, no
matter how many times "Sync now" is clicked.
## Root cause
`fillPendingWorkouts`/`fillActivityWorkout` (`backend/internal/sync/service.go:339-371`) treats
every `get_workout_by_id` failure identically: log it, leave `workout_raw_json` `NULL`, and let
`ActivitiesMissingWorkout` naturally retry it on the next sync. This is the right behavior for a
*transient* failure (network blip, rate limiting), but wrong for a genuine HTTP 404 -- the
activity's `workout_id` points at a workout that was deleted on Garmin's side after the activity
was recorded (confirmed via the user's own backend log: a 404 for a specific `workout_id`). A 404
is definitive and will never succeed on retry, yet the current code retries it every single sync,
forever, with the failure only ever logged server-side (`log.Printf`) -- never surfaced to the
user beyond a perpetual, unexplained "1 more workout pending" nudge.
## Fix
An activity can legitimately end up with `workout_id` set but `workout_raw_json` permanently
`NULL` when the workout can't be found on Garmin -- `workout_raw_json` stays `NULL` forever (no
fabricated data), but a new, separate marker records "confirmed missing" distinctly from "not yet
fetched," so `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` stop counting it.
### `internal/garmin/pyscript/wrapper.py`
`garminconnect` already defines `GarminConnectNotFoundError` (a subclass of
`GarminConnectConnectionError`) specifically for this case -- its own docstring says "so callers
can now catch a missing resource specifically (e.g. deleting an already-deleted workout)." It's
raised by `connectapi()` (used by `get_workout_by_id` and other lookups) whenever the underlying
HTTP response is a 404.
`dispatch()`'s generic exception handler (used by every `cmd`, including the generic `call`
dispatcher that `get_workout_by_id` goes through) gains a check: if the caught exception is a
`GarminConnectNotFoundError`, the returned JSON error response gets an additional
`"not_found": true` field alongside the existing `"error"` string. This is deliberately generic
(any Garmin API call that 404s gets this marker, not just workouts) -- the decision about what to
*do* with a not-found error stays method-specific in the Go/sync layer.
### `internal/garmin/client.go`
- `wireResponse` gains `NotFound bool `json:"not_found,omitempty"``.
- A new sentinel: `var ErrNotFound = errors.New("garmin: resource not found")`.
- `execute`: when `resp.Error != "" && resp.NotFound`, the returned error wraps `ErrNotFound`
(`fmt.Errorf("%s: %s: %w", cmdName, resp.Error, ErrNotFound)`), so callers can
`errors.Is(err, garmin.ErrNotFound)` regardless of which method was called.
### `internal/store` (schema + queries)
- `schema.sql`: `activities` gains `workout_not_found_at TEXT` (nullable), placed next to
`workout_raw_json`, following the exact same style as the existing `details_fetched_at`/
`splits_fetched_at` timestamp markers.
- New method `SetActivityWorkoutNotFound(ctx, userID, activityID) error`, mirroring
`SetActivitySplitsFetched`'s exact shape (`UPDATE activities SET workout_not_found_at =
datetime('now'), updated_at = datetime('now') WHERE id = ? AND user_id = ?`).
- `ActivitiesMissingWorkout`/`CountActivitiesMissingWorkout` both gain
`AND workout_not_found_at IS NULL` in their `WHERE` clause.
- `UpsertActivity`'s `ON CONFLICT DO UPDATE` already never touches `details_fetched_at`/
`details_raw_json`/`splits_fetched_at`/`workout_raw_json` -- `workout_not_found_at` follows the
same pattern, so it's untouched by a later re-sync and the "confirmed missing" marker persists
indefinitely once set.
- `docs/DATABASE.md` regenerated via `go run ./cmd/dumpschema` after the schema edit.
### `internal/sync/service.go`
In `fillPendingWorkouts`'s loop, when `fillActivityWorkout` returns an error:
- If `errors.Is(err, garmin.ErrNotFound)`: call `db.SetActivityWorkoutNotFound(ctx, s.userID,
a.ID)` and log at Info level ("workout not found on Garmin, likely deleted -- marking as such,
will not retry"). No further retry.
- Otherwise: keep the existing behavior exactly as it is today (log a warning, leave
`workout_raw_json` `NULL`, naturally retried on the next sync).
### `internal/garmin/mock`
`mock.Client`'s existing `WorkoutErrByID map[int64]error` field (added in a previous plan) needs
no shape change -- a test simulating a 404 just sets the mapped error to
`fmt.Errorf("...: %w", garmin.ErrNotFound)` (or `garmin.ErrNotFound` directly), which
`errors.Is` picks up the same way a real wrapped error would.
## Non-goals
- No change to `fillActivityDetails`/`fillPendingActivityDetails`'s error handling (a
details/splits fetch failure still aborts the batch and surfaces as a sync error, as it does
today) -- this fix is scoped to workouts specifically, matching the confirmed root cause. If a
details/splits 404 ever turns out to need the same treatment, that's a separate, unconfirmed
problem to investigate on its own evidence.
- No frontend change -- `workouts_pending` simply stops counting a confirmed-missing workout, so
the existing SyncModal/banner UI needs nothing new.
- No handling for the (very unlikely) edge case of Garmin re-linking a *different* `workout_id`
to the same activity after `workout_not_found_at` was set for an earlier one -- accepted as an
edge case not worth the added complexity.
## Testing
- `internal/garmin/pyscript/tests/test_wrapper.py`: a new test mirroring the existing
`test_call_propagates_garminconnect_exception_as_error`, but raising
`GarminConnectNotFoundError` and asserting the response includes `"not_found": true`.
- `internal/garmin` (Go): a test asserting `execute` wraps the error with `ErrNotFound` when
the wire response sets `not_found: true`.
- `internal/store`: extend the existing `ActivitiesMissingWorkout` test (or add a new one) with a
fixture that has `workout_not_found_at` set, asserting it's excluded from both
`ActivitiesMissingWorkout` and `CountActivitiesMissingWorkout`.
- `internal/sync`: a test using `mock.Client.WorkoutErrByID` set to a `garmin.ErrNotFound`-wrapped
error, asserting `fillPendingWorkouts`/`FillPendingDetails` completes without error, the
activity's `workout_not_found_at` gets set, and it's excluded from a subsequent
`CountActivitiesMissingWorkout` call (i.e., confirming it does NOT get retried on a second call,
unlike today's forever-retry behavior for this exact scenario).

View File

@@ -0,0 +1,132 @@
# Configuration: application vs environment — design
Date: 2026-08-03
Status: approved
## Problem
All runtime tuning today is either an env var (`internal/config`, process-level)
or a per-user `profile` column. There is no home for instance-global,
behavior-affecting settings shared by all users, and some env vars are
misnamed or shouldn't be env vars at all. From `docs/IDEAS.md` ("configuration").
## Concepts
- **Application configuration**: key/value pairs, stored in the database,
common to all users, cold (read once at startup; a change takes effect on
the next backend restart — no hot reload). Unrelated to the execution
environment.
- **Environment configuration**: env vars, tied to the execution environment
(paths, URLs, credentials for infrastructure). Unchanged mechanism.
## Data model
New table in `schema.sql`:
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
- Deliberately **not** user-scoped — the single exception to the per-user
convention, because application configuration is instance-global by
definition. A schema comment records this.
- The table stores **overrides only**; defaults live in code. Absent key =
default in effect.
- Regenerate `docs/DATABASE.md` (`go run ./cmd/dumpschema`) after the schema
change. Breaking schema change: delete the local DB and let `Open()`
recreate it (normal pre-production remedy).
## Application-config registry
`internal/config` gains a registry of known keys — name, default, validator,
description. Launch set (one key):
| key | type | default | validation |
|--------------------|---------------|---------|----------------------|
| `session.duration` | integer hours | `720` | integer, > 0 |
- `config.Load()` (env) stays as-is: fail-fast, runs before the DB opens.
- New `config.LoadApp(getter)` reads overrides through a small store-backed
getter, merges defaults, validates, and returns a typed `AppConfig` struct
(`SessionDuration time.Duration`). `main.go` calls it after `store.Open`
and wires the result into `api.SessionConfig.Duration`.
- Writes validate against the registry: unknown keys and invalid values are
rejected, so the `config` table cannot accumulate junk.
## Environment-variable changes (all breaking; pre-production, no shims)
| before | after |
|----------------------------|----------------------------|
| `GENIUSRUN_ADDR` | `GENIUSRUN_BACKEND_ADDR` |
| `GARMIN_WRAPPER_PYTHON` | `GENIUSRUN_PYTHON_PATH` |
| `GARMIN_TOKENSTORE` | `GENIUSRUN_TOKENSTORE_PATH`|
| `GENIUSRUN_MIN_CONFIDENCE` | removed — hard-coded to `classify.DefaultMinConfidence` (0.6) |
| `GENIUSRUN_SESSION_DURATION` | removed — becomes app-config key `session.duration` |
| `GENIUSRUN_SESSION_SECRET` | unchanged (stays env; still required, >= 32 chars) |
Also update: `backend/.env` (local), `CLAUDE.md`, config tests, and any doc
references.
## API
Both routes sit behind the usual session + `requireProvisionedUser` gates.
Any provisioned user may read and write (single-operator app in practice).
- `GET /api/config`
```json
{
"application": [
{"key": "session.duration", "value": "720", "default": "720",
"overridden": false, "description": "Session cookie lifetime in hours"}
],
"environment": [
{"name": "GENIUSRUN_BACKEND_ADDR", "value": ":8080"},
{"name": "GENIUSRUN_OIDC_CLIENT_SECRET", "value": "•••• (set)"}
]
}
```
- Secrets (`GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET`) are
masked as `•••• (set)` / `(unset)`; values never leave the process.
- `main.go` builds the display-safe environment snapshot once (from
`config.Config`, masking applied) and passes it to `api.NewServer` —
handlers never call `os.Getenv`.
- `PUT /api/config` with `{"session.duration": "168"}`:
validates every pair against the registry (reject unknown key / invalid
value with 400 naming the offender; all-or-nothing), upserts, returns the
same shape as GET. Cold semantics: response carries no magic — the UI
states that changes apply after a backend restart.
## Frontend
- **Config page** at browser path `/config` — `App.tsx` derives the view from
`window.location.pathname` (no router library; `history.pushState` on
navigation so the URL is shareable/deep-linkable, and Vite's SPA fallback
serves it in dev; a production reverse proxy needs SPA fallback for the
path).
- Page content: an "Application configuration" card (editable fields per
registry entry, Save button, a visible note "changes take effect after the
backend restarts"), and a read-only "Environment configuration" card
listing name/value pairs, secrets masked by the backend.
- **Header changes** (`App.tsx`), left to right after the tabs:
1. Profile button: icon-only, user-profile glyph (👤). The profile name
moves into `title`/`aria-label` — no visible text.
2. New settings button: icon-only gear (⚙), navigates to `/config`.
3. Log out: icon-only cross (✕), still the POST `<form>` (OIDC logout
needs a real navigation). `title`/`aria-label` "Log out".
- No visible text labels on any of the three; same emoji/glyph style as
the existing tabs, no icon library added.
## Testing
- `internal/store`: config upsert/read round-trip; overrides-only semantics.
- `internal/config`: table-driven registry tests — default when absent,
override applied, invalid value rejected, unknown key rejected.
- `internal/api`: httptest — GET shape + secret masking; PUT happy path,
unknown key 400, invalid value 400; 403 until provisioned (inherited gate,
asserted once).
- Frontend: live click-through — header icons, navigate to `/config`, edit
`session.duration`, save, verify persisted via GET and after restart.

12
docs/workouts.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: v1
name: geniusrun
templates:
- name: 'Recovery 30m'
difficulty: 1
kind: recovery
blocks:
- index: 1
reps: 1
block:
- kind: run
duration: 30m

View File

@@ -52,40 +52,6 @@ body {
color: white; color: white;
} }
/* Pill-shaped and icon-led, deliberately unlike the rectangular .tab
buttons: this opens account/profile settings, not an application view
alongside Activities/Progression/Training plan, so it reads more like an
account chip (à la Slack/GitHub's corner avatar) than another nav tab. */
.profile-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab;
padding: 0.4rem 0.9rem 0.4rem 0.7rem;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
font-size: 0.85rem;
}
.profile-name::before {
content: "⚙️";
font-size: 0.8rem;
}
.profile-name:hover:not(.active) {
border-color: #3b82f6;
color: #e6e6e6;
}
.profile-name.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.header-actions { .header-actions {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -93,19 +59,51 @@ body {
gap: 0.6rem; gap: 0.6rem;
} }
.logout-link { /* Icon-only header buttons (profile / settings / log out): circular chips,
background: none; deliberately unlike the rectangular .tab buttons -- these are account/
border: none; instance actions, not application views. No visible text: the glyph is
padding: 0; the label (full text lives in title/aria-label). */
font: inherit; .icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
background: #1a1d24;
border: 1px solid #2a2d35;
color: #9aa0ab; color: #9aa0ab;
font-size: 0.85rem; border-radius: 999px;
text-decoration: none;
cursor: pointer; cursor: pointer;
font-size: 0.95rem;
} }
.logout-link:hover { .icon-button:hover:not(.active) {
color: #3b82f6; border-color: #3b82f6;
color: #e6e6e6;
}
.icon-button.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.env-config div {
display: flex;
gap: 1rem;
padding: 0.25rem 0;
}
.env-config dt {
min-width: 18rem;
color: #9aa0ab;
font-family: monospace;
}
.env-config dd {
margin: 0;
font-family: monospace;
overflow-wrap: anywhere;
} }
.garmin-connection { .garmin-connection {
@@ -211,6 +209,11 @@ input[type="color"] {
cursor: pointer; cursor: pointer;
} }
.field-hint {
font-size: 0.75rem;
color: #6b7280;
}
button:hover:not(:disabled) { button:hover:not(:disabled) {
border-color: #3b82f6; border-color: #3b82f6;
} }
@@ -231,6 +234,17 @@ button:disabled {
color: #fff; color: #fff;
} }
.danger-zone-confirm {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.danger-zone-confirm p {
margin: 0;
color: #9aa0ab;
}
.filter-pills { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -258,10 +272,6 @@ button:disabled {
color: #9aa0ab; color: #9aa0ab;
} }
.error {
color: #f87171;
}
.review-list { .review-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
@@ -474,6 +484,74 @@ button:disabled {
word-break: break-word; word-break: break-word;
} }
.sync-modal-content {
width: min(420px, 90vw);
}
.sync-modal-body {
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.sync-modal-progress {
width: 100%;
height: 0.6rem;
}
.sync-modal-label {
margin: 0;
color: #9aa0ab;
font-size: 0.9rem;
}
.sync-modal-spinner {
width: 2rem;
height: 2rem;
border: 3px solid #2a2d35;
border-top-color: #3b82f6;
border-radius: 50%;
animation: sync-modal-spin 0.8s linear infinite;
}
@keyframes sync-modal-spin {
to {
transform: rotate(360deg);
}
}
.sync-modal-actions {
justify-content: flex-end;
padding: 0.75rem 1rem;
border-top: 1px solid #2a2d35;
}
.garmin-mfa-modal-content {
width: min(420px, 90vw);
}
.garmin-mfa-modal-body {
padding: 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.garmin-mfa-modal-message {
margin: 0;
color: #9aa0ab;
font-size: 0.9rem;
}
.garmin-mfa-modal-actions {
justify-content: flex-end;
padding: 0.75rem 1rem;
border-top: 1px solid #2a2d35;
}
.json-toggle { .json-toggle {
display: inline-block; display: inline-block;
width: 1rem; width: 1rem;
@@ -530,12 +608,19 @@ button:disabled {
max-width: 580px; max-width: 580px;
} }
.profile-page { .profile-page,
.config-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.75rem; gap: 1.75rem;
} }
/* Wider than the default .kind-editor cap: the environment card lists
URLs/paths as single-line monospace values that would otherwise wrap. */
.config-page .kind-editor {
max-width: 900px;
}
.training-type-list { .training-type-list {
list-style: none; list-style: none;
margin: 0; margin: 0;

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client"; import { api, BASE_URL } from "./api/client";
import "./App.css"; import "./App.css";
import { BannerStack } from "./components/BannerStack";
import { Activities } from "./pages/Activities"; import { Activities } from "./pages/Activities";
import { Analysis } from "./pages/Analysis"; import { Analysis } from "./pages/Analysis";
import { Config } from "./pages/Config";
import { Plan } from "./pages/Plan"; import { Plan } from "./pages/Plan";
import { Profile } from "./pages/Profile"; import { Profile } from "./pages/Profile";
import type { SessionInfo } from "./types/api"; import type { SessionInfo } from "./types/api";
@@ -13,21 +15,64 @@ const TABS = [
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan }, { key: "plan", label: "Training plan", icon: "✎", Component: Plan },
] as const; ] as const;
type TabKey = (typeof TABS)[number]["key"]; // Every view is pathname-addressable (/activities, /analysis, /plan,
// /profile, /config) so any of them can be deep-linked, bookmarked, and
// survives a reload -- no router library, just pushState + popstate with
// the URL as the single source of truth. "/" and anything unrecognized
// fall back to the default activities view.
const VIEWS = ["activities", "analysis", "plan", "profile", "config"] as const;
type View = (typeof VIEWS)[number];
function viewFromPathname(pathname: string): View {
const candidate = pathname.replace(/^\/+/, "").replace(/\/+$/, "");
return (VIEWS as readonly string[]).includes(candidate) ? (candidate as View) : "activities";
}
// POST_LOGIN_PATH_KEY is where LoginGate stashes the path a logged-out
// visitor was heading to: the OIDC flow always lands back on "/", so the
// deep link would otherwise be lost across the Keycloak round-trip.
export const POST_LOGIN_PATH_KEY = "geniusrun_post_login_path";
// restoreDeepLink resolves the initial view: normally straight from the
// URL, but right after a login round-trip (we're on "/" and LoginGate
// stashed a path) the stashed destination wins and the URL is rewritten
// to it -- replaceState, not pushState, so Back doesn't bounce through
// an intermediate "/" entry. The stash is consumed either way; a stale
// one must never redirect some later, unrelated visit to "/".
function restoreDeepLink(): View {
const stashed = sessionStorage.getItem(POST_LOGIN_PATH_KEY);
if (stashed === null) return viewFromPathname(window.location.pathname);
sessionStorage.removeItem(POST_LOGIN_PATH_KEY);
if (window.location.pathname !== "/") return viewFromPathname(window.location.pathname);
history.replaceState({}, "", stashed);
return viewFromPathname(stashed);
}
function App({ session }: { session: SessionInfo }) { function App({ session }: { session: SessionInfo }) {
const [tab, setTab] = useState<TabKey>("activities"); const [view, setView] = useState<View>(restoreDeepLink);
// Profile isn't a tab: it's reached via the profile name in the top-right
// corner instead, since (for now, single-profile) it's account settings,
// not a content view alongside Activities/Analysis/Training plan.
const [showProfile, setShowProfile] = useState(false);
const [profileName, setProfileName] = useState<string | null>(null); const [profileName, setProfileName] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {}); api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
}, []); }, []);
const Active = TABS.find((t) => t.key === tab)!.Component; useEffect(() => {
const onPop = () => setView(viewFromPathname(window.location.pathname));
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
function navigate(next: View) {
// No history entry when the URL already means this view (e.g. "/"
// already renders activities) -- avoids junk Back-button stops.
if (viewFromPathname(window.location.pathname) !== next) {
history.pushState({}, "", "/" + next);
}
setView(next);
}
const ActiveTab = TABS.find((t) => t.key === view)?.Component;
return ( return (
<div className="app"> <div className="app">
@@ -37,11 +82,8 @@ function App({ session }: { session: SessionInfo }) {
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.key} key={t.key}
className={!showProfile && t.key === tab ? "tab active" : "tab"} className={view === t.key ? "tab active" : "tab"}
onClick={() => { onClick={() => navigate(t.key)}
setShowProfile(false);
setTab(t.key);
}}
> >
<span className="tab-icon">{t.icon}</span> <span className="tab-icon">{t.icon}</span>
{t.label} {t.label}
@@ -51,19 +93,39 @@ function App({ session }: { session: SessionInfo }) {
<div className="header-actions"> <div className="header-actions">
<button <button
type="button" type="button"
className={showProfile ? "profile-name active" : "profile-name"} className={view === "profile" ? "icon-button active" : "icon-button"}
onClick={() => setShowProfile(true)} title={profileName ?? "Profile"}
aria-label={profileName ?? "Profile"}
onClick={() => navigate("profile")}
> >
{profileName ?? "Profile"} 👤
</button>
<button
type="button"
className={view === "config" ? "icon-button active" : "icon-button"}
title="Settings"
aria-label="Settings"
onClick={() => navigate("config")}
>
</button> </button>
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}> <form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
<button type="submit" className="logout-link"> <button type="submit" className="icon-button" aria-label="Log out" title="Log out">
Log out
</button> </button>
</form> </form>
</div> </div>
</header> </header>
<main>{showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main> <BannerStack />
<main>
{view === "config" ? (
<Config />
) : view === "profile" ? (
<Profile onSaved={(p) => setProfileName(p.Name)} />
) : (
ActiveTab && <ActiveTab />
)}
</main>
</div> </div>
); );
} }

View File

@@ -1,43 +0,0 @@
.create-profile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
text-align: center;
}
.create-profile form {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
.create-profile input {
padding: 0.6rem 0.8rem;
font-size: 1rem;
border-radius: 0.5rem;
border: 1px solid #ccc;
}
.create-profile button {
padding: 0.6rem 0.8rem;
font-size: 1rem;
border-radius: 0.5rem;
border: none;
background: #3b82f6;
color: white;
cursor: pointer;
}
.create-profile button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.create-profile-error {
color: #ef4444;
}

View File

@@ -1,52 +0,0 @@
import { useState } from "react";
import { api } from "./api/client";
import "./CreateProfile.css";
// Shown once, right after a brand-new OIDC login, before the account has a
// geniusrun profile at all. The only thing asked for is a display name --
// Garmin credentials and every other tunable are filled in afterward via
// the existing Profile screen, same as a fresh single-user install today.
export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) {
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const trimmed = displayName.trim();
if (!trimmed) {
setError("Please enter a display name.");
return;
}
setSubmitting(true);
setError(null);
try {
const result = await api.setup(trimmed);
onCreated(result.display_name);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
setSubmitting(false);
}
};
return (
<div className="create-profile">
<h1>🧞 Welcome to geniusrun</h1>
<p>Let's set up your profile. You can add your Garmin account afterward.</p>
<form onSubmit={handleSubmit}>
<input
type="text"
placeholder="Display name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={submitting}
autoFocus
/>
{error && <p className="create-profile-error">{error}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Continue"}
</button>
</form>
</div>
);
}

View File

@@ -11,10 +11,6 @@
color: #e6e6e6; color: #e6e6e6;
} }
.login-gate-error {
color: #f87171;
}
.login-gate-button { .login-gate-button {
display: inline-block; display: inline-block;
margin-top: 1rem; margin-top: 1rem;

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, BASE_URL } from "./api/client"; import { api, BASE_URL, NetworkError } from "./api/client";
import { showError } from "./banner";
import { BannerStack } from "./components/BannerStack";
import "./LoginGate.css"; import "./LoginGate.css";
import App from "./App"; import App, { POST_LOGIN_PATH_KEY } from "./App";
import { CreateProfile } from "./CreateProfile"; import { OnboardingWizard } from "./OnboardingWizard";
import type { SessionInfo } from "./types/api"; import type { SessionInfo } from "./types/api";
type Status = "loading" | "authenticated" | "unauthenticated"; type Status = "loading" | "authenticated" | "unauthenticated";
@@ -17,7 +19,11 @@ const AUTH_ERROR_MESSAGES: Record<string, string> = {
// this is the first fork -- "show the login screen" vs "show the app." A // this is the first fork -- "show the login screen" vs "show the app." A
// second fork, once authenticated, is whether the session's account has a // second fork, once authenticated, is whether the session's account has a
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login // provisioned profile yet (session.has_profile) -- a brand-new OIDC login
// sees CreateProfile instead of App until it submits one. // sees OnboardingWizard instead of App until it completes one. Nothing is
// persisted until the wizard's Garmin-connect step actually succeeds (see
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md),
// so has_profile alone is always sufficient here -- there's no separate
// "provisioned but not connected" state to gate on.
export function LoginGate() { export function LoginGate() {
const [status, setStatus] = useState<Status>("loading"); const [status, setStatus] = useState<Status>("loading");
const [session, setSession] = useState<SessionInfo | null>(null); const [session, setSession] = useState<SessionInfo | null>(null);
@@ -29,29 +35,63 @@ export function LoginGate() {
setSession(s); setSession(s);
setStatus("authenticated"); setStatus("authenticated");
}) })
.catch(() => setStatus("unauthenticated")); .catch((e) => {
// A NetworkError here means the backend itself isn't reachable, not
// that this browser is genuinely logged out -- worth a banner, since
// otherwise this is the one place in the app where "backend not
// here" would show no feedback at all. Either way the screen still
// falls through to "unauthenticated" (showing the Log in button),
// since there's no session to trust regardless of why the check
// failed.
if (e instanceof NetworkError) showError(e.message);
setStatus("unauthenticated");
});
}, []); }, []);
// Keycloak redirects back here with ?auth_error=<code> when the
// login-gate's own role check (not Keycloak's own authentication) rejects
// a session -- surfaced once, as a banner, the moment this screen is
// reached.
useEffect(() => {
if (status !== "unauthenticated") return;
const authError = new URLSearchParams(window.location.search).get("auth_error");
if (authError) showError(AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again.");
// Deep link survival: the OIDC flow always lands back on "/" (the
// callback's redirect), so remember where the user was actually
// heading -- App pops this stash on its first render after login and
// restores the path (see restoreDeepLink in App.tsx).
if (window.location.pathname !== "/") {
sessionStorage.setItem(POST_LOGIN_PATH_KEY, window.location.pathname);
}
}, [status]);
if (status === "loading") { if (status === "loading") {
return <div className="login-gate-loading">Loading</div>; return (
<>
<BannerStack />
<div className="login-gate-loading">Loading</div>
</>
);
} }
if (status === "unauthenticated") { if (status === "unauthenticated") {
const authError = new URLSearchParams(window.location.search).get("auth_error");
return ( return (
<>
<BannerStack />
<div className="login-gate"> <div className="login-gate">
<h1>🧞 geniusrun</h1> <h1>🧞 geniusrun</h1>
{authError && <p className="login-gate-error">{AUTH_ERROR_MESSAGES[authError] ?? "Login failed, please try again."}</p>}
<a className="login-gate-button" href={`${BASE_URL}/api/session/login`}> <a className="login-gate-button" href={`${BASE_URL}/api/session/login`}>
Log in Log in
</a> </a>
</div> </div>
</>
); );
} }
if (!session!.has_profile) { if (!session!.has_profile) {
return ( return (
<CreateProfile <OnboardingWizard
defaultName={session!.name}
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
/> />
); );

View File

@@ -0,0 +1,74 @@
.onboarding-wizard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 1rem;
text-align: center;
}
.onboarding-wizard form {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
.onboarding-wizard input {
padding: 0.6rem 0.8rem;
font-size: 1rem;
border-radius: 0.5rem;
border: 1px solid #ccc;
}
.onboarding-wizard button {
padding: 0.6rem 0.8rem;
font-size: 1rem;
border-radius: 0.5rem;
border: none;
background: #3b82f6;
color: white;
cursor: pointer;
}
.onboarding-wizard button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.onboarding-wizard-error {
color: #ef4444;
}
.onboarding-wizard-message {
margin: 0;
color: #9ca3af;
}
.onboarding-wizard-mfa {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 100%;
max-width: 320px;
}
.onboarding-wizard-name-row {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
.onboarding-wizard-name-row input {
flex: 1;
min-width: 0;
}
.onboarding-wizard-icon-button {
flex: 0 0 auto;
padding: 0.6rem 0.9rem;
font-size: 1.1rem;
line-height: 1;
}

Some files were not shown because too many files have changed in this diff Show More