Compare commits

...

96 Commits

Author SHA1 Message Date
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
owain 02eccad578 Add segments, YTD stats, route matching fixes, body battery layout, pace fix
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
- 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>
2026-06-07 12:01:25 +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 d57054509c Fix HealthPage crash: move intradayData query below selectedDay declaration
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
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>
2026-06-07 10:51:14 +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 a28ce0e009 Add sync progress bar; change auto-sync to every 30 minutes
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 16s
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>
2026-06-07 10:36:15 +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 edeb3ccece Fix: commit missing init_db migration for sync_lookback_days column
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 6s
Build and push images / build-frontend (push) Successful in 5s
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>
2026-06-07 01:11:55 +01:00
owain 17ec83bfc2 Fix: add sync_lookback_days column to GarminConnectConfig model
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 45s
Build and push images / build-worker (push) Successful in 45s
Build and push images / build-frontend (push) Successful in 22s
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>
2026-06-07 01:06:38 +01:00
owain a3c039b3ea Fix Garmin Connect save — make password optional for settings updates
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 49s
Build and push images / build-frontend (push) Successful in 27s
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>
2026-06-07 00:58:00 +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 7d6d34f61f Fix health trends range selector; add day navigation and chart click
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 51s
Build and push images / build-worker (push) Successful in 49s
Build and push images / build-frontend (push) Successful in 23s
- 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>
2026-06-07 00:27:35 +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
owain 0cdc653664 Add daily health snapshot to Health page
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 49s
Build and push images / build-worker (push) Successful in 47s
Build and push images / build-frontend (push) Successful in 9s
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>
2026-06-06 23:46:08 +01:00
owain c3637fa3fa Fix wellness parser: field names, sleep epoch durations, HRV, sleep score
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 5s
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>
2026-06-06 23:33:50 +01:00
owain 95f704cb54 Fix upload auto-refresh, health data refresh, and HR zone recalculation
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 10s
- 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>
2026-06-06 23:13:44 +01:00
owain b5fd17a597 Fix PocketID login redirect
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 11s
2026-06-06 19:56:18 +01:00
owain df6c993709 Fix PocketID OIDC endpoints for auth.jarrett.eu
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
2026-06-06 19:51:08 +01:00
owain 34284f3d9d Fix PocketID callback URL to use full base URL
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
2026-06-06 19:43:26 +01:00
owain 16cf4a9313 Fix wellness_parser - had fit_parser content instead of wellness parser
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 4s
2026-06-06 19:38:53 +01:00
owain ed4ab0eff8 Fix FIT parser - handle raw timestamps and semicircle auto-detection
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 5s
2026-06-06 19:32:34 +01:00
owain 0fd3ff7414 Fix SDK field names - use camelCase throughout
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 51s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 5s
2026-06-06 19:27:35 +01:00
owain f609931ebc Remove fitparse entirely - use Garmin SDK only with messages dict approach
Build and push images / validate (push) Successful in 2s
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 24s
2026-06-06 19:17:51 +01:00
owain e9811d8d83 Fix duplicate detection, add wellness suffixes, add reprocess 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
2026-06-06 19:02:42 +01:00
owain 93b8f00f94 Fix map route not rendering; fix health date filter timezone mismatch
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
2026-06-06 18:52:29 +01:00
owain 24f8417982 Fix package.json, Dockerfile, add CI validation to prevent recurrence
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 21s
2026-06-06 18:27:29 +01:00
owain 070267eee5 Fix package.json trailing comma
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 22s
2026-06-06 18:20:40 +01:00
owain 4b93cbf5e0 Remove non-existent polyline-codec package
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Failing after 5s
2026-06-06 18:19:35 +01:00
owain b0248fb581 Fix frontend Dockerfile - use npm install not npm ci
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) Failing after 15s
2026-06-06 18:17:38 +01:00
owain ec5a01d12a All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s
2026-06-06 18:10:35 +01:00
owain 043b3b7269 Switch to official Garmin FIT Python SDK for both activity and wellness parsing
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 6s
2026-06-06 16:09:57 +01:00
owain 38632cfe4f Use ON CONFLICT upsert for health metrics - fixes concurrent worker race condition
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
2026-06-06 15:53:56 +01:00
owain 8104ca5ed0 Route wellness FIT files to health parser, parse HR/HRV/sleep/stress/SpO2
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
2026-06-06 15:50:25 +01:00
owain c4e5eb91ed Use sync SQLAlchemy in Celery worker - fixes asyncpg connection issues
Build and push images / build-backend (push) Successful in 1m52s
Build and push images / build-worker (push) Successful in 44s
Build and push images / build-frontend (push) Successful in 25s
2026-06-06 15:29:36 +01:00
owain 29c39c3bbb Make admin_password optional so worker can start without it
Build and push images / build-backend (push) Successful in 4s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 5s
2026-06-06 15:09:27 +01:00
owain bfb3daba05 Fix worker race condition - single uvicorn worker + tolerate duplicates
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
2026-06-06 15:09:22 +01:00
owain 5a57e84e80 Pin bcrypt to 4.0.1, add celery_app entry point
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Successful in 5s
2026-06-06 15:05:16 +01:00
owain 264c27469b Fix DB init - composite PK for hypertable + separate transactions
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 7s
2026-06-06 15:01:39 +01:00
owain ecc077f153 Fix package.json trailing comma
Build and push images / build-backend (push) Successful in 4s
Build and push images / build-worker (push) Successful in 4s
Build and push images / build-frontend (push) Successful in 21s
2026-06-06 14:48:50 +01:00
owain 9fd12676d1 Remove non-existent polyline-codec package
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) Failing after 5s
2026-06-06 14:45:26 +01:00
owain 8fdf3df013 Fix frontend build - use npm install instead of npm ci
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) Failing after 13s
2026-06-06 14:42:28 +01:00
owain e0b09d6d44 Hardcode registry URL to fix variable issue
Build and push images / build-backend (push) Successful in 52s
Build and push images / build-worker (push) Successful in 46s
Build and push images / build-frontend (push) Failing after 14s
2026-06-06 14:39:27 +01:00
owain 97e79fd020 Hardcode registry URL to fix variable issue 2026-06-06 14:39:01 +01:00
128 changed files with 22813 additions and 1643 deletions
+44 -31
View File
@@ -3,25 +3,44 @@ name: Build and push images
on:
push:
branches: [main]
workflow_dispatch: # allow manual trigger from Gitea UI
env:
REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
OWNER: ${{ gitea.repository_owner }}
workflow_dispatch:
jobs:
build-backend:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Validate package.json
run: |
# Fail if package.json is invalid JSON
python3 -c "import json, sys; json.load(open('frontend/package.json'))" || \
{ echo "ERROR: frontend/package.json is invalid JSON"; exit 1; }
# Fail if non-existent packages are present
if grep -q "@polyline-codec" frontend/package.json; then
echo "ERROR: @polyline-codec/core does not exist on npm - remove it"
exit 1
fi
# Fail if npm ci is still in Dockerfile (requires lockfile we don't have)
if grep -q "npm ci" frontend/Dockerfile; then
echo "ERROR: frontend/Dockerfile uses 'npm ci' but no package-lock.json exists - change to 'npm install'"
exit 1
fi
echo "Validation passed"
build-backend:
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
- name: Build and push backend
uses: docker/build-push-action@v5
@@ -30,21 +49,18 @@ jobs:
file: ./backend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-backend:latest
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-backend:${{ gitea.sha }}
build-worker:
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Log in to registry
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
- name: Build and push worker
uses: docker/build-push-action@v5
@@ -53,21 +69,18 @@ jobs:
file: ./backend/Dockerfile.worker
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-worker:latest
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-worker:${{ gitea.sha }}
build-frontend:
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Log in to registry
run: echo "${{ secrets.PACKAGE_TOKEN }}" | docker login gitea.jarrett.eu -u ${{ gitea.actor }} --password-stdin
- name: Build and push frontend
uses: docker/build-push-action@v5
@@ -76,8 +89,8 @@ jobs:
file: ./frontend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-frontend:latest
gitea.jarrett.eu/${{ gitea.repository_owner }}/milevault-frontend:${{ gitea.sha }}
build-args: |
VITE_API_URL=/api
VITE_MAPBOX_TOKEN=
VITE_MAPBOX_TOKEN=
+14
View File
@@ -0,0 +1,14 @@
# Python
__pycache__/
*.py[cod]
# Node / frontend build artifacts
node_modules/
dist/
# Environment / secrets
.env
.env.*
# OS noise
.DS_Store
BIN
View File
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this project is
MileVault is a self-hosted fitness tracker. It ingests Garmin FIT files and Strava exports, stores activity and wellness data in TimescaleDB (PostgreSQL), and serves a React dashboard with maps, charts, personal records, and health trends.
## Running locally
Everything runs in Docker Compose. There is no way to run individual services without Docker unless you wire up your own Postgres + Redis.
```bash
# First-time setup (generates .env with secrets, then starts containers):
./scripts/manage.sh setup
# Start/stop:
./scripts/manage.sh start
./scripts/manage.sh stop
# Follow logs (all services, or a specific one):
./scripts/manage.sh logs
./scripts/manage.sh logs backend
# Backup/restore the database:
./scripts/manage.sh backup
./scripts/manage.sh restore milevault_backup_20240101_120000.sql
```
The app is served on port 80 by nginx, which proxies `/api/*` to the backend (port 8000) and serves the React SPA for everything else.
There are no automated tests. Verification is done by running the app and observing behaviour.
## Debugging running containers
The production stack runs in `~/milevault_docker` with fixed container names. Use these to investigate issues — never patch the running files:
```bash
# Tail logs from a specific container
docker logs -f milevault_backend
docker logs -f milevault_worker
docker logs -f milevault_db
# Run a one-off query or command inside a container
docker exec milevault_backend python -c "from app.core.config import settings; print(settings.base_url)"
docker exec -it milevault_db psql -U milevault -d milevault
```
## Building and deploying
`docker-compose.yml` — build from source (dev/CI).
`docker-compose.deploy.yml` — pull pre-built images from the Gitea registry (production).
The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes images on push to `main`. Deployment machines only need `docker-compose.deploy.yml` and `nginx.conf`.
`./deploy.sh "<commit message>"` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`.
**CI validation**: The build workflow runs a `validate` job before building images. It will fail if `@polyline-codec` appears in `frontend/package.json` or if `npm ci` is used in `frontend/Dockerfile` (no lockfile exists — always use `npm install`). Fix these before pushing.
**`VITE_MAPBOX_TOKEN`** is baked empty by the CI build (`build-args: VITE_MAPBOX_TOKEN=`), so satellite tiles are disabled in all pre-built images. To enable them, rebuild locally with the token set in `.env`.
```bash
# Rebuild and restart from source:
docker compose build --no-cache
docker compose up -d
# Update a deployed instance:
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
```
## Architecture
### Services
| Service | Purpose |
|---------|---------|
| `db` | TimescaleDB (PostgreSQL 16) — `activity_data_points` is a hypertable |
| `redis` | Celery broker + result backend |
| `backend` | FastAPI (async) — uvicorn, single worker |
| `worker` | Celery worker — synchronous SQLAlchemy (asyncio incompatible with prefork) |
| `beat` | Celery Beat scheduler — runs `sync_all_garmin_connect` every 30 minutes |
| `frontend` | React SPA built by Vite at container build time |
| `nginx` | Reverse proxy, serves the SPA |
### Backend (`backend/app/`)
- `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable)
- `core/``config.py` (pydantic-settings from env), `database.py` (async engine for FastAPI + sync engine for Celery), `security.py` (JWT, bcrypt)
- `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `users`, `segments`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `Segment`, `SegmentEffort`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` (the old `RouteSegment` model was removed in the segments rewrite; a new GPS-geometry `Segment`/`SegmentEffort` pair replaces it)
- `services/fit_parser.py` — parses Garmin FIT and GPX files; handles raw FIT timestamps (FIT epoch offset 631065600s) and semicircle→degree conversion
- `services/wellness_parser.py` — parses Garmin wellness FIT files (metrics, sleep, HRV, SPO2, etc.)
- `services/route_matcher.py` — bounding-box pre-filter + DTW (Dynamic Time Warping) for GPS track similarity
- `services/garmin_connect_sync.py` — Garmin Connect API integration; `authenticate_garmin()` tries stored OAuth token first, falls back to email/password; Garmin credentials stored Fernet-encrypted using `SECRET_KEY` as the key
- `workers/tasks.py` — Celery tasks: `process_activity_file`, `parse_wellness_fit`, `detect_route`, `compute_personal_records`, `match_segment`, `match_activity_segments`, `process_garmin_health_zip`, `sync_garmin_connect_user`, `sync_all_garmin_connect` (beat-scheduled), `recalculate_hr_zones_for_user`
### Key design decisions
**Async vs sync split**: FastAPI uses async SQLAlchemy (`asyncpg`). Celery workers use sync SQLAlchemy (`psycopg2`) because Celery's prefork model doesn't survive asyncio engine forks. The `DATABASE_URL` uses `postgresql+asyncpg://`; the worker converts it to `postgresql+psycopg2://` at runtime.
**File routing in Celery**: `process_activity_file` inspects the filename; files matching wellness suffixes (`_METRICS.fit`, `_WELLNESS.fit`, `_SLEEP.fit`, etc.) are routed to `parse_wellness_fit` instead.
**Schema management**: No Alembic migrations are used in production. `Base.metadata.create_all` runs at startup with retry logic to handle multi-worker races. Post-initial schema changes (new columns, constraint changes) are applied as `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` / `DROP CONSTRAINT IF EXISTS` statements in `init_db()` in `main.py` — this is the only place schema migrations happen. Health metrics upserts use raw SQL `ON CONFLICT ... DO UPDATE SET ... COALESCE(EXCLUDED.x, existing.x)` to merge data from multiple file sources without overwriting.
**PocketID OIDC**: Optional passkey auth. Config is read from the admin user's DB record first, falling back to env vars. The OAuth callback redirects to `/?token=<jwt>` and `useAuth.js` extracts the token from the URL at module load time.
### Frontend (`frontend/src/`)
- `App.jsx` — React Router v6, `RequireAuth` wrapper, all routes defined here
- `hooks/useAuth.js` — Zustand store for auth state, reads JWT from `localStorage`, handles PocketID token-in-URL flow
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler
- TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth state
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
- `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
- `components/ui/``Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards)
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
## Environment variables
Required in `.env` (or passed to Docker Compose):
| Variable | Purpose |
|----------|---------|
| `DATABASE_URL` | Full async DB URL (`postgresql+asyncpg://...`) |
| `SECRET_KEY` | JWT signing key — generate with `openssl rand -hex 32`; also used as Fernet key for Garmin credentials |
| `ADMIN_USERNAME` | Admin account username (default: `admin`) |
| `ADMIN_PASSWORD` | Seeds the admin user on first start |
| `REDIS_URL` | Celery broker |
| `DB_USER` / `DB_PASSWORD` | Postgres credentials (compose-level; default: `milevault`) |
| `REDIS_PASSWORD` | Redis auth (compose-level; default: `milevault`) |
| `HTTP_PORT` | Host port for nginx (default: `80`) |
| `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) |
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
## milevault_export/
`milevault_export/` is a sanitised snapshot of the project used for public distribution (stripped of dev-only configs). It mirrors the main project structure. When making changes that affect deployment files (`docker-compose.yml`, `nginx.conf`, `scripts/manage.sh`, `docker/init.sql`, etc.), keep this directory in sync manually.
## Rules
- The current build will always be running in docker at ~/milevault_docker with the following container names:
`milevault_backend`
`milevault_db`
`milevault_frontend`
`milevault_redis`
`milevault_worker`
- When an issue is highlighted by the user, check the logs on these containers for the error, do not spin up new containers, use these for finding the problem, rectify the issues in ~/milevault project without running the updated versions, push to git instead.
- Do NOT patch the running files under any circumstances, fix the development files.
+3 -2
View File
@@ -11,5 +11,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Tables are created at runtime by SQLAlchemy in app/main.py lifespan
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
# Single worker avoids race condition during DB initialization.
# For a personal app this is fine; async handles concurrent requests well.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+165 -3
View File
@@ -1,13 +1,13 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc
from sqlalchemy import select, func, desc, delete
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap, PersonalRecord
router = APIRouter()
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
moving_time_s: Optional[float]
elevation_loss_m: Optional[float]
max_heart_rate: Optional[float]
avg_power: Optional[float]
@@ -75,6 +76,30 @@ class LapOut(BaseModel):
from_attributes = True
@router.get("/stats/ytd")
async def ytd_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return year-to-date distance totals grouped by sport type."""
from datetime import date, timezone
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
result = await db.execute(
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
.group_by(Activity.sport_type)
)
rows = result.all()
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
return {
"running_km": round(totals.get("running", 0), 2),
"cycling_km": round(totals.get("cycling", 0), 2),
"hiking_km": round(totals.get("hiking", 0), 2),
"walking_km": round(totals.get("walking", 0), 2),
"total_km": round(sum(totals.values()), 2),
}
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
@@ -126,7 +151,6 @@ async def get_data_points(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify ownership
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
@@ -172,6 +196,103 @@ async def get_laps(
return result.scalars().all()
@router.get("/{activity_id}/lap-bests")
async def get_lap_bests(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Best (fastest) time per lap number across all activities on the same route."""
act = (await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)).scalar_one_or_none()
if not act:
raise HTTPException(status_code=404, detail="Activity not found")
if not act.named_route_id:
return {}
# Best per lap number across OTHER activities on the same route, so the
# comparison is meaningful (excluding this activity from its own benchmark).
rows = (await db.execute(
select(ActivityLap.lap_number, func.min(ActivityLap.duration_s))
.join(Activity, Activity.id == ActivityLap.activity_id)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.id != activity_id,
ActivityLap.duration_s.isnot(None),
)
.group_by(ActivityLap.lap_number)
)).all()
return {str(lap_number): best for lap_number, best in rows}
@router.get("/{activity_id}/route-leaderboard")
async def get_route_leaderboard(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest-time leaderboard across all of this user's activities on the same
route. Returns this activity's rank/gap plus the top 10. Null if the activity
has no associated route (or no timed efforts to rank)."""
act = (await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)).scalar_one_or_none()
if not act:
raise HTTPException(status_code=404, detail="Activity not found")
if not act.named_route_id:
return None
rows = (await db.execute(
select(
Activity.id, Activity.name, Activity.start_time,
Activity.duration_s, Activity.distance_m, Activity.avg_heart_rate,
)
.where(
Activity.named_route_id == act.named_route_id,
Activity.user_id == current_user.id,
Activity.duration_s.isnot(None),
)
.order_by(Activity.duration_s)
)).all()
if not rows:
return None
fastest_s = rows[0].duration_s
entries = []
current = None
for i, r in enumerate(rows):
entry = {
"rank": i + 1,
"activity_id": r.id,
"name": r.name,
"start_time": r.start_time,
"duration_s": r.duration_s,
"distance_m": r.distance_m,
"avg_heart_rate": r.avg_heart_rate,
"gap_s": r.duration_s - fastest_s,
"is_current": r.id == activity_id,
}
if entry["is_current"]:
current = entry
entries.append(entry)
return {
"route_id": act.named_route_id,
"total": len(entries),
"fastest_s": fastest_s,
"current": current,
"top": entries[:10],
}
@router.patch("/{activity_id}/name")
async def rename_activity(
activity_id: int,
@@ -209,5 +330,46 @@ async def delete_activity(
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
# PersonalRecord.activity_id has no cascade, so remove the activity's PR rows
# first or the delete fails the FK constraint. (segment_efforts cascade in DB;
# data_points/laps cascade via the ORM relationship.)
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity)
await db.commit()
@router.post("/{activity_id}/reprocess")
async def reprocess_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Re-parse the source FIT file and update polyline, data points etc."""
import os
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
if not activity.source_file:
raise HTTPException(status_code=400, detail="No source file stored for this activity")
if not os.path.exists(activity.source_file):
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
source_file = activity.source_file
source_type = activity.source_type or "fit"
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
# Drop PR rows referencing this activity (no cascade); the re-parse re-computes them.
await db.execute(delete(PersonalRecord).where(PersonalRecord.activity_id == activity_id))
await db.delete(activity)
await db.commit()
from app.workers.tasks import process_activity_file
task = process_activity_file.delay(source_file, current_user.id, source_type)
return {"task_id": task.id, "status": "queued"}
+237 -52
View File
@@ -4,15 +4,115 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import timedelta
from jose import jwt, JWTError
import httpx
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, hash_password, get_current_user
from app.core.security import verify_password, create_access_token, get_current_user
from app.core.config import settings
from app.models.user import User
router = APIRouter()
# Marks a short-lived OIDC `state` token as an account-link request (as opposed
# to a normal sign-in), so the callback attaches the passkey to a known user
# instead of creating/looking-up by identity.
LINK_STATE_PURPOSE = "pocketid-link"
LOGIN_STATE_PURPOSE = "pocketid-login"
def _make_link_state(user_id: int) -> str:
"""Signed, short-lived token carrying 'link this passkey to user_id' intent."""
return create_access_token(
{"sub": str(user_id), "purpose": LINK_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _make_login_state() -> str:
"""Signed, short-lived CSRF token proving the login flow started from this app."""
return create_access_token(
{"sub": "login", "purpose": LOGIN_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _valid_login_state(state: Optional[str]) -> bool:
"""True if `state` is a valid, unexpired login-state token we issued."""
if not state:
return False
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
return payload.get("purpose") == LOGIN_STATE_PURPOSE
except JWTError:
return False
def _decode_link_state(state: Optional[str]) -> Optional[int]:
"""Return the user id from a valid link-state token, else None."""
if not state:
return None
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("purpose") != LINK_STATE_PURPOSE:
return None
return int(payload["sub"])
except (JWTError, KeyError, TypeError, ValueError):
return None
async def _config_admin(db: AsyncSession):
"""The admin row that holds instance-wide PocketID settings.
Settings live on an admin user row, but there can be more than one admin.
Prefer the admin that actually has an issuer configured; otherwise fall back
to the lowest-id admin. Without this, an unordered LIMIT 1 could return an
admin with no config and make PocketID look disabled / gating inconsistent.
"""
result = await db.execute(
select(User)
.where(User.is_admin == True, User.pocketid_issuer.isnot(None))
.order_by(User.id)
.limit(1)
)
admin = result.scalar_one_or_none()
if admin is None:
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.id).limit(1)
)
admin = result.scalar_one_or_none()
return admin
async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars."""
admin = await _config_admin(db)
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
return issuer, client_id, client_secret
async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
admin = await _config_admin(db)
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None
async def _unique_username(db: AsyncSession, base: str) -> str:
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
base = (base or "user").strip() or "user"
candidate = base
n = 1
while True:
existing = await db.execute(select(User).where(User.username == candidate))
if existing.scalar_one_or_none() is None:
return candidate
n += 1
candidate = f"{base}-{n}"
class Token(BaseModel):
access_token: str
@@ -27,6 +127,7 @@ class UserOut(BaseModel):
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool = False
class Config:
from_attributes = True
@@ -37,98 +138,182 @@ async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(User).where(User.username == form_data.username)
)
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
if not user:
raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password):
if user.pocketid_sub is not None:
raise HTTPException(
status_code=400,
detail="Password login is disabled for this account — use your passkey to sign in.",
)
if not user.hashed_password or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)})
return Token(
access_token=token,
token_type="bearer",
user_id=user.id,
username=user.username,
is_admin=user.is_admin,
)
return Token(access_token=token, token_type="bearer",
user_id=user.id, username=user.username, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
return UserOut(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
has_passkey=current_user.pocketid_sub is not None,
)
@router.get("/pocketid/available")
async def pocketid_available():
return {"available": bool(settings.pocketid_issuer and settings.pocketid_client_id)}
async def pocketid_available(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url")
async def pocketid_login_url():
"""Return the OIDC authorization URL for PocketID."""
if not settings.pocketid_issuer:
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
params = {
"client_id": settings.pocketid_client_id,
"redirect_uri": "/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email",
}
from urllib.parse import urlencode
url = f"{settings.pocketid_issuer}/authorize?{urlencode(params)}"
return {"url": url}
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": _make_login_state(),
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/link-url")
async def pocketid_link_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Authenticated user starts an OIDC flow to attach a passkey to THEIR account.
The `state` carries a signed 'link to this user' token so the callback links
the returned identity instead of creating/matching a new account.
"""
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": _make_link_state(current_user.id),
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, db: AsyncSession = Depends(get_db)):
"""Exchange OIDC code for tokens and create/login user."""
if not settings.pocketid_issuer:
async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
# Exchange code for tokens
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{settings.pocketid_issuer}/token",
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": "/api/auth/pocketid/callback",
"client_id": settings.pocketid_client_id,
"client_secret": settings.pocketid_client_secret,
},
f"{issuer}/api/oidc/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": client_secret},
)
if resp.status_code != 200:
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json()
access_token = tokens.get("access_token")
if not access_token:
raise HTTPException(status_code=400, detail="Token exchange failed")
userinfo_resp = await client.get(
f"{settings.pocketid_issuer}/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {access_token}"},
)
if userinfo_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Failed to fetch user info")
userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
# A missing subject means we cannot identify the user. Never continue, or the
# `pocketid_sub == sub` (== None → IS NULL) lookups below would match any
# password-only account and log the caller in as someone else.
if not sub:
return RedirectResponse(url="/login?auth_error=no_identity")
# ── Explicit account-link flow ──────────────────────────────────────────
# Initiated by an already-authenticated user from their profile. Attach the
# passkey to that account. No group gating here: this is identity linking,
# not access control, and the initiator is already an authorised user.
link_user_id = _decode_link_state(state)
if link_user_id is not None:
result = await db.execute(select(User).where(User.pocketid_sub == sub))
holder = result.scalar_one_or_none()
if holder and holder.id != link_user_id:
# This passkey is already attached to a different account.
return RedirectResponse(url="/login?auth_error=passkey_in_use")
result = await db.execute(select(User).where(User.id == link_user_id))
target = result.scalar_one_or_none()
if target is None:
return RedirectResponse(url="/login?auth_error=link_failed")
target.pocketid_sub = sub
target.hashed_password = None # disable password login once passkey is linked
if not target.email and email:
dup = await db.execute(
select(User).where(User.email == email, User.id != target.id)
)
if dup.scalar_one_or_none() is None:
target.email = email
return RedirectResponse(url="/profile?linked=1")
# Normal sign-in: require the signed, short-lived state we issued in
# /pocketid/login-url, so the callback can't be driven by an injected code.
if not _valid_login_state(state):
return RedirectResponse(url="/login?auth_error=invalid_state")
# Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db)
if allowed_group:
groups = userinfo.get("groups") or []
if allowed_group not in groups:
return RedirectResponse(url="/login?auth_error=not_authorized")
# 1) Existing passkey identity → use it.
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
# 2) No passkey identity yet, but an account with this email exists and is
# not already linked to a different passkey → link them (preserves data).
if not user and email:
result = await db.execute(select(User).where(User.email == email))
existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub
existing.hashed_password = None # disable password login once passkey is linked
user = existing
# 3) Otherwise provision a new account with a collision-safe username.
if not user:
user = User(
username=preferred_username,
email=email,
pocketid_sub=sub,
)
base = preferred_username or (email.split("@")[0] if email else "user")
username = await _unique_username(db, base)
# Only set email if no other account already claims it (unique column).
email_taken = False
if email:
dup = await db.execute(select(User).where(User.email == email))
email_taken = dup.scalar_one_or_none() is not None
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
# Redirect to frontend with token
from fastapi.responses import RedirectResponse
return RedirectResponse(url=f"/?token={token}")
+242
View File
@@ -0,0 +1,242 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import User, GarminConnectConfig
router = APIRouter()
def _redis_client():
import redis as redis_lib
return redis_lib.Redis.from_url(settings.redis_url)
def sync_task_key(user_id: int) -> str:
return f"garmin_sync_task:{user_id}"
def sync_cancel_key(user_id: int) -> str:
return f"garmin_sync_cancel:{user_id}"
class GarminConfigIn(BaseModel):
email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
sync_enabled: bool = True
sync_activities: bool = True
sync_wellness: bool = True
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
class GarminConfigOut(BaseModel):
email: str
sync_enabled: bool
sync_activities: bool
sync_wellness: bool
sync_lookback_days: int
sync_interval_minutes: int # how often the automatic sync runs
last_sync_at: Optional[datetime]
last_sync_status: Optional[str]
connected: bool
class Config:
from_attributes = True
def _wants_more_history(old: int, new: int) -> bool:
"""True if `new` lookback requests older data than `old` (-1 = all-time)."""
if new == old:
return False
if new == -1: # all-time requested where it wasn't before
return True
if old == -1: # was all-time, now finite → narrower, not more
return False
return new > old
@router.get("/config", response_model=GarminConfigOut)
async def get_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg:
return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
last_sync_at=None, last_sync_status=None, connected=False,
)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.put("/config", response_model=GarminConfigOut)
async def save_config(
body: GarminConfigIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Save Garmin Connect settings. If a password is provided, re-authenticates and
refreshes the stored OAuth token. If no password is provided, only updates the
non-credential settings (toggles, lookback days) without re-logging in.
"""
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if body.password:
# Credentials update — test-login before saving
enc = encrypt_password(body.password)
try:
garmin, token_store = authenticate_garmin(body.email, enc, None)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
if cfg:
cfg.email = body.email
cfg.password_enc = enc
cfg.token_store = token_store
cfg.last_sync_status = "Credentials updated"
else:
cfg = GarminConnectConfig(
user_id=current_user.id,
email=body.email,
password_enc=enc,
token_store=token_store,
last_sync_status="Connected",
)
db.add(cfg)
else:
# Settings-only update — password unchanged
if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
# If the user is now asking for MORE history than before, reset last_sync_at so
# the next sync treats it as a first sync and does a one-time backfill of the
# wider lookback window (then resumes cheap incremental syncs). Scheduled syncs
# otherwise only refresh the last day or two, so without this an increased
# lookback would never actually fetch the older data.
old_lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
if _wants_more_history(old_lookback, body.sync_lookback_days):
cfg.last_sync_at = None
cfg.last_sync_status = "Lookback increased — backfill on next sync"
cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness
cfg.sync_lookback_days = body.sync_lookback_days
await db.commit()
await db.refresh(cfg)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
sync_interval_minutes=settings.garmin_sync_interval_minutes,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.delete("/config")
async def delete_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
await db.delete(cfg)
await db.commit()
return {"status": "ok"}
@router.post("/trigger")
async def trigger_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Enqueue an immediate Garmin Connect sync for this user."""
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id)
# Track the active task id and clear any stale cancel flag so the new sync runs.
try:
r = _redis_client()
r.delete(sync_cancel_key(current_user.id))
r.set(sync_task_key(current_user.id), task.id, ex=3600)
except Exception:
pass
return {"task_id": task.id, "status": "queued"}
@router.post("/cancel")
async def cancel_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Request cancellation of the user's in-progress Garmin sync. The running task
checks this flag between items and aborts cooperatively."""
from app.workers.tasks import celery_app
try:
r = _redis_client()
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
task_id = r.get(sync_task_key(current_user.id))
if task_id:
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
# terminate=False: don't kill a running worker mid-transaction; the
# cooperative flag handles an already-running task, and this revoke
# prevents a still-queued one from starting.
celery_app.control.revoke(tid, terminate=False)
except Exception:
pass
# Reflect intent immediately so the UI updates before the worker writes "Cancelled".
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
cfg.last_sync_status = "Cancelling…"
await db.commit()
return {"status": "cancelling"}
+52 -21
View File
@@ -1,9 +1,9 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from pydantic import BaseModel, model_validator
from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
@@ -44,6 +44,13 @@ class HealthMetricOut(BaseModel):
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
@model_validator(mode='after')
def _strip_bb_values(self):
if isinstance(self.body_battery, dict):
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
return self
class Config:
from_attributes = True
@@ -53,17 +60,20 @@ class HealthMetricOut(BaseModel):
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=1000),
limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
q = q.where(HealthMetric.date >= from_date)
if to_date:
q = q.where(HealthMetric.date <= to_date)
q = q.order_by(desc(HealthMetric.date)).limit(limit)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@@ -73,8 +83,6 @@ async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
@@ -83,9 +91,7 @@ async def health_summary(
)
latest = latest_result.scalar_one_or_none()
# 30-day averages
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
@@ -97,7 +103,7 @@ async def health_summary(
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
@@ -116,23 +122,48 @@ async def health_summary(
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
"sleep_stages": metric.sleep_stages if metric else None,
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually add or update a health metric for a given date."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
from fastapi import HTTPException
date_str = body.get("date")
if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
@@ -153,4 +184,4 @@ async def add_manual_metric(
db.add(metric)
await db.commit()
return {"status": "ok"}
return {"status": "ok"}
+262
View File
@@ -0,0 +1,262 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
biological_sex: Optional[str] = None
goal_weight_kg: Optional[float] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
biological_sex: Optional[str]
goal_weight_kg: Optional[float]
estimated_max_hr: Optional[int]
is_admin: bool
dashboard_layout: Optional[list] = None
class Config:
from_attributes = True
class DashboardLayoutIn(BaseModel):
layout: Optional[list] = None # react-grid-layout array of {i,x,y,w,h}
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.put("/dashboard-layout")
async def save_dashboard_layout(
body: DashboardLayoutIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Persist the user's customised dashboard widget layout."""
current_user.dashboard_layout = body.layout
await db.commit()
return {"status": "ok"}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
old_max_hr = current_user.max_heart_rate
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
if body.biological_sex is not None:
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
if body.goal_weight_kg is not None:
if body.goal_weight_kg and not (20 <= body.goal_weight_kg <= 500):
raise HTTPException(400, "Goal weight must be 20500 kg")
current_user.goal_weight_kg = body.goal_weight_kg or None
await db.commit()
await db.refresh(current_user)
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
from app.workers.tasks import recalculate_hr_zones_for_user
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
allowed_group: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"allowed_group": allowed_group or "",
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
# Only overwrite the secret when a non-empty value is supplied; a blank
# field means "keep the existing secret" (matches the UI hint).
if body.client_secret:
current_user.pocketid_client_secret = body.client_secret
if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
+31 -1
View File
@@ -7,7 +7,7 @@ from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
from app.models.user import User, PersonalRecord, NamedRoute, HealthMetric, Activity
router = APIRouter()
@@ -44,6 +44,36 @@ async def list_records(
return result.scalars().all()
@router.get("/routes")
async def route_records(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest activity per named route (course records)."""
from sqlalchemy import text
rows = await db.execute(
text("""
SELECT DISTINCT ON (nr.id)
nr.id AS route_id,
nr.name AS route_name,
nr.sport_type,
nr.distance_m,
nr.reference_polyline,
a.id AS activity_id,
a.name AS activity_name,
a.duration_s,
a.start_time,
a.avg_speed_ms
FROM named_routes nr
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
ORDER BY nr.id, a.duration_s ASC
"""),
{"uid": current_user.id},
)
return [dict(r._mapping) for r in rows]
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
+140 -64
View File
@@ -1,29 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
from app.models.user import User, NamedRoute, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int # use this activity as the reference route
activity_id: int
class RouteUpdate(BaseModel):
name: Optional[str] = None
sport_type: Optional[str] = None
class RouteOut(BaseModel):
@@ -34,18 +32,9 @@ class RouteOut(BaseModel):
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
activity_count: int = 0
class Config:
from_attributes = True
@@ -56,12 +45,58 @@ async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute)
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
return result.scalars().all()
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
@@ -70,7 +105,6 @@ async def create_route(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Load the reference activity
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
@@ -89,11 +123,10 @@ async def create_route(
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
# Link this activity to the route
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
@@ -118,13 +151,37 @@ async def get_route(
return route
@router.patch("/{route_id}", response_model=RouteOut)
async def update_route(
route_id: int,
body: RouteUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
if body.name is not None and body.name.strip():
route.name = body.name.strip()
if body.sport_type is not None:
route.sport_type = body.sport_type
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""All activities on this named route, ordered fastest first."""
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
@@ -146,6 +203,61 @@ async def route_activities(
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
@@ -153,7 +265,6 @@ async def assign_activity_to_route(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually assign an activity to a named route."""
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
@@ -164,41 +275,6 @@ async def assign_activity_to_route(
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
+214
View File
@@ -0,0 +1,214 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
import polyline as polyline_lib
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Segment, SegmentEffort, Activity, ActivityDataPoint
from app.services.route_matcher import haversine_m
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
activity_id: int
start_distance_m: float
end_distance_m: float
class EffortOut(BaseModel):
activity_id: int
activity_name: str
date: Optional[datetime]
duration_s: float
rank: Optional[int]
class SegmentOut(BaseModel):
id: int
name: str
sport_type: Optional[str]
polyline: Optional[str]
distance_m: Optional[float]
created_from_activity_id: Optional[int]
effort_count: int = 0
best_s: Optional[float] = None
class SegmentDetailOut(SegmentOut):
leaderboard: List[EffortOut] = []
class ActivitySegmentOut(BaseModel):
segment_id: int
name: str
polyline: Optional[str]
distance_m: Optional[float]
duration_s: float
rank: Optional[int] # this activity's place on the leaderboard
best_s: Optional[float] # current gold time
effort_count: int
def _bbox(coords):
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats), "min_lon": min(lons), "max_lon": max(lons)}
async def _own_segment(segment_id: int, user_id: int, db: AsyncSession) -> Segment:
seg = (await db.execute(
select(Segment).where(Segment.id == segment_id, Segment.user_id == user_id)
)).scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
return seg
@router.get("/", response_model=List[SegmentOut])
async def list_segments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
segs = (await db.execute(
select(Segment).where(Segment.user_id == current_user.id).order_by(Segment.created_at.desc())
)).scalars().all()
out = []
for s in segs:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == s.id)
)).one()
out.append(SegmentOut(
id=s.id, name=s.name, sport_type=s.sport_type, polyline=s.polyline,
distance_m=s.distance_m, created_from_activity_id=s.created_from_activity_id,
effort_count=agg[0] or 0, best_s=agg[1],
))
return out
@router.post("/", response_model=SegmentOut)
async def create_segment(
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity = (await db.execute(
select(Activity).where(Activity.id == body.activity_id, Activity.user_id == current_user.id)
)).scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
lo, hi = sorted((body.start_distance_m, body.end_distance_m))
dps = (await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == activity.id)
.order_by(ActivityDataPoint.timestamp)
)).scalars().all()
coords = [
(p.latitude, p.longitude)
for p in dps
if p.distance_m is not None and p.latitude is not None and p.longitude is not None
and lo <= p.distance_m <= hi
]
if len(coords) < 2:
raise HTTPException(status_code=400, detail="Selected range has too few GPS points")
seg = Segment(
user_id=current_user.id,
name=body.name.strip() or "Segment",
sport_type=activity.sport_type,
polyline=polyline_lib.encode(coords),
start_lat=coords[0][0], start_lng=coords[0][1],
end_lat=coords[-1][0], end_lng=coords[-1][1],
distance_m=max(0.0, hi - lo),
bounding_box=_bbox(coords),
created_from_activity_id=activity.id,
)
db.add(seg)
await db.commit()
await db.refresh(seg)
# Match across all activities in the background.
from app.workers.tasks import match_segment
match_segment.delay(seg.id)
return SegmentOut(
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
effort_count=0, best_s=None,
)
@router.get("/by-activity/{activity_id}", response_model=List[ActivitySegmentOut])
async def segments_for_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Segments that this activity has an effort on, with the activity's place + the gold time."""
rows = (await db.execute(
select(Segment, SegmentEffort)
.join(SegmentEffort, SegmentEffort.segment_id == Segment.id)
.where(Segment.user_id == current_user.id, SegmentEffort.activity_id == activity_id)
.order_by(Segment.created_at.desc())
)).all()
out = []
for seg, effort in rows:
agg = (await db.execute(
select(func.count(SegmentEffort.id), func.min(SegmentEffort.duration_s))
.where(SegmentEffort.segment_id == seg.id)
)).one()
out.append(ActivitySegmentOut(
segment_id=seg.id, name=seg.name, polyline=seg.polyline, distance_m=seg.distance_m,
duration_s=effort.duration_s, rank=effort.rank,
best_s=agg[1], effort_count=agg[0] or 0,
))
return out
@router.get("/{segment_id}", response_model=SegmentDetailOut)
async def get_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
rows = (await db.execute(
select(SegmentEffort, Activity)
.join(Activity, Activity.id == SegmentEffort.activity_id)
.where(SegmentEffort.segment_id == seg.id)
.order_by(SegmentEffort.duration_s)
)).all()
leaderboard = [
EffortOut(
activity_id=e.activity_id, activity_name=a.name,
date=e.achieved_at or a.start_time, duration_s=e.duration_s, rank=e.rank,
)
for e, a in rows
]
return SegmentDetailOut(
id=seg.id, name=seg.name, sport_type=seg.sport_type, polyline=seg.polyline,
distance_m=seg.distance_m, created_from_activity_id=seg.created_from_activity_id,
effort_count=len(leaderboard), best_s=leaderboard[0].duration_s if leaderboard else None,
leaderboard=leaderboard,
)
@router.delete("/{segment_id}", status_code=204)
async def delete_segment(
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
seg = await _own_segment(segment_id, current_user.id, db)
await db.delete(seg)
await db.commit()
+109 -26
View File
@@ -1,5 +1,4 @@
import os
import shutil
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
@@ -13,18 +12,68 @@ from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB upload cap
MAX_EXTRACT_SIZE = 4 * 1024 * 1024 * 1024 # 4 GB total uncompressed cap (zip-bomb guard)
_CHUNK = 1024 * 1024
def _safe_name(filename: str) -> str:
"""Reduce an uploaded filename to a safe basename — no path traversal."""
name = os.path.basename((filename or "").replace("\\", "/"))
if not name or name in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid filename")
return name
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
"""Stream an upload to disk under dest_dir, enforcing the size cap."""
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename
dest = dest_dir / _safe_name(upload.filename)
size = 0
with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f)
while True:
chunk = upload.file.read(_CHUNK)
if not chunk:
break
size += len(chunk)
if size > MAX_FILE_SIZE:
f.close()
dest.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="File exceeds the 500 MB limit")
f.write(chunk)
return dest
def _safe_extract(zf: zipfile.ZipFile, dest_dir: Path) -> list[Path]:
"""Extract a zip safely: skip path-traversal members, cap total uncompressed
bytes (zip-bomb guard). Returns the list of extracted regular-file paths."""
dest_dir.mkdir(parents=True, exist_ok=True)
dest_root = dest_dir.resolve()
total = 0
extracted: list[Path] = []
for info in zf.infolist():
if info.is_dir():
continue
target = (dest_root / info.filename).resolve()
# Reject absolute paths and ../ traversal: the target must stay under dest_root.
if target != dest_root and dest_root not in target.parents:
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
while True:
chunk = src.read(_CHUNK)
if not chunk:
break
total += len(chunk)
if total > MAX_EXTRACT_SIZE:
out.close()
target.unlink(missing_ok=True)
raise HTTPException(status_code=413, detail="Archive expands beyond the size limit")
out.write(chunk)
extracted.append(target)
return extracted
@router.post("/activity")
async def upload_activity(
file: UploadFile = File(...),
@@ -62,19 +111,44 @@ async def upload_garmin_export(
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
# Extract and queue all FIT files
# Extract (safely) and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit"):
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
try:
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
except zipfile.BadZipFile:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
has_health = False
for path in extracted:
suffix = path.suffix.lower()
if suffix == ".fit":
task = process_activity_file.delay(str(path), current_user.id, "fit")
task_ids.append(task.id)
elif suffix == ".json":
has_health = True # Garmin wellness data is exported as JSON files
elif suffix == ".zip":
# Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_extract = path.parent / path.stem
try:
with zipfile.ZipFile(path) as nzf:
nested = _safe_extract(nzf, nested_extract)
except zipfile.BadZipFile:
nested = []
for np in nested:
if np.suffix.lower() == ".fit":
task = process_activity_file.delay(str(np), current_user.id, "fit")
task_ids.append(task.id)
if not task_ids and not has_health:
raise HTTPException(
status_code=400,
detail="No fitness data found in this archive — make sure you uploaded your full Garmin Connect export ZIP",
)
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
@@ -82,7 +156,7 @@ async def upload_garmin_export(
return {
"status": "queued",
"activity_tasks": len(task_ids),
"health_task": health_task.id,
"task_id": health_task.id,
}
@@ -100,22 +174,31 @@ async def upload_strava_export(
dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit") or lower.endswith(".gpx"):
file_path = extract_dir / name
ext = Path(name).suffix[1:]
task = process_activity_file.delay(str(file_path), current_user.id, ext)
task_ids.append(task.id)
try:
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
except zipfile.BadZipFile:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
for path in extracted:
suffix = path.suffix.lower()
if suffix in (".fit", ".gpx"):
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id)
if not task_ids:
raise HTTPException(
status_code=400,
detail="No activity files (.fit or .gpx) found in this Strava archive",
)
return {
"status": "queued",
"activity_tasks": len(task_ids),
"task_id": task_ids[-1] if task_ids else None,
}
+143
View File
@@ -0,0 +1,143 @@
"""
Admin-only user management: list provisioned users, promote/demote admin,
and delete a user together with all of their data.
New users are normally provisioned just-in-time on first PocketID login
(see app/api/auth.py). This router is the in-app surface for managing them.
"""
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import (
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
Segment, SegmentEffort, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
)
router = APIRouter()
def _require_admin(current_user: User):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
async def _admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
return result.scalar_one()
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool
activity_count: int
created_at: Optional[str]
class AdminUpdate(BaseModel):
is_admin: bool
@router.get("/")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
# activity counts per user in one grouped query
counts = dict(
(await db.execute(
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
)).all()
)
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
UserOut(
id=u.id,
username=u.username,
email=u.email,
is_admin=u.is_admin,
has_passkey=u.pocketid_sub is not None,
activity_count=counts.get(u.id, 0),
created_at=u.created_at.isoformat() if u.created_at else None,
)
for u in users
]
@router.patch("/{user_id}")
async def set_admin(
user_id: int,
body: AdminUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot change your own admin status")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
# Demoting the last remaining admin would lock everyone out.
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot demote the last admin")
user.is_admin = body.is_admin
await db.commit()
return {"status": "ok", "is_admin": user.is_admin}
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
if user.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot delete the last admin")
# Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
segment_ids = select(Segment.id).where(Segment.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(SegmentEffort).where(SegmentEffort.segment_id.in_(segment_ids)))
await db.execute(delete(Segment).where(Segment.user_id == user_id))
await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
# Remove the user's uploaded files from disk (best-effort).
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
return {"status": "ok"}
+7 -8
View File
@@ -6,27 +6,26 @@ from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = Field(..., env="DATABASE_URL")
# Redis
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
# Auth
secret_key: str = Field(..., env="SECRET_KEY")
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Admin account
admin_username: str = Field("admin", env="ADMIN_USERNAME")
admin_password: str = Field(..., env="ADMIN_PASSWORD")
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
# Base URL - used for OAuth callbacks
base_url: str = Field("https://milevault.jarrett.eu", env="BASE_URL")
# PocketID OIDC (optional)
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
# Garmin Connect — how often the beat scheduler runs the automatic sync
garmin_sync_interval_minutes: int = Field(30, env="GARMIN_SYNC_INTERVAL_MINUTES")
# Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment
environment: str = Field("production", env="ENVIRONMENT")
@@ -35,4 +34,4 @@ class Settings(BaseSettings):
case_sensitive = False
settings = Settings()
settings = Settings()
+16 -1
View File
@@ -1,7 +1,9 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
# Async engine for FastAPI
engine = create_async_engine(
settings.database_url,
echo=settings.environment == "development",
@@ -15,6 +17,19 @@ AsyncSessionLocal = async_sessionmaker(
expire_on_commit=False,
)
# Sync engine for Celery workers (Celery + asyncio don't mix well)
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
sync_engine = create_engine(
sync_url,
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
+201 -24
View File
@@ -2,45 +2,218 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import text
import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users, segments
@asynccontextmanager
async def lifespan(app: FastAPI):
# Create tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup.
# Try to enable TimescaleDB hypertable for data points
Multiple uvicorn workers may race here on first start. We tolerate
duplicate table errors since they just mean another worker got there first.
"""
for attempt in range(15):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
print("Tables already created by another worker - skipping")
break
if attempt == 14:
raise
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
await asyncio.sleep(2)
# Try TimescaleDB hypertable (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"SELECT create_hypertable('activity_data_points', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
))
except Exception:
pass # Already exists or TimescaleDB not available
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Seed admin user
async with AsyncSessionLocal() as db:
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
# Add columns that were introduced after initial table creation (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE garmin_connect_configs "
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
))
except Exception as e:
print(f"Column migration skipped: {e}")
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
# activities.moving_time_s column added after initial creation (timer time)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
))
except Exception as e:
print(f"activities.moving_time_s column migration skipped: {e}")
# health_metrics columns added after initial creation
try:
async with engine.begin() as conn:
for stmt in [
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
]:
await conn.execute(text(stmt))
except Exception as e:
print(f"health_metrics column migration skipped: {e}")
# biological_sex column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
))
except Exception as e:
print(f"users.biological_sex column migration skipped: {e}")
# pocketid_allowed_group column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
))
except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {e}")
# goal_weight_kg column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS goal_weight_kg FLOAT"
))
except Exception as e:
print(f"users.goal_weight_kg column migration skipped: {e}")
# dashboard_layout column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS dashboard_layout JSON"
))
except Exception as e:
print(f"users.dashboard_layout column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
await conn.execute(text("""
UPDATE health_metrics SET
avg_hr_day = sub.avg_hr,
max_hr_day = sub.max_hr
FROM (
SELECT id,
AVG((elem->>1)::float) AS avg_hr,
MAX((elem->>1)::float) AS max_hr
FROM health_metrics,
json_array_elements(intraday_hr) AS elem
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
AND intraday_hr IS NOT NULL
AND (elem->>1)::float > 0
GROUP BY id
) sub
WHERE health_metrics.id = sub.id
"""))
except Exception as e:
print(f"avg_hr_day backfill skipped: {e}")
# Replace the all-columns unique constraint on personal_records with a partial
# index (only current records must be unique per user/sport/distance).
# The old constraint also covered is_current_record=False rows, causing
# UniqueViolation crashes when multiple workers deactivate the same PR.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE personal_records "
"DROP CONSTRAINT IF EXISTS uq_pr_current"
))
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
"ON personal_records (user_id, sport_type, distance_m) "
"WHERE is_current_record = true"
))
except Exception as e:
print(f"PR constraint migration skipped: {e}")
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
# without first manually unlinking every activity.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities "
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
))
await conn.execute(text(
"ALTER TABLE activities "
"ADD CONSTRAINT activities_named_route_id_fkey "
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
))
except Exception as e:
print(f"FK migration skipped: {e}")
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
await conn.execute(text(
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
except Exception as e:
print(f"avg_speed_ms fix skipped: {e}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
return
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
db.add(admin)
await db.commit()
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
except Exception as e:
msg = str(e).lower()
if "duplicate" in msg or "unique" in msg:
print("Admin user already exists - skipping seed")
else:
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
@@ -64,6 +237,10 @@ app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
app.include_router(segments.router, prefix="/api/segments", tags=["segments"])
@app.get("/health")
+119 -63
View File
@@ -1,6 +1,6 @@
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint
ForeignKey, Text, JSON, Index, UniqueConstraint, text
)
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
@@ -17,14 +17,72 @@ class User(Base):
id = Column(Integer, primary_key=True)
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(256), unique=True, nullable=True)
hashed_password = Column(String(256), nullable=True) # null = OIDC-only user
hashed_password = Column(String(256), nullable=True)
is_admin = Column(Boolean, default=False)
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
# Health profile
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
goal_weight_kg = Column(Float, nullable=True)
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
pocketid_client_id = Column(String(256), nullable=True)
pocketid_client_secret = Column(String(256), nullable=True)
# Only PocketID users in this group may sign in. Null/blank = allow all.
pocketid_allowed_group = Column(String(128), nullable=True)
# Saved dashboard widget layout (react-grid-layout array). Null = use default.
dashboard_layout = Column(JSON, nullable=True)
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
class GarminConnectConfig(Base):
"""Per-user Garmin Connect credentials and sync state."""
__tablename__ = "garmin_connect_configs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
email = Column(String(256), nullable=False)
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
sync_enabled = Column(Boolean, default=True)
sync_activities = Column(Boolean, default=True)
sync_wellness = Column(Boolean, default=True)
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="garmin_connect_config")
class WeightLog(Base):
"""Manual weight entries separate from health_metrics for easy tracking."""
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
weight_kg = Column(Float, nullable=False)
body_fat_pct = Column(Float, nullable=True)
note = Column(String(256), nullable=True)
__table_args__ = (
Index("ix_weight_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="weight_logs")
class Activity(Base):
@@ -32,16 +90,13 @@ class Activity(Base):
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# Core fields
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False) # running, cycling, swimming, etc.
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
# Metrics summary (cached aggregates)
distance_m = Column(Float, nullable=True) # metres
duration_s = Column(Float, nullable=True) # seconds
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True) # total elapsed time (wall clock, incl. pauses)
moving_time_s = Column(Float, nullable=True) # timer time — excludes paused periods
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
@@ -55,23 +110,14 @@ class Activity(Base):
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
vo2max_estimate = Column(Float, nullable=True)
# Route reference
named_route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=True)
# Raw GPS track (encoded polyline for quick map render)
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True) # {min_lat, max_lat, min_lon, max_lon}
# Source file info
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True) # fit, gpx, strava_json
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
# HR zones (% of time in each zone)
hr_zones = Column(JSON, nullable=True) # {z1: pct, z2: pct, ...}
hr_zones = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="activities")
@@ -81,16 +127,10 @@ class Activity(Base):
class ActivityDataPoint(Base):
"""
TimescaleDB hypertable - one row per second of activity data.
After creation, converted to hypertable in migration:
SELECT create_hypertable('activity_data_points', 'timestamp');
"""
__tablename__ = "activity_data_points"
id = Column(Integer, primary_key=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
timestamp = Column(DateTime(timezone=True), nullable=False)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
altitude_m = Column(Float, nullable=True)
@@ -99,11 +139,7 @@ class ActivityDataPoint(Base):
speed_ms = Column(Float, nullable=True)
power = Column(Float, nullable=True)
temperature_c = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True) # cumulative distance
__table_args__ = (
Index("ix_adp_activity_time", "activity_id", "timestamp"),
)
distance_m = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="data_points")
@@ -133,28 +169,53 @@ class NamedRoute(Base):
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
reference_polyline = Column(Text, nullable=True) # canonical route polyline
reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base):
"""Named sections within a route for targeted comparisons (e.g. 'The big hill')"""
__tablename__ = "route_segments"
class Segment(Base):
"""A user-defined GPS segment (a stretch of road/trail) matched across activities."""
__tablename__ = "segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False) # distance into route where segment starts
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
polyline = Column(Text, nullable=True) # encoded GPS geometry of the segment
start_lat = Column(Float, nullable=True)
start_lng = Column(Float, nullable=True)
end_lat = Column(Float, nullable=True)
end_lng = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
bounding_box = Column(JSON, nullable=True) # {min_lat,max_lat,min_lon,max_lon}
created_from_activity_id = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
route = relationship("NamedRoute", back_populates="segments")
efforts = relationship("SegmentEffort", back_populates="segment", cascade="all, delete-orphan")
class SegmentEffort(Base):
"""One activity's time over a segment."""
__tablename__ = "segment_efforts"
id = Column(Integer, primary_key=True)
segment_id = Column(Integer, ForeignKey("segments.id", ondelete="CASCADE"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id", ondelete="CASCADE"), nullable=False, index=True)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=True)
rank = Column(Integer, nullable=True) # 1/2/3 for podium, else null
__table_args__ = (
UniqueConstraint("segment_id", "activity_id", name="uq_segment_effort"),
)
segment = relationship("Segment", back_populates="efforts")
class PersonalRecord(Base):
@@ -164,38 +225,35 @@ class PersonalRecord(Base):
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
sport_type = Column(String(64), nullable=False)
distance_m = Column(Float, nullable=False) # e.g. 1000, 1609, 5000, 10000, 42195
distance_label = Column(String(32), nullable=False) # e.g. "1k", "1 mile", "5k"
distance_m = Column(Float, nullable=False)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
__table_args__ = (
UniqueConstraint("user_id", "sport_type", "distance_m", "is_current_record",
name="uq_pr_current"),
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
# (created in init_db), which only covers is_current_record=true rows.
# The old all-columns UniqueConstraint was dropped because it incorrectly
# constrained is_current_record=false rows too, causing multi-worker races.
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
postgresql_where=text("is_current_record = true"), unique=True),
)
class HealthMetric(Base):
"""Daily health summary metrics from Garmin Connect / FIT wellness data"""
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
# Heart rate
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
# HRV
hrv_status = Column(String(32), nullable=True) # balanced, unbalanced, etc.
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
# Sleep
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
@@ -204,26 +262,24 @@ class HealthMetric(Base):
sleep_score = Column(Float, nullable=True)
sleep_start = Column(DateTime(timezone=True), nullable=True)
sleep_end = Column(DateTime(timezone=True), nullable=True)
# Body composition
weight_kg = Column(Float, nullable=True)
bmi = Column(Float, nullable=True)
body_fat_pct = Column(Float, nullable=True)
muscle_mass_kg = Column(Float, nullable=True)
# Fitness
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
# Stress & activity
avg_stress = Column(Float, nullable=True)
steps = Column(Integer, nullable=True)
floors_climbed = Column(Integer, nullable=True)
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
+365 -318
View File
@@ -1,326 +1,30 @@
"""
Parses Garmin .fit files and GPX files into normalized activity data.
Handles full Strava and Garmin data export archives.
FIT and GPX file parser.
Parses FIT files directly using the Garmin SDK but applies manual
scale conversion for fields where the SDK doesn't auto-convert.
"""
import os
import zipfile
import json
import math
from pathlib import Path
import struct
from datetime import datetime, timezone
from typing import Optional
import fitparse
import gpxpy
import polyline as polyline_lib
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Returns distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return 2 * R * math.asin(math.sqrt(a))
def semicircles_to_degrees(sc: int) -> float:
return sc * (180 / 2**31)
def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit file and return normalized activity dict."""
fit = fitparse.FitFile(filepath)
data_points = []
laps = []
session = {}
for record in fit.get_messages():
name = record.name
if name == "session":
for f in record:
session[f.name] = f.value
elif name == "lap":
lap = {}
for f in record:
lap[f.name] = f.value
laps.append(lap)
elif name == "record":
point = {}
for f in record:
point[f.name] = f.value
if point:
# Convert semicircles to degrees
if "position_lat" in point and point["position_lat"] is not None:
point["position_lat"] = semicircles_to_degrees(point["position_lat"])
if "position_long" in point and point["position_long"] is not None:
point["position_long"] = semicircles_to_degrees(point["position_long"])
data_points.append(point)
# Build normalized output
sport = str(session.get("sport", "generic")).lower()
sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running",
}
sport_type = sport_map.get(sport, sport)
start_time = session.get("start_time")
if start_time and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
# Build GPS track for polyline
coords = [
(p["position_lat"], p["position_long"])
for p in data_points
if p.get("position_lat") is not None and p.get("position_long") is not None
]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Calculate cumulative distance if not in FIT
cumulative_dist = 0.0
prev_lat, prev_lon = None, None
normalized_points = []
for p in data_points:
ts = p.get("timestamp")
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
lat = p.get("position_lat")
lon = p.get("position_long")
dist = p.get("distance")
if dist is None and lat and lon and prev_lat and prev_lon:
cumulative_dist += haversine_distance(prev_lat, prev_lon, lat, lon)
dist = cumulative_dist
elif dist is not None:
cumulative_dist = float(dist)
if lat and lon:
prev_lat, prev_lon = lat, lon
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": lat,
"longitude": lon,
"altitude_m": p.get("altitude"),
"heart_rate": p.get("heart_rate"),
"cadence": p.get("cadence"),
"speed_ms": p.get("speed"),
"power": p.get("power"),
"temperature_c": p.get("temperature"),
"distance_m": dist,
})
# Parse laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if ls and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": _safe_float(lap.get("total_elapsed_time")),
"distance_m": _safe_float(lap.get("total_distance")),
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
"avg_cadence": _safe_float(lap.get("avg_cadence")),
"avg_speed_ms": _safe_float(lap.get("avg_speed")),
"avg_power": _safe_float(lap.get("avg_power")),
})
return {
"name": session.get("sport", "Activity").title() + " " + (
start_time.strftime("%Y-%m-%d") if start_time else ""),
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")),
"duration_s": _safe_float(session.get("total_elapsed_time")),
"elevation_gain_m": _safe_float(session.get("total_ascent")),
"elevation_loss_m": _safe_float(session.get("total_descent")),
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
"avg_cadence": _safe_float(session.get("avg_cadence")),
"avg_power": _safe_float(session.get("avg_power")),
"normalized_power": _safe_float(session.get("normalized_power")),
"avg_speed_ms": _safe_float(session.get("avg_speed")),
"max_speed_ms": _safe_float(session.get("max_speed")),
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
"calories": _safe_float(session.get("total_calories")),
"training_stress_score": _safe_float(session.get("training_stress_score")),
"vo2max_estimate": _safe_float(session.get("estimated_sweat_loss")), # varies by device
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"data_points": normalized_points,
"laps": normalized_laps,
}
def parse_gpx_file(filepath: str) -> dict:
"""Parse a GPX file into normalized activity dict."""
with open(filepath) as f:
gpx = gpxpy.parse(f)
data_points = []
track = gpx.tracks[0] if gpx.tracks else None
if not track:
raise ValueError("No tracks found in GPX file")
for segment in track.segments:
for pt in segment.points:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
for child in ext:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
try:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude,
"longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
"speed_ms": extensions.get("speed"),
"power": extensions.get("power"),
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
"distance_m": None,
})
# Calculate distance and elevation
coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
diff = alts[i] - alts[i-1]
if diff > 0:
uphill += diff
else:
downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = "running" # GPX doesn't always include sport; default to running
if track.type:
sport = track.type.lower()
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport,
"start_time": start_time_str,
"distance_m": total_dist,
"duration_s": duration,
"elevation_gain_m": uphill,
"elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None,
"avg_power": None,
"normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"max_speed_ms": None,
"avg_temperature_c": None,
"calories": None,
"training_stress_score": None,
"vo2max_estimate": None,
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "gpx",
"data_points": data_points,
"laps": [],
}
def parse_strava_export(export_dir: str) -> list[dict]:
"""
Parse a full Strava data export directory.
Structure: activities.csv + activities/ folder with .gpx/.fit.gz files
"""
activities = []
activities_dir = Path(export_dir) / "activities"
if not activities_dir.exists():
return activities
for fname in sorted(activities_dir.iterdir()):
if fname.suffix in (".fit", ".gpx"):
try:
if fname.suffix == ".fit":
act = parse_fit_file(str(fname))
else:
act = parse_gpx_file(str(fname))
act["source_type"] = "strava_" + fname.suffix[1:]
activities.append(act)
except Exception as e:
print(f"Error parsing {fname}: {e}")
return activities
def calculate_hr_zones(data_points: list[dict], max_hr: float) -> dict:
"""Calculate percentage of time spent in each HR zone."""
if not max_hr:
return {}
zones = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0}
zone_bounds = [0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr:
continue
pct = hr / max_hr
total += 1
if pct < zone_bounds[1]:
zones["z1"] += 1
elif pct < zone_bounds[2]:
zones["z2"] += 1
elif pct < zone_bounds[3]:
zones["z3"] += 1
elif pct < zone_bounds[4]:
zones["z4"] += 1
else:
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
def _semicircles_to_deg(val):
if val is None:
return None
try:
result = float(val) * SEMICIRCLES_TO_DEG
if -90 <= result <= 90 or -180 <= result <= 180:
return result
except (TypeError, ValueError):
pass
return None
def _safe_float(val) -> Optional[float]:
@@ -330,12 +34,355 @@ def _safe_float(val) -> Optional[float]:
return None
def _bounding_box(coords: list[tuple]) -> Optional[dict]:
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
fv = _safe_float(val)
if fv is None or fv >= 65.0:
if dist_m and dur_s and float(dur_s) > 0:
return float(dist_m) / float(dur_s)
return None
return fv
# Conservative average-speed ceilings (m/s) above which an activity was almost
# certainly recorded in a vehicle rather than under human power. Sports not
# listed fall back to the generous default.
_VEHICLE_SPEED_CEILINGS = {
"running": 8.0, # ~28.8 km/h — well above elite sprint pace sustained
"walking": 8.0,
"hiking": 8.0,
"cycling": 22.0, # ~79 km/h — beyond sustained amateur cycling
}
_VEHICLE_SPEED_DEFAULT = 25.0 # ~90 km/h
def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Optional[str]:
"""Return a human-readable reason if the average speed is implausibly fast for
the sport (i.e. the 'activity' looks like car/vehicle travel), else None."""
speed = _safe_float(avg_speed_ms)
if speed is None and dist_m and dur_s and float(dur_s) > 0:
speed = float(dist_m) / float(dur_s)
if speed is None or speed <= 0:
return None
ceiling = _VEHICLE_SPEED_CEILINGS.get(sport_type, _VEHICLE_SPEED_DEFAULT)
if speed > ceiling:
return (f"Looks like vehicle travel — average speed {speed * 3.6:.0f} km/h "
f"exceeds the plausible limit for {sport_type}")
return None
def _bounding_box(coords):
if not coords:
return None
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {
"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons),
return {"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons)}
def _to_dt(val) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
try:
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return None
return None
def _is_valid_lat(v):
return v is not None and -90 <= v <= 90
def _is_valid_lon(v):
return v is not None and -180 <= v <= 180
def parse_fit_file(filepath: str) -> dict:
session_data = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
if mesg_num == 18: # session
session_data.update(msg)
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
stream = Stream.from_file(filepath)
decoder = Decoder(stream)
decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
# The SDK may return field names in camelCase or snake_case depending on version.
# Try both. Also handle raw timestamp integers for start_time.
def get(d, *keys):
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
sport_map = {
"running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking",
"generic": "other", "trail_running": "running",
"e_biking": "cycling", "open_water_swimming": "other",
}
sport_type = sport_map.get(sport_raw, sport_raw)
# start_time — SDK may return datetime or raw int
start_time_raw = get(session_data, "startTime", "start_time")
start_time = _to_dt(start_time_raw)
# Position fields — the SDK may or may not convert semicircles.
# Check if values look like semicircles (>= 90 for lat) and convert if so.
def get_lat(d):
v = get(d, "positionLat", "position_lat")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
# If absolute value > 90, it's semicircles
if abs(fv) > 90:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lat(fv) else None
def get_lon(d):
v = get(d, "positionLong", "position_long")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
if abs(fv) > 180:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lon(fv) else None
# Build GPS track
coords = []
for r in records:
lat = get_lat(r)
lon = get_lon(r)
if lat is not None and lon is not None:
coords.append((lat, lon))
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = []
for r in records:
ts = _to_dt(get(r, "timestamp"))
lat = get_lat(r)
lon = get_lon(r)
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
hr = get(r, "heartRate", "heart_rate")
cadence = get(r, "cadence")
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
power = get(r, "power")
temp = get(r, "temperature")
distance = get(r, "distance")
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": _safe_float(lat),
"longitude": _safe_float(lon),
"altitude_m": _safe_float(altitude),
"heart_rate": _safe_float(hr),
"cadence": _safe_float(cadence),
"speed_ms": _safe_float(speed),
"power": _safe_float(power),
"temperature_c": _safe_float(temp),
"distance_m": _safe_float(distance),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = _to_dt(get(lap, "startTime", "start_time"))
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": lap_dur,
"distance_m": lap_dist,
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
"avg_speed_ms": _sanitize_speed(
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=lap_dist, dur_s=lap_dur,
),
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
})
name = sport_type.title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
total_dist = _safe_float(get(session_data, "totalDistance", "total_distance"))
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
# Timer time = time the device was actively recording (excludes auto/manual pauses).
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
avg_speed = _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=total_dist, dur_s=elapsed_s,
)
return {
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": total_dist,
"duration_s": elapsed_s,
"moving_time_s": moving_s,
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": avg_speed,
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
"training_stress_score")),
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
"total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points,
"laps": normalized_laps,
}
def parse_gpx_file(filepath: str) -> dict:
with open(filepath) as f:
gpx = gpxpy.parse(f)
data_points = []
track = gpx.tracks[0] if gpx.tracks else None
if not track:
raise ValueError("No tracks found in GPX file")
for segment in track.segments:
for pt in segment.points:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
for child in ext:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
try:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude, "longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
"speed_ms": extensions.get("speed"),
"power": extensions.get("power"),
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
"distance_m": None,
})
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
R = 6371000
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
dphi = math.radians(p["latitude"] - prev[0])
dlam = math.radians(p["longitude"] - prev[1])
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
total_dist += 2 * R * math.asin(math.sqrt(a))
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
diff = alts[i] - alts[i-1]
if diff > 0: uphill += diff
else: downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running"
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration, "moving_time_s": None,
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": gpx_avg_speed,
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx",
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
"data_points": data_points, "laps": [],
}
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
if not user_max_hr or user_max_hr < 100:
return {}
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr or hr < 20:
continue
pct = hr / user_max_hr
total += 1
for i, key in enumerate(zone_keys):
if zone_bounds[i] <= pct < zone_bounds[i+1]:
zones[key] += 1
break
else:
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
+589
View File
@@ -0,0 +1,589 @@
"""
Garmin Connect sync helpers.
authenticate_garmin() returns an authenticated client, refreshing the stored
OAuth token when possible and falling back to email/password re-login.
sync_activities() downloads new FIT files and queues them for processing.
sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API
and upserts them into health_metrics.
"""
import io
import zipfile
import logging
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
# On incremental syncs (last_sync_at is set) only re-fetch the last day or two
# rather than the full configured lookback window. A 1-day buffer means the
# window is "yesterday + today", which catches late-arriving / revised data
# (sleep finalised next morning, body battery, manual weight, HRV status, the
# midnight boundary) without re-pulling the same N days on every scheduled run.
INCREMENTAL_BUFFER_DAYS = 1
# ── Password encryption ─────────────────────────────────────────────────────
def _fernet():
import base64, hashlib
from cryptography.fernet import Fernet
from app.core.config import settings
key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest())
return Fernet(key)
def encrypt_password(password: str) -> str:
return _fernet().encrypt(password.encode()).decode()
def decrypt_password(enc: str) -> str:
return _fernet().decrypt(enc.encode()).decode()
# ── Auth ─────────────────────────────────────────────────────────────────────
def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple:
"""
Returns (garmin_client, new_token_store_or_None).
new_token_store is set only when tokens were refreshed/re-created so the
caller can persist them.
"""
import garminconnect
# Try stored OAuth token first.
# Use garth.loads() directly (always treats the argument as an inline string).
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
# filesystem paths and raising FileNotFoundError on every token-based auth attempt.
# After loads(), set display_name from the embedded profile — required by
# get_stats(), get_sleep_data(), and other endpoints that build URLs from it.
if token_store:
try:
garmin = garminconnect.Garmin(
email=email, password=decrypt_password(password_enc)
)
garmin.garth.loads(token_store)
garmin.display_name = (garmin.garth.profile or {}).get("displayName", "")
return garmin, None
except Exception as exc:
logger.info("Garmin token invalid (%s), re-authenticating", exc)
# Full login with email + password
garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc))
garmin.login()
return garmin, garmin.garth.dumps()
# ── Activity sync ─────────────────────────────────────────────────────────────
def sync_activities(garmin, user_id: int, since: Optional[datetime],
db, file_store_path: str, lookback_days: int = 30,
status_callback=None) -> int:
"""
List activities from Garmin Connect, skip any already in the DB, download
FIT ZIPs for new ones, and queue them for processing.
lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010
N → last N days
Every subsequent (incremental) sync re-fetches only the last
INCREMENTAL_BUFFER_DAYS days, regardless of lookback_days, to avoid
re-pulling the whole window on every scheduled run.
Returns the number of new activities queued.
"""
import time
from app.workers.tasks import process_activity_file
from app.models.user import Activity
from sqlalchemy import select, func
if since:
# Incremental: just the recent buffer (cheap, dedup skips already-imported)
start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif lookback_days == -1:
start_date = date(2010, 1, 1)
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
end_date = date.today()
try:
activities = garmin.get_activities_by_date(
start_date.isoformat(), end_date.isoformat()
)
except Exception as exc:
logger.error("Failed to list Garmin activities: %s", exc)
return 0
total = len(activities)
if status_callback and total:
status_callback(f"Syncing activities: 0/{total} queued")
queued = 0
for act in activities:
garmin_id = str(act.get("activityId", "")).strip()
if not garmin_id:
continue
# Fast path: already imported via Garmin Connect sync
existing = db.execute(
select(Activity).where(Activity.garmin_activity_id == garmin_id)
).scalar_one_or_none()
if existing:
continue
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
if act_start_str:
try:
from datetime import datetime as _dt
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
time_match = db.execute(
select(Activity).where(
Activity.user_id == user_id,
func.date(Activity.start_time) == act_start.date(),
)
).scalar_one_or_none()
if time_match:
if not time_match.garmin_activity_id:
time_match.garmin_activity_id = garmin_id
db.commit()
continue
except Exception:
pass # couldn't parse time — fall through to download
# Download original FIT (Garmin wraps it in a ZIP)
try:
zip_bytes = garmin.download_activity(
int(garmin_id),
dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL,
)
except Exception as exc:
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
continue
# Extract the FIT from the ZIP
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
if not fit_names:
logger.debug("No FIT in ZIP for activity %s", garmin_id)
continue
fit_data = zf.read(fit_names[0])
except Exception as exc:
logger.warning("Failed to unzip activity %s: %s", garmin_id, exc)
continue
# Save to disk and queue
dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect"
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{garmin_id}.fit"
dest.write_bytes(fit_data)
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
queued += 1
if status_callback and (queued % 5 == 0 or queued == total):
status_callback(f"Syncing activities: {queued}/{total} queued")
# Brief pause to avoid hammering the Garmin API
time.sleep(0.5)
return queued
# ── Wellness sync ─────────────────────────────────────────────────────────────
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
lookback_days: int = 90, status_callback=None) -> int:
"""
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
day in the window and upsert into health_metrics.
lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010
N → last N days
Every subsequent (incremental) sync re-fetches only the last
INCREMENTAL_BUFFER_DAYS days so late-finalised data (sleep, body battery,
weight) is corrected without re-pulling the whole window each run.
Returns the number of days upserted.
"""
from sqlalchemy import text
if since:
start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif lookback_days == -1:
start_date = date(2010, 1, 1)
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
days = (date.today() - start_date).days + 1
processed = 0
import time as _time
import json as _json
total_days = max(days, 1)
if status_callback:
status_callback(f"Syncing wellness: 0/{total_days} days")
for i in range(total_days):
day = start_date + timedelta(days=i)
if status_callback and (i % 5 == 0 or i == total_days - 1):
status_callback(f"Syncing wellness: {i + 1}/{total_days} days")
day_str = day.isoformat()
stats = _safe(garmin.get_stats, day_str)
sleep_data = _safe(garmin.get_sleep_data, day_str)
hrv_data = _safe(garmin.get_hrv_data, day_str)
# Intraday HR (requires display_name; skip gracefully if absent)
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None
bc_data = _safe(garmin.get_body_composition, day_str, day_str)
bb_raw = _safe(garmin.get_body_battery, day_str, day_str)
_time.sleep(0.25) # avoid hammering Garmin's wellness API
row = _parse_day(stats, sleep_data, hrv_data)
# Weight + body composition from weight service (more reliable than stats)
if bc_data:
entries = (bc_data.get("dateWeightList")
or bc_data.get("allWeightMetrics")
or bc_data.get("weightList") or [])
if entries:
e = entries[0]
bw = e.get("weight")
if bw and float(bw) > 0:
bwf = float(bw)
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
if e.get("bmi"):
_set(row, "bmi", float(e["bmi"]))
if e.get("bodyFat"):
_set(row, "body_fat_pct", float(e["bodyFat"]))
mm = e.get("muscleMass")
if mm and float(mm) > 0:
mmf = float(mm)
_set(row, "muscle_mass_kg", round(mmf / 1000 if mmf > 300 else mmf, 2))
# Weight from daily stats as fallback (present when Garmin scale is used)
if stats and "weight_kg" not in row:
bw = stats.get("bodyWeight")
if bw and float(bw) > 0:
bwf = float(bw)
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
# Body battery — store summary + fine-grained timeline
bb = None
if bb_raw:
bb = _parse_body_battery(bb_raw, day_str)
if bb:
row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
intraday = None
if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday:
row["intraday_hr"] = intraday
hr_vals = [v for _, v in intraday if v > 0]
if hr_vals:
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
row["max_hr_day"] = float(max(hr_vals))
# High-resolution body battery derived from BB checkpoints + intraday HR
if bb and intraday:
hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
if hires:
row["body_battery_hires"] = _json.dumps(hires)
if not row:
continue
# psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON
# columns as strings so psycopg2 passes them correctly to json/jsonb columns.
if "intraday_hr" in row and not isinstance(row["intraday_hr"], str):
row["intraday_hr"] = _json.dumps(row["intraday_hr"])
if "body_battery" in row and not isinstance(row["body_battery"], str):
row["body_battery"] = _json.dumps(row["body_battery"])
cols = list(row.keys())
col_sql = ", ".join(cols)
val_sql = ", ".join(f":{c}" for c in cols)
upd_sql = ", ".join(
# total_calories uses GREATEST so multiple sources don't downgrade
f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})"
if c == "total_calories" else
f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})"
for c in cols
)
params = {"user_id": user_id, "day": day.isoformat()}
params.update(row)
try:
db.execute(text(f"""
INSERT INTO health_metrics (user_id, date, {col_sql})
VALUES (:user_id, :day, {val_sql})
ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql}
"""), params)
db.commit()
processed += 1
except Exception as exc:
logger.warning("Failed to upsert health_metrics for %s: %s", day_str, exc)
db.rollback()
# Fetch historical VO2 max across the full sync window via maxmet/daily range query
today_str = date.today().isoformat()
fa_data = _safe(garmin.get_fitnessage_data, today_str)
fa_age = None
if fa_data:
fa_age = fa_data.get("fitnessAge") or fa_data.get("achievableFitnessAge")
mm_entries = []
try:
mm_raw = garmin.connectapi(
f"/metrics-service/metrics/maxmet/daily/{start_date.isoformat()}/{today_str}"
)
logger.info("maxmet range query returned type=%s len=%s",
type(mm_raw).__name__,
len(mm_raw) if isinstance(mm_raw, (list, dict)) else "n/a")
if isinstance(mm_raw, list):
mm_entries = mm_raw
except Exception as exc:
logger.info("maxmet history fetch failed: %s", exc)
# Each entry has the vo2max data nested under entry["generic"]
def _extract_generic(entry):
return (entry.get("generic") or {}) if isinstance(entry, dict) else {}
valid_from_range = any(
(_extract_generic(e).get("vo2MaxPreciseValue") or _extract_generic(e).get("vo2MaxValue") or 0)
for e in mm_entries
)
# Always fall back to training_status when the range query had no valid data
if not valid_from_range:
ts_data = _safe(garmin.get_training_status, today_str)
generic = ((ts_data or {}).get("mostRecentVO2Max") or {}).get("generic") or {}
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
logger.info("training_status vo2max=%s at %s", v, generic.get("calendarDate"))
if v and float(v) > 0:
mm_entries = [{"generic": {"calendarDate": generic.get("calendarDate") or today_str,
"vo2MaxPreciseValue": float(v)}}]
stored = 0
for entry in mm_entries:
generic = _extract_generic(entry)
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
if not v or float(v) <= 0:
continue
entry_date = generic.get("calendarDate") or today_str
try:
fa_row = {"vo2max": float(v)}
if fa_age and entry_date == today_str:
fa_row["fitness_age"] = int(fa_age)
fa_cols = list(fa_row.keys())
db.execute(text(f"""
INSERT INTO health_metrics (user_id, date, {", ".join(fa_cols)})
VALUES (:user_id, :day, {", ".join(f":{c}" for c in fa_cols)})
ON CONFLICT (user_id, date) DO UPDATE SET
{", ".join(f"{c} = EXCLUDED.{c}" for c in fa_cols)}
"""), {"user_id": user_id, "day": entry_date, **fa_row})
db.commit()
stored += 1
except Exception as exc:
logger.warning("Failed to upsert VO2 max for %s: %s", entry_date, exc)
db.rollback()
logger.info("VO2 max: stored=%d from range_valid=%s", stored, valid_from_range)
return processed
def _parse_body_battery(bb_response, day_str: str):
"""Parse get_body_battery() response for a single day into a compact dict."""
if not bb_response:
return None
entry = next((e for e in bb_response if e.get("date") == day_str), None)
if not entry and bb_response:
entry = bb_response[0]
if not entry:
return None
charged = entry.get("charged")
drained = entry.get("drained")
start_lvl = entry.get("startValue")
end_lvl = entry.get("endValue")
# Fine-grained timeline: [[ts_ms, level, type_code, stress], ...]
# type_code: 0=REST, 1=ACTIVE, 2=SLEEP, 3=STRESS, 4=UNMEASURABLE
values = entry.get("bodyBatteryValuesArray") or []
if not values:
# Fall back to bodyBatteryStatList (segment-level data)
type_map = {"REST": 0, "ACTIVE": 1, "SLEEP": 2, "STRESS": 3, "UNMEASURABLE": 4}
for seg in (entry.get("bodyBatteryStatList") or []):
ts_str = seg.get("startTimestampGMT") or seg.get("startTimestampLocal")
if ts_str:
try:
from datetime import datetime as _dt, timezone as _tz
ts = _dt.fromisoformat(ts_str.rstrip("Z")).replace(tzinfo=_tz.utc)
type_code = type_map.get(seg.get("activityType", "UNMEASURABLE"), 4)
values.append([int(ts.timestamp() * 1000),
int(seg.get("bodyBatteryLevel") or 0),
type_code,
int(seg.get("stressLevel") or -1)])
except Exception:
pass
if charged is None and end_lvl is None and not values:
return None
return {
"charged": charged,
"drained": drained,
"start_level": start_lvl,
"end_level": end_lvl,
"values": values, # stripped from list-API, returned in intraday endpoint
}
def _compute_body_battery_hires(bb_values, intraday_hr):
"""
Produce a higher-resolution body battery series by interpolating between
sparse BB checkpoints using intraday HR as a proxy for effort.
During drain segments (BB falling) the drain is distributed proportionally
to how much each HR reading exceeds the day's median — peaks spend battery
faster than valleys. During recovery segments (BB rising) recovery is
spread uniformly over time.
Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
if inputs are insufficient.
"""
if not bb_values or not intraday_hr or len(bb_values) < 2:
return None
# Drop entries with None timestamp or level — raw API data can have gaps
bb = sorted([v for v in bb_values if v[0] is not None and v[1] is not None],
key=lambda x: x[0])
if len(bb) < 2:
return None
hr = sorted(intraday_hr, key=lambda x: x[0])
hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
if not hr_vals:
return None
hr_median = sorted(hr_vals)[len(hr_vals) // 2]
result = []
for i in range(len(bb) - 1):
t1, L1 = bb[i][0], bb[i][1]
t2, L2 = bb[i + 1][0], bb[i + 1][1]
delta = L2 - L1
seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
result.append([t1, round(float(L1), 1)])
if not seg_hr or abs(delta) < 1:
continue
if delta < 0:
# Drain: weight each reading by HR above median
efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
total = sum(efforts) or 1.0
cumul = 0.0
for j, (ts, bpm) in enumerate(seg_hr):
cumul += efforts[j] * delta / total
level = max(0.0, min(100.0, L1 + cumul))
result.append([ts, round(level, 1)])
else:
# Recovery: linear over time
span = max(1, t2 - t1)
for ts, _ in seg_hr:
frac = (ts - t1) / span
level = max(0.0, min(100.0, L1 + delta * frac))
result.append([ts, round(level, 1)])
result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
# Deduplicate and sort
seen, out = set(), []
for item in sorted(result, key=lambda x: x[0]):
if item[0] not in seen:
seen.add(item[0])
out.append(item)
return out if len(out) > 4 else None
def _safe(fn, *args):
try:
return fn(*args)
except Exception as exc:
logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc)
return None
def _parse_day(stats, sleep_data, hrv_data) -> dict:
row = {}
if stats:
_set(row, "resting_hr", stats.get("restingHeartRate"))
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
_set(row, "max_hr_day", stats.get("maxHeartRate"))
_set(row, "steps", stats.get("totalSteps"))
_set(row, "floors_climbed", stats.get("floorsAscended"))
_set(row, "avg_stress", stats.get("averageStressLevel"))
active = stats.get("activeKilocalories")
bmr = stats.get("bmrKilocalories")
_set(row, "active_calories", active)
if active and bmr:
_set(row, "total_calories", float(active) + float(bmr))
if sleep_data:
dto = sleep_data.get("dailySleepDTO") or sleep_data
_set(row, "sleep_duration_s", dto.get("sleepTimeSeconds"))
_set(row, "sleep_deep_s", dto.get("deepSleepSeconds"))
_set(row, "sleep_light_s", dto.get("lightSleepSeconds"))
_set(row, "sleep_rem_s", dto.get("remSleepSeconds"))
_set(row, "sleep_awake_s", dto.get("awakeSleepSeconds"))
# Timestamps are milliseconds since epoch in local time
for key, col in (("sleepStartTimestampLocal", "sleep_start"),
("sleepEndTimestampLocal", "sleep_end")):
ms = dto.get(key)
if ms:
_set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat())
# SpO2
spo2 = dto.get("averageSpO2Value")
if spo2 and 50 < float(spo2) <= 100:
row["spo2_avg"] = float(spo2)
# Sleep score — Garmin nests it under dailySleepDTO.sleepScores on most
# firmware, but some return it at the top level; check both.
scores = (dto.get("sleepScores") or sleep_data.get("sleepScores")
or dto.get("sleepScore") or sleep_data.get("sleepScore"))
if isinstance(scores, dict):
overall = scores.get("overall") or scores.get("qualityScore")
if isinstance(overall, dict):
_set(row, "sleep_score", overall.get("value"))
else:
_set(row, "sleep_score", overall)
elif isinstance(scores, (int, float)):
row["sleep_score"] = scores
if hrv_data:
summary = hrv_data.get("hrvSummary") or hrv_data
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
status = summary.get("status")
if status:
row["hrv_status"] = str(status).lower()
return row
def _set(d: dict, key: str, val):
if val is not None:
d[key] = val
+69 -26
View File
@@ -63,11 +63,21 @@ def routes_are_similar(
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
dist1: Optional[float] = None,
dist2: Optional[float] = None,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
When dist1/dist2 are provided:
- Rejects if distance differs by more than 2.5%
- Uses 3% of route distance as the DTW threshold (capped at 300m)
"""
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
return False
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
@@ -85,39 +95,72 @@ def routes_are_similar(
return dist < dtw_threshold_m
def find_segment_times(
data_points: list[dict],
start_dist_m: float,
end_dist_m: float,
def match_segment_in_activity(
seg_coords: list[tuple],
act_coords: list[tuple],
act_times: list,
tol_m: float = 30.0,
) -> Optional[float]:
"""
Given activity data points (with cumulative distance_m),
find the time to traverse from start_dist_m to end_dist_m.
Returns duration in seconds, or None if not found.
"""
start_time = None
end_time = None
Determine whether an activity track traverses a segment's GPS geometry in the
segment's own direction, and if so how long the fastest such traversal took.
Works even when the activity's overall route differs — only the overlapping
stretch matters.
for p in data_points:
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
seg_coords: [(lat, lon), ...] segment geometry (start → end).
act_coords: [(lat, lon), ...] activity track, in time order.
act_times: parallel list of datetimes for act_coords.
Strategy: for every pass of the activity near the segment START, walk forward
accumulating path length; accept the traversal only if the activity reaches the
segment END after covering roughly the segment's own length (so an out-and-back
route can't match an early start to a late finish), and the intermediate segment
points are passed in order. Returns the shortest valid traversal time, or None.
"""
n = len(act_coords)
m = len(seg_coords)
if n < 2 or m < 2:
return None
start_pt, end_pt = seg_coords[0], seg_coords[-1]
seg_len = sum(haversine_m(seg_coords[k], seg_coords[k + 1]) for k in range(m - 1))
if seg_len <= 0:
return None
near_start = lambda i: haversine_m(act_coords[i], start_pt) <= tol_m
# One candidate entry per pass through the start region (first point of each run).
entries = [i for i in range(n) if near_start(i) and (i == 0 or not near_start(i - 1))]
best = None
for si in entries:
path = 0.0
ei = None
for i in range(si + 1, n):
path += haversine_m(act_coords[i - 1], act_coords[i])
if path > seg_len * 1.5: # wandered too far without finishing → wrong pass/direction
break
if path >= seg_len * 0.6 and haversine_m(act_coords[i], end_pt) <= tol_m:
ei = i
break
if ei is None:
continue
if start_time is None and dist >= start_dist_m:
start_time = ts
# Confirm the activity follows the segment shape in order between the anchors.
ok = True
for frac in (0.25, 0.5, 0.75):
sp = seg_coords[int(frac * (m - 1))]
if not any(haversine_m(act_coords[k], sp) <= tol_m for k in range(si, ei + 1)):
ok = False
break
if not ok:
continue
if start_time is not None and dist >= end_dist_m:
end_time = ts
break
dur = (act_times[ei] - act_times[si]).total_seconds()
if dur > 0 and (best is None or dur < best):
best = dur
if start_time and end_time:
from datetime import datetime
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
return None
return best
def find_best_split_time(
+356
View File
@@ -0,0 +1,356 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The SDK with convert_types_to_strings=True returns snake_case field names.
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
and a stage name. Duration of each stage = gap to the next stage's timestamp.
The sleep session stop time (from event message 21, event_type='stop') closes
the last stage.
"""
from datetime import datetime, timezone, date
from typing import Optional
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
def _fit_ts(raw) -> Optional[datetime]:
if raw is None:
return None
try:
s = int(raw)
if s <= 0 or s == 0xFFFFFFFF:
return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return None
def _to_date(val) -> Optional[date]:
if val is None:
return None
if isinstance(val, datetime):
if val.tzinfo is None:
val = val.replace(tzinfo=timezone.utc)
return val.date()
if isinstance(val, (int, float)):
dt = _fit_ts(val)
return dt.date() if dt else None
return None
def _to_dt(val) -> Optional[datetime]:
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
return _fit_ts(val)
return None
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file.
Returns {"days": {date: metrics_dict}, "error": str|None}
"""
daily = {}
last_date_seen = [None]
def ensure_day(d: date) -> dict:
if d not in daily:
daily[d] = {
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
# Each entry: (datetime, level_int) — duration computed from gaps
"sleep_epochs": [],
"sleep_start": None,
"sleep_end": None,
"steps": None,
"floors_climbed": None,
"active_calories": None,
"bmr": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
"sleep_score": None,
}
return daily[d]
def _add_sleep_epoch(ts: datetime, level_raw):
d = _to_date(ts)
if not d:
return
last_date_seen[0] = d
if isinstance(level_raw, str):
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
else:
level = level_raw
if level is not None:
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
def listener(mesg_num: int, msg: dict):
# ── monitoring_info (147) - older firmware ─────────────────────────
if mesg_num == 147:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
rhr = msg.get("resting_heart_rate")
if d and rhr and 20 < rhr < 120:
last_date_seen[0] = d
ensure_day(d)["resting_hr"] = int(rhr)
# ── monitoring (148) - older firmware ──────────────────────────────
elif mesg_num == 148:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress))
# ── monitoring (55) - modern, per-interval running totals ──────────
elif mesg_num == 55:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
active_cal = msg.get("active_calories")
if active_cal and active_cal > 0:
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
ascent = msg.get("ascent")
if ascent and ascent > 0:
# Garmin counts 1 floor ≈ 3 m of ascent
floors = max(1, round(float(ascent) / 3))
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
# ── monitoring_info (103) - calibration; carries BMR ───────────────
elif mesg_num == 103:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
bmr = msg.get("resting_metabolic_rate")
if bmr and bmr > 0:
ensure_day(d)["bmr"] = int(bmr)
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
elif mesg_num == 370:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hrv_avg = msg.get("last_night_average")
if hrv_avg and hrv_avg > 0:
entry["hrv_nightly_avg"] = float(hrv_avg)
hrv_high = msg.get("last_night_5_min_high")
if hrv_high and hrv_high > 0:
entry["hrv_5min_high"] = float(hrv_high)
status = msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
elif mesg_num == 275:
sleep_level = msg.get("sleep_level")
ts = _to_dt(msg.get("timestamp"))
if sleep_level is not None and ts:
_add_sleep_epoch(ts, sleep_level)
elif ts:
# Older firmware: HRV summary in message 275
d = _to_date(ts)
if d:
last_date_seen[0] = d
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v and v > 0:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status") or msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── sleep_level (269) - older firmware sleep epochs ────────────────
elif mesg_num == 269:
ts = _to_dt(msg.get("timestamp"))
level = msg.get("sleep_level")
if ts and level is not None:
_add_sleep_epoch(ts, level)
# ── event (21) - sleep session start / stop ────────────────────────
elif mesg_num == 21:
ts = _to_dt(msg.get("timestamp"))
if not ts:
return
d = _to_date(ts)
if not d:
return
event_type = msg.get("event_type")
if event_type == "start":
last_date_seen[0] = d
ensure_day(d)["sleep_start"] = ts
elif event_type == "stop":
last_date_seen[0] = d
ensure_day(d)["sleep_end"] = ts
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
elif mesg_num == 346:
d = last_date_seen[0]
if not d:
return
score = msg.get("overall_sleep_score")
if score and score > 0:
ensure_day(d)["sleep_score"] = int(score)
# ── stress_level (132) ─────────────────────────────────────────────
elif mesg_num == 132:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress))
# ── spo2_data (258) ────────────────────────────────────────────────
elif mesg_num == 258:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2))
# ── per-minute stress + HR (227) proprietary ───────────────────────
elif mesg_num == 227:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr_raw = msg.get(2)
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
stress = msg.get("stress_level_value")
if stress is None:
stress = msg.get(0)
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
entry["stress_values"].append(int(stress))
# ── daily resting HR (211) proprietary ─────────────────────────────
elif mesg_num == 211:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr)
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
messages, errors = decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=False,
mesg_listener=listener,
)
except Exception as e:
return {"error": str(e), "days": {}}
result = {}
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_epochs = data.pop("sleep_epochs", [])
sleep_end_ts = data.pop("sleep_end", None)
sleep_start_ts = data.pop("sleep_start", None)
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
max_hr = max(hrs) if hrs else None
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Compute sleep stage durations from epoch timestamps
if sleep_epochs:
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
for i, (ts, level) in enumerate(epochs_sorted):
if i + 1 < len(epochs_sorted):
next_ts = epochs_sorted[i + 1][0]
elif sleep_end_ts:
next_ts = sleep_end_ts
else:
continue
dur = (next_ts - ts).total_seconds()
if level in level_secs and dur > 0:
level_secs[level] += dur
sleep_deep_s = level_secs[3] or None
sleep_light_s = level_secs[2] or None
sleep_rem_s = level_secs[4] or None
sleep_awake_s = level_secs[1] or None
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
sleep_stages = None
active_cal = data.get("active_calories")
bmr = data.get("bmr")
# Require active_cal so we don't store BMR-only as "total" calories
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
"avg_hr_day": avg_hr,
"max_hr_day": max_hr,
"avg_stress": avg_stress,
"spo2_avg": spo2_avg,
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
"hrv_5min_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors_climbed": data.get("floors_climbed"),
"active_calories": active_cal,
"total_calories": total_cal,
"sleep_duration_s": sleep_duration_s,
"sleep_deep_s": sleep_deep_s,
"sleep_light_s": sleep_light_s,
"sleep_rem_s": sleep_rem_s,
"sleep_awake_s": sleep_awake_s,
"sleep_score": data.get("sleep_score"),
"sleep_start": sleep_start_ts,
"sleep_end": sleep_end_ts,
"sleep_stages": sleep_stages,
}
return {"days": result, "error": None}
+7
View File
@@ -0,0 +1,7 @@
"""
Celery entry point. Re-exports celery_app from tasks so the worker
can be started with: celery -A app.workers.celery_app worker
"""
from app.workers.tasks import celery_app
__all__ = ["celery_app"]
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -6,12 +6,13 @@ alembic==1.13.1
pydantic==2.7.1
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.9
httpx==0.27.0
redis[hiredis]==5.0.4
celery[redis]==5.4.0
fitparse==1.2.0
garmin-fit-sdk==21.195.0
gpxpy==1.6.2
numpy==1.26.4
scipy==1.13.0
@@ -21,3 +22,6 @@ Pillow==10.3.0
aiofiles==23.2.1
python-dateutil==2.9.0
pytz==2024.1
psycopg2-binary==2.9.9
garminconnect==0.2.24
cryptography==42.0.8
Executable
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
MESSAGE="${1:-update}"
cd "$(dirname "$0")"
git add -A
git commit -m "$MESSAGE"
git push
cd ../milevault_docker
docker compose down
echo ""
echo "Done. Run 'docker compose pull && docker compose up -d' when the build completes."
+18
View File
@@ -91,6 +91,24 @@ services:
redis:
condition: service_healthy
beat:
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
container_name: milevault_beat
restart: unless-stopped
command: celery -A app.workers.celery_app beat --loglevel=info
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
FILE_STORE_PATH: /data/files
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
frontend:
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
container_name: milevault_frontend
+20
View File
@@ -83,6 +83,26 @@ services:
redis:
condition: service_healthy
beat:
build:
context: ./backend
dockerfile: Dockerfile.worker
container_name: milevault_beat
restart: unless-stopped
command: celery -A app.workers.celery_app beat --loglevel=info
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
FILE_STORE_PATH: /data/files
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./frontend
+3 -3
View File
@@ -1,8 +1,8 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY package.json ./
RUN npm install
COPY . .
ARG VITE_API_URL=/api
@@ -15,4 +15,4 @@ RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
EXPOSE 80
+3527
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -9,25 +9,25 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"recharts": "^2.12.7",
"date-fns": "^3.6.0",
"clsx": "^2.1.1",
"zustand": "^4.5.2",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"leaflet": "^1.9.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
"@polyline-codec/core": "^2.0.0"
"react-grid-layout": "^1.5.3",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"zustand": "^4.5.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.2.13",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4"
"tailwindcss": "^3.4.4",
"vite": "^5.2.13"
}
}
+5 -19
View File
@@ -10,6 +10,8 @@ import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage'
import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage'
import UsersPage from './pages/UsersPage'
function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token)
@@ -24,28 +26,10 @@ export default function App() {
if (token) fetchUser()
}, [token])
// Handle token from PocketID callback URL
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
localStorage.setItem('token', urlToken)
useAuthStore.setState({ token: urlToken })
window.history.replaceState({}, '', '/')
}
}, [])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<DashboardPage />} />
<Route path="activities" element={<ActivitiesPage />} />
<Route path="activities/:id" element={<ActivityDetailPage />} />
@@ -53,6 +37,8 @@ export default function App() {
<Route path="routes" element={<RoutesPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
)
+174 -57
View File
@@ -1,8 +1,8 @@
import { useEffect, useRef } from 'react'
import L from 'leaflet'
import { sportColor } from '../../utils/format'
import { projectToTrack } from '../../utils/track'
// Fix Leaflet default icon issue with bundlers
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
@@ -10,38 +10,153 @@ L.Icon.Default.mergeOptions({
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
const TILE_LAYERS = {
dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
// Tile options tuned for smoother panning/zooming: keep a larger off-screen
// buffer of tiles and don't defer loads until the map is idle.
const TILE_OPTS = { maxZoom: 19, keepBuffer: 6, updateWhenIdle: false, updateWhenZooming: false }
// Slow → fast colour ramp for speed-coloured routes (red → purple).
export const SPEED_STOPS = ['#ef4444', '#f97316', '#22c55e', '#3b82f6', '#a855f7']
// CSS gradient string for the speed legend.
export const SPEED_GRADIENT = `linear-gradient(to right, ${SPEED_STOPS.join(', ')})`
const SPEED_LEVELS = 24 // quantisation steps → smooth gradient while limiting layer count
function lerpColor(c1, c2, t) {
const a = parseInt(c1.slice(1), 16), b = parseInt(c2.slice(1), 16)
const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t)
const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t)
const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t)
return `#${((1 << 24) + (r << 16) + (g << 8) + bl).toString(16).slice(1)}`
}
function rampColor(t) {
t = Math.max(0, Math.min(1, t))
const seg = t * (SPEED_STOPS.length - 1)
const i = Math.min(SPEED_STOPS.length - 2, Math.floor(seg))
return lerpColor(SPEED_STOPS[i], SPEED_STOPS[i + 1], seg - i)
}
function decodePolyline(encoded) {
// Simple polyline decoder
const coords = []
let index = 0, lat = 0, lng = 0
while (index < encoded.length) {
let b, shift = 0, result = 0
do {
b = encoded.charCodeAt(index++) - 63
result |= (b & 0x1f) << shift
shift += 5
} while (b >= 0x20)
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do {
b = encoded.charCodeAt(index++) - 63
result |= (b & 0x1f) << shift
shift += 5
} while (b >= 0x20)
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1
coords.push([lat / 1e5, lng / 1e5])
}
return coords
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType }) {
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
// Pulsing target dot shown under the cursor while drawing a segment, so the user
// can see exactly which track point a click will snap to.
const SEG_TARGET_ICON = L.divIcon({
html: '<div style="width:14px;height:14px;background:#22c55e;border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(34,197,94,0.9)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
// Prefer the data-point track when colouring by speed; fall back to the encoded polyline.
const speedPts = (colorMode === 'speed' && dataPoints)
? dataPoints.filter(p => p.latitude != null && p.longitude != null)
: []
const group = L.layerGroup()
if (speedPts.length >= 2 && speedPts.some(p => p.speed_ms != null)) {
const speeds = speedPts.map(p => p.speed_ms).filter(s => s != null && s > 0)
speeds.sort((a, b) => a - b)
// Clamp the range to the 5th95th percentile so a couple of GPS spikes don't wash out the ramp.
const lo = speeds[Math.floor(speeds.length * 0.05)] ?? 0
const hi = speeds[Math.floor(speeds.length * 0.95)] ?? lo + 1
const levelOf = (s) => {
const t = (hi > lo) ? (((s ?? lo) - lo) / (hi - lo)) : 0.5
return Math.round(Math.max(0, Math.min(1, t)) * SPEED_LEVELS)
}
// Group consecutive points into runs of the same colour level → one polyline per run.
let runStart = 0
let runLevel = levelOf(speedPts[0].speed_ms)
const flush = (end) => {
const coords = speedPts.slice(runStart, end + 1).map(p => [p.latitude, p.longitude])
if (coords.length >= 2) {
L.polyline(coords, { color: rampColor(runLevel / SPEED_LEVELS), weight: 3, opacity: 0.95 }).addTo(group)
}
}
for (let i = 1; i < speedPts.length; i++) {
const level = levelOf(speedPts[i].speed_ms)
if (level !== runLevel) {
flush(i) // include current point so runs join up
runStart = i
runLevel = level
}
}
flush(speedPts.length - 1)
const coords = speedPts.map(p => [p.latitude, p.longitude])
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
return
}
// Solid single-colour route from the encoded polyline.
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }).addTo(group)
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(group)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(group)
group.addTo(map)
trackRef.current = group
map.fitBounds(L.latLngBounds(coords), { padding: [20, 20] })
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'street', colorMode = 'speed', onMapClick }) {
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const segTargetRef = useRef(null)
const trackRef = useRef(null)
const tileLayerRef = useRef(null)
const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
const clickRef = useRef(onMapClick)
drawArgsRef.current = { polyline, dataPoints, sportType, colorMode }
useEffect(() => { clickRef.current = onMapClick }, [onMapClick])
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return
@@ -49,16 +164,34 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
mapInstanceRef.current = L.map(mapRef.current, {
zoomControl: true,
attributionControl: true,
preferCanvas: true,
})
// Use CartoDB dark tiles (no API key needed)
L.tileLayer(
'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
{
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
const tile = TILE_LAYERS.street
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
.addTo(mapInstanceRef.current)
mapInstanceRef.current.on('click', (e) => {
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
})
// While in segment-create mode, show a target dot snapped to the nearest
// track point so the user can see what a click will select.
mapInstanceRef.current.on('mousemove', (e) => {
const pts = drawArgsRef.current.dataPoints
if (!clickRef.current || !pts?.length) return
const np = projectToTrack(pts, e.latlng.lat, e.latlng.lng)
if (!np) return
if (segTargetRef.current) {
segTargetRef.current.setLatLng([np.latitude, np.longitude])
} else {
segTargetRef.current = L.marker([np.latitude, np.longitude],
{ icon: SEG_TARGET_ICON, interactive: false }).addTo(mapInstanceRef.current)
}
).addTo(mapInstanceRef.current)
})
mapInstanceRef.current.on('mouseout', () => {
if (segTargetRef.current) { segTargetRef.current.remove(); segTargetRef.current = null }
})
return () => {
mapInstanceRef.current?.remove()
@@ -66,47 +199,31 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
}
}, [])
// Draw route when polyline changes
// Clear the target dot when leaving segment-create mode.
useEffect(() => {
if (!mapInstanceRef.current || !polyline) return
if (trackRef.current) {
trackRef.current.remove()
if (!onMapClick && segTargetRef.current) {
segTargetRef.current.remove()
segTargetRef.current = null
}
}, [onMapClick])
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, {
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(mapInstanceRef.current)
mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
// Start/end markers
if (coords.length > 0) {
const startIcon = L.divIcon({
html: '<div style="width:12px;height:12px;background:#22c55e;border:2px solid white;border-radius:50%"></div>',
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
const endIcon = L.divIcon({
html: '<div style="width:12px;height:12px;background:#ef4444;border:2px solid white;border-radius:50%"></div>',
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
L.marker(coords[0], { icon: startIcon }).addTo(mapInstanceRef.current)
L.marker(coords[coords.length - 1], { icon: endIcon }).addTo(mapInstanceRef.current)
}
}, [polyline, sportType])
// Move position marker when timeline is hovered
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return
if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, ...TILE_OPTS })
.addTo(mapInstanceRef.current)
}, [mapType])
useEffect(() => {
if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, drawArgsRef.current, trackRef)
}, [polyline, sportType, colorMode, dataPoints])
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return
if (markerRef.current) {
markerRef.current.setLatLng([point.latitude, point.longitude])
} else {
@@ -1,3 +1,5 @@
import { formatDuration } from '../../utils/format'
const ZONE_CONFIG = [
{ key: 'z1', label: 'Z1 Recovery', color: '#60a5fa' },
{ key: 'z2', label: 'Z2 Base', color: '#34d399' },
@@ -6,7 +8,9 @@ const ZONE_CONFIG = [
{ key: 'z5', label: 'Z5 Max', color: '#f43f5e' },
]
export default function HRZoneBar({ zones }) {
// zones holds the % of time in each zone; multiply by the activity's active time
// to show the approximate time spent in each.
export default function HRZoneBar({ zones, totalSeconds }) {
return (
<div className="space-y-2">
{/* Stacked bar */}
@@ -34,6 +38,9 @@ export default function HRZoneBar({ zones }) {
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
<span className="text-xs font-medium text-white">{pct}%</span>
{totalSeconds > 0 && (
<span className="text-xs text-gray-500">{formatDuration(Math.round((pct / 100) * totalSeconds))}</span>
)}
</div>
)
})}
+43 -20
View File
@@ -1,6 +1,10 @@
import { formatDuration, formatDistance, formatPace, formatHeartRate } from '../../utils/format'
import { formatDuration, formatDistance, formatPace, formatHeartRate, formatCadence } from '../../utils/format'
export default function LapTable({ laps, sportType }) {
const RUNNING_TYPES = new Set(['running', 'hiking', 'walking'])
export default function LapTable({ laps, sportType, lapBests }) {
const showPower = !RUNNING_TYPES.has(sportType?.toLowerCase())
const hasBests = lapBests && Object.keys(lapBests).length > 0
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
@@ -9,30 +13,49 @@ export default function LapTable({ laps, sportType }) {
<th className="text-left pb-2 font-medium">Lap</th>
<th className="text-right pb-2 font-medium">Distance</th>
<th className="text-right pb-2 font-medium">Time</th>
{hasBests && <th className="text-right pb-2 font-medium">Best</th>}
{hasBests && <th className="text-right pb-2 font-medium">Δ</th>}
<th className="text-right pb-2 font-medium">Pace</th>
<th className="text-right pb-2 font-medium">Avg HR</th>
<th className="text-right pb-2 font-medium">Cadence</th>
<th className="text-right pb-2 font-medium">Power</th>
{showPower && <th className="text-right pb-2 font-medium">Power</th>}
</tr>
</thead>
<tbody>
{laps.map((lap) => (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-gray-400">{lap.lap_number}</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className="py-2 text-right text-gray-200">{formatDuration(lap.duration_s)}</td>
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
<td className="py-2 text-right">
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? `${Math.round(lap.avg_cadence)} rpm` : '--'}
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
</tr>
))}
{laps.map((lap) => {
const best = hasBests ? lapBests[String(lap.lap_number)] : null
const delta = best != null && lap.duration_s != null ? lap.duration_s - best : null
const isBest = delta != null && delta <= 0.5
return (
<tr key={lap.lap_number} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
<td className="py-2 text-gray-400">{lap.lap_number}</td>
<td className="py-2 text-right text-gray-200">{formatDistance(lap.distance_m)}</td>
<td className={`py-2 text-right ${isBest ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(lap.duration_s)}</td>
{hasBests && (
<td className="py-2 text-right font-mono text-gray-500">{best != null ? formatDuration(best) : '--'}</td>
)}
{hasBests && (
<td className={`py-2 text-right font-mono ${
delta == null ? 'text-gray-700' : isBest ? 'text-yellow-400' : delta < 0 ? 'text-green-400' : 'text-red-400'
}`}>
{delta == null ? '--' : isBest ? '🏆' : `${delta > 0 ? '+' : ''}${formatDuration(Math.abs(delta))}`}
</td>
)}
<td className="py-2 text-right text-gray-200">{formatPace(lap.avg_speed_ms, sportType)}</td>
<td className="py-2 text-right">
<span className="text-red-400">{formatHeartRate(lap.avg_heart_rate)}</span>
</td>
<td className="py-2 text-right text-gray-400">
{lap.avg_cadence ? formatCadence(lap.avg_cadence, sportType) : '--'}
</td>
{showPower && (
<td className="py-2 text-right text-gray-400">
{lap.avg_power ? `${Math.round(lap.avg_power)} W` : '--'}
</td>
)}
</tr>
)
})}
</tbody>
</table>
</div>
@@ -1,9 +1,25 @@
import { useMemo, useCallback } from 'react'
import { useMemo } from 'react'
import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
ComposedChart, Line, Scatter, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer,
} from 'recharts'
import { formatDuration, formatPace } from '../../utils/format'
import { formatPace, formatCadence } from '../../utils/format'
// Running cadence colour bands (steps per minute). Cadence is stored halved for
// running, so spm = stored × 2.
function cadenceColor(spm) {
if (spm < 153) return '#ef4444' // red — slow
if (spm < 164) return '#f97316' // orange — moderate
if (spm < 174) return '#22c55e' // green — good recreational
if (spm < 184) return '#3b82f6' // blue — experienced
return '#a855f7' // purple — elite
}
const renderCadenceDot = (props) => {
const { cx, cy, payload } = props
if (cx == null || cy == null || payload?.cadence == null) return null
return <circle cx={cx} cy={cy} r={2} fill={cadenceColor(payload.cadence * 2)} />
}
function downsample(points, maxPoints = 500) {
if (points.length <= maxPoints) return points
@@ -11,33 +27,43 @@ function downsample(points, maxPoints = 500) {
return points.filter((_, i) => i % step === 0)
}
function buildChartData(dataPoints, activeMetrics) {
// mm:ss label for the time-based X-axis (stationary/indoor activities).
function fmtSeconds(s) {
const m = Math.floor(s / 60)
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`
}
function buildChartData(dataPoints, activeMetrics, useTimeAxis) {
const base = useTimeAxis
? new Date(dataPoints.find(p => p.timestamp)?.timestamp || 0).getTime()
: 0
return dataPoints
.filter(p => p.timestamp)
.map(p => {
const row = { distance_m: p.distance_m ?? 0 }
const x = useTimeAxis
? (new Date(p.timestamp).getTime() - base) / 1000
: (p.distance_m ?? 0)
const row = { x }
for (const key of activeMetrics) {
row[key] = p[key] ?? null
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
}
return row
})
}
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => {
if (!active || !payload?.length) return null
if (onHover) onHover(label)
return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
<p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
{payload.map(entry => {
const metric = metrics.find(m => m.key === entry.dataKey)
if (!metric || entry.value == null) return null
let display = entry.value.toFixed(1)
if (entry.dataKey === 'speed_ms') display = formatPace(entry.value, sportType)
else if (entry.dataKey === 'heart_rate') display = `${Math.round(entry.value)} bpm`
else if (entry.dataKey === 'cadence') display = `${Math.round(entry.value)} rpm`
else if (entry.dataKey === 'cadence') display = formatCadence(entry.value, sportType)
else if (entry.dataKey === 'power') display = `${Math.round(entry.value)} W`
else if (entry.dataKey === 'temperature_c') display = `${entry.value.toFixed(1)} °C`
else if (entry.dataKey === 'altitude_m') display = `${entry.value.toFixed(0)} m`
@@ -54,26 +80,39 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
}
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
// Stationary/indoor activities (HIIT, strength, trainer) record no distance, so
// plotting against distance collapses every sample onto x=0. Fall back to an
// elapsed-time X-axis when there's no distance spread.
const useTimeAxis = useMemo(
() => !dataPoints.some(p => p.distance_m != null && p.distance_m > 0),
[dataPoints]
)
const chartData = useMemo(() =>
downsample(buildChartData(dataPoints, activeMetrics)),
[dataPoints, activeMetrics]
downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)),
[dataPoints, activeMetrics, useTimeAxis]
)
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
// Build per-metric Y-axis domains
const domains = useMemo(() => {
const result = {}
for (const m of activeMetricConfigs) {
const vals = chartData.map(p => p[m.key]).filter(v => v != null)
let vals = chartData.map(p => p[m.key]).filter(v => v != null)
if (!vals.length) continue
// Clamp GPS speed outliers (spikes cause absurd pace labels like 0:01/km)
if (m.key === 'speed_ms') {
const speedCap = sportType === 'cycling' ? 25 : 12
vals = vals.filter(v => v > 0 && v <= speedCap)
if (!vals.length) continue
}
const min = Math.min(...vals)
const max = Math.max(...vals)
const pad = (max - min) * 0.1 || 1
result[m.key] = [min - pad, max + pad]
}
return result
}, [chartData, activeMetricConfigs])
}, [chartData, activeMetricConfigs, sportType])
if (!chartData.length) {
return (
@@ -87,27 +126,23 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
<div className="space-y-4">
{activeMetricConfigs.map((metric, idx) => {
const domain = domains[metric.key] || ['auto', 'auto']
const data = chartData.filter(p => p[metric.key] != null)
if (!data.length) return null
const hasData = chartData.some(p => p[metric.key] != null)
if (!hasData) return null
return (
<div key={metric.key}>
<div className="flex items-center gap-2 mb-1">
<span style={{ color: metric.color }} className="text-xs font-medium">
{metric.label}
</span>
{metric.unit && (
<span className="text-xs text-gray-600">({metric.unit})</span>
)}
<span style={{ color: metric.color }} className="text-xs font-medium">{metric.label}</span>
{metric.unit && <span className="text-xs text-gray-600">({metric.unit})</span>}
</div>
<ResponsiveContainer width="100%" height={100}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }}>
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
dataKey="x"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
@@ -118,39 +153,45 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={36}
width={40}
tickFormatter={v => {
if (metric.key === 'speed_ms') return `${(v * 3.6).toFixed(0)}`
if (metric.key === 'speed_ms') {
if (v <= 0 || v > 25) return ''
if (sportType === 'cycling') return `${(v * 3.6).toFixed(0)}`
const spm = 1000 / v
return `${Math.floor(spm/60)}:${String(Math.floor(spm%60)).padStart(2,'0')}`
}
if (metric.key === 'cadence') return Math.round(v * (sportType === 'running' ? 2 : 1))
return Math.round(v)
}}
/>
<Tooltip
content={
<CustomTooltip
metrics={metrics}
sportType={sportType}
onHover={onHoverDistance}
/>
}
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey={metric.key}
stroke={metric.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
{metric.key === 'cadence' && sportType === 'running' ? (
<>
{/* 165 spm guide → 82.5 in stored (halved) units */}
<ReferenceLine y={82.5} stroke="#22c55e" strokeDasharray="4 4" strokeWidth={1.5} />
<Scatter dataKey="cadence" shape={renderCadenceDot} isAnimationActive={false} />
</>
) : (
<Line
type="monotone"
dataKey={metric.key}
stroke={metric.color}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
connectNulls={false}
/>
)}
</ComposedChart>
</ResponsiveContainer>
</div>
)
})}
{/* Shared distance axis label */}
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
</div>
)
}
@@ -0,0 +1,103 @@
import { Link } from 'react-router-dom'
import { formatDuration, formatDate } from '../../utils/format'
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
// Compact +M:SS / +SS gap label (fastest effort shows nothing).
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
export default function RouteLeaderboard({ data }) {
if (!data || !data.top || data.top.length === 0) return null
const { current, total, top } = data
const currentGap = current ? gapLabel(current.gap_s) : null
const inTop10 = current && current.rank <= 10
return (
<div className="overflow-x-auto">
{/* This activity's standing on the route */}
{current && (
<div className="mb-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 flex items-center justify-between gap-3">
<div>
<div className="text-xs text-emerald-300/80">This activity</div>
<div className="text-lg font-semibold text-emerald-300 font-mono">
{formatDuration(current.duration_s)}
</div>
</div>
<div className="text-right">
<div className="text-sm font-medium text-gray-200">
#{current.rank} <span className="text-gray-500">of {total}</span>
</div>
<div className="text-xs">
{currentGap == null
? <span className="text-yellow-400">🏆 Fastest</span>
: <span className="text-gray-400">{currentGap} off fastest</span>}
</div>
</div>
</div>
)}
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">#</th>
<th className="text-left pb-2 font-medium">Date</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Δ</th>
</tr>
</thead>
<tbody>
{top.map((e) => {
const gap = gapLabel(e.gap_s)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
e.is_current
? 'bg-emerald-500/15 hover:bg-emerald-500/20'
: 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
{MEDALS[e.rank] || e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${e.is_current ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{formatDate(e.start_time)}
</Link>
</td>
<td className={`py-2 text-right font-mono ${e.is_current ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
{/* If this activity ranks outside the top 10, still surface its row. */}
{current && !inTop10 && (
<tr className="border-t border-gray-700 bg-emerald-500/15">
<td className="py-2 text-emerald-300">{current.rank}</td>
<td className="py-2">
<span className="text-emerald-300 font-medium">{formatDate(current.start_time)}</span>
</td>
<td className="py-2 text-right font-mono text-emerald-300 font-semibold">
{formatDuration(current.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gapLabel(current.gap_s) ?? '--'}
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
@@ -0,0 +1,146 @@
import { useState, Fragment } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../../utils/api'
import { formatDuration, formatDistance } from '../../utils/format'
const MEDALS = { 1: '🏆', 2: '🥈', 3: '🥉' }
const PLACE_MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
// Top-10 leaderboard for a single segment, styled to match RouteLeaderboard.
function Leaderboard({ segmentId, activityId }) {
const { data } = useQuery({
queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
})
if (!data) return <p className="text-xs text-gray-600 py-2">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p>
const top = data.leaderboard.slice(0, 10)
const fastest = top[0].duration_s
return (
<table className="w-full text-sm mt-1 mb-2">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">#</th>
<th className="text-left pb-2 font-medium">Activity</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Δ</th>
</tr>
</thead>
<tbody>
{top.map((e) => {
const isCurrent = e.activity_id === activityId
const gap = gapLabel(e.duration_s - fastest)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank <= 3 ? 'text-yellow-400' : 'text-gray-400'}`}>
{MEDALS[e.rank] || e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${isCurrent ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{e.activity_name}
</Link>
</td>
<td className={`py-2 text-right font-mono ${isCurrent ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
</tbody>
</table>
)
}
export default function SegmentsPanel({ segments, activityId }) {
const qc = useQueryClient()
const [open, setOpen] = useState(null)
const remove = async (id) => {
if (!confirm('Delete this segment?')) return
await api.delete(`/segments/${id}`)
qc.invalidateQueries()
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">Segment</th>
<th className="text-right pb-2 font-medium">This run</th>
<th className="text-right pb-2 font-medium">Best</th>
<th className="text-right pb-2 font-medium">Place</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
const isOpen = open === seg.segment_id
return (
<Fragment key={seg.segment_id}>
<tr
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="py-2">
<button
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
className="text-left text-gray-300 hover:text-white"
>
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
{seg.name}
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
</button>
</td>
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</td>
<td className="py-2 text-right font-mono">
{isPodium
? <span title="Podium time on this activity" className="text-yellow-400">{PLACE_MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="py-2 text-right">
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={5} className="bg-gray-950/40">
<Leaderboard segmentId={seg.segment_id} activityId={activityId} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
+87 -28
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
const nav = [
{ to: '/', label: 'Dashboard', icon: '📊', exact: true },
@@ -8,64 +10,121 @@ const nav = [
{ to: '/routes', label: 'Routes', icon: '🗺️' },
{ to: '/records', label: 'Records', icon: '🏆' },
{ to: '/upload', label: 'Import', icon: '⬆️' },
{ to: '/profile', label: 'Profile', icon: '⚙️' },
{ to: '/users', label: 'Users', icon: '👥', adminOnly: true },
]
export default function Layout() {
const { user, logout } = useAuthStore()
const navigate = useNavigate()
const { inProgress, status, startPolling, stopPolling } = useSyncStore()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
useEffect(() => {
startPolling()
return () => stopPolling()
}, [])
const toggleCollapsed = () => {
setCollapsed(c => {
const next = !c
localStorage.setItem('navCollapsed', next ? '1' : '0')
return next
})
}
const handleLogout = () => {
logout()
navigate('/login')
}
const role = user?.is_admin ? 'Administrator' : 'Member'
return (
<div className="flex h-screen overflow-hidden bg-gray-950">
{/* Sidebar */}
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
{/* Logo */}
<div className="px-4 py-5 border-b border-gray-800">
<h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault
</h1>
{user && (
<p className="text-xs text-gray-500 mt-0.5">@{user.username}</p>
<aside className={`${collapsed ? 'w-16' : 'w-56'} flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col transition-[width] duration-200`}>
<div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
{!collapsed && (
<h1 className="text-lg font-bold text-white tracking-tight">
<span className="text-blue-400">Mile</span>Vault
</h1>
)}
<button onClick={toggleCollapsed}
title={collapsed ? 'Expand menu' : 'Collapse menu'}
className="text-gray-500 hover:text-white transition-colors text-lg leading-none">
{collapsed ? '»' : '«'}
</button>
</div>
{/* Nav */}
<nav className="flex-1 py-4 overflow-y-auto">
{nav.map(({ to, label, icon, exact }) => (
<NavLink
key={to}
to={to}
end={exact}
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
`flex items-center gap-3 py-2.5 text-sm transition-colors ${collapsed ? 'justify-center px-0' : 'px-4'} ${
isActive
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
}`
}
>
}>
<span>{icon}</span>
{label}
{!collapsed && label}
</NavLink>
))}
</nav>
{/* Footer */}
<div className="px-4 py-4 border-t border-gray-800">
<button
onClick={handleLogout}
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Sign out
</button>
{inProgress && !collapsed && (
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-blue-400">
<span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
Garmin sync
</div>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${syncProgressPct(status)}%` }} />
</div>
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div>
)}
{inProgress && collapsed && (
<div className="flex justify-center py-3 border-t border-gray-800" title={`Garmin sync: ${status || 'starting…'}`}>
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400 animate-pulse" />
</div>
)}
{/* Logged-in user + privilege level */}
<div className="border-t border-gray-800 p-3">
{user ? (
collapsed ? (
<div className="flex justify-center" title={`${user.username} · ${role}`}>
<span className="w-8 h-8 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
</div>
) : (
<div className="flex items-center gap-2.5">
<span className="w-8 h-8 flex-shrink-0 rounded-full bg-blue-600/20 text-blue-300 flex items-center justify-center text-sm font-semibold uppercase">
{user.username?.[0] || '?'}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-white truncate">{user.username}</p>
<p className={`text-xs ${user.is_admin ? 'text-amber-400' : 'text-gray-500'}`}>{role}</p>
</div>
<button onClick={handleLogout} title="Sign out"
className="text-gray-500 hover:text-red-400 transition-colors text-sm">
</button>
</div>
)
) : null}
{collapsed && (
<button onClick={handleLogout} title="Sign out"
className="w-full mt-2 text-center text-gray-500 hover:text-red-400 transition-colors text-sm">
</button>
)}
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto">
<Outlet />
</main>
+133
View File
@@ -0,0 +1,133 @@
import { useMemo } from 'react'
import { sportColor } from '../../utils/format'
function decodePolyline(encoded) {
const coords = []
let index = 0, lat = 0, lng = 0
while (index < encoded.length) {
let b, shift = 0, result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1
coords.push([lat / 1e5, lng / 1e5])
}
return coords
}
function haversineDist([lat1, lng1], [lat2, lng2]) {
const R = 6371000
const dLat = (lat2 - lat1) * Math.PI / 180
const dLng = (lng2 - lng1) * Math.PI / 180
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
// Internal viewBox dimensions — path is always drawn into this space, SVG scales it
const VW = 100
const VH = 80
const PAD = 6
function buildPaths(polyline, segStartM, segEndM) {
if (!polyline) return null
const coords = decodePolyline(polyline)
if (coords.length < 2) return null
const lats = coords.map(c => c[0])
const lngs = coords.map(c => c[1])
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
const latRange = maxLat - minLat || 0.001
const lngRange = maxLng - minLng || 0.001
const drawW = VW - PAD * 2
const drawH = VH - PAD * 2
const scale = Math.min(drawW / lngRange, drawH / latRange)
const offX = PAD + (drawW - lngRange * scale) / 2
const offY = PAD + (drawH - latRange * scale) / 2
const toXY = ([lat, lng]) => [
offX + (lng - minLng) * scale,
offY + (maxLat - lat) * scale,
]
const fullPath = coords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
if (segStartM == null || segEndM == null) return { fullPath, segPath: null }
// Compute cumulative distances to find segment slice
const cumDist = [0]
for (let i = 1; i < coords.length; i++) {
cumDist.push(cumDist[i - 1] + haversineDist(coords[i - 1], coords[i]))
}
const totalDist = cumDist[cumDist.length - 1] || 1
// Interpolate a point at a given distance along the route
const interpAt = (targetM) => {
for (let i = 1; i < cumDist.length; i++) {
if (cumDist[i] >= targetM || i === cumDist.length - 1) {
const t = cumDist[i] === cumDist[i - 1] ? 0 : (targetM - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1])
const lat = coords[i - 1][0] + t * (coords[i][0] - coords[i - 1][0])
const lng = coords[i - 1][1] + t * (coords[i][1] - coords[i - 1][1])
return [lat, lng]
}
}
return coords[coords.length - 1]
}
const clampedStart = Math.max(0, Math.min(segStartM, totalDist))
const clampedEnd = Math.max(0, Math.min(segEndM, totalDist))
// Collect segment points: interpolated start + all interior coords + interpolated end
const segCoords = [interpAt(clampedStart)]
for (let i = 0; i < coords.length; i++) {
if (cumDist[i] > clampedStart && cumDist[i] < clampedEnd) {
segCoords.push(coords[i])
}
}
segCoords.push(interpAt(clampedEnd))
const segPath = segCoords.map((c, i) => {
const [x, y] = toXY(c)
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
return { fullPath, segPath }
}
export default function RouteMiniMap({ polyline, sportType, width = 80, height = 60, segmentStartM, segmentEndM }) {
const paths = useMemo(
() => buildPaths(polyline, segmentStartM, segmentEndM),
[polyline, segmentStartM, segmentEndM],
)
const svgProps = {
viewBox: `0 0 ${VW} ${VH}`,
preserveAspectRatio: 'xMidYMid meet',
className: 'rounded overflow-hidden block',
style: { background: '#111827', width, height },
}
if (!paths) return (
<svg {...svgProps}>
<text x={VW / 2} y={VH / 2} textAnchor="middle" dominantBaseline="middle" fill="#374151" fontSize="10"></text>
</svg>
)
const baseColor = paths.segPath ? '#374151' : sportColor(sportType)
return (
<svg {...svgProps}>
<path d={paths.fullPath} fill="none" stroke={baseColor} strokeWidth={paths.segPath ? 1.5 : 2}
strokeLinejoin="round" strokeLinecap="round" />
{paths.segPath && (
<path d={paths.segPath} fill="none" stroke="#f97316" strokeWidth="3"
strokeLinejoin="round" strokeLinecap="round" />
)}
</svg>
)
}
+1 -1
View File
@@ -9,7 +9,7 @@ const accentColors = {
export default function StatCard({ label, value, accent = 'default', sub }) {
return (
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50">
<div className="bg-gray-800/60 rounded-xl p-3 border border-gray-700/50 h-full flex flex-col justify-center">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className={`text-lg font-semibold ${accentColors[accent]}`}>{value}</p>
{sub && <p className="text-xs text-gray-600 mt-0.5">{sub}</p>}
+13 -5
View File
@@ -1,11 +1,21 @@
import { create } from 'zustand'
import api from '../utils/api'
// Read token from URL params synchronously at module load time,
// before any component renders. This handles PocketID OAuth callbacks.
const params = new URLSearchParams(window.location.search)
const urlToken = params.get('token')
if (urlToken) {
localStorage.setItem('token', urlToken)
window.history.replaceState({}, '', '/')
}
const initialToken = urlToken || localStorage.getItem('token')
export const useAuthStore = create((set) => ({
token: localStorage.getItem('token'),
token: initialToken,
user: null,
isLoading: false,
login: async (username, password) => {
set({ isLoading: true })
try {
@@ -23,12 +33,10 @@ export const useAuthStore = create((set) => ({
throw e
}
},
logout: () => {
localStorage.removeItem('token')
set({ token: null, user: null })
},
fetchUser: async () => {
try {
const { data } = await api.get('/auth/me')
@@ -38,4 +46,4 @@ export const useAuthStore = create((set) => ({
localStorage.removeItem('token')
}
},
}))
}))
+95
View File
@@ -0,0 +1,95 @@
import { create } from 'zustand'
import api from '../utils/api'
// A status string is "terminal" when the sync has finished (success, partial, error, or cancelled).
const isTerminal = (s) =>
s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') ||
s.startsWith('Credentials') || s.startsWith('Connected') || s.startsWith('Cancelled')
// Map a Garmin sync status string to an approximate completion percentage.
export function syncProgressPct(status) {
if (!status) return 3
if (status.startsWith('Connecting')) return 10
if (status.startsWith('Syncing activities')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m && +m[2] > 0) return 15 + Math.round((+m[1] / +m[2]) * 30)
return 20
}
if (status.startsWith('Syncing wellness')) {
const m = status.match(/(\d+)\/(\d+)/)
if (m && +m[2] > 0) return 45 + Math.round((+m[1] / +m[2]) * 45)
return 50
}
return 3
}
export function syncPhase(status) {
if (!status) return -1
if (status.startsWith('Connecting') || status.startsWith('Starting')) return 0
if (status.startsWith('Syncing activities')) return 1
if (status.startsWith('Syncing wellness')) return 2
return -1
}
let pollTimer = null
export const useSyncStore = create((set, get) => ({
status: '',
inProgress: false,
connected: false,
lastSyncAt: null,
email: '',
poll: async () => {
try {
const { data } = await api.get('/garmin-sync/config')
const status = data?.last_sync_status ?? ''
const inProgress = !!status && !isTerminal(status)
set({
status, inProgress,
connected: !!data?.connected,
lastSyncAt: data?.last_sync_at ?? null,
email: data?.email ?? '',
})
return inProgress
} catch {
return get().inProgress
}
},
// Adaptive polling: fast while a sync runs, slow when idle. Runs for the
// lifetime of the app (started by Layout) so the floating bar stays accurate
// no matter which page you're on.
startPolling: () => {
if (pollTimer) return
const tick = async () => {
const inProgress = await get().poll()
pollTimer = setTimeout(tick, inProgress ? 3000 : 20000)
}
tick()
},
stopPolling: () => {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null }
},
trigger: async () => {
set({ inProgress: true, status: 'Starting sync…' })
try {
await api.post('/garmin-sync/trigger')
} catch {
set({ inProgress: false })
return
}
get().stopPolling()
get().startPolling()
},
cancel: async () => {
set({ status: 'Cancelling…' })
try {
await api.post('/garmin-sync/cancel')
} catch { /* ignore — poll will reflect the true state */ }
get().poll()
},
}))
+41 -4
View File
@@ -1,33 +1,48 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import api from '../utils/api'
import {
formatDuration, formatDistance, formatPace, formatHeartRate,
formatDate, sportIcon, sportColor,
} from '../utils/format'
const SPORTS = ['all', 'running', 'cycling', 'swimming', 'hiking', 'walking']
const SPORTS = ['all', 'running', 'cycling', 'hiking', 'walking']
export default function ActivitiesPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const [sport, setSport] = useState('all')
const [page, setPage] = useState(1)
const fromParam = searchParams.get('from')
const toParam = searchParams.get('to')
const { data: activities, isLoading } = useQuery({
queryKey: ['activities', sport, page],
queryKey: ['activities', sport, page, fromParam, toParam],
queryFn: () =>
api.get('/activities/', {
params: {
sport_type: sport === 'all' ? undefined : sport,
page,
per_page: 20,
from_date: fromParam ? new Date(fromParam).toISOString() : undefined,
to_date: toParam ? new Date(toParam + 'T23:59:59').toISOString() : undefined,
},
}).then(r => r.data),
})
const { data: ytdStats } = useQuery({
queryKey: ['ytd-stats'],
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
})
const clearDateFilter = () => navigate('/activities')
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Activities</h1>
<Link
to="/upload"
@@ -37,6 +52,28 @@ export default function ActivitiesPage() {
</Link>
</div>
{/* YTD stats */}
{ytdStats && (
<div className="flex gap-4 mb-4 text-sm">
{ytdStats.running_km > 0 && (
<span className="text-blue-400">🏃 {ytdStats.running_km.toFixed(0)} km this year</span>
)}
{ytdStats.cycling_km > 0 && (
<span className="text-orange-400">🚴 {ytdStats.cycling_km.toFixed(0)} km this year</span>
)}
</div>
)}
{/* Date filter chip */}
{fromParam && (
<div className="flex items-center gap-2 mb-4">
<span className="text-xs bg-blue-600/20 text-blue-300 border border-blue-500/30 px-3 py-1 rounded-full">
Week of {format(new Date(fromParam), 'MMM d, yyyy')}
</span>
<button onClick={clearDateFilter} className="text-xs text-gray-500 hover:text-gray-300 transition-colors"> Clear</button>
</div>
)}
{/* Sport filter */}
<div className="flex gap-2 mb-6 flex-wrap">
{SPORTS.map(s => (
+224 -48
View File
@@ -1,22 +1,26 @@
import { useParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, useMemo } from 'react'
import api from '../utils/api'
import ActivityMap from '../components/activity/ActivityMap'
import ActivityMap, { SPEED_GRADIENT } from '../components/activity/ActivityMap'
import MetricTimeline from '../components/activity/MetricTimeline'
import HRZoneBar from '../components/activity/HRZoneBar'
import LapTable from '../components/activity/LapTable'
import SegmentsPanel from '../components/activity/SegmentsPanel'
import RouteLeaderboard from '../components/activity/RouteLeaderboard'
import StatCard from '../components/ui/StatCard'
import {
formatDuration, formatDistance, formatPace, formatElevation,
formatHeartRate, formatDateTime, sportIcon,
formatHeartRate, formatDateTime, formatCadence, sportIcon,
} from '../utils/format'
import { projectToTrack } from '../utils/track'
const METRICS = [
{ key: 'heart_rate', label: 'Heart Rate', unit: 'bpm', color: '#f43f5e' },
{ key: 'speed_ms', label: 'Pace / Speed', unit: '', color: '#3b82f6' },
{ key: 'altitude_m', label: 'Elevation', unit: 'm', color: '#84cc16' },
{ key: 'cadence', label: 'Cadence', unit: 'rpm', color: '#f97316' },
{ key: 'cadence', label: 'Cadence', unit: '', color: '#f97316' },
{ key: 'power', label: 'Power', unit: 'W', color: '#a855f7' },
{ key: 'temperature_c', label: 'Temperature', unit: '°C', color: '#06b6d4' },
]
@@ -25,6 +29,13 @@ export default function ActivityDetailPage() {
const { id } = useParams()
const [activeMetrics, setActiveMetrics] = useState(['heart_rate', 'speed_ms', 'altitude_m'])
const [hoveredDistance, setHoveredDistance] = useState(null)
const [mapHeight, setMapHeight] = useState(420)
const [mapType, setMapType] = useState('street')
const [colorMode, setColorMode] = useState('speed')
const [segCreate, setSegCreate] = useState(false)
const [segPoints, setSegPoints] = useState([]) // [{distance_m}, ...] up to 2
const [segName, setSegName] = useState('')
const qc = useQueryClient()
const { data: activity, isLoading } = useQuery({
queryKey: ['activity', id],
@@ -43,25 +54,71 @@ export default function ActivityDetailPage() {
enabled: !!activity,
})
const { data: actSegments } = useQuery({
queryKey: ['activity-segments', id],
queryFn: () => api.get(`/segments/by-activity/${id}`).then(r => r.data),
enabled: !!activity,
})
const { data: lapBests } = useQuery({
queryKey: ['lap-bests', id],
queryFn: () => api.get(`/activities/${id}/lap-bests`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const { data: routeBoard } = useQuery({
queryKey: ['route-leaderboard', id],
queryFn: () => api.get(`/activities/${id}/route-leaderboard`).then(r => r.data),
enabled: !!activity?.named_route_id,
})
const handleMapClick = ({ lat, lng }) => {
if (!segCreate || !dataPoints) return
const proj = projectToTrack(dataPoints, lat, lng)
if (proj?.distance_m == null) return
const dist = proj.distance_m
setSegPoints(prev => (prev.length >= 2 ? [{ distance_m: dist }] : [...prev, { distance_m: dist }]))
}
const [segError, setSegError] = useState('')
const createSegment = async () => {
const [a, b] = segPoints
setSegError('')
try {
await api.post('/segments/', {
name: segName.trim() || 'Segment',
activity_id: Number(id),
start_distance_m: a.distance_m,
end_distance_m: b.distance_m,
})
setSegCreate(false); setSegPoints([]); setSegName('')
qc.invalidateQueries({ queryKey: ['activity-segments', id] })
} catch (e) {
setSegError(e.response?.data?.detail || 'Failed to create segment')
}
}
const toggleMetric = (key) => {
setActiveMetrics(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-500">Loading activity</div>
</div>
// Check which metrics have actual data
const availableMetrics = useMemo(() => {
if (!dataPoints?.length) return new Set()
return new Set(
METRICS
.filter(m => dataPoints.some(p => p[m.key] != null && p[m.key] !== 0))
.map(m => m.key)
)
}, [dataPoints])
if (isLoading) {
return <div className="flex items-center justify-center h-full"><div className="text-gray-500">Loading activity</div></div>
}
if (!activity) return null
const speed = activity.avg_speed_ms
const pace = formatPace(speed, activity.sport_type)
return (
<div className="p-6 space-y-6">
{/* Header */}
@@ -75,50 +132,150 @@ export default function ActivityDetailPage() {
</div>
</div>
{/* Summary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
{/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Pace" value={pace} />
<StatCard label="Elevation" value={`${formatElevation(activity.elevation_gain_m)}`} />
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
sub={activity.moving_time_s ? 'moving' : undefined} />
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
)}
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
<StatCard label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div>
{/* Secondary stats */}
<div className="grid grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard label="Max HR" value={formatHeartRate(activity.max_heart_rate)} />
<StatCard label="Avg Cadence" value={activity.avg_cadence ? `${Math.round(activity.avg_cadence)} rpm` : '--'} />
<StatCard label="Avg Power" value={activity.avg_power ? `${Math.round(activity.avg_power)} W` : '--'} />
<StatCard label="NP" value={activity.normalized_power ? `${Math.round(activity.normalized_power)} W` : '--'} />
<StatCard label="TSS" value={activity.training_stress_score ? Math.round(activity.training_stress_score) : '--'} />
<StatCard label="Elevation ↓" value={formatElevation(activity.elevation_loss_m)} />
<StatCard label="Cadence" value={formatCadence(activity.avg_cadence, activity.sport_type)} />
<StatCard label="Avg Temp" value={activity.avg_temperature_c ? `${activity.avg_temperature_c.toFixed(1)} °C` : '--'} />
</div>
{/* Map */}
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800" style={{ height: 420 }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
/>
</div>
{/* HR Zones */}
{activity.hr_zones && Object.keys(activity.hr_zones).length > 0 && (
{activity.hr_zones && Object.values(activity.hr_zones).some(v => v > 0) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate Zones</h3>
<HRZoneBar zones={activity.hr_zones} />
<HRZoneBar zones={activity.hr_zones} totalSeconds={activity.moving_time_s ?? activity.duration_s} />
</div>
)}
{/* Metric selector */}
{/* Map and activity timeline side by side, each ~half width on large screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* Map with controls — only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Map style:</span>
{['dark', 'street', 'satellite'].map(t => (
<button
key={t}
onClick={() => setMapType(t)}
className={`text-xs px-2.5 py-1 rounded-full capitalize transition-colors ${
mapType === t ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{t}
</button>
))}
{dataPoints?.length > 0 && (
<button
onClick={() => { setSegCreate(c => !c); setSegPoints([]); setSegName('') }}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ml-2 ${
segCreate ? 'bg-green-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
+ Segment
</button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Route:</span>
{[['speed', 'Speed'], ['solid', 'Solid']].map(([mode, label]) => (
<button
key={mode}
onClick={() => setColorMode(mode)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
colorMode === mode ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{label}
</button>
))}
<span className="text-xs text-gray-500 ml-2">Height:</span>
{[280, 420, 560].map(h => (
<button
key={h}
onClick={() => setMapHeight(h)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
mapHeight === h ? 'bg-blue-600 text-white' : 'text-gray-400 hover:text-white bg-gray-800'
}`}
>
{h === 280 ? 'S' : h === 420 ? 'M' : 'L'}
</button>
))}
</div>
</div>
{segCreate && (
<div className="flex flex-wrap items-center gap-3 px-4 py-2 border-b border-gray-800 bg-green-900/10 text-xs">
<span className="text-green-400">
Click two points on the route to mark the segment start and end.
</span>
<span className="text-gray-400">
Start: {segPoints[0] ? `${(segPoints[0].distance_m / 1000).toFixed(2)} km` : '—'}
{' · '}End: {segPoints[1] ? `${(segPoints[1].distance_m / 1000).toFixed(2)} km` : '—'}
</span>
{segPoints.length === 2 && (
<>
<input
value={segName}
onChange={e => setSegName(e.target.value)}
placeholder="Segment name"
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-white focus:outline-none focus:ring-2 focus:ring-green-500"
/>
<button onClick={createSegment} disabled={!segName.trim()}
className="bg-green-600 hover:bg-green-700 disabled:opacity-40 text-white px-3 py-1 rounded-lg">
Create
</button>
</>
)}
{segPoints.length > 0 && (
<button onClick={() => setSegPoints([])} className="text-gray-400 hover:text-white">Reset</button>
)}
{segError && <span className="text-red-400">{segError}</span>}
</div>
)}
<div style={{ height: mapHeight }}>
<ActivityMap
polyline={activity.polyline}
dataPoints={dataPoints}
hoveredDistance={hoveredDistance}
sportType={activity.sport_type}
mapType={mapType}
colorMode={colorMode}
onMapClick={segCreate ? handleMapClick : undefined}
/>
</div>
{colorMode === 'speed' && (
<div className="flex items-center gap-2 px-4 py-2 border-t border-gray-800">
<span className="text-xs text-gray-500">Slow</span>
<div className="h-2 flex-1 max-w-xs rounded-full" style={{ background: SPEED_GRADIENT }} />
<span className="text-xs text-gray-500">Fast</span>
</div>
)}
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 flex items-center justify-center text-gray-600 text-sm">
No GPS track for this activity
</div>
)}
{/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Activity Timeline</h3>
<div className="flex flex-wrap gap-2">
{METRICS.map(({ key, label, color }) => (
{METRICS.filter(m => availableMetrics.has(m.key)).map(({ key, label, color }) => (
<button
key={key}
onClick={() => toggleMetric(key)}
@@ -134,23 +291,42 @@ export default function ActivityDetailPage() {
))}
</div>
</div>
{dataPoints && (
{dataPoints && dataPoints.length > 0 ? (
<MetricTimeline
dataPoints={dataPoints}
activeMetrics={activeMetrics}
activeMetrics={activeMetrics.filter(m => availableMetrics.has(m))}
metrics={METRICS}
onHoverDistance={setHoveredDistance}
sportType={activity.sport_type}
/>
) : (
<p className="text-gray-600 text-sm text-center py-8">No timeline data available for this activity</p>
)}
</div>
</div>
{/* Laps */}
{laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} />
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
expanding to fill the width when fewer are present. */}
{((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
<div className="flex flex-wrap gap-4 items-start">
{laps && laps.length > 0 && (
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div>
)}
{routeBoard && routeBoard.top?.length > 0 && (
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3>
<RouteLeaderboard data={routeBoard} />
</div>
)}
{actSegments && actSegments.length > 0 && (
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
</div>
)}
</div>
)}
</div>
+624 -169
View File
@@ -1,197 +1,652 @@
import { Link } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format, subDays, startOfWeek } from 'date-fns'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState, useEffect, useRef } from 'react'
import {
BarChart, Bar, AreaChart, Area, Cell, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import GridLayout, { WidthProvider } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import 'react-resizable/css/styles.css'
import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import ActivityMap from '../components/activity/ActivityMap'
import {
formatDuration, formatDistance, formatPace, formatHeartRate,
formatDate, sportIcon, formatSleep,
formatDuration, formatDistance, formatHeartRate, formatElevation,
formatDate, sportIcon, sportColor, formatSleep,
} from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
function WeeklyChart({ activities }) {
if (!activities?.length) return null
const Grid = WidthProvider(GridLayout)
// Build last 8 weeks of distance data
const weeks = {}
activities.forEach(a => {
const week = format(startOfWeek(new Date(a.start_time)), 'MMM d')
if (!weeks[week]) weeks[week] = { week, km: 0, runs: 0 }
weeks[week].km += (a.distance_m || 0) / 1000
weeks[week].runs++
})
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
const data = Object.values(weeks).slice(-8)
const HRV_PALETTE = {
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
low: 'text-red-400 bg-red-400/10 border-red-400/30',
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
}
// Compact single-stat widgets. val(health, ytdStats) → display string.
const STAT_DEFS = {
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
stat_calories: { label: 'Active calories', accent: 'orange', val: h => h.active_calories != null ? Math.round(h.active_calories).toLocaleString() : '--' },
stat_floors: { label: 'Floors climbed', accent: 'green', val: h => h.floors_climbed != null ? h.floors_climbed : '--' },
}
// Full widget registry: size defaults + palette label. Stats inherit from STAT_DEFS.
const WIDGETS = {
...Object.fromEntries(Object.entries(STAT_DEFS).map(([id, d]) => [id, { label: d.label, w: 2, h: 1, minW: 1, minH: 1 }])),
weekly: { label: 'Weekly distance', w: 6, h: 3, minW: 4, minH: 2 },
bodyBattery: { label: 'Body Battery', w: 4, h: 3, minW: 3, minH: 2 },
vo2maxTrend: { label: 'VO₂ max trend', w: 3, h: 3, minW: 2, minH: 2 },
sleepDetail: { label: 'Sleep stages', w: 5, h: 3, minW: 3, minH: 2 },
weight: { label: 'Weight trend', w: 3, h: 3, minW: 2, minH: 2 },
featured: { label: 'Latest activity', w: 8, h: 5, minW: 4, minH: 3 },
recent: { label: 'Recent activities', w: 4, h: 5, minW: 3, minH: 3 },
prs: { label: 'Running PRs', w: 12, h: 2, minW: 4, minH: 2 },
}
// Default arrangement (used for new users and to migrate pre-redesign layouts).
const DEFAULT_LAYOUT = [
{ i: 'stat_steps', x: 0, y: 0, w: 2, h: 1 },
{ i: 'stat_resting_hr', x: 2, y: 0, w: 2, h: 1 },
{ i: 'stat_sleep', x: 4, y: 0, w: 2, h: 1 },
{ i: 'stat_vo2max', x: 6, y: 0, w: 2, h: 1 },
{ i: 'stat_hrv', x: 8, y: 0, w: 2, h: 1 },
{ i: 'stat_running', x: 10, y: 0, w: 2, h: 1 },
{ i: 'weekly', x: 0, y: 1, w: 6, h: 3 },
{ i: 'bodyBattery', x: 6, y: 1, w: 4, h: 3 },
{ i: 'featured', x: 0, y: 4, w: 8, h: 5 },
{ i: 'recent', x: 8, y: 4, w: 4, h: 5 },
{ i: 'prs', x: 0, y: 9, w: 12, h: 2 },
]
const attachMins = (lay) =>
lay.filter(l => WIDGETS[l.i]).map(l => ({ ...l, minW: WIDGETS[l.i].minW, minH: WIDGETS[l.i].minH }))
function buildLayout(saved) {
const known = (saved || []).filter(l => WIDGETS[l.i])
// Migrate old layouts (no stat_* widgets) or empty/missing to the new default.
const hasStats = known.some(l => l.i.startsWith('stat_'))
return attachMins(known.length && hasStats ? known : DEFAULT_LAYOUT)
}
// ── Reusable card shell ──────────────────────────────────────────────────────
function Card({ title, viewHref, children, className = '' }) {
return (
<div className={`bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col ${className}`}>
{title && (
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>
{viewHref && <Link to={viewHref} className="text-xs text-blue-400 hover:underline">View </Link>}
</div>
)}
<div className="flex-1 min-h-0">{children}</div>
</div>
)
}
function Stat({ label, value }) {
return (
<div className="bg-gray-900 px-4 py-3 flex flex-col justify-center">
<p className="text-xs text-gray-500">{label}</p>
<p className="text-lg font-semibold text-white">{value}</p>
</div>
)
}
// ── Chart widgets ────────────────────────────────────────────────────────────
function BodyBatteryToday({ bb, hires, sleepStart, sleepEnd }) {
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ t: ts, level }))
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
const data = raw.map((d, i) => ({
...d,
type: inferBBType(d.t, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
}))
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level
const peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
const hasGraph = data.length >= 2
const presentTypes = [...new Set(data.map(d => d.type))]
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => `${v.toFixed(0)}`} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
formatter={(v, name) => [`${v.toFixed(1)} km`, 'Distance']}
/>
<Bar dataKey="km" fill="#3b82f6" radius={[3, 3, 0, 0]} isAnimationActive={false} />
</BarChart>
<Card title="Body Battery" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-3 flex-wrap">
{peak != null && <span className="text-3xl font-bold" style={{ color: bbLevelColor(peak) }}>{Math.round(peak)}</span>}
{charged != null && <span className="text-sm font-semibold text-green-400">+{charged}</span>}
{drained != null && <span className="text-sm font-semibold text-orange-400">-{drained}</span>}
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
</div>
{hasGraph ? (
<>
<div className="flex-1 min-h-0 mt-2">
<ResponsiveContainer width="100%" height="100%" minHeight={80}>
<BarChart data={data} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(data.length / 6))} />
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={26} ticks={[0, 50, 100]} />
<Tooltip contentStyle={tooltipStyle} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}%`, 'Battery']} />
<Bar dataKey="level" isAnimationActive={false} radius={0}>
{data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{presentTypes.map(type => (
<div key={type} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm" style={{ backgroundColor: BB_INFERRED_COLOR[type] }} />
<span className="text-xs text-gray-500">{BB_INFERRED_LABEL[type]}</span>
</div>
))}
</div>
</>
) : (
<p className="text-xs text-gray-600 mt-3">No body battery data today</p>
)}
</div>
</Card>
)
}
function Sparkline({ data, dataKey, color, gradId, fmt }) {
return (
<ResponsiveContainer width="100%" height="100%" minHeight={60}>
<AreaChart data={data} margin={{ top: 4, right: 2, bottom: 0, left: 0 }}>
<defs>
<linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={['dataMin - 1', 'dataMax + 1']} hide />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d')}
formatter={v => [fmt ? fmt(v) : v, '']} />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2} fill={`url(#${gradId})`}
dot={false} connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)
}
export default function DashboardPage() {
const { data: recentActivities } = useQuery({
queryKey: ['activities-recent'],
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
})
function Vo2MaxTrend({ health, recentHealth }) {
const series = useMemo(
() => [...(recentHealth || [])].filter(d => d.vo2max != null)
.sort((a, b) => new Date(a.date) - new Date(b.date))
.map(d => ({ date: d.date, v: d.vo2max })),
[recentHealth],
)
return (
<Card title="VO₂ Max" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-blue-400">{health.vo2max != null ? health.vo2max.toFixed(1) : '--'}</span>
<span className="text-xs text-gray-500">ml/kg/min</span>
</div>
{health.fitness_age != null && <p className="text-xs text-gray-500 mt-0.5">Fitness age {health.fitness_age}</p>}
<div className="flex-1 min-h-0 mt-2">
{series.length >= 2
? <Sparkline data={series} dataKey="v" color="#3b82f6" gradId="grad-dash-vo2" fmt={v => v.toFixed(1)} />
: <p className="text-xs text-gray-600">Not enough history</p>}
</div>
</div>
</Card>
)
}
const { data: allActivities } = useQuery({
queryKey: ['activities-all-chart'],
queryFn: () =>
api.get('/activities/', {
params: {
per_page: 100,
from_date: subDays(new Date(), 60).toISOString(),
},
}).then(r => r.data),
})
function WeightMini({ recentHealth }) {
const series = useMemo(
() => [...(recentHealth || [])].filter(d => d.weight_kg != null)
.sort((a, b) => new Date(a.date) - new Date(b.date))
.map(d => ({ date: d.date, w: +d.weight_kg.toFixed(2) })),
[recentHealth],
)
const latest = series.length ? series[series.length - 1].w : null
return (
<Card title="Weight" viewHref="/health">
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-emerald-300">{latest != null ? latest.toFixed(1) : '--'}</span>
<span className="text-xs text-gray-500">kg</span>
</div>
<div className="flex-1 min-h-0 mt-2">
{series.length >= 2
? <Sparkline data={series} dataKey="w" color="#34d399" gradId="grad-dash-weight" fmt={v => `${v.toFixed(1)} kg`} />
: <p className="text-xs text-gray-600">Not enough history</p>}
</div>
</div>
</Card>
)
}
const { data: healthSummary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const SLEEP_STAGES = [
{ key: 'sleep_deep_s', label: 'Deep', color: '#3b82f6' },
{ key: 'sleep_rem_s', label: 'REM', color: '#8b5cf6' },
{ key: 'sleep_light_s', label: 'Light', color: '#60a5fa' },
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
]
const { data: records } = useQuery({
queryKey: ['records-running'],
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
})
function SleepDetail({ health }) {
const total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0)
return (
<Card title="Sleep" viewHref="/health">
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
{health.sleep_score != null && (
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
)}
</div>
{total > 0 ? (
<>
<div className="flex h-3 rounded-full overflow-hidden gap-0.5 mt-3">
{SLEEP_STAGES.map(st => {
const pct = ((health[st.key] || 0) / total) * 100
if (pct < 0.5) return null
return <div key={st.key} style={{ width: `${pct}%`, backgroundColor: st.color }} title={`${st.label}: ${formatSleep(health[st.key])}`} />
})}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{SLEEP_STAGES.map(st => (health[st.key] ? (
<div key={st.key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: st.color }} />
<span className="text-xs text-gray-400">{st.label}</span>
<span className="text-xs text-white">{formatSleep(health[st.key])}</span>
</div>
) : null))}
</div>
</>
) : (
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
)}
</Card>
)
}
const latest = healthSummary?.latest
const totalActivities = recentActivities?.length ?? 0
const totalDistance = recentActivities?.reduce((s, a) => s + (a.distance_m || 0), 0) ?? 0
const sportLabel = s => (s ? s.charAt(0).toUpperCase() + s.slice(1) : 'Other')
function WeeklyChart({ activities }) {
const navigate = useNavigate()
const { data, sports } = useMemo(() => {
if (!activities?.length) return { data: [], sports: [] }
// Sports present, ordered by total distance (largest stacks at the bottom).
const totals = {}
for (const a of activities) totals[a.sport_type] = (totals[a.sport_type] || 0) + (a.distance_m || 0)
const sports = Object.keys(totals).sort((x, y) => totals[y] - totals[x])
const now = new Date()
const weeks = eachWeekOfInterval({ start: subWeeks(startOfWeek(now), 7), end: startOfWeek(now) })
const data = weeks.map(weekStart => {
const weekEnd = addDays(weekStart, 7)
const row = { week: format(weekStart, 'MMM d'), weekStartISO: format(weekStart, 'yyyy-MM-dd'), weekEndISO: format(weekEnd, 'yyyy-MM-dd') }
for (const s of sports) row[s] = 0
for (const a of activities) {
const t = new Date(a.start_time)
if (t >= weekStart && t < weekEnd) row[a.sport_type] += (a.distance_m || 0) / 1000
}
for (const s of sports) row[s] = +row[s].toFixed(2)
return row
})
return { data, sports }
}, [activities])
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<Link
to="/upload"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
+ Import data
</Link>
</div>
{/* Top stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Activities (10)" value={totalActivities} />
<StatCard label="Distance (10)" value={formatDistance(totalDistance)} accent="blue" />
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)} accent="red" />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Weekly distance chart */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weekly distance (km)</h3>
<WeeklyChart activities={allActivities} />
</div>
{/* Health snapshot */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
<h3 className="text-sm font-medium text-gray-300">Health today</h3>
{latest ? (
<>
<div className="flex justify-between text-sm">
<span className="text-gray-500">HRV</span>
<span className="text-white">{latest.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Sleep score</span>
<span className="text-white">{latest.sleep_score ? Math.round(latest.sleep_score) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Steps</span>
<span className="text-white">{latest.steps?.toLocaleString() ?? '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">VO2 Max</span>
<span className="text-white">{latest.vo2max ? latest.vo2max.toFixed(1) : '--'}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-500">Stress</span>
<span className="text-white">{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}</span>
</div>
<Link to="/health" className="block text-xs text-blue-400 hover:underline mt-2">
View full health dashboard
</Link>
</>
) : (
<p className="text-xs text-gray-600">No health data. Import a Garmin export.</p>
)}
</div>
</div>
{/* Recent activities */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Recent activities</h3>
<Link to="/activities" className="text-xs text-blue-400 hover:underline">View all </Link>
</div>
<div className="space-y-2">
{recentActivities?.slice(0, 5).map(activity => (
<Link
key={activity.id}
to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors"
>
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
<div className="flex gap-4 text-sm text-right">
<div>
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
<p className="text-xs text-gray-600">dist</p>
</div>
<div>
<p className="text-gray-200">{formatDuration(activity.duration_s)}</p>
<p className="text-xs text-gray-600">time</p>
</div>
<div>
<p className="text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
<p className="text-xs text-gray-600">HR</p>
</div>
</div>
</Link>
))}
{!recentActivities?.length && (
<p className="text-gray-600 text-sm text-center py-8">
No activities yet <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link>
</p>
)}
</div>
</div>
{/* PRs snapshot */}
{records?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-300">Running PRs</h3>
<Link to="/records" className="text-xs text-blue-400 hover:underline">View all </Link>
<Card title="Weekly distance (km)">
{data.length ? (
<div className="flex flex-col h-full">
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%" minHeight={100}>
<BarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={20}
onClick={e => { const p = e?.activePayload?.[0]?.payload; if (p) navigate(`/activities?from=${p.weekStartISO}&to=${p.weekEndISO}`) }}
style={{ cursor: 'pointer' }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="week" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28} tickFormatter={v => `${v.toFixed(0)}`} />
<Tooltip contentStyle={tooltipStyle} cursor={{ fill: 'rgba(255,255,255,0.06)' }}
formatter={(v, name) => [`${(+v).toFixed(1)} km`, sportLabel(name)]} />
{sports.map((s, i) => (
<Bar key={s} dataKey={s} stackId="dist" fill={sportColor(s)} isAnimationActive={false}
radius={i === sports.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]} />
))}
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{records.slice(0, 5).map(rec => (
<div key={rec.id} className="bg-gray-800/60 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 mb-1">{rec.distance_label}</p>
<p className="font-mono font-semibold text-yellow-400">{formatDuration(rec.duration_s)}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{sports.map(s => (
<div key={s} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: sportColor(s) }} />
<span className="text-xs text-gray-400">{sportLabel(s)}</span>
</div>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div>
)}
</Card>
)
}
function FeaturedActivity({ activity, segments }) {
if (!activity) return (
<Card title="Latest activity"><div className="flex items-center justify-center h-full text-gray-600 text-sm">No activities yet</div></Card>
)
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-800">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xl">{sportIcon(activity.sport_type)}</span>
<div className="min-w-0">
<Link to={`/activities/${activity.id}`} className="text-sm font-semibold text-white hover:text-blue-400 transition-colors truncate block">{activity.name}</Link>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
</div>
<Link to={`/activities/${activity.id}`} className="text-xs text-blue-400 hover:underline flex-shrink-0">Open </Link>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 flex-1 min-h-0">
<div className="lg:col-span-2 min-h-[180px] bg-gray-950">
{activity.polyline
? <ActivityMap polyline={activity.polyline} sportType={activity.sport_type} colorMode="solid" mapType="dark" />
: <div className="flex items-center justify-center h-full text-gray-600 text-sm">No GPS track</div>}
</div>
<div className="grid grid-cols-2 lg:grid-cols-1 gap-px bg-gray-800/50 content-start">
<Stat label="Distance" value={formatDistance(activity.distance_m)} />
<Stat label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<Stat label="Moving time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)} />
<Stat label="Calories" value={activity.calories ? `${Math.round(activity.calories)} kcal` : '--'} />
</div>
</div>
{segments?.length > 0 && (
<div className="border-t border-gray-800 px-4 py-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-xs font-medium text-gray-400 uppercase tracking-wide">Segments</h4>
<Link to={`/activities/${activity.id}`} className="text-xs text-blue-400 hover:underline">Details </Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1.5">
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
return (
<div key={seg.segment_id} className="flex items-center gap-2 text-sm">
<span className="flex-1 text-gray-300 text-xs truncate">{seg.name}</span>
<span className={`font-mono text-xs ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>{formatDuration(seg.duration_s)}</span>
<span className="w-8 text-right text-xs">
{isPodium ? <span title={`#${seg.rank} of ${seg.effort_count}`}>{MEDALS[seg.rank]}</span>
: delta != null ? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</span>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
function RecentActivities({ activities }) {
return (
<Card title="Recent activities" viewHref="/activities">
<div className="space-y-2 overflow-auto h-full">
{activities?.slice(0, 6).map(activity => (
<Link key={activity.id} to={`/activities/${activity.id}`}
className="flex items-center gap-3 py-2 border-b border-gray-800/50 hover:bg-gray-800/30 rounded-lg px-2 -mx-2 transition-colors">
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
</div>
<div className="text-right text-sm">
<p className="text-gray-200">{formatDistance(activity.distance_m)}</p>
<p className="text-xs text-red-400">{formatHeartRate(activity.avg_heart_rate)}</p>
</div>
</Link>
))}
{!activities?.length && (
<p className="text-gray-600 text-sm text-center py-8">No activities yet <Link to="/upload" className="text-blue-400 hover:underline">import some data</Link></p>
)}
</div>
</Card>
)
}
const DASH_PR_LABELS = ['1k', '1 mile', '5k', '10k']
function RunningPRs({ records }) {
const byLabel = Object.fromEntries((records || []).map(r => [r.distance_label, r]))
return (
<Card title="Running PRs" viewHref="/records">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{DASH_PR_LABELS.map(label => {
const rec = byLabel[label]
return (
<div key={label} className="bg-gray-800/60 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 mb-1">{label}</p>
<p className="font-mono font-semibold text-yellow-400">{rec ? formatDuration(rec.duration_s) : '--'}</p>
</div>
)
})}
</div>
</Card>
)
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const { data: recentActivities } = useQuery({
queryKey: ['activities-recent'],
queryFn: () => api.get('/activities/', { params: { per_page: 10 } }).then(r => r.data),
})
const { data: allActivities } = useQuery({
queryKey: ['activities-all-chart'],
queryFn: () => api.get('/activities/', { params: { per_page: 100, from_date: subDays(new Date(), 60).toISOString() } }).then(r => r.data),
})
const { data: recentHealth } = useQuery({
queryKey: ['health-metrics', 'dash'],
queryFn: () => api.get('/health-metrics/', { params: { limit: 365 } }).then(r => r.data),
})
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
const health = useMemo(() => {
const rows = [...(recentHealth || [])].sort((a, b) => new Date(b.date) - new Date(a.date))
const pick = f => rows.find(d => d[f] != null)?.[f] ?? null
const latest = rows[0] || {}
return {
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null,
resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'),
sleep_start: latest.sleep_start ?? null,
sleep_end: latest.sleep_end ?? null,
sleep_deep_s: latest.sleep_deep_s ?? null,
sleep_rem_s: latest.sleep_rem_s ?? null,
sleep_light_s: latest.sleep_light_s ?? null,
sleep_awake_s: latest.sleep_awake_s ?? null,
sleep_score: pick('sleep_score'),
hrv_nightly_avg: pick('hrv_nightly_avg'),
hrv_status: pick('hrv_status'),
steps: pick('steps'),
vo2max: pick('vo2max'),
fitness_age: pick('fitness_age'),
avg_stress: pick('avg_stress'),
active_calories: pick('active_calories'),
floors_climbed: pick('floors_climbed'),
}
}, [recentHealth])
const { data: intraday } = useQuery({
queryKey: ['health-intraday-dash', health.date],
queryFn: () => api.get('/health-metrics/intraday', { params: { date: health.date } }).then(r => r.data),
enabled: !!health.date,
})
const { data: records } = useQuery({
queryKey: ['records-running'],
queryFn: () => api.get('/records/', { params: { sport_type: 'running' } }).then(r => r.data),
})
const { data: ytdStats } = useQuery({
queryKey: ['ytd-stats'],
queryFn: () => api.get('/activities/stats/ytd').then(r => r.data),
})
const featured = recentActivities?.[0]
const { data: featuredSegments } = useQuery({
queryKey: ['activity-segments', featured?.id],
queryFn: () => api.get(`/segments/by-activity/${featured.id}`).then(r => r.data),
enabled: !!featured?.id,
})
// ── Layout state ──────────────────────────────────────────────────────────
const [editMode, setEditMode] = useState(false)
const [addOpen, setAddOpen] = useState(false)
const [layout, setLayout] = useState(() => buildLayout(null))
const saveTimer = useRef(null)
const loadedRef = useRef(false)
useEffect(() => {
if (profile && !loadedRef.current) {
loadedRef.current = true
setLayout(buildLayout(profile.dashboard_layout))
}
}, [profile])
const qc = useQueryClient()
const stripLayout = (lay) => lay.map(({ i, x, y, w, h }) => ({ i, x, y, w, h }))
const saveLayout = useMutation({
mutationFn: (lay) => api.put('/profile/dashboard-layout', { layout: stripLayout(lay) }),
// Keep the cached profile in sync so re-mounting the page doesn't revert the layout.
onSuccess: (_d, lay) => qc.setQueryData(['profile'], p => (p ? { ...p, dashboard_layout: stripLayout(lay) } : p)),
})
const persist = (lay) => { clearTimeout(saveTimer.current); saveLayout.mutate(lay) }
const handleLayoutChange = (next) => {
const withMins = attachMins(next)
setLayout(withMins)
if (editMode) {
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveLayout.mutate(withMins), 700)
}
}
const addWidget = (id) => {
if (layout.some(l => l.i === id)) { setAddOpen(false); return }
const maxY = layout.reduce((m, l) => Math.max(m, l.y + l.h), 0)
const def = WIDGETS[id]
const next = attachMins([...layout, { i: id, x: 0, y: maxY, w: def.w, h: def.h }])
setLayout(next); persist(next); setAddOpen(false)
}
const removeWidget = (id) => { const next = layout.filter(l => l.i !== id); setLayout(next); persist(next) }
const finishEditing = () => { persist(layout); setEditMode(false); setAddOpen(false) }
const resetLayout = () => { const def = attachMins(DEFAULT_LAYOUT); setLayout(def); persist(def) }
const renderWidget = (id) => {
if (STAT_DEFS[id]) {
const d = STAT_DEFS[id]
return <StatCard label={d.label} accent={d.accent} value={d.val(health, ytdStats)}
sub={typeof d.sub === 'function' ? d.sub(health) : d.sub} />
}
switch (id) {
case 'weekly': return <WeeklyChart activities={allActivities} />
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
case 'sleepDetail': return <SleepDetail health={health} />
case 'weight': return <WeightMini recentHealth={recentHealth} />
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
case 'recent': return <RecentActivities activities={recentActivities} />
case 'prs': return <RunningPRs records={records} />
default: return null
}
}
const presentIds = new Set(layout.map(l => l.i))
const available = Object.keys(WIDGETS).filter(id => !presentIds.has(id))
return (
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<div className="flex items-center gap-3">
{editMode && (
<div className="relative">
<button onClick={() => setAddOpen(o => !o)}
className="text-sm font-medium px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 text-gray-200 transition-colors">
+ Add widget
</button>
{addOpen && (
<div className="absolute right-0 mt-1 w-56 max-h-80 overflow-auto bg-gray-900 border border-gray-700 rounded-lg shadow-xl z-50 py-1">
{available.length === 0
? <p className="text-xs text-gray-500 px-3 py-2">All widgets are on the dashboard</p>
: available.map(id => (
<button key={id} onClick={() => addWidget(id)}
className="block w-full text-left text-sm text-gray-300 hover:bg-gray-800 px-3 py-1.5 transition-colors">
{WIDGETS[id].label}
</button>
))}
</div>
)}
</div>
)}
{editMode && (
<button onClick={resetLayout} className="text-xs text-gray-400 hover:text-white transition-colors">Reset layout</button>
)}
<button
onClick={() => (editMode ? finishEditing() : setEditMode(true))}
className={`text-sm font-medium px-3 py-1.5 rounded-lg transition-colors ${
editMode ? 'bg-blue-600 hover:bg-blue-500 text-white' : 'bg-gray-800 hover:bg-gray-700 text-gray-200'
}`}>
{editMode ? '✓ Done' : '✎ Edit dashboard'}
</button>
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div>
</div>
{editMode && (
<p className="text-xs text-gray-500 mb-3">Drag to move, drag a corner to resize, or remove a widget with . Add widgets from the menu. Changes save automatically.</p>
)}
<Grid
className="layout"
layout={layout}
cols={12}
rowHeight={80}
margin={[16, 16]}
isDraggable={editMode}
isResizable={editMode}
onLayoutChange={handleLayoutChange}
compactType="vertical"
draggableCancel=".widget-delete"
>
{layout.filter(l => WIDGETS[l.i]).map(l => (
<div key={l.i} className={`rounded-xl relative ${editMode ? 'ring-2 ring-blue-500/40 cursor-move' : ''}`}>
{editMode && (
<button onClick={() => removeWidget(l.i)}
className="widget-delete absolute -top-2 -right-2 z-20 w-6 h-6 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white text-xs shadow-lg"
title="Remove widget"></button>
)}
<div className={`h-full ${editMode ? 'pointer-events-none select-none' : ''}`}>
{renderWidget(l.i)}
</div>
</div>
))}
</Grid>
</div>
)
}
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -4,10 +4,19 @@ import { useAuthStore } from '../hooks/useAuth'
import { useQuery } from '@tanstack/react-query'
import api from '../utils/api'
const AUTH_ERRORS = {
not_authorized: "Your account isn't permitted to access MileVault — ask the admin to add you to the allowed group.",
passkey_in_use: "That passkey is already linked to another account. Sign in to that account, or have an admin remove it on the Users page, then try linking again.",
link_failed: "Couldn't link the passkey. Please try again.",
invalid_state: "Your sign-in link expired or was invalid. Please try signing in again.",
no_identity: "Couldn't read your identity from the provider. Please try again.",
}
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const authError = new URLSearchParams(window.location.search).get('auth_error')
const [error, setError] = useState(AUTH_ERRORS[authError] || '')
const { login, isLoading } = useAuthStore()
const navigate = useNavigate()
+485
View File
@@ -0,0 +1,485 @@
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync'
// Human-friendly description of the automatic sync cadence, e.g. "every 30 min",
// "hourly", "every 2 h". Driven by the backend's configured interval.
function formatSyncInterval(minutes) {
if (!minutes || minutes <= 0) return 'automatic'
if (minutes === 60) return 'hourly'
if (minutes < 60) return `every ${minutes} min`
if (minutes % 60 === 0) return `every ${minutes / 60} h`
return `every ${Math.floor(minutes / 60)} h ${minutes % 60} min`
}
function Section({ title, children }) {
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h2 className="text-sm font-semibold text-gray-300">{title}</h2>
{children}
</div>
)
}
function Field({ label, hint, children }) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
{children}
{hint && <p className="text-xs text-gray-600 mt-1">{hint}</p>}
</div>
)
}
function Input({ type = 'text', value, onChange, placeholder, min, max }) {
return (
<input type={type} value={value} onChange={onChange} placeholder={placeholder} min={min} max={max}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500" />
)
}
function SaveButton({ onClick, loading, saved, label = 'Save' }) {
return (
<div className="flex items-center gap-3 pt-1">
<button onClick={onClick} disabled={loading}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{loading ? 'Saving…' : label}
</button>
{saved && <span className="text-green-400 text-sm"> Saved</span>}
</div>
)
}
export default function ProfilePage() {
const qc = useQueryClient()
const { user, fetchUser } = useAuthStore()
const { data: profile } = useQuery({
queryKey: ['profile'],
queryFn: () => api.get('/profile/').then(r => r.data),
})
// Passkey linking (available to all users when PocketID is configured)
const { data: pocketidAvailable } = useQuery({
queryKey: ['pocketid-available'],
queryFn: () => api.get('/auth/pocketid/available').then(r => r.data),
})
const [showLinked, setShowLinked] = useState(
new URLSearchParams(window.location.search).get('linked') === '1'
)
useEffect(() => {
if (showLinked) {
fetchUser() // refresh has_passkey
window.history.replaceState({}, '', '/profile')
const t = setTimeout(() => setShowLinked(false), 6000)
return () => clearTimeout(t)
}
}, [])
const handleLinkPasskey = async () => {
const { data } = await api.get('/auth/pocketid/link-url')
window.location.href = data.url
}
const { data: pocketidConfig } = useQuery({
queryKey: ['pocketid-config'],
queryFn: () => api.get('/profile/pocketid-config').then(r => r.data),
enabled: !!user?.is_admin,
})
const { data: healthSummary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', birth_year: '', height_cm: '', biological_sex: '', goal_weight_kg: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
useEffect(() => {
if (profile) setHrForm({
max_heart_rate: profile.max_heart_rate || '',
birth_year: profile.birth_year || '',
height_cm: profile.height_cm || '',
biological_sex: profile.biological_sex || '',
goal_weight_kg: profile.goal_weight_kg || '',
})
}, [profile])
const updateProfile = useMutation({
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['profile'] })
setHrSaved(true)
setTimeout(() => setHrSaved(false), 3000)
if (maxHrChangedRef.current) {
setHrZoneRecalc(true)
setTimeout(() => setHrZoneRecalc(false), 6000)
maxHrChangedRef.current = false
}
},
})
// Password change
const [pwForm, setPwForm] = useState({ current_password: '', new_password: '', confirm: '' })
const [pwError, setPwError] = useState('')
const [pwSaved, setPwSaved] = useState(false)
const changePassword = useMutation({
mutationFn: data => api.post('/profile/change-password', data).then(r => r.data),
onSuccess: () => { setPwSaved(true); setPwForm({ current_password: '', new_password: '', confirm: '' }); setTimeout(() => setPwSaved(false), 3000) },
onError: e => setPwError(e.response?.data?.detail || 'Failed to change password'),
})
// Garmin Connect sync
const { data: garminConfig, refetch: refetchGarmin } = useQuery({
queryKey: ['garmin-config'],
queryFn: () => api.get('/garmin-sync/config').then(r => r.data),
})
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync, cancel: cancelSync } = useSyncStore()
const gcFormLoaded = useRef(false)
useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) {
gcFormLoaded.current = true
setGcForm(f => ({
...f,
email: garminConfig.email || '',
sync_enabled: garminConfig.sync_enabled,
sync_activities: garminConfig.sync_activities,
sync_wellness: garminConfig.sync_wellness,
sync_lookback_days: String(garminConfig.sync_lookback_days ?? 30),
}))
} else if (!garminConfig?.connected) {
gcFormLoaded.current = false
}
}, [garminConfig])
const saveGarmin = useMutation({
mutationFn: data => api.put('/garmin-sync/config', data).then(r => r.data),
onSuccess: () => {
refetchGarmin()
setGcSaved(true)
setGcError('')
setGcForm(f => ({ ...f, password: '' }))
setTimeout(() => setGcSaved(false), 3000)
},
onError: e => setGcError(e.response?.data?.detail || 'Failed to save'),
})
const deleteGarmin = useMutation({
mutationFn: () => api.delete('/garmin-sync/config'),
onSuccess: () => {
refetchGarmin()
setGcForm({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
},
})
// PocketID config
const [pidForm, setPidForm] = useState({ issuer: '', client_id: '', client_secret: '', allowed_group: '' })
const [pidSaved, setPidSaved] = useState(false)
useEffect(() => {
if (pocketidConfig) setPidForm({ issuer: pocketidConfig.issuer || '', client_id: pocketidConfig.client_id || '', client_secret: '', allowed_group: pocketidConfig.allowed_group || '' })
}, [pocketidConfig])
const savePocketID = useMutation({
mutationFn: data => api.post('/profile/pocketid-config', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['pocketid-config'] }); setPidSaved(true); setTimeout(() => setPidSaved(false), 3000) },
})
const effectiveMaxHr = profile?.max_heart_rate || profile?.estimated_max_hr
return (
<div className="p-6 max-w-2xl space-y-6">
<h1 className="text-2xl font-bold text-white">Profile & Settings</h1>
{/* HR & Measurements */}
<Section title="Heart Rate & Measurements">
<div className="bg-blue-950/30 border border-blue-900/40 rounded-lg p-3 text-xs text-gray-400">
Max HR is used for accurate zone calculations. Set it from your hardest recorded effort or a lab test.
{effectiveMaxHr && (
<div className="mt-2 text-white">
Effective max HR: <strong>{effectiveMaxHr} bpm</strong>
{!profile?.max_heart_rate && ' (estimated from age)'}
{' · '}Zones: Z1 &lt;{Math.round(effectiveMaxHr * 0.6)}, Z2 {Math.round(effectiveMaxHr * 0.6)}{Math.round(effectiveMaxHr * 0.7)}, Z3 {Math.round(effectiveMaxHr * 0.7)}{Math.round(effectiveMaxHr * 0.8)}, Z4 {Math.round(effectiveMaxHr * 0.8)}{Math.round(effectiveMaxHr * 0.9)}, Z5 &gt;{Math.round(effectiveMaxHr * 0.9)}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Max heart rate (bpm)" hint="Best from a sprint test or hard race">
<Input type="number" value={hrForm.max_heart_rate} placeholder="e.g. 185" min={100} max={250}
onChange={e => setHrForm(f => ({ ...f, max_heart_rate: e.target.value }))} />
</Field>
<Field label="Birth year" hint="Used to estimate max HR if not set above">
<Input type="number" value={hrForm.birth_year} placeholder="e.g. 1988" min={1920} max={2010}
onChange={e => setHrForm(f => ({ ...f, birth_year: e.target.value }))} />
</Field>
<Field label="Height (cm)">
<Input type="number" value={hrForm.height_cm} placeholder="e.g. 178" min={50} max={300}
onChange={e => setHrForm(f => ({ ...f, height_cm: e.target.value }))} />
</Field>
<Field label="Biological sex" hint="Used for VO2 max fitness category thresholds">
<div className="flex gap-2">
{['male', 'female'].map(s => (
<button key={s} type="button"
onClick={() => setHrForm(f => ({ ...f, biological_sex: f.biological_sex === s ? '' : s }))}
className={`flex-1 py-2 rounded-lg text-sm border transition-colors capitalize ${
hrForm.biological_sex === s
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{s}
</button>
))}
</div>
</Field>
</div>
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-gray-800">
<Field label="Goal weight (kg)" hint="Shown as a target line on the weight trend chart">
<Input type="number" value={hrForm.goal_weight_kg} placeholder="e.g. 72" min={20} max={500}
onChange={e => setHrForm(f => ({ ...f, goal_weight_kg: e.target.value }))} />
</Field>
{healthSummary?.latest?.weight_kg && (
<div>
<p className="text-xs text-gray-500 mb-0.5">Current weight (from Garmin)</p>
<span className="text-lg font-semibold text-emerald-400">{healthSummary.latest.weight_kg.toFixed(1)} kg</span>
</div>
)}
</div>
<SaveButton
onClick={() => {
const data = Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, k === 'biological_sex' ? v : parseFloat(v)])
)
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
updateProfile.mutate(data)
}}
loading={updateProfile.isPending}
saved={hrSaved}
/>
{hrZoneRecalc && (
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
)}
</Section>
{/* Password change */}
<Section title="Change Password">
<div className="space-y-3">
<Field label="Current password">
<Input type="password" value={pwForm.current_password}
onChange={e => { setPwForm(f => ({ ...f, current_password: e.target.value })); setPwError('') }} />
</Field>
<Field label="New password (min 8 characters)">
<Input type="password" value={pwForm.new_password}
onChange={e => setPwForm(f => ({ ...f, new_password: e.target.value }))} />
</Field>
<Field label="Confirm new password">
<Input type="password" value={pwForm.confirm}
onChange={e => setPwForm(f => ({ ...f, confirm: e.target.value }))} />
</Field>
{pwError && <p className="text-red-400 text-xs">{pwError}</p>}
</div>
<SaveButton
onClick={() => {
if (pwForm.new_password !== pwForm.confirm) { setPwError('Passwords do not match'); return }
changePassword.mutate({ current_password: pwForm.current_password, new_password: pwForm.new_password })
}}
loading={changePassword.isPending}
saved={pwSaved}
label="Change password"
/>
</Section>
{/* Passkey sign-in — available to all users when PocketID is configured */}
{pocketidAvailable?.available && (
<Section title="🔑 Passkey sign-in">
{user?.has_passkey ? (
<p className="text-sm text-green-400"> A passkey is linked to this account you can sign in with PocketID.</p>
) : (
<>
<p className="text-xs text-gray-500">
Link your PocketID passkey to <strong>this</strong> account so you can sign in with a passkey
instead of being given a separate, empty account.
</p>
<button onClick={handleLinkPasskey}
className="bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium px-4 py-2 rounded-lg transition-colors flex items-center justify-center gap-2">
🔑 Link a passkey to this account
</button>
</>
)}
{showLinked && <p className="text-green-400 text-sm"> Passkey linked to your account.</p>}
</Section>
)}
{/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data
{' '}{formatSyncInterval(garminConfig?.sync_interval_minutes)}. Credentials are encrypted at rest.
</p>
{garminConfig?.connected && (
<div className="flex items-center justify-between bg-green-900/20 border border-green-800/40 rounded-lg px-3 py-2 text-xs">
<span className="text-green-400"> Connected as {garminConfig.email}</span>
<div className="flex items-center gap-3">
{garminConfig.last_sync_at && (
<span className="text-gray-500">
Last sync: {new Date(garminConfig.last_sync_at).toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
)}
{garminConfig.last_sync_status && (
<span className={garminConfig.last_sync_status.startsWith('OK') ? 'text-green-400' : garminConfig.last_sync_status.startsWith('Auth') ? 'text-red-400' : 'text-yellow-400'}>
{garminConfig.last_sync_status}
</span>
)}
</div>
</div>
)}
<div className="space-y-3">
<Field label="Garmin Connect email">
<Input value={gcForm.email} placeholder="you@example.com"
onChange={e => setGcForm(f => ({ ...f, email: e.target.value }))} />
</Field>
<Field label={garminConfig?.connected ? 'Password (leave blank to keep existing)' : 'Password'}>
<Input type="password" value={gcForm.password} placeholder="••••••••"
onChange={e => setGcForm(f => ({ ...f, password: e.target.value }))} />
</Field>
<div className="flex flex-wrap gap-4 pt-1">
{[
['sync_enabled', `Enable automatic sync (${formatSyncInterval(garminConfig?.sync_interval_minutes)})`],
['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => (
<label key={key} className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={gcForm[key]}
onChange={e => setGcForm(f => ({ ...f, [key]: e.target.checked }))}
className="w-4 h-4 accent-blue-500" />
<span className="text-sm text-gray-300">{label}</span>
</label>
))}
</div>
<Field label="Sync lookback days" hint="How far back to pull on the first sync (-1 = all history back to 2010). After that, scheduled syncs only refresh the last day or two. Increasing this value triggers a one-time backfill of the extra history on the next sync.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
<p className="text-yellow-400 text-xs mt-1">Warning: syncing more than 365 days at once may take a long time and could trigger Garmin rate limits.</p>
)}
</Field>
</div>
{gcError && <p className="text-red-400 text-xs">{gcError}</p>}
<div className="flex items-center gap-3 flex-wrap pt-1">
<SaveButton
onClick={() => {
if (!garminConfig?.connected && !gcForm.password) {
setGcError('Password is required for first-time setup')
return
}
const payload = {
...gcForm,
sync_lookback_days: parseInt(gcForm.sync_lookback_days, 10) || 30,
}
if (!payload.password) delete payload.password
saveGarmin.mutate(payload)
}}
loading={saveGarmin.isPending}
saved={gcSaved}
label={garminConfig?.connected ? 'Update' : 'Connect'}
/>
{garminConfig?.connected && (
<>
<button
onClick={triggerSync}
disabled={gcSyncing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors">
{gcSyncing ? 'Syncing…' : '↻ Sync now'}
</button>
<button
onClick={() => { if (confirm('Remove Garmin Connect credentials?')) deleteGarmin.mutate() }}
className="text-red-400 hover:text-red-300 text-sm transition-colors">
Disconnect
</button>
</>
)}
</div>
{gcSyncing && (() => {
const status = syncStatus || ''
const pct = syncProgressPct(status)
const phase = syncPhase(status)
return (
<div className="space-y-2 pt-1">
<div className="flex items-center gap-1 text-xs">
{[['Connect', 0], ['Activities', 1], ['Wellness', 2]].map(([label, idx]) => (
<span key={label} className={`flex items-center gap-1 ${phase >= idx ? 'text-blue-400' : 'text-gray-600'}`}>
{idx > 0 && <span className="text-gray-700"></span>}
{label}
</span>
))}
</div>
<div className="flex items-center gap-2">
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<button
onClick={cancelSync}
disabled={status.startsWith('Cancel')}
className="text-red-400 hover:text-red-300 disabled:opacity-50 text-xs font-medium px-2 py-1 rounded-lg border border-red-500/40 hover:border-red-400 transition-colors whitespace-nowrap">
Cancel
</button>
</div>
<p className="text-xs text-blue-400">
{status || 'Starting sync…'}
</p>
</div>
)
})()}
</Section>
{/* PocketID — admin only */}
{user?.is_admin && (
<Section title="🔑 PocketID Passkey Authentication (Admin)">
<p className="text-xs text-gray-500">
Configure passkey authentication via PocketID. Once set, a "Sign in with passkey" button appears on the login page.
</p>
<div className="space-y-3">
<Field label="PocketID issuer URL" hint="e.g. https://auth.yourdomain.com">
<Input value={pidForm.issuer} placeholder="https://auth.example.com"
onChange={e => setPidForm(f => ({ ...f, issuer: e.target.value }))} />
</Field>
<Field label="Client ID">
<Input value={pidForm.client_id} placeholder="milevault"
onChange={e => setPidForm(f => ({ ...f, client_id: e.target.value }))} />
</Field>
<Field label="Client secret" hint="Leave blank to keep existing secret">
<Input type="password" value={pidForm.client_secret} placeholder="••••••••"
onChange={e => setPidForm(f => ({ ...f, client_secret: e.target.value }))} />
</Field>
<Field label="Allowed PocketID group" hint="Only members of this PocketID group may sign in. Leave blank to allow all.">
<Input value={pidForm.allowed_group} placeholder="e.g. milevault-users"
onChange={e => setPidForm(f => ({ ...f, allowed_group: e.target.value }))} />
</Field>
{pocketidConfig?.enabled && (
<p className="text-xs text-green-400"> PocketID is currently active</p>
)}
</div>
<SaveButton
onClick={() => savePocketID.mutate(pidForm)}
loading={savePocketID.isPending}
saved={pidSaved}
label="Save PocketID config"
/>
</Section>
)}
</div>
)
}
+205 -45
View File
@@ -1,19 +1,24 @@
import { useState } from 'react'
import { useState, Fragment } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { format } from 'date-fns'
import api from '../utils/api'
import { formatDuration, formatDate } from '../utils/format'
import { formatDuration, formatDate, formatPace, formatDistance } from '../utils/format'
import RouteMiniMap from '../components/ui/RouteMiniMap'
const SPORTS = ['running', 'cycling', 'swimming']
const SPORTS = ['running', 'cycling']
const DISTANCE_ORDER = [
'400m', '800m', '1k', '1 mile', '3k', '5k', '10k',
'Half marathon', 'Marathon', '50k', '100k',
]
export default function RecordsPage() {
const TABS = ['Distance PRs', 'Route Records', 'Segments']
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function DistancePRs() {
const [sport, setSport] = useState('running')
const [selectedDistance, setSelectedDistance] = useState(null)
@@ -31,7 +36,6 @@ export default function RecordsPage() {
enabled: !!selectedDistance,
})
// Sort by standard distance order
const sortedRecords = records?.slice().sort((a, b) => {
const ai = DISTANCE_ORDER.indexOf(a.distance_label)
const bi = DISTANCE_ORDER.indexOf(b.distance_label)
@@ -39,10 +43,7 @@ export default function RecordsPage() {
})
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Personal Records</h1>
{/* Sport selector */}
<div className="space-y-4">
<div className="flex gap-2">
{SPORTS.map(s => (
<button
@@ -67,7 +68,6 @@ export default function RecordsPage() {
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Records table */}
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
@@ -84,9 +84,7 @@ export default function RecordsPage() {
key={rec.id}
onClick={() => setSelectedDistance(rec.distance_label)}
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
selectedDistance === rec.distance_label
? 'bg-blue-900/20'
: 'hover:bg-gray-800/40'
selectedDistance === rec.distance_label ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
}`}
>
<td className="px-4 py-3 font-medium text-white">{rec.distance_label}</td>
@@ -111,52 +109,29 @@ export default function RecordsPage() {
</table>
</div>
{/* Progress chart */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
{selectedDistance && history ? (
<>
<h3 className="text-sm font-medium text-gray-300 mb-1">
{selectedDistance} progression
</h3>
<h3 className="text-sm font-medium text-gray-300 mb-1">{selectedDistance} progression</h3>
<p className="text-xs text-gray-600 mb-4">Lower is faster</p>
{history.length > 1 ? (
<ResponsiveContainer width="100%" height={220}>
<LineChart
data={history.map(h => ({
date: h.achieved_at,
time: h.duration_s,
}))}
data={history.map(h => ({ date: h.achieved_at, time: h.duration_s }))}
margin={{ top: 4, right: 4, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')}
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={40}
tickFormatter={formatDuration}
reversed
/>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM yy')} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={40} tickFormatter={formatDuration} />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatDuration(v), 'Time']}
/>
<Line
type="monotone"
dataKey="time"
stroke="#fbbf24"
strokeWidth={2}
dot={{ fill: '#fbbf24', r: 4 }}
isAnimationActive={false}
/>
<Line type="monotone" dataKey="time" stroke="#fbbf24" strokeWidth={2}
dot={{ fill: '#fbbf24', r: 4 }} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
) : (
@@ -175,3 +150,188 @@ export default function RecordsPage() {
</div>
)
}
function RouteRecords() {
const navigate = useNavigate()
const { data: records, isLoading } = useQuery({
queryKey: ['route-records'],
queryFn: () => api.get('/records/routes').then(r => r.data),
})
if (isLoading) return <p className="text-gray-500 text-sm">Loading</p>
if (!records?.length) return (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🗺</p>
<p>No route records yet create named routes and complete activities on them</p>
</div>
)
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="px-3 py-3" />
<th className="text-left px-3 py-3 font-medium">Route</th>
<th className="text-right px-3 py-3 font-medium">Distance</th>
<th className="text-right px-3 py-3 font-medium">Best time</th>
<th className="text-right px-3 py-3 font-medium">Pace</th>
<th className="text-right px-3 py-3 font-medium">Date</th>
</tr>
</thead>
<tbody>
{records.map(rec => (
<tr
key={rec.route_id}
onClick={() => navigate(`/activities/${rec.activity_id}`)}
className="border-b border-gray-800/50 hover:bg-gray-800/40 transition-colors cursor-pointer"
>
<td className="px-3 py-2">
<RouteMiniMap polyline={rec.reference_polyline} sportType={rec.sport_type} width={72} height={50} />
</td>
<td className="px-3 py-3 font-medium text-white">
<span className="capitalize text-xs text-gray-500 mr-2">{rec.sport_type}</span>
{rec.route_name}
</td>
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatDistance(rec.distance_m)}
</td>
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
{formatDuration(rec.duration_s)}
</td>
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatPace(rec.avg_speed_ms, rec.sport_type)}
</td>
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatDate(rec.start_time)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function SegmentLeaderboard({ segmentId }) {
const { data } = useQuery({
queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
})
if (!data) return <p className="text-xs text-gray-600 py-2 px-4">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2 px-4">No efforts yet still matching.</p>
return (
<div className="px-4 py-2 space-y-0.5 bg-gray-950/40">
{data.leaderboard.map((e, i) => (
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
<span className="w-6 text-right">{MEDALS[e.rank] || i + 1}</span>
<span className="font-mono text-gray-200 w-16 text-right">{formatDuration(e.duration_s)}</span>
<Link to={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">
{e.activity_name}
</Link>
{e.date && <span className="text-gray-600">{formatDate(e.date)}</span>}
</div>
))}
</div>
)
}
function SegmentRecords() {
const [open, setOpen] = useState(null)
const { data: segments, isLoading } = useQuery({
queryKey: ['segments'],
queryFn: () => api.get('/segments/').then(r => r.data),
})
if (isLoading) return <p className="text-gray-500 text-sm">Loading</p>
if (!segments?.length) return (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">🏅</p>
<p>No segments yet create one from an activity's detail page</p>
</div>
)
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800 bg-gray-900/80">
<th className="px-3 py-3" />
<th className="text-left px-3 py-3 font-medium">Segment</th>
<th className="text-right px-3 py-3 font-medium">Distance</th>
<th className="text-right px-3 py-3 font-medium">Best time</th>
<th className="text-right px-3 py-3 font-medium">Efforts</th>
</tr>
</thead>
<tbody>
{segments.map(seg => (
<Fragment key={seg.id}>
<tr
onClick={() => setOpen(open === seg.id ? null : seg.id)}
className={`border-b border-gray-800/50 cursor-pointer transition-colors ${
open === seg.id ? 'bg-blue-900/20' : 'hover:bg-gray-800/40'
}`}
>
<td className="px-3 py-2">
<RouteMiniMap polyline={seg.polyline} sportType={seg.sport_type} width={72} height={50} />
</td>
<td className="px-3 py-3 font-medium text-white">
{seg.sport_type && <span className="capitalize text-xs text-gray-500 mr-2">{seg.sport_type}</span>}
{seg.name}
</td>
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{formatDistance(seg.distance_m)}
</td>
<td className="px-3 py-3 text-right font-mono text-yellow-400 font-semibold">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</td>
<td className="px-3 py-3 text-right text-gray-400 text-xs">
{seg.effort_count}
</td>
</tr>
{open === seg.id && (
<tr>
<td colSpan={5} className="p-0 border-b border-gray-800/50">
<SegmentLeaderboard segmentId={seg.id} />
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)
}
export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs')
return (
<div className="p-6 space-y-6">
<h1 className="text-2xl font-bold text-white">Records</h1>
<div className="flex gap-2 flex-wrap">
{TABS.map(t => (
<button
key={t}
onClick={() => setTab(t)}
className={`text-sm px-4 py-1.5 rounded-full border transition-colors ${
tab === t
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}
>
{t}
</button>
))}
</div>
{tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />}
{tab === 'Segments' && <SegmentRecords />}
</div>
)
}
+297 -147
View File
@@ -1,7 +1,235 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { formatDistance, formatDuration, formatDate, formatPace } from '../utils/format'
import ActivityMap from '../components/activity/ActivityMap'
import { formatDistance, formatDuration, formatDate, formatPace, sportIcon } from '../utils/format'
// Decode Google encoded polyline to [[lat,lng], ...]
function decodePolyline(encoded) {
if (!encoded) return []
const points = []
let idx = 0, lat = 0, lng = 0
while (idx < encoded.length) {
let shift = 0, result = 0, byte
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
lat += result & 1 ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do { byte = encoded.charCodeAt(idx++) - 63; result |= (byte & 0x1f) << shift; shift += 5 } while (byte >= 0x20)
lng += result & 1 ? ~(result >> 1) : result >> 1
points.push([lat / 1e5, lng / 1e5])
}
return points
}
function RouteMap({ polyline, className = '', sportType = '' }) {
const pts = decodePolyline(polyline)
if (pts.length < 2) return (
<div className={`bg-gray-800 rounded flex items-center justify-center text-gray-600 text-xs ${className}`}>
no track
</div>
)
const t = (sportType || '').toLowerCase()
const stroke = (t.includes('cycl') || t.includes('bike') || t.includes('ride')) ? '#f97316' : '#3b82f6'
const lats = pts.map(p => p[0]), lngs = pts.map(p => p[1])
const minLat = Math.min(...lats), maxLat = Math.max(...lats)
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs)
const rangeL = maxLng - minLng || 1e-5
const rangeA = maxLat - minLat || 1e-5
const pad = 4
const w = 100, h = 60
const toX = lng => pad + ((lng - minLng) / rangeL) * (w - pad * 2)
const toY = lat => pad + ((maxLat - lat) / rangeA) * (h - pad * 2)
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p[1]).toFixed(1)},${toY(p[0]).toFixed(1)}`).join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className={`bg-gray-800 rounded ${className}`} xmlns="http://www.w3.org/2000/svg">
<path d={d} fill="none" stroke={stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
function routeSportStyle(sportType) {
const t = (sportType || '').toLowerCase()
if (t.includes('cycl') || t.includes('bike') || t.includes('ride'))
return { border: 'border-orange-500/50', selected: 'border-orange-500 bg-orange-900/20', accent: 'text-orange-400' }
if (t.includes('run') || t.includes('jog') || t.includes('walk'))
return { border: 'border-blue-500/30', selected: 'border-blue-500 bg-blue-900/20', accent: 'text-blue-400' }
return { border: 'border-gray-800', selected: 'border-gray-500 bg-gray-800/50', accent: 'text-gray-400' }
}
const MEDALS = ['🥇', '🥈', '🥉']
function RouteDetail({ selected, setSelected }) {
const qc = useQueryClient()
const [merging, setMerging] = useState(false)
const [mergeTarget, setMergeTarget] = useState('')
const [editingName, setEditingName] = useState(false)
const [nameInput, setNameInput] = useState(selected.name)
const { data: routes } = useQuery({
queryKey: ['routes'],
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: routeActivities } = useQuery({
queryKey: ['route-activities', selected.id],
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
})
const renameRoute = useMutation({
mutationFn: name => api.patch(`/routes/${selected.id}`, { name }).then(r => r.data),
onSuccess: updated => {
qc.invalidateQueries({ queryKey: ['routes'] })
setSelected(updated)
setEditingName(false)
},
})
const mergeRoute = useMutation({
mutationFn: ({ into, from }) => api.post(`/routes/${into}/merge/${from}`).then(r => r.data),
onSuccess: updated => {
qc.invalidateQueries({ queryKey: ['routes'] })
qc.invalidateQueries({ queryKey: ['route-activities', updated.id] })
setMerging(false)
setMergeTarget('')
setSelected(updated)
},
})
const deleteRoute = useMutation({
mutationFn: id => api.delete(`/routes/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['routes'] })
setSelected(null)
},
})
const fastest = routeActivities?.[0]
const crTime = fastest?.duration_s
const otherRoutes = (routes || []).filter(r => r.id !== selected.id && r.sport_type === selected.sport_type)
return (
<div className="col-span-full bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="flex gap-4 items-start min-w-0">
<div className="w-56 h-40 flex-shrink-0 rounded-lg overflow-hidden border border-gray-800">
{selected.reference_polyline
? <ActivityMap polyline={selected.reference_polyline} sportType={selected.sport_type} colorMode="solid" />
: <RouteMap polyline={selected.reference_polyline} className="w-full h-full" sportType={selected.sport_type} />}
</div>
<div className="min-w-0">
{editingName ? (
<div className="flex items-center gap-2">
<input
value={nameInput}
onChange={e => setNameInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && nameInput.trim()) renameRoute.mutate(nameInput.trim()) }}
autoFocus
className="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button onClick={() => renameRoute.mutate(nameInput.trim())} disabled={!nameInput.trim() || renameRoute.isPending}
className="text-xs bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white px-2 py-1 rounded-lg">Save</button>
<button onClick={() => { setEditingName(false); setNameInput(selected.name) }}
className="text-xs text-gray-400 hover:text-white px-1">Cancel</button>
</div>
) : (
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold text-white truncate">{selected.name}</h2>
<button onClick={() => { setNameInput(selected.name); setEditingName(true) }}
className="text-gray-500 hover:text-white text-sm" title="Rename route"></button>
</div>
)}
<div className="flex flex-wrap gap-2 mt-1 text-xs text-gray-500">
{selected.sport_type && <span className="capitalize">{selected.sport_type}</span>}
<span>{formatDistance(selected.distance_m)}</span>
{selected.auto_detected && (
<span className="text-blue-400 border border-blue-700/40 px-1.5 py-0.5 rounded-full">Auto-detected</span>
)}
</div>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<button onClick={() => { setMerging(m => !m); setMergeTarget('') }}
className="text-xs bg-gray-800 hover:bg-gray-700 text-gray-300 px-3 py-1.5 rounded-lg transition-colors">
Merge
</button>
<button
onClick={() => { if (confirm(`Delete "${selected.name}"? Activities will be unlinked.`)) deleteRoute.mutate(selected.id) }}
className="text-xs text-red-500 hover:text-red-400 px-2 py-1.5 rounded-lg transition-colors">
Delete
</button>
</div>
</div>
{/* Merge panel */}
{merging && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 space-y-2">
<p className="text-xs text-yellow-400 font-medium">Merge another route into this one</p>
<p className="text-xs text-gray-500">All activities from the selected route will be moved here, then the other route will be deleted.</p>
<div className="flex gap-2">
<select value={mergeTarget} onChange={e => setMergeTarget(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-yellow-500">
<option value="">Select route to merge in</option>
{otherRoutes.map(r => (
<option key={r.id} value={r.id}>{r.name} ({formatDistance(r.distance_m)})</option>
))}
</select>
<button
disabled={!mergeTarget || mergeRoute.isPending}
onClick={() => mergeRoute.mutate({ into: selected.id, from: parseInt(mergeTarget) })}
className="bg-yellow-600 hover:bg-yellow-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Merge
</button>
</div>
{otherRoutes.length === 0 && (
<p className="text-xs text-gray-600">No other {selected.sport_type} routes to merge with.</p>
)}
</div>
)}
{/* Podium */}
{routeActivities?.length > 0 && (
<div className="grid grid-cols-3 gap-3">
{routeActivities.slice(0, 3).map((act, i) => (
<Link key={act.id} to={`/activities/${act.id}`}
className="bg-gray-800/50 hover:bg-gray-800 rounded-lg p-3 text-center transition-colors">
<p className="text-xl">{MEDALS[i]}</p>
<p className="font-mono text-lg font-bold text-white">{formatDuration(act.duration_s)}</p>
<p className="text-xs text-gray-500">{formatDate(act.start_time)}</p>
{i > 0 && crTime != null && (
<p className="text-xs text-red-400">+{formatDuration(act.duration_s - crTime)}</p>
)}
</Link>
))}
</div>
)}
{/* All completions */}
<h3 className="text-sm font-medium text-gray-400">All completions ({routeActivities?.length ?? 0})</h3>
<div className="space-y-1">
{routeActivities?.map((act, i) => {
const delta = crTime != null ? act.duration_s - crTime : null
return (
<Link key={act.id} to={`/activities/${act.id}`}
className="flex items-center gap-4 px-2 py-2 rounded-lg hover:bg-gray-800/60 transition-colors text-sm group">
<span className="text-gray-600 w-5 text-right flex-shrink-0">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className={`font-mono text-xs w-16 text-right ${i === 0 ? 'text-yellow-400' : 'text-red-400'}`}>
{i === 0 ? 'CR' : delta != null ? `+${formatDuration(delta)}` : ''}
</span>
<span className="text-gray-500 w-20 text-right">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate
? <span className="text-red-400 text-xs w-16 text-right">{Math.round(act.avg_heart_rate)} bpm</span>
: <span className="w-16" />}
<span className="text-gray-700 group-hover:text-gray-400 text-xs transition-colors"></span>
</Link>
)
})}
</div>
</div>
)
}
export default function RoutesPage() {
const [selected, setSelected] = useState(null)
@@ -14,192 +242,114 @@ export default function RoutesPage() {
queryFn: () => api.get('/routes/').then(r => r.data),
})
const { data: routeActivities } = useQuery({
queryKey: ['route-activities', selected?.id],
queryFn: () => api.get(`/routes/${selected.id}/activities`).then(r => r.data),
enabled: !!selected,
})
// Sort by most completions first
const sortedRoutes = [...(routes || [])].sort((a, b) => (b.activity_count || 0) - (a.activity_count || 0))
const { data: segments } = useQuery({
queryKey: ['route-segments', selected?.id],
queryFn: () => api.get(`/routes/${selected.id}/segments`).then(r => r.data),
enabled: !!selected,
const { data: recentActivities } = useQuery({
queryKey: ['recent-activities-for-route'],
queryFn: () => api.get('/routes/recent-activities').then(r => r.data),
enabled: showCreate,
})
const createRoute = useMutation({
mutationFn: (data) => api.post('/routes/', data).then(r => r.data),
onSuccess: () => {
mutationFn: data => api.post('/routes/', data).then(r => r.data),
onSuccess: route => {
qc.invalidateQueries({ queryKey: ['routes'] })
setShowCreate(false)
setNewRoute({ name: '', activity_id: '' })
setSelected(route)
},
})
const fastest = routeActivities?.[0]
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<button
onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
<div>
<h1 className="text-2xl font-bold text-white">Named Routes</h1>
<p className="text-xs text-gray-500 mt-1">
Routes are auto-detected when you run the same path twice. You can also create them manually.
</p>
</div>
<button onClick={() => setShowCreate(true)}
className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 rounded-lg transition-colors">
+ New route
</button>
</div>
{/* Create route modal */}
{/* Create route panel */}
{showCreate && (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-5 space-y-4">
<h3 className="text-sm font-semibold text-white">Create named route</h3>
<p className="text-xs text-gray-500">
Pick an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
Select an activity to use as the reference GPS track. Future activities on the same route will be linked automatically.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Route name</label>
<input
value={newRoute.name}
onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
<input value={newRoute.name} onChange={e => setNewRoute(r => ({ ...r, name: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g. Morning park loop"
/>
placeholder="e.g. Morning park loop" />
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Reference activity ID</label>
<input
type="number"
value={newRoute.activity_id}
onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Activity ID"
/>
<label className="text-xs text-gray-400 mb-1 block">Reference activity (last 2 weeks)</label>
<select value={newRoute.activity_id} onChange={e => setNewRoute(r => ({ ...r, activity_id: e.target.value }))}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">Select an activity</option>
{recentActivities?.map(a => (
<option key={a.id} value={a.id}>
{sportIcon(a.sport_type)} {a.name} {formatDistance(a.distance_m)} on {formatDate(a.start_time)}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !newRoute.activity_id}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
<button onClick={() => createRoute.mutate({ ...newRoute, activity_id: parseInt(newRoute.activity_id) })}
disabled={!newRoute.name || !newRoute.activity_id || createRoute.isPending}
className="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 text-white text-sm px-4 py-2 rounded-lg transition-colors">
Create
</button>
<button
onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors"
>
<button onClick={() => setShowCreate(false)}
className="text-gray-400 hover:text-white text-sm px-4 py-2 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Route list */}
<div className="space-y-2">
{routes?.length === 0 && (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
<p className="text-sm">No named routes yet</p>
</div>
)}
{routes?.map(route => (
<button
key={route.id}
onClick={() => setSelected(route)}
className={`w-full text-left p-4 rounded-xl border transition-all ${
selected?.id === route.id
? 'bg-blue-900/20 border-blue-700'
: 'bg-gray-900 border-gray-800 hover:border-gray-600'
}`}
>
<p className="font-medium text-white">{route.name}</p>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
<span>{formatDistance(route.distance_m)}</span>
{route.sport_type && <span className="capitalize">{route.sport_type}</span>}
<span>{formatDate(route.created_at)}</span>
</div>
</button>
))}
{/* Route tile grid — selected route's detail expands inline under its row */}
{routes?.length === 0 && !showCreate ? (
<div className="text-center py-12 text-gray-600">
<p className="text-3xl mb-2">🗺</p>
<p className="text-sm">No named routes yet</p>
<p className="text-xs mt-1">Routes are created automatically when you repeat a run, or create one manually above.</p>
</div>
{/* Route detail */}
{selected && (
<div className="lg:col-span-2 space-y-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h2 className="text-lg font-semibold text-white mb-1">{selected.name}</h2>
{selected.description && (
<p className="text-sm text-gray-400 mb-3">{selected.description}</p>
)}
{/* CR */}
{fastest && (
<div className="bg-yellow-900/20 border border-yellow-700/40 rounded-lg p-3 mb-4">
<p className="text-xs text-yellow-600 mb-1">Course record</p>
<div className="flex items-center gap-4">
<span className="text-xl font-bold text-yellow-400">
{formatDuration(fastest.duration_s)}
</span>
<span className="text-sm text-gray-400">
{formatDate(fastest.start_time)} · {formatPace(fastest.avg_speed_ms, selected.sport_type)}
</span>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{sortedRoutes.map(route => {
const style = routeSportStyle(route.sport_type)
const isSelected = selected?.id === route.id
return [
<button key={route.id}
onClick={() => setSelected(isSelected ? null : route)}
className={`text-left rounded-xl border p-2 transition-all ${
isSelected ? style.selected : `bg-gray-900 ${style.border} hover:border-gray-600`
}`}>
<RouteMap polyline={route.reference_polyline} className="w-full h-20" sportType={route.sport_type} />
<p className="text-xs font-medium text-white mt-2 truncate">{route.name}</p>
<div className="flex items-center justify-between mt-0.5 gap-1">
<span className="text-xs text-gray-500">{formatDistance(route.distance_m)}</span>
{route.activity_count > 0 && (
<span className={`text-xs font-medium ${style.accent}`}>{route.activity_count}×</span>
)}
</div>
)}
{/* All runs on route */}
<h3 className="text-sm font-medium text-gray-400 mb-2">
All runs ({routeActivities?.length ?? 0})
</h3>
<div className="space-y-2">
{routeActivities?.map((act, i) => (
<div
key={act.id}
className="flex items-center gap-4 py-2 border-b border-gray-800/50 text-sm"
>
<span className="text-gray-600 w-5 text-right">{i + 1}</span>
<span className="text-gray-400 flex-1">{formatDate(act.start_time)}</span>
<span className="font-mono text-white font-medium">{formatDuration(act.duration_s)}</span>
<span className="text-gray-500">{formatPace(act.avg_speed_ms, selected.sport_type)}</span>
{act.avg_heart_rate && (
<span className="text-red-400 text-xs">{Math.round(act.avg_heart_rate)} bpm</span>
)}
{i === 0 && (
<span className="text-xs bg-yellow-900/40 text-yellow-400 px-2 py-0.5 rounded-full border border-yellow-700/40">
CR
</span>
)}
</div>
))}
</div>
</div>
{/* Segments */}
{segments && segments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<div className="space-y-2">
{segments.map(seg => (
<div key={seg.id} className="flex items-center justify-between py-2 border-b border-gray-800/50">
<div>
<p className="text-sm font-medium text-white">{seg.name}</p>
{seg.description && (
<p className="text-xs text-gray-500">{seg.description}</p>
)}
</div>
<div className="text-xs text-gray-400 text-right">
<p>{formatDistance(seg.start_distance_m)} {formatDistance(seg.end_distance_m)}</p>
<p>{formatDistance(seg.end_distance_m - seg.start_distance_m)}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{route.auto_detected && <span className="text-xs text-gray-600">auto</span>}
</button>,
isSelected && <RouteDetail key={`detail-${route.id}`} selected={selected} setSelected={setSelected} />,
]
})}
</div>
)}
</div>
)
}
+66 -8
View File
@@ -1,10 +1,45 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
function UploadZone({ title, description, accept, endpoint, icon }) {
const [tasks, setTasks] = useState([])
const queryClient = useQueryClient()
const intervalsRef = useRef({})
const pollTask = useCallback((taskId) => {
if (intervalsRef.current[taskId]) return
const intervalId = setInterval(async () => {
try {
const { data } = await api.get(`/upload/task/${taskId}`)
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId])
delete intervalsRef.current[taskId]
// A successful task may still have skipped the file (e.g. a duplicate or
// an activity that looks like vehicle travel) — surface the reason.
const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped'
setTasks(ts => ts.map(t =>
t.task_id === taskId
? { ...t,
status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done',
reason: skipped ? data.result?.reason : t.reason }
: t
))
if (data.status === 'SUCCESS' && !skipped) {
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
}
}
} catch { /* ignore transient poll errors */ }
}, 2000)
intervalsRef.current[taskId] = intervalId
}, [queryClient])
useEffect(() => {
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
}, [])
const upload = useMutation({
mutationFn: async (file) => {
@@ -16,7 +51,15 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
return { file: file.name, ...data }
},
onSuccess: (data) => {
setTasks(t => [...t, { ...data, status: 'queued' }])
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
setTasks(t => [...t, task])
if (data.task_id) {
pollTask(data.task_id)
}
},
onError: (err, file) => {
const reason = err.response?.data?.detail || 'Upload failed'
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
},
})
@@ -30,6 +73,14 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
multiple: true,
})
function StatusBadge({ status }) {
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'skipped') return <span className="ml-2 text-amber-400"> Skipped</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span>
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
@@ -68,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{tasks.length > 0 && (
<div className="mt-4 space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<StatusBadge status={task.status} />
</div>
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
{task.reason}
</p>
)}
<span className="ml-2 text-green-400"> Queued</span>
</div>
))}
</div>
+98
View File
@@ -0,0 +1,98 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
export default function UsersPage() {
const qc = useQueryClient()
const { user: me } = useAuthStore()
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => api.get('/users/').then(r => r.data),
})
const setAdmin = useMutation({
mutationFn: ({ id, is_admin }) => api.patch(`/users/${id}`, { is_admin }).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to update user'),
})
const deleteUser = useMutation({
mutationFn: id => api.delete(`/users/${id}`).then(r => r.data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
onError: e => alert(e.response?.data?.detail || 'Failed to delete user'),
})
const handleDelete = u => {
if (confirm(`Delete ${u.username} and ALL of their data (activities, routes, health, records)? This cannot be undone.`)) {
deleteUser.mutate(u.id)
}
}
return (
<div className="p-6 max-w-3xl space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Users</h1>
<p className="text-xs text-gray-500 mt-1">
New users are created in PocketID and provisioned automatically on first passkey sign-in.
Each user's data is fully separate.
</p>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
{isLoading ? (
<p className="p-5 text-sm text-gray-500">Loading…</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-gray-500 border-b border-gray-800">
<th className="px-4 py-3 font-medium">User</th>
<th className="px-4 py-3 font-medium">Sign-in</th>
<th className="px-4 py-3 font-medium text-right">Activities</th>
<th className="px-4 py-3 font-medium text-center">Admin</th>
<th className="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{users?.map(u => {
const isMe = u.id === me?.id
return (
<tr key={u.id} className="border-b border-gray-800/60 last:border-0">
<td className="px-4 py-3">
<div className="text-white">@{u.username}{isMe && <span className="text-gray-500"> (you)</span>}</div>
{u.email && <div className="text-xs text-gray-500">{u.email}</div>}
</td>
<td className="px-4 py-3 text-gray-400">
{u.has_passkey ? '🔑 Passkey' : '🔒 Password'}
</td>
<td className="px-4 py-3 text-right text-gray-300">{u.activity_count}</td>
<td className="px-4 py-3 text-center">
<input
type="checkbox"
checked={u.is_admin}
disabled={isMe || setAdmin.isPending}
onChange={e => setAdmin.mutate({ id: u.id, is_admin: e.target.checked })}
className="w-4 h-4 accent-blue-500 disabled:opacity-40"
title={isMe ? "You can't change your own admin status" : ''}
/>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleDelete(u)}
disabled={isMe || deleteUser.isPending}
className="text-red-400 hover:text-red-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs transition-colors"
title={isMe ? "You can't delete your own account" : ''}
>
Delete
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)
}
+36
View File
@@ -0,0 +1,36 @@
// Shared Body Battery rendering helpers, used by both the Health page chart and
// the Dashboard mini chart so they colour bars identically.
// Colour per inferred state (matches the Health page legend)
export const BB_INFERRED_COLOR = {
sleep: '#4f46e5',
rest: '#0d9488',
activity: '#f97316',
stable: '#374151',
}
export const BB_INFERRED_LABEL = {
sleep: 'Sleep',
rest: 'Rest',
activity: 'Active/Stress',
stable: 'Stable',
}
// Colour a single battery level by magnitude (used for the headline number)
export function bbLevelColor(level) {
if (level == null) return '#6b7280'
if (level >= 75) return '#3b82f6'
if (level >= 50) return '#22c55e'
if (level >= 25) return '#f59e0b'
return '#ef4444'
}
// Classify a sample as sleep / rest (charging) / activity (draining) / stable.
export function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
const inSleep = sleepStartMs != null && sleepEndMs != null && tsMs >= sleepStartMs && tsMs <= sleepEndMs
if (inSleep) return 'sleep'
if (prevLevel != null) {
if (level > prevLevel + 0.3) return 'rest'
if (level < prevLevel - 0.3) return 'activity'
}
return 'stable'
}
+14 -4
View File
@@ -10,8 +10,7 @@ export function formatDuration(seconds) {
export function formatPace(speedMs, sportType = 'running') {
if (!speedMs || speedMs <= 0) return '--'
if (sportType === 'cycling') {
const kph = speedMs * 3.6
return `${kph.toFixed(1)} km/h`
return `${(speedMs * 3.6).toFixed(1)} km/h`
}
const secsPerKm = 1000 / speedMs
const mins = Math.floor(secsPerKm / 60)
@@ -62,6 +61,17 @@ export function formatDateTime(dateStr) {
})
}
export function formatCadence(value, sportType) {
if (!value) return '--'
// Garmin stores running cadence as steps per minute / 2 (one foot)
// We need to double it to get total steps per minute (both feet)
if (sportType === 'running' || sportType === 'hiking' || sportType === 'walking') {
return `${Math.round(value * 2)} spm`
}
// Cycling is already in rpm
return `${Math.round(value)} rpm`
}
export function hrZoneColor(zone) {
const colors = { z1: '#60a5fa', z2: '#34d399', z3: '#fbbf24', z4: '#f97316', z5: '#f43f5e' }
return colors[zone] || '#9ca3af'
@@ -69,7 +79,7 @@ export function hrZoneColor(zone) {
export function sportIcon(sportType) {
const icons = {
running: '🏃', cycling: '🚴', swimming: '🏊', hiking: '🥾',
running: '🏃', cycling: '🚴', hiking: '🥾',
walking: '🚶', other: '⚡',
}
return icons[sportType?.toLowerCase()] || '⚡'
@@ -77,7 +87,7 @@ export function sportIcon(sportType) {
export function sportColor(sportType) {
const colors = {
running: '#3b82f6', cycling: '#f97316', swimming: '#06b6d4',
running: '#3b82f6', cycling: '#f97316',
hiking: '#84cc16', walking: '#a78bfa', other: '#6b7280',
}
return colors[sportType?.toLowerCase()] || '#6b7280'
+36
View File
@@ -0,0 +1,36 @@
// Project a lat/lng onto the activity's GPS track, returning the nearest point
// *along the line* (not just the nearest recorded sample) with an interpolated
// cumulative distance. This gives smooth snapping anywhere along the route rather
// than jumping between logged GPS points.
//
// Coordinates are treated as planar (lat/lng as y/x). At the scale of a single
// activity this is accurate enough for visual snapping and segment selection.
export function projectToTrack(points, lat, lng) {
const valid = points.filter(p => p.latitude != null && p.longitude != null)
if (valid.length === 0) return null
if (valid.length === 1) {
return { latitude: valid[0].latitude, longitude: valid[0].longitude, distance_m: valid[0].distance_m ?? 0 }
}
let best = null
let bestD = Infinity
for (let i = 0; i < valid.length - 1; i++) {
const a = valid[i]
const b = valid[i + 1]
const ax = a.longitude, ay = a.latitude
const dx = b.longitude - ax, dy = b.latitude - ay
const len2 = dx * dx + dy * dy
let t = len2 > 0 ? (((lng - ax) * dx + (lat - ay) * dy) / len2) : 0
t = Math.max(0, Math.min(1, t))
const px = ax + t * dx
const py = ay + t * dy
const d = (lat - py) ** 2 + (lng - px) ** 2
if (d < bestD) {
bestD = d
const da = a.distance_m, db = b.distance_m
const dist = (da != null && db != null) ? da + (db - da) * t : (da ?? db ?? null)
best = { latitude: py, longitude: px, distance_m: dist }
}
}
return best
}
+36
View File
@@ -0,0 +1,36 @@
# FitTracker configuration
# Copy this file to .env and edit, OR just run: bash install.sh
# install.sh auto-generates all secrets for you.
# ── Required ──────────────────────────────────────────────────────────────────
# Login for the web interface
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# Security: generate with: openssl rand -hex 32
SECRET_KEY=changeme_run_openssl_rand_hex_32
# Database password
DB_PASSWORD=changeme
DB_USER=fittracker
# Redis password
REDIS_PASSWORD=changeme
# ── Optional ──────────────────────────────────────────────────────────────────
# Port to expose (default: 80)
HTTP_PORT=80
# Mapbox token for satellite map tiles — free at mapbox.com
# Leave blank to use OpenStreetMap (CartoDB dark tiles, no key needed)
VITE_MAPBOX_TOKEN=
# PocketID passkey authentication — leave blank to use local auth only
# See: https://github.com/pocket-id/pocket-id
POCKETID_ISSUER=
POCKETID_CLIENT_ID=
POCKETID_CLIENT_SECRET=
# Restrict sign-in to members of this PocketID group (leave blank to allow all)
POCKETID_ALLOWED_GROUP=
@@ -0,0 +1,83 @@
name: Build and push images
on:
push:
branches: [main]
workflow_dispatch: # allow manual trigger from Gitea UI
env:
REGISTRY: ${{ vars.GITEA_URL }} # e.g. gitea.yourdomain.com — set in repo Settings → Variables
OWNER: ${{ gitea.repository_owner }}
jobs:
build-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-backend:${{ gitea.sha }}
build-worker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Build and push worker
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile.worker
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-worker:${{ gitea.sha }}
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.PACKAGE_TOKEN }}
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:latest
${{ env.REGISTRY }}/${{ env.OWNER }}/milevault-frontend:${{ gitea.sha }}
build-args: |
VITE_API_URL=/api
VITE_MAPBOX_TOKEN=
+12
View File
@@ -0,0 +1,12 @@
.env
node_modules/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.DS_Store
*.sql.bak
db_data/
redis_data/
file_data/
+153
View File
@@ -0,0 +1,153 @@
# MileVault
Self-hosted fitness tracking — Garmin & Strava import, maps, health trends, personal records.
---
## For users — deploy with two files
Once this repo is pushed to Gitea and the Actions workflow has run once, anyone on your network only needs **two files** to run MileVault. No source code, no cloning.
```bash
mkdir milevault && cd milevault
# Download the two deployment files
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/docker-compose.deploy.yml
curl -O https://gitea.yourdomain.com/yourusername/milevault/raw/branch/main/nginx.conf
# Start (images pulled automatically from your Gitea registry)
docker compose -f docker-compose.deploy.yml up -d
```
Default login: `admin` / `admin`
**Change `ADMIN_PASSWORD` in a `.env` file before exposing to a network** (see Configuration below).
To update when a new version is pushed to Gitea:
```bash
docker compose -f docker-compose.deploy.yml pull
docker compose -f docker-compose.deploy.yml up -d
```
---
## For developers — first-time Gitea setup
### 1. Enable the Gitea container registry
In your Gitea instance (`app.ini` or admin panel):
```ini
[packages]
ENABLED = true
```
Restart Gitea. The registry is then available at `gitea.yourdomain.com`.
### 2. Create a Gitea Actions runner
Gitea Actions needs a runner on your server:
```bash
# On the server that will build images
docker run -d \
--name gitea-runner \
--restart always \
-v /var/run/docker.sock:/var/run/docker.sock \
-v gitea-runner-data:/data \
-e GITEA_INSTANCE_URL=https://gitea.yourdomain.com \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<token from Gitea → Settings → Runners> \
gitea/act_runner:latest
```
Get the registration token from: **Gitea → Your repo → Settings → Actions → Runners → Create Runner**
### 3. Create a package token
The workflow needs a token to push images to the registry:
1. Gitea → Your profile → **Settings → Applications → Generate Token**
2. Scopes: tick **`write:package`**
3. Copy the token
Then in your repo: **Settings → Secrets → Actions → Add Secret**
- Name: `PACKAGE_TOKEN`
- Value: the token you just copied
### 4. Set the registry URL variable
In your repo: **Settings → Variables → Actions → Add Variable**
- Name: `GITEA_URL`
- Value: `gitea.yourdomain.com` (no `https://`)
### 5. Push the repo
```bash
git remote add origin https://gitea.yourdomain.com/yourusername/milevault.git
git push -u origin main
```
The Actions workflow (`.gitea/workflows/build.yml`) triggers automatically, builds all three images, and pushes them to your Gitea registry. Check progress under **Actions** in the Gitea UI.
### 6. Update docker-compose.deploy.yml
Before the first deploy, replace the placeholder registry URLs in `docker-compose.deploy.yml`:
```
gitea.yourdomain.com/yourusername/ → your actual Gitea host and username
```
---
## Configuration
Create a `.env` file next to `docker-compose.deploy.yml` to override any defaults:
```env
# Admin login
ADMIN_USERNAME=admin
ADMIN_PASSWORD=a_strong_password_here
# Generate with: openssl rand -hex 32
SECRET_KEY=
# Ports
HTTP_PORT=80
# Optional: Mapbox token for satellite tiles
VITE_MAPBOX_TOKEN=
# Optional: PocketID passkey auth
POCKETID_ISSUER=
POCKETID_CLIENT_ID=
POCKETID_CLIENT_SECRET=
```
Docker Compose picks up `.env` automatically.
---
## If your Gitea registry requires authentication to pull
If your Gitea instance is private, add a pull secret on the deploy machine:
```bash
docker login gitea.yourdomain.com
# enter your Gitea username and password (or a read:package token)
```
Docker stores the credentials in `~/.docker/config.json` and uses them automatically on `docker compose pull`.
---
## Repo structure
```
.gitea/workflows/build.yml ← Gitea Actions: builds & pushes images on push to main
docker-compose.yml ← dev/build compose (builds from source)
docker-compose.deploy.yml ← production compose (pulls pre-built images)
nginx.conf ← standalone nginx config for deploy compose
backend/ ← FastAPI + Celery worker
frontend/ ← React + Vite
nginx/nginx.conf ← nginx config for dev compose
docker/init.sql ← DB init (enables TimescaleDB extension)
```
+16
View File
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Single worker avoids race condition during DB initialization.
# For a personal app this is fine; async handles concurrent requests well.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["celery", "-A", "app.workers.celery_app", "worker", "--loglevel=info", "--concurrency=2"]
@@ -0,0 +1,271 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, desc, delete
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, Activity, ActivityDataPoint, ActivityLap
router = APIRouter()
class ActivitySummary(BaseModel):
id: int
name: str
sport_type: str
start_time: datetime
distance_m: Optional[float]
duration_s: Optional[float]
elevation_gain_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
calories: Optional[float]
polyline: Optional[str]
bounding_box: Optional[dict]
hr_zones: Optional[dict]
named_route_id: Optional[int]
class Config:
from_attributes = True
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
elevation_loss_m: Optional[float]
max_heart_rate: Optional[float]
avg_power: Optional[float]
normalized_power: Optional[float]
max_speed_ms: Optional[float]
avg_temperature_c: Optional[float]
training_stress_score: Optional[float]
vo2max_estimate: Optional[float]
class DataPointOut(BaseModel):
timestamp: Optional[datetime]
latitude: Optional[float]
longitude: Optional[float]
altitude_m: Optional[float]
heart_rate: Optional[float]
cadence: Optional[float]
speed_ms: Optional[float]
power: Optional[float]
temperature_c: Optional[float]
distance_m: Optional[float]
class Config:
from_attributes = True
class LapOut(BaseModel):
lap_number: int
start_time: Optional[datetime]
duration_s: Optional[float]
distance_m: Optional[float]
avg_heart_rate: Optional[float]
avg_cadence: Optional[float]
avg_speed_ms: Optional[float]
avg_power: Optional[float]
class Config:
from_attributes = True
@router.get("/stats/ytd")
async def ytd_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return year-to-date distance totals grouped by sport type."""
from datetime import date, timezone
year_start = datetime(date.today().year, 1, 1, tzinfo=timezone.utc)
result = await db.execute(
select(Activity.sport_type, func.sum(Activity.distance_m).label("total_m"))
.where(Activity.user_id == current_user.id, Activity.start_time >= year_start)
.group_by(Activity.sport_type)
)
rows = result.all()
totals = {r.sport_type: (r.total_m or 0) / 1000 for r in rows}
return {
"running_km": round(totals.get("running", 0), 2),
"cycling_km": round(totals.get("cycling", 0), 2),
"hiking_km": round(totals.get("hiking", 0), 2),
"walking_km": round(totals.get("walking", 0), 2),
"total_km": round(sum(totals.values()), 2),
}
@router.get("/", response_model=List[ActivitySummary])
async def list_activities(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
sport_type: Optional[str] = None,
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Activity).where(Activity.user_id == current_user.id)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
if from_date:
q = q.where(Activity.start_time >= from_date)
if to_date:
q = q.where(Activity.start_time <= to_date)
q = q.order_by(desc(Activity.start_time))
q = q.offset((page - 1) * per_page).limit(per_page)
result = await db.execute(q)
return result.scalars().all()
@router.get("/{activity_id}", response_model=ActivityDetail)
async def get_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
return activity
@router.get("/{activity_id}/data-points", response_model=List[DataPointOut])
async def get_data_points(
activity_id: int,
downsample: int = Query(0, ge=0, description="Return every Nth point; 0 = all"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
q = select(ActivityDataPoint).where(
ActivityDataPoint.activity_id == activity_id
).order_by(ActivityDataPoint.timestamp)
result = await db.execute(q)
points = result.scalars().all()
if downsample > 1:
points = points[::downsample]
return points
@router.get("/{activity_id}/laps", response_model=List[LapOut])
async def get_laps(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
if not act.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Activity not found")
result = await db.execute(
select(ActivityLap)
.where(ActivityLap.activity_id == activity_id)
.order_by(ActivityLap.lap_number)
)
return result.scalars().all()
@router.patch("/{activity_id}/name")
async def rename_activity(
activity_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.name = body.get("name", activity.name)
await db.commit()
return {"id": activity_id, "name": activity.name}
@router.delete("/{activity_id}", status_code=204)
async def delete_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
await db.delete(activity)
await db.commit()
@router.post("/{activity_id}/reprocess")
async def reprocess_activity(
activity_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Re-parse the source FIT file and update polyline, data points etc."""
import os
result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
if not activity.source_file:
raise HTTPException(status_code=400, detail="No source file stored for this activity")
if not os.path.exists(activity.source_file):
raise HTTPException(status_code=404, detail="Source file no longer exists on disk")
source_file = activity.source_file
source_type = activity.source_type or "fit"
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id == activity_id))
await db.delete(activity)
await db.commit()
from app.workers.tasks import process_activity_file
task = process_activity_file.delay(source_file, current_user.id, source_type)
return {"task_id": task.id, "status": "queued"}
+275
View File
@@ -0,0 +1,275 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import timedelta
from jose import jwt, JWTError
import httpx
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, get_current_user
from app.core.config import settings
from app.models.user import User
router = APIRouter()
# Marks a short-lived OIDC `state` token as an account-link request (as opposed
# to a normal sign-in), so the callback attaches the passkey to a known user
# instead of creating/looking-up by identity.
LINK_STATE_PURPOSE = "pocketid-link"
def _make_link_state(user_id: int) -> str:
"""Signed, short-lived token carrying 'link this passkey to user_id' intent."""
return create_access_token(
{"sub": str(user_id), "purpose": LINK_STATE_PURPOSE},
expires_delta=timedelta(minutes=10),
)
def _decode_link_state(state: Optional[str]) -> Optional[int]:
"""Return the user id from a valid link-state token, else None."""
if not state:
return None
try:
payload = jwt.decode(state, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("purpose") != LINK_STATE_PURPOSE:
return None
return int(payload["sub"])
except (JWTError, KeyError, TypeError, ValueError):
return None
async def _config_admin(db: AsyncSession):
"""The admin row that holds instance-wide PocketID settings.
Settings live on an admin user row, but there can be more than one admin.
Prefer the admin that actually has an issuer configured; otherwise fall back
to the lowest-id admin. Without this, an unordered LIMIT 1 could return an
admin with no config and make PocketID look disabled / gating inconsistent.
"""
result = await db.execute(
select(User)
.where(User.is_admin == True, User.pocketid_issuer.isnot(None))
.order_by(User.id)
.limit(1)
)
admin = result.scalar_one_or_none()
if admin is None:
result = await db.execute(
select(User).where(User.is_admin == True).order_by(User.id).limit(1)
)
admin = result.scalar_one_or_none()
return admin
async def _get_pocketid_config(db: AsyncSession):
"""Get PocketID config from DB (admin user) falling back to env vars."""
admin = await _config_admin(db)
issuer = (admin and admin.pocketid_issuer) or settings.pocketid_issuer
client_id = (admin and admin.pocketid_client_id) or settings.pocketid_client_id
client_secret = (admin and admin.pocketid_client_secret) or settings.pocketid_client_secret
return issuer, client_id, client_secret
async def _get_allowed_group(db: AsyncSession):
"""Group a PocketID user must belong to in order to sign in (None = allow all)."""
admin = await _config_admin(db)
group = (admin and admin.pocketid_allowed_group) or settings.pocketid_allowed_group
return (group or "").strip() or None
async def _unique_username(db: AsyncSession, base: str) -> str:
"""Return `base`, or `base-2`, `base-3`, … until it is not already taken."""
base = (base or "user").strip() or "user"
candidate = base
n = 1
while True:
existing = await db.execute(select(User).where(User.username == candidate))
if existing.scalar_one_or_none() is None:
return candidate
n += 1
candidate = f"{base}-{n}"
class Token(BaseModel):
access_token: str
token_type: str
user_id: int
username: str
is_admin: bool
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool = False
class Config:
from_attributes = True
@router.post("/token", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not user.hashed_password:
raise HTTPException(status_code=400, detail="Invalid credentials")
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Invalid credentials")
token = create_access_token({"sub": str(user.id)})
return Token(access_token=token, token_type="bearer",
user_id=user.id, username=user.username, is_admin=user.is_admin)
@router.get("/me", response_model=UserOut)
async def get_me(current_user: User = Depends(get_current_user)):
return UserOut(
id=current_user.id,
username=current_user.username,
email=current_user.email,
is_admin=current_user.is_admin,
has_passkey=current_user.pocketid_sub is not None,
)
@router.get("/pocketid/available")
async def pocketid_available(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
return {"available": bool(issuer and client_id)}
@router.get("/pocketid/login-url")
async def pocketid_login_url(db: AsyncSession = Depends(get_db)):
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/link-url")
async def pocketid_link_url(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Authenticated user starts an OIDC flow to attach a passkey to THEIR account.
The `state` carries a signed 'link to this user' token so the callback links
the returned identity instead of creating/matching a new account.
"""
issuer, client_id, _ = await _get_pocketid_config(db)
if not issuer or not client_id:
raise HTTPException(status_code=404, detail="PocketID not configured")
from urllib.parse import urlencode
params = {
"client_id": client_id,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"response_type": "code",
"scope": "openid profile email groups",
"state": _make_link_state(current_user.id),
}
return {"url": f"{issuer}/authorize?{urlencode(params)}"}
@router.get("/pocketid/callback")
async def pocketid_callback(code: str, state: Optional[str] = None, db: AsyncSession = Depends(get_db)):
issuer, client_id, client_secret = await _get_pocketid_config(db)
if not issuer:
raise HTTPException(status_code=404, detail="PocketID not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{issuer}/api/oidc/token",
data={"grant_type": "authorization_code", "code": code,
"redirect_uri": f"{settings.base_url}/api/auth/pocketid/callback",
"client_id": client_id, "client_secret": client_secret},
)
if resp.status_code != 200:
print(f"PocketID token exchange failed ({resp.status_code}): {resp.text}")
raise HTTPException(status_code=400, detail="Token exchange failed")
tokens = resp.json()
userinfo_resp = await client.get(
f"{issuer}/api/oidc/userinfo",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
userinfo = userinfo_resp.json()
from fastapi.responses import RedirectResponse
sub = userinfo.get("sub")
email = userinfo.get("email")
preferred_username = userinfo.get("preferred_username") or email
# ── Explicit account-link flow ──────────────────────────────────────────
# Initiated by an already-authenticated user from their profile. Attach the
# passkey to that account. No group gating here: this is identity linking,
# not access control, and the initiator is already an authorised user.
link_user_id = _decode_link_state(state)
if link_user_id is not None:
result = await db.execute(select(User).where(User.pocketid_sub == sub))
holder = result.scalar_one_or_none()
if holder and holder.id != link_user_id:
# This passkey is already attached to a different account.
return RedirectResponse(url="/login?auth_error=passkey_in_use")
result = await db.execute(select(User).where(User.id == link_user_id))
target = result.scalar_one_or_none()
if target is None:
return RedirectResponse(url="/login?auth_error=link_failed")
target.pocketid_sub = sub
if not target.email and email:
dup = await db.execute(
select(User).where(User.email == email, User.id != target.id)
)
if dup.scalar_one_or_none() is None:
target.email = email
return RedirectResponse(url="/profile?linked=1")
# Group gating: if an allowed group is configured, the user must be in it.
allowed_group = await _get_allowed_group(db)
if allowed_group:
groups = userinfo.get("groups") or []
if allowed_group not in groups:
return RedirectResponse(url="/login?auth_error=not_authorized")
# 1) Existing passkey identity → use it.
result = await db.execute(select(User).where(User.pocketid_sub == sub))
user = result.scalar_one_or_none()
# 2) No passkey identity yet, but an account with this email exists and is
# not already linked to a different passkey → link them (preserves data).
if not user and email:
result = await db.execute(select(User).where(User.email == email))
existing = result.scalar_one_or_none()
if existing and existing.pocketid_sub is None:
existing.pocketid_sub = sub
user = existing
# 3) Otherwise provision a new account with a collision-safe username.
if not user:
base = preferred_username or (email.split("@")[0] if email else "user")
username = await _unique_username(db, base)
# Only set email if no other account already claims it (unique column).
email_taken = False
if email:
dup = await db.execute(select(User).where(User.email == email))
email_taken = dup.scalar_one_or_none() is not None
user = User(username=username, email=None if email_taken else email, pocketid_sub=sub)
db.add(user)
await db.flush()
token = create_access_token({"sub": str(user.id)})
return RedirectResponse(url=f"/?token={token}")
@@ -0,0 +1,160 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, GarminConnectConfig
router = APIRouter()
class GarminConfigIn(BaseModel):
email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
sync_enabled: bool = True
sync_activities: bool = True
sync_wellness: bool = True
sync_lookback_days: int = 30 # days to look back on first sync; -1 = all-time
class GarminConfigOut(BaseModel):
email: str
sync_enabled: bool
sync_activities: bool
sync_wellness: bool
sync_lookback_days: int
last_sync_at: Optional[datetime]
last_sync_status: Optional[str]
connected: bool
class Config:
from_attributes = True
@router.get("/config", response_model=GarminConfigOut)
async def get_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg:
return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30,
last_sync_at=None, last_sync_status=None, connected=False,
)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.put("/config", response_model=GarminConfigOut)
async def save_config(
body: GarminConfigIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Save Garmin Connect settings. If a password is provided, re-authenticates and
refreshes the stored OAuth token. If no password is provided, only updates the
non-credential settings (toggles, lookback days) without re-logging in.
"""
from app.services.garmin_connect_sync import encrypt_password, authenticate_garmin
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if body.password:
# Credentials update — test-login before saving
enc = encrypt_password(body.password)
try:
garmin, token_store = authenticate_garmin(body.email, enc, None)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Garmin login failed: {exc}")
if cfg:
cfg.email = body.email
cfg.password_enc = enc
cfg.token_store = token_store
cfg.last_sync_status = "Credentials updated"
else:
cfg = GarminConnectConfig(
user_id=current_user.id,
email=body.email,
password_enc=enc,
token_store=token_store,
last_sync_status="Connected",
)
db.add(cfg)
else:
# Settings-only update — password unchanged
if not cfg:
raise HTTPException(status_code=400, detail="No Garmin account connected — password required for first-time setup")
cfg.sync_enabled = body.sync_enabled
cfg.sync_activities = body.sync_activities
cfg.sync_wellness = body.sync_wellness
cfg.sync_lookback_days = body.sync_lookback_days
await db.commit()
await db.refresh(cfg)
return GarminConfigOut(
email=cfg.email,
sync_enabled=cfg.sync_enabled,
sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30,
last_sync_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status,
connected=True,
)
@router.delete("/config")
async def delete_config(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
await db.delete(cfg)
await db.commit()
return {"status": "ok"}
@router.post("/trigger")
async def trigger_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Enqueue an immediate Garmin Connect sync for this user."""
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
raise HTTPException(status_code=400, detail="Garmin Connect sync is not configured or disabled")
from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id)
return {"task_id": task.id, "status": "queued"}
+187
View File
@@ -0,0 +1,187 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel, model_validator
from typing import Optional, List, Any
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, HealthMetric
router = APIRouter()
class HealthMetricOut(BaseModel):
id: int
date: datetime
resting_hr: Optional[float]
max_hr_day: Optional[float]
avg_hr_day: Optional[float]
hrv_nightly_avg: Optional[float]
hrv_status: Optional[str]
hrv_5min_high: Optional[float]
hrv_5min_low: Optional[float]
sleep_duration_s: Optional[float]
sleep_deep_s: Optional[float]
sleep_light_s: Optional[float]
sleep_rem_s: Optional[float]
sleep_awake_s: Optional[float]
sleep_score: Optional[float]
sleep_start: Optional[datetime]
sleep_end: Optional[datetime]
weight_kg: Optional[float]
bmi: Optional[float]
body_fat_pct: Optional[float]
muscle_mass_kg: Optional[float]
vo2max: Optional[float]
fitness_age: Optional[int]
training_load: Optional[float]
recovery_time_h: Optional[float]
avg_stress: Optional[float]
steps: Optional[int]
floors_climbed: Optional[int]
active_calories: Optional[float]
total_calories: Optional[float]
spo2_avg: Optional[float]
body_battery: Optional[Any] = None # {charged,drained,start_level,end_level} — values stripped
@model_validator(mode='after')
def _strip_bb_values(self):
if isinstance(self.body_battery, dict):
self.body_battery = {k: v for k, v in self.body_battery.items() if k != 'values'}
return self
class Config:
from_attributes = True
@router.get("/", response_model=List[HealthMetricOut])
async def list_health_metrics(
from_date: Optional[datetime] = None,
to_date: Optional[datetime] = None,
limit: int = Query(365, ge=1, le=2000),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id)
if from_date:
from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date
q = q.where(func.date(HealthMetric.date) >= from_date_naive.date())
if to_date:
to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date
q = q.where(func.date(HealthMetric.date) <= to_date_naive.date())
q = q.order_by(desc(HealthMetric.date)).limit(limit)
result = await db.execute(q)
return result.scalars().all()
@router.get("/summary")
async def health_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
latest_result = await db.execute(
select(HealthMetric)
.where(HealthMetric.user_id == current_user.id)
.order_by(desc(HealthMetric.date))
.limit(1)
)
latest = latest_result.scalar_one_or_none()
cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
avg_result = await db.execute(
select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"),
func.avg(HealthMetric.hrv_nightly_avg).label("avg_hrv"),
func.avg(HealthMetric.sleep_duration_s).label("avg_sleep_s"),
func.avg(HealthMetric.sleep_score).label("avg_sleep_score"),
func.avg(HealthMetric.avg_stress).label("avg_stress"),
func.avg(HealthMetric.steps).label("avg_steps"),
func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) >= cutoff,
)
)
avgs = avg_result.one()
return {
"latest": HealthMetricOut.model_validate(latest) if latest else None,
"avg_30d": {
"resting_hr": avgs.avg_resting_hr,
"hrv": avgs.avg_hrv,
"sleep_h": (avgs.avg_sleep_s / 3600) if avgs.avg_sleep_s else None,
"sleep_score": avgs.avg_sleep_score,
"stress": avgs.avg_stress,
"steps": avgs.avg_steps,
"weight_kg": avgs.avg_weight,
},
}
@router.get("/intraday")
async def intraday_health(
date: str = Query(..., description="YYYY-MM-DD"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return intraday heart rate series for a specific day."""
from datetime import date as _date
from fastapi import HTTPException
try:
metric_date = _date.fromisoformat(date)
except ValueError:
raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD")
result = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date,
)
)
metric = result.scalar_one_or_none()
return {
"hr_values": metric.intraday_hr if metric else None,
"body_battery": metric.body_battery if metric else None,
"body_battery_hires": metric.body_battery_hires if metric else None,
"sleep_stages": metric.sleep_stages if metric else None,
}
@router.put("/manual")
async def add_manual_metric(
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from fastapi import HTTPException
date_str = body.get("date")
if not date_str:
raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str)
existing = await db.execute(
select(HealthMetric).where(
HealthMetric.user_id == current_user.id,
func.date(HealthMetric.date) == metric_date.date(),
)
)
metric = existing.scalar_one_or_none()
if metric:
for key, val in body.items():
if hasattr(metric, key) and key not in ("id", "user_id"):
setattr(metric, key, val)
else:
metric = HealthMetric(user_id=current_user.id, date=metric_date, **{
k: v for k, v in body.items()
if hasattr(HealthMetric, k) and k not in ("id", "user_id")
})
db.add(metric)
await db.commit()
return {"status": "ok"}
+239
View File
@@ -0,0 +1,239 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date, timezone
from app.core.database import get_db
from app.core.security import get_current_user, hash_password, verify_password
from app.models.user import User, WeightLog
router = APIRouter()
# ── Profile ────────────────────────────────────────────────────────────────
class ProfileUpdate(BaseModel):
max_heart_rate: Optional[int] = None
resting_heart_rate: Optional[int] = None
birth_year: Optional[int] = None
height_cm: Optional[float] = None
biological_sex: Optional[str] = None
class ProfileOut(BaseModel):
id: int
username: str
email: Optional[str]
max_heart_rate: Optional[int]
resting_heart_rate: Optional[int]
birth_year: Optional[int]
height_cm: Optional[float]
biological_sex: Optional[str]
estimated_max_hr: Optional[int]
is_admin: bool
class Config:
from_attributes = True
def _estimated_max_hr(user: User) -> Optional[int]:
if user.birth_year:
return 220 - (datetime.now().year - user.birth_year)
return None
@router.get("/", response_model=ProfileOut)
async def get_profile(current_user: User = Depends(get_current_user)):
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
@router.patch("/", response_model=ProfileOut)
async def update_profile(
body: ProfileUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
old_max_hr = current_user.max_heart_rate
if body.max_heart_rate is not None:
if not (100 <= body.max_heart_rate <= 250):
raise HTTPException(400, "Max HR must be 100250")
current_user.max_heart_rate = body.max_heart_rate
if body.resting_heart_rate is not None:
if not (20 <= body.resting_heart_rate <= 120):
raise HTTPException(400, "Resting HR must be 20120")
current_user.resting_heart_rate = body.resting_heart_rate
if body.birth_year is not None:
if not (1920 <= body.birth_year <= 2010):
raise HTTPException(400, "Invalid birth year")
current_user.birth_year = body.birth_year
if body.height_cm is not None:
if not (50 <= body.height_cm <= 300):
raise HTTPException(400, "Height must be 50300 cm")
current_user.height_cm = body.height_cm
if body.biological_sex is not None:
if body.biological_sex not in ('male', 'female', ''):
raise HTTPException(400, "biological_sex must be 'male' or 'female'")
current_user.biological_sex = body.biological_sex or None
await db.commit()
await db.refresh(current_user)
if body.max_heart_rate is not None and body.max_heart_rate != old_max_hr:
from app.workers.tasks import recalculate_hr_zones_for_user
recalculate_hr_zones_for_user.delay(current_user.id, body.max_heart_rate)
return {**{c.name: getattr(current_user, c.name)
for c in User.__table__.columns},
"estimated_max_hr": _estimated_max_hr(current_user)}
# ── Password change ────────────────────────────────────────────────────────
class PasswordChange(BaseModel):
current_password: str
new_password: str
@router.post("/change-password")
async def change_password(
body: PasswordChange,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.hashed_password:
raise HTTPException(400, "Account uses passkey login — no password to change")
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(400, "Current password is incorrect")
if len(body.new_password) < 8:
raise HTTPException(400, "New password must be at least 8 characters")
current_user.hashed_password = hash_password(body.new_password)
await db.commit()
return {"status": "ok"}
# ── PocketID configuration (admin only) ────────────────────────────────────
class PocketIDConfig(BaseModel):
issuer: Optional[str] = None
client_id: Optional[str] = None
client_secret: Optional[str] = None
allowed_group: Optional[str] = None
@router.get("/pocketid-config")
async def get_pocketid_config(current_user: User = Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
from app.core.config import settings
# Show DB config if set, fall back to env
issuer = current_user.pocketid_issuer or settings.pocketid_issuer
client_id = current_user.pocketid_client_id or settings.pocketid_client_id
allowed_group = current_user.pocketid_allowed_group or settings.pocketid_allowed_group
return {
"issuer": issuer or "",
"client_id": client_id or "",
"client_secret_set": bool(current_user.pocketid_client_secret or settings.pocketid_client_secret),
"allowed_group": allowed_group or "",
"enabled": bool(issuer and client_id),
}
@router.post("/pocketid-config")
async def save_pocketid_config(
body: PocketIDConfig,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
if body.issuer is not None:
current_user.pocketid_issuer = body.issuer.rstrip("/") if body.issuer else None
if body.client_id is not None:
current_user.pocketid_client_id = body.client_id or None
# Only overwrite the secret when a non-empty value is supplied; a blank
# field means "keep the existing secret" (matches the UI hint).
if body.client_secret:
current_user.pocketid_client_secret = body.client_secret
if body.allowed_group is not None:
current_user.pocketid_allowed_group = body.allowed_group.strip() or None
await db.commit()
return {"status": "ok"}
# ── Weight log ─────────────────────────────────────────────────────────────
class WeightEntry(BaseModel):
date: datetime
weight_kg: float
body_fat_pct: Optional[float] = None
note: Optional[str] = None
class WeightOut(BaseModel):
id: int
date: datetime
weight_kg: float
body_fat_pct: Optional[float]
note: Optional[str]
class Config:
from_attributes = True
@router.get("/weight", response_model=List[WeightOut])
async def list_weight(
limit: int = 365,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog)
.where(WeightLog.user_id == current_user.id)
.order_by(desc(WeightLog.date))
.limit(limit)
)
return result.scalars().all()
@router.post("/weight", response_model=WeightOut)
async def log_weight(
body: WeightEntry,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if not (20 <= body.weight_kg <= 500):
raise HTTPException(400, "Weight must be 20500 kg")
entry = WeightLog(
user_id=current_user.id,
date=body.date,
weight_kg=body.weight_kg,
body_fat_pct=body.body_fat_pct,
note=body.note,
)
db.add(entry)
await db.commit()
await db.refresh(entry)
return entry
@router.delete("/weight/{entry_id}", status_code=204)
async def delete_weight(
entry_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(WeightLog).where(
WeightLog.id == entry_id,
WeightLog.user_id == current_user.id,
)
)
entry = result.scalar_one_or_none()
if not entry:
raise HTTPException(404, "Not found")
await db.delete(entry)
await db.commit()
@@ -0,0 +1,92 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, PersonalRecord, NamedRoute, RouteSegment, HealthMetric, Activity
router = APIRouter()
# ─── Personal Records ────────────────────────────────────────────────────────
class PROut(BaseModel):
id: int
sport_type: str
distance_m: float
distance_label: str
duration_s: float
achieved_at: datetime
activity_id: int
class Config:
from_attributes = True
@router.get("/", response_model=List[PROut])
async def list_records(
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.is_current_record == True,
)
if sport_type:
q = q.where(PersonalRecord.sport_type == sport_type)
q = q.order_by(PersonalRecord.distance_m)
result = await db.execute(q)
return result.scalars().all()
@router.get("/routes")
async def route_records(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fastest activity per named route (course records)."""
from sqlalchemy import text
rows = await db.execute(
text("""
SELECT DISTINCT ON (nr.id)
nr.id AS route_id,
nr.name AS route_name,
nr.sport_type,
nr.distance_m,
nr.reference_polyline,
a.id AS activity_id,
a.name AS activity_name,
a.duration_s,
a.start_time,
a.avg_speed_ms
FROM named_routes nr
JOIN activities a ON a.named_route_id = nr.id AND a.user_id = nr.user_id
WHERE nr.user_id = :uid AND a.duration_s IS NOT NULL
ORDER BY nr.id, a.duration_s ASC
"""),
{"uid": current_user.id},
)
return [dict(r._mapping) for r in rows]
@router.get("/history/{distance_label}")
async def record_history(
distance_label: str,
sport_type: str = "running",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Show progression of a PR over time."""
result = await db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == current_user.id,
PersonalRecord.sport_type == sport_type,
PersonalRecord.distance_label == distance_label,
).order_by(PersonalRecord.achieved_at)
)
return result.scalars().all()
+572
View File
@@ -0,0 +1,572 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, timedelta, timezone
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.user import User, NamedRoute, RouteSegment, Activity
router = APIRouter()
class SegmentCreate(BaseModel):
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str] = None
class RouteCreate(BaseModel):
name: str
description: Optional[str] = None
sport_type: Optional[str] = None
activity_id: int
class RouteOut(BaseModel):
id: int
name: str
description: Optional[str]
sport_type: Optional[str]
reference_polyline: Optional[str]
bounding_box: Optional[dict]
distance_m: Optional[float]
auto_detected: Optional[bool]
created_at: datetime
activity_count: int = 0
class Config:
from_attributes = True
class SegmentOut(BaseModel):
id: int
name: str
start_distance_m: float
end_distance_m: float
description: Optional[str]
auto_generated: Optional[bool] = False
auto_generated_type: Optional[str] = None
class Config:
from_attributes = True
class AutoGenerateRequest(BaseModel):
type: str # "1km" | "turns" | "hills"
gradient_pct: float = 5.0
turn_angle_deg: float = 45.0
class SegmentTimeEntry(BaseModel):
activity_id: int
date: datetime
name: str
duration_s: float
@router.get("/", response_model=List[RouteOut])
async def list_routes(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Fetch routes with activity counts in one query
count_subq = (
select(Activity.named_route_id, func.count(Activity.id).label("cnt"))
.where(Activity.user_id == current_user.id, Activity.named_route_id.isnot(None))
.group_by(Activity.named_route_id)
.subquery()
)
result = await db.execute(
select(NamedRoute, func.coalesce(count_subq.c.cnt, 0).label("activity_count"))
.outerjoin(count_subq, NamedRoute.id == count_subq.c.named_route_id)
.where(NamedRoute.user_id == current_user.id)
.order_by(desc(NamedRoute.created_at))
)
rows = result.all()
out = []
for route, cnt in rows:
d = {c.name: getattr(route, c.name) for c in route.__table__.columns}
d["activity_count"] = cnt
out.append(RouteOut(**d))
return out
@router.get("/recent-activities")
async def recent_activities_for_route(
days: int = Query(14, ge=1, le=90),
sport_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return recent activities for the route creation dropdown."""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
q = select(Activity).where(
Activity.user_id == current_user.id,
Activity.start_time >= cutoff,
Activity.sport_type != "swimming",
)
if sport_type:
q = q.where(Activity.sport_type == sport_type)
q = q.order_by(desc(Activity.start_time)).limit(50)
result = await db.execute(q)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"sport_type": a.sport_type,
"start_time": a.start_time,
"distance_m": a.distance_m,
"duration_s": a.duration_s,
}
for a in activities
]
@router.post("/", response_model=RouteOut)
async def create_route(
body: RouteCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
act_result = await db.execute(
select(Activity).where(
Activity.id == body.activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
route = NamedRoute(
user_id=current_user.id,
name=body.name,
description=body.description,
sport_type=body.sport_type or activity.sport_type,
reference_polyline=activity.polyline,
bounding_box=activity.bounding_box,
distance_m=activity.distance_m,
auto_detected=False,
)
db.add(route)
await db.flush()
activity.named_route_id = route.id
await db.commit()
await db.refresh(route)
return route
@router.get("/{route_id}", response_model=RouteOut)
async def get_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(NamedRoute).where(
NamedRoute.id == route_id,
NamedRoute.user_id == current_user.id,
)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/activities")
async def route_activities(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(
select(Activity).where(
Activity.named_route_id == route_id,
Activity.user_id == current_user.id,
).order_by(Activity.duration_s)
)
activities = result.scalars().all()
return [
{
"id": a.id,
"name": a.name,
"start_time": a.start_time,
"duration_s": a.duration_s,
"distance_m": a.distance_m,
"avg_heart_rate": a.avg_heart_rate,
"avg_speed_ms": a.avg_speed_ms,
}
for a in activities
]
@router.post("/{route_id}/merge/{source_id}", response_model=RouteOut)
async def merge_routes(
route_id: int,
source_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Move all activities from source route into route_id, then delete source route."""
from sqlalchemy import update
target = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
source = (await db.execute(
select(NamedRoute).where(NamedRoute.id == source_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not target or not source:
raise HTTPException(status_code=404, detail="Route not found")
if route_id == source_id:
raise HTTPException(status_code=400, detail="Cannot merge a route with itself")
await db.execute(
update(Activity)
.where(Activity.named_route_id == source_id, Activity.user_id == current_user.id)
.values(named_route_id=route_id)
)
await db.delete(source)
await db.commit()
await db.refresh(target)
return target
@router.delete("/{route_id}")
async def delete_route(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from sqlalchemy import update as sa_update
route = (await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == current_user.id)
)).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# Unlink activities before deleting
await db.execute(
sa_update(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.values(named_route_id=None)
)
await db.delete(route)
await db.commit()
return {"status": "ok"}
@router.post("/{route_id}/assign-activity")
async def assign_activity_to_route(
route_id: int,
body: dict,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
activity_id = body.get("activity_id")
act_result = await db.execute(
select(Activity).where(
Activity.id == activity_id,
Activity.user_id == current_user.id,
)
)
activity = act_result.scalar_one_or_none()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
activity.named_route_id = route_id
await db.commit()
return {"status": "ok"}
async def _get_owned_route(route_id: int, user_id: int, db: AsyncSession) -> NamedRoute:
result = await db.execute(
select(NamedRoute).where(NamedRoute.id == route_id, NamedRoute.user_id == user_id)
)
route = result.scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/segments", response_model=List[SegmentOut])
async def list_segments(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
return result.scalars().all()
@router.post("/{route_id}/segments", response_model=SegmentOut)
async def create_segment(
route_id: int,
body: SegmentCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
segment = RouteSegment(
route_id=route_id,
name=body.name,
start_distance_m=body.start_distance_m,
end_distance_m=body.end_distance_m,
description=body.description,
auto_generated=False,
)
db.add(segment)
await db.commit()
await db.refresh(segment)
return segment
@router.delete("/{route_id}/segments/{segment_id}", status_code=204)
async def delete_segment(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
await _get_owned_route(route_id, current_user.id, db)
result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
await db.delete(seg)
await db.commit()
@router.post("/{route_id}/segments/auto", response_model=List[SegmentOut])
async def auto_generate_segments(
route_id: int,
body: AutoGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Auto-generate segments: 1km splits, turns, or hills."""
from app.services.route_matcher import (
generate_1km_segments, generate_turn_segments, generate_hill_segments,
)
from sqlalchemy import delete as sql_delete
route = await _get_owned_route(route_id, current_user.id, db)
if body.type not in ("1km", "turns", "hills"):
raise HTTPException(status_code=400, detail="type must be '1km', 'turns', or 'hills'")
# Clear only auto-generated segments of the same type so other auto types are preserved
await db.execute(
sql_delete(RouteSegment).where(
RouteSegment.route_id == route_id,
RouteSegment.auto_generated == True,
RouteSegment.auto_generated_type == body.type,
)
)
raw_segments: list[tuple[str, float, float]] = []
if body.type == "1km":
if not route.distance_m:
raise HTTPException(status_code=400, detail="Route has no distance recorded")
raw_segments = generate_1km_segments(route.reference_polyline or "", route.distance_m)
elif body.type == "turns":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
raw_segments = generate_turn_segments(route.reference_polyline, body.turn_angle_deg)
elif body.type == "hills":
if not route.reference_polyline:
raise HTTPException(status_code=400, detail="Route has no polyline")
# Find most recent matched activity for elevation data
act_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(1)
)
act = act_result.scalar_one_or_none()
if not act:
raise HTTPException(status_code=400, detail="No matched activities found for elevation data")
from app.models.user import ActivityDataPoint
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [{"distance_m": p.distance_m, "altitude_m": p.altitude_m} for p in dps]
raw_segments = generate_hill_segments(dp_list, body.gradient_pct)
new_segments = []
for name, start_m, end_m in raw_segments:
seg = RouteSegment(
route_id=route_id,
name=name,
start_distance_m=start_m,
end_distance_m=end_m,
auto_generated=True,
auto_generated_type=body.type,
)
db.add(seg)
new_segments.append(seg)
await db.commit()
for seg in new_segments:
await db.refresh(seg)
return new_segments
class SegmentBestOut(BaseModel):
segment_id: int
name: str
start_distance_m: float
end_distance_m: float
auto_generated: bool
best_s: Optional[float]
best_activity_id: Optional[int]
count: int
@router.get("/{route_id}/segment-bests", response_model=List[SegmentBestOut])
async def get_segment_bests(
route_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return best time per segment across all matched activities for a route."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
from collections import defaultdict
await _get_owned_route(route_id, current_user.id, db)
segs_result = await db.execute(
select(RouteSegment)
.where(RouteSegment.route_id == route_id)
.order_by(RouteSegment.start_distance_m)
)
segments = segs_result.scalars().all()
if not segments:
return []
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(20)
)
activities = acts_result.scalars().all()
if not activities:
return [
SegmentBestOut(
segment_id=s.id, name=s.name,
start_distance_m=s.start_distance_m, end_distance_m=s.end_distance_m,
auto_generated=bool(s.auto_generated), best_s=None, best_activity_id=None, count=0,
)
for s in segments
]
act_ids = [a.id for a in activities]
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id.in_(act_ids))
.order_by(ActivityDataPoint.activity_id, ActivityDataPoint.timestamp)
)
all_dps = dp_result.scalars().all()
# Group data points by activity_id
dp_by_act = defaultdict(list)
for dp in all_dps:
if dp.distance_m is not None:
dp_by_act[dp.activity_id].append({"distance_m": dp.distance_m, "timestamp": dp.timestamp})
bests = []
for seg in segments:
best_s = None
best_act_id = None
count = 0
for act_id in act_ids:
dp_list = dp_by_act.get(act_id, [])
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration is not None:
count += 1
if best_s is None or duration < best_s:
best_s = duration
best_act_id = act_id
bests.append(SegmentBestOut(
segment_id=seg.id, name=seg.name,
start_distance_m=seg.start_distance_m, end_distance_m=seg.end_distance_m,
auto_generated=bool(seg.auto_generated),
best_s=best_s, best_activity_id=best_act_id, count=count,
))
return bests
@router.get("/{route_id}/segments/{segment_id}/times", response_model=List[SegmentTimeEntry])
async def get_segment_times(
route_id: int,
segment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return the last 10 times this segment was traversed across matched activities."""
from app.services.route_matcher import find_segment_times
from app.models.user import ActivityDataPoint
await _get_owned_route(route_id, current_user.id, db)
seg_result = await db.execute(
select(RouteSegment).where(
RouteSegment.id == segment_id, RouteSegment.route_id == route_id
)
)
seg = seg_result.scalar_one_or_none()
if not seg:
raise HTTPException(status_code=404, detail="Segment not found")
acts_result = await db.execute(
select(Activity)
.where(Activity.named_route_id == route_id, Activity.user_id == current_user.id)
.order_by(desc(Activity.start_time))
.limit(10)
)
activities = acts_result.scalars().all()
times = []
for act in activities:
dp_result = await db.execute(
select(ActivityDataPoint)
.where(ActivityDataPoint.activity_id == act.id)
.order_by(ActivityDataPoint.timestamp)
)
dps = dp_result.scalars().all()
dp_list = [
{"distance_m": p.distance_m, "timestamp": p.timestamp}
for p in dps
if p.distance_m is not None
]
duration = find_segment_times(dp_list, seg.start_distance_m, seg.end_distance_m)
if duration:
times.append(SegmentTimeEntry(
activity_id=act.id,
date=act.start_time,
name=act.name,
duration_s=duration,
))
return times
+151
View File
@@ -0,0 +1,151 @@
import os
import shutil
import zipfile
from pathlib import Path
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import User
from app.workers.tasks import process_activity_file, process_garmin_health_zip
router = APIRouter()
ALLOWED_EXTENSIONS = {".fit", ".gpx", ".zip"}
MAX_FILE_SIZE = 500 * 1024 * 1024 # 500 MB
def save_upload(upload: UploadFile, dest_dir: Path) -> Path:
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / upload.filename
with open(dest, "wb") as f:
shutil.copyfileobj(upload.file, f)
return dest
@router.post("/activity")
async def upload_activity(
file: UploadFile = File(...),
background_tasks: BackgroundTasks = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a single .fit or .gpx activity file."""
suffix = Path(file.filename).suffix.lower()
if suffix not in {".fit", ".gpx"}:
raise HTTPException(status_code=400, detail="Only .fit and .gpx files are supported")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "activities"
dest = save_upload(file, dest_dir)
# Queue processing
task = process_activity_file.delay(str(dest), current_user.id, suffix[1:])
return {"task_id": task.id, "status": "queued", "filename": file.filename}
@router.post("/garmin-export")
async def upload_garmin_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Upload a full Garmin Connect data export ZIP.
Processes all FIT files for activities + wellness data.
"""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Garmin export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
# Extract and queue all FIT files
extract_dir = dest_dir / f"garmin_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit"):
fit_path = extract_dir / name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
elif lower.endswith(".zip"):
# Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
nested_zip_path = extract_dir / name
nested_extract = nested_zip_path.parent / nested_zip_path.stem
nested_extract.mkdir(exist_ok=True)
try:
with zipfile.ZipFile(nested_zip_path) as nzf:
nzf.extractall(nested_extract)
for nested_name in nzf.namelist():
if nested_name.lower().endswith(".fit"):
fit_path = nested_extract / nested_name
task = process_activity_file.delay(str(fit_path), current_user.id, "fit")
task_ids.append(task.id)
except zipfile.BadZipFile:
pass
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
"task_id": health_task.id,
}
@router.post("/strava-export")
async def upload_strava_export(
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a Strava bulk export ZIP (contains activities/ folder with GPX/FIT files)."""
if not file.filename.endswith(".zip"):
raise HTTPException(status_code=400, detail="Please upload a .zip Strava export")
dest_dir = Path(settings.file_store_path) / str(current_user.id) / "exports"
dest = save_upload(file, dest_dir)
extract_dir = dest_dir / f"strava_{dest.stem}"
extract_dir.mkdir(exist_ok=True)
task_ids = []
with zipfile.ZipFile(dest) as zf:
zf.extractall(extract_dir)
for name in zf.namelist():
lower = name.lower()
if lower.endswith(".fit") or lower.endswith(".gpx"):
file_path = extract_dir / name
ext = Path(name).suffix[1:]
task = process_activity_file.delay(str(file_path), current_user.id, ext)
task_ids.append(task.id)
return {
"status": "queued",
"activity_tasks": len(task_ids),
"task_id": task_ids[-1] if task_ids else None,
}
@router.get("/task/{task_id}")
async def check_task_status(
task_id: str,
current_user: User = Depends(get_current_user),
):
"""Check the status of an upload processing task."""
from app.workers.celery_app import celery_app
result = celery_app.AsyncResult(task_id)
return {
"task_id": task_id,
"status": result.status,
"result": result.result if result.ready() else None,
}
+142
View File
@@ -0,0 +1,142 @@
"""
Admin-only user management: list provisioned users, promote/demote admin,
and delete a user together with all of their data.
New users are normally provisioned just-in-time on first PocketID login
(see app/api/auth.py). This router is the in-app surface for managing them.
"""
import shutil
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import (
User, Activity, ActivityDataPoint, ActivityLap, NamedRoute,
RouteSegment, PersonalRecord, HealthMetric, WeightLog, GarminConnectConfig,
)
router = APIRouter()
def _require_admin(current_user: User):
if not current_user.is_admin:
raise HTTPException(403, "Admin only")
async def _admin_count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User).where(User.is_admin == True))
return result.scalar_one()
class UserOut(BaseModel):
id: int
username: str
email: Optional[str]
is_admin: bool
has_passkey: bool
activity_count: int
created_at: Optional[str]
class AdminUpdate(BaseModel):
is_admin: bool
@router.get("/")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
# activity counts per user in one grouped query
counts = dict(
(await db.execute(
select(Activity.user_id, func.count(Activity.id)).group_by(Activity.user_id)
)).all()
)
result = await db.execute(select(User).order_by(User.id))
users = result.scalars().all()
return [
UserOut(
id=u.id,
username=u.username,
email=u.email,
is_admin=u.is_admin,
has_passkey=u.pocketid_sub is not None,
activity_count=counts.get(u.id, 0),
created_at=u.created_at.isoformat() if u.created_at else None,
)
for u in users
]
@router.patch("/{user_id}")
async def set_admin(
user_id: int,
body: AdminUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot change your own admin status")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
# Demoting the last remaining admin would lock everyone out.
if user.is_admin and not body.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot demote the last admin")
user.is_admin = body.is_admin
await db.commit()
return {"status": "ok", "is_admin": user.is_admin}
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin(current_user)
if user_id == current_user.id:
raise HTTPException(400, "You cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "User not found")
if user.is_admin and await _admin_count(db) <= 1:
raise HTTPException(400, "Cannot delete the last admin")
# Ordered deletes: PersonalRecord and the activity/route child tables have no
# cascade path from User, so remove them before the parents to avoid FK errors.
activity_ids = select(Activity.id).where(Activity.user_id == user_id)
route_ids = select(NamedRoute.id).where(NamedRoute.user_id == user_id)
await db.execute(delete(PersonalRecord).where(PersonalRecord.user_id == user_id))
await db.execute(delete(ActivityLap).where(ActivityLap.activity_id.in_(activity_ids)))
await db.execute(delete(ActivityDataPoint).where(ActivityDataPoint.activity_id.in_(activity_ids)))
await db.execute(delete(RouteSegment).where(RouteSegment.route_id.in_(route_ids)))
await db.execute(delete(Activity).where(Activity.user_id == user_id))
await db.execute(delete(NamedRoute).where(NamedRoute.user_id == user_id))
await db.execute(delete(HealthMetric).where(HealthMetric.user_id == user_id))
await db.execute(delete(WeightLog).where(WeightLog.user_id == user_id))
await db.execute(delete(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id))
await db.execute(delete(User).where(User.id == user_id))
await db.commit()
# Remove the user's uploaded files from disk (best-effort).
shutil.rmtree(Path(settings.file_store_path) / str(user_id), ignore_errors=True)
return {"status": "ok"}
@@ -0,0 +1,35 @@
from pydantic_settings import BaseSettings
from pydantic import Field
from typing import Optional
class Settings(BaseSettings):
# Database
database_url: str = Field(..., env="DATABASE_URL")
# Redis
redis_url: str = Field("redis://localhost:6379/0", env="REDIS_URL")
# Auth
secret_key: str = Field(..., env="SECRET_KEY")
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Admin account
admin_username: str = Field("admin", env="ADMIN_USERNAME")
admin_password: Optional[str] = Field(None, env="ADMIN_PASSWORD")
# Base URL - used for OAuth callbacks
base_url: str = Field("https://milevault.jarrett.eu", env="BASE_URL")
# PocketID OIDC (optional)
pocketid_issuer: Optional[str] = Field(None, env="POCKETID_ISSUER")
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP")
# Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment
environment: str = Field("production", env="ENVIRONMENT")
class Config:
env_file = ".env"
case_sensitive = False
settings = Settings()
@@ -0,0 +1,47 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
# Async engine for FastAPI
engine = create_async_engine(
settings.database_url,
echo=settings.environment == "development",
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Sync engine for Celery workers (Celery + asyncio don't mix well)
# Convert async URL to sync: postgresql+asyncpg:// → postgresql+psycopg2://
sync_url = settings.database_url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
sync_engine = create_engine(
sync_url,
echo=False,
pool_size=5,
max_overflow=10,
pool_pre_ping=True,
)
SyncSessionLocal = sessionmaker(sync_engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@@ -0,0 +1,55 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
+232
View File
@@ -0,0 +1,232 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from sqlalchemy import text
import asyncio
from app.core.database import engine, AsyncSessionLocal, Base
from app.core.config import settings
from app.api import auth, activities, routes, health, records, upload, profile, garmin_sync, users
async def init_db():
"""Create tables then seed admin, with retries for slow DB startup.
Multiple uvicorn workers may race here on first start. We tolerate
duplicate table errors since they just mean another worker got there first.
"""
for attempt in range(15):
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
break
except Exception as e:
msg = str(e).lower()
if "already exists" in msg or "duplicate" in msg or "pg_type_typname" in msg:
print("Tables already created by another worker - skipping")
break
if attempt == 14:
raise
print(f"DB not ready yet (attempt {attempt + 1}/15): {e}")
await asyncio.sleep(2)
# Try TimescaleDB hypertable (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"SELECT create_hypertable('activity_data_points', 'timestamp', "
"if_not_exists => TRUE, migrate_data => TRUE)"
))
except Exception as e:
print(f"TimescaleDB hypertable skipped: {e}")
# Add columns that were introduced after initial table creation (non-fatal)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE garmin_connect_configs "
"ADD COLUMN IF NOT EXISTS sync_lookback_days INTEGER DEFAULT 30"
))
except Exception as e:
print(f"Column migration skipped: {e}")
# health_metrics columns added after initial creation
try:
async with engine.begin() as conn:
for stmt in [
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS avg_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS max_hr_day FLOAT",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
]:
await conn.execute(text(stmt))
except Exception as e:
print(f"health_metrics column migration skipped: {e}")
# biological_sex column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS biological_sex VARCHAR(8)"
))
except Exception as e:
print(f"users.biological_sex column migration skipped: {e}")
# pocketid_allowed_group column on users added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pocketid_allowed_group VARCHAR(128)"
))
except Exception as e:
print(f"users.pocketid_allowed_group column migration skipped: {e}")
# route_segments auto_generated column added after initial creation
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated BOOLEAN DEFAULT FALSE"
))
await conn.execute(text(
"ALTER TABLE route_segments ADD COLUMN IF NOT EXISTS auto_generated_type VARCHAR(20)"
))
except Exception as e:
print(f"route_segments column migration skipped: {e}")
# Backfill avg_hr_day / max_hr_day from intraday_hr for Garmin Connect synced days
try:
async with engine.begin() as conn:
await conn.execute(text("""
UPDATE health_metrics SET
avg_hr_day = sub.avg_hr,
max_hr_day = sub.max_hr
FROM (
SELECT id,
AVG((elem->>1)::float) AS avg_hr,
MAX((elem->>1)::float) AS max_hr
FROM health_metrics,
json_array_elements(intraday_hr) AS elem
WHERE (avg_hr_day IS NULL OR max_hr_day IS NULL)
AND intraday_hr IS NOT NULL
AND (elem->>1)::float > 0
GROUP BY id
) sub
WHERE health_metrics.id = sub.id
"""))
except Exception as e:
print(f"avg_hr_day backfill skipped: {e}")
# Replace the all-columns unique constraint on personal_records with a partial
# index (only current records must be unique per user/sport/distance).
# The old constraint also covered is_current_record=False rows, causing
# UniqueViolation crashes when multiple workers deactivate the same PR.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE personal_records "
"DROP CONSTRAINT IF EXISTS uq_pr_current"
))
await conn.execute(text(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_pr_current_active "
"ON personal_records (user_id, sport_type, distance_m) "
"WHERE is_current_record = true"
))
except Exception as e:
print(f"PR constraint migration skipped: {e}")
# Ensure named_route_id FK has ON DELETE SET NULL so routes can be deleted
# without first manually unlinking every activity.
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities "
"DROP CONSTRAINT IF EXISTS activities_named_route_id_fkey"
))
await conn.execute(text(
"ALTER TABLE activities "
"ADD CONSTRAINT activities_named_route_id_fkey "
"FOREIGN KEY (named_route_id) REFERENCES named_routes(id) ON DELETE SET NULL"
))
except Exception as e:
print(f"FK migration skipped: {e}")
# Fix avg_speed_ms stored as the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s)
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE activities SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
await conn.execute(text(
"UPDATE activity_laps SET avg_speed_ms = distance_m / duration_s "
"WHERE avg_speed_ms > 30 AND distance_m > 0 AND duration_s > 0"
))
except Exception as e:
print(f"avg_speed_ms fix skipped: {e}")
# Seed admin user (only if password is configured)
if not settings.admin_password:
print("ADMIN_PASSWORD not set - skipping admin user seed")
return
from sqlalchemy import select
from app.models.user import User
from app.core.security import hash_password
try:
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.username == settings.admin_username)
)
if not result.scalar_one_or_none():
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
is_admin=True,
)
db.add(admin)
await db.commit()
print(f"Admin user '{settings.admin_username}' created")
except Exception as e:
msg = str(e).lower()
if "duplicate" in msg or "unique" in msg:
print("Admin user already exists - skipping seed")
else:
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(
title="MileVault",
version="1.0.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.environment == "development" else [],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(activities.router, prefix="/api/activities", tags=["activities"])
app.include_router(routes.router, prefix="/api/routes", tags=["routes"])
app.include_router(health.router, prefix="/api/health-metrics", tags=["health"])
app.include_router(records.router, prefix="/api/records", tags=["records"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
app.include_router(profile.router, prefix="/api/profile", tags=["profile"])
app.include_router(garmin_sync.router, prefix="/api/garmin-sync", tags=["garmin-sync"])
app.include_router(users.router, prefix="/api/users", tags=["users"])
@app.get("/health")
async def healthcheck():
return {"status": "ok"}
+261
View File
@@ -0,0 +1,261 @@
from sqlalchemy import (
Column, Integer, String, Float, DateTime, Boolean,
ForeignKey, Text, JSON, Index, UniqueConstraint, text
)
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
def now_utc():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
username = Column(String(64), unique=True, nullable=False, index=True)
email = Column(String(256), unique=True, nullable=True)
hashed_password = Column(String(256), nullable=True)
is_admin = Column(Boolean, default=False)
pocketid_sub = Column(String(256), unique=True, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
# Health profile
max_heart_rate = Column(Integer, nullable=True)
resting_heart_rate = Column(Integer, nullable=True)
birth_year = Column(Integer, nullable=True)
height_cm = Column(Float, nullable=True)
biological_sex = Column(String(8), nullable=True) # 'male' | 'female'
# PocketID config (stored per-user so admin can set via UI)
pocketid_issuer = Column(String(512), nullable=True)
pocketid_client_id = Column(String(256), nullable=True)
pocketid_client_secret = Column(String(256), nullable=True)
# Only PocketID users in this group may sign in. Null/blank = allow all.
pocketid_allowed_group = Column(String(128), nullable=True)
activities = relationship("Activity", back_populates="user", cascade="all, delete-orphan")
health_metrics = relationship("HealthMetric", back_populates="user", cascade="all, delete-orphan")
named_routes = relationship("NamedRoute", back_populates="user", cascade="all, delete-orphan")
weight_logs = relationship("WeightLog", back_populates="user", cascade="all, delete-orphan")
garmin_connect_config = relationship("GarminConnectConfig", back_populates="user", uselist=False, cascade="all, delete-orphan")
class GarminConnectConfig(Base):
"""Per-user Garmin Connect credentials and sync state."""
__tablename__ = "garmin_connect_configs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True, index=True)
email = Column(String(256), nullable=False)
password_enc = Column(String(512), nullable=False) # Fernet-encrypted
token_store = Column(Text, nullable=True) # garth OAuth2 token JSON
sync_enabled = Column(Boolean, default=True)
sync_activities = Column(Boolean, default=True)
sync_wellness = Column(Boolean, default=True)
sync_lookback_days = Column(Integer, default=30) # -1 = all-time
last_sync_at = Column(DateTime(timezone=True), nullable=True)
last_sync_status = Column(String(512), nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="garmin_connect_config")
class WeightLog(Base):
"""Manual weight entries separate from health_metrics for easy tracking."""
__tablename__ = "weight_logs"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
weight_kg = Column(Float, nullable=False)
body_fat_pct = Column(Float, nullable=True)
note = Column(String(256), nullable=True)
__table_args__ = (
Index("ix_weight_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="weight_logs")
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
sport_type = Column(String(64), nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
max_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
normalized_power = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
max_speed_ms = Column(Float, nullable=True)
avg_temperature_c = Column(Float, nullable=True)
calories = Column(Float, nullable=True)
training_stress_score = Column(Float, nullable=True)
vo2max_estimate = Column(Float, nullable=True)
named_route_id = Column(Integer, ForeignKey("named_routes.id", ondelete="SET NULL"), nullable=True)
polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
source_file = Column(String(512), nullable=True)
source_type = Column(String(32), nullable=True)
garmin_activity_id = Column(String(64), nullable=True, unique=True)
strava_activity_id = Column(String(64), nullable=True, unique=True)
hr_zones = Column(JSON, nullable=True)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="activities")
data_points = relationship("ActivityDataPoint", back_populates="activity", cascade="all, delete-orphan")
named_route = relationship("NamedRoute", back_populates="activities")
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
class ActivityDataPoint(Base):
__tablename__ = "activity_data_points"
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, primary_key=True)
timestamp = Column(DateTime(timezone=True), nullable=False, primary_key=True)
latitude = Column(Float, nullable=True)
longitude = Column(Float, nullable=True)
altitude_m = Column(Float, nullable=True)
heart_rate = Column(Float, nullable=True)
cadence = Column(Float, nullable=True)
speed_ms = Column(Float, nullable=True)
power = Column(Float, nullable=True)
temperature_c = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="data_points")
class ActivityLap(Base):
__tablename__ = "activity_laps"
id = Column(Integer, primary_key=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False, index=True)
lap_number = Column(Integer, nullable=False)
start_time = Column(DateTime(timezone=True), nullable=True)
duration_s = Column(Float, nullable=True)
distance_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
avg_cadence = Column(Float, nullable=True)
avg_speed_ms = Column(Float, nullable=True)
avg_power = Column(Float, nullable=True)
activity = relationship("Activity", back_populates="laps")
class NamedRoute(Base):
__tablename__ = "named_routes"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
description = Column(Text, nullable=True)
sport_type = Column(String(64), nullable=True)
reference_polyline = Column(Text, nullable=True)
bounding_box = Column(JSON, nullable=True)
distance_m = Column(Float, nullable=True)
auto_detected = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), default=now_utc)
user = relationship("User", back_populates="named_routes")
activities = relationship("Activity", back_populates="named_route")
segments = relationship("RouteSegment", back_populates="route", cascade="all, delete-orphan")
class RouteSegment(Base):
__tablename__ = "route_segments"
id = Column(Integer, primary_key=True)
route_id = Column(Integer, ForeignKey("named_routes.id"), nullable=False, index=True)
name = Column(String(256), nullable=False)
start_distance_m = Column(Float, nullable=False)
end_distance_m = Column(Float, nullable=False)
description = Column(Text, nullable=True)
auto_generated = Column(Boolean, default=False)
auto_generated_type = Column(String(20), nullable=True) # '1km' | 'turns' | 'hills'
route = relationship("NamedRoute", back_populates="segments")
class PersonalRecord(Base):
__tablename__ = "personal_records"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
activity_id = Column(Integer, ForeignKey("activities.id"), nullable=False)
sport_type = Column(String(64), nullable=False)
distance_m = Column(Float, nullable=False)
distance_label = Column(String(32), nullable=False)
duration_s = Column(Float, nullable=False)
achieved_at = Column(DateTime(timezone=True), nullable=False)
is_current_record = Column(Boolean, default=True)
__table_args__ = (
# Uniqueness is enforced at runtime by the partial index uq_pr_current_active
# (created in init_db), which only covers is_current_record=true rows.
# The old all-columns UniqueConstraint was dropped because it incorrectly
# constrained is_current_record=false rows too, causing multi-worker races.
Index("uq_pr_current_active", "user_id", "sport_type", "distance_m",
postgresql_where=text("is_current_record = true"), unique=True),
)
class HealthMetric(Base):
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
date = Column(DateTime(timezone=True), nullable=False)
resting_hr = Column(Float, nullable=True)
max_hr_day = Column(Float, nullable=True)
avg_hr_day = Column(Float, nullable=True)
hrv_status = Column(String(32), nullable=True)
hrv_nightly_avg = Column(Float, nullable=True)
hrv_5min_high = Column(Float, nullable=True)
hrv_5min_low = Column(Float, nullable=True)
sleep_duration_s = Column(Float, nullable=True)
sleep_deep_s = Column(Float, nullable=True)
sleep_light_s = Column(Float, nullable=True)
sleep_rem_s = Column(Float, nullable=True)
sleep_awake_s = Column(Float, nullable=True)
sleep_score = Column(Float, nullable=True)
sleep_start = Column(DateTime(timezone=True), nullable=True)
sleep_end = Column(DateTime(timezone=True), nullable=True)
weight_kg = Column(Float, nullable=True)
bmi = Column(Float, nullable=True)
body_fat_pct = Column(Float, nullable=True)
muscle_mass_kg = Column(Float, nullable=True)
vo2max = Column(Float, nullable=True)
fitness_age = Column(Integer, nullable=True)
training_load = Column(Float, nullable=True)
recovery_time_h = Column(Float, nullable=True)
avg_stress = Column(Float, nullable=True)
steps = Column(Integer, nullable=True)
floors_climbed = Column(Integer, nullable=True)
active_calories = Column(Float, nullable=True)
total_calories = Column(Float, nullable=True)
spo2_avg = Column(Float, nullable=True)
intraday_hr = Column(JSON, nullable=True) # [[epoch_ms, bpm], ...] — not in API list response
body_battery = Column(JSON, nullable=True) # {charged,drained,start_level,end_level,values:[[ts_ms,level,type,stress]...]}
body_battery_hires = Column(JSON, nullable=True) # [[ts_ms, level], ...] interpolated from bb + HR; higher resolution than raw values
sleep_stages = Column(JSON, nullable=True) # [[ts_ms, level], ...] 0=unmeasurable,1=awake,2=light,3=deep,4=rem
__table_args__ = (
UniqueConstraint("user_id", "date", name="uq_health_user_date"),
Index("ix_health_user_date", "user_id", "date"),
)
user = relationship("User", back_populates="health_metrics")
@@ -0,0 +1,351 @@
"""
FIT and GPX file parser.
Parses FIT files directly using the Garmin SDK but applies manual
scale conversion for fields where the SDK doesn't auto-convert.
"""
import math
import struct
from datetime import datetime, timezone
from typing import Optional
import gpxpy
import polyline as polyline_lib
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
def _semicircles_to_deg(val):
if val is None:
return None
try:
result = float(val) * SEMICIRCLES_TO_DEG
if -90 <= result <= 90 or -180 <= result <= 180:
return result
except (TypeError, ValueError):
pass
return None
def _safe_float(val) -> Optional[float]:
try:
return float(val) if val is not None else None
except (TypeError, ValueError):
return None
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
fv = _safe_float(val)
if fv is None or fv >= 65.0:
if dist_m and dur_s and float(dur_s) > 0:
return float(dist_m) / float(dur_s)
return None
return fv
def _bounding_box(coords):
if not coords:
return None
lats = [c[0] for c in coords]
lons = [c[1] for c in coords]
return {"min_lat": min(lats), "max_lat": max(lats),
"min_lon": min(lons), "max_lon": max(lons)}
def _to_dt(val) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
try:
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return None
return None
def _is_valid_lat(v):
return v is not None and -90 <= v <= 90
def _is_valid_lon(v):
return v is not None and -180 <= v <= 180
def parse_fit_file(filepath: str) -> dict:
session_data = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
if mesg_num == 18: # session
session_data.update(msg)
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
laps.append(msg)
stream = Stream.from_file(filepath)
decoder = Decoder(stream)
decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
mesg_listener=listener,
)
# The SDK may return field names in camelCase or snake_case depending on version.
# Try both. Also handle raw timestamp integers for start_time.
def get(d, *keys):
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
sport_map = {
"running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking",
"generic": "other", "trail_running": "running",
"e_biking": "cycling", "open_water_swimming": "other",
}
sport_type = sport_map.get(sport_raw, sport_raw)
# start_time — SDK may return datetime or raw int
start_time_raw = get(session_data, "startTime", "start_time")
start_time = _to_dt(start_time_raw)
# Position fields — the SDK may or may not convert semicircles.
# Check if values look like semicircles (>= 90 for lat) and convert if so.
def get_lat(d):
v = get(d, "positionLat", "position_lat")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
# If absolute value > 90, it's semicircles
if abs(fv) > 90:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lat(fv) else None
def get_lon(d):
v = get(d, "positionLong", "position_long")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
if abs(fv) > 180:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lon(fv) else None
# Build GPS track
coords = []
for r in records:
lat = get_lat(r)
lon = get_lon(r)
if lat is not None and lon is not None:
coords.append((lat, lon))
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = []
for r in records:
ts = _to_dt(get(r, "timestamp"))
lat = get_lat(r)
lon = get_lon(r)
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
hr = get(r, "heartRate", "heart_rate")
cadence = get(r, "cadence")
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
power = get(r, "power")
temp = get(r, "temperature")
distance = get(r, "distance")
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": _safe_float(lat),
"longitude": _safe_float(lon),
"altitude_m": _safe_float(altitude),
"heart_rate": _safe_float(hr),
"cadence": _safe_float(cadence),
"speed_ms": _safe_float(speed),
"power": _safe_float(power),
"temperature_c": _safe_float(temp),
"distance_m": _safe_float(distance),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = _to_dt(get(lap, "startTime", "start_time"))
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": lap_dur,
"distance_m": lap_dist,
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
"avg_speed_ms": _sanitize_speed(
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=lap_dist, dur_s=lap_dur,
),
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
})
name = sport_type.title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
return {
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
"training_stress_score")),
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
"total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"data_points": normalized_points,
"laps": normalized_laps,
}
def parse_gpx_file(filepath: str) -> dict:
with open(filepath) as f:
gpx = gpxpy.parse(f)
data_points = []
track = gpx.tracks[0] if gpx.tracks else None
if not track:
raise ValueError("No tracks found in GPX file")
for segment in track.segments:
for pt in segment.points:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
for child in ext:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
try:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude, "longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
"speed_ms": extensions.get("speed"),
"power": extensions.get("power"),
"temperature_c": extensions.get("temp") or extensions.get("atemp"),
"distance_m": None,
})
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
R = 6371000
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
dphi = math.radians(p["latitude"] - prev[0])
dlam = math.radians(p["longitude"] - prev[1])
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
total_dist += 2 * R * math.asin(math.sqrt(a))
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
diff = alts[i] - alts[i-1]
if diff > 0: uphill += diff
else: downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running"
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration,
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx", "data_points": data_points, "laps": [],
}
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
if not user_max_hr or user_max_hr < 100:
return {}
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr or hr < 20:
continue
pct = hr / user_max_hr
total += 1
for i, key in enumerate(zone_keys):
if zone_bounds[i] <= pct < zone_bounds[i+1]:
zones[key] += 1
break
else:
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
@@ -0,0 +1,582 @@
"""
Garmin Connect sync helpers.
authenticate_garmin() returns an authenticated client, refreshing the stored
OAuth token when possible and falling back to email/password re-login.
sync_activities() downloads new FIT files and queues them for processing.
sync_wellness() pulls daily stats/sleep/HRV summaries from the JSON API
and upserts them into health_metrics.
"""
import io
import zipfile
import logging
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
# ── Password encryption ─────────────────────────────────────────────────────
def _fernet():
import base64, hashlib
from cryptography.fernet import Fernet
from app.core.config import settings
key = base64.urlsafe_b64encode(hashlib.sha256(settings.secret_key.encode()).digest())
return Fernet(key)
def encrypt_password(password: str) -> str:
return _fernet().encrypt(password.encode()).decode()
def decrypt_password(enc: str) -> str:
return _fernet().decrypt(enc.encode()).decode()
# ── Auth ─────────────────────────────────────────────────────────────────────
def authenticate_garmin(email: str, password_enc: str, token_store: Optional[str]) -> Tuple:
"""
Returns (garmin_client, new_token_store_or_None).
new_token_store is set only when tokens were refreshed/re-created so the
caller can persist them.
"""
import garminconnect
# Try stored OAuth token first.
# Use garth.loads() directly (always treats the argument as an inline string).
# garmin.login(tokenstore=...) dispatches on len>512, treating short tokens as
# filesystem paths and raising FileNotFoundError on every token-based auth attempt.
# After loads(), set display_name from the embedded profile — required by
# get_stats(), get_sleep_data(), and other endpoints that build URLs from it.
if token_store:
try:
garmin = garminconnect.Garmin(
email=email, password=decrypt_password(password_enc)
)
garmin.garth.loads(token_store)
garmin.display_name = (garmin.garth.profile or {}).get("displayName", "")
return garmin, None
except Exception as exc:
logger.info("Garmin token invalid (%s), re-authenticating", exc)
# Full login with email + password
garmin = garminconnect.Garmin(email=email, password=decrypt_password(password_enc))
garmin.login()
return garmin, garmin.garth.dumps()
# ── Activity sync ─────────────────────────────────────────────────────────────
def sync_activities(garmin, user_id: int, since: Optional[datetime],
db, file_store_path: str, lookback_days: int = 30,
status_callback=None) -> int:
"""
List activities from Garmin Connect, skip any already in the DB, download
FIT ZIPs for new ones, and queue them for processing.
lookback_days controls the start date on every sync:
-1 → full history back to 2010 on first sync, then incremental (since-1d)
N → incremental (since-1d) when since is set; else last N days on first sync
Returns the number of new activities queued.
"""
import time
from app.workers.tasks import process_activity_file
from app.models.user import Activity
from sqlalchemy import select, func
if lookback_days == -1:
# All-time: full pull on first sync, incremental thereafter
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
elif since:
# Use whichever is earlier: one day before last sync OR the configured lookback
# window. This ensures increasing lookback_days actually fetches older data.
incremental = (since - timedelta(days=1)).date()
lookback = date.today() - timedelta(days=max(lookback_days, 1))
start_date = min(incremental, lookback)
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
end_date = date.today()
try:
activities = garmin.get_activities_by_date(
start_date.isoformat(), end_date.isoformat()
)
except Exception as exc:
logger.error("Failed to list Garmin activities: %s", exc)
return 0
total = len(activities)
if status_callback and total:
status_callback(f"Syncing activities: 0/{total} queued")
queued = 0
for act in activities:
garmin_id = str(act.get("activityId", "")).strip()
if not garmin_id:
continue
# Fast path: already imported via Garmin Connect sync
existing = db.execute(
select(Activity).where(Activity.garmin_activity_id == garmin_id)
).scalar_one_or_none()
if existing:
continue
# Slow-path dedup: activity imported via bulk export (no garmin_activity_id).
# Check by start_time; stamp the ID so future syncs skip it in the fast path.
act_start_str = act.get("startTimeLocal") or act.get("startTimeGMT") or ""
if act_start_str:
try:
from datetime import datetime as _dt
act_start = _dt.fromisoformat(act_start_str.replace("Z", "+00:00"))
time_match = db.execute(
select(Activity).where(
Activity.user_id == user_id,
func.date(Activity.start_time) == act_start.date(),
)
).scalar_one_or_none()
if time_match:
if not time_match.garmin_activity_id:
time_match.garmin_activity_id = garmin_id
db.commit()
continue
except Exception:
pass # couldn't parse time — fall through to download
# Download original FIT (Garmin wraps it in a ZIP)
try:
zip_bytes = garmin.download_activity(
int(garmin_id),
dl_fmt=garmin.ActivityDownloadFormat.ORIGINAL,
)
except Exception as exc:
logger.warning("Failed to download activity %s: %s", garmin_id, exc)
continue
# Extract the FIT from the ZIP
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
if not fit_names:
logger.debug("No FIT in ZIP for activity %s", garmin_id)
continue
fit_data = zf.read(fit_names[0])
except Exception as exc:
logger.warning("Failed to unzip activity %s: %s", garmin_id, exc)
continue
# Save to disk and queue
dest_dir = Path(file_store_path) / str(user_id) / "garmin_connect"
dest_dir.mkdir(parents=True, exist_ok=True)
dest = dest_dir / f"{garmin_id}.fit"
dest.write_bytes(fit_data)
process_activity_file.delay(str(dest), user_id, "fit", garmin_id)
queued += 1
if status_callback and (queued % 5 == 0 or queued == total):
status_callback(f"Syncing activities: {queued}/{total} queued")
# Brief pause to avoid hammering the Garmin API
time.sleep(0.5)
return queued
# ── Wellness sync ─────────────────────────────────────────────────────────────
def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
lookback_days: int = 90, status_callback=None) -> int:
"""
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
day in the window and upsert into health_metrics.
lookback_days controls the window on every sync:
-1 → full history back to 2010 on first sync, then incremental (since-1d)
N → incremental (since-1d) when since is set; else last N days on first sync
Returns the number of days upserted.
"""
from sqlalchemy import text
if lookback_days == -1:
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1)
elif since:
# Use whichever is earlier: one day before last sync OR the configured lookback
# window. This ensures increasing lookback_days actually fetches older data.
incremental = (since - timedelta(days=1)).date()
lookback = date.today() - timedelta(days=max(lookback_days, 1))
start_date = min(incremental, lookback)
else:
start_date = date.today() - timedelta(days=max(lookback_days, 1))
days = (date.today() - start_date).days + 1
processed = 0
import time as _time
import json as _json
total_days = max(days, 1)
if status_callback:
status_callback(f"Syncing wellness: 0/{total_days} days")
for i in range(total_days):
day = start_date + timedelta(days=i)
if status_callback and (i % 5 == 0 or i == total_days - 1):
status_callback(f"Syncing wellness: {i + 1}/{total_days} days")
day_str = day.isoformat()
stats = _safe(garmin.get_stats, day_str)
sleep_data = _safe(garmin.get_sleep_data, day_str)
hrv_data = _safe(garmin.get_hrv_data, day_str)
# Intraday HR (requires display_name; skip gracefully if absent)
hr_raw = _safe(garmin.get_heart_rates, day_str) if garmin.display_name else None
bc_data = _safe(garmin.get_body_composition, day_str, day_str)
bb_raw = _safe(garmin.get_body_battery, day_str, day_str)
_time.sleep(0.25) # avoid hammering Garmin's wellness API
row = _parse_day(stats, sleep_data, hrv_data)
# Weight + body composition from weight service (more reliable than stats)
if bc_data:
entries = (bc_data.get("dateWeightList")
or bc_data.get("allWeightMetrics")
or bc_data.get("weightList") or [])
if entries:
e = entries[0]
bw = e.get("weight")
if bw and float(bw) > 0:
bwf = float(bw)
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
if e.get("bmi"):
_set(row, "bmi", float(e["bmi"]))
if e.get("bodyFat"):
_set(row, "body_fat_pct", float(e["bodyFat"]))
mm = e.get("muscleMass")
if mm and float(mm) > 0:
mmf = float(mm)
_set(row, "muscle_mass_kg", round(mmf / 1000 if mmf > 300 else mmf, 2))
# Weight from daily stats as fallback (present when Garmin scale is used)
if stats and "weight_kg" not in row:
bw = stats.get("bodyWeight")
if bw and float(bw) > 0:
bwf = float(bw)
_set(row, "weight_kg", round(bwf / 1000 if bwf > 300 else bwf, 2))
# Body battery — store summary + fine-grained timeline
bb = None
if bb_raw:
bb = _parse_body_battery(bb_raw, day_str)
if bb:
row["body_battery"] = _json.dumps(bb)
# Intraday heart rate — store non-null [epoch_ms, bpm] pairs + compute daily averages
intraday = None
if hr_raw:
raw_vals = hr_raw.get("heartRateValues") or []
intraday = [[int(ts), int(v)] for ts, v in raw_vals if v is not None]
if intraday:
row["intraday_hr"] = intraday
hr_vals = [v for _, v in intraday if v > 0]
if hr_vals:
row["avg_hr_day"] = round(sum(hr_vals) / len(hr_vals), 1)
row["max_hr_day"] = float(max(hr_vals))
# High-resolution body battery derived from BB checkpoints + intraday HR
if bb and intraday:
hires = _compute_body_battery_hires(bb.get("values") or [], intraday)
if hires:
row["body_battery_hires"] = _json.dumps(hires)
if not row:
continue
# psycopg2 treats Python lists/dicts as PG arrays/hstore; serialize JSON
# columns as strings so psycopg2 passes them correctly to json/jsonb columns.
if "intraday_hr" in row and not isinstance(row["intraday_hr"], str):
row["intraday_hr"] = _json.dumps(row["intraday_hr"])
if "body_battery" in row and not isinstance(row["body_battery"], str):
row["body_battery"] = _json.dumps(row["body_battery"])
cols = list(row.keys())
col_sql = ", ".join(cols)
val_sql = ", ".join(f":{c}" for c in cols)
upd_sql = ", ".join(
# total_calories uses GREATEST so multiple sources don't downgrade
f"{c} = GREATEST(EXCLUDED.{c}, health_metrics.{c})"
if c == "total_calories" else
f"{c} = COALESCE(EXCLUDED.{c}, health_metrics.{c})"
for c in cols
)
params = {"user_id": user_id, "day": day.isoformat()}
params.update(row)
try:
db.execute(text(f"""
INSERT INTO health_metrics (user_id, date, {col_sql})
VALUES (:user_id, :day, {val_sql})
ON CONFLICT (user_id, date) DO UPDATE SET {upd_sql}
"""), params)
db.commit()
processed += 1
except Exception as exc:
logger.warning("Failed to upsert health_metrics for %s: %s", day_str, exc)
db.rollback()
# Fetch historical VO2 max across the full sync window via maxmet/daily range query
today_str = date.today().isoformat()
fa_data = _safe(garmin.get_fitnessage_data, today_str)
fa_age = None
if fa_data:
fa_age = fa_data.get("fitnessAge") or fa_data.get("achievableFitnessAge")
mm_entries = []
try:
mm_raw = garmin.connectapi(
f"/metrics-service/metrics/maxmet/daily/{start_date.isoformat()}/{today_str}"
)
logger.info("maxmet range query returned type=%s len=%s",
type(mm_raw).__name__,
len(mm_raw) if isinstance(mm_raw, (list, dict)) else "n/a")
if isinstance(mm_raw, list):
mm_entries = mm_raw
except Exception as exc:
logger.info("maxmet history fetch failed: %s", exc)
# Each entry has the vo2max data nested under entry["generic"]
def _extract_generic(entry):
return (entry.get("generic") or {}) if isinstance(entry, dict) else {}
valid_from_range = any(
(_extract_generic(e).get("vo2MaxPreciseValue") or _extract_generic(e).get("vo2MaxValue") or 0)
for e in mm_entries
)
# Always fall back to training_status when the range query had no valid data
if not valid_from_range:
ts_data = _safe(garmin.get_training_status, today_str)
generic = ((ts_data or {}).get("mostRecentVO2Max") or {}).get("generic") or {}
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
logger.info("training_status vo2max=%s at %s", v, generic.get("calendarDate"))
if v and float(v) > 0:
mm_entries = [{"generic": {"calendarDate": generic.get("calendarDate") or today_str,
"vo2MaxPreciseValue": float(v)}}]
stored = 0
for entry in mm_entries:
generic = _extract_generic(entry)
v = generic.get("vo2MaxPreciseValue") or generic.get("vo2MaxValue")
if not v or float(v) <= 0:
continue
entry_date = generic.get("calendarDate") or today_str
try:
fa_row = {"vo2max": float(v)}
if fa_age and entry_date == today_str:
fa_row["fitness_age"] = int(fa_age)
fa_cols = list(fa_row.keys())
db.execute(text(f"""
INSERT INTO health_metrics (user_id, date, {", ".join(fa_cols)})
VALUES (:user_id, :day, {", ".join(f":{c}" for c in fa_cols)})
ON CONFLICT (user_id, date) DO UPDATE SET
{", ".join(f"{c} = EXCLUDED.{c}" for c in fa_cols)}
"""), {"user_id": user_id, "day": entry_date, **fa_row})
db.commit()
stored += 1
except Exception as exc:
logger.warning("Failed to upsert VO2 max for %s: %s", entry_date, exc)
db.rollback()
logger.info("VO2 max: stored=%d from range_valid=%s", stored, valid_from_range)
return processed
def _parse_body_battery(bb_response, day_str: str):
"""Parse get_body_battery() response for a single day into a compact dict."""
if not bb_response:
return None
entry = next((e for e in bb_response if e.get("date") == day_str), None)
if not entry and bb_response:
entry = bb_response[0]
if not entry:
return None
charged = entry.get("charged")
drained = entry.get("drained")
start_lvl = entry.get("startValue")
end_lvl = entry.get("endValue")
# Fine-grained timeline: [[ts_ms, level, type_code, stress], ...]
# type_code: 0=REST, 1=ACTIVE, 2=SLEEP, 3=STRESS, 4=UNMEASURABLE
values = entry.get("bodyBatteryValuesArray") or []
if not values:
# Fall back to bodyBatteryStatList (segment-level data)
type_map = {"REST": 0, "ACTIVE": 1, "SLEEP": 2, "STRESS": 3, "UNMEASURABLE": 4}
for seg in (entry.get("bodyBatteryStatList") or []):
ts_str = seg.get("startTimestampGMT") or seg.get("startTimestampLocal")
if ts_str:
try:
from datetime import datetime as _dt, timezone as _tz
ts = _dt.fromisoformat(ts_str.rstrip("Z")).replace(tzinfo=_tz.utc)
type_code = type_map.get(seg.get("activityType", "UNMEASURABLE"), 4)
values.append([int(ts.timestamp() * 1000),
int(seg.get("bodyBatteryLevel") or 0),
type_code,
int(seg.get("stressLevel") or -1)])
except Exception:
pass
if charged is None and end_lvl is None and not values:
return None
return {
"charged": charged,
"drained": drained,
"start_level": start_lvl,
"end_level": end_lvl,
"values": values, # stripped from list-API, returned in intraday endpoint
}
def _compute_body_battery_hires(bb_values, intraday_hr):
"""
Produce a higher-resolution body battery series by interpolating between
sparse BB checkpoints using intraday HR as a proxy for effort.
During drain segments (BB falling) the drain is distributed proportionally
to how much each HR reading exceeds the day's median — peaks spend battery
faster than valleys. During recovery segments (BB rising) recovery is
spread uniformly over time.
Returns [[ts_ms, level], ...] at the granularity of intraday HR, or None
if inputs are insufficient.
"""
if not bb_values or not intraday_hr or len(bb_values) < 2:
return None
# Drop entries with None timestamp or level — raw API data can have gaps
bb = sorted([v for v in bb_values if v[0] is not None and v[1] is not None],
key=lambda x: x[0])
if len(bb) < 2:
return None
hr = sorted(intraday_hr, key=lambda x: x[0])
hr_vals = [bpm for _, bpm in hr if bpm is not None and bpm > 0]
if not hr_vals:
return None
hr_median = sorted(hr_vals)[len(hr_vals) // 2]
result = []
for i in range(len(bb) - 1):
t1, L1 = bb[i][0], bb[i][1]
t2, L2 = bb[i + 1][0], bb[i + 1][1]
delta = L2 - L1
seg_hr = [(ts, bpm) for ts, bpm in hr if t1 <= ts <= t2 and bpm is not None]
result.append([t1, round(float(L1), 1)])
if not seg_hr or abs(delta) < 1:
continue
if delta < 0:
# Drain: weight each reading by HR above median
efforts = [max(0.0, bpm - hr_median) for _, bpm in seg_hr]
total = sum(efforts) or 1.0
cumul = 0.0
for j, (ts, bpm) in enumerate(seg_hr):
cumul += efforts[j] * delta / total
level = max(0.0, min(100.0, L1 + cumul))
result.append([ts, round(level, 1)])
else:
# Recovery: linear over time
span = max(1, t2 - t1)
for ts, _ in seg_hr:
frac = (ts - t1) / span
level = max(0.0, min(100.0, L1 + delta * frac))
result.append([ts, round(level, 1)])
result.append([bb[-1][0], round(float(bb[-1][1]), 1)])
# Deduplicate and sort
seen, out = set(), []
for item in sorted(result, key=lambda x: x[0]):
if item[0] not in seen:
seen.add(item[0])
out.append(item)
return out if len(out) > 4 else None
def _safe(fn, *args):
try:
return fn(*args)
except Exception as exc:
logger.debug("%s(%s) skipped: %s", fn.__name__, args, exc)
return None
def _parse_day(stats, sleep_data, hrv_data) -> dict:
row = {}
if stats:
_set(row, "resting_hr", stats.get("restingHeartRate"))
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
_set(row, "max_hr_day", stats.get("maxHeartRate"))
_set(row, "steps", stats.get("totalSteps"))
_set(row, "floors_climbed", stats.get("floorsAscended"))
_set(row, "avg_stress", stats.get("averageStressLevel"))
active = stats.get("activeKilocalories")
bmr = stats.get("bmrKilocalories")
_set(row, "active_calories", active)
if active and bmr:
_set(row, "total_calories", float(active) + float(bmr))
if sleep_data:
dto = sleep_data.get("dailySleepDTO") or sleep_data
_set(row, "sleep_duration_s", dto.get("sleepTimeSeconds"))
_set(row, "sleep_deep_s", dto.get("deepSleepSeconds"))
_set(row, "sleep_light_s", dto.get("lightSleepSeconds"))
_set(row, "sleep_rem_s", dto.get("remSleepSeconds"))
_set(row, "sleep_awake_s", dto.get("awakeSleepSeconds"))
# Timestamps are milliseconds since epoch in local time
for key, col in (("sleepStartTimestampLocal", "sleep_start"),
("sleepEndTimestampLocal", "sleep_end")):
ms = dto.get(key)
if ms:
_set(row, col, datetime.fromtimestamp(ms / 1000, tz=timezone.utc).isoformat())
# SpO2
spo2 = dto.get("averageSpO2Value")
if spo2 and 50 < float(spo2) <= 100:
row["spo2_avg"] = float(spo2)
# Sleep score — structure varies across firmware
scores = sleep_data.get("sleepScores") or sleep_data.get("sleepScore")
if isinstance(scores, dict):
overall = scores.get("overall") or scores.get("qualityScore")
if isinstance(overall, dict):
_set(row, "sleep_score", overall.get("value"))
else:
_set(row, "sleep_score", overall)
elif isinstance(scores, (int, float)):
row["sleep_score"] = scores
if hrv_data:
summary = hrv_data.get("hrvSummary") or hrv_data
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg"))
_set(row, "hrv_5min_high", summary.get("lastNight5MinHigh"))
status = summary.get("status")
if status:
row["hrv_status"] = str(status).lower()
return row
def _set(d: dict, key: str, val):
if val is not None:
d[key] = val
@@ -0,0 +1,348 @@
"""
Route matching: identifies when multiple activities were on the same route.
Uses a bounding-box pre-filter + dynamic time warping (DTW) for GPS track similarity.
"""
import math
from typing import Optional
import polyline as polyline_lib
import numpy as np
def decode_polyline_to_coords(encoded: str) -> list[tuple[float, float]]:
return polyline_lib.decode(encoded)
def bounding_boxes_overlap(bb1: dict, bb2: dict, tolerance_deg: float = 0.005) -> bool:
"""Quick check: do two bounding boxes overlap (with a tolerance margin)?"""
return (
bb1["min_lat"] - tolerance_deg <= bb2["max_lat"] + tolerance_deg and
bb1["max_lat"] + tolerance_deg >= bb2["min_lat"] - tolerance_deg and
bb1["min_lon"] - tolerance_deg <= bb2["max_lon"] + tolerance_deg and
bb1["max_lon"] + tolerance_deg >= bb2["min_lon"] - tolerance_deg
)
def sample_coords(coords: list[tuple], n: int = 100) -> list[tuple]:
"""Downsample a track to n evenly-spaced points for DTW efficiency."""
if len(coords) <= n:
return coords
indices = [int(i * (len(coords) - 1) / (n - 1)) for i in range(n)]
return [coords[i] for i in indices]
def dtw_distance(track1: list[tuple], track2: list[tuple]) -> float:
"""
Compute DTW distance between two GPS tracks.
Each point is (lat, lon). Returns average distance in metres per matched pair.
"""
n, m = len(track1), len(track2)
dtw = np.full((n + 1, m + 1), np.inf)
dtw[0][0] = 0.0
for i in range(1, n + 1):
for j in range(1, m + 1):
cost = haversine_m(track1[i-1], track2[j-1])
dtw[i][j] = cost + min(dtw[i-1][j], dtw[i][j-1], dtw[i-1][j-1])
return dtw[n][m] / max(n, m)
def haversine_m(p1: tuple, p2: tuple) -> float:
R = 6371000
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1)*math.cos(lat2)*math.sin(dlon/2)**2
return 2 * R * math.asin(math.sqrt(a))
def routes_are_similar(
poly1: str,
poly2: str,
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
dist1: Optional[float] = None,
dist2: Optional[float] = None,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
When dist1/dist2 are provided:
- Rejects if distance differs by more than 2.5%
- Uses 3% of route distance as the DTW threshold (capped at 300m)
"""
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
return False
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
try:
coords1 = sample_coords(decode_polyline_to_coords(poly1), 60)
coords2 = sample_coords(decode_polyline_to_coords(poly2), 60)
except Exception:
return False
if not coords1 or not coords2:
return False
dist = dtw_distance(coords1, coords2)
return dist < dtw_threshold_m
def find_segment_times(
data_points: list[dict],
start_dist_m: float,
end_dist_m: float,
) -> Optional[float]:
"""
Given activity data points (with cumulative distance_m),
find the time to traverse from start_dist_m to end_dist_m.
Returns duration in seconds, or None if not found.
"""
start_time = None
end_time = None
for p in data_points:
dist = p.get("distance_m")
ts = p.get("timestamp")
if dist is None or ts is None:
continue
if start_time is None and dist >= start_dist_m:
start_time = ts
if start_time is not None and dist >= end_dist_m:
end_time = ts
break
if start_time and end_time:
from datetime import datetime
t1 = datetime.fromisoformat(start_time) if isinstance(start_time, str) else start_time
t2 = datetime.fromisoformat(end_time) if isinstance(end_time, str) else end_time
return (t2 - t1).total_seconds()
return None
def find_best_split_time(
data_points: list[dict],
target_distance_m: float,
) -> Optional[float]:
"""
Find the best (fastest) time over any target_distance_m window within an activity.
E.g. fastest 1km split in a 10km run.
Returns duration in seconds.
"""
points_with_dist = [
p for p in data_points
if p.get("distance_m") is not None and p.get("timestamp") is not None
]
if not points_with_dist:
return None
best = None
j = 0
for i, start_p in enumerate(points_with_dist):
start_dist = start_p["distance_m"]
start_ts = start_p["timestamp"]
# Advance j until distance covered >= target
while j < len(points_with_dist):
end_p = points_with_dist[j]
covered = end_p["distance_m"] - start_dist
if covered >= target_distance_m:
from datetime import datetime
t1 = datetime.fromisoformat(start_ts) if isinstance(start_ts, str) else start_ts
t2 = datetime.fromisoformat(end_p["timestamp"]) if isinstance(end_p["timestamp"], str) else end_p["timestamp"]
duration = (t2 - t1).total_seconds()
if best is None or duration < best:
best = duration
break
j += 1
if j >= len(points_with_dist):
break
return best
def _bearing(p1: tuple, p2: tuple) -> float:
"""Compass bearing in degrees (0-360) from p1 to p2."""
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return math.degrees(math.atan2(x, y)) % 360
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
if not encoded_polyline:
return []
km_count = int(total_dist_m / 1000)
segments = []
for i in range(km_count):
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
remainder = total_dist_m - km_count * 1000
if remainder >= 200:
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
return segments
def generate_turn_segments(
encoded_polyline: str,
turn_angle_deg: float = 45.0,
) -> list[tuple[str, float, float]]:
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
coords = decode_polyline_to_coords(encoded_polyline)
if len(coords) < 3:
return []
cum_dists = [0.0]
for i in range(1, len(coords)):
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
total = cum_dists[-1]
HALF_WINDOW = 100.0 # metres either side of candidate turn point
turn_centers: list[float] = []
for i in range(1, len(coords) - 1):
# Find index ~HALF_WINDOW before and after
start_i = i
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
start_i -= 1
end_i = i
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
end_i += 1
if start_i == i or end_i == i:
continue
b1 = _bearing(coords[start_i], coords[i])
b2 = _bearing(coords[i], coords[end_i])
diff = abs(b2 - b1) % 360
if diff > 180:
diff = 360 - diff
if diff >= turn_angle_deg:
turn_centers.append(cum_dists[i])
if not turn_centers:
return []
# Cluster turns within 150 m of each other → one segment per cluster
clusters: list[list[float]] = [[turn_centers[0]]]
for d in turn_centers[1:]:
if d - clusters[-1][-1] < 150:
clusters[-1].append(d)
else:
clusters.append([d])
segments = []
for cluster in clusters:
center = sum(cluster) / len(cluster)
start = max(0.0, center - HALF_WINDOW)
end = min(total, center + HALF_WINDOW)
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
return segments
def generate_hill_segments(
data_points: list[dict],
gradient_pct: float = 5.0,
) -> list[tuple[str, float, float]]:
"""
Detect uphill sections using activity data points (with altitude_m + distance_m).
Returns list of (name, start_m, end_m).
"""
pts = [
(p["distance_m"], p["altitude_m"])
for p in data_points
if p.get("distance_m") is not None and p.get("altitude_m") is not None
]
if len(pts) < 10:
return []
pts.sort(key=lambda x: x[0])
dists = [p[0] for p in pts]
alts = [p[1] for p in pts]
# Smooth altitude with a sliding window to reduce GPS noise
SMOOTH = 10
smooth_alts = []
for i in range(len(alts)):
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
grad_threshold = gradient_pct / 100.0
MIN_HILL_M = 200.0
in_hill = False
hill_start_idx = 0
segments = []
for i in range(1, len(dists)):
d_dist = dists[i] - dists[i - 1]
if d_dist <= 0:
continue
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
if grad >= grad_threshold and not in_hill:
in_hill = True
hill_start_idx = i - 1
elif grad < grad_threshold and in_hill:
length = dists[i - 1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[i - 1],
))
in_hill = False
if in_hill:
length = dists[-1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[-1],
))
return segments
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
(1000, "1k"),
(1609.34, "1 mile"),
(3000, "3k"),
(5000, "5k"),
(10000, "10k"),
(21097.5, "Half marathon"),
(42195, "Marathon"),
(50000, "50k"),
(100000, "100k"),
]
def compute_best_splits(data_points: list[dict], total_distance_m: float) -> dict[str, float]:
"""Compute best split times for all standard distances that fit within the activity."""
results = {}
for dist_m, label in STANDARD_DISTANCES:
if total_distance_m >= dist_m * 0.95: # allow 5% tolerance
best = find_best_split_time(data_points, dist_m)
if best:
results[label] = best
return results
@@ -0,0 +1,356 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The SDK with convert_types_to_strings=True returns snake_case field names.
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
and a stage name. Duration of each stage = gap to the next stage's timestamp.
The sleep session stop time (from event message 21, event_type='stop') closes
the last stage.
"""
from datetime import datetime, timezone, date
from typing import Optional
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
def _fit_ts(raw) -> Optional[datetime]:
if raw is None:
return None
try:
s = int(raw)
if s <= 0 or s == 0xFFFFFFFF:
return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return None
def _to_date(val) -> Optional[date]:
if val is None:
return None
if isinstance(val, datetime):
if val.tzinfo is None:
val = val.replace(tzinfo=timezone.utc)
return val.date()
if isinstance(val, (int, float)):
dt = _fit_ts(val)
return dt.date() if dt else None
return None
def _to_dt(val) -> Optional[datetime]:
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
return _fit_ts(val)
return None
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file.
Returns {"days": {date: metrics_dict}, "error": str|None}
"""
daily = {}
last_date_seen = [None]
def ensure_day(d: date) -> dict:
if d not in daily:
daily[d] = {
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
# Each entry: (datetime, level_int) — duration computed from gaps
"sleep_epochs": [],
"sleep_start": None,
"sleep_end": None,
"steps": None,
"floors_climbed": None,
"active_calories": None,
"bmr": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
"sleep_score": None,
}
return daily[d]
def _add_sleep_epoch(ts: datetime, level_raw):
d = _to_date(ts)
if not d:
return
last_date_seen[0] = d
if isinstance(level_raw, str):
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
else:
level = level_raw
if level is not None:
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
def listener(mesg_num: int, msg: dict):
# ── monitoring_info (147) - older firmware ─────────────────────────
if mesg_num == 147:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
rhr = msg.get("resting_heart_rate")
if d and rhr and 20 < rhr < 120:
last_date_seen[0] = d
ensure_day(d)["resting_hr"] = int(rhr)
# ── monitoring (148) - older firmware ──────────────────────────────
elif mesg_num == 148:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress))
# ── monitoring (55) - modern, per-interval running totals ──────────
elif mesg_num == 55:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
active_cal = msg.get("active_calories")
if active_cal and active_cal > 0:
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
ascent = msg.get("ascent")
if ascent and ascent > 0:
# Garmin counts 1 floor ≈ 3 m of ascent
floors = max(1, round(float(ascent) / 3))
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
# ── monitoring_info (103) - calibration; carries BMR ───────────────
elif mesg_num == 103:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
bmr = msg.get("resting_metabolic_rate")
if bmr and bmr > 0:
ensure_day(d)["bmr"] = int(bmr)
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
elif mesg_num == 370:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hrv_avg = msg.get("last_night_average")
if hrv_avg and hrv_avg > 0:
entry["hrv_nightly_avg"] = float(hrv_avg)
hrv_high = msg.get("last_night_5_min_high")
if hrv_high and hrv_high > 0:
entry["hrv_5min_high"] = float(hrv_high)
status = msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
elif mesg_num == 275:
sleep_level = msg.get("sleep_level")
ts = _to_dt(msg.get("timestamp"))
if sleep_level is not None and ts:
_add_sleep_epoch(ts, sleep_level)
elif ts:
# Older firmware: HRV summary in message 275
d = _to_date(ts)
if d:
last_date_seen[0] = d
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v and v > 0:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status") or msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── sleep_level (269) - older firmware sleep epochs ────────────────
elif mesg_num == 269:
ts = _to_dt(msg.get("timestamp"))
level = msg.get("sleep_level")
if ts and level is not None:
_add_sleep_epoch(ts, level)
# ── event (21) - sleep session start / stop ────────────────────────
elif mesg_num == 21:
ts = _to_dt(msg.get("timestamp"))
if not ts:
return
d = _to_date(ts)
if not d:
return
event_type = msg.get("event_type")
if event_type == "start":
last_date_seen[0] = d
ensure_day(d)["sleep_start"] = ts
elif event_type == "stop":
last_date_seen[0] = d
ensure_day(d)["sleep_end"] = ts
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
elif mesg_num == 346:
d = last_date_seen[0]
if not d:
return
score = msg.get("overall_sleep_score")
if score and score > 0:
ensure_day(d)["sleep_score"] = int(score)
# ── stress_level (132) ─────────────────────────────────────────────
elif mesg_num == 132:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress))
# ── spo2_data (258) ────────────────────────────────────────────────
elif mesg_num == 258:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2))
# ── per-minute stress + HR (227) proprietary ───────────────────────
elif mesg_num == 227:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr_raw = msg.get(2)
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
stress = msg.get("stress_level_value")
if stress is None:
stress = msg.get(0)
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
entry["stress_values"].append(int(stress))
# ── daily resting HR (211) proprietary ─────────────────────────────
elif mesg_num == 211:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr)
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
messages, errors = decoder.read(
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=False,
mesg_listener=listener,
)
except Exception as e:
return {"error": str(e), "days": {}}
result = {}
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_epochs = data.pop("sleep_epochs", [])
sleep_end_ts = data.pop("sleep_end", None)
sleep_start_ts = data.pop("sleep_start", None)
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
max_hr = max(hrs) if hrs else None
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Compute sleep stage durations from epoch timestamps
if sleep_epochs:
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
for i, (ts, level) in enumerate(epochs_sorted):
if i + 1 < len(epochs_sorted):
next_ts = epochs_sorted[i + 1][0]
elif sleep_end_ts:
next_ts = sleep_end_ts
else:
continue
dur = (next_ts - ts).total_seconds()
if level in level_secs and dur > 0:
level_secs[level] += dur
sleep_deep_s = level_secs[3] or None
sleep_light_s = level_secs[2] or None
sleep_rem_s = level_secs[4] or None
sleep_awake_s = level_secs[1] or None
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
sleep_stages = None
active_cal = data.get("active_calories")
bmr = data.get("bmr")
# Require active_cal so we don't store BMR-only as "total" calories
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
"avg_hr_day": avg_hr,
"max_hr_day": max_hr,
"avg_stress": avg_stress,
"spo2_avg": spo2_avg,
"hrv_nightly_avg": data.get("hrv_nightly_avg"),
"hrv_5min_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors_climbed": data.get("floors_climbed"),
"active_calories": active_cal,
"total_calories": total_cal,
"sleep_duration_s": sleep_duration_s,
"sleep_deep_s": sleep_deep_s,
"sleep_light_s": sleep_light_s,
"sleep_rem_s": sleep_rem_s,
"sleep_awake_s": sleep_awake_s,
"sleep_score": data.get("sleep_score"),
"sleep_start": sleep_start_ts,
"sleep_end": sleep_end_ts,
"sleep_stages": sleep_stages,
}
return {"days": result, "error": None}
@@ -0,0 +1,7 @@
"""
Celery entry point. Re-exports celery_app from tasks so the worker
can be started with: celery -A app.workers.celery_app worker
"""
from app.workers.tasks import celery_app
__all__ = ["celery_app"]
@@ -0,0 +1,625 @@
"""
Background tasks: activity ingestion, route matching, PR calculation.
Uses synchronous SQLAlchemy because Celery's prefork model doesn't play
well with asyncio - each worker process needs its own connection pool,
and async pools don't survive process forks.
"""
from celery import Celery
from app.core.config import settings
celery_app = Celery(
"milevault",
broker=settings.redis_url,
backend=settings.redis_url,
)
celery_app.conf.update(
task_serializer="json",
result_serializer="json",
accept_content=["json"],
timezone="UTC",
enable_utc=True,
task_track_started=True,
worker_prefetch_multiplier=1,
beat_schedule={
"sync-garmin-connect": {
"task": "sync_all_garmin_connect",
"schedule": 1800.0, # every 30 minutes
},
},
)
WELLNESS_SUFFIXES = (
"_METRICS.fit",
"_WELLNESS.fit",
"_SLEEP.fit",
"_SLEEP_DATA.fit",
"_STRESS.fit",
"_SPO2.fit",
"_HRV.fit",
"_HRV_STATUS.fit",
"_MONITORING.fit",
"_MONITORING_B.fit",
"_RESPIRATION.fit",
"_PULSE_OX.fit",
)
def is_wellness_file(file_path: str) -> bool:
name = file_path.upper()
return any(name.endswith(s.upper()) for s in WELLNESS_SUFFIXES)
@celery_app.task(bind=True, name="process_activity_file")
def process_activity_file(self, file_path: str, user_id: int, source_type: str,
garmin_activity_id: str = None):
"""Parse a FIT/GPX file. Routes wellness files to health parser."""
if is_wellness_file(file_path):
parse_wellness_fit.delay(file_path, user_id)
return {"status": "routed_to_wellness", "file": file_path}
from app.services.fit_parser import parse_fit_file, parse_gpx_file, calculate_hr_zones
from app.core.database import SyncSessionLocal
from app.models.user import Activity, ActivityDataPoint, ActivityLap
from sqlalchemy import select, func
from datetime import datetime
self.update_state(state="PROGRESS", meta={"step": "parsing"})
try:
if source_type == "fit" or file_path.endswith(".fit"):
parsed = parse_fit_file(file_path)
else:
parsed = parse_gpx_file(file_path)
except Exception as e:
raise self.retry(exc=e, countdown=10, max_retries=3)
if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path}
with SyncSessionLocal() as db:
start_time = datetime.fromisoformat(parsed["start_time"])
# Deduplicate: same user + sport_type + start_time within ±60s
from datetime import timedelta
existing = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == parsed["sport_type"],
Activity.start_time >= start_time - timedelta(seconds=60),
Activity.start_time <= start_time + timedelta(seconds=60),
)
).scalars().first()
if existing:
# Stamp garmin_activity_id if this came from a Garmin Connect sync
# so future syncs skip the fast-path dedup and don't re-download.
if garmin_activity_id and not existing.garmin_activity_id:
existing.garmin_activity_id = garmin_activity_id
db.commit()
return {"activity_id": existing.id, "status": "duplicate"}
# Get user max HR for zone calculation
from app.models.user import User as UserModel
user_obj = db.execute(
select(UserModel).where(UserModel.id == user_id)
).scalar_one_or_none()
user_max_hr = None
if user_obj:
user_max_hr = user_obj.max_heart_rate
if not user_max_hr and user_obj.birth_year:
from datetime import date as _date
age = _date.today().year - user_obj.birth_year
user_max_hr = 220 - age
if not user_max_hr:
user_max_hr = parsed.get("max_heart_rate") or 190
hr_zones = calculate_hr_zones(parsed.get("data_points", []), user_max_hr)
activity = Activity(
user_id=user_id,
name=parsed["name"],
sport_type=parsed["sport_type"],
garmin_activity_id=garmin_activity_id,
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"),
max_heart_rate=parsed.get("max_heart_rate"),
avg_cadence=parsed.get("avg_cadence"),
avg_power=parsed.get("avg_power"),
normalized_power=parsed.get("normalized_power"),
avg_speed_ms=parsed.get("avg_speed_ms"),
max_speed_ms=parsed.get("max_speed_ms"),
avg_temperature_c=parsed.get("avg_temperature_c"),
calories=parsed.get("calories"),
training_stress_score=parsed.get("training_stress_score"),
polyline=parsed.get("polyline"),
bounding_box=parsed.get("bounding_box"),
source_file=file_path,
source_type=parsed.get("source_type"),
hr_zones=hr_zones,
)
db.add(activity)
db.flush()
seen = set()
batch = []
for p in parsed.get("data_points", []):
if not p.get("timestamp"):
continue
ts = datetime.fromisoformat(p["timestamp"]) if isinstance(p["timestamp"], str) else p["timestamp"]
key = (activity.id, ts)
if key in seen:
continue
seen.add(key)
batch.append(ActivityDataPoint(
activity_id=activity.id,
timestamp=ts,
latitude=p.get("latitude"),
longitude=p.get("longitude"),
altitude_m=p.get("altitude_m"),
heart_rate=p.get("heart_rate"),
cadence=p.get("cadence"),
speed_ms=p.get("speed_ms"),
power=p.get("power"),
temperature_c=p.get("temperature_c"),
distance_m=p.get("distance_m"),
))
if len(batch) >= 500:
db.add_all(batch)
db.flush()
batch = []
if batch:
db.add_all(batch)
db.flush()
for lap in parsed.get("laps", []):
ls = datetime.fromisoformat(lap["start_time"]) if lap.get("start_time") else None
db.add(ActivityLap(
activity_id=activity.id,
lap_number=lap["lap_number"],
start_time=ls,
duration_s=lap.get("duration_s"),
distance_m=lap.get("distance_m"),
avg_heart_rate=lap.get("avg_heart_rate"),
avg_cadence=lap.get("avg_cadence"),
avg_speed_ms=lap.get("avg_speed_ms"),
avg_power=lap.get("avg_power"),
))
db.commit()
activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed)
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@celery_app.task(name="parse_wellness_fit")
def parse_wellness_fit(file_path: str, user_id: int):
"""Parse a Garmin wellness FIT file and upsert into health_metrics."""
from app.services.wellness_parser import parse_wellness_fit as _parse
from app.core.database import SyncSessionLocal
from datetime import datetime, timezone
from sqlalchemy import text
try:
result = _parse(file_path)
except Exception as e:
return {"status": "error", "error": str(e), "file": file_path}
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
days = result.get("days", {})
if not days:
return {"status": "no_data", "file": file_path}
with SyncSessionLocal() as db:
for day_date, data in days.items():
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
db.execute(text("""
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
steps, floors_climbed, active_calories, total_calories,
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s,
sleep_score, sleep_start, sleep_end, sleep_stages)
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
:steps, :floors, :active_cal, :total_cal,
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake,
:sleep_score, :sleep_start, :sleep_end, :sleep_stages::json)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = GREATEST(EXCLUDED.total_calories, health_metrics.total_calories),
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s),
sleep_score = COALESCE(EXCLUDED.sleep_score, health_metrics.sleep_score),
sleep_start = COALESCE(EXCLUDED.sleep_start, health_metrics.sleep_start),
sleep_end = COALESCE(EXCLUDED.sleep_end, health_metrics.sleep_end),
sleep_stages = COALESCE(EXCLUDED.sleep_stages, health_metrics.sleep_stages)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": data.get("resting_hr"),
"avg_hr": data.get("avg_hr_day"),
"max_hr": data.get("max_hr_day"),
"avg_stress": data.get("avg_stress"),
"spo2_avg": data.get("spo2_avg"),
"hrv_avg": data.get("hrv_nightly_avg"),
"hrv_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors": data.get("floors_climbed"),
"active_cal": data.get("active_calories"),
"total_cal": data.get("total_calories"),
"sleep_dur": data.get("sleep_duration_s"),
"sleep_deep": data.get("sleep_deep_s"),
"sleep_light": data.get("sleep_light_s"),
"sleep_rem": data.get("sleep_rem_s"),
"sleep_awake": data.get("sleep_awake_s"),
"sleep_score": data.get("sleep_score"),
"sleep_start": data.get("sleep_start"),
"sleep_end": data.get("sleep_end"),
"sleep_stages": __import__('json').dumps(data.get("sleep_stages")) if data.get("sleep_stages") else None,
})
db.commit()
return {"status": "ok", "days_processed": len(days), "file": file_path}
@celery_app.task(name="detect_route")
def detect_route(activity_id: int, user_id: int):
"""Auto-detect and link activities to named routes."""
from app.services.route_matcher import routes_are_similar
from app.core.database import SyncSessionLocal
from app.models.user import Activity, NamedRoute
from sqlalchemy import select
with SyncSessionLocal() as db:
new_act = db.execute(
select(Activity).where(Activity.id == activity_id)
).scalar_one_or_none()
if not new_act or not new_act.polyline:
return {"status": "no_polyline"}
if new_act.named_route_id:
return {"status": "already_assigned"}
routes = db.execute(
select(NamedRoute).where(
NamedRoute.user_id == user_id,
NamedRoute.sport_type == new_act.sport_type,
)
).scalars().all()
for route in routes:
if route.reference_polyline and routes_are_similar(
new_act.polyline, route.reference_polyline,
new_act.bounding_box, route.bounding_box,
dist1=new_act.distance_m, dist2=route.distance_m,
):
new_act.named_route_id = route.id
db.commit()
return {"status": "matched_existing", "route_id": route.id}
candidates = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == new_act.sport_type,
Activity.named_route_id == None,
Activity.id != activity_id,
Activity.polyline != None,
Activity.distance_m >= (new_act.distance_m or 0) * 0.95,
Activity.distance_m <= (new_act.distance_m or 0) * 1.05,
)
).scalars().all()
for candidate in candidates:
if routes_are_similar(
new_act.polyline, candidate.polyline,
new_act.bounding_box, candidate.bounding_box,
dist1=new_act.distance_m, dist2=candidate.distance_m,
):
older = candidate if candidate.start_time < new_act.start_time else new_act
newer = new_act if candidate.start_time < new_act.start_time else candidate
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
new_route = NamedRoute(
user_id=user_id,
name=route_name,
sport_type=older.sport_type,
reference_polyline=older.polyline,
bounding_box=older.bounding_box,
distance_m=older.distance_m,
auto_detected=True,
)
db.add(new_route)
db.flush()
older.named_route_id = new_route.id
newer.named_route_id = new_route.id
db.commit()
return {"status": "auto_created", "route_id": new_route.id}
return {"status": "no_match"}
@celery_app.task(name="compute_personal_records")
def compute_personal_records(activity_id: int, user_id: int, parsed: dict):
"""Calculate personal records for standard distances from this activity."""
from app.services.route_matcher import compute_best_splits, STANDARD_DISTANCES
from app.core.database import SyncSessionLocal
from app.models.user import PersonalRecord
from sqlalchemy import select
from datetime import datetime, timezone
data_points = parsed.get("data_points", [])
total_dist = parsed.get("distance_m", 0) or 0
sport = parsed.get("sport_type", "running")
start_time_str = parsed.get("start_time")
start_time = datetime.fromisoformat(start_time_str) if start_time_str else datetime.now(timezone.utc)
best_splits = compute_best_splits(data_points, total_dist)
with SyncSessionLocal() as db:
for label, duration_s in best_splits.items():
dist_m = next((d for d, l in STANDARD_DISTANCES if l == label), None)
if dist_m is None:
continue
current = db.execute(
select(PersonalRecord).where(
PersonalRecord.user_id == user_id,
PersonalRecord.sport_type == sport,
PersonalRecord.distance_m == dist_m,
PersonalRecord.is_current_record == True,
)
).scalar_one_or_none()
if current is None or duration_s < current.duration_s:
if current:
current.is_current_record = False
db.add(PersonalRecord(
user_id=user_id,
activity_id=activity_id,
sport_type=sport,
distance_m=dist_m,
distance_label=label,
duration_s=duration_s,
achieved_at=start_time,
is_current_record=True,
))
db.commit()
@celery_app.task(name="process_garmin_health_zip")
def process_garmin_health_zip(zip_path: str, user_id: int):
"""Extract wellness data from a Garmin Connect export ZIP."""
import zipfile
import json
from app.core.database import SyncSessionLocal
from datetime import datetime, timezone
from sqlalchemy import text
INSERT_SQL = text("""
INSERT INTO health_metrics (user_id, date, resting_hr, max_hr_day, steps,
floors_climbed, active_calories, total_calories, avg_stress, spo2_avg)
VALUES (:user_id, :date, :resting_hr, :max_hr_day, :steps,
:floors, :active_cal, :total_cal, :stress, :spo2)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg)
""")
def _extract_stress(item):
stress_data = item.get("allDayStress")
if not stress_data or not isinstance(stress_data, dict):
return item.get("averageStressLevel")
for agg in stress_data.get("aggregatorList", []):
if agg.get("type") == "TOTAL":
return agg.get("averageStressLevel")
return None
def _floors_from_item(item):
# UDS format reports meters; 1 floor = 3.048 m
meters = item.get("floorsAscendedInMeters")
if meters is not None:
return round(meters / 3.048)
return item.get("floorsAscended")
def _process_record(db, item):
date_str = item.get("calendarDate") or item.get("date")
if not date_str:
return
try:
date_dt = datetime.fromisoformat(date_str).replace(tzinfo=timezone.utc)
except ValueError:
return
db.execute(INSERT_SQL, {
"user_id": user_id, "date": date_dt,
"resting_hr": item.get("restingHeartRate"),
"max_hr_day": item.get("maxHeartRate"),
"steps": item.get("totalSteps"),
"floors": _floors_from_item(item),
"active_cal": item.get("activeKilocalories"),
"total_cal": item.get("totalKilocalories"),
"stress": _extract_stress(item),
"spo2": item.get("avgSpo2"),
})
with SyncSessionLocal() as db:
with zipfile.ZipFile(zip_path) as zf:
for name in zf.namelist():
if not name.endswith(".json"):
continue
# Garmin Connect export stores daily summaries in UDSFile_*.json
# (DI-Connect-Aggregator). Older/alternative exports may use DailyMetrics.
is_uds = "UDSFile" in name
is_legacy = "DailyMetrics" in name
if not (is_uds or is_legacy):
continue
with zf.open(name) as f:
try:
data = json.load(f)
except Exception:
continue
# UDS files are lists of daily records; legacy format is a single object
records = data if isinstance(data, list) else [data]
for item in records:
if isinstance(item, dict):
_process_record(db, item)
db.commit()
@celery_app.task(name="sync_garmin_connect_user")
def sync_garmin_connect_user(user_id: int):
"""Sync Garmin Connect data (activities + wellness) for one user."""
from app.services.garmin_connect_sync import authenticate_garmin, sync_activities, sync_wellness
from app.core.database import SyncSessionLocal
from app.models.user import GarminConnectConfig
from app.core.config import settings
from sqlalchemy import select
from datetime import datetime, timezone
with SyncSessionLocal() as db:
cfg = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
).scalar_one_or_none()
if not cfg or not cfg.sync_enabled:
return {"status": "skipped"}
# Snapshot config values before any intermediate commits (commits expire ORM attrs)
email = cfg.email
password_enc = cfg.password_enc
token_store = cfg.token_store
last_sync_at = cfg.last_sync_at
sync_acts = cfg.sync_activities
sync_well = cfg.sync_wellness
lookback = cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30
cfg.last_sync_status = "Connecting to Garmin..."
db.commit()
try:
garmin, new_token = authenticate_garmin(email, password_enc, token_store)
except Exception as exc:
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = f"Auth error: {exc}"
db.commit()
return {"status": "auth_error", "error": str(exc)}
if new_token:
cfg.token_store = new_token
db.commit()
activities_queued = 0
wellness_days = 0
errors = []
def _set_status(text):
cfg.last_sync_status = text
db.commit()
if sync_acts:
_set_status("Syncing activities...")
try:
activities_queued = sync_activities(
garmin, user_id, last_sync_at, db, settings.file_store_path,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"activities: {exc}")
if sync_well:
_set_status("Syncing wellness...")
try:
wellness_days = sync_wellness(
garmin, user_id, last_sync_at, db,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = (
f"OK — {activities_queued} activities queued, {wellness_days} wellness days synced"
if not errors else
f"Partial — {'; '.join(errors)}"
)
db.commit()
return {"status": "ok", "activities_queued": activities_queued, "wellness_days": wellness_days}
@celery_app.task(name="sync_all_garmin_connect")
def sync_all_garmin_connect():
"""Hourly beat task: dispatch per-user sync for all enabled configs."""
from app.core.database import SyncSessionLocal
from app.models.user import GarminConnectConfig
from sqlalchemy import select
with SyncSessionLocal() as db:
configs = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.sync_enabled == True)
).scalars().all()
user_ids = [c.user_id for c in configs]
for uid in user_ids:
sync_garmin_connect_user.delay(uid)
return {"dispatched": len(user_ids)}
@celery_app.task(name="recalculate_hr_zones_for_user")
def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
"""Recalculate hr_zones for all of a user's activities using a new max HR."""
from app.services.fit_parser import calculate_hr_zones
from app.core.database import SyncSessionLocal
from app.models.user import Activity, ActivityDataPoint
from sqlalchemy import select
with SyncSessionLocal() as db:
activities = db.execute(
select(Activity).where(Activity.user_id == user_id)
).scalars().all()
for activity in activities:
data_points = db.execute(
select(ActivityDataPoint).where(ActivityDataPoint.activity_id == activity.id)
).scalars().all()
points_dicts = [{"heart_rate": dp.heart_rate} for dp in data_points]
new_zones = calculate_hr_zones(points_dicts, new_max_hr)
if new_zones:
activity.hr_zones = new_zones
db.commit()
+27
View File
@@ -0,0 +1,27 @@
fastapi==0.111.0
uvicorn[standard]==0.30.0
sqlalchemy[asyncio]==2.0.30
asyncpg==0.29.0
alembic==1.13.1
pydantic==2.7.1
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
passlib==1.7.4
bcrypt==4.0.1
python-multipart==0.0.9
httpx==0.27.0
redis[hiredis]==5.0.4
celery[redis]==5.4.0
garmin-fit-sdk==21.195.0
gpxpy==1.6.2
numpy==1.26.4
scipy==1.13.0
geopy==2.4.1
polyline==2.0.2
Pillow==10.3.0
aiofiles==23.2.1
python-dateutil==2.9.0
pytz==2024.1
psycopg2-binary==2.9.9
garminconnect==0.2.24
cryptography==42.0.8
+114
View File
@@ -0,0 +1,114 @@
version: "3.9"
# MileVault — standalone deployment
#
# 1. Copy this file somewhere on your server (no other files needed)
# 2. Run: docker compose up -d
# 3. Visit http://localhost
#
# Images are pulled from your Gitea container registry automatically.
# To update to the latest build: docker compose pull && docker compose up -d
# ── Replace these with your actual Gitea host and username ───────────────────
x-registry: &registry gitea.yourdomain.com/yourusername
# ─────────────────────────────────────────────────────────────────────────────
services:
db:
image: timescale/timescaledb:latest-pg16
container_name: milevault_db
restart: unless-stopped
environment:
POSTGRES_DB: milevault
POSTGRES_USER: ${DB_USER:-milevault}
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
container_name: milevault_redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
backend:
image: gitea.yourdomain.com/yourusername/milevault-backend:latest
container_name: milevault_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
FILE_STORE_PATH: /data/files
ENVIRONMENT: production
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
worker:
image: gitea.yourdomain.com/yourusername/milevault-worker:latest
container_name: milevault_worker
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_run_openssl_rand_hex_32}
FILE_STORE_PATH: /data/files
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
frontend:
image: gitea.yourdomain.com/yourusername/milevault-frontend:latest
container_name: milevault_frontend
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: milevault_nginx
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
volumes:
db_data:
redis_data:
file_data:
+111
View File
@@ -0,0 +1,111 @@
version: "3.9"
services:
db:
image: timescale/timescaledb:latest-pg16
container_name: milevault_db
restart: unless-stopped
environment:
POSTGRES_DB: milevault
POSTGRES_USER: ${DB_USER:-milevault}
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
volumes:
- db_data:/var/lib/postgresql/data
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
container_name: milevault_redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: milevault_backend
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
POCKETID_ISSUER: ${POCKETID_ISSUER:-}
POCKETID_CLIENT_ID: ${POCKETID_CLIENT_ID:-}
POCKETID_CLIENT_SECRET: ${POCKETID_CLIENT_SECRET:-}
FILE_STORE_PATH: /data/files
ENVIRONMENT: ${ENVIRONMENT:-production}
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 15s
timeout: 5s
retries: 10
start_period: 30s
worker:
build:
context: ./backend
dockerfile: Dockerfile.worker
container_name: milevault_worker
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://${DB_USER:-milevault}:${DB_PASSWORD:-milevault}@db:5432/milevault
REDIS_URL: redis://:${REDIS_PASSWORD:-milevault}@redis:6379/0
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
FILE_STORE_PATH: /data/files
volumes:
- file_data:/data/files
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-/api}
VITE_MAPBOX_TOKEN: ${VITE_MAPBOX_TOKEN:-}
container_name: milevault_frontend
restart: unless-stopped
nginx:
image: nginx:alpine
container_name: milevault_nginx
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- backend
- frontend
volumes:
db_data:
redis_data:
file_data:
+7
View File
@@ -0,0 +1,7 @@
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS postgis;
-- Activity data points will use TimescaleDB hypertable for efficient
-- time-series queries on HR, cadence, power, temperature, etc.
-- Tables are created by Alembic migrations; this just ensures extensions exist.
+18
View File
@@ -0,0 +1,18 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
ARG VITE_API_URL=/api
ARG VITE_MAPBOX_TOKEN=
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-spa.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MileVault</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "milevault-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"recharts": "^2.12.7",
"date-fns": "^3.6.0",
"clsx": "^2.1.1",
"zustand": "^4.5.2",
"@tanstack/react-query": "^5.40.0",
"axios": "^1.7.2",
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.2.13",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4"
}
}
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+47
View File
@@ -0,0 +1,47 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useEffect } from 'react'
import { useAuthStore } from './hooks/useAuth'
import Layout from './components/ui/Layout'
import LoginPage from './pages/LoginPage'
import DashboardPage from './pages/DashboardPage'
import ActivitiesPage from './pages/ActivitiesPage'
import ActivityDetailPage from './pages/ActivityDetailPage'
import HealthPage from './pages/HealthPage'
import RoutesPage from './pages/RoutesPage'
import SegmentsPage from './pages/SegmentsPage'
import RecordsPage from './pages/RecordsPage'
import UploadPage from './pages/UploadPage'
import ProfilePage from './pages/ProfilePage'
import UsersPage from './pages/UsersPage'
function RequireAuth({ children }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
return children
}
export default function App() {
const { token, fetchUser } = useAuthStore()
useEffect(() => {
if (token) fetchUser()
}, [token])
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<RequireAuth><Layout /></RequireAuth>}>
<Route index element={<DashboardPage />} />
<Route path="activities" element={<ActivitiesPage />} />
<Route path="activities/:id" element={<ActivityDetailPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="routes" element={<RoutesPage />} />
<Route path="segments" element={<SegmentsPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="upload" element={<UploadPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Routes>
)
}
@@ -0,0 +1,133 @@
import { useEffect, useRef } from 'react'
import L from 'leaflet'
import { sportColor } from '../../utils/format'
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})
const TILE_LAYERS = {
dark: {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
street: {
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
},
satellite: {
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© <a href="https://www.esri.com/">Esri</a>',
},
}
function decodePolyline(encoded) {
const coords = []
let index = 0, lat = 0, lng = 0
while (index < encoded.length) {
let b, shift = 0, result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lat += (result & 1) ? ~(result >> 1) : result >> 1
shift = 0; result = 0
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
lng += (result & 1) ? ~(result >> 1) : result >> 1
coords.push([lat / 1e5, lng / 1e5])
}
return coords
}
function drawRoute(map, polyline, sportType, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
trackRef.current = null
}
if (!polyline) return
const coords = decodePolyline(polyline)
if (!coords.length) return
trackRef.current = L.polyline(coords, {
color: sportColor(sportType),
weight: 3,
opacity: 0.9,
}).addTo(map)
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
const dot = (color) => L.divIcon({
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map)
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map)
}
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const trackRef = useRef(null)
const tileLayerRef = useRef(null)
const polylineRef = useRef(polyline)
const sportTypeRef = useRef(sportType)
useEffect(() => { polylineRef.current = polyline }, [polyline])
useEffect(() => { sportTypeRef.current = sportType }, [sportType])
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return
mapInstanceRef.current = L.map(mapRef.current, {
zoomControl: true,
attributionControl: true,
})
const tile = TILE_LAYERS.dark
tileLayerRef.current = L.tileLayer(tile.url, {
attribution: tile.attribution,
maxZoom: 19,
}).addTo(mapInstanceRef.current)
return () => {
mapInstanceRef.current?.remove()
mapInstanceRef.current = null
}
}, [])
useEffect(() => {
if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current = L.tileLayer(tile.url, {
attribution: tile.attribution,
maxZoom: 19,
}).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
}, [mapType])
useEffect(() => {
if (!mapInstanceRef.current) return
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
}, [polyline, sportType])
useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return
if (markerRef.current) {
markerRef.current.setLatLng([point.latitude, point.longitude])
} else {
const icon = L.divIcon({
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
.addTo(mapInstanceRef.current)
}
}, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
}

Some files were not shown because too many files have changed in this diff Show More