feat(insights): scaffold 6 metric sub-pages with shared layout shell

Carves the v1.4.24 monolithic /insights page into routed sub-pages
under /insights/{blutdruck,gewicht,puls,stimmung,medikamente,bmi}.
Each sub-page renders its metric chart with the per-chart cog
(chartKey from CHART_OVERLAY_KEYS) and the existing AI status card;
empty states surface a CTA where the chart can't compute (BMI without
height, mood with zero entries, medication adherence without a tracked
medication).

The sticky tab strip moves into src/app/insights/layout.tsx via the
new <InsightsLayoutShell>, which also owns the advisor query so the
regenerate affordance stays reachable from every Insights surface.
The tab strip itself is now route-aware (<Link> + usePathname()) and
exposes an "Overview" pill for the mother page. The CoachDrawer stays
in the mother-page body only — per Marc's directive, navigating to a
sub-page must unmount the drawer.

Shared infrastructure:
  - src/lib/insights/sub-page-metric.ts: slug → MeasurementType[] map
    + SUB_PAGE_SLUGS canonical order.
  - src/hooks/use-insights-layout-prefs.ts: reader for the dashboard-
    layout payload so every sub-page picks up the comparisonBaseline
    without re-fetching.
  - src/components/insights/sub-page-shell.tsx: title + optional badge
    + focusable h1 (a11y — screen-reader focus restoration on tab
    navigation).
  - src/components/insights/insights-layout-shell.tsx: the layout's
    client shell that mounts the strip + threads the regenerate handler.

The 7th sub-page (/insights/schlaf) ships in the follow-up W4c commit
together with its sleep-stage chart components.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:06:49 +02:00
1 parent 2668020d92
commit 38bbba110a
13 files changed
+1187 -109

No files matched your search

@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SubPageShell } from "../sub-page-shell";
/**
* v1.4.25 W4 — `<SubPageShell>` unit tests.
*
* The shell renders a focusable `<h1>` (for screen-reader landing on
* tab navigation) plus an optional description and badge. SSR-rendered
* markup is enough to assert the heading hierarchy + tabIndex contract.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<SubPageShell>", () => {
it("renders the title as a focusable h1", () => {
const html = render(
<SubPageShell title="Blood pressure">
<p>child</p>
</SubPageShell>,
);
expect(html).toMatch(/<h1[^>]*id="insights-subpage-title"/);
expect(html).toMatch(/tabindex="-1"/);
expect(html).toContain("Blood pressure");
expect(html).toContain("<p>child</p>");
});
it("renders the description paragraph when supplied", () => {
const html = render(
<SubPageShell title="Weight" description="One paragraph">
<span />
</SubPageShell>,
);
expect(html).toContain("One paragraph");
});
it("omits the description block when undefined", () => {
const html = render(
<SubPageShell title="Pulse">
<span />
</SubPageShell>,
);
// The shell uses a `<p>` for the description; no `<p>` appears when
// the description prop is absent.
expect(html).not.toMatch(/<p[^>]*class="text-muted-foreground/);
});
});
@@ -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 `<CoachDrawer>` 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 (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
/>
{children}
</div>
);
}
+79 -109
View File
@@ -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 `<InsightsSectionNav>` 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 `<Link>` 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 `<section id="section-{slug}">`
* in the page. IntersectionObserver tracks which section is in
* the active band so the matching pill highlights.
* - Right slot: a 44×44 icon-only `<RefreshCw>` 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:
* - `<nav aria-label>` 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 `<InsightsSectionNav>` so
* the visual rhythm of the page is unchanged.
* - `<nav aria-label>` 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<SubPageSlug, string> = {
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<string>(SECTION_IDS[0]);
const observerRef = useRef<IntersectionObserver | null>(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<boolean>(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({
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{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 (
<button
key={id}
onClick={() => scrollTo(id)}
aria-current={isActive ? "location" : undefined}
<Link
key={tab.href}
href={tab.href}
aria-current={isActive ? "page" : undefined}
data-slot="insights-tab-strip-pill"
data-active={isActive ? "true" : undefined}
className={cn(
@@ -167,8 +137,8 @@ export function InsightsTabStrip({
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{t(SECTION_LABEL_KEYS[id])}
</button>
{t(tab.labelKey)}
</Link>
);
})}
</div>
@@ -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<HTMLHeadingElement | null>(null);
useEffect(() => {
if (typeof window !== "undefined") {
window.scrollTo({ top: 0, behavior: "auto" });
}
headingRef.current?.focus({ preventScroll: true });
}, []);
return (
<div data-slot="insights-subpage" className="space-y-6">
<header className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h1
ref={headingRef}
id="insights-subpage-title"
tabIndex={-1}
className={cn(
"text-xl font-semibold sm:text-2xl",
"focus-visible:outline-none",
)}
>
{title}
</h1>
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
</Badge>
) : null}
</div>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{children}
</div>
);
}