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
co-authored by Claude Opus 4.8
parent 6a1726e0c3
commit 04689a29bd
22 changed files with 3832 additions and 109 deletions
+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>
)
}