diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index d045ce489..d75d15006 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -12,7 +12,11 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; * * The spec drives that end-to-end against the real read path: * - * 1. load the dashboard with last night's sleep still missing; + * 0. give the account a reason to paint a hero at all — one integration + * needing a reconnect, which is an ordinary Today for someone whose + * readings sync from devices; + * 1. load the dashboard with last night's sleep still missing, and prove + * it reads provisional with the pending note up; * 2. land it through the REAL batch endpoint, and the arrival marker the * spine's worker would have written; * 3. surface the tab's real visibility-change signal — the browser event @@ -28,6 +32,15 @@ import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; * always about survives it: the open page reads differently on its own * cadence. * + * Step 0 is what the chip's removal exposed rather than caused. The arrival + * was the only thing keeping the hero on screen for this bare fixture + * account, so the spec was watching the day flip on a hero its own subject + * was propping up — and on whatever data a sibling spec happened to leave + * behind. It now establishes its own precondition, so the hero is there for + * a reason a reader would recognise and the run order cannot decide the + * outcome. `today-hero-just-in.test.tsx` pins the same composition at the + * component, since this file cannot be run without a built app. + * * The marker is seeded directly rather than waited for from pg-boss: whether * the queue drains inside the e2e web server is the SPINE's contract and has * its own tests. What this spec owns is everything downstream of a marker @@ -87,17 +100,52 @@ test("a fresh arrival flips the day in place", async ({ page }) => { "UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1", [userId], ); + // And no sleep sample from an earlier run of THIS spec, which would + // land the day on `final` before the scenario starts and make the + // provisional assertion below unreachable. Scoped to the external ids + // this file writes: it is the only e2e spec that records sleep for the + // fixture account (the two v1427 specs mock the route in the browser + // and write nothing), so nothing else can be depending on these rows. + await client.query( + "DELETE FROM measurements WHERE user_id = $1 AND external_id LIKE 'e2e-just-in-%'", + [userId], + ); + + // The account needs a reason to paint a hero that is not the arrival + // itself. A connection needing a reconnect is that reason: the digest + // reads `integration_statuses` fresh on every request rather than + // through the dashboard snapshot's SWR cell, so unlike a seeded score + // or a seeded briefing (both of which ride that cell) it + // cannot lose a race with a cached snapshot, and `buildSyncIssueItems` + // admits it unconditionally — no module gate, no window, and + // `sync_issue` carries no dismiss path that a previous run could have + // silenced. Removed again in the `finally` below. + await client.query( + `INSERT INTO integration_statuses (id, user_id, integration, state, updated_at) + VALUES ($1, $2, 'withings', 'error_reauth', NOW()) + ON CONFLICT (user_id, integration) DO UPDATE SET state = 'error_reauth'`, + [`c${Date.now()}syncissue000`, userId], + ); // ── BEFORE ──────────────────────────────────────────────────────────── - // Wait until the digest settles. Other browser specs deliberately reuse - // this account and may have recorded older health data already, so an - // ordinary briefing hero is a valid baseline. Nothing on it may name an - // arrival: that chip was removed. + // Wait until the digest settles, then pin the starting state. The two + // assertions that matter here are the ones that make the AFTER block + // mean something: a note that was never rendered is also absent at the + // end, and a day that was already final also reads final at the end. await page.goto("/"); await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( 0, ); const hero = page.locator('[data-slot="today-hero"]'); + // Counts rather than visibility, matching the AFTER block: the two are + // then the same assertion with the expected number flipped, and neither + // can pass because a responsive class happened to hide the element. + await expect(hero).toHaveCount(1, { timeout: 20_000 }); + await expect(hero).toHaveAttribute("data-phase", "provisional"); + await expect( + hero.locator('[data-slot="today-hero-sleep-pending"]'), + ).toHaveCount(1); + // Nothing on it may name an arrival: that chip was removed. await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount( 0, ); @@ -165,6 +213,15 @@ test("a fresh arrival flips the day in place", async ({ page }) => { 0, ); } finally { + await client + .query( + "DELETE FROM integration_statuses WHERE user_id = (SELECT id FROM users WHERE username = $1) AND integration = 'withings'", + [E2E_USER.username], + ) + // The account is shared, so the seeded row must go even when an + // assertion above threw — but a cleanup that throws would replace a + // real failure with a confusing one. + .catch(() => {}); client.release(); await pool.end(); } diff --git a/src/components/daily/__tests__/today-hero-just-in.test.tsx b/src/components/daily/__tests__/today-hero-just-in.test.tsx index c423cccc4..12759331f 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -114,6 +114,84 @@ describe("TodayHero — an arrival is not a chip", () => { }); }); +/** + * What the `today-just-in` e2e spec stands on. + * + * That spec watches an open dashboard flip from provisional to final when + * last night's sleep lands. It can only watch it on a hero that exists, + * and until the chip was removed the ARRIVAL was what kept the hero alive + * on the bare fixture account — so the spec was quietly testing the day + * flip on a hero that only its own subject was propping up. It now seeds + * a rail item instead, which is the same shape as a real account with a + * connection needing attention. + * + * These cases are the local half of that: the browser proves the flip on + * a real page, and this proves the composition the fixture relies on is + * the one the component actually renders. Without them the e2e change + * would be a guess about a file it never touches. + */ +describe("TodayHero — what earns the hero without a score", () => { + const SYNC_ISSUE: DailyDigest["worthALook"][number] = { + kind: "sync_issue", + title: "A connection needs your attention", + body: "One of your integrations stopped syncing.", + status: "warning", + actions: [ + { + labelKey: "daily.action.reconnect", + intent: "sync.reconnect", + href: "/settings/integrations", + }, + ], + }; + + /** No score, no briefing, no reaction line: one rail item and a night. */ + function itemOnly(over: Partial = {}): DailyDigest { + return digest({ + score: null, + briefingLead: null, + reactionLine: null, + phase: "provisional", + sleepPending: true, + worthALook: [SYNC_ISSUE], + justIn: { kind: "sleep_night", at: ARRIVED_AT }, + ...over, + }); + } + + it("renders on a rail item alone, and names the day it is reading", () => { + const html = render(); + + expect(html).toContain('data-slot="today-hero"'); + expect(html).toContain('data-phase="provisional"'); + expect(html).toContain('data-slot="today-hero-rail"'); + // The note the flip is going to clear. Asserting it is HERE first is + // what stops the "it is gone afterwards" assertion from passing on a + // note that was never rendered at all. + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + }); + + it("clears the pending note and reads final once the night is in", () => { + const html = render( + , + ); + + expect(html).toContain('data-phase="final"'); + expect(html).not.toContain('data-slot="today-hero-sleep-pending"'); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + }); + + it("has nothing to render once the item goes too", () => { + // The mirror, and the reason the e2e fixture has to seed something: + // strip the item and the same digest paints nothing, arrival and + // pending night included. + const html = render(); + + expect(html).not.toContain('data-slot="today-hero"'); + }); +}); + describe("TodayHero — the reaction line replaces the lead", () => { const REACTION = "A solid night, deeper than your recent stretch."; diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 97f7a8700..aaffafb74 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -92,11 +92,11 @@ export function TodayHero({ renderFilteredAllClear?: boolean; }) { const { t } = useTranslations(); - // Gates ONLY the chip's time string — never the chip's presence. The hero - // itself is deliberately NOT mount-gated (v1.30.9: it is the LCP element and - // paints from the server-dehydrated digest on both the SSR and the hydration - // render). So the slot renders on both passes and the formatted time fills in - // on the first client re-render, keeping those two passes byte-identical. + // The hero is deliberately NOT mount-gated (v1.30.9: it is the LCP element + // and paints from the server-dehydrated digest on both the SSR and the + // hydration render), so nothing in it may differ between those two passes. + // The one thing that did — the removed arrival chip's wall-clock time — was + // the reason a mount gate lived here at all. const { keep, letGo } = useCoachCheckinAction(); const dismissItem = usePriorityItemDismiss(); @@ -147,13 +147,20 @@ export function TodayHero({ const compactAllClear = !lead && !topSignal && !hasItems; // Calm degrade (plan §3): a genuinely empty account — no score, no rail - // items, no cached briefing lead, and no fresh arrival — surfaces nothing + // items, no cached briefing lead and no reaction line — surfaces nothing // here. The tile strip below carries its own "add your first reading" empty // state, so a second alarming empty card on the hero would be noise. The // all-clear state (score present, nothing needs attention) is handled inline - // below. A fresh marker counts as content even when line generation was - // unavailable: the deterministic digest line still gives the hero an honest - // lead, and hiding the marker would lose the arrival moment entirely. + // below. + // + // A fresh arrival used to count as content on its own, back when the + // "just in" chip was the thing it kept on screen. With the chip gone it + // no longer earns a hero: an arrival-only digest reaches the compact + // all-clear composition, which would tell someone with no readings at + // all that everything is clear — a claim about their health made from + // nothing, and made only on the days something happened to sync. The + // arrival still rides the DTO and still feeds the morning line. + // // A filtered layout is the one explicit exception: its empty digest means // "the hidden canonical candidates are quiet here", so retain all-clear. if ( diff --git a/tests/integration/health-score-configured-flag.test.ts b/tests/integration/health-score-configured-flag.test.ts index 456617d81..ecce1f999 100644 --- a/tests/integration/health-score-configured-flag.test.ts +++ b/tests/integration/health-score-configured-flag.test.ts @@ -277,7 +277,6 @@ describe("the resolved configured flag, on every wire that carries it", () => { "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ]); @@ -324,7 +323,6 @@ describe("the resolved configured flag, on every wire that carries it", () => { "ACTIVITY", "SLEEP", "ADIPOSITY", - "FITNESS", "LIPIDS", ]); @@ -341,7 +339,6 @@ describe("the resolved configured flag, on every wire that carries it", () => { "SLEEP", "ADIPOSITY", "WELLBEING", - "LIPIDS", ]); const stored = await getPrismaClient().user.findUniqueOrThrow({ @@ -349,7 +346,7 @@ describe("the resolved configured flag, on every wire that carries it", () => { select: { healthScoreConfigJson: true }, }); expect(stored.healthScoreConfigJson).toMatchObject({ - excludedPillars: ["FITNESS"], + excludedPillars: ["LIPIDS"], }); expectEveryWire(await readWires(), true); @@ -382,7 +379,6 @@ describe("the resolved configured flag, on every wire that carries it", () => { "SLEEP", "ADIPOSITY", "WELLBEING", - "FITNESS", "LIPIDS", ]); expectEveryWire(await readWires(), false);