- 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>
- Segments page: new /segments route with auto-generate (1km splits, turn
detection, hill detection), manual segment creation, per-segment performance
times across matched activities; fixed auth on existing segment endpoints
- YTD distance: new /activities/stats/ytd endpoint; Dashboard replaces
'Total distance' with 'Running this year' + 'Cycling this year'; Activities
page shows YTD stats row
- Weekly chart click: clicking a Dashboard bar navigates to Activities filtered
to that week; Activities reads from/to query params with dismissable chip
- Route matching: add ±2.5% distance gate + 3% relative DTW threshold
(was flat 80m); tighten candidate pre-filter from 80/120% to 95/105%
- Body battery layout: HR chart and body battery now side-by-side at same
height on large screens instead of stacked full-width
- Pace display fix: MetricTimeline clamps GPS speed outliers before computing
Y-axis domain; tick formatter guards against v<=0 or v>25 m/s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- 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>
This was missing from the earlier commit — tasks.py referenced
cfg.sync_lookback_days but the column wasn't defined in the SQLAlchemy model,
causing AttributeError when connecting or syncing Garmin.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 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>