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