Files
MileVault/frontend/src/pages/HealthPage.jsx
T
owainandClaude Opus 4.8 3a07655e2e
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
dashboard sleep widget: fill empty space with hypnogram graph from Health tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:54:49 +01:00

1112 lines
50 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useMemo } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'
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'
import SleepHypnogram from '../components/health/SleepHypnogram'
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
// White arrow: tip lands exactly at the arc centre-line at the value's angle;
// base extends outside the track — unambiguously marks the precise position.
const arrowPts = value != null ? (() => {
const a = toAngle(Math.max(MIN, Math.min(MAX, value)))
const tipR = r // tip at centre of the coloured track
const baseR = r + sw / 2 + 9 // base well outside the outer edge
const s = 0.09 // half-spread ≈ 5° — narrow for precision
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 (
<div className="flex flex-col items-center">
<svg width="140" height="92" viewBox="0 0 140 92">
{/* Dark background track, slightly wider than the colour bands */}
<path d={arc(MIN, MAX)} stroke="#1f2937" strokeWidth={sw + 4} fill="none" strokeLinecap="butt" />
{/* 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 (
<path key={i} d={arc(v1, v2)}
stroke={c.color} strokeWidth={sw} fill="none" strokeLinecap="butt" />
)
})}
{/* White arrow: tip at exact value position on arc, base pointing outward */}
{arrowPts && <polygon points={arrowPts} fill="white" />}
{/* VO2 number, coloured by category */}
<text x={cx} y={cy - 6} textAnchor="middle" dominantBaseline="middle"
fontSize="21" fontWeight="700" fill={cat?.color ?? '#6b7280'}>
{value != null ? value.toFixed(1) : '--'}
</text>
{/* Category label */}
<text x={cx} y={cy + 11} textAnchor="middle" dominantBaseline="middle"
fontSize="9" fill="#9ca3af">
{cat?.label ?? (biologicalSex ? '' : 'Set sex in profile')}
</text>
</svg>
</div>
)
}
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 (
<ResponsiveContainer width="100%" height={100}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: 0 }}>
<defs>
<linearGradient id="grad-intraday-hr" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f43f5e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f43f5e" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(data.length / 6))} />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => Math.round(v)} domain={['auto', 'auto']} />
<Tooltip contentStyle={tooltipStyle}
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
formatter={v => [`${Math.round(v)} bpm`, 'HR']} />
<Area type="monotone" dataKey="hr" stroke="#f43f5e" strokeWidth={1.5}
fill="url(#grad-intraday-hr)" dot={false} isAnimationActive={false} connectNulls={false} />
</AreaChart>
</ResponsiveContainer>
)
}
// ── Body Battery ─────────────────────────────────────────────────────────────
function ActivityRefLabel({ viewBox, icon }) {
if (!viewBox) return null
const { x, y, width = 0 } = viewBox
return (
<text x={x + width / 2} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
{icon}
</text>
)
}
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
// The X axis is categorical (band scale), so overlays must use values that
// exist in the data — snap activity start/end to the nearest sample.
const nearestT = (ms) => {
let best = null, bd = Infinity
for (const d of chartData) { const dd = Math.abs(d.t - ms); if (dd < bd) { bd = dd; best = d.t } }
return best
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
<div className="flex items-baseline gap-3 flex-wrap mb-3">
{maxLevel != null && (
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
)}
{charged != null && (
<span className="text-sm font-semibold text-green-400">+{charged}</span>
)}
{drained != null && (
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
)}
{end_level != null && (
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
)}
</div>
<div className="flex-1">
<ResponsiveContainer width="100%" height={100}>
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
interval={Math.max(1, Math.floor(chartData.length / 6))} />
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
<Tooltip contentStyle={tooltipStyle} 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}>
{chartData.map((d, i) => (
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
))}
</Bar>
{(activities || []).map(a => {
const start = new Date(a.start_time).getTime()
const end = a.duration_s ? start + a.duration_s * 1000 : start
const x1 = nearestT(start), x2 = nearestT(end)
if (x1 == null || x2 == null) return null
return (
<ReferenceArea
key={`area-${a.id}`}
x1={x1}
x2={x2}
fill="rgba(255,255,255,0.16)"
stroke="rgba(255,255,255,0.3)"
strokeWidth={1}
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
/>
)
})}
</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>
</div>
)
}
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: '#7c3aed' },
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
].filter(seg => seg.s > 0)
return (
<div className="mt-2">
<div className="flex h-5 rounded-sm overflow-hidden">
{segments.map(seg => (
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
))}
</div>
</div>
)
}
function NavArrow({ onClick, disabled, children }) {
return (
<button
onClick={onClick}
disabled={disabled}
className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400
hover:text-white hover:bg-gray-800 disabled:opacity-20 disabled:cursor-default
transition-colors text-base font-medium"
>
{children}
</button>
)
}
function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, latestVo2max, birthYear, biologicalSex, onOlder, onNewer, hasOlder, hasNewer }) {
if (!day) return (
<div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p>
<p>No health data yet</p>
<p className="text-sm mt-1">Import a Garmin export to see your daily snapshot</p>
</div>
)
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 (
<div className="space-y-4">
{/* Header + arrows */}
<div className="flex items-center gap-3">
<NavArrow onClick={onOlder} disabled={!hasOlder}></NavArrow>
<div className="min-w-0">
<p className="text-xs text-gray-500 uppercase tracking-wide">Daily snapshot</p>
<h2 className="text-xl font-semibold text-white leading-tight">{dateLabel}</h2>
</div>
<NavArrow onClick={onNewer} disabled={!hasNewer}></NavArrow>
</div>
{/* Sleep (wide) + Heart / HRV */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
{day.sleep_score != null && (
<span className="text-xs px-2 py-0.5 rounded-full border border-indigo-400/30 bg-indigo-400/10 text-indigo-300">
Score {Math.round(day.sleep_score)}
</span>
)}
</div>
<div className="flex flex-wrap items-end gap-3">
<span className="text-4xl font-bold text-white tracking-tight">
{formatSleep(day.sleep_duration_s)}
</span>
{day.sleep_start && day.sleep_end && (
<span className="text-sm text-gray-500 pb-1">
{fmtTime(day.sleep_start)} {fmtTime(day.sleep_end)}
</span>
)}
</div>
{hasSleepStages ? (
<>
{sleepStages?.length ? (
<SleepHypnogram
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
stages={sleepStages}
/>
) : (
<SleepStageFallbackBar
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
/>
)}
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
{[
['Deep', day.sleep_deep_s, '#6366f1'],
['REM', day.sleep_rem_s, '#7c3aed'],
['Light', day.sleep_light_s, '#a78bfa'],
['Awake', day.sleep_awake_s, '#eab308'],
].map(([label, secs, color]) => secs ? (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">
{label} <span className="text-white">{formatSleep(secs)}</span>
</span>
</div>
) : null)}
</div>
</>
) : !day.sleep_duration_s ? (
<p className="text-sm text-gray-600">No sleep data</p>
) : null}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart & HRV</h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold text-rose-400">
{day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
</div>
{avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)}
{day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span>
: null}
</p>
)}
</div>
<div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400">
{(day.hrv_weekly_avg ?? day.hrv_nightly_avg) ? Math.round(day.hrv_weekly_avg ?? day.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
</div>
<div className="mt-0.5"><HrvBadge status={day.hrv_status} /></div>
</div>
<div>
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
<div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold text-orange-400">
{day.avg_hr_day ? Math.round(day.avg_hr_day) : '--'}
</span>
{day.max_hr_day && <span className="text-xs text-gray-500">/ {Math.round(day.max_hr_day)} max</span>}
</div>
</div>
<div>
<p className="text-xs text-gray-500 mb-0.5">Weight</p>
<div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-xl font-semibold text-emerald-400">
{snapshotWeight ? snapshotWeight.kg.toFixed(1) : '--'}
</span>
{snapshotWeight && <span className="text-xs text-gray-500">kg</span>}
{snapshotWeight?.fat && !snapshotWeight.carried && <span className="text-xs text-gray-500">{snapshotWeight.fat.toFixed(1)}% fat</span>}
</div>
{snapshotWeight?.carried && (
<p className="text-xs text-gray-600 mt-0.5">as of {format(new Date(snapshotWeight.date), 'd MMM')}</p>
)}
</div>
</div>
</div>
</div>
{/* 24-hour heart rate chart + body battery (side by side) */}
{(intradayHr?.length > 0 || bodyBattery) && (
<div className={`grid gap-4 ${intradayHr?.length > 0 && bodyBattery ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
{intradayHr?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-gray-300">24-hour Heart Rate</h3>
{day.avg_hr_day && (
<span className="text-xs text-gray-500">avg {Math.round(day.avg_hr_day)} bpm</span>
)}
</div>
<div className="flex-1 min-h-0">
<IntradayHrChart values={intradayHr} />
</div>
</div>
)}
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
</div>
)}
{/* Activity strip */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Steps</p>
<div className="flex items-baseline gap-1 mb-2">
<span className="text-2xl font-bold text-yellow-400">
{day.steps ? day.steps.toLocaleString() : '--'}
</span>
</div>
{day.steps ? (
<>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
<div className="h-full bg-yellow-400 rounded-full transition-all"
style={{ width: `${stepsPct}%` }} />
</div>
<p className="text-xs text-gray-600 mt-1">{stepsPct}% of {stepsGoal.toLocaleString()}</p>
</>
) : null}
{day.floors_climbed
? <p className="text-xs text-gray-500 mt-1">{day.floors_climbed} floors</p>
: null}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Calories</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-white">
{day.total_calories
? Math.round(day.total_calories)
: day.active_calories ? Math.round(day.active_calories) : '--'}
</span>
<span className="text-xs text-gray-500">kcal</span>
</div>
{day.active_calories && day.total_calories && (
<p className="text-xs text-gray-500 mt-1">Active {Math.round(day.active_calories)} kcal</p>
)}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Stress</p>
<div className="flex items-baseline gap-1">
<span className={`text-2xl font-bold ${stressColor}`}>
{day.avg_stress ? Math.round(day.avg_stress) : '--'}
</span>
{day.avg_stress && <span className="text-xs text-gray-500">/100</span>}
</div>
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col">
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex-1 flex items-center justify-center">
<Vo2MaxGauge
value={(day.vo2max ?? latestVo2max) ?? null}
birthYear={birthYear}
biologicalSex={biologicalSex}
/>
</div>
{day.fitness_age && <p className="text-xs text-gray-500 mt-1 text-center">Fitness age {day.fitness_age}</p>}
</div>
</div>
</div>
)
}
// ── Trend Charts ────────────────────────────────────────────────────────────
// Highlight problem days on a trend line by colouring the dot from a status field
// (e.g. HRV status): orange = unbalanced, red = low/poor. Other days get no dot.
const STATUS_DOT_COLORS = { balanced: '#22c55e', unbalanced: '#f97316', low: '#ef4444', poor: '#ef4444' }
const statusDot = (statusKey) => (props) => {
const { cx, cy, payload } = props
const color = STATUS_DOT_COLORS[String(payload?.[statusKey] || '').toLowerCase()]
if (cx == null || cy == null || !color) return null
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
}
// Custom tooltip: collapses the dashed gap-bridging Line and the solid Area
// (same dataKey) into one row, and never shows the helper baseline band.
function ChartTooltip({ active, payload, label, formatter }) {
if (!active || !payload?.length) return null
const seen = new Set()
const rows = []
for (const p of payload) {
if (p.dataKey === '__band' || p.value == null || seen.has(p.dataKey)) continue
seen.add(p.dataKey)
rows.push(p)
}
if (!rows.length) return null
return (
<div style={{ ...tooltipStyle, padding: '6px 10px' }}>
<div style={{ color: '#9ca3af', marginBottom: 2 }}>{format(new Date(label), 'MMM d, yyyy')}</div>
{rows.map(p => (
<div key={p.dataKey} style={{ color: '#fff' }}>{formatter ? formatter(p.value) : p.value?.toFixed(1)}</div>
))}
</div>
)
}
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={chartData}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}
>
<defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} domain={domain} />
<Tooltip content={<ChartTooltip formatter={formatter} />} />
{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" />
)}
{(referenceLines || []).map((rl, i) => (
<ReferenceLine key={i} {...rl} />
))}
{/* Dashed line bridging gaps (no data). Drawn first; the solid area below
covers it wherever real data exists, leaving only gaps shown dashed. */}
<Line type="monotone" dataKey={dataKey} stroke={color} strokeWidth={1.5}
strokeDasharray="4 4" dot={false} connectNulls isAnimationActive={false} legendType="none" />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={statusDotKey ? statusDot(statusDotKey) : (showDots ? { fill: color, r: 3, strokeWidth: 0 } : false)}
connectNulls={false} isAnimationActive={false} />
</ComposedChart>
</ResponsiveContainer>
)
}
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 (
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
)
const totals = chartData
.map(d => (d.deep || 0) + (d.rem || 0) + (d.light || 0) + (d.awake || 0))
.filter(t => t > 0)
const avgSleep = totals.length ? +(totals.reduce((a, b) => a + b, 0) / totals.length).toFixed(1) : null
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart
data={chartData}
margin={{ top: 4, right: 44, bottom: 4, left: 0 }}
barSize={6}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
<ReferenceLine y={8} stroke="#22c55e" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: '8h', position: 'right', fill: '#22c55e', fontSize: 9 }} />
{avgSleep != null && (
<ReferenceLine y={avgSleep} stroke="#a855f7" strokeDasharray="4 3" strokeWidth={1.5}
label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} />
)}
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#7c3aed" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)
}
// ── Weight (with goal line + kg ⇄ st/lb toggle) ──────────────────────────────
const KG_TO_LB = 2.2046226218
function fmtStLb(lb) {
let st = Math.floor(lb / 14)
let r = Math.round(lb - st * 14)
if (r === 14) { st += 1; r = 0 }
return `${st} st ${r} lb`
}
function WeightChart({ data, goalKg, selectedDate, onDayClick }) {
const [unit, setUnit] = useState(() => localStorage.getItem('weightUnit') || 'kg')
const choose = (u) => { setUnit(u); localStorage.setItem('weightUnit', u) }
const imperial = unit === 'lb'
const toU = (kg) => (imperial ? kg * KG_TO_LB : kg)
const withWeight = data.filter(d => d.weight_kg != null)
const series = withWeight.map(d => ({ date: d.date, w: +toU(d.weight_kg).toFixed(2) }))
const title = imperial ? 'Weight (st & lb)' : 'Weight (kg)'
const toggle = (
<div className="flex gap-1">
{[['kg', 'kg'], ['lb', 'st/lb']].map(([u, label]) => (
<button key={u} onClick={() => choose(u)}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
unit === u ? 'bg-blue-600 text-white' : 'text-gray-400 bg-gray-800 hover:text-white'
}`}>
{label}
</button>
))}
</div>
)
if (!series.length) {
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No weight data</div>
</>
)
}
const maxKg = Math.max(...withWeight.map(d => d.weight_kg))
const minKg = Math.min(...withWeight.map(d => d.weight_kg))
const goalU = goalKg != null ? +toU(goalKg).toFixed(1) : null
const yMax = Math.ceil(toU(maxKg + 20)) // highest weight + 20 kg equivalent
const yMin = Math.max(0, Math.floor(toU(Math.max(0, minKg - 20)))) // lowest weight 20 kg equivalent
const fmtVal = (v) => (imperial ? `${fmtStLb(v)} (${Math.round(v)} lb)` : `${v.toFixed(1)} kg`)
return (
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">{title}</h3>{toggle}
</div>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={series} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date && onDayClick) onDayClick(p.date)
}}>
<defs>
<linearGradient id="grad-weight" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.3} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis domain={[yMin, yMax]} tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
width={36} tickFormatter={v => Math.round(v)} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [fmtVal(v), 'Weight']} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
{goalU != null && (
<ReferenceLine y={goalU} stroke="#22c55e" strokeDasharray="5 3" strokeWidth={1.5}
label={{ value: `Goal ${imperial ? fmtStLb(goalU) : `${goalU} kg`}`, position: 'insideTopLeft', fill: '#22c55e', fontSize: 9 }} />
)}
<Area type="monotone" dataKey="w" stroke="#34d399" strokeWidth={2}
fill="url(#grad-weight)" dot={{ fill: '#34d399', r: 3, strokeWidth: 0 }}
connectNulls isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</>
)
}
// ── 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],
)
// Disable trend ranges that reach further back than the data goes. Keep every
// range up to and including the first one that already covers the full history
// enabled; ranges beyond that would only show the same (full) data. While the
// history is still loading we leave all ranges enabled.
const maxEnabledRangeIdx = useMemo(() => {
if (!allDaysSorted.length) return RANGES.length - 1
const oldest = allDaysSorted[allDaysSorted.length - 1].date
const span = differenceInCalendarDays(new Date(), parseISO(oldest))
const idx = RANGES.findIndex(r => r.days >= span)
return idx === -1 ? RANGES.length - 1 : idx
}, [allDaysSorted])
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])
// Weight for the snapshot: the selected day's, or the most recent earlier reading.
const snapshotWeight = useMemo(() => {
if (!selectedDay) return null
if (selectedDay.weight_kg != null)
return { kg: selectedDay.weight_kg, fat: selectedDay.body_fat_pct, carried: false }
const earlier = allDaysSorted.find(d => d.weight_kg != null && d.date <= selectedDay.date)
return earlier ? { kg: earlier.weight_kg, fat: earlier.body_fat_pct, carried: true, date: earlier.date } : null
}, [selectedDay, 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 (
<div className="p-4 md:p-6 space-y-6 md:space-y-8">
<h1 className="text-2xl font-bold text-white">Health</h1>
<DailySnapshot
day={selectedDay}
snapshotWeight={snapshotWeight}
avg30={summary?.avg_30d}
intradayHr={intradayData?.hr_values}
bodyBattery={intradayData?.body_battery}
bbHires={intradayData?.body_battery_hires}
sleepStages={intradayData?.sleep_stages}
activities={dayActivities}
latestVo2max={latestVo2max}
birthYear={profile?.birth_year}
biologicalSex={profile?.biological_sex}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
hasNewer={selectedIdx > 0}
/>
<div className="border-t border-gray-800" />
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
<p className="text-xs text-gray-600">Click any point to load that day above</p>
</div>
<div className="flex flex-wrap gap-1.5">
{RANGES.map(({ label, days }, i) => {
const disabled = i > maxEnabledRangeIdx
return (
<button key={label}
onClick={() => !disabled && setRangeDays(days)}
disabled={disabled}
title={disabled ? 'Not enough history for this range' : undefined}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
disabled
? 'border-gray-800 text-gray-700 opacity-40 cursor-not-allowed'
: rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
)
})}
</div>
</div>
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-300">HRV (overnight avg)</h3>
<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-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</span>
</div>
</div>
<MetricChart data={metrics} dataKey="hrv_weekly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
selectedDate={selDateForCharts} onDayClick={handleDayClick}
statusDotKey="hrv_status"
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
/>
</div>
<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</h3>
<SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
<div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
</div>
))}
</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}
goalKg={profile?.goal_weight_kg}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}>
<BarChart
data={metrics}
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
barSize={6}
style={{ cursor: 'pointer' }}
onClick={evt => {
const p = evt?.activePayload?.[0]?.payload
if (p?.date) handleDayClick(p.date)
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
{selDateForCharts && (
<ReferenceLine x={selDateForCharts} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)}
domain={[0, 100]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Heart Rate</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => Math.round(v)}
domain={[0, 200]}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
{metrics.some(d => d.body_battery?.end_level != 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">Body Battery (end of day)</h3>
<MetricChart
data={metrics.map(d => ({ ...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} />
</div>
)}
{metrics.some(d => d.vo2max) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)}
domain={[30, 70]}
connectNulls showDots
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div>
)}
</div>
) : (
<div className="text-center py-12 text-gray-600">
<p className="text-lg">No trend data for this period</p>
<p className="text-sm mt-1">Try a longer date range</p>
</div>
)}
</div>
</div>
)
}