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:
Generated
+3468
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 })() && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user