Fix health trends range selector; add day navigation and chart click
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 51s
Build and push images / build-worker (push) Successful in 49s
Build and push images / build-frontend (push) Successful in 23s

- Fix keepPreviousData v4→v5: import keepPreviousData from @tanstack/react-query
  and use as placeholderData so charts don't blank out when switching ranges
- Normalise all metric dates to YYYY-MM-DD in queryFn so XAxis values and
  ReferenceLine x values always match
- Add allDays query (last 365 days) for snapshot navigation, keyed under
  ['health-metrics', 'all'] so UploadPage invalidation covers it
- Arrow nav (← →) in snapshot header steps through available days
- Clicking any trend chart data point loads that day in the snapshot
- Blue dashed ReferenceLine marks the selected day in every chart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 00:27:35 +01:00
parent 6d224d51c5
commit 7d6d34f61f
+204 -125
View File
@@ -1,12 +1,12 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { import {
AreaChart, Area, BarChart, Bar, AreaChart, Area, BarChart, Bar, ReferenceLine,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { format, subDays } from 'date-fns' import { format, subDays } from 'date-fns'
import api from '../utils/api' import api from '../utils/api'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format' import { formatSleep } from '../utils/format'
const RANGES = [ const RANGES = [
{ label: '1W', days: 7 }, { label: '1W', days: 7 },
@@ -17,9 +17,14 @@ const RANGES = [
{ label: '1Y', days: 365 }, { label: '1Y', days: 365 },
] ]
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 } const tooltipStyle = {
background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12,
}
// ── Daily Snapshot helpers ────────────────────────────────────────────────── // 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) { function fmtTime(ts) {
if (!ts) return '--' if (!ts) return '--'
@@ -52,22 +57,22 @@ function HrvBadge({ status }) {
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span> return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
} }
function Stat({ label, value, unit, sub, accent }) { function NavArrow({ onClick, disabled, children }) {
const accentCls = accent === 'red' ? 'text-rose-400' : accent === 'blue' ? 'text-blue-400' : accent === 'green' ? 'text-green-400' : 'text-white'
return ( return (
<div> <button
<p className="text-xs text-gray-500 mb-0.5">{label}</p> onClick={onClick}
<div className="flex items-baseline gap-1"> disabled={disabled}
<span className={`text-2xl font-bold ${accentCls}`}>{value}</span> className="w-7 h-7 flex items-center justify-center rounded-lg text-gray-400
{unit && <span className="text-xs text-gray-500">{unit}</span>} hover:text-white hover:bg-gray-800 disabled:opacity-20 disabled:cursor-default
</div> transition-colors text-base font-medium"
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>} >
</div> {children}
</button>
) )
} }
function DailySnapshot({ latest, avg30 }) { function DailySnapshot({ day, avg30, onOlder, onNewer, hasOlder, hasNewer }) {
if (!latest) return ( if (!day) return (
<div className="text-center py-10 text-gray-600"> <div className="text-center py-10 text-gray-600">
<p className="text-3xl mb-2">📊</p> <p className="text-3xl mb-2">📊</p>
<p>No health data yet</p> <p>No health data yet</p>
@@ -75,71 +80,67 @@ function DailySnapshot({ latest, avg30 }) {
</div> </div>
) )
const dateLabel = latest.date const dateLabel = day.date ? format(new Date(day.date), 'EEEE, d MMMM yyyy') : 'Latest'
? format(new Date(latest.date), 'EEEE, d MMMM yyyy') const hasSleepStages = day.sleep_deep_s || day.sleep_light_s || day.sleep_rem_s
: 'Latest'
const hasSleepStages = latest.sleep_deep_s || latest.sleep_light_s || latest.sleep_rem_s
const stepsGoal = 10000 const stepsGoal = 10000
const stepsPct = latest.steps ? Math.min(100, (latest.steps / stepsGoal * 100).toFixed(0)) : 0 const stepsPct = day.steps ? Math.min(100, Math.round(day.steps / stepsGoal * 100)) : 0
const stressLabel = !latest.avg_stress ? null : const stressLabel = !day.avg_stress ? null
latest.avg_stress < 25 ? 'Restful' : : day.avg_stress < 25 ? 'Restful'
latest.avg_stress < 50 ? 'Low' : : day.avg_stress < 50 ? 'Low'
latest.avg_stress < 75 ? 'Medium' : 'High' : day.avg_stress < 75 ? 'Medium' : 'High'
const stressColor = !latest.avg_stress ? 'text-white' : const stressColor = !day.avg_stress ? 'text-white'
latest.avg_stress < 25 ? 'text-green-400' : : day.avg_stress < 25 ? 'text-green-400'
latest.avg_stress < 50 ? 'text-yellow-400' : : day.avg_stress < 50 ? 'text-yellow-400'
latest.avg_stress < 75 ? 'text-orange-400' : 'text-red-400' : day.avg_stress < 75 ? 'text-orange-400' : 'text-red-400'
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div>
{/* 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> <p className="text-xs text-gray-500 uppercase tracking-wide">Daily snapshot</p>
<h2 className="text-xl font-semibold text-white mt-0.5">{dateLabel}</h2> <h2 className="text-xl font-semibold text-white leading-tight">{dateLabel}</h2>
</div>
<NavArrow onClick={onNewer} disabled={!hasNewer}></NavArrow>
</div> </div>
{/* Top row: Sleep (wide) + Heart/HRV */} {/* Sleep (wide) + Heart / HRV */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Sleep card — 2/3 width on desktop */}
<div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3"> <div className="lg:col-span-2 bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3> <h3 className="text-sm font-medium text-gray-300">Sleep</h3>
<div className="flex items-center gap-2"> {day.sleep_score != null && (
{latest.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"> <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(latest.sleep_score)} Score {Math.round(day.sleep_score)}
</span> </span>
)} )}
</div> </div>
</div>
<div className="flex items-end gap-3"> <div className="flex items-end gap-3">
<span className="text-4xl font-bold text-white tracking-tight"> <span className="text-4xl font-bold text-white tracking-tight">
{formatSleep(latest.sleep_duration_s)} {formatSleep(day.sleep_duration_s)}
</span> </span>
{latest.sleep_start && latest.sleep_end && ( {day.sleep_start && day.sleep_end && (
<span className="text-sm text-gray-500 pb-1"> <span className="text-sm text-gray-500 pb-1">
{fmtTime(latest.sleep_start)} {fmtTime(latest.sleep_end)} {fmtTime(day.sleep_start)} {fmtTime(day.sleep_end)}
</span> </span>
)} )}
</div> </div>
{hasSleepStages ? ( {hasSleepStages ? (
<> <>
<SleepStagesBar <SleepStagesBar
deep={latest.sleep_deep_s} deep={day.sleep_deep_s} light={day.sleep_light_s}
light={latest.sleep_light_s} rem={day.sleep_rem_s} awake={day.sleep_awake_s}
rem={latest.sleep_rem_s}
awake={latest.sleep_awake_s}
/> />
<div className="flex flex-wrap gap-x-5 gap-y-1.5"> <div className="flex flex-wrap gap-x-5 gap-y-1.5">
{[ {[
['Deep', latest.sleep_deep_s, '#6366f1'], ['Deep', day.sleep_deep_s, '#6366f1'],
['REM', latest.sleep_rem_s, '#8b5cf6'], ['REM', day.sleep_rem_s, '#8b5cf6'],
['Light', latest.sleep_light_s, '#a78bfa'], ['Light', day.sleep_light_s, '#a78bfa'],
['Awake', latest.sleep_awake_s, '#4b5563'], ['Awake', day.sleep_awake_s, '#4b5563'],
].map(([label, secs, color]) => secs ? ( ].map(([label, secs, color]) => secs ? (
<div key={label} className="flex items-center gap-1.5"> <div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} /> <div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
@@ -150,53 +151,47 @@ function DailySnapshot({ latest, avg30 }) {
) : null)} ) : null)}
</div> </div>
</> </>
) : !latest.sleep_duration_s ? ( ) : !day.sleep_duration_s ? (
<p className="text-sm text-gray-600">No sleep data</p> <p className="text-sm text-gray-600">No sleep data</p>
) : null} ) : null}
</div> </div>
{/* Heart & HRV card — 1/3 width */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-5 space-y-4">
<h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3> <h3 className="text-sm font-medium text-gray-300">Heart & HRV</h3>
<div> <div>
<p className="text-xs text-gray-500 mb-0.5">Resting HR</p> <p className="text-xs text-gray-500 mb-0.5">Resting HR</p>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-3xl font-bold text-rose-400"> <span className="text-3xl font-bold text-rose-400">
{latest.resting_hr ? Math.round(latest.resting_hr) : '--'} {day.resting_hr ? Math.round(day.resting_hr) : '--'}
</span> </span>
<span className="text-sm text-gray-500">bpm</span> <span className="text-sm text-gray-500">bpm</span>
</div> </div>
{avg30?.resting_hr && latest.resting_hr && ( {avg30?.resting_hr && day.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)} bpm 30d avg {Math.round(avg30.resting_hr)} bpm
{latest.resting_hr < avg30.resting_hr {day.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span> ? <span className="text-green-400 ml-1"></span>
: latest.resting_hr > avg30.resting_hr : day.resting_hr > avg30.resting_hr
? <span className="text-red-400 ml-1"></span> ? <span className="text-red-400 ml-1"></span>
: null} : null}
</p> </p>
)} )}
</div> </div>
<div> <div>
<p className="text-xs text-gray-500 mb-0.5">HRV</p> <p className="text-xs text-gray-500 mb-0.5">HRV</p>
<div className="flex items-baseline gap-1.5 flex-wrap"> <div className="flex items-baseline gap-1.5 flex-wrap">
<span className="text-3xl font-bold text-violet-400"> <span className="text-3xl font-bold text-violet-400">
{latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'} {day.hrv_nightly_avg ? Math.round(day.hrv_nightly_avg) : '--'}
</span> </span>
<span className="text-sm text-gray-500">ms</span> <span className="text-sm text-gray-500">ms</span>
<HrvBadge status={latest.hrv_status} /> <HrvBadge status={day.hrv_status} />
</div> </div>
</div> </div>
{day.avg_hr_day && (
{latest.avg_hr_day && (
<div> <div>
<p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p> <p className="text-xs text-gray-500 mb-0.5">Avg HR (day)</p>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
<span className="text-xl font-semibold text-orange-400"> <span className="text-xl font-semibold text-orange-400">{Math.round(day.avg_hr_day)}</span>
{Math.round(latest.avg_hr_day)}
</span>
<span className="text-xs text-gray-500">bpm</span> <span className="text-xs text-gray-500">bpm</span>
</div> </div>
</div> </div>
@@ -207,78 +202,69 @@ function DailySnapshot({ latest, avg30 }) {
{/* Activity strip */} {/* Activity strip */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{/* Steps */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Steps</p> <p className="text-xs text-gray-500 mb-1">Steps</p>
<div className="flex items-baseline gap-1 mb-2"> <div className="flex items-baseline gap-1 mb-2">
<span className="text-2xl font-bold text-yellow-400"> <span className="text-2xl font-bold text-yellow-400">
{latest.steps ? latest.steps.toLocaleString() : '--'} {day.steps ? day.steps.toLocaleString() : '--'}
</span> </span>
</div> </div>
{latest.steps ? ( {day.steps ? (
<> <>
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden"> <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 className="h-full bg-yellow-400 rounded-full transition-all"
style={{ width: `${stepsPct}%` }} />
</div> </div>
<p className="text-xs text-gray-600 mt-1">{stepsPct}% of {stepsGoal.toLocaleString()}</p> <p className="text-xs text-gray-600 mt-1">{stepsPct}% of {stepsGoal.toLocaleString()}</p>
</> </>
) : null} ) : null}
{latest.floors_climbed ? ( {day.floors_climbed
<p className="text-xs text-gray-500 mt-1">{latest.floors_climbed} floors</p> ? <p className="text-xs text-gray-500 mt-1">{day.floors_climbed} floors</p>
) : null} : null}
</div> </div>
{/* Calories */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Calories</p> <p className="text-xs text-gray-500 mb-1">Calories</p>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-white"> <span className="text-2xl font-bold text-white">
{latest.total_calories {day.total_calories
? Math.round(latest.total_calories) ? Math.round(day.total_calories)
: latest.active_calories : day.active_calories ? Math.round(day.active_calories) : '--'}
? Math.round(latest.active_calories)
: '--'}
</span> </span>
<span className="text-xs text-gray-500">kcal</span> <span className="text-xs text-gray-500">kcal</span>
</div> </div>
{latest.active_calories && latest.total_calories && ( {day.active_calories && day.total_calories && (
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">Active {Math.round(day.active_calories)} kcal</p>
Active {Math.round(latest.active_calories)} kcal
</p>
)} )}
</div> </div>
{/* Stress */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-1">Stress</p> <p className="text-xs text-gray-500 mb-1">Stress</p>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className={`text-2xl font-bold ${stressColor}`}> <span className={`text-2xl font-bold ${stressColor}`}>
{latest.avg_stress ? Math.round(latest.avg_stress) : '--'} {day.avg_stress ? Math.round(day.avg_stress) : '--'}
</span> </span>
{latest.avg_stress && <span className="text-xs text-gray-500">/100</span>} {day.avg_stress && <span className="text-xs text-gray-500">/100</span>}
</div> </div>
{stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>} {stressLabel && <p className="text-xs text-gray-500 mt-1">{stressLabel}</p>}
</div> </div>
{/* SpO2 or VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4"> <div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
{latest.spo2_avg ? ( {day.spo2_avg ? (
<> <>
<p className="text-xs text-gray-500 mb-1">SpO2</p> <p className="text-xs text-gray-500 mb-1">SpO2</p>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-sky-400">{latest.spo2_avg.toFixed(1)}</span> <span className="text-2xl font-bold text-sky-400">{day.spo2_avg.toFixed(1)}</span>
<span className="text-xs text-gray-500">%</span> <span className="text-xs text-gray-500">%</span>
</div> </div>
</> </>
) : latest.vo2max ? ( ) : day.vo2max ? (
<> <>
<p className="text-xs text-gray-500 mb-1">VO2 Max</p> <p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">{latest.vo2max.toFixed(1)}</span> <span className="text-2xl font-bold text-blue-400">{day.vo2max.toFixed(1)}</span>
</div> </div>
{latest.fitness_age && ( {day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
<p className="text-xs text-gray-500 mt-1">Fitness age {latest.fitness_age}</p>
)}
</> </>
) : ( ) : (
<> <>
@@ -294,14 +280,22 @@ function DailySnapshot({ latest, avg30 }) {
// ── Trend Charts ──────────────────────────────────────────────────────────── // ── Trend Charts ────────────────────────────────────────────────────────────
function MetricChart({ data, dataKey, color, formatter, height = 140 }) { function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick }) {
const vals = data.filter(d => d[dataKey] != null) const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return ( if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div> <div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
) )
return ( return (
<ResponsiveContainer width="100%" height={height}> <ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}> <AreaChart
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> <defs>
<linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`grad-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} /> <stop offset="5%" stopColor={color} stopOpacity={0.3} />
@@ -315,6 +309,9 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
tickFormatter={formatter} /> tickFormatter={formatter} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} <Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} /> formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2} <Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} /> fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
</AreaChart> </AreaChart>
@@ -322,25 +319,39 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
) )
} }
function SleepChart({ data }) { function SleepChart({ data, selectedDate, onDayClick }) {
const chartData = data.map(d => ({ const chartData = data.map(d => ({
date: d.date, date: d.date, // already normalised to YYYY-MM-DD
deep: d.sleep_deep_s ? +(d.sleep_deep_s / 3600).toFixed(2) : null, 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, 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, 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, awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
})) }))
const hasData = chartData.some(d => d.deep || d.rem || d.light) 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> if (!hasData) return (
<div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
)
return ( return (
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}> <BarChart
data={chartData}
margin={{ top: 4, right: 4, 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} /> <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} /> tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} /> <Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
{selectedDate && (
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
)}
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" /> <Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" /> <Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
@@ -350,44 +361,92 @@ function SleepChart({ data }) {
) )
} }
// ── Page ──────────────────────────────────────────────────────────────────── // ── Page ────────────────────────────────────────────────────────────────────
export default function HealthPage() { export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(7) 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 fromDate = useMemo(
() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'),
[rangeDays],
)
const { data: summary } = useQuery({ const { data: summary } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data), queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
}) })
const { data: metrics, isLoading } = useQuery({ // Full history for snapshot navigation.
queryKey: ['health-metrics', rangeDays], // Key starts with ['health-metrics'] so UploadPage invalidation hits it automatically.
const { data: allDays } = useQuery({
queryKey: ['health-metrics', 'all'],
queryFn: () => queryFn: () =>
api.get('/health-metrics/', { api.get('/health-metrics/', { params: { limit: 365 } })
params: { from_date: fromDate, limit: rangeDays + 1 }, .then(r => r.data.map(d => ({ ...d, date: d10(d.date) }))),
}).then(r => r.data.slice().reverse()),
keepPreviousData: true,
}) })
const latest = summary?.latest // Trend window (changes with range selector).
const avg30 = summary?.avg_30d // 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])
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 ( return (
<div className="p-6 space-y-8"> <div className="p-6 space-y-8">
<h1 className="text-2xl font-bold text-white">Health</h1> <h1 className="text-2xl font-bold text-white">Health</h1>
{/* Daily snapshot */} <DailySnapshot
<DailySnapshot latest={latest} avg30={avg30} /> day={selectedDay}
avg30={summary?.avg_30d}
onOlder={goOlder}
onNewer={goNewer}
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
hasNewer={selectedIdx > 0}
/>
{/* Divider */}
<div className="border-t border-gray-800" /> <div className="border-t border-gray-800" />
{/* Trends section */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-base font-semibold text-gray-300">Trends</h2> <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"> <div className="flex gap-1.5">
{RANGES.map(({ label, days }) => ( {RANGES.map(({ label, days }) => (
<button key={label} onClick={() => setRangeDays(days)} <button key={label} onClick={() => setRangeDays(days)}
@@ -404,24 +463,27 @@ export default function HealthPage() {
{isLoading ? ( {isLoading ? (
<div className="text-gray-500 text-sm">Loading</div> <div className="text-gray-500 text-sm">Loading</div>
) : metrics && metrics.length > 0 ? ( ) : metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <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"> <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> <h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e" <MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`} /> formatter={v => `${Math.round(v)} bpm`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">HRV (nightly avg)</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6" <MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`} /> formatter={v => `${Math.round(v)} ms`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">Sleep</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
<SleepChart data={metrics} /> <SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
<div className="flex gap-4 mt-2"> <div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => ( {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5"> <div key={l} className="flex items-center gap-1.5">
@@ -435,19 +497,32 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">Weight</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399" <MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`} /> formatter={v => `${v.toFixed(1)} kg`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">Daily Steps</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}> <ResponsiveContainer width="100%" height={140}>
<BarChart data={metrics} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}> <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} /> <CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} <XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36} <YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} /> <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} /> <Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -456,19 +531,23 @@ export default function HealthPage() {
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">Stress Level</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa" <MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)} /> formatter={v => Math.round(v)}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">Avg Heart Rate (day)</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316" <MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} /> formatter={v => `${Math.round(v)} bpm`}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
{metrics.some(d => d.vo2max) && ( {metrics.some(d => d.vo2max) && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-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">VO2 Max</h3> <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)} /> <MetricChart data={metrics} dataKey="vo2max" color="#3b82f6"
formatter={v => v.toFixed(1)}
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
</div> </div>
)} )}