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
+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 { 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">
<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>}
<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 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,12 +85,43 @@ 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
</button>
{/* 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>
+79 -27
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,22 +52,32 @@ 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}>
<YAxis domain={[0, 100]} hide />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
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={bbLevelColor(d.level)} />)}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<>
<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}>
<YAxis domain={[0, 100]} hide />
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 6, fontSize: 11, color: '#fff' }}
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>
)}
@@ -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'
}