diff --git a/messages/de.json b/messages/de.json index f6f3f4a80..8181f7efb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 33ec9750d..4727f2170 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index b4069c3e1..2e2b7f3ae 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index f628a06bc..6c17c69f4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 3959ae74e..119147de9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 9a7ac9ee7..9c8455fa7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx new file mode 100644 index 000000000..dd40dfe1a --- /dev/null +++ b/src/components/insights/sleep-hypnogram.tsx @@ -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; + 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 = 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 = { + 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 ( + + +
+ + {t("insights.sleep.hypnogram.title")} + + + {t("insights.sleep.hypnogram.subtitle", { + asleep: formatMinutes(session.asleepMinutes, locale), + })} + +
+
+ + {!hasTrack ? ( +

+ {t("insights.sleep.stages.unavailable")} +

+ ) : ( + <> +
+ + + + timeFmt.format(new Date(v))} + /> + 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] ?? ""; + }} + /> + + + +
+ +
+ {breakdown.map(({ stage, minutes, pct }) => ( +
+ + + + {formatMinutes(minutes, locale)} · {pct}% + +
+ ))} +
+ +
+ + {t("insights.sleep.hypnogram.asleepTotal", { + value: formatMinutes(session.asleepMinutes, locale), + })} + + {session.inBedMinutes != null && ( + + {t("insights.sleep.hypnogram.inBedTotal", { + value: formatMinutes(session.inBedMinutes, locale), + })} + + )} + + {t("insights.sleep.hypnogram.awakenings", { + count: String(session.awakenings), + })} + +
+ + )} +
+
+ ); +} diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index f9d1fe494..b5fb129c7 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -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 ( + {lastNight && } + {sleepStages ? ( ) : ( diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx index e9022cd7e..fee161957 100644 --- a/src/components/insights/sleep-stage-stacked-bar.tsx +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -82,7 +82,12 @@ const STAGE_ORDER = [ "IN_BED", ] as const; -const STAGE_COLORS: Record = { +/** + * 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 = { DEEP: "#bd93f9", // dracula-purple — deepest, most restorative REM: "#ff79c6", // dracula-pink — dream phase CORE: "#8be9fd", // dracula-cyan — bulk of sleep