27 Commits

Author SHA1 Message Date
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 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 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 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 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 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 f0bbe92b2c Fix sync window: respect lookback_days even after first sync
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
Previously, once last_sync_at was set the incremental path always used
since-1d as start_date, ignoring lookback_days entirely. Increasing the
lookback setting had no effect on already-synced instances.

Fix: take min(since-1d, today-lookback_days) so the window always covers
at least the configured lookback period, whether or not a prior sync ran.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:16:08 +01:00
owain 616099402b Add body battery: sync, storage, and health UI chart
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 10s
Parses Garmin Connect get_body_battery() per day, storing charged/drained/
start+end levels and the fine-grained [[ts_ms, level, type, stress]] values
array in a new body_battery JSONB column on health_metrics.

Frontend adds:
- BatteryRing SVG gauge (color-scaled 0–100)
- BodyBatteryChart: ComposedChart with type-colored bars (REST/ACTIVE/SLEEP/
  STRESS) and battery level overlay line, matching Garmin's layout
- Body battery trend chart in the Trends section (end_level per day)

Also adds avg_hr_day and weight data which now correctly sync with the
intraday_hr JSON serialization fix from the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:13:38 +01:00
owain 37ffd4c9e0 Fix wellness sync crash: serialize intraday_hr as JSON string for psycopg2
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 5s
psycopg2 treats Python lists as PostgreSQL arrays (bigint[]) rather than JSON,
causing a DatatypeMismatch error on the json/jsonb column. Serializing with
json.dumps() before the raw SQL INSERT fixes the type error.

Also wrap per-day INSERT in try/except+rollback so one bad day doesn't abort
the entire session, and add db.rollback() in tasks.py after sync_wellness
failure so the final status-update commit can always succeed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 11:00:22 +01:00
owain f927e32853 Fix missing avg_hr_day/weight data; add 24hr HR chart to daily snapshot
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
Backend:
- main.py: add ADD COLUMN IF NOT EXISTS migrations for avg_hr_day, max_hr_day,
  and intraday_hr (JSONB) on health_metrics — these columns were in the model
  but missing from existing DB instances, silently dropping all avg/max HR data.
- models/user.py: add intraday_hr JSON column to HealthMetric.
- garmin_connect_sync.py: fetch body composition (weight, BMI, body fat, muscle
  mass) via get_body_composition() per day, with stats.bodyWeight as fallback.
  Fetch intraday heart rate via get_heart_rates() and store non-null
  [epoch_ms, bpm] pairs in intraday_hr.
- health.py: add GET /health-metrics/intraday?date=YYYY-MM-DD endpoint that
  returns the stored intraday_hr array for a specific day.

Frontend (HealthPage):
- Add IntradayHrChart component: AreaChart rendering the 24-hour HR trace
  with time-of-day x-axis.
- DailySnapshot: show 24-hour HR chart (when intraday data present) above
  the activity strip; add weight + body fat % to the Heart & HRV card;
  show max HR alongside avg HR.
- HealthPage: query /intraday for the selected day and pass data down.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:47:53 +01:00
owain a9b3da858d Fix five code-review findings: token auth, sync rate-limiting, model drift, FK cascade
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 5s
- garmin_connect_sync: revert to garth.loads() for token auth — login(tokenstore=)
  dispatches on len>512, treating compact tokens as filesystem paths and forcing a
  full re-login on every sync. Explicitly set display_name from the embedded profile.
- garmin_connect_sync: restore incremental sync for both activities and wellness —
  always re-fetching the full lookback window was generating ~270 Garmin API calls
  per wellness sync run, risking rate-limits. Now uses since-1d when since is set.
  Add 0.25s per-day sleep in sync_wellness as an additional rate-limit guard.
- models/user.py: replace the dropped uq_pr_current UniqueConstraint in
  PersonalRecord.__table_args__ with the partial Index the DB actually has,
  so the model and live schema no longer permanently diverge.
- models/user.py: add ondelete="SET NULL" to Activity.named_route_id FK so the
  DB cascade handles unlinks if routes are deleted outside the API endpoint.
- main.py: add startup migration to re-add activities_named_route_id_fkey with
  ON DELETE SET NULL on existing deployments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 10:30:20 +01:00
owain 211f77a574 Fix sync_lookback_days actually controlling the sync window
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 48s
Build and push images / build-worker (push) Successful in 52s
Build and push images / build-frontend (push) Successful in 29s
Activities: lookback_days was ignored once last_sync_at was set (since
always took priority). Now lookback_days always sets the window; -1 is
all-time on first sync then incremental.

Wellness: lookback_days was never passed to sync_wellness at all —
hardcoded 90-day cap regardless of settings. Fixed by adding lookback_days
param and wiring it through from the Celery task.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:34:18 +01:00
owain 22b41109f5 Fix Garmin stats sync, add route merge/map/links, fix PR constraint
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 7s
Build and push images / build-frontend (push) Successful in 10s
Garmin health: fix display_name=None when using stored OAuth tokens.
authenticate_garmin() now calls login(tokenstore=...) instead of
garth.loads() directly, so display_name is populated and get_user_summary
works. Also add avg_hr_day / max_hr_day from stats response.

Routes: add merge endpoint (POST /{id}/merge/{source}), delete endpoint.
Routes page: polyline SVG mini-map on each route card, merge UI with
confirmation, activity rows are now Links to the activity detail page.

Personal records: replace all-columns unique constraint with a partial
index (unique on current records only) to stop UniqueViolation crashes
when parallel workers deactivate the same PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 01:25:01 +01:00
owain f8c126fbda Add configurable sync_lookback_days for Garmin Connect
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 6s
Build and push images / build-frontend (push) Successful in 10s
Users can now set how many days back the first sync fetches. -1 syncs all
history back to 2010; any positive value sets a rolling window. Values
over 365 show a rate-limit warning in the UI. The default remains 30 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:40:55 +01:00
owain 335bd0a053 Fix Garmin Connect sync to import full history and prevent re-downloads
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 54s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 22s
Activity sync:
- First sync (no last_sync_at) now fetches from 2010-01-01 instead of -30 days,
  importing the full account history rather than only the last month
- Pre-download dedup: check existing activities by start_time before downloading;
  stamps garmin_activity_id on the match so subsequent syncs take the fast path
- process_activity_file stamps garmin_activity_id on duplicate detection for
  the same reason (covers activities imported via bulk export)
- 0.5 s sleep between downloads to avoid Garmin API rate limiting

Wellness sync:
- First sync now covers last 90 days instead of 7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 00:33:49 +01:00
owain 6d224d51c5 Add Garmin Connect auto-sync via python-garminconnect
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 6s
Build and push images / build-frontend (push) Successful in 8s
- GarminConnectConfig model stores encrypted credentials and OAuth token
- garmin_connect_sync service: token-based auth with password fallback,
  activity FIT download + queue, daily wellness from JSON API
- Celery beat schedule: sync_all_garmin_connect fires every hour
- New API router /api/garmin-sync: config CRUD, manual trigger
- Beat container added to docker-compose.yml and docker-compose.deploy.yml
- ProfilePage: Garmin Connect section with connect/update/disconnect and Sync now

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