From 8c89ddaca77be6d53de7e965458d85d6b248c377 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 01:58:16 +0200 Subject: [PATCH] refactor(insights): consolidate sub-page data-fetch and empty state --- src/app/insights/blutdruck/page.tsx | 53 ++++------- src/app/insights/bmi/page.tsx | 55 +++++------- src/app/insights/gewicht/page.tsx | 49 +++-------- src/app/insights/puls/page.tsx | 51 ++++------- src/app/insights/schlaf/page.tsx | 51 +++-------- src/app/insights/stimmung/page.tsx | 21 +++-- .../insights/metric-empty-state.tsx | 73 ++++++++++++++++ src/hooks/use-insights-analytics.ts | 87 +++++++++++++++++++ src/types/analytics.ts | 23 +++++ 9 files changed, 277 insertions(+), 186 deletions(-) create mode 100644 src/components/insights/metric-empty-state.tsx create mode 100644 src/hooks/use-insights-analytics.ts create mode 100644 src/types/analytics.ts diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx index 4d93f61d0..7d92204cc 100644 --- a/src/app/insights/blutdruck/page.tsx +++ b/src/app/insights/blutdruck/page.tsx @@ -1,22 +1,20 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { HeartPulse } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightStatus } from "@/hooks/use-insight-status"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; 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 { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { getBpTargets } from "@/lib/analytics/bp-targets"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { hasMetricData } from "@/lib/insights/metric-availability"; /** * v1.4.25 W4 — `/insights/blutdruck`. @@ -33,56 +31,39 @@ import { hasMetricData } from "@/lib/insights/metric-availability"; * v1.4.27 MB6 — the previous `/measurements/new` href hit a 404; the * measurements page now consumes `?add=` and auto-opens the * dialog with the matching default type. + * + * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — the analytics fetch and the + * empty-state render were lifted into `useInsightsAnalytics()` and + * ``; the page module is now Apple-Health-lean. */ -interface AnalyticsData { - summaries: Record; -} export default function InsightsBlutdruckPage() { - const { isAuthenticated, user } = useAuth(); + const { user } = useAuth(); const { t } = useTranslations(); - const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + const { compareBaseline } = useInsightsLayoutPrefs(user != null); const { data: status, isLoading: isStatusLoading } = useInsightStatus("blood-pressure"); - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS"); - if ( - isAuthenticated && - analytics && - !hasMetricData("BLOOD_PRESSURE_SYS", { - summaries: analytics.summaries, - hasMood: false, - hasMedication: false, - }) - ) { + if (isEmpty) { return ( - - + } title={t("insights.emptyState.bloodPressure.title")} description={t("insights.emptyState.bloodPressure.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index b2218f8be..8cc2e2d81 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -1,11 +1,11 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Ruler } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightStatus } from "@/hooks/use-insight-status"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; import { Button } from "@/components/ui/button"; @@ -13,13 +13,8 @@ import { EmptyState } from "@/components/ui/empty-state"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { hasMetricData } from "@/lib/insights/metric-availability"; - -interface AnalyticsData { - summaries: Record; -} /** * v1.4.25 W4 — `/insights/bmi`. @@ -29,6 +24,11 @@ interface AnalyticsData { * 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. + * + * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + the + * weight-not-logged-yet branch consume the shared hook + primitive. + * The "height not set" branch keeps its bespoke `` because + * the CTA targets `/settings/account`, not the measurement onboarding. */ const BMI_BANDS = [ { min: 0, max: 17, color: "#ff5555", opacity: 0.16 }, @@ -45,17 +45,7 @@ export default function InsightsBmiPage() { const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi"); - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { isEmpty } = useInsightsAnalytics("BMI"); // v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute @@ -63,32 +53,24 @@ export default function InsightsBmiPage() { // `/measurements?add=WEIGHT` instead so the user logs the weight // first. v1.4.27 MB6 — query-param replaces the dead // `/measurements/new` route. - if ( - isAuthenticated && - analytics && - !hasMetricData("BMI", { - summaries: analytics.summaries, - hasMood: false, - hasMedication: false, - }) - ) { + if (isEmpty) { return ( - - + } title={t("insights.emptyState.bmi.title")} description={t("insights.emptyState.bmi.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); @@ -96,7 +78,10 @@ export default function InsightsBmiPage() { if (!user?.heightCm) { return ( - + } title={t("insights.bmiEmptyTitle")} diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx index 480ccb876..b6f9367d4 100644 --- a/src/app/insights/gewicht/page.tsx +++ b/src/app/insights/gewicht/page.tsx @@ -1,26 +1,20 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Scale } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightStatus } from "@/hooks/use-insight-status"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; 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 { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { hasMetricData } from "@/lib/insights/metric-availability"; - -interface AnalyticsData { - summaries: Record; -} /** * v1.4.25 W4 — `/insights/gewicht`. @@ -30,6 +24,9 @@ interface AnalyticsData { * assessment. The chart-cog (`chartKey="weight"`) lets the user toggle * trend lines + comparison overlay independently from the dashboard * weight card. + * + * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state + * branch now consume `useInsightsAnalytics()` + ``. */ export default function InsightsGewichtPage() { const { isAuthenticated, user } = useAuth(); @@ -39,44 +36,26 @@ export default function InsightsGewichtPage() { const { data: status, isLoading: isStatusLoading } = useInsightStatus("weight"); - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { isEmpty } = useInsightsAnalytics("WEIGHT"); - if ( - isAuthenticated && - analytics && - !hasMetricData("WEIGHT", { - summaries: analytics.summaries, - hasMood: false, - hasMedication: false, - }) - ) { + if (isEmpty) { return ( - - + } title={t("insights.emptyState.weight.title")} description={t("insights.emptyState.weight.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx index e3c0bd70e..ea5686f47 100644 --- a/src/app/insights/puls/page.tsx +++ b/src/app/insights/puls/page.tsx @@ -1,26 +1,24 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightStatus } from "@/hooks/use-insight-status"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; 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 { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row"; -import type { DataSummary } from "@/lib/analytics/trends"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -import { hasMetricData } from "@/lib/insights/metric-availability"; /** * v1.4.25 W4 — `/insights/puls`. @@ -31,11 +29,12 @@ import { hasMetricData } from "@/lib/insights/metric-availability"; * comparison-overlay independently from the dashboard pulse card; the * MeasurementType filter is `PULSE` (the same field used elsewhere * in the codebase). + * + * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state + * branch consume `useInsightsAnalytics()` + ``. The + * VO2 max chart-row still reads `data.summaries.VO2_MAX` directly so + * the hook exposes the unwrapped payload alongside `isEmpty`. */ -interface AnalyticsData { - summaries: Record; -} - export default function InsightsPulsPage() { const { isAuthenticated, user } = useAuth(); const { t } = useTranslations(); @@ -48,17 +47,7 @@ export default function InsightsPulsPage() { // bundle the mother page reads. Sharing the cache key keeps the // payload single-fetch on tab navigation (React-Query unwraps from // the same key). - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE"); const vo2Summary = analytics?.summaries?.VO2_MAX ?? null; // v1.4.27 F17 — gate the sub-page on at least one pulse observation. @@ -67,32 +56,24 @@ export default function InsightsPulsPage() { // `/measurements?add=PULSE` rather than a chart skeleton over a // blank target band. v1.4.27 MB6 — query-param replaces the dead // `/measurements/new` route. - if ( - isAuthenticated && - analytics && - !hasMetricData("PULSE", { - summaries: analytics.summaries, - hasMood: false, - hasMedication: false, - }) - ) { + if (isEmpty) { return ( - - + } title={t("insights.emptyState.pulse.title")} description={t("insights.emptyState.pulse.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); diff --git a/src/app/insights/schlaf/page.tsx b/src/app/insights/schlaf/page.tsx index 20d5a72f0..ef0efa717 100644 --- a/src/app/insights/schlaf/page.tsx +++ b/src/app/insights/schlaf/page.tsx @@ -1,18 +1,15 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { Moon } from "lucide-react"; -import { useAuth } from "@/hooks/use-auth"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { Button } from "@/components/ui/button"; -import { EmptyState } from "@/components/ui/empty-state"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SleepOverview } from "@/components/insights/sleep-overview"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import type { DataSummary } from "@/lib/analytics/trends"; -import { hasMetricData } from "@/lib/insights/metric-availability"; /** * v1.4.25 W4c — `/insights/schlaf`. @@ -26,53 +23,33 @@ import { hasMetricData } from "@/lib/insights/metric-availability"; * Apple-Health / Withings sleep rows yet), the page short-circuits * to an empty-state CTA pointing at `/settings/data-sources` so the * user can connect a sleep source. + * + * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state + * render consume the shared hook + primitive. */ -interface AnalyticsData { - summaries: Record; -} - export default function InsightsSchlafPage() { - const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data: analytics } = useQuery({ - queryKey: ["analytics"], - queryFn: async () => { - const res = await fetch("/api/analytics"); - if (!res.ok) throw new Error("Failed"); - const json = await res.json(); - return json.data as AnalyticsData; - }, - enabled: isAuthenticated, - staleTime: 60 * 1000, - }); + const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION"); - if ( - isAuthenticated && - analytics && - !hasMetricData("SLEEP_DURATION", { - summaries: analytics.summaries, - hasMood: false, - hasMedication: false, - }) - ) { + if (isEmpty) { return ( - - + } title={t("insights.emptyState.sleep.title")} description={t("insights.emptyState.sleep.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); diff --git a/src/app/insights/stimmung/page.tsx b/src/app/insights/stimmung/page.tsx index cc6623e1f..29608e724 100644 --- a/src/app/insights/stimmung/page.tsx +++ b/src/app/insights/stimmung/page.tsx @@ -10,10 +10,10 @@ import { useInsightStatus } from "@/hooks/use-insight-status"; 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 { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -64,22 +64,27 @@ export default function InsightsStimmungPage() { // `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated // mood-logging surface) — short-circuits the user to the quickest // path to log their first entry. + // + // v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared + // `` primitive. The mood data path stays on + // `/api/insights/comprehensive` because the `moodSummary.count` + // signal is event-driven, not sensor-aggregated. if (isAuthenticated && comprehensive && moodCount === 0) { return ( - - + } title={t("insights.emptyState.mood.title")} description={t("insights.emptyState.mood.description")} - ctaSize="lg" - action={ + cta={ } - /> - ); diff --git a/src/components/insights/metric-empty-state.tsx b/src/components/insights/metric-empty-state.tsx new file mode 100644 index 000000000..b5d51ff35 --- /dev/null +++ b/src/components/insights/metric-empty-state.tsx @@ -0,0 +1,73 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { EmptyState } from "@/components/ui/empty-state"; + +/** + * v1.4.28 R3d (BK-F-M1 + BK-MED-4) — shared empty-state primitive for + * the insights sub-pages. + * + * Pre-fix, every sub-page repeated the same five-line incantation: + * + * + * + * + * The action slot was always a `` + * pointing into `/measurements?add=` (or `/mood` / `/medications` + * for the event-driven metrics). Some sub-pages forgot to mount the + * `` underneath; BK-MED-4 wanted every sub-page to + * expose a Coach launch on the empty state because the conversational + * affordance is exactly the right onboarding nudge when the user hasn't + * logged data yet. + * + * This primitive consolidates both responsibilities. Consumers pass: + * + * - the `icon` + `title` + `description` strings (already localised), + * - the primary CTA node (typically a ``. */ + cta: ReactNode; + /** + * Optional Coach prefill. `null` falls back to the shared default + * prompt the `` ships. Pass a metric-specific + * sentence ("I haven't recorded any blood pressure yet — …") to + * seed the first Coach turn with onboarding context. + */ + coachPrefill?: string | null; +} + +export function MetricEmptyState({ + icon, + title, + description, + cta, + coachPrefill, +}: MetricEmptyStateProps) { + return ( + <> + + + + ); +} diff --git a/src/hooks/use-insights-analytics.ts b/src/hooks/use-insights-analytics.ts new file mode 100644 index 000000000..9c490c838 --- /dev/null +++ b/src/hooks/use-insights-analytics.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { hasMetricData, type InsightMetric } from "@/lib/insights/metric-availability"; +import { queryKeys } from "@/lib/query-keys"; +import type { SubPageAnalyticsData } from "@/types/analytics"; + +/** + * v1.4.28 R3d (BK-F-H1) — shared React-Query wrapper for the analytics + * payload behind every insights sub-page. + * + * The five hard-data sub-pages (`puls`, `blutdruck`, `gewicht`, `bmi`, + * `schlaf`) each used to declare: + * + * - a private `AnalyticsData` interface, + * - a `useQuery({ queryKey: ["analytics"], staleTime: 60_000 })` block, + * - an `if (isAuthenticated && analytics && !hasMetricData(...)) { … }` + * short-circuit that rendered a sub-page-specific empty state. + * + * Those three are now collapsed onto this hook: + * + * - the query reuses the same `["analytics"]` cache key as the + * dashboard + the insights mother page, so navigating between + * surfaces is a free cache hit; + * - `isEmpty` runs `hasMetricData()` with the caller's metric so + * each sub-page checks the right gate without re-importing the + * helper; + * - the typed result keeps the existing `data` slot so any caller + * that still needs a richer field (today: none — the sub-pages + * only read `summaries`) can fall back to direct access. + * + * The mood and medication sub-pages still need richer payloads + * (`/api/insights/comprehensive`) so they don't consume this hook — + * those keep their bespoke fetch but adopt the same `` + * primitive for the empty-state render so the visual contract is one. + */ +export interface UseInsightsAnalyticsResult { + data: SubPageAnalyticsData | undefined; + isLoading: boolean; + /** + * True when the analytics payload arrived AND the metric has no + * observations. Sub-pages render the empty-state primitive when this + * flips true. False while the query is still in-flight (we don't + * want to flash the empty state before the data lands). + */ + isEmpty: boolean; + error: Error | null; +} + +export function useInsightsAnalytics( + metric: InsightMetric, +): UseInsightsAnalyticsResult { + const { isAuthenticated } = useAuth(); + + const query = useQuery({ + queryKey: queryKeys.analytics(), + queryFn: async (): Promise => { + const res = await fetch("/api/analytics"); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: SubPageAnalyticsData }; + return json.data; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); + + const isEmpty = + Boolean(isAuthenticated) && + query.data !== undefined && + !hasMetricData(metric, { + summaries: query.data.summaries, + // Sub-pages that go through this hook are sensor-backed only + // (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_SYS / SLEEP_DURATION). + // Mood + medication run their own gate. + hasMood: false, + hasMedication: false, + }); + + return { + data: query.data, + isLoading: query.isLoading, + isEmpty, + error: query.error as Error | null, + }; +} diff --git a/src/types/analytics.ts b/src/types/analytics.ts new file mode 100644 index 000000000..12b5f971f --- /dev/null +++ b/src/types/analytics.ts @@ -0,0 +1,23 @@ +/** + * v1.4.28 R3d (BK-F-M1) — shared shape for the trimmed `/api/analytics` + * payload that every insights sub-page consumes. + * + * Pre-fix, each of the seven `/insights//page.tsx` modules + * declared its own `interface AnalyticsData { summaries: Record<...> }` + * inline. They were structurally identical so the TanStack-Query cache + * unified anyway, but maintaining seven copies meant every iOS-contract + * addition (e.g. a new `MeasurementType`) showed up only after the + * matching sub-page was touched. Hoisting the interface here is a + * single-place change. + * + * NOTE: the dashboard's `AnalyticsData` (in `src/app/page.tsx`) is a + * wider type — it carries `bpInTargetPct*` aggregates the sub-pages + * don't use. The dashboard keeps its own declaration; we'd lose more + * than we gain by trying to merge them. + */ +import type { DataSummary } from "@/lib/analytics/trends"; + +export interface SubPageAnalyticsData { + /** `Record` — sentinel value for "not loaded yet" is `undefined`. */ + summaries: Record; +}