From 96c10f07e6ef9eae1e62dd596b988a5ffbebee6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 13:21:04 +0200 Subject: [PATCH] Give the today-just-in spec an account that earns a hero The spec watched the day flip from provisional to final on a hero that only its own subject was keeping on screen: the arrival marker was the last thing in the calm-degrade guard holding the hero up for the bare fixture account, so removing the arrival chip took the hero with it and left the spec nothing to observe. Putting the arrival back into the guard would bring back a worse problem. An arrival-only digest lands in 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 guard stays as it is; the fixture was what was wrong. So the spec now seeds its own precondition: one integration needing a reconnect, which is an ordinary Today for anyone whose readings come from a device. It is read fresh on every request rather than through the dashboard snapshot cache, so unlike a seeded score or briefing it cannot lose a race with a cached snapshot, and the rail item admits no module gate and no dismiss path. The row is removed again afterwards. While the spec was open: it now pins the state it starts from. It only ever asserted that the pending-sleep note was absent at the end, which a note that never rendered also satisfies. It asserts the note is up and the day reads provisional first, so the two halves are one assertion with the number flipped. The component side is pinned in unit tests, since the spec needs a built app and a database to run. --- e2e/today-just-in.spec.ts | 67 ++++++++++++++-- .../__tests__/today-hero-just-in.test.tsx | 78 +++++++++++++++++++ src/components/daily/today-hero.tsx | 25 +++--- .../health-score-configured-flag.test.ts | 6 +- 4 files changed, 157 insertions(+), 19 deletions(-) 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);