mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user