From f3ff262451a0830ae8649fb79f7ef98ffc6c42bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 16:54:51 +0200 Subject: [PATCH] feat(insights): gate sub-pages and tab pills on metric data availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brand-new accounts (no manual logs, no Apple Health upload yet) used to land on chart skeletons over blank target bands when they clicked any metric tab. Apple Health hides those surfaces entirely until the first observation lands — match the pattern. Introduce `src/lib/insights/metric-availability.ts` as the single decision point. `hasMetricData(metric, inputs)` reads `summaries[METRIC].count` for sensor metrics (PULSE / WEIGHT / BMI / BLOOD_PRESSURE_* / SLEEP_DURATION / VO2_MAX / STEPS / ACTIVE_ENERGY), the `hasMood` / `hasMedication` booleans for event-driven metrics, and BMI inherits from WEIGHT. Wire three render gates: - `` filters its pills via the new `availability` prop. Backward compatible: undefined `availability` keeps every pill. - `` fetches `/api/analytics` + `/api/insights/comprehensive` once per route and threads the inputs into the strip; both queries share their cache keys with sub-page consumers so the payload lands single-fetch. - Each `src/app/insights/{slug}/page.tsx` early-returns an `` with the right CTA — `/measurements/new` for sensor metrics, `/mood` for Mood, `/medications` for Medication, `/settings/data-sources` for Sleep. Auto-light-up: once the iOS native client uploads its first measurement the same gate flips on the next refetch — no per-metric feature flag. References the new `insights.emptyState..{title,description,cta}` translation keys (B6 lands them in messages/*.json): - insights.emptyState.bloodPressure.{title,description,cta} - insights.emptyState.weight.{title,description,cta} - insights.emptyState.pulse.{title,description,cta} - insights.emptyState.mood.{title,description,cta} - insights.emptyState.medication.{title,description,cta} - insights.emptyState.bmi.{title,description,cta} - insights.emptyState.sleep.{title,description,cta} --- src/app/insights/blutdruck/page.tsx | 55 +++++- src/app/insights/bmi/page.tsx | 50 +++++ src/app/insights/gewicht/page.tsx | 49 +++++ src/app/insights/medikamente/page.tsx | 9 +- src/app/insights/puls/page.tsx | 35 ++++ src/app/insights/schlaf/page.tsx | 59 ++++++ src/app/insights/stimmung/page.tsx | 12 +- .../__tests__/insights-tab-strip.test.tsx | 117 ++++++++++++ .../insights/insights-layout-shell.tsx | 55 ++++++ .../insights/insights-tab-strip.tsx | 60 ++++-- .../__tests__/metric-availability.test.ts | 171 ++++++++++++++++++ src/lib/insights/metric-availability.ts | 93 ++++++++++ 12 files changed, 739 insertions(+), 26 deletions(-) create mode 100644 src/components/insights/__tests__/insights-tab-strip.test.tsx create mode 100644 src/lib/insights/__tests__/metric-availability.test.ts create mode 100644 src/lib/insights/metric-availability.ts diff --git a/src/app/insights/blutdruck/page.tsx b/src/app/insights/blutdruck/page.tsx index 654857b5e..84e74486f 100644 --- a/src/app/insights/blutdruck/page.tsx +++ b/src/app/insights/blutdruck/page.tsx @@ -1,15 +1,21 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { HeartPulse } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; 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 { InsightStatusCard } from "@/components/insights/insight-status-card"; 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`. @@ -19,10 +25,14 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; * 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. + * v1.4.27 F17 — when the user has zero BP observations, the page + * short-circuits to an empty-state CTA pointing at `/measurements/new`. + * Apple Health convention: empty surfaces lead to onboarding hints + * rather than chart skeletons. */ +interface AnalyticsData { + summaries: Record; +} const HealthChart = dynamic( () => import("@/components/charts/health-chart").then((mod) => ({ @@ -39,6 +49,45 @@ export default function InsightsBlutdruckPage() { 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, + }); + + if ( + isAuthenticated && + analytics && + !hasMetricData("BLOOD_PRESSURE_SYS", { + summaries: analytics.summaries, + hasMood: false, + hasMedication: false, + }) + ) { + return ( + + } + title={t("insights.emptyState.bloodPressure.title")} + description={t("insights.emptyState.bloodPressure.description")} + action={ + + } + /> + + ); + } + const bpTargets = user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; const bpTargetZones = bpTargets diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 50f5f2c2c..e1ef69dab 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; import Link from "next/link"; import { Ruler } from "lucide-react"; @@ -12,6 +13,12 @@ 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"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { hasMetricData } from "@/lib/insights/metric-availability"; + +interface AnalyticsData { + summaries: Record; +} /** * v1.4.25 W4 — `/insights/bmi`. @@ -45,6 +52,49 @@ 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, + }); + + // v1.4.27 F17 — BMI is derived from WEIGHT. When no weight readings + // exist yet, the existing "set your height" branch can never compute + // anything useful either; surface the empty-state CTA pointing at + // `/measurements/new` instead so the user logs the weight first. + if ( + isAuthenticated && + analytics && + !hasMetricData("BMI", { + summaries: analytics.summaries, + hasMood: false, + hasMedication: false, + }) + ) { + return ( + + } + title={t("insights.emptyState.bmi.title")} + description={t("insights.emptyState.bmi.description")} + action={ + + } + /> + + ); + } + if (!user?.heightCm) { return ( diff --git a/src/app/insights/gewicht/page.tsx b/src/app/insights/gewicht/page.tsx index e043830bf..6d9003f5a 100644 --- a/src/app/insights/gewicht/page.tsx +++ b/src/app/insights/gewicht/page.tsx @@ -1,15 +1,25 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { Scale } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; 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 { InsightStatusCard } from "@/components/insights/insight-status-card"; 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`. @@ -36,6 +46,45 @@ 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, + }); + + if ( + isAuthenticated && + analytics && + !hasMetricData("WEIGHT", { + summaries: analytics.summaries, + hasMood: false, + hasMedication: false, + }) + ) { + return ( + + } + title={t("insights.emptyState.weight.title")} + description={t("insights.emptyState.weight.description")} + action={ + + } + /> + + ); + } + const weightBands = user?.heightCm ? buildWeightBandsFromHeight(user.heightCm, { lowerBound: 30, diff --git a/src/app/insights/medikamente/page.tsx b/src/app/insights/medikamente/page.tsx index 1f92e064d..e2b2b5356 100644 --- a/src/app/insights/medikamente/page.tsx +++ b/src/app/insights/medikamente/page.tsx @@ -117,16 +117,19 @@ export default function InsightsMedikamentePage() { } if (medications.length === 0) { + // v1.4.27 F17 — medication compliance is event-driven so the + // gate reads `medications.length > 0`. CTA targets `/medications` + // (the dedicated medication-management surface). return ( } - title={t("insights.subPage.medikamenteEmptyTitle")} - description={t("insights.subPage.medikamenteEmptyDescription")} + title={t("insights.emptyState.medication.title")} + description={t("insights.emptyState.medication.description")} action={ } diff --git a/src/app/insights/puls/page.tsx b/src/app/insights/puls/page.tsx index 626991684..2a031d43f 100644 --- a/src/app/insights/puls/page.tsx +++ b/src/app/insights/puls/page.tsx @@ -2,12 +2,15 @@ import { useQuery } from "@tanstack/react-query"; import dynamic from "next/dynamic"; +import Link from "next/link"; import { Heart } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; 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 { InsightStatusCard } from "@/components/insights/insight-status-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row"; @@ -16,6 +19,7 @@ import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; +import { hasMetricData } from "@/lib/insights/metric-availability"; /** * v1.4.25 W4 — `/insights/puls`. @@ -64,6 +68,37 @@ export default function InsightsPulsPage() { }); const vo2Summary = analytics?.summaries?.VO2_MAX ?? null; + // v1.4.27 F17 — gate the sub-page on at least one pulse observation. + // Brand-new accounts (no manual logs, no Apple-Health upload yet) + // see a one-line empty-state with a CTA into `/measurements/new` + // rather than a chart skeleton over a blank target band. + if ( + isAuthenticated && + analytics && + !hasMetricData("PULSE", { + summaries: analytics.summaries, + hasMood: false, + hasMedication: false, + }) + ) { + return ( + + } + title={t("insights.emptyState.pulse.title")} + description={t("insights.emptyState.pulse.description")} + action={ + + } + /> + + ); + } + const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); const pulseTarget = getPersonalizedPulseTarget( pulseAge, diff --git a/src/app/insights/schlaf/page.tsx b/src/app/insights/schlaf/page.tsx index fe355cad8..596c0941c 100644 --- a/src/app/insights/schlaf/page.tsx +++ b/src/app/insights/schlaf/page.tsx @@ -1,8 +1,17 @@ "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 { useTranslations } from "@/lib/i18n/context"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/ui/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`. @@ -11,9 +20,59 @@ import { SubPageShell } from "@/components/insights/sub-page-shell"; * the v1.4.23 schema gained but never rendered. All charts live inside * `` so the page-level scaffold stays trivial; data * fetches and empty-state handling are encapsulated in the component. + * + * v1.4.27 F17 — when `summaries.SLEEP_DURATION.count === 0` (no + * 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. */ +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, + }); + + if ( + isAuthenticated && + analytics && + !hasMetricData("SLEEP_DURATION", { + summaries: analytics.summaries, + hasMood: false, + hasMedication: false, + }) + ) { + return ( + + } + title={t("insights.emptyState.sleep.title")} + description={t("insights.emptyState.sleep.description")} + action={ + + } + /> + + ); + } + return ( 0`. CTA targets `/mood` (the dedicated + // mood-logging surface) — short-circuits the user to the quickest + // path to log their first entry. if (isAuthenticated && comprehensive && moodCount === 0) { return ( } - title={t("insights.subPage.stimmungEmptyTitle")} - description={t("insights.subPage.stimmungEmptyDescription")} + title={t("insights.emptyState.mood.title")} + description={t("insights.emptyState.mood.description")} action={ } /> diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx new file mode 100644 index 000000000..b90819795 --- /dev/null +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +// `usePathname` is consulted to mark the active pill; SSR has no +// Next.js router, so we stub it. +vi.mock("next/navigation", () => ({ + usePathname: () => "/insights", +})); + +// `toast` is consulted from a falling-edge effect; renderToStaticMarkup +// doesn't fire effects, but we stub it for completeness. +vi.mock("sonner", () => ({ toast: { success: vi.fn() } })); + +import { InsightsTabStrip } from "../insights-tab-strip"; +import type { InsightInputs } from "@/lib/insights/metric-availability"; +import type { DataSummary } from "@/lib/analytics/trends"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +function fakeSummary(count: number): DataSummary { + return { + count, + latest: count > 0 ? 1 : null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + }; +} + +describe(" — availability gating (v1.4.27 F19)", () => { + it("renders every pill when availability is omitted (backward compat)", () => { + const html = render(); + expect(html).toContain(">Blood Pressure<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Mood<"); + expect(html).toContain(">Medication<"); + expect(html).toContain(">BMI<"); + expect(html).toContain(">Sleep<"); + }); + + it("drops pills for metrics with zero observations", () => { + const availability: InsightInputs = { + summaries: { + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(3), + }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + // Pills that have data stay. + expect(html).toContain(">Pulse<"); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + // Pills without data drop. + expect(html).not.toContain(">Blood Pressure<"); + expect(html).not.toContain(">Mood<"); + expect(html).not.toContain(">Medication<"); + expect(html).not.toContain(">Sleep<"); + }); + + it("keeps the overview pill regardless of availability", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Overview<"); + }); + + it("lights up Mood when hasMood flips to true", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: true, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Mood<"); + expect(html).not.toContain(">Medication<"); + }); + + it("lights up Medication when hasMedication flips to true", () => { + const availability: InsightInputs = { + summaries: {}, + hasMood: false, + hasMedication: true, + }; + const html = render(); + expect(html).toContain(">Medication<"); + expect(html).not.toContain(">Mood<"); + }); + + it("derives BMI from WEIGHT count", () => { + const availability: InsightInputs = { + summaries: { WEIGHT: fakeSummary(2) }, + hasMood: false, + hasMedication: false, + }; + const html = render(); + expect(html).toContain(">Weight<"); + expect(html).toContain(">BMI<"); + }); +}); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 4fff3685e..0e39b7336 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -1,10 +1,13 @@ "use client"; import type { ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { InsightsTabStrip } from "@/components/insights/insights-tab-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; +import type { InsightInputs } from "@/lib/insights/metric-availability"; +import type { DataSummary } from "@/lib/analytics/trends"; /** * v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`. @@ -16,19 +19,71 @@ import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advi * `queryKeys.insightsAdvisor()` key, so the two consumers reuse the * same cache entry without extra network traffic. * + * v1.4.27 F19 — also owns the analytics + comprehensive reads that + * power the tab-strip availability gate. Both queries share their + * cache keys with the sub-page consumers (`["analytics"]` and + * `["insights", "comprehensive"]`) so the cost stays one fetch per + * route, dedup-shared across consumers. + * * 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. */ +interface AnalyticsPayload { + summaries?: Record; +} + +interface ComprehensivePayload { + moodSummary: { count: number } | null; + medications: Array<{ id: string }>; +} + export function InsightsLayoutShell({ children }: { children: ReactNode }) { const { isAuthenticated } = useAuth(); const advisor = useInsightsAdvisorQuery(isAuthenticated); + // Shared analytics fetch — sub-pages consume the same cache key. + const analyticsQuery = 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 AnalyticsPayload; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); + + // Shared comprehensive fetch — mood + medication signals for the + // event-driven gating branches. Sub-pages read the same key so the + // payload lands once per route. + const comprehensiveQuery = 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 ComprehensivePayload; + }, + enabled: isAuthenticated, + }); + + const availability: InsightInputs | undefined = isAuthenticated + ? { + summaries: analyticsQuery.data?.summaries, + hasMood: (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0, + hasMedication: + (comprehensiveQuery.data?.medications?.length ?? 0) > 0, + } + : undefined; + return (
{children}
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index e989a6a82..d6882ec36 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -13,6 +13,11 @@ import { SUB_PAGE_SLUGS, type SubPageSlug, } from "@/lib/insights/sub-page-metric"; +import { + hasMetricData, + type InsightInputs, + type InsightMetric, +} from "@/lib/insights/metric-availability"; /** * v1.4.25 W4 — routed tab strip for `/insights`. @@ -53,6 +58,13 @@ export interface InsightsTabStripProps { onRegenerate?: () => void; /** Spinner state — disables the button and swaps the icon. */ regenerating?: boolean; + /** + * v1.4.27 F19 — analytics + event-driven availability inputs the + * gating helper reads. When omitted the strip falls back to its + * pre-v1.4.27 behaviour (every pill renders) so legacy mounts + * stay backward-compatible. + */ + availability?: InsightInputs; } interface TabEntry { @@ -62,32 +74,50 @@ interface TabEntry { labelKey: string; } -function buildTabs(): TabEntry[] { - const subPages: Record = { - blutdruck: "insights.navBloodPressure", - gewicht: "insights.navWeight", - puls: "insights.navPulse", - stimmung: "insights.navMood", - medikamente: "insights.navMedication", - bmi: "insights.navBmi", - schlaf: "insights.navSleep", - }; +/** + * Slug → (label key, gating metric) mapping. Keeping the metric here + * means the tab strip and the empty-state gates on each sub-page can + * stay in sync from a single source of truth — adding a sub-page is + * one row. + */ +const SUB_PAGE_TABS: Record< + SubPageSlug, + { labelKey: string; metric: InsightMetric } +> = { + blutdruck: { + labelKey: "insights.navBloodPressure", + metric: "BLOOD_PRESSURE_SYS", + }, + gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" }, + puls: { labelKey: "insights.navPulse", metric: "PULSE" }, + stimmung: { labelKey: "insights.navMood", metric: "MOOD" }, + medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" }, + bmi: { labelKey: "insights.navBmi", metric: "BMI" }, + schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" }, +}; + +function buildTabs(availability: InsightInputs | undefined): TabEntry[] { + const subPageEntries = SUB_PAGE_SLUGS.filter((slug) => { + if (!availability) return true; + return hasMetricData(SUB_PAGE_TABS[slug].metric, availability); + }).map((slug) => ({ + href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, + labelKey: SUB_PAGE_TABS[slug].labelKey, + })); return [ { href: INSIGHTS_OVERVIEW_PATH, labelKey: "insights.navOverview" }, - ...SUB_PAGE_SLUGS.map((slug) => ({ - href: `${INSIGHTS_OVERVIEW_PATH}/${slug}`, - labelKey: subPages[slug], - })), + ...subPageEntries, ]; } export function InsightsTabStrip({ onRegenerate, regenerating = false, + availability, }: InsightsTabStripProps) { const { t } = useTranslations(); const pathname = usePathname(); - const tabs = buildTabs(); + const tabs = buildTabs(availability); // Fire success toast on the falling edge of `regenerating`. Same // rising-edge ref guard as the W3 implementation so the toast fires diff --git a/src/lib/insights/__tests__/metric-availability.test.ts b/src/lib/insights/__tests__/metric-availability.test.ts new file mode 100644 index 000000000..d55cf454a --- /dev/null +++ b/src/lib/insights/__tests__/metric-availability.test.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from "vitest"; + +import { hasMetricData } from "../metric-availability"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.4.27 F17/F18/F19 — gating helper unit cover. + * + * The matrix covers each metric × {has data, no data, missing + * summary entry, undefined summaries}. The `MOOD` and `MEDICATION` + * paths read the boolean flags directly; the rest go through the + * `summaries[METRIC].count` lookup. + */ + +function fakeSummary(count: number): DataSummary { + return { + count, + latest: count > 0 ? 1 : null, + min: null, + max: null, + mean: null, + avg7: null, + avg30: null, + slope7: null, + slope30: null, + slope90: null, + anomalyCount: 0, + }; +} + +const emptyInputs = { + summaries: {}, + hasMood: false, + hasMedication: false, +}; + +describe("hasMetricData", () => { + it("returns false for PULSE when summaries are undefined", () => { + expect( + hasMetricData("PULSE", { + summaries: undefined, + hasMood: false, + hasMedication: false, + }), + ).toBe(false); + }); + + it("returns false for PULSE when the summary entry is missing", () => { + expect(hasMetricData("PULSE", emptyInputs)).toBe(false); + }); + + it("returns false for PULSE when the count is zero", () => { + expect( + hasMetricData("PULSE", { + ...emptyInputs, + summaries: { PULSE: fakeSummary(0) }, + }), + ).toBe(false); + }); + + it("returns true for PULSE when the count is one", () => { + expect( + hasMetricData("PULSE", { + ...emptyInputs, + summaries: { PULSE: fakeSummary(1) }, + }), + ).toBe(true); + }); + + it("returns true for WEIGHT when the count is positive", () => { + expect( + hasMetricData("WEIGHT", { + ...emptyInputs, + summaries: { WEIGHT: fakeSummary(42) }, + }), + ).toBe(true); + }); + + it("derives BMI from WEIGHT count — true when WEIGHT > 0", () => { + expect( + hasMetricData("BMI", { + ...emptyInputs, + summaries: { WEIGHT: fakeSummary(3) }, + }), + ).toBe(true); + }); + + it("derives BMI from WEIGHT count — false when WEIGHT is missing", () => { + expect(hasMetricData("BMI", emptyInputs)).toBe(false); + }); + + it("returns true for BLOOD_PRESSURE_SYS independently of DIA", () => { + expect( + hasMetricData("BLOOD_PRESSURE_SYS", { + ...emptyInputs, + summaries: { BLOOD_PRESSURE_SYS: fakeSummary(5) }, + }), + ).toBe(true); + }); + + it("returns false for BLOOD_PRESSURE_DIA when only SYS has data", () => { + expect( + hasMetricData("BLOOD_PRESSURE_DIA", { + ...emptyInputs, + summaries: { BLOOD_PRESSURE_SYS: fakeSummary(5) }, + }), + ).toBe(false); + }); + + it("returns true for MOOD when hasMood is true", () => { + expect( + hasMetricData("MOOD", { + summaries: {}, + hasMood: true, + hasMedication: false, + }), + ).toBe(true); + }); + + it("returns false for MOOD when hasMood is false even with a summary", () => { + expect( + hasMetricData("MOOD", { + summaries: { MOOD: fakeSummary(99) }, + hasMood: false, + hasMedication: false, + }), + ).toBe(false); + }); + + it("returns true for MEDICATION when hasMedication is true", () => { + expect( + hasMetricData("MEDICATION", { + summaries: {}, + hasMood: false, + hasMedication: true, + }), + ).toBe(true); + }); + + it("returns false for MEDICATION when hasMedication is false", () => { + expect(hasMetricData("MEDICATION", emptyInputs)).toBe(false); + }); + + it("returns true for SLEEP_DURATION when the summary count is positive", () => { + expect( + hasMetricData("SLEEP_DURATION", { + ...emptyInputs, + summaries: { SLEEP_DURATION: fakeSummary(8) }, + }), + ).toBe(true); + }); + + it("returns true for VO2_MAX when the summary count is positive", () => { + expect( + hasMetricData("VO2_MAX", { + ...emptyInputs, + summaries: { VO2_MAX: fakeSummary(2) }, + }), + ).toBe(true); + }); + + it("returns false for VO2_MAX when summaries are undefined", () => { + expect( + hasMetricData("VO2_MAX", { + summaries: undefined, + hasMood: false, + hasMedication: false, + }), + ).toBe(false); + }); +}); diff --git a/src/lib/insights/metric-availability.ts b/src/lib/insights/metric-availability.ts new file mode 100644 index 000000000..7af192164 --- /dev/null +++ b/src/lib/insights/metric-availability.ts @@ -0,0 +1,93 @@ +/** + * v1.4.27 F17/F18/F19 — single decision point for "does the user have + * any observations on this metric?". + * + * Three render gates consume this helper: + * + * 1. The routed Insights sub-pages (`/insights/{slug}/page.tsx`) + * early-return an empty-state with a CTA when the gate is false. + * 2. The Insights tab strip (``) filters pills so + * a metric with zero observations doesn't surface a navigation + * target the user can't act on. + * 3. The dashboard tile registry (`src/app/page.tsx`) — wired in + * bucket B1 — applies the same gate per tile. + * + * Source signals: + * + * - Sensor metrics (`PULSE`, `WEIGHT`, `BMI`, `BLOOD_PRESSURE_*`, + * `SLEEP_DURATION`, `VO2_MAX`, `STEPS`, `ACTIVE_ENERGY`) read + * `summaries[METRIC].count` from the analytics endpoint. + * - `BMI` is derived from `WEIGHT` so it inherits the weight count. + * - Event-driven metrics (`MOOD`, `MEDICATION`) read the boolean + * `hasMood` / `hasMedication` flags the caller threads in from + * `/api/insights/comprehensive` (mood summary count > 0, + * medications array length > 0). + * + * Auto-light-up behaviour: once the iOS native client uploads its + * first Apple-Health measurement, `summaries[METRIC].count` flips + * from 0 to ≥1 and the next React-Query refetch reveals the matching + * pill, sub-page, and dashboard tile — no separate feature flag + * needed. (F18.) + */ +// TODO(B1+B4 reconcile): wire per-tile availability gate using +// hasMetricData from src/lib/insights/metric-availability.ts. Bucket +// B1 owns `src/app/page.tsx`; this helper is the single decision +// point both buckets should call. +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * Metric keys the gating helper distinguishes. Mirrors the + * `MeasurementType` enum for sensor metrics and adds synthetic keys + * for `MOOD`, `MEDICATION`, and `BMI` (derived from `WEIGHT`). + * + * Adding a new metric is a one-place change: list it here, decide + * which branch of `hasMetricData` handles it, and the sub-page / + * tab strip / dashboard tile gates pick it up automatically. + */ +export type InsightMetric = + | "BLOOD_PRESSURE_SYS" + | "BLOOD_PRESSURE_DIA" + | "PULSE" + | "WEIGHT" + | "BMI" + | "MOOD" + | "MEDICATION" + | "SLEEP_DURATION" + | "VO2_MAX" + | "STEPS" + | "ACTIVE_ENERGY"; + +/** + * Inputs the gating helper consumes. The `summaries` shape mirrors + * the `/api/analytics` response (`Record`) + * — undefined means the caller hasn't loaded analytics yet, which + * the helper treats as "not available" so loading shells aren't + * accidentally lit up. + */ +export interface InsightInputs { + summaries: Record | undefined; + /** Whether the user has logged at least one mood entry. */ + hasMood: boolean; + /** Whether the user has at least one active medication. */ + hasMedication: boolean; +} + +/** + * Decide whether a metric has enough observations to surface its + * sub-page, tab pill, or dashboard tile. Returns `false` for any + * unknown branch so a future metric added to `InsightMetric` without + * a handler stays hidden until the gate is wired explicitly. + */ +export function hasMetricData( + metric: InsightMetric, + inputs: InsightInputs, +): boolean { + if (metric === "MOOD") return inputs.hasMood; + if (metric === "MEDICATION") return inputs.hasMedication; + if (metric === "BMI") { + // BMI is derived from WEIGHT + the profile height. The chart + // mounts even at one weight reading; the gate matches. + return (inputs.summaries?.WEIGHT?.count ?? 0) > 0; + } + return (inputs.summaries?.[metric]?.count ?? 0) > 0; +}