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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user