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>
- 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>
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>
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>
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>
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>
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>
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>
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>
- 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>
- 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>
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>
- 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>
- 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>
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>
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>
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>
The useQuery for intradayData referenced selectedDay (a useMemo) before it
was declared in the function body, causing ReferenceError on every render
and breaking the health page entirely.
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>
Backend:
- Change beat schedule from 3600s (hourly) to 1800s (30 minutes)
- Emit intermediate last_sync_status DB commits at each phase of
sync_garmin_connect_user ("Connecting to Garmin...", "Syncing activities...",
"Syncing wellness data...") so the frontend can reflect live progress.
Snapshot config fields upfront to avoid reading expired ORM attrs after commits.
Frontend (ProfilePage):
- Replace blind 3-second timeout with 2s polling loop that reads the live
last_sync_status from /garmin-sync/config after triggering a sync.
- Wait until an in-progress status is observed before declaring completion,
avoiding a false-finish on the previous terminal status.
- Show an animated progress bar that advances through the sync phases with
the current status text below it. Safety timeout stops polling after 10 min.
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>
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>
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>
This ALTER TABLE was written locally but never staged, so the production
image ran without it and the column was never added to the DB.
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>
password was required in GarminConfigIn, causing a 422 when users updated
toggles or sync_lookback_days without re-entering their credentials.
Now only re-authenticates when a new password is supplied; settings-only
updates (sync_enabled, sync_activities, sync_wellness, sync_lookback_days)
go through without touching the stored credentials.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
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>
- Fix keepPreviousData v4→v5: import keepPreviousData from @tanstack/react-query
and use as placeholderData so charts don't blank out when switching ranges
- Normalise all metric dates to YYYY-MM-DD in queryFn so XAxis values and
ReferenceLine x values always match
- Add allDays query (last 365 days) for snapshot navigation, keyed under
['health-metrics', 'all'] so UploadPage invalidation covers it
- Arrow nav (← →) in snapshot header steps through available days
- Clicking any trend chart data point loads that day in the snapshot
- Blue dashed ReferenceLine marks the selected day in every chart
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>
Replaces the flat stat card grid with a rich daily view at the top: sleep card
with duration, stage bar and times; heart/HRV card; activity strip (steps with
progress bar, calories, stress, SpO2). Trend charts moved below under a Trends
heading with the range selector inline.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Garmin FIT SDK returns snake_case field names but the parser was
looking for camelCase. Sleep epoch durations were wrong (fixed 30s each
instead of computing from timestamp gaps). HRV is in message 370 not 275
(275 now carries sleep levels in modern firmware). Multiple fixes:
- msg 55: use 'steps', 'heart_rate', 'active_calories' (not numeric keys)
- msg 211: use 'resting_heart_rate' (not msg.get(0))
- msg 227: use 'stress_level_time'/'stress_level_value' for named fields
- msg 132: use snake_case 'stress_level_time'/'stress_level_value'
- msg 275: detect sleep_level field → handle as sleep epoch (modern),
fall back to HRV handling for older firmware
- msg 370: new handler for modern hrv_status_summary (last_night_average,
last_night_5_min_high, status)
- msg 346: new handler for sleep_assessment → overall_sleep_score
- msg 21: new handler for sleep session start/stop events to close the
last sleep epoch and record sleep_start/sleep_end timestamps
- Sleep duration: computed from epoch timestamp gaps instead of 30s/epoch
- Celery task SQL: add sleep_score, sleep_start, sleep_end to INSERT/UPDATE;
use GREATEST for total_calories so most-complete value wins across files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- UploadPage now polls task status every 2s and invalidates activity,
health-summary, and health-metrics queries on completion so new
activities and health data appear without a hard refresh
- Garmin and Strava export endpoints now return a task_id for polling
- Updating max HR in Profile triggers a background Celery task to
recalculate hr_zones for all existing activities; profile page shows
a confirmation note when this is queued
- Add CLAUDE.md with repo architecture and dev commands
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>