import { useState, useMemo } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { AreaChart, Area, BarChart, Bar, ReferenceLine, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, } from 'recharts' import { format, subDays } from 'date-fns' import api from '../utils/api' import { formatSleep, sportIcon } from '../utils/format' const RANGES = [ { label: '1W', days: 7 }, { label: '2W', days: 14 }, { label: '1M', days: 30 }, { label: '3M', days: 90 }, { label: '6M', days: 180 }, { label: '1Y', days: 365 }, { label: '3Y', days: 1095 }, { label: '5Y', days: 1825 }, ] // ── VO2 Max gauge ──────────────────────────────────────────────────────────── // Garmin/Cooper Institute VO2 max thresholds // [maxAge, [fair_min, good_min, excellent_min, superior_min]] // value < fair_min → Poor; >= superior_min → Superior const VO2_MALE = [ [29, [41.7, 45.4, 51.1, 55.4]], [39, [40.5, 44.0, 48.3, 54.0]], [49, [38.5, 42.4, 46.4, 52.5]], [59, [35.6, 39.2, 43.4, 48.9]], [69, [32.3, 35.5, 39.5, 45.7]], [Infinity, [29.4, 32.3, 36.7, 42.1]], ] const VO2_FEMALE = [ [29, [36.1, 39.5, 43.9, 49.6]], [39, [34.4, 37.8, 42.4, 47.4]], [49, [33.0, 36.3, 39.7, 45.3]], [59, [30.1, 33.0, 36.7, 41.1]], [69, [27.5, 30.0, 33.0, 37.8]], [Infinity, [25.9, 28.1, 30.9, 36.7]], ] const VO2_CATEGORIES = [ { label: 'Poor', color: '#ef4444' }, { label: 'Fair', color: '#f97316' }, { label: 'Good', color: '#22c55e' }, { label: 'Excellent', color: '#3b82f6' }, { label: 'Superior', color: '#a855f7' }, ] function getVo2Category(value, age, sex) { const table = sex === 'female' ? VO2_FEMALE : VO2_MALE const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] const thresholds = row[1] // thresholds are lower-bounds: count how many the value meets or exceeds const idx = thresholds.reduce((n, t) => value >= t ? n + 1 : n, 0) return VO2_CATEGORIES[idx] } function Vo2MaxGauge({ value, birthYear, biologicalSex }) { const MIN = 30, MAX = 70 // cx/cy = centre of the semicircle; arc goes left→top→right (sweep=1, clockwise in SVG) const cx = 70, cy = 74, r = 50, sw = 11 const age = birthYear ? new Date().getFullYear() - birthYear : 40 // Standard-math angle: PI = left (VO2 30), 0 = right (VO2 70) const toAngle = v => Math.PI * (1 - Math.max(0, Math.min(1, (v - MIN) / (MAX - MIN)))) // SVG coordinates for a VO2 value at a given radius from centre const toXY = (v, radius = r) => { const a = toAngle(v) return [cx + radius * Math.cos(a), cy - radius * Math.sin(a)] } // Arc path from VO2 v1 to v2; sweep=1 → clockwise = upper semicircle in SVG const arc = (v1, v2, radius = r) => { const [x1, y1] = toXY(v1, radius) const [x2, y2] = toXY(v2, radius) const large = 0 // gauge spans 180°, so no segment ever exceeds 180° return `M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${radius} ${radius} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}` } // ACSM category boundaries for this user's age/sex const table = biologicalSex === 'female' ? VO2_FEMALE : VO2_MALE const row = table.find(([maxAge]) => age <= maxAge) || table[table.length - 1] const thresholds = row[1] const bounds = [MIN, ...thresholds, MAX] // 6 boundary values for 5 colour bands const cat = value != null ? getVo2Category(value, age, biologicalSex) : null // Small white triangle: base outside the arc, tip touching the outer edge — points inward const arrowPts = value != null ? (() => { const a = toAngle(Math.max(MIN, Math.min(MAX, value))) const outerEdge = r + sw / 2 // outer surface of the track const tipR = outerEdge + 1 // tip just outside the track surface const baseR = outerEdge + 12 // base further out const s = 0.11 // half-spread ≈ 6° const tipX = cx + tipR * Math.cos(a), tipY = cy - tipR * Math.sin(a) const b1x = cx + baseR * Math.cos(a + s), b1y = cy - baseR * Math.sin(a + s) const b2x = cx + baseR * Math.cos(a - s), b2y = cy - baseR * Math.sin(a - s) return `${tipX.toFixed(1)},${tipY.toFixed(1)} ${b1x.toFixed(1)},${b1y.toFixed(1)} ${b2x.toFixed(1)},${b2y.toFixed(1)}` })() : null return (
{/* Extra top padding so the arrow doesn't clip at the top of the card */} {/* Dark background track, slightly wider than the colour bands */} {/* Full-brightness ACSM colour bands */} {VO2_CATEGORIES.map((c, i) => { const v1 = Math.max(bounds[i], MIN) const v2 = Math.min(bounds[i + 1], MAX) if (v2 <= v1) return null return ( ) })} {/* White arrow pointing inward at the value's position */} {arrowPts && } {/* VO2 number, coloured by category */} {value != null ? value.toFixed(1) : '--'} {/* Category label */} {cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
) } const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, } // Normalise any date string to YYYY-MM-DD so XAxis values and ReferenceLine x match. const d10 = (s) => (s || '').slice(0, 10) // ── Daily Snapshot ────────────────────────────────────────────────────────── function fmtTime(ts) { if (!ts) return '--' return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) } function IntradayHrChart({ values }) { if (!values?.length) return null const data = values.map(([ts, hr]) => ({ t: ts, hr })) return ( format(new Date(ts), 'HH:mm')} interval={Math.max(1, Math.floor(data.length / 6))} /> Math.round(v)} domain={['auto', 'auto']} /> format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)} bpm`, 'HR']} /> ) } // ── 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 } = viewBox return ( {icon} ) } function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) { if (!bb) return null const { charged, drained, start_level, end_level } = bb if (!hiresValues?.length && !bb.values?.length && end_level == null) return null const rawData = hiresValues?.length ? hiresValues.map(([ts, level]) => ({ t: ts, level })) : (bb.values || []).map(([ts, level]) => ({ t: ts, level })) if (!rawData.length) return null const sleepStartMs = sleepStart ? new Date(sleepStart).getTime() : null const sleepEndMs = sleepEnd ? new Date(sleepEnd).getTime() : null const chartData = rawData.map((d, i) => ({ ...d, type: inferBBType(d.t, d.level, i > 0 ? rawData[i - 1].level : null, sleepStartMs, sleepEndMs), })) const presentTypes = [...new Set(chartData.map(d => d.type))] const levelColor = bbLevelColor(end_level) const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null return (

Body Battery

{maxLevel != null && ( {Math.round(maxLevel)} )} {charged != null && ( +{charged} )} {drained != null && ( -{drained} )} {end_level != null && ( now {Math.round(end_level)} )}
format(new Date(ts), 'HH:mm')} interval={Math.max(1, Math.floor(chartData.length / 6))} /> v} ticks={[0, 25, 50, 75, 100]} /> format(new Date(ts), 'HH:mm')} formatter={v => [`${Math.round(v)}`, 'Battery']} /> {chartData.map((d, i) => ( ))} {(activities || []).map(a => ( } /> ))}
{presentTypes.map(type => (
{BB_INFERRED_LABEL[type]}
))}
) } // Proper sleep hypnogram: 4 horizontal lanes (Awake/REM/Light/Deep), time on X axis const SLEEP_LANE_ORDER = [1, 4, 2, 3] // top→bottom: awake, rem, light, deep const SLEEP_STAGE_COLOR = { 0: '#6b7280', 1: '#eab308', 2: '#a78bfa', 3: '#6366f1', 4: '#8b5cf6' } const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' } const LANE_H = 15 function SleepHypnogram({ sleepStart, sleepEnd, stages }) { if (!sleepStart || !sleepEnd || !stages?.length) return null const startMs = new Date(sleepStart).getTime() const endMs = new Date(sleepEnd).getTime() const windowMs = endMs - startMs if (windowMs <= 0) return null // Build segments per lane const segsByLane = {} SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] }) stages.forEach(([tsMs, level], i) => { if (!(level in segsByLane)) return const nextTs = i + 1 < stages.length ? stages[i + 1][0] : endMs const left = Math.max(0, (tsMs - startMs) / windowMs * 100) const right = Math.min(100, (nextTs - startMs) / windowMs * 100) const w = right - left if (w > 0) segsByLane[level].push({ left, w }) }) // Hour ticks const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1) const ticks = [] for (let t = sh.getTime(); t < endMs; t += 3600000) { const pct = (t - startMs) / windowMs * 100 if (pct >= 0 && pct <= 100) ticks.push({ pct, label: new Date(t).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) }) } return (
{SLEEP_LANE_ORDER.map(level => (
{SLEEP_STAGE_LABEL[level]}
{segsByLane[level].map((seg, i) => (
))} {ticks.map((t, i) => (
))}
))}
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} {ticks.map((t, i) => ( {t.label} ))} {new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
) } function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) { const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0) if (!total) return null const segments = [ { label: 'Deep', s: deepS || 0, color: '#6366f1' }, { label: 'REM', s: remS || 0, color: '#8b5cf6' }, { label: 'Light', s: lightS || 0, color: '#a78bfa' }, { label: 'Awake', s: awakeS || 0, color: '#eab308' }, ].filter(seg => seg.s > 0) return (
{segments.map(seg => (
))}
) } 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 {status} } function NavArrow({ onClick, disabled, children }) { return ( ) } function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) { if (!day) return (

📊

No health data yet

Import a Garmin export to see your daily snapshot

) const dateLabel = day.date ? format(new Date(day.date), 'EEEE, d MMMM yyyy') : 'Latest' const hasSleepStages = day.sleep_deep_s || day.sleep_light_s || day.sleep_rem_s const stepsGoal = 10000 const stepsPct = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0 const stressLabel = !day.avg_stress ? null : day.avg_stress < 25 ? 'Restful' : day.avg_stress < 50 ? 'Low' : day.avg_stress < 75 ? 'Medium' : 'High' const stressColor = !day.avg_stress ? 'text-white' : day.avg_stress < 25 ? 'text-green-400' : day.avg_stress < 50 ? 'text-yellow-400' : day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400' return (
{/* Header + arrows */}

Daily snapshot

{dateLabel}

{/* Sleep (wide) + Heart / HRV */}

Sleep

{day.sleep_score != null && ( Score {Math.round(day.sleep_score)} )}
{formatSleep(day.sleep_duration_s)} {day.sleep_start && day.sleep_end && ( {fmtTime(day.sleep_start)} → {fmtTime(day.sleep_end)} )}
{hasSleepStages ? ( <> {sleepStages?.length ? ( ) : ( )}
{[ ['Deep', day.sleep_deep_s, '#6366f1'], ['REM', day.sleep_rem_s, '#8b5cf6'], ['Light', day.sleep_light_s, '#a78bfa'], ['Awake', day.sleep_awake_s, '#eab308'], ].map(([label, secs, color]) => secs ? (
{label} {formatSleep(secs)}
) : null)}
) : !day.sleep_duration_s ? (

No sleep data

) : null}

Heart & HRV

Resting HR

{day.resting_hr ? Math.round(day.resting_hr) : '--'} bpm
{avg30?.resting_hr && day.resting_hr && (

30d avg {Math.round(avg30.resting_hr)} {day.resting_hr < avg30.resting_hr ? : day.resting_hr > avg30.resting_hr ? : null}

)}

HRV

{day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'} ms

Avg HR (day)

{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'} {day.max_hr_day && / {Math.round(day.max_hr_day)} max}

Weight

{day.weight_kg ? day.weight_kg.toFixed(1) : '--'} {day.weight_kg && kg} {day.body_fat_pct && {day.body_fat_pct.toFixed(1)}% fat}
{/* 24-hour heart rate chart + body battery (side by side) */} {(intradayHr?.length > 0 || bodyBattery) && (
0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}> {intradayHr?.length > 0 && (

24-hour Heart Rate

{day.avg_hr_day && ( avg {Math.round(day.avg_hr_day)} bpm )}
)}
)} {/* Activity strip */}

Steps

{day.steps ? day.steps.toLocaleString() : '--'}
{day.steps ? ( <>

{stepsPct}% of {stepsGoal.toLocaleString()}

) : null} {day.floors_climbed ?

{day.floors_climbed} floors

: null}

Calories

{day.total_calories ? Math.round(day.total_calories) : day.active_calories ? Math.round(day.active_calories) : '--'} kcal
{day.active_calories && day.total_calories && (

Active {Math.round(day.active_calories)} kcal

)}

Stress

{day.avg_stress ? Math.round(day.avg_stress) : '--'} {day.avg_stress && /100}
{stressLabel &&

{stressLabel}

}

VO2 Max

{day.fitness_age &&

Fitness age {day.fitness_age}

}
) } // ── Trend Charts ──────────────────────────────────────────────────────────── function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) { const vals = data.filter(d => d[dataKey] != null) if (!vals.length) return (
No data
) return ( { const p = evt?.activePayload?.[0]?.payload if (p?.date && onDayClick) onDayClick(p.date) }} > format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> format(new Date(d), 'MMM d, yyyy')} formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} /> {selectedDate && ( )} {(referenceLines || []).map((rl, i) => ( ))} ) } function SleepChart({ data, selectedDate, onDayClick }) { const chartData = data.map(d => ({ date: d.date, // already normalised to YYYY-MM-DD deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, rem: d.sleep_rem_s ? +(d.sleep_rem_s / 3600).toFixed(2) : null, light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null, awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null, })) const hasData = chartData.some(d => d.deep || d.rem || d.light) if (!hasData) return (
No sleep data
) return ( { const p = evt?.activePayload?.[0]?.payload if (p?.date && onDayClick) onDayClick(p.date) }} > format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> `${v}h`} /> format(new Date(d), 'MMM d, yyyy')} /> {selectedDate && ( )} ) } // ── Page ───────────────────────────────────────────────────────────────────── export default function HealthPage() { const [rangeDays, setRangeDays] = useState(7) const [selectedDateStr, setSelectedDateStr] = useState(null) // YYYY-MM-DD or null = latest const fromDate = useMemo( () => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), [rangeDays], ) const { data: summary } = useQuery({ queryKey: ['health-summary'], queryFn: () => api.get('/health-metrics/summary').then(r => r.data), }) const { data: profile } = useQuery({ queryKey: ['profile'], queryFn: () => api.get('/profile/').then(r => r.data), }) // Full history for snapshot navigation. // Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically. const { data: allDays } = useQuery({ queryKey: ['health-metrics', 'all'], queryFn: () => api.get('/health-metrics/', { params: { limit: 2000 } }) .then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))), }) // Trend window (changes with range selector). // Dates normalised to YYYY-MM-DD so XAxis values match ReferenceLine x. const { data: rawMetrics, isLoading } = useQuery({ queryKey: ['health-metrics', rangeDays], queryFn: () => api.get('/health-metrics/', { params: { from_date: fromDate, limit: rangeDays + 1 } }) .then(r => r.data.slice().reverse().map(d => ({ ...d, date: d10(d.date) }))), placeholderData: keepPreviousData, }) const metrics = rawMetrics || [] // Snapshot navigation: newest-first sorted list of all available days const allDaysSorted = useMemo( () => (allDays || []).slice().sort((a, b) => b.date.localeCompare(a.date)), [allDays], ) const selectedDay = useMemo(() => { if (!selectedDateStr) return allDaysSorted[0] || null return allDaysSorted.find(d => d.date === selectedDateStr) || null }, [selectedDateStr, allDaysSorted]) const selectedIdx = useMemo(() => { if (!selectedDay) return -1 return allDaysSorted.findIndex(d => d.date === selectedDay.date) }, [selectedDay, allDaysSorted]) // Most recent day with a VO2 max reading (Garmin only updates it after certain activities) const latestVo2max = useMemo(() => { const found = allDaysSorted.find(d => d.vo2max != null) return found ? found.vo2max : null }, [allDaysSorted]) const { data: intradayData } = useQuery({ queryKey: ['health-intraday', selectedDay?.date], queryFn: () => api.get('/health-metrics/intraday', { params: { date: selectedDay.date } }).then(r => r.data), enabled: !!selectedDay?.date, }) const { data: dayActivities } = useQuery({ queryKey: ['activities-day', selectedDay?.date], queryFn: () => api.get('/activities/', { params: { from_date: selectedDay.date + 'T00:00:00', to_date: selectedDay.date + 'T23:59:59', per_page: 20, }}).then(r => r.data), enabled: !!selectedDay?.date, }) const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr)) const goOlder = () => { if (selectedIdx < allDaysSorted.length - 1) setSelectedDateStr(allDaysSorted[selectedIdx + 1].date) } const goNewer = () => { if (selectedIdx > 0) setSelectedDateStr(allDaysSorted[selectedIdx - 1].date) } // The date string to highlight in charts (only shown if it falls within the current trend window) const selDateForCharts = selectedDay?.date return (

Health

= 0 && selectedIdx < allDaysSorted.length - 1} hasNewer={selectedIdx > 0} />

Trends

Click any point to load that day above

{RANGES.map(({ label, days }) => ( ))}
{isLoading ? (
Loading…
) : metrics.length > 0 ? (

Resting Heart Rate

Math.round(v)} domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />

HRV (nightly avg)

`${Math.round(v)} ms`} selectedDate={selDateForCharts} onDayClick={handleDayClick} 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 } }, ]} />

Sleep

{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
{l}
))}

Weight

d.weight_kg != null)} dataKey="weight_kg" color="#34d399" formatter={v => `${v.toFixed(1)} kg`} selectedDate={selDateForCharts} onDayClick={handleDayClick} connectNulls showDots />

Daily Steps

{ const p = evt?.activePayload?.[0]?.payload if (p?.date) handleDayClick(p.date) }} > format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> format(new Date(d), 'MMM d, yyyy')} /> {selDateForCharts && ( )}

Stress Level

Math.round(v)} domain={[0, 100]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />

Heart Rate

Math.round(v)} domain={[0, 200]} selectedDate={selDateForCharts} onDayClick={handleDayClick} />
{metrics.some(d => d.body_battery?.end_level != null) && (

Body Battery (end of day)

({ ...d, body_battery_level: d.body_battery?.end_level ?? null }))} dataKey="body_battery_level" color="#3b82f6" formatter={v => `${Math.round(v)}`} selectedDate={selDateForCharts} onDayClick={handleDayClick} />
)} {metrics.some(d => d.vo2max) && (

VO2 Max

v.toFixed(1)} connectNulls showDots selectedDate={selDateForCharts} onDayClick={handleDayClick} />
)}
) : (

No trend data for this period

Try a longer date range

)}
) }