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`.
`./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
# Rebuild and restart from source:
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)
- `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`
- `models/user.py` — all SQLAlchemy models: `User`, `Activity`, `ActivityDataPoint`, `ActivityLap`, `NamedRoute`, `RouteSegment`, `PersonalRecord`, `HealthMetric`, `WeightLog`, `GarminConnectConfig`
- `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`, `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
@@ -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
- 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; includes `SegmentsPage` for route segment management
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`
- `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)
- `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.
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.security import get_current_user
from app.core.config import settings
from app.models.user import User, GarminConnectConfig
router = APIRouter()
@@ -27,6 +28,7 @@ class GarminConfigOut(BaseModel):
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
@@ -48,6 +50,7 @@ async def get_config(
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(
@@ -56,6 +59,7 @@ async def get_config(
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,
@@ -121,6 +125,7 @@ async def save_config(
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,
+2
View File
@@ -22,6 +22,8 @@ class Settings(BaseSettings):
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
Binary file not shown.
+28 -23
View File
@@ -17,6 +17,13 @@ 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 ─────────────────────────────────────────────────────
@@ -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
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
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
@@ -88,15 +98,11 @@ def sync_activities(garmin, user_id: int, since: Optional[datetime],
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)
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()
@@ -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
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
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 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)
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
+2 -1
View File
@@ -25,7 +25,8 @@ celery_app.conf.update(
beat_schedule={
"sync-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
+60 -12
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Outlet, NavLink, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../hooks/useAuth'
import { useSyncStore, syncProgressPct } from '../../hooks/useSync'
@@ -18,44 +18,61 @@ 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">
<aside className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="px-4 py-5 border-b border-gray-800">
<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>
{user && <p className="text-xs text-gray-500 mt-0.5">@{user.username}{user.is_admin ? ' · admin' : ''}</p>}
)}
<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 className="flex-1 py-4 overflow-y-auto">
{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 }) =>
`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>
{inProgress && (
{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" />
@@ -68,13 +85,44 @@ export default function Layout() {
<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>
)}
<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
{/* 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 className="flex-1 overflow-y-auto">
+64 -12
View File
@@ -10,6 +10,9 @@ import {
formatDuration, formatDistance, formatPace, formatHeartRate, formatElevation,
formatDate, sportIcon, formatSleep,
} from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function Stat({ label, value }) {
return (
@@ -20,19 +23,19 @@ function Stat({ label, value }) {
)
}
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 MiniBodyBattery({ bb, hires }) {
const data = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
function MiniBodyBattery({ bb, hires, sleepStart, sleepEnd }) {
const raw = (hires?.length ? hires : bb?.values || []).map(([ts, level]) => ({ ts, level }))
const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null
const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null
// Same classification the Health page uses, so colours match across views.
const data = raw.map((d, i) => ({
...d,
type: inferBBType(d.ts, 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 (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 h-full flex flex-col">
@@ -49,6 +52,7 @@ function MiniBodyBattery({ bb, hires }) {
{end_level != null && <span className="text-xs text-gray-500">now {Math.round(end_level)}</span>}
</div>
{hasGraph ? (
<>
<div className="mt-3 flex-1">
<ResponsiveContainer width="100%" height={70}>
<BarChart data={data} margin={{ top: 2, right: 0, bottom: 0, left: 0 }} barCategoryGap={0}>
@@ -60,11 +64,20 @@ function MiniBodyBattery({ bb, hires }) {
formatter={v => [`${Math.round(v)}%`, 'Battery']}
/>
<Bar dataKey="level" isAnimationActive={false} radius={0}>
{data.map((d, i) => <Cell key={i} fill={bbLevelColor(d.level)} />)}
{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>
)}
@@ -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
resting_hr: pick('resting_hr'),
sleep_duration_s: pick('sleep_duration_s'),
sleep_start: pick('sleep_start'),
sleep_end: pick('sleep_end'),
hrv_nightly_avg: pick('hrv_nightly_avg'),
sleep_score: pick('sleep_score'),
steps: pick('steps'),
@@ -181,6 +196,12 @@ export default function DashboardPage() {
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 (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
@@ -202,7 +223,8 @@ export default function DashboardPage() {
</div>
<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 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` : '--'} />
</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>
)}
+16 -31
View File
@@ -7,6 +7,7 @@ import {
import { format, subDays } from 'date-fns'
import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
const RANGES = [
{ label: '1W', days: 7 },
@@ -181,37 +182,6 @@ function IntradayHrChart({ values }) {
// ── 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 }) {
if (!viewBox) return null
const { x, y, width = 0 } = viewBox
@@ -1052,6 +1022,21 @@ export default function HealthPage() {
</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">
<WeightChart
data={metrics}
+14 -4
View File
@@ -4,6 +4,16 @@ 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">
@@ -306,8 +316,8 @@ export default function ProfilePage() {
{/* 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 every hour.
Credentials are encrypted at rest.
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 && (
@@ -340,7 +350,7 @@ export default function ProfilePage() {
<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_wellness', 'Sync wellness data'],
].map(([key, label]) => (
@@ -353,7 +363,7 @@ export default function ProfilePage() {
))}
</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}
onChange={e => setGcForm(f => ({ ...f, sync_lookback_days: e.target.value }))} />
{(() => { 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 { Link, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
@@ -14,7 +14,9 @@ const DISTANCE_ORDER = [
'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() {
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() {
const [tab, setTab] = useState('Distance PRs')
@@ -237,6 +331,7 @@ export default function RecordsPage() {
{tab === 'Distance PRs' && <DistancePRs />}
{tab === 'Route Records' && <RouteRecords />}
{tab === 'Segments' && <SegmentRecords />}
</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'
}