dashboard sleep widget: fill empty space with hypnogram graph from Health tab
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import api from '../utils/api'
|
|||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
import HrvBadge from '../components/ui/HrvBadge'
|
import HrvBadge from '../components/ui/HrvBadge'
|
||||||
|
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
@@ -237,10 +238,12 @@ const SLEEP_STAGES = [
|
|||||||
{ key: 'sleep_awake_s', label: 'Awake', color: '#6b7280' },
|
{ 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 total = SLEEP_STAGES.reduce((s, st) => s + (health[st.key] || 0), 0)
|
||||||
|
const hasHypnogram = health.sleep_start && health.sleep_end && sleepStages?.length
|
||||||
return (
|
return (
|
||||||
<Card title="Sleep" viewHref="/health">
|
<Card title="Sleep" viewHref="/health">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-baseline gap-3 flex-wrap">
|
<div className="flex items-baseline gap-3 flex-wrap">
|
||||||
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
|
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
|
||||||
{health.sleep_score != null && (
|
{health.sleep_score != null && (
|
||||||
@@ -265,10 +268,18 @@ function SleepDetail({ health }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null))}
|
) : null))}
|
||||||
</div>
|
</div>
|
||||||
|
{hasHypnogram && (
|
||||||
|
<div className="flex-1 flex items-center mt-4">
|
||||||
|
<div className="w-full">
|
||||||
|
<SleepHypnogram sleepStart={health.sleep_start} sleepEnd={health.sleep_end} stages={sleepStages} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
|
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -572,7 +583,7 @@ export default function DashboardPage() {
|
|||||||
case 'weekly': return <WeeklyChart activities={allActivities} />
|
case 'weekly': return <WeeklyChart activities={allActivities} />
|
||||||
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
|
case 'bodyBattery': return <BodyBatteryToday bb={intraday?.body_battery} hires={intraday?.body_battery_hires} sleepStart={health.sleep_start} sleepEnd={health.sleep_end} />
|
||||||
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
|
case 'vo2maxTrend': return <Vo2MaxTrend health={health} recentHealth={recentHealth} />
|
||||||
case 'sleepDetail': return <SleepDetail health={health} />
|
case 'sleepDetail': return <SleepDetail health={health} sleepStages={intraday?.sleep_stages} />
|
||||||
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
case 'weight': return <WeightMini recentHealth={recentHealth} />
|
||||||
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
case 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
|
||||||
case 'recent': return <RecentActivities activities={recentActivities} />
|
case 'recent': return <RecentActivities activities={recentActivities} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useRef } from 'react'
|
import { useState, useMemo } 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,
|
||||||
@@ -9,6 +9,7 @@ import api from '../utils/api'
|
|||||||
import { formatSleep, sportIcon } from '../utils/format'
|
import { formatSleep, sportIcon } from '../utils/format'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
import HrvBadge from '../components/ui/HrvBadge'
|
import HrvBadge from '../components/ui/HrvBadge'
|
||||||
|
import SleepHypnogram from '../components/health/SleepHypnogram'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ 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 (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||||
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
||||||
if (!total) return null
|
if (!total) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user