mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
959adec899
commit
cb825ec67a
5 files changed
+520
No files matched your search
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user