diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx new file mode 100644 index 000000000..19f02840e --- /dev/null +++ b/src/app/insights/blutdruck/page.tsx @@ -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 ( + + + + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> + + ); +} diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx new file mode 100644 index 000000000..c226e4285 --- /dev/null +++ b/src/app/insights/bmi/page.tsx @@ -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 `` 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 ( + + } + title={t("insights.bmiEmptyTitle")} + description={t("insights.bmiEmptyDescription")} + action={ + + } + /> + + ); + } + + return ( + + + + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> + + ); +} diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx new file mode 100644 index 000000000..237f2261f --- /dev/null +++ b/src/app/insights/gewicht/page.tsx @@ -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 ( + + + + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> + + ); +} diff --git a/src/app/insights/layout.tsx b/src/app/insights/layout.tsx new file mode 100644 index 000000000..6db91e360 --- /dev/null +++ b/src/app/insights/layout.tsx @@ -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 `` 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 `` 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 {children}; +} diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx new file mode 100644 index 000000000..7a6a87843 --- /dev/null +++ b/src/app/insights/medikamente/page.tsx @@ -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 `` 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; +} + +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 ( + +
+ +
+
+ ); + } + + if (medications.length === 0) { + return ( + + } + title={t("insights.subPage.medikamenteEmptyTitle")} + description={t("insights.subPage.medikamenteEmptyDescription")} + action={ + + } + /> + + ); + } + + return ( + +
= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4" + } + > + {medications.map((med) => { + const medicationSummary = medicationSummaryById.get(med.id); + return ( + + +
+
+ + + {med.name} + +
+ {med.streak > 0 && ( + + {t("insights.dayStreak", { count: med.streak })} + + )} +
+

{med.dose}

+
+ +
+
+ {t("insights.compliance7d")} + {med.compliance7}% +
+ +
+
+
+ {t("insights.compliance30d")} + {med.compliance30}% +
+ +
+
+ + {med.taken7}{" "} + {t("insights.taken")} + + + {med.skipped7}{" "} + {t("insights.skipped")} + + + {med.missed7}{" "} + {t("insights.missed")} + +
+ + {medicationSummary ? ( +

+ {medicationSummary} +

+ ) : null} +
+
+ ); + })} +
+ + } + text={status?.summary ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> +
+ ); +} + +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 ( +
+ +
+ ); + } + + if (!data?.dailyCompliance) { + return ( +
+ {t("insights.complianceNoData")} +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx new file mode 100644 index 000000000..fa8389035 --- /dev/null +++ b/src/app/insights/puls/page.tsx @@ -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 ( + + + + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> + + ); +} diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx new file mode 100644 index 000000000..6126606ab --- /dev/null +++ b/src/app/insights/stimmung/page.tsx @@ -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 ( + + } + title={t("insights.subPage.stimmungEmptyTitle")} + description={t("insights.subPage.stimmungEmptyDescription")} + action={ + + } + /> + + ); + } + + return ( + + + + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + cached={status?.cached ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + /> + + ); +} diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx new file mode 100644 index 000000000..4873a42e7 --- /dev/null +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -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 — `` unit tests. + * + * The shell renders a focusable `

` (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( + {node}, + ); +} + +describe("", () => { + it("renders the title as a focusable h1", () => { + const html = render( + +

child

+
, + ); + expect(html).toMatch(/]*id="insights-subpage-title"/); + expect(html).toMatch(/tabindex="-1"/); + expect(html).toContain("Blood pressure"); + expect(html).toContain("

child

"); + }); + + it("renders the description paragraph when supplied", () => { + const html = render( + + + , + ); + expect(html).toContain("One paragraph"); + }); + + it("omits the description block when undefined", () => { + const html = render( + + + , + ); + // The shell uses a `

` for the description; no `

` appears when + // the description prop is absent. + expect(html).not.toMatch(/]*class="text-muted-foreground/); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx new file mode 100644 index 000000000..4fff3685e --- /dev/null +++ b/src/components/insights/insights-layout-shell.tsx @@ -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 `` 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 ( +

+ + {children} +
+ ); +} diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index f4cb366f0..dbf4b5743 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -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 `` 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 `` 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 `
` - * in the page. IntersectionObserver tracks which section is in - * the active band so the matching pill highlights. - * - Right slot: a 44×44 icon-only `` 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: - * - `