Sleep hypnogram: hover tooltip with exact times + darker REM colour
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:33:28 +01:00
parent e3964bbcdc
commit f69d34508d
+54 -25
View File
@@ -1,4 +1,4 @@
import { useState, useMemo } from 'react' import { useState, useMemo, useRef } from 'react'
import { useQuery, keepPreviousData } from '@tanstack/react-query' import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { import {
AreaChart, Area, ComposedChart, Line, BarChart, Bar, ReferenceLine, ReferenceArea, 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 // 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_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 SLEEP_STAGE_LABEL = { 1: 'Awake', 2: 'Light', 3: 'Deep', 4: 'REM' }
const LANE_H = 15 const LANE_H = 15
const fmtClock = (ms) => new Date(ms).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
function SleepHypnogram({ sleepStart, sleepEnd, stages }) { function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
const wrapRef = useRef(null)
const [tip, setTip] = useState(null)
if (!sleepStart || !sleepEnd || !stages?.length) return null if (!sleepStart || !sleepEnd || !stages?.length) return null
const startMs = new Date(sleepStart).getTime() const startMs = new Date(sleepStart).getTime()
const endMs = new Date(sleepEnd).getTime() const endMs = new Date(sleepEnd).getTime()
const windowMs = endMs - startMs const windowMs = endMs - startMs
if (windowMs <= 0) return null 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 = {} const segsByLane = {}
SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] }) SLEEP_LANE_ORDER.forEach(lv => { segsByLane[lv] = [] })
stages.forEach(([tsMs, level], i) => { stages.forEach(([tsMs, level], i) => {
@@ -313,9 +317,21 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
const left = Math.max(0, (tsMs - startMs) / windowMs * 100) const left = Math.max(0, (tsMs - startMs) / windowMs * 100)
const right = Math.min(100, (nextTs - startMs) / windowMs * 100) const right = Math.min(100, (nextTs - startMs) / windowMs * 100)
const w = right - left 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 // Hour ticks
const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1) const sh = new Date(startMs); sh.setMinutes(0, 0, 0); sh.setHours(sh.getHours() + 1)
const ticks = [] const ticks = []
@@ -327,25 +343,38 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
return ( return (
<div className="pl-10"> <div className="pl-10">
<div className="space-y-px"> <div ref={wrapRef} className="relative" onMouseLeave={() => setTip(null)}>
{SLEEP_LANE_ORDER.map(level => ( <div className="space-y-px">
<div key={level} className="relative flex items-center"> {SLEEP_LANE_ORDER.map(level => (
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none" <div key={level} className="relative flex items-center">
style={{ fontSize: 10 }}> <span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
{SLEEP_STAGE_LABEL[level]} style={{ fontSize: 10 }}>
</span> {SLEEP_STAGE_LABEL[level]}
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}> </span>
{segsByLane[level].map((seg, i) => ( <div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
<div key={i} className="absolute top-0 h-full" {segsByLane[level].map((seg, i) => (
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }} /> <div key={i} className="absolute top-0 h-full cursor-pointer"
))} style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }}
{ticks.map((t, i) => ( onMouseEnter={(e) => showTip(seg, e)}
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none" onMouseMove={(e) => showTip(seg, e)} />
style={{ left: `${t.pct}%` }} /> ))}
))} {ticks.map((t, i) => (
<div key={i} className="absolute top-0 bottom-0 w-px bg-black/20 pointer-events-none"
style={{ left: `${t.pct}%` }} />
))}
</div>
</div> </div>
))}
</div>
{tip && (
<div className="absolute z-20 pointer-events-none px-2 py-1 rounded-md bg-gray-900/95 border border-gray-700 shadow-lg whitespace-nowrap flex items-center gap-1.5"
style={{ left: tip.x, top: tip.y - 10, transform: 'translate(-50%, -100%)', fontSize: 11 }}>
<span className="inline-block w-2 h-2 rounded-sm" style={{ backgroundColor: SLEEP_STAGE_COLOR[tip.level] }} />
<span className="text-white font-medium">{SLEEP_STAGE_LABEL[tip.level]}</span>
<span className="text-gray-400">{tip.range}</span>
<span className="text-gray-500">· {tip.mins}m</span>
</div> </div>
))} )}
</div> </div>
<div className="relative h-4 mt-1 ml-0"> <div className="relative h-4 mt-1 ml-0">
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}> <span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
@@ -370,7 +399,7 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
if (!total) return null if (!total) return null
const segments = [ const segments = [
{ label: 'Deep', s: deepS || 0, color: '#6366f1' }, { 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: 'Light', s: lightS || 0, color: '#a78bfa' },
{ label: 'Awake', s: awakeS || 0, color: '#eab308' }, { label: 'Awake', s: awakeS || 0, color: '#eab308' },
].filter(seg => seg.s > 0) ].filter(seg => seg.s > 0)
@@ -485,7 +514,7 @@ function DailySnapshot({ day, snapshotWeight, avg30, intradayHr, bodyBattery, bb
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2"> <div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
{[ {[
['Deep', day.sleep_deep_s, '#6366f1'], ['Deep', day.sleep_deep_s, '#6366f1'],
['REM', day.sleep_rem_s, '#8b5cf6'], ['REM', day.sleep_rem_s, '#7c3aed'],
['Light', day.sleep_light_s, '#a78bfa'], ['Light', day.sleep_light_s, '#a78bfa'],
['Awake', day.sleep_awake_s, '#eab308'], ['Awake', day.sleep_awake_s, '#eab308'],
].map(([label, secs, color]) => secs ? ( ].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 }} /> label={{ value: `avg ${avgSleep}h`, position: 'right', fill: '#a855f7', fontSize: 9 }} />
)} )}
<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="#7c3aed" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" /> <Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} /> <Bar dataKey="awake" name="Awake" stackId="a" fill="#eab308" radius={[2, 2, 0, 0]} />
</BarChart> </BarChart>
@@ -1055,7 +1084,7 @@ export default function HealthPage() {
<SleepChart data={metrics} <SleepChart data={metrics}
selectedDate={selDateForCharts} onDayClick={handleDayClick} /> 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','#eab308']].map(([l,c]) => ( {[['Deep','#6366f1'],['REM','#7c3aed'],['Light','#a78bfa'],['Awake','#eab308']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5"> <div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} /> <div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span> <span className="text-xs text-gray-400">{l}</span>