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

+113
View File
@@ -0,0 +1,113 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
*
* Routed Blood-Pressure sub-page. The mother page hosts the overview
* (hero + briefing + advisor); this page concentrates on BP-only depth:
* the BP chart with its chart-cog overlays, the per-section AI
* assessment, and the chart's target zones tied to the user's age.
*
* Empty-state rule (Marc directive): if the user has no BP data, the
* chart's own empty-state takes over — no extra "no data" CTAs stack
* on top of the chart skeleton.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
interface BloodPressureStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
export default function InsightsBlutdruckPage() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "blood-pressure-status", locale],
queryFn: async () => {
const res = await fetch(
`/api/insights/blood-pressure-status?locale=${locale}`,
);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as BloodPressureStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const bpTargets =
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
const bpTargetZones = bpTargets
? [
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "#ff79c6",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "#ff79c6",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "#8be9fd",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "#8be9fd",
lineOpacity: 0.24,
},
]
: undefined;
return (
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
>
<HealthChart
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Ruler } 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 { EmptyState } from "@/components/ui/empty-state";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/bmi`.
*
* Routed BMI sub-page. BMI is derived from `WEIGHT / (height/100)^2`,
* so the chart sets `valueMode="bmi"` on `<HealthChart>` and the
* underlying WEIGHT series is reused. When the user has no height set
* the chart can't compute; we surface the same plain empty-state the
* mother page used.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
interface BmiStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
const BMI_BANDS = [
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
];
export default function InsightsBmiPage() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "bmi-status", locale],
queryFn: async () => {
const res = await fetch(`/api/insights/bmi-status?locale=${locale}`);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as BmiStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
if (!user?.heightCm) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
<EmptyState
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
description={t("insights.bmiEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/settings/account">
{t("insights.bmiEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
>
<HealthChart
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["#f1fa8c"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
/**
* v1.4.25 W4 — `/insights/gewicht`.
*
* Routed Weight sub-page. Renders the weight chart with the user's
* height-derived green/orange/red bands plus the per-section AI
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
* trend lines + comparison overlay independently from the dashboard
* weight card.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
interface WeightStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
export default function InsightsGewichtPage() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "weight-status", locale],
queryFn: async () => {
const res = await fetch(`/api/insights/weight-status?locale=${locale}`);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as WeightStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const weightBands = user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
return (
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
>
<HealthChart
chartKey="weight"
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
*
* Hosts the sticky `<InsightsTabStrip>` so the metric pills + (mother-
* page-only) regenerate affordance stay reachable while a user drills
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
* Critical design rule (Marc directive 2026-05-11):
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer (matches the Apple Health convention that AI/
* coach surfaces only live on the overview, never on a metric page).
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
}
+260
View File
@@ -0,0 +1,260 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, Pill } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/medikamente`.
*
* Routed Medication-Compliance sub-page. Mirrors the per-medication
* grid from the v1.4.24 mother page (compliance bars + heatmap +
* per-med AI sentence) plus the section-level AI assessment.
*
* No `<HealthChart>` here — medication compliance is event-driven, so
* the data path runs through `/api/insights/comprehensive` +
* `/api/medications/[id]/compliance` instead of the measurement-series
* Prisma path.
*
* Empty-state: zero medications → big "Add your first medication" CTA.
*/
interface MedicationEntry {
id: string;
name: string;
dose: string;
category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER";
compliance7: number;
compliance30: number;
streak: number;
taken7: number;
skipped7: number;
missed7: number;
}
interface ComprehensiveMedicationData {
medications: MedicationEntry[];
}
interface MedicationComplianceStatusData {
hasProvider: boolean;
summary: string | null;
medications: Array<{ medicationId: string; text: string }>;
cached: boolean;
updatedAt: string | null;
}
interface MedicationDailyData {
expected: number;
taken: number;
skipped: number;
onTime?: number;
late?: number;
veryLate?: number;
}
interface MedicationComplianceDailyResponse {
dailyCompliance: Record<string, MedicationDailyData>;
}
export default function InsightsMedikamentePage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMedicationData;
},
enabled: isAuthenticated,
});
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "medication-compliance-status", locale],
queryFn: async () => {
const res = await fetch(
`/api/insights/medication-compliance-status?locale=${locale}`,
);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MedicationComplianceStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const medications = comprehensive?.medications ?? [];
const medicationSummaryById = new Map(
(status?.medications ?? []).map((entry) => [
entry.medicationId,
entry.text,
]),
);
if (isComprehensiveLoading) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
</SubPageShell>
);
}
if (medications.length === 0) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.subPage.medikamenteEmptyTitle")}
description={t("insights.subPage.medikamenteEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/medications">
{t("insights.subPage.medikamenteEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
>
<div
className={
medications.length >= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4"
}
>
{medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
return (
<Card key={med.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Pill className="text-dracula-orange h-4 w-4" />
<CardTitle className="text-sm font-medium">
{med.name}
</CardTitle>
</div>
{med.streak > 0 && (
<Badge variant="outline" className="text-xs">
{t("insights.dayStreak", { count: med.streak })}
</Badge>
)}
</div>
<p className="text-muted-foreground text-xs">{med.dose}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance7d")}</span>
<span className="font-medium">{med.compliance7}%</span>
</div>
<Progress value={med.compliance7} className="h-2" />
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance30d")}</span>
<span className="font-medium">{med.compliance30}%</span>
</div>
<Progress value={med.compliance30} className="h-2" />
</div>
<div className="text-muted-foreground flex justify-between text-xs">
<span>
<span className="text-green-500">{med.taken7}</span>{" "}
{t("insights.taken")}
</span>
<span>
<span className="text-orange-500">{med.skipped7}</span>{" "}
{t("insights.skipped")}
</span>
<span>
<span className="text-red-500">{med.missed7}</span>{" "}
{t("insights.missed")}
</span>
</div>
<MedicationComplianceCalendar medicationId={med.id} />
{medicationSummary ? (
<p className="text-muted-foreground text-sm leading-6">
{medicationSummary}
</p>
) : null}
</CardContent>
</Card>
);
})}
</div>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
text={status?.summary ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
function MedicationComplianceCalendar({
medicationId,
}: {
medicationId: string;
}) {
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: ["compliance-chart-inline", medicationId],
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/compliance`);
if (!res.ok) return null;
const json = await res.json();
return json.data as MedicationComplianceDailyResponse;
},
enabled: !!medicationId,
staleTime: 60 * 1000,
});
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
</div>
);
}
if (!data?.dailyCompliance) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
{t("insights.complianceNoData")}
</div>
);
}
return (
<div className="w-full">
<ComplianceHeatmap dailyCompliance={data.dailyCompliance} stretch />
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
/**
* v1.4.25 W4 — `/insights/puls`.
*
* Routed Pulse sub-page. Renders the pulse chart with the personalized
* Karvonen-derived target band plus the per-section AI assessment.
* Note: `chartKey="pulse"` so the chart-cog can override the
* comparison-overlay independently from the dashboard pulse card; the
* MeasurementType filter is `PULSE` (the same field used elsewhere
* in the codebase).
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
interface PulseStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
export default function InsightsPulsPage() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "pulse-status", locale],
queryFn: async () => {
const res = await fetch(`/api/insights/pulse-status?locale=${locale}`);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as PulseStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
const pulseBands = [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((band) => band.max > band.min);
return (
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
>
<HealthChart
chartKey="pulse"
types={["PULSE"]}
title={t("charts.pulse")}
colors={["#50fa7b"]}
unit="bpm"
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Smile } 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 { EmptyState } from "@/components/ui/empty-state";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/stimmung`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
* cannot just blank itself — we'd land on an empty white page. Instead,
* the sub-page surfaces a clear empty-state CTA into `/mood` so the
* user can log their first entry, matching the Apple Health "No data"
* convention (research §1.1).
*/
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false },
);
interface MoodStatusData {
hasProvider: boolean;
text: string | null;
cached: boolean;
updatedAt: string | null;
}
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
}
export default function InsightsStimmungPage() {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "mood-status", locale],
queryFn: async () => {
const res = await fetch(`/api/insights/mood-status?locale=${locale}`);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MoodStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMoodData;
},
enabled: isAuthenticated,
});
const moodCount = comprehensive?.moodSummary?.count ?? 0;
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell title={t("insights.moodSectionTitle")}>
<EmptyState
icon={<Smile className="size-6" />}
title={t("insights.subPage.stimmungEmptyTitle")}
description={t("insights.subPage.stimmungEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/mood">
{t("insights.subPage.stimmungEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
>
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
@@ -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>
);
}
+42
View File
@@ -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;
}
+61
View File
@@ -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 `<HealthChart>`. `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<SubPageSlug, string[]> = {
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);
}