diff --git a/src/app/insights/schlaf/page.tsx b/src/app/insights/schlaf/page.tsx new file mode 100644 index 000000000..fe355cad8 --- /dev/null +++ b/src/app/insights/schlaf/page.tsx @@ -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 + * `` 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 ( + + + + ); +} diff --git a/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx new file mode 100644 index 000000000..715fc76cd --- /dev/null +++ b/src/components/insights/__tests__/sleep-stage-stacked-bar.test.tsx @@ -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 `` 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( + {node}, + ); +} + +describe("", () => { + 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(); + 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(); + 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(, "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(), + ).not.toThrow(); + }); +}); diff --git a/src/components/insights/sleep-duration-chart.tsx b/src/components/insights/sleep-duration-chart.tsx new file mode 100644 index 000000000..6922e7b85 --- /dev/null +++ b/src/components/insights/sleep-duration-chart.tsx @@ -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 `` 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 ( + + ); +} diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx new file mode 100644 index 000000000..83648ae56 --- /dev/null +++ b/src/components/insights/sleep-overview.tsx @@ -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 `` 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 ( + } + title={t("insights.sleep.empty.title")} + description={t("insights.sleep.empty.description")} + action={ + + } + /> + ); + } + + const averageMinutes = sleepSummary?.avg30 ?? sleepSummary?.avg7 ?? null; + const { primary, suffix } = + averageMinutes != null + ? formatHoursMinutes(averageMinutes, locale) + : { primary: "—", suffix: "" }; + + return ( +
+ + +
+ + + {t("insights.sleep.headlineTitle")} + +
+
+ +
+

{primary}

+

+ {sleepSummary?.count + ? t("insights.sleep.headlineCaption", { + count: sleepSummary.count, + suffix, + }) + : suffix} +

+
+
+
+ + {sleepStages ? ( + + ) : ( + + + {t("insights.sleep.stages.unavailable")} + + + )} + + +
+ ); +} diff --git a/src/components/insights/sleep-stage-stacked-bar.tsx b/src/components/insights/sleep-stage-stacked-bar.tsx new file mode 100644 index 000000000..00b271f85 --- /dev/null +++ b/src/components/insights/sleep-stage-stacked-bar.tsx @@ -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 `` 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; +} + +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 = { + 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 = { + 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 = { + 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 ( + + +
+ + {t("insights.sleep.compositionTitle")} + + + {t("insights.sleep.compositionSubtitle", { + nights: breakdown.nights, + })} + +
+
+ +
+ + + + `${Math.round(v * 100)}%`} + stroke="hsl(var(--muted-foreground))" + fontSize={11} + /> + + { + if (!active || !payload || payload.length === 0) return null; + return ( +
+ {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 ( +
+ + + + {formatMinutes(minutes, locale)} · {pct}% + +
+ ); + })} +
+ ); + }} + /> + stageLabels[value] ?? value} + /> + {STAGE_ORDER.map((stage) => ( + + + + ))} +
+
+
+
+
+ ); +}