diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index dbc70a054..d045ce489 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -12,15 +12,21 @@ 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 nothing fresh — no chip, day is provisional; - * 2. land last night's sleep through the REAL batch endpoint, and the - * arrival marker the spine's worker would have written; + * 1. load the dashboard with last night's sleep still missing; + * 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 * TanStack Query's focus manager consumes for * `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll); * using that existing trigger keeps the test inside the real cadence * while staying well under the poll interval; - * 4. assert the chip appeared and the phase flipped — in place, no reload. + * 4. assert the phase flipped and the pending note cleared — in place, no + * reload. + * + * The hero used to carry a "just in" chip here; it is gone, because under a + * number "just in" never said what had arrived. What the milestone was + * always about survives it: the open page reads differently on its own + * cadence. * * 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 @@ -43,9 +49,7 @@ function localDayKey(at: Date, timeZone: string): string { }).format(at); } -test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({ - page, -}) => { +test("a fresh arrival flips the day in place", async ({ page }) => { const dbUrl = process.env.DATABASE_URL; test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker"); @@ -85,10 +89,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy ); // ── BEFORE ──────────────────────────────────────────────────────────── - // Wait until the digest settles, then prove there is no stale arrival - // reaction. Other browser specs deliberately reuse this account and may - // have recorded older health data already, so an ordinary briefing Hero - // is a valid baseline; the invariant here is that it has no just-in chip. + // 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. await page.goto("/"); await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( 0, @@ -149,9 +153,6 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy window.dispatchEvent(new Event("visibilitychange")), ); - await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({ - timeout: 20_000, - }); await expect(hero).toHaveAttribute("data-phase", "final", { timeout: 20_000, }); @@ -159,10 +160,10 @@ test("a fresh arrival surfaces the just-in chip and flips the day in place", asy await expect( hero.locator('[data-slot="today-hero-sleep-pending"]'), ).toHaveCount(0); - // The chip names the arrival kind — the stable attribute, not the copy. - await expect( - hero.locator('[data-slot="today-hero-just-in"]'), - ).toHaveAttribute("data-just-in-kind", "sleep_night"); + // And the arrival still raises no chip of its own, on either pass. + await expect(page.locator('[data-slot="today-hero-just-in"]')).toHaveCount( + 0, + ); } finally { client.release(); await pool.end(); diff --git a/messages/de.json b/messages/de.json index 160289c44..301b630f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", "sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.", - "justIn": "Gerade eingetroffen", - "justInAt": "Gerade eingetroffen · {time}", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen" diff --git a/messages/en.json b/messages/en.json index 4a6bbe727..50500e27f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} vs your baseline", "deltaFlat": "In line with your baseline", "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", - "justIn": "Just in", - "justInAt": "Just in · {time}", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", "ringLink": "Open {metric} details" diff --git a/messages/es.json b/messages/es.json index e6d3a489c..602833c88 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} frente a tu valor de referencia", "deltaFlat": "En línea con tu valor de referencia", "sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.", - "justIn": "Recién llegado", - "justInAt": "Recién llegado · {time}", "worthALook": "Merece un vistazo", "allClear": "Hoy nada necesita tu atención.", "ringLink": "Abrir detalles de {metric}" diff --git a/messages/fr.json b/messages/fr.json index e8df25ca5..f850b79f5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} par rapport à ta référence", "deltaFlat": "Conforme à ta référence", "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", - "justIn": "Vient d'arriver", - "justInAt": "Vient d'arriver · {time}", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", "ringLink": "Ouvrir les détails de {metric}" diff --git a/messages/it.json b/messages/it.json index 296ac67d6..5362ee653 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento", "deltaFlat": "In linea con il tuo valore di riferimento", "sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.", - "justIn": "Appena arrivato", - "justInAt": "Appena arrivato · {time}", "worthALook": "Da tenere d'occhio", "allClear": "Oggi nulla richiede la tua attenzione.", "ringLink": "Apri i dettagli di {metric}" diff --git a/messages/pl.json b/messages/pl.json index e28c1f528..83d8db5ae 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9288,8 +9288,6 @@ "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", "deltaFlat": "Zgodnie z Twoją wartością bazową", "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", - "justIn": "Właśnie dotarło", - "justInAt": "Właśnie dotarło · {time}", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}" 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 4f39b0c7f..c423cccc4 100644 --- a/src/components/daily/__tests__/today-hero-just-in.test.tsx +++ b/src/components/daily/__tests__/today-hero-just-in.test.tsx @@ -1,22 +1,17 @@ /** - * The Today hero's "just in" chip and the reaction line's lead replacement. + * The Today hero's arrival handling and the reaction line's lead + * replacement. * - * Two invariants, both of which have a specific failure this project has - * already paid for: + * The hero used to carry a "just in" chip under the score, with the + * arrival's local time on it. It is gone: under a number, "just in" did + * not say what had arrived — the score? a reading? — and the clock face + * changed nothing a person would then do. What is left is the invariant + * that an arrival alone is not a reason to paint anything, and the one + * the reaction line has always had. * - * 1. HYDRATION. The hero is deliberately NOT mount-gated — it paints from the - * server-dehydrated digest on the SSR pass because it is the LCP element - * (v1.30.9). So anything inside it that differs between the server and the - * browser is a React #418 mismatch. A wall-clock time is exactly that: the - * server's locale and timezone are not the reader's. The chip therefore - * renders its SLOT on both passes and fills the time in only after mount. - * These tests render through `renderToStaticMarkup`, which takes the SERVER - * snapshot of `useSyncExternalStore` — i.e. precisely the pass that must - * carry no formatted time. - * - * 2. NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a second - * paragraph. The hero stays one lead line tall, so nothing below it moves - * when the line arrives on a poll. + * NO LAYOUT SHIFT. The reaction line REPLACES the lead; it is never a + * second paragraph. The hero stays one lead line tall, so nothing below + * it moves when the line arrives on a poll. */ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -53,30 +48,56 @@ function digest(over: Partial = {}): DailyDigest { }; } -/** Any hh:mm clock face — the thing the server must never emit. */ +/** Any hh:mm clock face — the thing the hero must never print here. */ const CLOCK_FACE = /\d{1,2}:\d{2}/; -describe("TodayHero — the just-in chip", () => { - it("renders no chip when nothing landed", () => { - const html = render(); - expect(html).not.toContain('data-slot="today-hero-just-in"'); - }); - - it("renders the chip slot on the server pass, carrying the kind", () => { +describe("TodayHero — an arrival is not a chip", () => { + it("paints no arrival marker when something has landed", () => { const html = render( , ); - // The slot is present on the SSR pass, so the hydration render — which - // takes the same server snapshot — produces identical HTML. - expect(html).toContain('data-slot="today-hero-just-in"'); - expect(html).toContain('data-just-in-kind="sleep_night"'); - expect(html).toContain("Just in"); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + expect(html).not.toContain("Just in"); + expect(html).not.toContain(ARRIVED_AT); }); - it("renders for a bare account when the arrival has no generated line", () => { + it("prints no clock face beside the score", () => { + // The removed chip was the only wall clock on this surface, and a + // wall clock rendered on the server pass is a hydration mismatch as + // well as noise. Neither is possible once nothing prints one. + const html = render( + , + ); + + expect(html).not.toMatch(CLOCK_FACE); + }); + + it("still shows a pending night, which is a different sentence", () => { + // The freshness note says the day is not complete yet, which changes + // how the number should be read. That one stays. + const html = render( + , + ); + + expect(html).toContain('data-slot="today-hero-sleep-pending"'); + expect(html).not.toContain('data-slot="today-hero-just-in"'); + }); + + it("renders nothing at all for an account whose only news was the arrival", () => { + // With no chip there is nothing left for such a digest to say, and a + // bordered empty hero above the tile strip would be worse than + // absence. const html = render( { />, ); - expect(html).toContain('data-slot="today-hero"'); - expect(html).toContain('data-slot="today-hero-just-in"'); - }); - - it("emits NO formatted time and no raw ISO instant on the server pass", () => { - const html = render( - , - ); - - const chip = html.slice(html.indexOf('data-slot="today-hero-just-in"')); - // The mismatch itself: a wall clock the browser would render differently. - expect(chip).not.toMatch(CLOCK_FACE); - // And the raw instant must not leak either — it is machine data, not copy. - expect(html).not.toContain(ARRIVED_AT); - }); - - it("keeps the chip muted meta — never an accent, never an opacity modifier", () => { - const html = render( - , - ); - - const row = html.slice( - 0, - html.indexOf('data-slot="today-hero-just-in"') + 400, - ); - expect(row).toContain("text-muted-foreground"); - expect(row).toContain("text-xs"); - // UI-STANDARDS §text: alpha on muted text drops below AA contrast. - expect(html).not.toContain("text-muted-foreground/"); - expect(html).not.toContain("text-primary"); - }); - - it("shows the chip alongside a still-pending night", () => { - // Legitimately co-occurring: a weight landed this morning while last - // night's sleep has not arrived yet. - const html = render( - , - ); - - expect(html).toContain('data-slot="today-hero-sleep-pending"'); - expect(html).toContain('data-slot="today-hero-just-in"'); - }); - - it("localises the chip", () => { - const html = render( - , - "de", - ); - expect(html).toContain("Gerade eingetroffen"); - expect(html).not.toContain("Just in"); + expect(html).not.toContain('data-slot="today-hero"'); }); }); diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 870b27798..97f7a8700 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -37,8 +37,6 @@ import Link from "next/link"; import { Moon } from "lucide-react"; -import { useMounted } from "@/hooks/use-mounted"; -import { formatTime } from "@/lib/format"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; @@ -86,32 +84,6 @@ function withoutRepeatedScore( return result.length > 0 ? result : null; } -/** - * The "just in" chip's clock face. - * - * Formatted CLIENT-side, and only after mount. `justIn.at` crosses the wire as - * an ISO instant precisely so this stays a client concern: the server has no - * business rendering a local wall-clock time it would format under its OWN - * locale and timezone, and any such string would differ from the browser's on - * the hydration render — React #418, a class of bug this project has already - * paid for once. - * - * Goes through the shared `formatTime`, which reads the profile's timezone and - * clock preference. The bare `toLocaleTimeString(undefined, …)` this replaces - * asked the BROWSER instead, which is a different question: someone whose - * profile says Europe/Berlin while their laptop sits in Manila saw a Manila - * time here and a Berlin time on every other surface, for the same instant. - */ -function formatJustInTime(iso: string): string | null { - const at = new Date(iso); - if (Number.isNaN(at.getTime())) return null; - try { - return formatTime(at); - } catch { - return null; - } -} - export function TodayHero({ digest, renderFilteredAllClear = false, @@ -125,7 +97,6 @@ export function TodayHero({ // 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. - const mounted = useMounted(); const { keep, letGo } = useCoachCheckinAction(); const dismissItem = usePriorityItemDismiss(); @@ -166,7 +137,6 @@ export function TodayHero({ const signalFallbackLead = hasScore && !lead ? topSignal?.headline?.trim() || null : null; const displayLead = lead ?? signalFallbackLead; - const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null; // A score-only all-clear digest has no narrative content for the leading // column. Keeping the full md ring in that two-column shell left a blank // column beside a 168 px dial and pushed the actual all-clear read below it. @@ -191,8 +161,7 @@ export function TodayHero({ !hasScore && !hasItems && !digest.briefingLead && - !digest.reactionLine && - !digest.justIn + !digest.reactionLine ) { return null; } @@ -267,28 +236,15 @@ export function TodayHero({ {/* In the compact fallback the quiet freshness tier belongs next to the smaller ring. That uses the otherwise-empty leading space and avoids adding a second row below the dial. */} - {compactAllClear && (digest.sleepPending || digest.justIn) ? ( + {compactAllClear && digest.sleepPending ? (
- {digest.sleepPending ? ( -

-

- ) : null} - {digest.justIn ? ( -

- {mounted && justInTime - ? t("daily.today.justInAt", { time: justInTime }) - : t("daily.today.justIn")} -

- ) : null} +

+

) : null} @@ -334,43 +290,18 @@ export function TodayHero({ {/* Meta row — the hero's quiet tier (UI-STANDARDS §text: `text-xs text-muted-foreground` is the meta floor; never an accent, never an - opacity modifier). Holds the freshness note and the "just in" chip; - they can legitimately co-occur (a weight landed while last night's - sleep is still pending), so they share one wrapping row rather than - competing for the same slot. */} - {!compactAllClear && (digest.sleepPending || digest.justIn) ? ( + opacity modifier). Freshness note (plan §2.4) — provisional day, + last night's sleep not yet folded in. Muted, non-blocking, + refreshes in place when the morning job lands. */} + {!compactAllClear && digest.sleepPending ? (
- {/* Freshness note (plan §2.4) — provisional day, last night's - sleep not yet folded in. Muted, non-blocking, refreshes in - place when the morning job lands. */} - {digest.sleepPending ? ( -

-

- ) : null} - - {/* The "just in" chip — a state change, not a notification. No - badge count, no accent, no animation: the record simply reads - differently on the next refresh, and this names why. It is - present for as long as the arrival is news (the server applies - the window, so the chip expires on a poll rather than on a - client timer) and carries the arrival's local time once the - client has mounted and can format it honestly. */} - {digest.justIn ? ( -

- {mounted && justInTime - ? t("daily.today.justInAt", { time: justInTime }) - : t("daily.today.justIn")} -

- ) : null} +

+

) : null}