Commit Graph

157 Commits

Author SHA1 Message Date
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
65a2dc90d0 refactored component names 2026-07-26 10:00:24 +02:00
3b2b5d0735 refined ideas + start.sh rely on .env 2026-07-26 09:59:56 +02:00
d308201803 fix(api): logout's post_logout_redirect_uri uses FrontendURL, not BackendURL
Same class of bug as the OIDC callback fix: handleSessionLogout redirected
Keycloak's end-session flow back to BackendURL+"/", which 404s in a
split-origin deployment (the backend serves no "/" route). Flagged as a
known-deferred question in the callback-redirect design spec; fixing it
now that it's been hit in practice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:37:22 +02:00
099e746119 refactor(config): rename GENIUSRUN_PUBLIC_BASE_URL to GENIUSRUN_BACKEND_URL
Now that GENIUSRUN_FRONTEND_URL exists as a separate config value, keeping
the backend's own origin named "PublicBaseURL" invited exactly the kind of
mixup that caused the OIDC callback 404 in the first place. Renamed
consistently: env var, Config.BackendURL, api.SessionConfig.BackendURL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:00:35 +02:00