Files
HealthLog/src/components/insights/sleep-hypnogram.tsx
T
Marc-André Bombeck c69e6deb67 fix(sleep): drop the misleading last-night timeline for estimated stages
The last-night card drew a stepped stage timeline for every source. For a
source that reports per-stage totals without measured onsets (WHOOP / Polar),
the server lays the segments out in a fixed physiological order, so the bar
invents a phase progression that was never recorded — it reads as one Core
block then one Deep block, not a real hypnogram.

Suppress the timeline when the night is reconstructed and keep the per-stage
breakdown + totals, which are real. A calm note explains that stage timing is
estimated for that source. Real-series nights (Apple Health, Withings, Fitbit)
keep the measured timeline unchanged.
2026-06-18 21:11:18 +02:00

401 lines
16 KiB
TypeScript
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.
"use client";
import { useMemo } from "react";
import {
CartesianGrid,
ComposedChart,
ReferenceArea,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { STAGE_COLORS } from "./sleep-stage-stacked-bar";
/**
* v1.11.5 — last-night HYPNOGRAM (the maintainer directive: the phase-progression
* view). Renders one night's stage timeline as a stepped depth track —
* Awake at the top, Deep at the base — over clock time, in the Dracula
* stage palette reused from `sleep-stage-stacked-bar.tsx` (no token
* reshuffle, per the charts-visual-identity rule). Below the chart: the
* per-stage breakdown (time + %), total asleep, time in bed, and the
* mid-sleep awakenings count.
*
* One canonical source per night (the endpoint already collapsed it), so
* exactly one timeline renders — Apple ↔ WHOOP are never overlaid.
*
* Recharts stays (project rule). Each stage segment is drawn as its own
* `ReferenceArea` rectangle spanning the segment's [start, end] clock range
* at the stage's depth lane, filled with the segment's `STAGE_COLORS` token
* (the same palette the breakdown legend below uses) — so the chart reads as
* a coloured "cityscape" rather than one flat single-colour line. Depth still
* increases downward (Awake top, Deep base).
*
* v1.11.5 — the chart renders only when per-segment `segments` exist
* (`hasTrack`); the per-stage breakdown + totals footer render whenever
* `session.stages` carries any positive value, so a legacy stage-summary
* night (no segment spans) still shows its breakdown instead of collapsing
* the whole card to "unavailable".
*/
export interface SleepHypnogramSegment {
stage: string | null;
start: string;
end: string;
minutes: number;
}
export interface SleepHypnogramSession {
night: string;
source: string | null;
start: string;
end: string;
asleepMinutes: number;
inBedMinutes: number | null;
awakeMinutes: number | null;
awakenings: number;
stages: Record<string, number>;
segments: SleepHypnogramSegment[];
/**
* `true` when the winning source has no measured per-stage onset
* timestamps and the server synthesised a contiguous timeline in a fixed
* physiological order (WHOOP / Polar). The "timeline" of such a night is
* not a real hypnogram — it would read as one long Core block followed by
* one Deep block, never the phase-to-phase progression a measured night
* shows — so we never draw the misleading stepped bar for it and fall back
* to the per-stage breakdown alone. A real-series source (Apple Health,
* Withings, Fitbit) is `false` and keeps the timeline.
*/
reconstructed?: boolean;
}
export interface SleepHypnogramProps {
session: SleepHypnogramSession;
}
/**
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
* downward, matching Apple Health / Oura's "cityscape" convention. Only
* the stages actually present in the night's spans get a lane (the
* order is preserved) — painting all six lanes left an Apple night
* (AWAKE/REM/CORE/DEEP) with two permanently empty rows.
*/
const LANE_ORDER = [
"AWAKE",
"IN_BED",
"ASLEEP",
"REM",
"CORE",
"DEEP",
] as const;
/** Breakdown order — deepest restorative first, matching the stacked bar. */
const BREAKDOWN_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
function formatMinutes(total: number, locale: string): string {
const hours = Math.floor(total / 60);
const mins = Math.round(total - hours * 60);
if (locale === "de") {
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
}
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}
export function SleepHypnogram({ session }: SleepHypnogramProps) {
const { t, locale } = useTranslations();
const stageLabels: Record<string, string> = {
DEEP: t("insights.sleep.stages.deep"),
REM: t("insights.sleep.stages.rem"),
CORE: t("insights.sleep.stages.core"),
ASLEEP: t("insights.sleep.stages.asleep"),
AWAKE: t("insights.sleep.stages.awake"),
IN_BED: t("insights.sleep.stages.inBed"),
};
// Build one coloured span per stage segment: its [start, end] clock range
// at the stage's depth lane, in the stage's own palette token. Each span
// renders as a `ReferenceArea` so the chart is multi-coloured (a single
// Recharts line can only carry one stroke). Lanes derive from the stages
// PRESENT in the spans (in `LANE_ORDER` order) so a four-stage night
// paints four lanes, not six.
const { spans, lanes } = useMemo(() => {
const known = new Set<string>(LANE_ORDER);
const segments = [...session.segments]
.filter((seg) => seg.stage != null && known.has(seg.stage))
.sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
);
const present = new Set(segments.map((seg) => seg.stage as string));
const lanes = LANE_ORDER.filter((stage) => present.has(stage));
const laneOf: Record<string, number> = Object.fromEntries(
// Reverse-index so the first present stage sits at the highest y.
lanes.map((s, i) => [s, lanes.length - 1 - i]),
);
const spans = segments.map((seg) => {
const stage = seg.stage as string;
return {
stage,
x1: new Date(seg.start).getTime(),
x2: new Date(seg.end).getTime(),
lane: laneOf[stage],
fill: STAGE_COLORS[stage],
};
});
return { spans, lanes };
}, [session.segments]);
const domain = useMemo<[number, number]>(
() => [new Date(session.start).getTime(), new Date(session.end).getTime()],
[session.start, session.end],
);
// v1.12.0 — explicit clock-aligned X-axis ticks. With `data={[]}` Recharts
// has no points to derive ticks from and would otherwise label the domain
// endpoints with raw bedtime/wake stamps (e.g. 23:14 / 06:02). Snap to whole
// clock hours and pick a 1–3 h step from the night's span so the axis reads
// 23:00 / 01:00 / 03:00 / 05:00 and stays mobile-legible (≈4–6 ticks).
const xTicks = useMemo(() => {
const [from, to] = domain;
const spanHours = (to - from) / 3_600_000;
if (!Number.isFinite(spanHours) || spanHours <= 0) return [];
// Aim for ~5 ticks: 1 h up to 5 h span, 2 h up to 10 h, else 3 h.
const stepHours = spanHours <= 5 ? 1 : spanHours <= 10 ? 2 : 3;
const stepMs = stepHours * 3_600_000;
// First whole hour at or after `from`, aligned to the step grid.
const firstHour = new Date(from);
firstHour.setMinutes(0, 0, 0);
if (firstHour.getTime() < from)
firstHour.setTime(firstHour.getTime() + 3_600_000);
// Align the first tick to a multiple of the step (relative to midnight).
while (firstHour.getHours() % stepHours !== 0) {
firstHour.setTime(firstHour.getTime() + 3_600_000);
}
const ticks: number[] = [];
for (let t = firstHour.getTime(); t <= to; t += stepMs) {
ticks.push(t);
}
return ticks;
}, [domain]);
const timeFmt = useMemo(
() =>
new Intl.DateTimeFormat(locale === "de" ? "de-DE" : "en-US", {
hour: "2-digit",
minute: "2-digit",
}),
[locale],
);
// Per-stage breakdown with percentage of the night's total tracked time.
const breakdown = useMemo(() => {
const total = Object.values(session.stages).reduce((s, v) => s + v, 0);
return BREAKDOWN_ORDER.filter(
(stage) => (session.stages[stage] ?? 0) > 0,
).map((stage) => {
const mins = session.stages[stage] ?? 0;
return {
stage,
minutes: mins,
pct: total > 0 ? Math.round((mins / total) * 100) : 0,
};
});
}, [session.stages]);
const ariaLabel = t("insights.sleep.hypnogram.ariaLabel", {
asleep: formatMinutes(session.asleepMinutes, locale),
awakenings: String(session.awakenings),
});
// The CHART needs per-segment spans; the BREAKDOWN + totals footer only
// need any positive per-stage minutes. A legacy stage-summary night (no
// segment spans) still shows its breakdown rather than collapsing the
// whole card. "Unavailable" is reserved for a truly empty session.
// Spans must also carry more than one distinct START instant: a real
// night with timed stages steps through the clock, so its segments begin
// at different instants and earn the timeline bar. Only a degenerate
// session whose every segment starts on a single instant collapses to the
// breakdown footer. (Gating on the END instant instead hid the bar for an
// ordinary multi-stage night whose segments happened to share a right
// edge — the regression this restores.)
// A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
// server laid its segments out in a fixed physiological order, so drawing
// the stepped bar would invent a phase progression that was never recorded
// (the "Core block then Deep block, no real breakdown" the bar reads as).
// For those we keep the per-stage breakdown + totals, which ARE real, and
// drop the misleading timeline entirely. Real-series sources keep the bar.
const hasTrack =
session.reconstructed !== true &&
spans.length > 0 &&
(spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
// v1.12.0 — card tightened: drop the default Card `gap-4 md:gap-6` to
// `gap-3` so the header sits closer to the timeline, reclaiming the empty
// band the maintainer flagged. The chart keeps its 200 px footprint.
<Card data-slot="sleep-hypnogram" className="gap-3 md:gap-3">
<CardHeader className="pb-0">
<div className="flex flex-col gap-0.5">
<CardTitle className="text-base font-semibold">
{t("insights.sleep.hypnogram.title")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.sleep.hypnogram.subtitle", {
asleep: formatMinutes(session.asleepMinutes, locale),
})}
</span>
{/* The night's measuring source rides the header as a muted
caption — it is already on the wire, and "which device
tracked this" is the first question a multi-tracker user
asks about a surprising night. */}
{session.source ? (
<span
data-slot="sleep-hypnogram-source"
className="text-muted-foreground/70 text-xs"
>
{t("insights.sleep.hypnogram.source", {
source: session.source,
})}
</span>
) : null}
{/* When the source reports stage TOTALS but no measured onsets
(WHOOP / Polar) the stepped timeline is suppressed — drawing one
would invent a phase order that was never recorded. Surface a calm
note so the breakdown below doesn't read as a missing chart. */}
{session.reconstructed === true && hasBreakdown ? (
<span
data-slot="sleep-hypnogram-estimate-note"
className="text-muted-foreground/70 text-xs"
>
{t("insights.sleep.hypnogram.estimateNote")}
</span>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-3">
{!hasTrack && !hasBreakdown ? (
<p
className="text-muted-foreground py-8 text-center text-xs"
data-slot="sleep-hypnogram-empty"
>
{t("insights.sleep.stages.unavailable")}
</p>
) : (
<>
{hasTrack && (
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={200}>
<ComposedChart
data={[]}
margin={{ top: 8, right: 16, left: 8, bottom: 8 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
opacity={0.3}
/>
<XAxis
type="number"
dataKey="t"
domain={domain}
scale="time"
ticks={xTicks}
stroke="var(--muted-foreground)"
fontSize={10}
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
/>
<YAxis
type="number"
dataKey="lane"
domain={[-0.5, lanes.length - 0.5]}
ticks={lanes.map((_, i) => lanes.length - 1 - i)}
stroke="var(--muted-foreground)"
fontSize={10}
width={56}
tickFormatter={(v: number) => {
const stage = lanes[lanes.length - 1 - v];
return stage ? (stageLabels[stage] ?? "") : "";
}}
/>
{spans.map((span, i) => (
<ReferenceArea
key={`${span.stage}-${i}`}
x1={span.x1}
x2={span.x2}
y1={span.lane - 0.4}
y2={span.lane + 0.4}
fill={span.fill}
fillOpacity={0.9}
stroke="none"
ifOverflow="extendDomain"
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{hasBreakdown && (
<>
<div
className="space-y-1.5"
data-slot="sleep-hypnogram-breakdown"
>
{breakdown.map(({ stage, minutes, pct }) => (
<div
key={stage}
className="flex items-center justify-between gap-3 text-xs"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block h-2 w-2 rounded-sm"
style={{ background: STAGE_COLORS[stage] }}
/>
{stageLabels[stage] ?? stage}
</span>
<span className="text-muted-foreground tabular-nums">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
))}
</div>
<div className="border-border flex flex-wrap items-center gap-x-4 gap-y-1 border-t pt-2 text-xs">
<span className="font-medium">
{t("insights.sleep.hypnogram.asleepTotal", {
value: formatMinutes(session.asleepMinutes, locale),
})}
</span>
{session.inBedMinutes != null && (
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.inBedTotal", {
value: formatMinutes(session.inBedMinutes, locale),
})}
</span>
)}
<span className="text-muted-foreground">
{t("insights.sleep.hypnogram.awakenings", {
count: String(session.awakenings),
})}
</span>
</div>
</>
)}
</>
)}
</CardContent>
</Card>
);
}