mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(sleep): add the last-night hypnogram to the sleep insights page
Render the night's phase progression as a stepped depth track (Awake top → Deep base) over clock time, reusing the Dracula stage palette from the stacked bar (now exported, no token reshuffle). Below it: the per-stage breakdown (time + %), total asleep, time in bed, and the mid-sleep awakenings count. One canonical source per night — Apple and WHOOP are never overlaid. Wired atop the sleep insights overview, gated on the same data-bearing branch as the rest of the page so a source-less account never fetches. New i18n keys under insights.sleep.hypnogram in all six locales; query-key factory entry + measurement-write invalidation.
This commit is contained in:
1 parent
e4a85c58af
commit
0f46bf4b1f
9 files changed
+348
-1
No files matched your search
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Phasen-Verteilung",
|
||||
"compositionSubtitle": "Letzte {nights} Nächte",
|
||||
"compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte",
|
||||
"hypnogram": {
|
||||
"title": "Letzte Nacht",
|
||||
"subtitle": "{asleep} geschlafen",
|
||||
"ariaLabel": "Schlafphasen-Verlauf der letzten Nacht: {asleep} geschlafen, {awakenings} Wachphasen",
|
||||
"asleepTotal": "Geschlafen {value}",
|
||||
"inBedTotal": "Im Bett {value}",
|
||||
"awakenings": "{count} Wachphasen"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Tief",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Stage composition",
|
||||
"compositionSubtitle": "Last {nights} nights",
|
||||
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
|
||||
"hypnogram": {
|
||||
"title": "Last night",
|
||||
"subtitle": "{asleep} asleep",
|
||||
"ariaLabel": "Sleep stage progression for last night: {asleep} asleep, {awakenings} awakenings",
|
||||
"asleepTotal": "Asleep {value}",
|
||||
"inBedTotal": "In bed {value}",
|
||||
"awakenings": "{count} awakenings"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Deep",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Distribución de fases",
|
||||
"compositionSubtitle": "Últimas {nights} noches",
|
||||
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
|
||||
"hypnogram": {
|
||||
"title": "Última noche",
|
||||
"subtitle": "{asleep} dormido",
|
||||
"ariaLabel": "Progresión de las fases del sueño de la última noche: {asleep} dormido, {awakenings} despertares",
|
||||
"asleepTotal": "Dormido {value}",
|
||||
"inBedTotal": "En la cama {value}",
|
||||
"awakenings": "{count} despertares"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profundo",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Répartition des phases",
|
||||
"compositionSubtitle": "{nights} dernières nuits",
|
||||
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
|
||||
"hypnogram": {
|
||||
"title": "La nuit dernière",
|
||||
"subtitle": "{asleep} de sommeil",
|
||||
"ariaLabel": "Progression des phases de sommeil de la nuit dernière : {asleep} de sommeil, {awakenings} réveils",
|
||||
"asleepTotal": "Sommeil {value}",
|
||||
"inBedTotal": "Au lit {value}",
|
||||
"awakenings": "{count} réveils"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profond",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Distribuzione delle fasi",
|
||||
"compositionSubtitle": "Ultime {nights} notti",
|
||||
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
|
||||
"hypnogram": {
|
||||
"title": "Ultima notte",
|
||||
"subtitle": "{asleep} di sonno",
|
||||
"ariaLabel": "Andamento delle fasi del sonno dell'ultima notte: {asleep} di sonno, {awakenings} risvegli",
|
||||
"asleepTotal": "Sonno {value}",
|
||||
"inBedTotal": "A letto {value}",
|
||||
"awakenings": "{count} risvegli"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profondo",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -2100,6 +2100,14 @@
|
||||
"compositionTitle": "Rozkład faz",
|
||||
"compositionSubtitle": "Ostatnie {nights} nocy",
|
||||
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
|
||||
"hypnogram": {
|
||||
"title": "Ostatnia noc",
|
||||
"subtitle": "{asleep} snu",
|
||||
"ariaLabel": "Przebieg faz snu ostatniej nocy: {asleep} snu, {awakenings} przebudzeń",
|
||||
"asleepTotal": "Sen {value}",
|
||||
"inBedTotal": "W łóżku {value}",
|
||||
"awakenings": "{count} przebudzeń"
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Głęboki",
|
||||
"rem": "REM",
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
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 (Marc 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). The stepped track is a `stepAfter` line
|
||||
* over a {t, lane} series built from each segment's [start, end] span.
|
||||
*/
|
||||
|
||||
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[];
|
||||
}
|
||||
|
||||
export interface SleepHypnogramProps {
|
||||
session: SleepHypnogramSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lane order top → bottom. Awake highest, Deep lowest — depth increases
|
||||
* downward, matching Apple Health / Oura's "cityscape" convention.
|
||||
*/
|
||||
const LANE_ORDER = ["AWAKE", "IN_BED", "ASLEEP", "REM", "CORE", "DEEP"] as const;
|
||||
const LANE_OF: Record<string, number> = Object.fromEntries(
|
||||
// Reverse-index so AWAKE (first) sits at the highest y value.
|
||||
LANE_ORDER.map((s, i) => [s, LANE_ORDER.length - 1 - i]),
|
||||
);
|
||||
|
||||
/** 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 the stepped {t, lane} series: a point at each segment START and
|
||||
// END so a `stepAfter` line holds each stage's depth across its span.
|
||||
const data = useMemo(() => {
|
||||
const points: Array<{ t: number; lane: number }> = [];
|
||||
const sorted = [...session.segments]
|
||||
.filter((seg) => seg.stage != null && LANE_OF[seg.stage] !== undefined)
|
||||
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
||||
for (const seg of sorted) {
|
||||
const lane = LANE_OF[seg.stage as string];
|
||||
points.push({ t: new Date(seg.start).getTime(), lane });
|
||||
points.push({ t: new Date(seg.end).getTime(), lane });
|
||||
}
|
||||
return points;
|
||||
}, [session.segments]);
|
||||
|
||||
const domain: [number, number] = [
|
||||
new Date(session.start).getTime(),
|
||||
new Date(session.end).getTime(),
|
||||
];
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
const hasTrack = data.length > 0;
|
||||
|
||||
return (
|
||||
<Card data-slot="sleep-hypnogram">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{t("insights.sleep.hypnogram.title")}
|
||||
</CardTitle>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.hypnogram.subtitle", {
|
||||
asleep: formatMinutes(session.asleepMinutes, locale),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!hasTrack ? (
|
||||
<p
|
||||
className="text-muted-foreground py-8 text-center text-xs"
|
||||
data-slot="sleep-hypnogram-empty"
|
||||
>
|
||||
{t("insights.sleep.stages.unavailable")}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div role="img" aria-label={ariaLabel}>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart
|
||||
data={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"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickFormatter={(v: number) => timeFmt.format(new Date(v))}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="lane"
|
||||
domain={[-0.5, LANE_ORDER.length - 0.5]}
|
||||
ticks={LANE_ORDER.map(
|
||||
(_, i) => LANE_ORDER.length - 1 - i,
|
||||
)}
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
width={56}
|
||||
tickFormatter={(v: number) => {
|
||||
const stage = LANE_ORDER[LANE_ORDER.length - 1 - v];
|
||||
return stageLabels[stage] ?? "";
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="stepAfter"
|
||||
dataKey="lane"
|
||||
stroke="var(--dracula-cyan, #8be9fd)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { Moon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
@@ -14,6 +16,10 @@ import {
|
||||
SleepStageStackedBar,
|
||||
type SleepStageBreakdown,
|
||||
} from "./sleep-stage-stacked-bar";
|
||||
import {
|
||||
SleepHypnogram,
|
||||
type SleepHypnogramSession,
|
||||
} from "./sleep-hypnogram";
|
||||
import { SleepDurationChart } from "./sleep-duration-chart";
|
||||
|
||||
/**
|
||||
@@ -80,6 +86,24 @@ export function SleepOverview() {
|
||||
const sleepStages = data?.sleepStages ?? null;
|
||||
const totalCount = sleepSummary?.count ?? 0;
|
||||
|
||||
// v1.11.5 — last-night hypnogram. Read-only over existing per-stage rows;
|
||||
// gated on the same data-bearing branch so a source-less account never
|
||||
// fetches. Shares the measurement-write invalidation prefix.
|
||||
const nightQuery = useQuery({
|
||||
queryKey: queryKeys.sleepNight(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/sleep/night");
|
||||
if (!res.ok) throw new Error("Failed to fetch sleep night");
|
||||
return (await res.json()).data as {
|
||||
night: string | null;
|
||||
main: SleepHypnogramSession | null;
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated && totalCount > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const lastNight = nightQuery.data?.main ?? null;
|
||||
|
||||
if (!isLoading && totalCount === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
@@ -130,6 +154,8 @@ export function SleepOverview() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{lastNight && <SleepHypnogram session={lastNight} />}
|
||||
|
||||
{sleepStages ? (
|
||||
<SleepStageStackedBar breakdown={sleepStages} />
|
||||
) : (
|
||||
|
||||
@@ -82,7 +82,12 @@ const STAGE_ORDER = [
|
||||
"IN_BED",
|
||||
] as const;
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
/**
|
||||
* Dracula stage palette. Exported so the last-night hypnogram
|
||||
* (`sleep-hypnogram.tsx`) reuses the exact same tokens — per the
|
||||
* charts-visual-identity rule, no token reshuffle.
|
||||
*/
|
||||
export const STAGE_COLORS: Record<string, string> = {
|
||||
DEEP: "#bd93f9", // dracula-purple — deepest, most restorative
|
||||
REM: "#ff79c6", // dracula-pink — dream phase
|
||||
CORE: "#8be9fd", // dracula-cyan — bulk of sleep
|
||||
|
||||
Reference in new issue
Block a user