Commit Graph

99 Commits

Author SHA1 Message Date
owain b5b838bddc Fix treadmill/indoor distance over-measure and clean up polluted PRs
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
- Garmin Connect sync now applies Garmins corrected summary distance/moving time,
  overriding the raw wrist-estimated FIT distance for treadmill/indoor runs
- Exclude indoor (no-GPS) runs from distance personal records (bogus fast splits)
- backfill_indoor_distances task: re-fetch corrected distance for historical indoor runs
- recompute_personal_records_all task: wipe and rebuild PRs from valid activities

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:55:21 +01:00
owain e9cb1ea4e4 Dashboard Running PRs: show fixed 1k, 1 mile, 5k, 10k distances
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:42:12 +01:00
owain c05d27c115 Dashboard grid: 1-col min width for stats, vertical compaction for make-room dragging
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / validate (push) Successful in 2s
Build and push images / build-frontend (push) Successful in 9s
- Stat widgets can now be resized down to a single column (minW 1)
- Switch to vertical compaction so dragging a widget pushes others down to reveal the drop spot, then settles (fixes both the no-move and fly-off behaviours)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:36:29 +01:00
owain bb09c37b3d Dashboard polish: bounded drag push, equal-height stats, dark featured map, sport-coloured weekly bars
Build and push images / validate (push) Successful in 3s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
- Prevent widgets flying down the page when dragging (preventCollision)
- Stat widgets fill their cell height so cards with/without sub-text align
- Featured-activity map defaults to dark tiles
- Weekly distance bars stacked and coloured by activity type, with a legend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:31:04 +01:00
owain 491660fc6b Dashboard: per-widget add/delete, free placement, VO2/HRV stats, body battery graph, more widgets
Build and push images / validate (push) Successful in 3s
Build and push images / build-frontend (push) Successful in 9s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
- VO2 max and HRV status now available as top-bar stat widgets
- Edit mode can add (palette) and remove (x) individual widgets
- Body Battery widget shows todays intraday graph (fixed collapsing height)
- compactType disabled so widgets stay put and align along top edges (no jumping up)
- New optional widgets: cycling, stress, active calories, floors, VO2 trend, sleep stages, weight trend

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:25 +01:00
owain 8ed47d6042 HRV balanced dots, dashed gap lines, dashboard widgets + drag-to-edit layout
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 21s
- Green dots for balanced HRV (joining orange unbalanced / red low) on the trend
- Trend charts bridge data gaps with a dashed line instead of a blank break
- Dashboard: drop Health today; add VO2 max, small sleep, and HRV status widgets
- Dashboard: editable widget grid (react-grid-layout) with drag/resize, saved per-user

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:04:43 +01:00
owain af32a0bb7f Add medals, HRV status dots, smooth segment hover, side-by-side map/timeline, HR zone times
Build and push images / validate (push) Successful in 3s
Build and push images / build-frontend (push) Successful in 10s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
- Silver/bronze medals (not just gold) on route & segment leaderboards
- Colour HRV nightly-avg trend dots: orange unbalanced, red low
- Project segment-hover dot smoothly along the track line (interpolated)
- Show map and activity timeline side by side, half width each
- Show time spent in each HR zone next to the percentage

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:44:20 +01:00
owain ec87f68729 Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s
- Grey out trend ranges beyond available health history
- Reject implausibly fast (vehicle) activities on upload with feedback
- Add cancel button + cooperative cancellation for Garmin sync
- Show daily steps prominently on the dashboard
- Clear errors for malformed/empty upload ZIPs
- Snap-target dot when drawing a segment on the map
- Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS
- Parse and display moving time (timer) vs elapsed; backfill task
- Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 19:41:56 +01:00
owain 057eb9391a Fix passkey-disabled message obscured by null hash check
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
Check pocketid_sub before hashed_password so users with a linked
passkey (and hence a null hash) get the helpful message rather than
"Invalid credentials".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:38:12 +01:00
owain 01a8fe135c Disable password login once a passkey is linked
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 6s
- /token: reject password auth with a clear message if pocketid_sub is
  set on the account — passkey-linked users must sign in via PocketID
- Link callback + auto-link-by-email: null out hashed_password when the
  passkey is attached so the old hash can't be used even if the check
  above were bypassed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:34:23 +01:00
owain d350e9caea Add per-route top-10 leaderboard to activity detail
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s
New /activities/{id}/route-leaderboard endpoint ranks the user's timed
efforts on the same route; frontend RouteLeaderboard card sits beside
Laps, showing this activity's time/rank/gap and the top 10 (current
effort highlighted green, also surfaced if outside the top 10).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:37:37 +01:00
owain bdd5f80c7e Harden auth/upload, fix PR-delete cascade and sync backfill
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 8s
- OIDC: require signed short-lived state on login callback; reject
  missing userinfo sub (account-takeover guard); validate token
  exchange + userinfo responses
- Upload: safe zip extraction (path-traversal + zip-bomb cap),
  streamed size-capped writes, sanitised filenames
- Garmin: increasing lookback resets last_sync_at for one-time backfill
- Activities: delete/reprocess remove PersonalRecord rows (no FK cascade)
- Profile: validate /weight limit; sync lookback UI copy
- Dashboard: sleep shading uses same day as charted body battery

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 20:24:24 +01:00
owain 04689a29bd Cut Garmin sync API volume; dashboard/health/records/UI improvements
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s
Garmin Connect sync:
- Incremental syncs now re-fetch only a 1-day buffer (yesterday + today)
  instead of the full lookback window every run. Full lookback applies on
  the first sync only. Cuts steady-state API calls ~10x.
- Beat interval is now configurable via GARMIN_SYNC_INTERVAL_MINUTES and
  surfaced to the UI; the sync toggle is relabelled to the real cadence.

Frontend:
- Collapsible sidebar; clearer logged-in user + role display.
- Unified Body Battery colouring between dashboard and health (shared util).
- Sleep score trend chart on health page.
- Segments + medals on the dashboard's most-recent activity.
- Segments tab on the Records page.

Repo hygiene: add .gitignore, untrack committed __pycache__/*.pyc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:52:52 +01:00
owain 6a1726e0c3 Fix sleep score parsing, dashboard body battery, segment direction
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s
- Garmin sync: read sleepScores from dailySleepDTO (Garmin nests it there),
  so sleep score is actually stored instead of always null
- Dashboard: pass YYYY-MM-DD to the intraday endpoint (was a full ISO
  timestamp), so the body-battery tile populates
- Segment matching: follow the segment in its created direction with a
  path-length sanity check, so out-and-back routes no longer match an early
  start pass to a late finish (the >1h bogus segment times)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:58:53 +01:00
owain 0aa27713ca Fix follow-ups: lap bests, segments, charts, dashboard health
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
- Lap bests: compare against OTHER activities on the route (exclude self),
  so single-activity routes no longer show every lap as "best"
- Segment create: POST to trailing-slash URL (was a 307 that dropped the body);
  surface errors in the UI
- PR splits: scale GPS distance stream to the activity's official distance so
  over-measured GPS no longer yields bogus split PRs
- Speed route colours: red->orange->green->blue->purple (slow->fast) with smooth
  interpolation + a Slow/Fast gradient key under the map
- Health body battery: snap activity highlight to the categorical axis;
  white tooltip text + % suffix
- Health weight: y-min = lowest weight - 20kg; st/lb hover shows total lbs too
- Health sleep: move 8h/avg reference labels into the right margin
- Dashboard: Health-today pulls latest non-null values (sleep score, VO2 max);
  body battery tile renders a condensed colour-graded intraday graph

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:39:26 +01:00
owain bc437cce92 Batch 1: dashboard, maps, segments rewrite, health, sync UX
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
Fixes:
- Dashboard: featured most-recent activity card with map + stats
- Maps default to Street; preferCanvas + larger tile buffer for smoother pan/zoom
- Running cadence as colour-banded dots + 165 spm guide line
- Routes: inline row expansion, rename (PATCH /routes/{id}), podium + deltas, tiled map
- Records: remove reversed pace Y-axis
- Profile: remove resting HR; add goal weight
- Health: snapshot weight carry-forward; VO2 trend axis 30-70;
  weight goal line + kg/st-lb toggle + axis max; sleep 8h/avg lines
- Garmin sync progress moved to global store with persistent floating bar

Features:
- Speed-coloured activity route (default) with Speed/Solid toggle
- GPS-geometry segments: draw on map, match across all activities,
  1st/2nd/3rd leaderboard + podium badges (replaces old distance segments)
- Lap bests: best time per lap across a route + delta column
- Body Battery: highlight activity time windows

Schema: users.goal_weight_kg ALTER; new segments/segment_efforts tables.
Removes RouteSegment, the Segments page, and segment-bests endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:59:06 +01:00
owain e5feeb1178 Add explicit "link passkey to my account" flow
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 34s
Signing in by passkey on a fresh install created a new empty account because
the seeded admin has no email to match on. Add canonical SSO-style linking: an
authenticated user starts an OIDC flow whose `state` is a signed, short-lived
"link to user N" token (purpose=pocketid-link). The callback detects that state
and attaches the returned identity to that account instead of creating/matching
one — no reliance on emails lining up, and no group gating (the initiator is
already authorised; this is identity linking, not access control).

- auth.py: _make_link_state/_decode_link_state, GET /pocketid/link-url, callback
  handles state (rejects if the passkey is already on another account →
  auth_error=passkey_in_use). Expose has_passkey on /auth/me.
- Profile: "Passkey sign-in" section for all users — shows linked state or a
  "Link a passkey to this account" button; success banner on return.
- Login: messages for passkey_in_use / link_failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:11:30 +01:00
owain e0ddc4cbf4 Fix PocketID config lookup when multiple admins exist
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 32s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 30s
_get_pocketid_config / _get_allowed_group selected an admin row with an
unordered LIMIT 1. With more than one admin (e.g. the seeded password admin
plus a passkey-linked admin), this non-deterministically returned an admin
without PocketID config — making the passkey button disappear (available=false)
and group gating inconsistent. Add _config_admin() which prefers the admin that
actually has an issuer set, then falls back to the lowest-id admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:37:19 +01:00
owain 0e18ef2291 Fix PocketID secret wiped on re-save; log token-exchange failures
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 29s
Build and push images / build-worker (push) Successful in 29s
Build and push images / build-frontend (push) Successful in 29s
save_pocketid_config cleared the stored client secret whenever the form was
submitted with a blank secret field — but the UI hint says blank means "keep
existing". Re-saving config (e.g. to set the allowed group) therefore wiped the
secret and broke token exchange ("Token exchange failed"). Now a blank field
keeps the existing secret; only a non-empty value overwrites it.

Also log PocketID's actual token-endpoint response body on failure so the cause
(invalid_client, redirect_uri mismatch, etc.) is visible in backend logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:26:35 +01:00
owain 0dd6eba589 docs: refresh CLAUDE.md (beat service, TanStack Query, env vars, no tests)
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 29s
Build and push images / build-frontend (push) Successful in 34s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:19:55 +01:00
owain 0e4bc7b444 Multi-user via PocketID: account linking, group gating, admin user management
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the
data layer was already fully user-scoped. This adds the missing pieces for
running real multi-user:

- auth.py callback: link by email to an existing un-linked account (so the
  admin keeps their data when first signing in by passkey), collision-safe
  username generation, and request the `groups` scope.
- Group gating: optional pocketid_allowed_group (admin-config or
  POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the
  callback and redirected to /login?auth_error=not_authorized.
- New admin users API (app/api/users.py): list users, promote/demote admin
  (guards against demoting/locking out the last admin or yourself), and delete
  a user with ordered bulk deletes of all their data + on-disk files.
- ProfilePage: allowed-group field; LoginPage: rejected-login message;
  Layout: admin-only Users nav; new UsersPage.

Resync milevault_export to current source (it had drifted many features behind
— missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own
CI) and add POCKETID_ALLOWED_GROUP to .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 13:19:55 +01:00
owain bc4d68da07 Fix avg_hr_day: remove dead averageHeartRate lookup; add max_hr_day from UDS export
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 29s
Build and push images / build-frontend (push) Successful in 30s
garmin.get_stats() never returns averageHeartRate — avg_hr_day is only computable
from intraday HR which Garmin's API only serves for recent dates (~90-120 days).
The dead lookup gave false confidence that historical backfill would work.

Also populate max_hr_day from the Garmin export's UDS daily summaries (maxHeartRate
field is present for the full history), so historical max HR is available after
re-importing the export.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:25:11 +01:00
owain 2d94f99356 Fix activity dedup crash: MultipleResultsFound on same-day same-sport activities
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 30s
Build and push images / build-frontend (push) Successful in 30s
The previous query used >= on start_time with no upper bound, so it matched
ALL activities of that sport type starting after the given minute on that day —
crashing with MultipleResultsFound whenever two such activities existed.

Fix: bound the window to ±60s from start_time and use .scalars().first()
so the query returns at most one Activity rather than raising on duplicates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 12:11:55 +01:00
owain f5d91cf8ae Fix Garmin full export import: UDSFile health data and nested zip FIT files
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 47s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 25s
Garmin Connect exports use UDSFile_*.json (not DailyMetrics) for daily
wellness summaries, and pack activity FIT files inside nested sub-zips
under DI-Connect-Uploaded-Files/ rather than at the top level.

- process_garmin_health_zip: match UDSFile_*.json instead of DailyMetrics,
  handle list-of-records format, extract stress from allDayStress.aggregatorList,
  convert floorsAscendedInMeters to floor count
- upload_garmin_export: recurse into nested .zip files to find and queue
  individual activity FIT files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:17:51 +01:00
owain 2ea691085f Fix VO2 arrow: tip lands at exact value on arc centre-line, base points outward
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
Previously the tip was sitting just outside the outer edge of the track,
making it hard to see exactly where it pointed. Now tipR=r (centre of the
coloured band) so the tip is precisely at the value's position, with a
narrow 5° spread for better precision.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:20:53 +01:00
owain bb9e8c59f4 VO2 max gauge: Garmin/Cooper Institute thresholds, add Superior category
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
- Replace ACSM estimates with exact Garmin/Cooper Institute values for 6 age
  brackets (20-29 through 70+) for both male and female
- Add Superior (≥95th percentile) as the top category in purple; rename
  Very Poor → removed, categories are now Poor/Fair/Good/Excellent/Superior
- Update getVo2Category to use lower-bound logic (count thresholds met)
  matching how Garmin publishes the data

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:15:26 +01:00
owain b6f185d5e8 Fix VO2 gauge: large-arc-flag must always be 0 for a 180° gauge
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 9s
Segments covering >50% of the gauge range were getting large=1, causing
the SVG to draw a 234°+ arc going the wrong way around, producing a
spurious lobe on the right side of the widget.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:56:46 +01:00
owain 5c5877c792 Rework VO2 max gauge: full-colour ACSM bands, white inward arrow, no fill arc
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
- Remove the filled arc from MIN to value (was overpainting the coloured bands)
- Category bands are now full-brightness with no opacity reduction
- White triangular arrow: base outside the track, tip touching the outer edge,
  pointing inward at the exact value position
- Dark background track slightly wider than colour bands for clean border effect
- Adjusted cy/viewBox height to give the arrow room above the arc

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:49:17 +01:00
owain 5256bd448c Fix VO2 gauge arc direction: sweep=1 for upper (top) semicircle
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s
sweep=0 in SVG is counter-clockwise which goes through the bottom of the
circle. sweep=1 (clockwise) correctly traces left→top→right.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:41:31 +01:00
owain 221b2cd333 Fix VO2 max gauge: correct semicircle geometry, fixed 30-70 range, arrow pointer
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
Rewrote the SVG arc math — sweep=0 (counter-clockwise) correctly draws the
upper semicircle from left (30) over the top to right (70). The gauge now:
- Spans a fixed VO2 range of 30–70 across 180°
- Shows dimmed age/sex-specific ACSM category bands as background
- Fills a bright arc from 30 to the current value in the category colour
- Has a small triangular arrow pointer at the value position on the arc
- Shows the value number centred in the dome, coloured for its category

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:38:53 +01:00
owain 45ff01f740 Fix health metrics API limit to support 5yr trend and snapshot navigation
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
Increased le=1000 → le=2000 to allow the 5Y trend (1826 days) and the
allDays snapshot navigation query (limit 2000) to succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 00:28:37 +01:00
owain 8d304545a3 Health page: VO2 max gauge, layout improvements, 3Y/5Y trends, biological sex profile
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
- Add biological_sex field to User model, profile API, and ProfilePage toggle (male/female) — used to select the correct ACSM VO2 max threshold table
- Replace simple VO2 max number in daily snapshot with a colour-coded SVG radial gauge (Very Poor=red, Poor=orange, Fair=green, Good=blue, Excellent=purple) driven by sex- and age-appropriate thresholds
- Shrink Sleep widget to half-width, expand Heart & HRV to half-width; reorganise Heart & HRV internals into a 2×2 grid to reduce vertical height
- Add connectNulls + showDots to VO2 Max trend chart so sparse readings connect with a continuous line
- Add 3Y and 5Y range options to the Trends selector; increase allDays limit to 2000 for full 5yr snapshot navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:49:01 +01:00
owain 70c7e5c0a8 Fix VO2 max extraction: values nested under entry['generic'] not top-level
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
The maxmet/daily range query returns entries shaped as:
  {"generic": {"calendarDate": "...", "vo2MaxPreciseValue": 42.7, ...}, ...}

The extractor was looking at the top level of each entry, finding nothing, and
falling through to the single-point training_status fallback every time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:18:04 +01:00
owain 093aa67e58 Log maxmet first entry structure to identify vo2max field name
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:13:43 +01:00
owain 546fdd96b5 Fix VO2 max sync: robust fallback when maxmet range returns non-list or valueless entries
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
The previous code used `if not mm_history:` to decide whether to fall back to
get_training_status(). If the maxmet API returned a non-empty list with no valid
vo2max values (or a non-list type), the fallback was skipped and nothing stored.

Changes:
- Normalise mm_raw: only use it if it's a list (handles dict/None responses)
- Check valid_from_range: fall back to training_status whenever no usable value
  was found in the range query, regardless of whether it returned entries
- Upgrade all related log lines to INFO so the result is visible without debug mode
- Guard the entry loop against non-dict items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 23:01:18 +01:00
owain 0bb1f9bc1e Fetch full VO2 max history via maxmet/daily range query
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
Instead of storing only the most recent measurement, query the maxmet
endpoint with the full sync window (start_date to today) to populate
one row per measurement date. Falls back to training_status most-recent
value if the range query returns nothing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:35:38 +01:00
owain 854d4ed7cb Fix VO2 max extraction from training_status.mostRecentVO2Max.generic
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
Correct path confirmed from live API response. Store against the actual
measurement date (calendarDate from the VO2 max record) rather than
today, so the carry-forward logic shows the right value from the correct
day. Also store fitnessAge from the fitnessage endpoint alongside it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 22:16:09 +01:00
owain 41a39ec3c7 Try training_status and stats_and_body for VO2 max (debug logging)
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 47s
Build and push images / build-worker (push) Successful in 45s
Build and push images / build-frontend (push) Successful in 23s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:59:05 +01:00
owain 367ae4e8f7 Switch VO2 max source to get_max_metrics (maxmet/daily endpoint)
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
fitnessage endpoint contains fitness age only, not VO2 max. The maxmet
endpoint (/metrics-service/metrics/maxmet/daily) is the correct source.
Keep debug logging temporarily to confirm key names from live API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:38:41 +01:00
owain e440fb35dd Add debug logging for fitnessage API response
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 5s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:29:19 +01:00
owain 8fd7f984d9 Fix VO2 max extraction — use fitnessage API not daily stats
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 52s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 6s
get_stats() does not include VO2 max. Switch to get_fitnessage_data()
which hits /fitnessage-service/fitnessage and returns the current VO2
max estimate and fitness age. Called once per sync (today only) since
VO2 max is a slow-changing metric; the frontend carry-forward shows it
on older days. Remove the incorrect stats.get() attempt from _parse_day.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:53:55 +01:00
owain 13ed824f01 VO2 max carry-forward and sync lookback days fix
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 30s
Build and push images / build-worker (push) Successful in 1m12s
Build and push images / build-frontend (push) Successful in 48s
Show the most recently known VO2 max value on days where Garmin has
not produced a new estimate (it only updates after certain activities).
Fix the sync lookback days input resetting to the server value during
polling — the form now initialises from the server once on first load
and is not overwritten by background refetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 20:12:51 +01:00
owain 45ff4c26aa Implemented all 9 UI fixes across health charts and activity detail pages. Changes are ready to push to git for the Docker build to pick them up.
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 1m9s
Build and push images / build-worker (push) Successful in 1m8s
Build and push images / build-frontend (push) Successful in 49s
2026-06-07 19:57:25 +01:00
owain 67fd4b3c96 Health hypnogram, routes tiles, BB bar chart, segment delta
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s
- Sleep: store per-epoch stage timestamps in new sleep_stages JSON column;
  DailySnapshot now renders a proper 4-lane hypnogram (Awake/REM/Light/Deep)
  instead of the old proportional flat bar
- Body battery: replace grey background bars + white line with per-minute bars
  coloured by inferred type (sleep=indigo, rest=teal, active=orange, stable=grey)
  derived from sleep window + battery direction; Y-axis fixed 0-100
- Routes: convert sidebar list to tile grid sorted by most completions; tiles
  colour-bordered by sport type (blue=running, orange=cycling); completion count
  shown on each tile; detail panel displays below the grid when a tile is clicked
- Segments on activity detail: add column headers (This run / Best / Δ) and
  show signed time delta vs best, green when faster, red when slower

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 18:44:00 +01:00
owain 492418586a Fixed Garmin sync progress bar granularity, timeout issue, and lookback days input, plus redesigned the sleep timeline with taller bars and yellow Awake colour.
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 48s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 28s
2026-06-07 18:15:07 +01:00
owain bf1920eb9d Segments and Av HR update
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 22s
2026-06-07 17:12:27 +01:00
owain 4a4cbdcc92 Fix pace sentinel, route map thumbnails, tiled segments, health/dashboard layout
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 8s
- Pace: FIT 0xFFFF sentinel (65.535 m/s) was stored as avg_speed_ms on every
  activity and lap; add _sanitize_speed() to parser falling back to dist/dur,
  plus a startup SQL migration that fixed 120 activities and 688 laps in-place
- Records: remove swimming from Distance PRs; Route Records rows are clickable
  (navigate to activity), View button removed, small SVG route map per row;
  Segment Records uses same tiled route-card layout as Segments page
- Segments: replace route dropdown with responsive tile grid showing SVG map
  thumbnails; selecting a tile reveals the segment management panel below
- RouteMiniMap: new pure-SVG component (no Leaflet) for route thumbnails,
  decodes polyline and normalises coords into a fixed viewBox
- Health: rename "Avg Heart Rate (day)" → "Heart Rate"; weight chart now
  filters to non-null rows and enables connectNulls + dots for sparse data
- Dashboard: 4-col layout at lg breakpoint so Body Battery sits between weekly
  chart and Health Today; Body Battery card gains a 24-hr sparkline from the
  values[] already present in the health summary response

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:36:54 +01:00
owain 5f5551db27 Fix wellness sync crash on None body battery levels
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
Guard bb variable scope and filter None entries from bodyBatteryValuesArray
before subtraction in _compute_body_battery_hires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 16:04:38 +01:00
owain da9c1e04cb Body
Build and push images / validate (push) Successful in 19s
Build and push images / build-backend (push) Successful in 1m15s
Build and push images / build-worker (push) Successful in 1m13s
Build and push images / build-frontend (push) Successful in 51s
2026-06-07 15:26:54 +01:00
owain 568dc31e97 Round 2: body battery redesign, profile cleanup, segment integration, route/segment records
Build and push images / validate (push) Successful in 18s
Build and push images / build-backend (push) Successful in 31s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 34s
- Body battery: replace circular ring with compact full-height colored bar chart,
  level as line overlay, legend shows only types present in data
- Dashboard: add mini body battery summary card above health today panel
- Profile: remove editable resting HR and manual weight log; show 7-day avg
  resting HR and latest Garmin weight as read-only
- Backend: add GET /routes/{id}/segment-bests bulk endpoint (fetches all matched
  activity data points in one query, computes best segment time per segment)
- Backend: add GET /records/routes for fastest activity per named route
- Routes page: add Segments panel to route detail (grouped as 1km splits vs
  hills/turns, best times, delete, theoretical best)
- Activity detail page: show segment times computed client-side from data points,
  🏆 badge if new best
- Records page: add Route Records and Segment Records tabs alongside Distance PRs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:14:00 +01:00