Files
MileVault/frontend/src/pages/HealthPage.jsx
T
owain 8ed47d6042
Build and push images / validate (push) Successful in 2s
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 21s
HRV balanced dots, dashed gap lines, dashboard widgets + drag-to-edit layout
- Green dots for balanced HRV (joining orange unbalanced / red low) on the trend
- Trend charts bridge data gaps with a dashed line instead of a blank break
- Dashboard: drop Health today; add VO2 max, small sleep, and HRV status widgets
- Dashboard: editable widget grid (react-grid-layout) with drag/resize, saved per-user

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:04:43 +01:00

1165 lines
52 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'
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>
)
}
// 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 (
<div className="pl-10">
<div className="space-y-px">
{SLEEP_LANE_ORDER.map(level => (
<div key={level} className="relative flex items-center">
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
style={{ fontSize: 10 }}>
{SLEEP_STAGE_LABEL[level]}
</span>
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
{segsByLane[level].map((seg, i) => (
<div key={i} className="absolute top-0 h-full"
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }} />
))}
{ticks.map((t, i) => (
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
style={{ left: `${t.pct}%` }} />
))}
</div>
</div>
))}
</div>
<div className="relative h-4 mt-1 ml-0">
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
{ticks.map((t, i) => (
<span key={i} className="absolute text-gray-600"
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
{t.label}
</span>
))}
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
</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: '#8b5cf6' },
{ 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 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
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 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, '#8b5cf6'],
['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_nightly_avg ? Math.round(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} />
}
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
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>
)
return (
<ResponsiveContainer width="100%" height={height}>
<ComposedChart
data={data}
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 contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
{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="#8b5cf6" />
<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-6 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 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 (nightly 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-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</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 } },
]}
/>
</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','#8b5cf6'],['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>
)
}