Commit Graph

158 Commits

Author SHA1 Message Date
f57b314be4 store: fix PRAGMA foreign_keys scoping for table-rebuild migrations
The previous implementation issued PRAGMA foreign_keys toggles inside
transactions (after tx.Begin()), but SQLite ignores pragmas once a
transaction is open with modernc.org/sqlite, making those calls no-ops.

This fix relocates the toggle to autocommit mode (db.Exec, not tx.Exec)
and limits it to only the three migrations that need it (0023/0024/0025),
which rebuild tables with incoming foreign keys (kind_assignments/
workout_type_paces reference workout_kinds; activities/sync_runs
reference sync_state).

The connection string now correctly restores ?_pragma=foreign_keys(1),
ensuring FK enforcement is ON by default for normal runtime operation
and during migrations that don't need the special handling.

Each affected migration now:
1. Disables FK in autocommit mode before starting its transaction
2. Runs the migration's CREATE/INSERT/DROP/RENAME sequence
3. Re-enables FK in autocommit mode after the transaction commits

All other migrations run normally with FK enforcement active throughout,
protecting against cascading deletions silently failing if a future
connection change causes FK enforcement to inadvertently remain off.

Added TestForeignKeyEnforcementPostMigration to verify FK is correctly
enforced after all migrations complete: valid FK references are accepted,
and invalid ones are rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 10:33:35 +02:00
245d4bf821 store: add users table and user_id scoping to migrations
Schema-only step toward per-user profile isolation: profile/workout_kinds/
sync_state are rebuilt to drop their singleton constraints, activities/
sync_runs gain a nullable user_id column. Store methods are scoped in
later tasks.
2026-07-25 10:20:59 +02:00
033e412cb7 docs: add per-user profile implementation plan
Copied into this worktree so subagent-driven-development can operate on
it; the plan was written and reviewed on main before this branch existed.
2026-07-25 10:13:12 +02:00
d29f69903d docs: design per-user profile and data isolation
Reverses the "single active profile, OIDC is access-only" decision from
2026-07-24 now that login needs to map each user to their own dataset
instead of a shared singleton.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 09:20:37 +02:00
502e61e7b4 Fix OIDC login-gate cross-file wiring bugs from whole-branch review
Four bugs slipped through per-task review since each task only saw its
own diff:

- Logout was a plain <a href> GET against a POST-only backend route, so
  it 405'd and never cleared the session cookie or hit Keycloak's
  end-session redirect. Now a <form method="post"> with a submit button
  styled to match the old link (still a real full-page navigation, not
  a fetch, so the Keycloak redirect chain still works).
- Login/logout used origin-relative paths, unreachable from the Vite
  dev server (:5173) against the backend (:8080) with no proxy
  configured. Both now build their URL from client.ts's now-exported
  BASE_URL.
- handleSessionCallback's four failure paths redirected to
  /?auth_error=failed with no logging, making a real OIDC failure
  undiagnosable in production. Added log.Printf on each failure site.
- handleSessionLogout passed a bare "/" to EndSessionURL; Keycloak
  requires post_logout_redirect_uri to be an absolute, registered URL.
  Added SessionConfig.PublicBaseURL, wired from cfg.PublicBaseURL in
  main.go, and used to build an absolute redirect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 22:03:02 +02:00
eeff1e9b40 docs: document the OIDC login gate and its required env vars 2026-07-24 21:50:42 +02:00
815db1ec1f frontend: add LoginGate, wire session into App, add logout link 2026-07-24 21:47:41 +02:00
6dd4d9309c frontend: add session API, credentialed fetch, 401-triggered relogin 2026-07-24 21:44:19 +02:00
f9990ddcc2 geniusrund: construct the OIDC verifier and require its config at startup 2026-07-24 21:41:32 +02:00
26b903721a api: gate all routes behind an OIDC session, add session endpoints
Router() now wraps every route except /health, /session/login, and
/session/callback in a chi group requiring a valid session cookie
(auth.RequireSession). Adds internal/api/session.go with the four
session HTTP handlers (login/callback/logout/me) and SessionConfig.
NewServer takes an auth.Verifier and SessionConfig. Test infra
(doJSON, newTestServer) now mints/attaches a signed session cookie
automatically so the 36 pre-existing tests keep exercising the
already-logged-in path unchanged, plus 8 new tests cover the gating
and session endpoints themselves.
2026-07-24 21:36:00 +02:00
48c17bf8ba auth: add RequireSession chi-compatible middleware
Implements HTTP middleware that enforces session authentication by validating
session cookies and making claims available via ClaimsFromContext. Rejects
requests without valid session cookies with 401 Unauthorized.

Tested via four test cases:
- Missing session cookie rejection
- Valid cookie acceptance with claims extraction
- Expired cookie rejection
- Tampered cookie rejection

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-07-24 21:30:39 +02:00
59df391291 auth: add Keycloak OIDC verifier, role check, and test mock 2026-07-24 21:26:15 +02:00
bf41a34f59 auth: add signed session/transaction cookie mint and parse
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:20:04 +02:00
5c0a9ad96c config: add OIDC/session env vars for the login gate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:13:07 +02:00
f9d85e16ef Rename smartrun to geniusrun throughout the codebase
Updates the Go module path, cmd/smartrund -> cmd/geniusrund, the
smartrun-dev skill, .gitignore, and every reference in docs/CLAUDE.md
to match.
2026-07-24 21:08:07 +02:00
70c6d143e1 Add implementation plan for the OIDC login gate
Task-by-task TDD plan covering config, the new internal/auth package
(session cookies, Keycloak OIDC verifier, RequireSession middleware),
wiring into internal/api and cmd/geniusrund, and the frontend
LoginGate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 21:02:42 +02:00
fd80065e81 Add design doc for OIDC-based app login gate
Adds an access-gate authentication design: Keycloak OIDC via a
backend-driven Authorization Code flow, restricted by realm role,
with geniusrun minting its own session cookie. No data model or
multi-profile changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 20:45:46 +02:00
Christophe Vila
2fab788208 Fix Garmin connect race, surface tool error content, add dev start scripts
- GarminConnection now flushes Profile's pending debounced autosave before
  connecting, avoiding a race where Connect fires with stale credentials.
- client.go reads res.Content before checking IsError so tool error
  messages actually include mcp-garmin's response text.
- seedsample: update kind lookups to match current taxonomy names
  ("Easy Run" -> "Easy", "Interval" -> "Intervals").
- Add backend/start.sh and frontend/start.sh dev launch scripts, and
  check in CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 18:33:49 +02:00
518bccf5ab Dedup Garmin data storage, configurable chart colors, taxonomy fixes, sync/progression fixes
- Remove duplicated Garmin fields from storage; decode display-only fields
  (activity name/type, lap duration/HR, structured workout raw JSON) from
  RawJSON at API-response time instead of storing redundant columns
- Add a fully configurable chart color system (pace/HR main-line colors, 4
  effort-kind colors, tint/darken/brighten intensity knobs) under Profile >
  Chart colors
- Rename training types and fix their display order (Easy, Long, 60'/30'
  Threshold, Tempo, Intervals, MAS Test, Race) everywhere they're listed
- Add an Efficiency Factor progression metric; fix Progression chart axes to
  use tight non-zero-based domains, m:ss/km pace formatting, and rounded
  ticks instead of raw floating-point labels
- Expose the raw get_workout_by_id() payload in the raw-data viewer
  alongside activity/lap/detail JSON; enlarge the modal and shrink array
  indentation for readability
- Fix "last sync" reporting a meaningless activity count: record one
  combined sync run per manual "Sync now" and count genuinely new
  activities instead of re-listing whatever Garmin returned for the queried
  window
- Let a Review Queue activity be manually cleared back to Unclassified, and
  make "Reset all" available even while disconnected from Garmin

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 06:33:47 +02:00
9818d35910 Tolerate exactly one extra trailing lap when aligning workout targets
Confirmed via Garmin Connect against a real activity ("Auriol - W3-3-Double
Barrel"): the workout's step count didn't match its 18 recorded laps
because the athlete kept running 6:46 past the prescribed 5-minute
cool-down, logged as an 18th lap the workout never defined. The strict
equality check meant this one extra lap discarded every other lap's real
target too, not just its own.

alignWorkoutTargets now tolerates exactly one extra recorded lap beyond the
step count: the steps that do exist still zip to their laps normally, and
only the trailing extra lap is left without a target. Any larger mismatch
still falls back to nil for every lap, since that can't be trusted at all.
2026-07-19 18:01:06 +02:00
513418123f Add configurable pace-artifact filtering to replace the hardcoded cutoff
Two new Profile settings -- "minimum representative pace" and "minimum
representative time" -- replace the previous hardcoded 20:00/km cutoff. A
stretch of consecutive samples slower than the configured pace is now
dropped from the chart (and its Y-axis scale) only if it lasts no longer
than the configured time; a longer stretch is kept as a real stop or walk
break rather than noise. Defaults to 12:00/km and 3 seconds.

Caught a boundary bug while verifying against real data: a run lasting
exactly the threshold duration survived filtering because the comparison
used strict "<" instead of "<=", contradicting "not lasting more than N
seconds" (which should include exactly N).
2026-07-19 17:38:50 +02:00
619277b0ee Suppress the target band for laps that are just an unplanned continuation
Confirmed against Garmin Connect's own workout view: a lap whose
IntensityType merely repeats the immediately preceding lap's (e.g. a second
"cool-down" lap right after the first) means the recording continued past
the end of that step, not that the workout prescribed a second target for
it. Only the first lap of such a run keeps its target/HR fields; the
continuation shows the actual trace with no expectation overlay. Doesn't
affect normal interval structure, since Effort/Recovery always alternate
and never appear back-to-back.
2026-07-19 17:17:19 +02:00
1fb00f5bc6 Merge consecutive same-phase laps before computing the average reference line
A cool-down (or warm-up) split across two or more laps was drawing each
lap's own average as a separate flat segment, so a workout with a 2-lap
cool-down showed two different "cool-down averages" back to back instead
of one. Consecutive laps sharing an IntensityType are now merged into a
single phase segment with one duration-weighted average across all of them.
2026-07-19 16:57:47 +02:00
2c2d4966b2 Fix pace/elapsed-time formatting rounding 59.6s up to ":60" instead of carrying over
formatPaceShort and formatElapsed each floored the minutes and rounded the
leftover seconds independently, so e.g. 419.6 sec/km displayed as "6:60/km"
instead of "7:00/km". Both now round the total seconds once, then split
into minutes/seconds from that rounded value.
2026-07-19 16:55:11 +02:00
82f08c0c1e Stack Pace/HR charts vertically at double height instead of side by side
Each chart now takes the full card width and 200px height (was 100px,
half-width side by side), making the phase bands, target range, and
per-second trace much easier to read.
2026-07-19 16:41:30 +02:00
db4c76b558 Paginate the Review Queue with cursor-based infinite scroll
Fetching every needs_review activity's laps and per-second samples up front
gets expensive once there are many -- that work is now deferred to the
current page only. GET /api/review-queue/ takes limit/before and returns
{items, next_cursor, total}; sorting/cursor filtering still needs each
activity's cheap summary row, but only the requested page's items get their
laps/samples fetched.

Frontend loads 10 at a time and fetches the next page when the list's
bottom sentinel scrolls into view. Selecting a specific-kind filter (not
"All") loads the rest of the backlog up front, since filtering only the
items scrolled into view so far would hide matches further down.
2026-07-19 16:31:24 +02:00
9ec721b9fc Style chart tooltips to match the dark theme
Recharts' default tooltip is a plain white box, which stood out badly
against the app's dark background. Applied across both the Review Queue's
pace/HR charts and the Progression chart.
2026-07-19 16:13:52 +02:00
02ee9fa76e Show the average line for single-effort-type workouts too; rebrand header to geniusrun
Previously the dashed average pace/HR line only drew for warm-up/cool-down
laps. Workouts with only one kind of effort throughout (no warm-up/cool-down
segmentation at all, e.g. a plain Long Run) had nothing to compare the
actual trace against, so they now get the same dashed line spanning the
whole activity at its overall (duration-weighted) average.

UI-only rename: "smartrun" -> "geniusrun", prefixed with the genie emoji.
2026-07-19 16:05:41 +02:00
a6efcec33b Fix Y-axis label overlap, add phase-boundary lines and warm-up/cool-down average lines, map INTERVAL to Effort
Y-axis ticks now use a fixed small count (3) snapped to round increments
(30s for pace, 5-20bpm for HR depending on range) instead of Recharts'
automatic placement, which was silently dropping most ticks in the
~100px-tall mini chart and occasionally leaving only one label visible.

A light vertical line now marks every point the effort type changes, even
between phases that don't get a background color. Warm-up and cool-down
segments -- which rarely have a target band -- get a dashed line at their
own average pace/HR instead, as a reference point.

"Pertuis - 1500m Hill" showed no phase coloring because its lap uses
Garmin's INTERVAL intensity type, which wasn't mapped -- confirmed via query
that 23 laps across the account use it, alongside the 5 types already
handled. It's the same concept as ACTIVE (a work/effort segment), so it now
shares that color and label.
2026-07-19 15:58:04 +02:00
252c8fa0d1 Filter start-of-run pace artifacts, use a percentile-based Y scale, round pace ticks to 30s
Samples right as recording starts (before the run itself begins) can compute
an implausible pace like 50+ min/km from a near-zero speed reading; these
are now treated as unknown rather than plotted. The pace/HR domain is also
now built from the 2nd-98th percentile of the actual trace instead of true
min/max, so an occasional stray point (a brief pause, GPS noise) can't
single-handedly stretch the scale and squash the rest of a steady effort --
the target band, which is deliberate data rather than noisy telemetry, is
still always fully included. Pace axis ticks always land on a round
30-second mark (7:30, 8:00, 8:30, ...).
2026-07-19 15:42:49 +02:00
7f0373f2f3 Build the Review Queue chart's actual trace from per-second samples, not lap averages
A lap can span many minutes, so plotting one flat value per lap hid real
within-lap variation -- a single-lap hill repeat rendered as a dead-flat
line despite the pace/HR swinging throughout it. The actual pace/HR trace
now comes from activity_samples (per-second telemetry) when available,
looking up each sample's enclosing lap only for the target band/phase
color. Falls back to the previous lap-stepped rendering when an activity
has no samples.
2026-07-19 15:25:48 +02:00
647757dcab Add a raw-data viewer to Review Queue cards, improve chart color palette
A "Raw data" button at the bottom right of each card opens a modal with the
full activity/laps/assignment JSON (embedded RawJSON strings parsed back
into objects for readability), to make it easier to inspect exactly what
data is available when iterating on the charts.

Phase band colors (warm-up/effort/recovery/cool-down) previously reused the
same blue/red as the pace/HR accent colors, making them hard to tell apart.
New palette (amber/pink/teal/violet) avoids both accents entirely.
2026-07-19 15:05:46 +02:00
6c6783ce50 Redesign the Review Queue pace/HR chart: always show actuals, time-based X-axis, area style, phase-colored bands
Previously the chart rendered nothing at all unless a structured Garmin
workout's target was resolved, which meant most activities showed no chart.
Now it always shows actual pace/HR over elapsed time (in minutes) for any
activity with laps, filled as an area rather than a line, with the expected
band layered on top only when available. Each lap's warm-up/effort/recovery/
cool-down phase (from Garmin's per-lap IntensityType) shades the background,
with a small legend for whichever phases are present.
2026-07-19 14:54:10 +02:00
249826afc0 Move backfill horizon from a startup env var into the editable profile
SMARTRUN_BACKFILL_HORIZON_DAYS was a server-startup-only env var with no UI,
defaulting to 3 years -- so editing the unrelated "Rolling window" profile
field (for classification, not sync) had no effect on how far back Sync Now
reached. Backfill horizon is now Profile.BackfillHorizonDays, read fresh on
every Backfill call, with its own field on the Profile page.
2026-07-19 12:57:18 +02:00
93746666b5 Reload the page after Reset all finishes
Other pages (Review Queue, Activities) fetch their own data once on mount
and had no way to know activities were deleted elsewhere, so they kept
showing stale rows until a manual reload.
2026-07-19 12:45:03 +02:00
7f7e4b10f0 Merge backfill into Sync now, replace Full backfill with a destructive Reset all
Sync now now runs Backfill (resumes from the watermark, so a widened history
horizon is picked up automatically) then IncrementalSync then detail-fill, in
one click. "Full backfill" is gone -- in its place, "Reset all" (styled as a
destructive action, gated by a confirm dialog) wipes every synced activity
and its laps/kind assignments and rewinds the backfill watermark, so the next
sync performs a genuinely fresh pull instead of trying to patch up existing
rows with newer schema fields.
2026-07-19 12:41:38 +02:00
af41aa0f7f Show expected vs actual pace/HR per lap in the Review Queue
Activities recorded from a structured Garmin workout carry a workoutId;
when its steps line up 1:1 with the recorded laps, the per-step target
pace/HR zone is resolved (via a Karvonen lookup for named zones) and stored
on each lap. The Review Queue plots it against the actual per-lap pace/HR
so the user can eyeball whether a run matches its plan while sorting it.
2026-07-19 11:55:13 +02:00
0610897541 Replace per-kind reclassify with a single global reclassify that respects manual and Race locks
Manual assignments are the user's definitive word and Race assignments come
from a hard Garmin fact (eventType.typeKey), not a retunable rule -- neither
is ever touched by reclassify again, and Race can no longer be set by hand
via the review queue. Adds an Activities page so this locked/unlocked status
is visible per workout, since Review Queue only ever showed unresolved items.
2026-07-19 11:11:50 +02:00
8ff3d62b2f Add Race workout kind with Garmin eventType auto-detection, sync-time running filter, and pill-based review queue filter
Race is the 8th fixed workout kind, seeded with a real (not placeholder)
rule since Garmin Connect's eventType.typeKey reports "race" for
manually-tagged race activities. Non-running activity types (padel,
cycling, strength training, ...) are now dropped at sync time instead of
being stored. The Review Queue's type filter is now clickable exclusive
pill buttons instead of a dropdown.
2026-07-19 10:52:09 +02:00
dfc883acb6 feat: replace per-workout-type phase-detection settings with one global pair
The 14 phase-related profile columns (warmup/cooldown per workout type,
plus an unused Interval pair) added more granularity than wanted. Migration
0006 drops all 14 and adds a single warmup_minutes/cooldown_minutes pair
applied uniformly to every fixed-duration workout type; Interval still
detects phases from lap data directly and ignores this setting. Restores
a simplified Phase detection section in the Profile UI with just the two
fields.
2026-07-18 08:53:21 +02:00
7f4877df57 feat: review queue polish (pace, sort, type filter), drop unused phase-detection UI
Review Queue: show pace alongside distance/duration/HR, sort by activity
date (most recent first) instead of classification timestamp, and add a
filter by workout kind with a special "Unsorted" option for runs where
the rule engine found zero candidates at all (distinct from ambiguous
multi-candidate runs).

Profile: remove the phase-detection warm-up/cool-down section from the
UI -- not used by anything yet (phase segmentation is future work) and
was adding noise. The underlying fields are untouched so no data is lost
and the settings screen still round-trips them on save.
2026-07-18 08:39:23 +02:00
75d8a8dd0d docs: fix stale env-var docs, document validateProfile's zone-range rationale
Two Minor findings from the final whole-branch review: the smartrun-dev
skill still told developers to set GARMIN_EMAIL/GARMIN_PASSWORD (now
sourced from the profile row instead), and validateProfile's deliberate
choice not to require 0-100% HR zone coverage had no explanation.
2026-07-17 19:36:51 +02:00
91b901ba60 fix: update seedsample for the fixed workout taxonomy and profile-based max HR
Update workout kinds by fixed name (via ListWorkoutKinds/UpdateWorkoutKind)
instead of creating new ones, since the taxonomy migration now seeds
Easy Run/Tempo/Interval by unique name. Set profile.MaxHeartRate so
avg_hr_pct_max is computed during classification, and drop the removed
appsync.Config.MaxHR field.
2026-07-17 19:29:02 +02:00
17ebe3c665 feat: add profile settings page and fixed-taxonomy WorkoutKinds UI with pace/HR-zone editing 2026-07-17 19:22:25 +02:00
9cfa3e138e feat: source Garmin credentials from the profile instead of env vars 2026-07-17 19:18:06 +02:00
c11dd152ba feat: classification reads max HR from the profile instead of static config
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:13:24 +02:00
078c87c413 feat: fix workout taxonomy to 7 types, add pace/HR-zone fields to the API 2026-07-17 19:09:55 +02:00
9c42931c9e fix: restore standard HR zone defaults, relax zone-boundary validation instead of migration 2026-07-17 19:06:01 +02:00
57be9065cd feat: add profile REST endpoints with HR zone validation
Implements GET /api/profile and PUT /api/profile endpoints with validation
of HR zones (must be contiguous, non-overlapping, 0-100%). Also updates
profile migration to use correct HR zone defaults (0-20, 20-40, etc.)
that match validation requirements.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 19:03:29 +02:00
6f62686d29 feat: support updating Garmin credentials at runtime
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 18:57:54 +02:00