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>
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>
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>
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>
- 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>
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>
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>
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>
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>
- 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>
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>