mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user