From 0cdc653664e1ad00789d725a3701b3331327b472 Mon Sep 17 00:00:00 2001 From: owain Date: Sat, 6 Jun 2026 23:46:08 +0100 Subject: [PATCH] Add daily health snapshot to Health page 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 --- frontend/src/pages/HealthPage.jsx | 470 +++++++++++++++++++++++------- 1 file changed, 371 insertions(+), 99 deletions(-) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 90dd7df..4171c9e 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -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 ( +
+
+
+
+
+
+ ) +} + +function HrvBadge({ status }) { + if (!status) return null + const palette = { + balanced: 'text-green-400 bg-green-400/10 border-green-400/30', + unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30', + low: 'text-orange-400 bg-orange-400/10 border-orange-400/30', + poor: 'text-red-400 bg-red-400/10 border-red-400/30', + } + const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30' + return {status} +} + +function 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 ( +
+

{label}

+
+ {value} + {unit && {unit}} +
+ {sub &&

{sub}

} +
+ ) +} + +function DailySnapshot({ latest, avg30 }) { + if (!latest) return ( +
+

📊

+

No health data yet

+

Import a Garmin export to see your daily snapshot

+
+ ) + + 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 ( +
+
+

Daily snapshot

+

{dateLabel}

+
+ + {/* Top row: Sleep (wide) + Heart/HRV */} +
+ + {/* Sleep card — 2/3 width on desktop */} +
+
+

Sleep

+
+ {latest.sleep_score != null && ( + + Score {Math.round(latest.sleep_score)} + + )} +
+
+ +
+ + {formatSleep(latest.sleep_duration_s)} + + {latest.sleep_start && latest.sleep_end && ( + + {fmtTime(latest.sleep_start)} → {fmtTime(latest.sleep_end)} + + )} +
+ + {hasSleepStages ? ( + <> + +
+ {[ + ['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 ? ( +
+
+ + {label} {formatSleep(secs)} + +
+ ) : null)} +
+ + ) : !latest.sleep_duration_s ? ( +

No sleep data

+ ) : null} +
+ + {/* Heart & HRV card — 1/3 width */} +
+

Heart & HRV

+ +
+

Resting HR

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

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

+ )} +
+ +
+

HRV

+
+ + {latest.hrv_nightly_avg ? Math.round(latest.hrv_nightly_avg) : '--'} + + ms + +
+
+ + {latest.avg_hr_day && ( +
+

Avg HR (day)

+
+ + {Math.round(latest.avg_hr_day)} + + bpm +
+
+ )} +
+
+ + {/* Activity strip */} +
+ + {/* Steps */} +
+

Steps

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

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

+ + ) : null} + {latest.floors_climbed ? ( +

{latest.floors_climbed} floors

+ ) : null} +
+ + {/* Calories */} +
+

Calories

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

+ Active {Math.round(latest.active_calories)} kcal +

+ )} +
+ + {/* Stress */} +
+

Stress

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

{stressLabel}

} +
+ + {/* SpO2 or VO2 Max */} +
+ {latest.spo2_avg ? ( + <> +

SpO2

+
+ {latest.spo2_avg.toFixed(1)} + % +
+ + ) : latest.vo2max ? ( + <> +

VO2 Max

+
+ {latest.vo2max.toFixed(1)} +
+ {latest.fitness_age && ( +

Fitness age {latest.fitness_age}

+ )} + + ) : ( + <> +

SpO2

+ -- + + )} +
+
+
+ ) +} + +// ── 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,115 +375,111 @@ export default function HealthPage() { const avg30 = summary?.avg_30d return ( -
+

Health

- {/* Summary cards */} -
- - - - - - - - -
+ {/* Daily snapshot */} + - {/* Range selector */} -
- {RANGES.map(({ label, days }) => ( - - ))} -
+ {/* Divider */} +
- {isLoading ? ( -
Loading…
- ) : metrics && metrics.length > 0 ? ( -
- -
-

Resting Heart Rate

- `${Math.round(v)} bpm`} /> + {/* Trends section */} +
+
+

Trends

+
+ {RANGES.map(({ label, days }) => ( + + ))}
+
-
-

HRV (nightly avg)

- `${Math.round(v)} ms`} /> -
+ {isLoading ? ( +
Loading…
+ ) : metrics && metrics.length > 0 ? ( +
-
-

Sleep Stages

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

Resting Heart Rate

+ `${Math.round(v)} bpm`} />
-
-
-

Weight

- `${v.toFixed(1)} kg`} /> -
+
+

HRV (nightly avg)

+ `${Math.round(v)} ms`} /> +
-
-

VO2 Max

- v.toFixed(1)} /> -
+
+

Sleep

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

Daily Steps

- - - - format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> - v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> - format(new Date(d), 'MMM d, yyyy')} /> - - - -
+
+

Weight

+ `${v.toFixed(1)} kg`} /> +
-
-

Avg Heart Rate (day)

- `${Math.round(v)} bpm`} /> -
+
+

Daily Steps

+ + + + format(new Date(d), 'MMM d')} interval="preserveStartEnd" /> + v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} /> + format(new Date(d), 'MMM d, yyyy')} /> + + + +
-
-

Stress Level

- Math.round(v)} /> -
+
+

Stress Level

+ Math.round(v)} /> +
-
- ) : ( -
-

📊

-

No health data for this period

-

Import a Garmin export or try a longer date range

-
- )} +
+

Avg Heart Rate (day)

+ `${Math.round(v)} bpm`} /> +
+ + {metrics.some(d => d.vo2max) && ( +
+

VO2 Max

+ v.toFixed(1)} /> +
+ )} + +
+ ) : ( +
+

No trend data for this period

+

Try a longer date range

+
+ )} +
) }