From f69d34508dc50d8e689e531c3eaaaea9cb8ddd30 Mon Sep 17 00:00:00 2001 From: owain Date: Fri, 12 Jun 2026 12:33:28 +0100 Subject: [PATCH] Sleep hypnogram: hover tooltip with exact times + darker REM colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-segment mouseover tooltip (stage, start–end clock, duration) to the sleep timeline, matching the other trend charts. Darken REM from #8b5cf6 to #7c3aed across the hypnogram, fallback bar, legends and SleepChart so it reads distinctly from light sleep (#a78bfa). Co-Authored-By: Claude Opus 4.8 --- frontend/src/pages/HealthPage.jsx | 79 +++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index cc037ac..ac94ac5 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useRef } from 'react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea, @@ -293,18 +293,22 @@ 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: '#8b5cf6' } +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 + // 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) => { @@ -313,9 +317,21 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) { 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 }) + 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 = [] @@ -327,25 +343,38 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) { return (
-
- {SLEEP_LANE_ORDER.map(level => ( -
- - {SLEEP_STAGE_LABEL[level]} - -
- {segsByLane[level].map((seg, i) => ( -
- ))} - {ticks.map((t, i) => ( -
- ))} +
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
- ))} + )}
@@ -370,7 +399,7 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) { if (!total) return null const segments = [ { label: 'Deep', s: deepS || 0, color: '#6366f1' }, - { label: 'REM', s: remS || 0, color: '#8b5cf6' }, + { label: 'REM', s: remS || 0, color: '#7c3aed' }, { label: 'Light', s: lightS || 0, color: '#a78bfa' }, { label: 'Awake', s: awakeS || 0, color: '#eab308' }, ].filter(seg => seg.s > 0) @@ -485,7 +514,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
{[ ['Deep', day.sleep_deep_s, '#6366f1'], - ['REM', day.sleep_rem_s, '#8b5cf6'], + ['REM', day.sleep_rem_s, '#7c3aed'], ['Light', day.sleep_light_s, '#a78bfa'], ['Awake', day.sleep_awake_s, '#eab308'], ].map(([label, secs, color]) => secs ? ( @@ -751,7 +780,7 @@ function SleepChart({ data, selectedDate, onDayClick }) { label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} /> )} - + @@ -1055,7 +1084,7 @@ export default function HealthPage() {
- {[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => ( + {[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
{l}