dashboard sleep widget: fill empty space with hypnogram graph from Health tab
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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 18:54:49 +01:00
co-authored by Claude Opus 4.8
parent c172bbe393
commit 3a07655e2e
3 changed files with 145 additions and 132 deletions
+39 -28
View File
@@ -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 (
<Card title="Sleep" viewHref="/health">
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
{health.sleep_score != null && (
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
<div className="flex flex-col h-full">
<div className="flex items-baseline gap-3 flex-wrap">
<span className="text-3xl font-bold text-indigo-300">{formatSleep(health.sleep_duration_s)}</span>
{health.sleep_score != null && (
<span className="text-sm text-gray-400">score <span className="text-white font-semibold">{Math.round(health.sleep_score)}</span></span>
)}
</div>
{total > 0 ? (
<>
<div className="flex h-3 rounded-full overflow-hidden gap-0.5 mt-3">
{SLEEP_STAGES.map(st => {
const pct = ((health[st.key] || 0) / total) * 100
if (pct < 0.5) return null
return <div key={st.key} style={{ width: `${pct}%`, backgroundColor: st.color }} title={`${st.label}: ${formatSleep(health[st.key])}`} />
})}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{SLEEP_STAGES.map(st => (health[st.key] ? (
<div key={st.key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: st.color }} />
<span className="text-xs text-gray-400">{st.label}</span>
<span className="text-xs text-white">{formatSleep(health[st.key])}</span>
</div>
) : null))}
</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>
)}
</div>
{total > 0 ? (
<>
<div className="flex h-3 rounded-full overflow-hidden gap-0.5 mt-3">
{SLEEP_STAGES.map(st => {
const pct = ((health[st.key] || 0) / total) * 100
if (pct < 0.5) return null
return <div key={st.key} style={{ width: `${pct}%`, backgroundColor: st.color }} title={`${st.label}: ${formatSleep(health[st.key])}`} />
})}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
{SLEEP_STAGES.map(st => (health[st.key] ? (
<div key={st.key} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: st.color }} />
<span className="text-xs text-gray-400">{st.label}</span>
<span className="text-xs text-white">{formatSleep(health[st.key])}</span>
</div>
) : null))}
</div>
</>
) : (
<p className="text-xs text-gray-600 mt-3">No sleep stages for last night</p>
)}
</Card>
)
}
@@ -572,7 +583,7 @@ export default function DashboardPage() {
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 '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 'featured': return <FeaturedActivity activity={featured} segments={featuredSegments} />
case 'recent': return <RecentActivities activities={recentActivities} />