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>
);
}