HRV baseline band from Garmin + dashboard HRV colours + route name on recent activities + sync-now race fix
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
// Coloured pill for a Garmin HRV status (balanced / unbalanced / low / poor).
|
||||
// Shared by the Health page and the Dashboard HRV widget so the palette stays
|
||||
// consistent across the app.
|
||||
const HRV_PALETTE = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
|
||||
export default function HrvBadge({ status }) {
|
||||
if (!status) return null
|
||||
const cls = HRV_PALETTE[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||
}
|
||||
@@ -39,17 +39,42 @@ export const useSyncStore = create((set, get) => ({
|
||||
connected: false,
|
||||
lastSyncAt: null,
|
||||
email: '',
|
||||
// Set when the user manually triggers a sync; cleared once the worker takes
|
||||
// over or finishes (see poll). prevSyncAt snapshots last_sync_at at trigger
|
||||
// time so we can detect completion without relying on clock-synced times.
|
||||
triggeredAt: null,
|
||||
prevSyncAt: null,
|
||||
|
||||
poll: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/garmin-sync/config')
|
||||
const status = data?.last_sync_status ?? ''
|
||||
const inProgress = !!status && !isTerminal(status)
|
||||
const lastSyncAt = data?.last_sync_at ?? null
|
||||
let inProgress = !!status && !isTerminal(status)
|
||||
|
||||
// Grace window after a manual trigger. The Celery worker may not have
|
||||
// updated last_sync_status yet, so the config can still report the
|
||||
// PREVIOUS (terminal) status. Without this, the first poll fired right
|
||||
// after triggering would clear inProgress and the button would look dead
|
||||
// until clicked a second time. Keep the sync "in progress" until the
|
||||
// worker either starts (non-terminal status) or finishes (last_sync_at
|
||||
// changed from its pre-trigger value), with a hard cap as a safety net.
|
||||
const { triggeredAt, prevSyncAt } = get()
|
||||
if (triggeredAt) {
|
||||
const finished = lastSyncAt && lastSyncAt !== prevSyncAt
|
||||
if (inProgress || finished) {
|
||||
set({ triggeredAt: null })
|
||||
} else if (Date.now() - triggeredAt < 90000) {
|
||||
inProgress = true
|
||||
} else {
|
||||
set({ triggeredAt: null })
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
status, inProgress,
|
||||
connected: !!data?.connected,
|
||||
lastSyncAt: data?.last_sync_at ?? null,
|
||||
email: data?.email ?? '',
|
||||
lastSyncAt, email: data?.email ?? '',
|
||||
})
|
||||
return inProgress
|
||||
} catch {
|
||||
@@ -74,11 +99,11 @@ export const useSyncStore = create((set, get) => ({
|
||||
},
|
||||
|
||||
trigger: async () => {
|
||||
set({ inProgress: true, status: 'Starting sync…' })
|
||||
set({ inProgress: true, status: 'Starting sync…', triggeredAt: Date.now(), prevSyncAt: get().lastSyncAt })
|
||||
try {
|
||||
await api.post('/garmin-sync/trigger')
|
||||
} catch {
|
||||
set({ inProgress: false })
|
||||
set({ inProgress: false, triggeredAt: null })
|
||||
return
|
||||
}
|
||||
get().stopPolling()
|
||||
|
||||
@@ -11,6 +11,7 @@ import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } f
|
||||
import api from '../utils/api'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import StatCard from '../components/ui/StatCard'
|
||||
import HrvBadge from '../components/ui/HrvBadge'
|
||||
import ActivityMap from '../components/activity/ActivityMap'
|
||||
import {
|
||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||
@@ -23,20 +24,13 @@ const Grid = WidthProvider(GridLayout)
|
||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
||||
|
||||
const HRV_PALETTE = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
low: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
|
||||
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
||||
const STAT_DEFS = {
|
||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
||||
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
||||
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
||||
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
|
||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? <HrvBadge status={h.hrv_status} /> : undefined },
|
||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
||||
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||
@@ -410,6 +404,9 @@ function RecentActivities({ activities }) {
|
||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||
{activity.named_route_name && (
|
||||
<p className="text-xs text-blue-400 truncate">📍 {activity.named_route_name}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { format, subDays, differenceInCalendarDays, parseISO } 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'
|
||||
import HrvBadge from '../components/ui/HrvBadge'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1W', days: 7 },
|
||||
@@ -414,18 +415,6 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||
)
|
||||
}
|
||||
|
||||
function HrvBadge({ status }) {
|
||||
if (!status) return null
|
||||
const palette = {
|
||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||
}
|
||||
const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||
}
|
||||
|
||||
function NavArrow({ onClick, disabled, children }) {
|
||||
return (
|
||||
<button
|
||||
@@ -688,15 +677,24 @@ const statusDot = (statusKey) => (props) => {
|
||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||
}
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) {
|
||||
const vals = data.filter(d => d[dataKey] != null)
|
||||
if (!vals.length) return (
|
||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||
)
|
||||
// Range band (e.g. Garmin's HRV baseline): Recharts renders an Area as a band
|
||||
// when its dataKey resolves to a [low, high] pair.
|
||||
const hasBand = bandLowKey && bandHighKey
|
||||
const chartData = hasBand
|
||||
? data.map(d => ({
|
||||
...d,
|
||||
__band: (d[bandLowKey] != null && d[bandHighKey] != null) ? [d[bandLowKey], d[bandHighKey]] : null,
|
||||
}))
|
||||
: data
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<ComposedChart
|
||||
data={data}
|
||||
data={chartData}
|
||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||
onClick={evt => {
|
||||
@@ -716,7 +714,11 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||
tickFormatter={formatter} domain={domain} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||
formatter={(v, name) => name === '__band' ? null : [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||
{hasBand && (
|
||||
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
||||
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
||||
)}
|
||||
{selectedDate && (
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
@@ -1065,17 +1067,14 @@ export default function HealthPage() {
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} /> Balanced</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
|
||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
|
||||
<span className="flex items-center gap-1"><span className="w-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</span>
|
||||
</div>
|
||||
</div>
|
||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||
formatter={v => `${Math.round(v)} ms`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
statusDotKey="hrv_status"
|
||||
referenceLines={[
|
||||
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||
]}
|
||||
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user