Add daily health snapshot to Health page
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 49s
Build and push images / build-worker (push) Successful in 47s
Build and push images / build-frontend (push) Successful in 9s

Replaces the flat stat card grid with a rich daily view at the top: sleep card
with duration, stage bar and times; heart/HRV card; activity strip (steps with
progress bar, calories, stress, SpO2). Trend charts moved below under a Trends
heading with the range selector inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:46:08 +01:00
parent c3637fa3fa
commit 0cdc653664
+313 -41
View File
@@ -1,12 +1,11 @@
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, AreaChart, Area, BarChart, Bar,
AreaChart, Area, BarChart, Bar,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts'
import { format, subDays } from 'date-fns'
import api from '../utils/api'
import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [
@@ -20,6 +19,281 @@ const RANGES = [
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
// ── Daily Snapshot helpers ──────────────────────────────────────────────────
function fmtTime(ts) {
if (!ts) return '--'
return new Date(ts).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
function SleepStagesBar({ deep, light, rem, awake }) {
const total = (deep || 0) + (light || 0) + (rem || 0) + (awake || 0)
if (!total) return null
const pct = s => `${((s || 0) / total * 100).toFixed(1)}%`
return (
<div className="flex rounded-full overflow-hidden h-2.5 w-full">
<div style={{ width: pct(deep), backgroundColor: '#6366f1' }} />
<div style={{ width: pct(rem), backgroundColor: '#8b5cf6' }} />
<div style={{ width: pct(light), backgroundColor: '#a78bfa' }} />
<div style={{ width: pct(awake), backgroundColor: '#374151' }} />
</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 Stat({ label, value, unit, sub, accent }) {
const accentCls = accent === 'red' ? 'text-rose-400' : accent === 'blue' ? 'text-blue-400' : accent === 'green' ? 'text-green-400' : 'text-white'
return (
<div>
<p className="text-xs text-gray-500 mb-0.5">{label}</p>
<div className="flex items-baseline gap-1">
<span className={`text-2xl font-bold ${accentCls}`}>{value}</span>
{unit && <span className="text-xs text-gray-500">{unit}</span>}
</div>
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
)
}
function DailySnapshot({ latest, avg30 }) {
if (!latest) 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 = latest.date
? format(new Date(latest.date), 'EEEE, d MMMM yyyy')
: 'Latest'
const hasSleepStages = latest.sleep_deep_s || latest.sleep_light_s || latest.sleep_rem_s
const stepsGoal = 10000
const stepsPct = latest.steps ? Math.min(100, (latest.steps / stepsGoal * 100).toFixed(0)) : 0
const stressLabel = !latest.avg_stress ? null :
latest.avg_stress < 25 ? 'Restful' :
latest.avg_stress < 50 ? 'Low' :
latest.avg_stress < 75 ? 'Medium' : 'High'
const stressColor = !latest.avg_stress ? 'text-white' :
latest.avg_stress < 25 ? 'text-green-400' :
latest.avg_stress < 50 ? 'text-yellow-400' :
latest.avg_stress < 75 ? 'text-orange-400' : 'text-red-400'
return (
<div className="space-y-4">
<div>
<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>
</div>
{/* Top row: Sleep (wide) + Heart/HRV */}
<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="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-300">Sleep</h3>
<div className="flex items-center gap-2">
{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">
Score {Math.round(latest.sleep_score)}
</span>
)}
</div>
</div>
<div className="flex items-end gap-3">
<span className="text-4xl font-bold text-white tracking-tight">
{formatSleep(latest.sleep_duration_s)}
</span>
{latest.sleep_start && latest.sleep_end && (
<span className="text-sm text-gray-500 pb-1">
{fmtTime(latest.sleep_start)} {fmtTime(latest.sleep_end)}
</span>
)}
</div>
{hasSleepStages ? (
<>
<SleepStagesBar
deep={latest.sleep_deep_s}
light={latest.sleep_light_s}
rem={latest.sleep_rem_s}
awake={latest.sleep_awake_s}
/>
<div className="flex flex-wrap gap-x-5 gap-y-1.5">
{[
['Deep', latest.sleep_deep_s, '#6366f1'],
['REM', latest.sleep_rem_s, '#8b5cf6'],
['Light', latest.sleep_light_s, '#a78bfa'],
['Awake', latest.sleep_awake_s, '#4b5563'],
].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>
</>
) : !latest.sleep_duration_s ? (
<p className="text-sm text-gray-600">No sleep data</p>
) : null}
</div>
{/* Heart & HRV card — 1/3 width */}
<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>
<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">
{latest.resting_hr ? Math.round(latest.resting_hr) : '--'}
</span>
<span className="text-sm text-gray-500">bpm</span>
</div>
{avg30?.resting_hr && latest.resting_hr && (
<p className="text-xs text-gray-500 mt-0.5">
30d avg {Math.round(avg30.resting_hr)} bpm
{latest.resting_hr < avg30.resting_hr
? <span className="text-green-400 ml-1"></span>
: latest.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">
{latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'}
</span>
<span className="text-sm text-gray-500">ms</span>
<HrvBadge status={latest.hrv_status} />
</div>
</div>
{latest.avg_hr_day && (
<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">
{Math.round(latest.avg_hr_day)}
</span>
<span className="text-xs text-gray-500">bpm</span>
</div>
</div>
)}
</div>
</div>
{/* Activity strip */}
<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">
<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">
{latest.steps ? latest.steps.toLocaleString() : '--'}
</span>
</div>
{latest.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}
{latest.floors_climbed ? (
<p className="text-xs text-gray-500 mt-1">{latest.floors_climbed} floors</p>
) : null}
</div>
{/* Calories */}
<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">
{latest.total_calories
? Math.round(latest.total_calories)
: latest.active_calories
? Math.round(latest.active_calories)
: '--'}
</span>
<span className="text-xs text-gray-500">kcal</span>
</div>
{latest.active_calories && latest.total_calories && (
<p className="text-xs text-gray-500 mt-1">
Active {Math.round(latest.active_calories)} kcal
</p>
)}
</div>
{/* Stress */}
<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}`}>
{latest.avg_stress ? Math.round(latest.avg_stress) : '--'}
</span>
{latest.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>
{/* SpO2 or VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
{latest.spo2_avg ? (
<>
<p className="text-xs text-gray-500 mb-1">SpO2</p>
<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-xs text-gray-500">%</span>
</div>
</>
) : latest.vo2max ? (
<>
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-blue-400">{latest.vo2max.toFixed(1)}</span>
</div>
{latest.fitness_age && (
<p className="text-xs text-gray-500 mt-1">Fitness age {latest.fitness_age}</p>
)}
</>
) : (
<>
<p className="text-xs text-gray-500 mb-1">SpO2</p>
<span className="text-2xl font-bold text-white">--</span>
</>
)}
</div>
</div>
</div>
)
}
// ── Trend Charts ────────────────────────────────────────────────────────────
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
@@ -76,8 +350,10 @@ function SleepChart({ data }) {
)
}
// ── Page ────────────────────────────────────────────────────────────────────
export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(7) // default 1 week
const [rangeDays, setRangeDays] = useState(7)
const fromDate = useMemo(() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), [rangeDays])
@@ -91,7 +367,7 @@ export default function HealthPage() {
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays + 1 },
}).then(r => r.data.slice().reverse()), // oldest first for charts
}).then(r => r.data.slice().reverse()),
keepPreviousData: true,
})
@@ -99,38 +375,32 @@ export default function HealthPage() {
const avg30 = summary?.avg_30d
return (
<div className="p-6 space-y-6">
<div className="p-6 space-y-8">
<h1 className="text-2xl font-bold text-white">Health</h1>
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined} />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
</div>
{/* Daily snapshot */}
<DailySnapshot latest={latest} avg30={avg30} />
{/* Range selector */}
<div className="flex gap-2">
{/* Divider */}
<div className="border-t border-gray-800" />
{/* Trends section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-300">Trends</h2>
<div className="flex gap-1.5">
{RANGES.map(({ label, days }) => (
<button key={label} onClick={() => setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
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>
@@ -150,7 +420,7 @@ export default function HealthPage() {
</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 Stages</h3>
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep</h3>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
@@ -168,11 +438,6 @@ export default function HealthPage() {
formatter={v => `${v.toFixed(1)} kg`} />
</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">VO2 Max</h3>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
</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}>
@@ -188,26 +453,33 @@ export default function HealthPage() {
</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">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</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)} />
</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">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</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)} />
</div>
)}
</div>
) : (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data for this period</p>
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
<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>
)
}