From 3a07655e2e0cba44a3a885b510f172d2f1e471dd Mon Sep 17 00:00:00 2001 From: owain Date: Tue, 16 Jun 2026 18:54:49 +0100 Subject: [PATCH] dashboard sleep widget: fill empty space with hypnogram graph from Health tab Co-Authored-By: Claude Opus 4.8 --- .../src/components/health/SleepHypnogram.jsx | 104 +++++++++++++++++ frontend/src/pages/DashboardPage.jsx | 67 ++++++----- frontend/src/pages/HealthPage.jsx | 106 +----------------- 3 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 frontend/src/components/health/SleepHypnogram.jsx diff --git a/frontend/src/components/health/SleepHypnogram.jsx b/frontend/src/components/health/SleepHypnogram.jsx new file mode 100644 index 0000000..0b9a09e --- /dev/null +++ b/frontend/src/components/health/SleepHypnogram.jsx @@ -0,0 +1,104 @@ +import { useState, useRef } from 'react' + +// 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: '#7c3aed' } +const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' } +const LANE_H = 15 + +const fmtClock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) + +export default function SleepHypnogram({ sleepStart, sleepEnd, stages }) { + const wrapRef = useRef(null) + const [tip, setTip] = useState(null) + 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 (keep each segment's real start/end for the tooltip) + 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, level, startMs: tsMs, endMs: nextTs }) + }) + + const showTip = (seg, e) => { + const rect = wrapRef.current?.getBoundingClientRect() + if (!rect) return + setTip({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + level: seg.level, + range: `${fmtClock(seg.startMs)}–${fmtClock(seg.endMs)}`, + mins: Math.max(1, Math.round((seg.endMs - seg.startMs) / 60000)), + }) + } + + // 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 ( +
+
setTip(null)}> +
+ {SLEEP_LANE_ORDER.map(level => ( +
+ + {SLEEP_STAGE_LABEL[level]} + +
+ {segsByLane[level].map((seg, i) => ( +
showTip(seg, e)} + onMouseMove={(e) => showTip(seg, e)} /> + ))} + {ticks.map((t, i) => ( +
+ ))} +
+
+ ))} +
+ {tip && ( +
+ + {SLEEP_STAGE_LABEL[tip.level]} + {tip.range} + · {tip.mins}m +
+ )} +
+
+ + {new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} + + {ticks.map((t, i) => ( + + {t.label} + + ))} + + {new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} + +
+
+ ) +} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index cfec754..4dc3d2c 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -12,6 +12,7 @@ import api from '../utils/api' import { useIsMobile } from '../hooks/useMediaQuery' import StatCard from '../components/ui/StatCard' import HrvBadge from '../components/ui/HrvBadge' +import SleepHypnogram from '../components/health/SleepHypnogram' import ActivityMap from '../components/activity/ActivityMap' import { formatDuration, formatDistance, formatHeartRate, formatElevation, @@ -237,38 +238,48 @@ const SLEEP_STAGES = [ { key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' }, ] -function SleepDetail({ health }) { +function SleepDetail({ health, sleepStages }) { const total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0) + const hasHypnogram = health.sleep_start && health.sleep_end && sleepStages?.length return ( -
- {formatSleep(health.sleep_duration_s)} - {health.sleep_score != null && ( - score {Math.round(health.sleep_score)} +
+
+ {formatSleep(health.sleep_duration_s)} + {health.sleep_score != null && ( + score {Math.round(health.sleep_score)} + )} +
+ {total > 0 ? ( + <> +
+ {SLEEP_STAGES.map(st => { + const pct = ((health[st.key] || 0) / total) * 100 + if (pct < 0.5) return null + return
+ })} +
+
+ {SLEEP_STAGES.map(st => (health[st.key] ? ( +
+
+ {st.label} + {formatSleep(health[st.key])} +
+ ) : null))} +
+ {hasHypnogram && ( +
+
+ +
+
+ )} + + ) : ( +

No sleep stages for last night

)}
- {total > 0 ? ( - <> -
- {SLEEP_STAGES.map(st => { - const pct = ((health[st.key] || 0) / total) * 100 - if (pct < 0.5) return null - return
- })} -
-
- {SLEEP_STAGES.map(st => (health[st.key] ? ( -
-
- {st.label} - {formatSleep(health[st.key])} -
- ) : null))} -
- - ) : ( -

No sleep stages for last night

- )} ) } @@ -572,7 +583,7 @@ export default function DashboardPage() { case 'weekly': return case 'bodyBattery': return case 'vo2maxTrend': return - case 'sleepDetail': return + case 'sleepDetail': return case 'weight': return case 'featured': return case 'recent': return diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 6b18d27..b695ddb 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useRef } from 'react' +import { useState, useMemo } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea, @@ -9,6 +9,7 @@ import api from '../utils/api' import { formatSleep, sportIcon } from '../utils/format' import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery' import HrvBadge from '../components/ui/HrvBadge' +import SleepHypnogram from '../components/health/SleepHypnogram' const RANGES = [ { label: '1W', days: 7 }, @@ -292,109 +293,6 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) ) } -// 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: '#7c3aed' } -const SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' } -const LANE_H = 15 - -const fmtClock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) - -function SleepHypnogram({ sleepStart, sleepEnd, stages }) { - const wrapRef = useRef(null) - const [tip, setTip] = useState(null) - 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 (keep each segment's real start/end for the tooltip) - 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, level, startMs: tsMs, endMs: nextTs }) - }) - - const showTip = (seg, e) => { - const rect = wrapRef.current?.getBoundingClientRect() - if (!rect) return - setTip({ - x: e.clientX - rect.left, - y: e.clientY - rect.top, - level: seg.level, - range: `${fmtClock(seg.startMs)}–${fmtClock(seg.endMs)}`, - mins: Math.max(1, Math.round((seg.endMs - seg.startMs) / 60000)), - }) - } - - // 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 ( -
-
setTip(null)}> -
- {SLEEP_LANE_ORDER.map(level => ( -
- - {SLEEP_STAGE_LABEL[level]} - -
- {segsByLane[level].map((seg, i) => ( -
showTip(seg, e)} - onMouseMove={(e) => showTip(seg, e)} /> - ))} - {ticks.map((t, i) => ( -
- ))} -
-
- ))} -
- {tip && ( -
- - {SLEEP_STAGE_LABEL[tip.level]} - {tip.range} - · {tip.mins}m -
- )} -
-
- - {new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} - - {ticks.map((t, i) => ( - - {t.label} - - ))} - - {new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })} - -
-
- ) -} - function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) { const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0) if (!total) return null