feat(insights): gate sub-pages and tab pills on metric data availability

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:

- `<InsightsTabStrip>` filters its pills via the new `availability`
  prop. Backward compatible: undefined `availability` keeps every pill.
- `<InsightsLayoutShell>` 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
  `<EmptyState>` 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.<metric>.{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}
This commit is contained in:
Marc-André Bombeck committed 2026-05-15 16:54:51 +02:00
1 parent 5364e347f5
commit f3ff262451
12 files changed
+739 -26

No files matched your search

@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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("<InsightsTabStrip> — availability gating (v1.4.27 F19)", () => {
it("renders every pill when availability is omitted (backward compat)", () => {
const html = render(<InsightsTabStrip />);
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(<InsightsTabStrip availability={availability} />);
// 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(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Overview<");
});
it("lights up Mood when hasMood flips to true", () => {
const availability: InsightInputs = {
summaries: {},
hasMood: true,
hasMedication: false,
};
const html = render(<InsightsTabStrip availability={availability} />);
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(<InsightsTabStrip availability={availability} />);
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(<InsightsTabStrip availability={availability} />);
expect(html).toContain(">Weight<");
expect(html).toContain(">BMI<");
});
});
@@ -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 `<CoachDrawer>` 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<string, DataSummary>;
}
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 (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={isAuthenticated ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
availability={availability}
/>
{children}
</div>
+45 -15
View File
@@ -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<SubPageSlug, string> = {
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