feat(insights): new /insights/schlaf sub-page with sleep-stage chart

The v1.4.23 schema added the SleepStage enum + Measurement.sleepStage
column for HealthKit per-stage rows, but the UI never surfaced them.
This commit adds the missing surface.

Components:
  - <SleepDurationChart>: thin wrapper around <HealthChart> with
    chartKey="sleep" so the per-chart cog (trend / target / compare)
    works the same way as the dashboard charts.
  - <SleepStageStackedBar>: horizontal Recharts stacked bar covering
    the trailing-30-day stage distribution (DEEP / REM / CORE /
    ASLEEP / AWAKE / IN_BED). Uses the /api/analytics sleepStages
    payload that v1.4.23 W2 already exposes; no new endpoint.
  - <SleepOverview>: composition that arranges the headline
    "X h Y min per night" card, the stage-composition stacked bar,
    and the duration trend chart. Empty state when the user has zero
    sleep rows points at /settings/devices with a v1.5 Apple Health
    sync teaser.

The sub-page itself (src/app/insights/schlaf/page.tsx) is trivial —
<SubPageShell> + <SleepOverview>. All data-fetching + empty-state
handling stays inside the components so a future v1.4.26 refinement
can iterate without touching the route.

Tests: <SleepStageStackedBar> covers the i18n titles (EN + DE), the
ARIA label that announces the nights count, and resilience against
unknown stage keys.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:07:17 +02:00
1 parent 959adec899
commit cb825ec67a
5 files changed
+520

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4c — `/insights/schlaf`.
*
* The Sleep sub-page surfaces the per-stage breakdown + duration trend
* the v1.4.23 schema gained but never rendered. All charts live inside
* `<SleepOverview>` so the page-level scaffold stays trivial; data
* fetches and empty-state handling are encapsulated in the component.
*/
export default function InsightsSchlafPage() {
const { t } = useTranslations();
return (
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
>
<SleepOverview />
</SubPageShell>
);
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "../sleep-stage-stacked-bar";
/**
* v1.4.25 W4c — sleep-stage composition chart unit tests.
*
* The chart relies on Recharts which uses `ResponsiveContainer` —
* `renderToStaticMarkup` produces SSR-only HTML so we assert the
* surrounding card chrome (heading, aria-label) rather than the
* Recharts-rendered `<rect>` nodes. That keeps the tests resilient to
* Recharts version bumps while still covering the prose contract the
* sub-page depends on.
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SleepStageStackedBar>", () => {
it("renders the composition title with the nights count", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 23,
totalMinutes: 460,
stages: { DEEP: 60, REM: 110, CORE: 230, AWAKE: 30, IN_BED: 30 },
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toContain("Stage composition");
expect(html).toContain("Last 23 nights");
});
it("exposes an accessible label that announces the nights covered", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 14,
totalMinutes: 100,
stages: { CORE: 100 },
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />);
expect(html).toMatch(/aria-label="Sleep stage composition over 14 nights"/);
});
it("renders German labels under the de locale", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 7,
totalMinutes: 200,
stages: { DEEP: 200 },
};
const html = render(<SleepStageStackedBar breakdown={breakdown} />, "de");
expect(html).toContain("Phasen-Verteilung");
expect(html).toContain("Letzte 7 Nächte");
});
it("does not crash when the breakdown carries unknown stage keys", () => {
const breakdown: SleepStageBreakdown = {
windowDays: 30,
nights: 3,
totalMinutes: 200,
stages: { DEEP: 100, UNKNOWN_STAGE: 100 },
};
expect(() =>
render(<SleepStageStackedBar breakdown={breakdown} />),
).not.toThrow();
});
});
@@ -0,0 +1,54 @@
"use client";
import dynamic from "next/dynamic";
import { useTranslations } from "@/lib/i18n/context";
import type { ComparisonBaseline } from "@/lib/dashboard-layout";
/**
* v1.4.25 W4c — total nightly sleep duration over time.
*
* Thin wrapper around `<HealthChart>` so the sleep sub-page gets the
* same chart-cog parity (`chartKey="sleep"`) as the dashboard chart
* surfaces. SLEEP_DURATION rows from Apple Health are per-stage (one
* row per stage per night) — the analytics API aggregates per Berlin
* day before summarising, and `HealthChart` itself relies on the
* pre-aggregated `/api/measurements/series` payload, so we pass the
* raw type through and let the existing chart wire do the day-rollup.
*
* Unit semantics: SLEEP_DURATION is canonically stored in MINUTES
* (v1.4.23 schema note). The chart shows the raw minutes value with a
* "min" unit suffix — the parent sub-page renders a separate
* "X h Y min" headline above the chart for the human-friendly read.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
export interface SleepDurationChartProps {
compareBaseline?: ComparisonBaseline;
userTimezone?: string;
}
export function SleepDurationChart({
compareBaseline,
userTimezone,
}: SleepDurationChartProps) {
const { t } = useTranslations();
return (
<HealthChart
chartKey="sleep"
types={["SLEEP_DURATION"]}
title={t("charts.sleep")}
colors={["#8be9fd"]}
unit="min"
yAxisUnit="min"
compareBaseline={compareBaseline}
userTimezone={userTimezone}
/>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "./sleep-stage-stacked-bar";
import { SleepDurationChart } from "./sleep-duration-chart";
/**
* v1.4.25 W4c — Sleep insights composition.
*
* Pulls the analytics `sleepStages` aggregate (added in v1.4.23 W2)
* plus the SLEEP_DURATION trend series via `<SleepDurationChart>` and
* renders three blocks:
*
* 1. Headline card with the average nightly total (h / min) and the
* number of nights covered.
* 2. Stage composition stacked bar (REM / Deep / Core / Awake / In
* bed / Asleep).
* 3. Duration trend chart with chart-cog parity (`chartKey="sleep"`).
*
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
* sync is coming in v1.5" message with a deep-link to Settings →
* Devices so the user can prepare. We do NOT render any of the three
* blocks in that case — half-rendered empty cards would be worse than
* a single clean CTA.
*/
interface SleepAnalyticsSummary {
latest: number | null;
avg7: number | null;
avg30: number | null;
count: number;
}
interface SleepAnalyticsResponse {
summaries: {
SLEEP_DURATION?: SleepAnalyticsSummary | null;
};
sleepStages: SleepStageBreakdown | null;
}
function formatHoursMinutes(
totalMinutes: number,
locale: string,
): { primary: string; suffix: string } {
const hours = Math.floor(totalMinutes / 60);
const mins = Math.round(totalMinutes - hours * 60);
if (locale === "de") {
return {
primary: `${hours} Std. ${mins} Min.`,
suffix: "pro Nacht",
};
}
return {
primary: `${hours}h ${mins}m`,
suffix: "per night",
};
}
export function SleepOverview() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data, isLoading } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed to load analytics");
const json = await res.json();
return json.data as SleepAnalyticsResponse;
},
enabled: isAuthenticated,
});
const sleepSummary = data?.summaries?.SLEEP_DURATION ?? null;
const sleepStages = data?.sleepStages ?? null;
const totalCount = sleepSummary?.count ?? 0;
if (!isLoading && totalCount === 0) {
return (
<EmptyState
icon={<Moon className="size-6" />}
title={t("insights.sleep.empty.title")}
description={t("insights.sleep.empty.description")}
action={
<Button size="sm" variant="outline" asChild>
<Link href="/settings/devices">
{t("insights.sleep.empty.action")}
</Link>
</Button>
}
/>
);
}
const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null;
const { primary, suffix } =
averageMinutes != null
? formatHoursMinutes(averageMinutes, locale)
: { primary: "—", suffix: "" };
return (
<div className="space-y-4">
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Moon className="text-dracula-cyan h-4 w-4" />
<CardTitle className="text-sm font-medium">
{t("insights.sleep.headlineTitle")}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-1">
<p className="text-2xl font-semibold">{primary}</p>
<p className="text-muted-foreground text-xs">
{sleepSummary?.count
? t("insights.sleep.headlineCaption", {
count: sleepSummary.count,
suffix,
})
: suffix}
</p>
</div>
</CardContent>
</Card>
{sleepStages ? (
<SleepStageStackedBar breakdown={sleepStages} />
) : (
<Card>
<CardContent className="text-muted-foreground py-6 text-sm">
{t("insights.sleep.stages.unavailable")}
</CardContent>
</Card>
)}
<SleepDurationChart
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
</div>
);
}
@@ -0,0 +1,210 @@
"use client";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.25 W4c — sleep-stage composition chart.
*
* Renders the trailing 30-day stage distribution as a horizontal
* stacked bar (REM / Deep / Core / Awake / In-bed / Asleep). The
* underlying data comes from `/api/analytics`'s `sleepStages` payload
* (added in v1.4.23 W2) which exposes the per-stage minute totals plus
* the number of nights covered, so the legend can read "averaged
* across N nights".
*
* Why composition over per-night time series? The current
* `/api/analytics` aggregate only carries totals; a per-night stacked
* column chart would need a separate endpoint. The "average night"
* read is the dominant question users ask of stage data — see
* Apple Health's own sleep tab, which shows the same composition
* column above the trend chart. v1.4.26 can add the per-night surface
* if Marc wants it.
*
* Accessibility: the Recharts wrapper sets `role="img"` and an
* `aria-label` derived from the composition so screen readers hear a
* meaningful summary instead of a forest of `<rect>` elements.
*/
export interface SleepStageBreakdown {
windowDays: number;
nights: number;
totalMinutes: number;
/**
* Keys mirror the Prisma `SleepStage` enum:
* IN_BED, AWAKE, ASLEEP, REM, CORE, DEEP.
* Values are total minutes across the trailing-30 window.
*/
stages: Record<string, number>;
}
export interface SleepStageStackedBarProps {
breakdown: SleepStageBreakdown;
}
/**
* Order on the stack — deepest restorative stages first so a user
* scanning left → right reads quality before context.
*/
const STAGE_ORDER = [
"DEEP",
"REM",
"CORE",
"ASLEEP",
"AWAKE",
"IN_BED",
] as const;
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
ASLEEP: "#50fa7b", // dracula-green — legacy iOS 15- unspecified
AWAKE: "#f1fa8c", // dracula-yellow — wake bouts
IN_BED: "#6272a4", // dracula-comment — pre-asleep
};
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 SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
const { t, locale } = useTranslations();
const total = breakdown.totalMinutes || 1; // avoid divide-by-zero
// Build a single-row dataset Recharts can stack on. Each stage is its
// own series so the user can hover one stage at a time.
const row: Record<string, number | string> = {
name: t("insights.sleep.compositionTitle"),
};
for (const stage of STAGE_ORDER) {
row[stage] = breakdown.stages[stage] ?? 0;
}
const data = [row];
// Pull stage names from i18n once so the legend + tooltip share a
// single source of truth. Missing keys fall back to the enum value
// — covers `ASLEEP`/`IN_BED` which we always want to show even when
// no localization exists yet.
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"),
};
const ariaLabel = t("insights.sleep.compositionAriaLabel", {
nights: breakdown.nights,
});
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
{t("insights.sleep.compositionTitle")}
</CardTitle>
<span className="text-muted-foreground text-xs">
{t("insights.sleep.compositionSubtitle", {
nights: breakdown.nights,
})}
</span>
</div>
</CardHeader>
<CardContent>
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={120}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 8, right: 16, left: 16, bottom: 8 }}
stackOffset="expand"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
/>
<XAxis
type="number"
domain={[0, 1]}
tickFormatter={(v: number) => `${Math.round(v * 100)}%`}
stroke="hsl(var(--muted-foreground))"
fontSize={11}
/>
<YAxis type="category" dataKey="name" hide />
<Tooltip
cursor={{ fill: "transparent" }}
content={({ active, payload }) => {
if (!active || !payload || payload.length === 0) return null;
return (
<div className="bg-popover text-popover-foreground rounded-md border p-2 text-xs shadow-md">
{payload.map((entry) => {
const stage = String(entry.dataKey ?? "");
const minutes =
typeof entry.value === "number" ? entry.value : 0;
if (minutes === 0) return null;
const pct = Math.round((minutes / total) * 100);
return (
<div
key={stage}
className="flex items-center justify-between gap-3"
>
<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">
{formatMinutes(minutes, locale)} · {pct}%
</span>
</div>
);
})}
</div>
);
}}
/>
<Legend
wrapperStyle={{ fontSize: 11 }}
formatter={(value: string) => stageLabels[value] ?? value}
/>
{STAGE_ORDER.map((stage) => (
<Bar
key={stage}
dataKey={stage}
stackId="sleep"
fill={STAGE_COLORS[stage]}
isAnimationActive={false}
>
<Cell fill={STAGE_COLORS[stage]} />
</Bar>
))}
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}