", () => {
+ it("renders the title as a focusable h1", () => {
+ const html = render(
+
+ child
+ ,
+ );
+ expect(html).toMatch(/]*id="insights-subpage-title"/);
+ expect(html).toMatch(/tabindex="-1"/);
+ expect(html).toContain("Blood pressure");
+ expect(html).toContain(" child
");
+ });
+
+ it("renders the description paragraph when supplied", () => {
+ const html = render(
+
+
+ ,
+ );
+ expect(html).toContain("One paragraph");
+ });
+
+ it("omits the description block when undefined", () => {
+ const html = render(
+
+
+ ,
+ );
+ // The shell uses a `` for the description; no `
` appears when
+ // the description prop is absent.
+ expect(html).not.toMatch(/
]*class="text-muted-foreground/);
+ });
+});
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
new file mode 100644
index 000000000..4fff3685e
--- /dev/null
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { useAuth } from "@/hooks/use-auth";
+import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
+import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
+
+/**
+ * v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
+ *
+ * Owns the advisor query so the regenerate button on the sticky tab
+ * strip works from every Insights surface (mother + the seven sub-
+ * pages). TanStack Query dedup keeps the cost negligible: the mother
+ * page's own `useInsightsAdvisorQuery` consumer shares the same
+ * `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
+ * same cache entry without extra network traffic.
+ *
+ * CRITICAL — the `` does NOT mount here. It lives only in
+ * `src/app/insights/page.tsx` body so navigating into a sub-page
+ * unmounts the drawer.
+ */
+export function InsightsLayoutShell({ children }: { children: ReactNode }) {
+ const { isAuthenticated } = useAuth();
+ const advisor = useInsightsAdvisorQuery(isAuthenticated);
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index f4cb366f0..dbf4b5743 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -1,142 +1,105 @@
"use client";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
import { Loader2, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
+import {
+ INSIGHTS_OVERVIEW_PATH,
+ SUB_PAGE_SLUGS,
+ type SubPageSlug,
+} from "@/lib/insights/sub-page-metric";
/**
- * v1.4.25 W3 — dedicated tab strip for `/insights`.
+ * v1.4.25 W4 — routed tab strip for `/insights`.
*
- * Extracted from the inline `` that lived inside
- * `src/app/insights/page.tsx` so the regenerate affordance can sit on
- * the same row as the metric pills. Up to v1.4.24 the regenerate
- * button lived inside the hero band's action row, which scrolled away
- * the moment the user started reading the per-metric cards. Marc's
- * directive (2026-05-14): the affordance has to stay reachable while
- * the user is deep in a section.
+ * Behaviour evolved from the v1.4.25 W3 scroll-anchor version: each pill
+ * is now a ` ` to a routed sub-page (`/insights/blutdruck` …),
+ * `usePathname()` decides the active pill, and the strip is mounted in
+ * `src/app/insights/layout.tsx` so it persists across navigation
+ * without re-rendering. The CoachDrawer is NOT mounted in this layout —
+ * the drawer lives in the mother page's body only (Marc directive
+ * 2026-05-11).
*
- * Behaviour:
- * - Six pills (BP / Weight / Pulse / Mood / Meds / BMI), each
- * scroll-anchored to the matching ``
- * in the page. IntersectionObserver tracks which section is in
- * the active band so the matching pill highlights.
- * - Right slot: a 44×44 icon-only `` button. Clicking it
- * fires the `onRegenerate` handler the page hands down and pops a
- * sonner toast with `t("insights.regenerateSuccess")` once the
- * advisor query confirms. While the regenerate is in-flight the
- * icon swaps to a spinner and the button is disabled.
+ * Pills:
+ * - "Overview" (the mother page) is the first pill and matches when
+ * `pathname === "/insights"` exactly.
+ * - The seven metric slugs each map to their own sub-route.
*
* Accessibility:
- * - `` mirrors the legacy nav's label so screen-reader
- * landmark traversal stays familiar ("Skip to section").
- * - Pill `aria-current="location"` flips with the scroll-anchor.
- * - The regenerate button has `aria-label={t("insights.regenerateAnalysis")}`
- * so the icon-only affordance is announced. Tooltip via `title`
- * mirrors the aria-label for sighted users.
- * - `prefers-reduced-motion` gates the smooth-scroll on pill clicks.
- *
- * Layout: sticky `top-0` strip with `backdrop-blur` and `bg-background/95`
- * — same surface treatment as the original `` so
- * the visual rhythm of the page is unchanged.
+ * - `` mirrors the legacy label.
+ * - Active pill gets `aria-current="page"`.
+ * - The regenerate button has an `aria-label` so the icon-only
+ * affordance is announced. The button only renders when
+ * `onRegenerate` is wired — sub-pages don't carry the regenerate
+ * handler today (the mother page owns the advisor query and
+ * forwards the trigger via the layout slot).
+ * - `prefers-reduced-motion` honoured at the global level (no
+ * scroll-into-view here — Next.js routing handles focus + scroll).
*/
export interface InsightsTabStripProps {
/**
- * Handler the page passes from `useInsightsAdvisorQuery().regenerate`.
- * When supplied, the right-side icon button is enabled; when omitted,
- * the button hides so the strip stays clean on surfaces that don't
- * own the advisor query (e.g. a future preview).
+ * Optional regenerate handler. Only the mother page passes this;
+ * sub-pages render the strip without the right-slot button.
+ * Wiring the strip in `layout.tsx` means we'd need a layout-level
+ * advisor query — for v1.4.25 we keep the strip stateless and the
+ * regenerate affordance on the mother page only.
*/
onRegenerate?: () => void;
/** Spinner state — disables the button and swaps the icon. */
regenerating?: boolean;
}
-const SECTION_IDS = [
- "section-bp",
- "section-weight",
- "section-pulse",
- "section-mood",
- "section-meds",
- "section-bmi",
-] as const;
+interface TabEntry {
+ /** Pathname this pill links to. */
+ href: string;
+ /** Translation key for the pill label. */
+ labelKey: string;
+}
-const SECTION_LABEL_KEYS: Record<(typeof SECTION_IDS)[number], string> = {
- "section-bp": "insights.navBloodPressure",
- "section-weight": "insights.navWeight",
- "section-pulse": "insights.navPulse",
- "section-mood": "insights.navMood",
- "section-meds": "insights.navMedication",
- "section-bmi": "insights.navBmi",
-};
+function buildTabs(): TabEntry[] {
+ const subPages: Record = {
+ blutdruck: "insights.navBloodPressure",
+ gewicht: "insights.navWeight",
+ puls: "insights.navPulse",
+ stimmung: "insights.navMood",
+ medikamente: "insights.navMedication",
+ bmi: "insights.navBmi",
+ schlaf: "insights.navSleep",
+ };
+ return [
+ { href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" },
+ ...SUB_PAGE_SLUGS.map((slug) => ({
+ href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`,
+ labelKey: subPages[slug],
+ })),
+ ];
+}
export function InsightsTabStrip({
onRegenerate,
regenerating = false,
}: InsightsTabStripProps) {
const { t } = useTranslations();
- const [activeId, setActiveId] = useState(SECTION_IDS[0]);
- const observerRef = useRef(null);
- // Track which regenerate call we've already toasted so the success
- // toast only fires on the rising edge (true → false). Without this
- // the toast pops every render where `regenerating` is false.
+ const pathname = usePathname();
+ const tabs = buildTabs();
+
+ // Fire success toast on the falling edge of `regenerating`. Same
+ // rising-edge ref guard as the W3 implementation so the toast fires
+ // exactly once per regenerate cycle.
const lastRegeneratingRef = useRef(regenerating);
-
useEffect(() => {
- observerRef.current = new IntersectionObserver(
- (entries) => {
- // v1.4.22 W5 reconcile (Code-MED-4) — pick the entry with the
- // highest intersectionRatio in the band rather than the
- // observer-supplied last entry. Three sections briefly
- // visible during a fast scroll otherwise made the active
- // pill jump to whichever one came last in the batch.
- const visible = entries
- .filter((e) => e.isIntersecting)
- .sort((a, b) => b.intersectionRatio - a.intersectionRatio);
- const top = visible[0];
- if (top) {
- setActiveId((current) =>
- current === top.target.id ? current : top.target.id,
- );
- }
- },
- { rootMargin: "-30% 0px -60% 0px" },
- );
-
- for (const id of SECTION_IDS) {
- const el = document.getElementById(id);
- if (el) observerRef.current.observe(el);
- }
-
- return () => observerRef.current?.disconnect();
- }, []);
-
- useEffect(() => {
- // Fire the success toast on the falling edge of `regenerating`
- // (rising-edge guard via the ref). Skips the toast on first
- // mount when the parent passes a "false" regenerating value.
if (lastRegeneratingRef.current && !regenerating) {
toast.success(t("insights.regenerateSuccess"));
}
lastRegeneratingRef.current = regenerating;
}, [regenerating, t]);
- function scrollTo(id: string) {
- const el = document.getElementById(id);
- if (!el) return;
- // v1.4.22 W5 reconcile (Design-H1) — gate smooth scrolling behind
- // `prefers-reduced-motion`; honour the user's OS-level pref.
- const prefersReducedMotion =
- typeof window !== "undefined" &&
- window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
- el.scrollIntoView({
- behavior: prefersReducedMotion ? "auto" : "smooth",
- });
- }
-
const regenerateLabel = t("insights.regenerateAnalysis");
return (
@@ -150,13 +113,20 @@ export function InsightsTabStrip({
>
- {SECTION_IDS.map((id) => {
- const isActive = activeId === id;
+ {tabs.map((tab) => {
+ // The overview pill matches the mother page exactly; the
+ // sub-page pills match a prefix so future nested routes
+ // (e.g. `/insights/schlaf/2026-05-11`) still highlight the
+ // parent tab.
+ const isActive =
+ tab.href === INSIGHTS_OVERVIEW_PATH
+ ? pathname === INSIGHTS_OVERVIEW_PATH
+ : pathname === tab.href || pathname.startsWith(`${tab.href}/`);
return (
- scrollTo(id)}
- aria-current={isActive ? "location" : undefined}
+
- {t(SECTION_LABEL_KEYS[id])}
-
+ {t(tab.labelKey)}
+
);
})}
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx
new file mode 100644
index 000000000..cc8abfc7a
--- /dev/null
+++ b/src/components/insights/sub-page-shell.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+/**
+ * v1.4.25 W4 — common visual frame for each metric sub-page.
+ *
+ * Each sub-page renders:
+ * - a calm header (title + optional status badge),
+ * - the canonical chart for the metric (passed in as `chart` so the
+ * page can wire the right `chartKey`, target zones, value bands, …),
+ * - secondary slot for related cards (correlation cards, per-medication
+ * lists, …).
+ *
+ * The shell handles focus restoration on route change — a screen-reader
+ * user landing here from a tab click hears the page heading
+ * automatically because we mount an `id="insights-subpage-title"`
+ * h1 with `tabIndex={-1}` and focus it on mount. Sighted users get a
+ * `scrollTo({ top: 0 })` so deep-scroll position from the previous
+ * sub-page doesn't leak in.
+ */
+
+export interface SubPageShellProps {
+ title: string;
+ /** Optional accent badge (status, dataset coverage, …). */
+ badge?: ReactNode;
+ /**
+ * Optional short paragraph beneath the title, mirroring the Apple-Health
+ * convention of a one-line scaffold on every metric page. The mother
+ * page does not pass it; sub-pages use it to anchor the surface.
+ */
+ description?: string;
+ children: ReactNode;
+}
+
+export function SubPageShell({
+ title,
+ badge,
+ description,
+ children,
+}: SubPageShellProps) {
+ // a11y: focus the heading on mount so a tab-strip navigation actually
+ // moves screen-reader focus into the sub-page body. The `scrollTo`
+ // pairs with that so sighted users don't see the previous page's
+ // deep-scroll position bleed in. Both are gated on
+ // `prefers-reduced-motion: reduce` (we honour the OS pref by using
+ // `scroll-behavior: auto` on the parent, which Tailwind already wires
+ // via the reduced-motion utilities — `scrollTo({ behavior: "auto" })`
+ // mirrors that contract intentionally).
+ const headingRef = useRef
(null);
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ window.scrollTo({ top: 0, behavior: "auto" });
+ }
+ headingRef.current?.focus({ preventScroll: true });
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/src/hooks/use-insights-layout-prefs.ts b/src/hooks/use-insights-layout-prefs.ts
new file mode 100644
index 000000000..af37ce719
--- /dev/null
+++ b/src/hooks/use-insights-layout-prefs.ts
@@ -0,0 +1,42 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+
+import { queryKeys } from "@/lib/query-keys";
+import {
+ resolveDashboardLayout,
+ type DashboardLayout,
+} from "@/lib/dashboard-layout";
+
+/**
+ * v1.4.25 W4 — shared reader for the dashboard-layout payload across
+ * the insights surfaces.
+ *
+ * The mother page and every sub-page need the persisted
+ * `comparisonBaseline` so the dashboard / insights chart-cog stays
+ * in sync. The shape repeats the same `useQuery({ … queryKey:
+ * queryKeys.dashboardWidgets() … })` four-line block that lived inline
+ * on the mother page — extracted here so a new sub-page consumer drops
+ * the boilerplate and so the TanStack-Query cache lookup stays
+ * authoritative for every consumer.
+ *
+ * Returns the resolved `comparisonBaseline` (defaults to `"none"`)
+ * directly so consumers don't have to remember the `resolveDashboardLayout`
+ * fallback dance. `enabled` controls whether the query fires — the
+ * sub-pages gate this on `useAuth().isAuthenticated`.
+ */
+export function useInsightsLayoutPrefs(enabled: boolean) {
+ const { data } = useQuery({
+ queryKey: queryKeys.dashboardWidgets(),
+ queryFn: async () => {
+ const res = await fetch("/api/dashboard/widgets");
+ if (!res.ok) throw new Error("Failed to load dashboard layout");
+ const json = await res.json();
+ return json.data as DashboardLayout;
+ },
+ enabled,
+ });
+ const compareBaseline =
+ resolveDashboardLayout(data).comparisonBaseline ?? "none";
+ return { layout: data ?? null, compareBaseline } as const;
+}
diff --git a/src/lib/insights/sub-page-metric.ts b/src/lib/insights/sub-page-metric.ts
new file mode 100644
index 000000000..58a0501ae
--- /dev/null
+++ b/src/lib/insights/sub-page-metric.ts
@@ -0,0 +1,61 @@
+/**
+ * v1.4.25 W4 — slug → metric mapping for the routed insights sub-pages.
+ *
+ * The mother page (`/insights`) hosts the overview (hero + briefing +
+ * trends + advisor). Each named slug below is a sub-route that focuses
+ * one metric. Charts on the sub-pages still use the dashboard chart-cog
+ * surface — see `chartKey` strings in `src/lib/dashboard-layout.ts`
+ * (`CHART_OVERLAY_KEYS`). The slugs themselves are German because the
+ * product surface is bilingual and German is the primary author voice.
+ *
+ * `medikamente` carries no `MeasurementType[]` because medication
+ * compliance is event-driven (`MedicationIntakeEvent` rows) rather than
+ * time-series `Measurement` rows; the page fetches that data via the
+ * existing `/api/insights/comprehensive` consumer.
+ *
+ * `bmi` lists both `WEIGHT` + `BODY_HEIGHT` because BMI is derived
+ * client-side — `WEIGHT / (heightCm/100)^2` — and the chart sets
+ * `valueMode="bmi"` on ``. `BODY_HEIGHT` is informational
+ * only (we don't fetch a height series; the user's height lives on the
+ * profile).
+ */
+export type SubPageSlug =
+ | "blutdruck"
+ | "gewicht"
+ | "puls"
+ | "stimmung"
+ | "medikamente"
+ | "bmi"
+ | "schlaf";
+
+export const SUB_PAGE_SLUGS = [
+ "blutdruck",
+ "gewicht",
+ "puls",
+ "stimmung",
+ "medikamente",
+ "bmi",
+ "schlaf",
+] as const satisfies readonly SubPageSlug[];
+
+export const SUB_PAGE_METRIC: Record = {
+ blutdruck: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
+ gewicht: ["WEIGHT"],
+ puls: ["PULSE"],
+ stimmung: ["MOOD"],
+ // medication adherence is event-driven; no measurement series.
+ medikamente: [],
+ // BMI is derived from WEIGHT + profile height (no separate series).
+ bmi: ["WEIGHT"],
+ schlaf: ["SLEEP_DURATION"],
+};
+
+/**
+ * Mother-page route. Kept here as a named constant so call sites
+ * never have to hard-code the string.
+ */
+export const INSIGHTS_OVERVIEW_PATH = "/insights" as const;
+
+export function isSubPageSlug(value: string): value is SubPageSlug {
+ return (SUB_PAGE_SLUGS as readonly string[]).includes(value);
+}