Cut Garmin sync API volume; dashboard/health/records/UI improvements
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user