Drop the just-in line from the Today hero

Under a number, 'Just in' did not say what had arrived - the score? a
reading? - and the clock face beside it changed nothing a person would
then do. The line is gone, and with it the only wall clock this surface
rendered.

What it was there for survives: the day still flips in place when last
night's sleep lands, and the pending-night note still says the number is
reading an incomplete day. An arrival on its own is no longer a reason
to paint a hero at all, so an account whose only news was the arrival
now gets nothing rather than a bordered empty card.

The digest still carries the arrival, which the morning line and the
wide event both read.
This commit is contained in:
Marc-André Bombeck committed 2026-08-01 11:09:37 +02:00
1 parent ee63b09e59
commit 4e934a191d
9 files changed
+91 -211

No files matched your search

@@ -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> = {}): 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(<TodayHero digest={digest()} />);
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(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
);
// 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(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
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(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
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(
<TodayHero
digest={digest({
@@ -89,68 +110,7 @@ describe("TodayHero — the just-in chip", () => {
/>,
);
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(
<TodayHero
digest={digest({ justIn: { kind: "weight", at: ARRIVED_AT } })}
/>,
);
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(
<TodayHero
digest={digest({ justIn: { kind: "workout", at: ARRIVED_AT } })}
/>,
);
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(
<TodayHero
digest={digest({
phase: "provisional",
sleepPending: true,
justIn: { kind: "weight", at: ARRIVED_AT },
})}
/>,
);
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(
<TodayHero
digest={digest({ justIn: { kind: "sleep_night", at: ARRIVED_AT } })}
/>,
"de",
);
expect(html).toContain("Gerade eingetroffen");
expect(html).not.toContain("Just in");
expect(html).not.toContain('data-slot="today-hero"');
});
});
+20 -89
View File
@@ -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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{digest.sleepPending ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : null}
{digest.justIn ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}
</div>
@@ -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 ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{/* 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 ? (
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
) : 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 ? (
<p
data-slot="today-hero-just-in"
data-just-in-kind={digest.justIn.kind}
className="flex items-center gap-1.5"
>
{mounted && justInTime
? t("daily.today.justInAt", { time: justInTime })
: t("daily.today.justIn")}
</p>
) : null}
<p
data-slot="today-hero-sleep-pending"
className="flex items-center gap-1.5"
>
<Moon className="size-3.5 shrink-0" aria-hidden="true" />
{t("daily.today.sleepPending")}
</p>
</div>
) : null}