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>
This commit is contained in:
2026-06-09 11:52:52 +01:00
parent 6a1726e0c3
commit 04689a29bd
22 changed files with 3832 additions and 109 deletions
+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
+7 -5
View File
@@ -38,6 +38,8 @@ There are no automated tests. Verification is done by running the app and observ
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`. 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`.
```bash ```bash
# Rebuild and restart from source: # Rebuild and restart from source:
docker compose build --no-cache docker compose build --no-cache
@@ -66,13 +68,13 @@ docker compose -f docker-compose.deploy.yml up -d
- `main.py` — FastAPI app, DB init on startup (creates tables, seeds admin user, creates TimescaleDB hypertable) - `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) - `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` - `api/` — routers: `auth`, `activities`, `routes`, `health`, `records`, `upload`, `profile`, `garmin_sync`, `users`, `segments`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig` - `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/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/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/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 - `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`, `process_garmin_health_zip`, `sync_all_garmin_connect` (beat-scheduled) - `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 ### Key design decisions
@@ -91,8 +93,8 @@ docker compose -f docker-compose.deploy.yml up -d
- `utils/api.js` — Axios instance with JWT interceptor and 401→redirect handler - `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 - 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. - `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
- `pages/` — one file per route; includes `SegmentsPage` for route segment management - `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable` - `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts)
- `components/ui/RouteMiniMap` — small Leaflet map used in route/segment cards - `components/ui/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. 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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from app.core.database import get_db from app.core.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
from app.core.config import settings
from app.models.user import User, GarminConnectConfig from app.models.user import User, GarminConnectConfig
router = APIRouter() router = APIRouter()
@@ -27,6 +28,7 @@ class GarminConfigOut(BaseModel):
sync_activities: bool sync_activities: bool
sync_wellness: bool sync_wellness: bool
sync_lookback_days: int sync_lookback_days: int
sync_interval_minutes: int # how often the automatic sync runs
last_sync_at: Optional[datetime] last_sync_at: Optional[datetime]
last_sync_status: Optional[str] last_sync_status: Optional[str]
connected: bool connected: bool
@@ -48,6 +50,7 @@ async def get_config(
return GarminConfigOut( return GarminConfigOut(
email="", sync_enabled=False, sync_activities=True, email="", sync_enabled=False, sync_activities=True,
sync_wellness=True, sync_lookback_days=30, 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, last_sync_at=None, last_sync_status=None, connected=False,
) )
return GarminConfigOut( return GarminConfigOut(
@@ -56,6 +59,7 @@ async def get_config(
sync_activities=cfg.sync_activities, sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness, sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, 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_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status, last_sync_status=cfg.last_sync_status,
connected=True, connected=True,
@@ -121,6 +125,7 @@ async def save_config(
sync_activities=cfg.sync_activities, sync_activities=cfg.sync_activities,
sync_wellness=cfg.sync_wellness, sync_wellness=cfg.sync_wellness,
sync_lookback_days=cfg.sync_lookback_days if cfg.sync_lookback_days is not None else 30, 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_at=cfg.last_sync_at,
last_sync_status=cfg.last_sync_status, last_sync_status=cfg.last_sync_status,
connected=True, connected=True,
+2
View File
@@ -22,6 +22,8 @@ class Settings(BaseSettings):
pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID") pocketid_client_id: Optional[str] = Field(None, env="POCKETID_CLIENT_ID")
pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET") pocketid_client_secret: Optional[str] = Field(None, env="POCKETID_CLIENT_SECRET")
pocketid_allowed_group: Optional[str] = Field(None, env="POCKETID_ALLOWED_GROUP") 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 # Files
file_store_path: str = Field("/data/files", env="FILE_STORE_PATH") file_store_path: str = Field("/data/files", env="FILE_STORE_PATH")
# Environment # Environment
Binary file not shown.
+28 -23
View File
@@ -17,6 +17,13 @@ from typing import Optional, Tuple
logger = logging.getLogger(__name__) 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 ───────────────────────────────────────────────────── # ── Password encryption ─────────────────────────────────────────────────────
@@ -78,9 +85,12 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
List activities from Garmin Connect, skip any already in the DB, download List activities from Garmin Connect, skip any already in the DB, download
FIT ZIPs for new ones, and queue them for processing. FIT ZIPs for new ones, and queue them for processing.
lookback_days controls the start date on every sync: lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010 on first sync, then incremental (since-1d) -1 → full history back to 2010
N → incremental (since-1d) when since is set; else last N days on first sync 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. Returns the number of new activities queued.
""" """
import time import time
@@ -88,15 +98,11 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
from app.models.user import Activity from app.models.user import Activity
from sqlalchemy import select, func from sqlalchemy import select, func
if lookback_days == -1: if since:
# All-time: full pull on first sync, incremental thereafter # Incremental: just the recent buffer (cheap, dedup skips already-imported)
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif since: elif lookback_days == -1:
# Use whichever is earlier: one day before last sync OR the configured lookback start_date = date(2010, 1, 1)
# 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: else:
start_date = date.today() - timedelta(days=max(lookback_days, 1)) start_date = date.today() - timedelta(days=max(lookback_days, 1))
end_date = date.today() end_date = date.today()
@@ -195,21 +201,20 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each Fetch daily stats / sleep / HRV from the Garmin Connect JSON API for each
day in the window and upsert into health_metrics. day in the window and upsert into health_metrics.
lookback_days controls the window on every sync: lookback_days only sets the window on the FIRST sync (since is None):
-1 → full history back to 2010 on first sync, then incremental (since-1d) -1 → full history back to 2010
N → incremental (since-1d) when since is set; else last N days on first sync 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. Returns the number of days upserted.
""" """
from sqlalchemy import text from sqlalchemy import text
if lookback_days == -1: if since:
start_date = (since - timedelta(days=1)).date() if since else date(2010, 1, 1) start_date = (since - timedelta(days=INCREMENTAL_BUFFER_DAYS)).date()
elif since: elif lookback_days == -1:
# Use whichever is earlier: one day before last sync OR the configured lookback start_date = date(2010, 1, 1)
# 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: else:
start_date = date.today() - timedelta(days=max(lookback_days, 1)) start_date = date.today() - timedelta(days=max(lookback_days, 1))
days = (date.today() - start_date).days + 1 days = (date.today() - start_date).days + 1
+2 -1
View File
@@ -25,7 +25,8 @@ celery_app.conf.update(
beat_schedule={ beat_schedule={
"sync-garmin-connect": { "sync-garmin-connect": {
"task": "sync_all_garmin_connect", "task": "sync_all_garmin_connect",
"schedule": 1800.0, # every 30 minutes # Interval is configurable via GARMIN_SYNC_INTERVAL_MINUTES (default 30 min)
"schedule": float(settings.garmin_sync_interval_minutes * 60),
}, },
}, },
) )
+3468
View File
File diff suppressed because it is too large Load Diff
+64 -16
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom' import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth' import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync' import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
@@ -18,44 +18,61 @@ export default function Layout() {
const { user, logout } = useAuthStore() const { user, logout } = useAuthStore()
const navigate = useNavigate() const navigate = useNavigate()
const { inProgress, status, startPolling, stopPolling } = useSyncStore() const { inProgress, status, startPolling, stopPolling } = useSyncStore()
const [collapsed, setCollapsed] = useState(() => localStorage.getItem('navCollapsed') === '1')
useEffect(() => { useEffect(() => {
startPolling() startPolling()
return () => stopPolling() return () => stopPolling()
}, []) }, [])
const toggleCollapsed = () => {
setCollapsed(c => {
const next = !c
localStorage.setItem('navCollapsed', next ? '1' : '0')
return next
})
}
const handleLogout = () => { const handleLogout = () => {
logout() logout()
navigate('/login') navigate('/login')
} }
const role = user?.is_admin ? 'Administrator' : 'Member'
return ( return (
<div className="flex h-screen overflow-hidden bg-gray-950"> <div className="flex h-screen overflow-hidden bg-gray-950">
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col"> <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="px-4 py-5 border-b border-gray-800"> <div className={`flex items-center border-b border-gray-800 px-3 py-5 ${collapsed ? 'justify-center' : 'justify-between'}`}>
<h1 className="text-lg font-bold text-white tracking-tight"> {!collapsed && (
<span className="text-blue-400">Mile</span>Vault <h1 className="text-lg font-bold text-white tracking-tight">
</h1> <span className="text-blue-400">Mile</span>Vault
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>} </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> </div>
<nav className="flex-1 py-4 overflow-y-auto"> <nav className="flex-1 py-4 overflow-y-auto">
{nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => ( {nav.filter(({ adminOnly }) => !adminOnly || user?.is_admin).map(({ to, label, icon, exact }) => (
<NavLink key={to} to={to} end={exact} <NavLink key={to} to={to} end={exact} title={collapsed ? label : undefined}
className={({ isActive }) => 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 isActive
? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400' ? 'bg-blue-600/20 text-blue-400 border-r-2 border-blue-400'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800' : 'text-gray-400 hover:text-gray-100 hover:bg-gray-800'
}` }`
}> }>
<span>{icon}</span> <span>{icon}</span>
{label} {!collapsed && label}
</NavLink> </NavLink>
))} ))}
</nav> </nav>
{inProgress && ( {inProgress && !collapsed && (
<div className="px-4 py-3 border-t border-gray-800 space-y-1.5"> <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"> <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" /> <span className="inline-block w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
@@ -68,12 +85,43 @@ export default function Layout() {
<p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p> <p className="text-xs text-gray-500 truncate">{status || 'Starting sync…'}</p>
</div> </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>
)}
<div className="px-4 py-4 border-t border-gray-800"> {/* Logged-in user + privilege level */}
<button onClick={handleLogout} <div className="border-t border-gray-800 p-3">
className="w-full text-left text-xs text-gray-500 hover:text-gray-300 transition-colors"> {user ? (
Sign out collapsed ? (
</button> <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> </div>
</aside> </aside>
+79 -27
View File
@@ -10,6 +10,9 @@ import {
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation, formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep, formatDate, sportIcon, formatSleep,
} from '../utils/format' } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function Stat({ label, value }) { function Stat({ label, value }) {
return ( return (
@@ -20,19 +23,19 @@ function Stat({ label, value }) {
) )
} }
function bbLevelColor(level) { function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) {
if (level == null) return '#6b7280' const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
if (level >= 75) return '#3b82f6' const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
if (level >= 50) return '#22c55e' const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
if (level >= 25) return '#f59e0b' // Same classification the Health page uses, so colours match across views.
return '#ef4444' const data = raw.map((d, i) => ({
} ...d,
type: inferBBType(d.ts, d.level, i > 0 ? raw[i - 1].level : null, sleepStartMs, sleepEndMs),
function MiniBodyBattery({ bb, hires }) { }))
const data = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
const charged = bb?.charged, drained = bb?.drained, end_level = bb?.end_level 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 peak = data.length ? Math.max(...data.map(d => d.level)) : end_level
const hasGraph = data.length >= 2 const hasGraph = data.length >= 2
const presentTypes = [...new Set(data.map(d => d.type))]
return ( return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col">
@@ -49,22 +52,32 @@ function MiniBodyBattery({ bb, hires }) {
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>} {end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
</div> </div>
{hasGraph ? ( {hasGraph ? (
<div className="mt-3 flex-1"> <>
<ResponsiveContainer width="100%" height={70}> <div className="mt-3 flex-1">
<BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}> <ResponsiveContainer width="100%" height={70}>
<YAxis domain={[0, 100]} hide /> <BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}>
<Tooltip <YAxis domain={[0, 100]} hide />
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }} <Tooltip
itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }} contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
labelFormatter={ts => format(new Date(ts), 'HH:mm')} itemStyle={{ color: '#fff' }} labelStyle={{ color: '#fff' }}
formatter={v => [`${Math.round(v)}%`, 'Battery']} 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={bbLevelColor(d.level)} />)} <Bar dataKey="level" isAnimationActive={false} radius={0}>
</Bar> {data.map((d, i) => <Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />)}
</BarChart> </Bar>
</ResponsiveContainer> </BarChart>
</div> </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> <p className="text-xs text-gray-600 mt-3">No body battery data today</p>
)} )}
@@ -155,6 +168,8 @@ export default function DashboardPage() {
date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD date: rows[0]?.date ? rows[0].date.slice(0, 10) : null, // intraday endpoint wants YYYY-MM-DD
resting_hr: pick('resting_hr'), resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'), sleep_duration_s: pick('sleep_duration_s'),
sleep_start: pick('sleep_start'),
sleep_end: pick('sleep_end'),
hrv_nightly_avg: pick('hrv_nightly_avg'), hrv_nightly_avg: pick('hrv_nightly_avg'),
sleep_score: pick('sleep_score'), sleep_score: pick('sleep_score'),
steps: pick('steps'), steps: pick('steps'),
@@ -181,6 +196,12 @@ export default function DashboardPage() {
const featured = recentActivities?.[0] 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,
})
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -202,7 +223,8 @@ export default function DashboardPage() {
</div> </div>
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<MiniBodyBattery bb={intraday?.body_battery} hires={intraday?.body_battery_hires} /> <MiniBodyBattery bb={intraday?.body_battery} hires={intraday?.body_battery_hires}
sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
</div> </div>
<div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3"> <div className="lg:col-span-1 bg-gray-900 rounded-xl border border-gray-800 p-4 space-y-3">
@@ -257,6 +279,36 @@ export default function DashboardPage() {
<Stat label="Calories" value={featured.calories ? `${Math.round(featured.calories)} kcal` : '--'} /> <Stat label="Calories" value={featured.calories ? `${Math.round(featured.calories)} kcal` : '--'} />
</div> </div>
</div> </div>
{featuredSegments?.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/${featured.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">
{featuredSegments.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> </div>
)} )}
+16 -31
View File
@@ -7,6 +7,7 @@ import {
import { format, subDays } from 'date-fns' import { format, subDays } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format' import { formatSleep, sportIcon } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const RANGES = [ const RANGES = [
{ label: '1W', days: 7 }, { label: '1W', days: 7 },
@@ -181,37 +182,6 @@ function IntradayHrChart({ values }) {
// ── Body Battery ───────────────────────────────────────────────────────────── // ── Body Battery ─────────────────────────────────────────────────────────────
const BB_INFERRED_COLOR = {
sleep: '#4f46e5',
rest: '#0d9488',
activity: '#f97316',
stable: '#374151',
}
const BB_INFERRED_LABEL = {
sleep: 'Sleep',
rest: 'Rest',
activity: 'Active/Stress',
stable: 'Stable',
}
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'
}
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'
}
function ActivityRefLabel({ viewBox, icon }) { function ActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null if (!viewBox) return null
const { x, y, width = 0 } = viewBox const { x, y, width = 0 } = viewBox
@@ -1052,6 +1022,21 @@ export default function HealthPage() {
</div> </div>
</div> </div>
{metrics.some(d => d.sleep_score != null) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Score</h3>
<MetricChart data={metrics} dataKey="sleep_score" color="#818cf8"
formatter={v => Math.round(v)}
domain={[0, 100]}
connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick}
referenceLines={[
{ y: 80, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
]}
/>
</div>
)}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<WeightChart <WeightChart
data={metrics} data={metrics}
+14 -4
View File
@@ -4,6 +4,16 @@ import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth' import { useAuthStore } from '../hooks/useAuth'
import { useSyncStore, syncProgressPct, syncPhase } from '../hooks/useSync' 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 }) { function Section({ title, children }) {
return ( return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
@@ -306,8 +316,8 @@ export default function ProfilePage() {
{/* Garmin Connect Sync */} {/* Garmin Connect Sync */}
<Section title="⌚ Garmin Connect Sync"> <Section title="⌚ Garmin Connect Sync">
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
Connect your Garmin account to automatically import new activities and wellness data every hour. Connect your Garmin account to automatically import new activities and wellness data
Credentials are encrypted at rest. {' '}{formatSyncInterval(garminConfig?.sync_interval_minutes)}. Credentials are encrypted at rest.
</p> </p>
{garminConfig?.connected && ( {garminConfig?.connected && (
@@ -340,7 +350,7 @@ export default function ProfilePage() {
<div className="flex flex-wrap gap-4 pt-1"> <div className="flex flex-wrap gap-4 pt-1">
{[ {[
['sync_enabled', 'Enable hourly sync'], ['sync_enabled', `Enable automatic sync (${formatSyncInterval(garminConfig?.sync_interval_minutes)})`],
['sync_activities', 'Sync activities (FIT download)'], ['sync_activities', 'Sync activities (FIT download)'],
['sync_wellness', 'Sync wellness data'], ['sync_wellness', 'Sync wellness data'],
].map(([key, label]) => ( ].map(([key, label]) => (
@@ -353,7 +363,7 @@ export default function ProfilePage() {
))} ))}
</div> </div>
<Field label="Sync lookback days" hint="-1 syncs all available history (back to 2010). Leave at 30 for incremental syncs."> <Field label="Initial sync lookback days" hint="How far back to pull on the FIRST sync only (-1 = all history back to 2010). After that, scheduled syncs just refresh the last few days. To re-pull old history later, disconnect and reconnect.">
<Input type="number" value={gcForm.sync_lookback_days} min={-1} <Input type="number" value={gcForm.sync_lookback_days} min={-1}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} /> onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && ( {(() => { const n = parseInt(gcForm.sync_lookback_days, 10); return n > 365 && n !== -1 })() && (
+97 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, Fragment } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
@@ -14,7 +14,9 @@ const DISTANCE_ORDER = [
'Half marathon', 'Marathon', '50k', '100k', 'Half marathon', 'Marathon', '50k', '100k',
] ]
const TABS = ['Distance PRs', 'Route Records'] const TABS = ['Distance PRs', 'Route Records', 'Segments']
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function DistancePRs() { function DistancePRs() {
const [sport, setSport] = useState('running') const [sport, setSport] = useState('running')
@@ -212,6 +214,98 @@ function RouteRecords() {
) )
} }
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() { export default function RecordsPage() {
const [tab, setTab] = useState('Distance PRs') const [tab, setTab] = useState('Distance PRs')
@@ -237,6 +331,7 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />} {tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />} {tab === 'Route Records' && <RouteRecords />}
{tab === 'Segments' && <SegmentRecords />}
</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'
}