diff --git a/src/components/insights/__tests__/insights-tab-strip.test.tsx b/src/components/insights/__tests__/insights-tab-strip.test.tsx index 1d7eef2f2..c69bfaffb 100644 --- a/src/components/insights/__tests__/insights-tab-strip.test.tsx +++ b/src/components/insights/__tests__/insights-tab-strip.test.tsx @@ -40,15 +40,22 @@ function fakeSummary(count: number): DataSummary { } describe(" — availability gating (v1.4.27 F19)", () => { - it("renders every pill when availability is omitted (backward compat)", () => { + it("renders only the Overview pill when availability is omitted (v1.4.36 W4d strict gate)", () => { + // v1.4.36 W4d — the pre-v1.4.36 fallback used to render every + // metric pill while the analytics fetch was in flight. Users with + // zero observations on a metric briefly saw nav targets they + // couldn't act on, and on a failed fetch the pills stayed up + // forever. The strict gate flips the contract: no availability => + // only Overview, then pills light up as data arrives. 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<"); + expect(html).toContain(">Overview<"); + expect(html).not.toContain(">Blood Pressure<"); + expect(html).not.toContain(">Weight<"); + expect(html).not.toContain(">Pulse<"); + expect(html).not.toContain(">Mood<"); + expect(html).not.toContain(">Medication<"); + expect(html).not.toContain(">BMI<"); + expect(html).not.toContain(">Sleep<"); }); it("drops pills for metrics with zero observations", () => { @@ -120,14 +127,40 @@ describe(" — availability gating (v1.4.27 F19)", () => { }); describe(" — vitals group collapse (v1.4.34 IW-D)", () => { + // v1.4.36 W4d — strict gate: every test here passes an explicit + // `availability` that lights up the wave-A metrics so the popover + // parent pill renders. Pre-v1.4.36 the no-availability fallback + // lit up every pill by default; under the strict gate the same + // intent is expressed by passing the right summaries. + const fullAvailability: InsightInputs = { + summaries: { + BLOOD_PRESSURE_SYS: fakeSummary(5), + BLOOD_PRESSURE_DIA: fakeSummary(5), + PULSE: fakeSummary(5), + WEIGHT: fakeSummary(5), + SLEEP_DURATION: fakeSummary(5), + VO2_MAX: fakeSummary(5), + STEPS: fakeSummary(5), + ACTIVE_ENERGY: fakeSummary(5), + ACTIVE_ENERGY_BURNED: fakeSummary(5), + HEART_RATE_VARIABILITY: fakeSummary(5), + RESTING_HEART_RATE: fakeSummary(5), + OXYGEN_SATURATION: fakeSummary(5), + BODY_TEMPERATURE: fakeSummary(5), + }, + hasMood: true, + hasMedication: true, + hasWorkouts: true, + }; + it("renders a single 'Vitals' parent pill instead of five wave-A pills in the strip row", () => { - // No availability ⇒ every wave-A pill is visible ⇒ the parent pill - // appears once. The five sub-pages still render in the popover - // body but that lives in a Radix Portal which `renderToStaticMarkup` - // does not serialise (no portal target in SSR) — so we assert on - // the strip-row only. Behaviour test for the popover body sits in - // a Playwright spec when the dev server runs. - const html = render(); + // Every wave-A pill has data ⇒ the parent pill appears once. The + // five sub-pages still render in the popover body but that lives + // in a Radix Portal which `renderToStaticMarkup` does not serialise + // (no portal target in SSR) — so we assert on the strip-row only. + // Behaviour test for the popover body sits in a Playwright spec + // when the dev server runs. + const html = render(); // Parent pill — there is exactly one. const parentMatches = html.match(/>Vitals — vitals group collapse (v1.4.34 IW-D)", () => { }); it("renders the parent pill as a popover trigger (button, not link)", () => { - const html = render(); + const html = render(); expect(html).toContain('data-slot="insights-tab-strip-group"'); expect(html).toContain('data-group="vitals"'); }); it("preserves the non-grouped pills inline", () => { - const html = render(); + const html = render(); // Flat (non-grouped) pills must still be reachable directly. expect(html).toContain(">Blood Pressure<"); expect(html).toContain(">Pulse<"); diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index ae70ef6cd..3f3f1049c 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -171,10 +171,22 @@ const SUB_PAGE_GROUP_META: Record< }; function buildTabs(availability: InsightInputs | undefined): TabEntry[] { - const visibleSlugs = SUB_PAGE_SLUGS.filter((slug) => { - if (!availability) return true; - return hasMetricData(SUB_PAGE_TABS[slug].metric, availability); - }); + // v1.4.36 W4d — strict gating contract: a sub-page only surfaces a + // pill when the availability inputs CONFIRM the metric has at + // least one observation in the 90-day window. The legacy + // `if (!availability) return true` fallback rendered every pill + // while the analytics fetch was in flight, so users with empty + // metrics briefly saw nav targets they couldn't act on (and on + // a failed/stale fetch the pills stayed up forever). The new + // contract: no availability → no metric pills, only Overview. + // The pills appear progressively as `summaries[METRIC].count` + // flips from 0 to ≥1 — exactly the auto-light-up behaviour + // described in `metric-availability.ts` line 26. + const visibleSlugs = availability + ? SUB_PAGE_SLUGS.filter((slug) => + hasMetricData(SUB_PAGE_TABS[slug].metric, availability), + ) + : []; const visibleSet = new Set(visibleSlugs); const entries: TabEntry[] = [