Files
MileVault/frontend/src/components/health/SleepHypnogram.jsx
T
owainandClaude Opus 4.8 3a07655e2e
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 9s
dashboard sleep widget: fill empty space with hypnogram graph from Health tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:54:49 +01:00

105 lines
4.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div className="pl-10">
<div ref={wrapRef} className="relative" onMouseLeave={() => setTip(null)}>
<div className="space-y-px">
{SLEEP_LANE_ORDER.map(level => (
<div key={level} className="relative flex items-center">
<span className="absolute right-full pr-1.5 text-gray-500 whitespace-nowrap select-none"
style={{ fontSize: 10 }}>
{SLEEP_STAGE_LABEL[level]}
</span>
<div className="relative flex-1 rounded-sm overflow-hidden bg-gray-800/50" style={{ height: LANE_H }}>
{segsByLane[level].map((seg, i) => (
<div key={i} className="absolute top-0 h-full cursor-pointer"
style={{ left: `${seg.left}%`, width: `${seg.w}%`, backgroundColor: SLEEP_STAGE_COLOR[level] }}
onMouseEnter={(e) => showTip(seg, e)}
onMouseMove={(e) => showTip(seg, e)} />
))}
{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>
{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 className="relative h-4 mt-1 ml-0">
<span className="absolute left-0 text-gray-500" style={{ fontSize: 10 }}>
{new Date(startMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
{ticks.map((t, i) => (
<span key={i} className="absolute text-gray-600"
style={{ left: `${t.pct}%`, transform: 'translateX(-50%)', fontSize: 10 }}>
{t.label}
</span>
))}
<span className="absolute right-0 text-gray-500" style={{ fontSize: 10 }}>
{new Date(endMs).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
)
}