refactor: the event row takes its clock instead of reading one

Every other surface here is handed `now` — the headline, the board, the dailies
strip, the detail sheet — for the reason the parsers are: a function that reads
the clock cannot be rendered against a fixed instant, so nothing about it can be
asserted. The list row was the one exception, calling the wall clock twice for
its window caption and its "starts in".

Two things follow from fixing it. Those numbers were counted from a different
instant than the `clock` sitting beside them in the same row, which is a
disagreement nobody would ever notice and nobody could ever prove. And the
countdown is now testable: rendering one row at two instants gives the two
answers the injected clock implies, which is what the new test pins.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:33:42 +02:00
co-authored by Claude Opus 5
parent fb390c1af8
commit 7bb7dc843c
3 changed files with 53 additions and 3 deletions
+1
View File
@@ -348,6 +348,7 @@ export function App() {
<EventRow <EventRow
key={row.event.id} key={row.event.id}
row={row} row={row}
now={now}
completed={isDone(row.event.id)} completed={isDone(row.event.id)}
status={prog.progress[row.event.id]?.status} status={prog.progress[row.event.id]?.status}
effort={prog.progress[row.event.id]?.effort} effort={prog.progress[row.event.id]?.effort}
+14 -2
View File
@@ -23,6 +23,17 @@ export interface DailyBadge {
interface EventRowProps { interface EventRowProps {
row: RowEvent; row: RowEvent;
/**
* The instant the page is being read at.
*
* Passed in like every other surface's, rather than read off the clock here.
* This row was the one component asking the wall clock itself, which meant its
* caption and its "starts in" counted from a different instant than the
* `clock` beside them had been computed against — and that neither could be
* rendered against a fixed time in a test, which is the rule the rest of this
* codebase holds to.
*/
now: number;
completed: boolean; completed: boolean;
status?: Status | undefined; status?: Status | undefined;
effort?: Effort | undefined; effort?: Effort | undefined;
@@ -36,6 +47,7 @@ interface EventRowProps {
export function EventRow({ export function EventRow({
row, row,
now,
completed, completed,
status, status,
effort, effort,
@@ -49,13 +61,13 @@ export function EventRow({
const game = gameMeta(event.game); const game = gameMeta(event.game);
const heat = URGENCY_COLOR[clock.urgency]; const heat = URGENCY_COLOR[clock.urgency];
const caption = windowCaption(clock, Date.now()); const caption = windowCaption(clock, now);
// Only ever a warning when the reader gave an estimate — inferring one to // Only ever a warning when the reader gave an estimate — inferring one to
// justify the warning would be inventing their input. // justify the warning would be inventing their input.
const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining); const risk = status === "done" ? "fine" : pressure(effort, clock.msRemaining);
const countdown = clock.upcoming const countdown = clock.upcoming
? `starts in ${formatRemaining(clock.startsMs - Date.now())}` ? `starts in ${formatRemaining(clock.startsMs - now)}`
: clock.msRemaining === null : clock.msRemaining === null
? "end date unknown" ? "end date unknown"
: formatRemaining(clock.msRemaining); : formatRemaining(clock.msRemaining);
+38 -1
View File
@@ -159,7 +159,12 @@ describe("EventRow provenance", () => {
test("marks the reader's own event as theirs", () => { test("marks the reader's own event as theirs", () => {
const html = render( const html = render(
<ul> <ul>
<EventRow row={row(OWN.id)} completed={false} onOpen={() => {}} /> <EventRow
row={row(OWN.id)}
now={Date.parse(AT)}
completed={false}
onOpen={() => {}}
/>
</ul>, </ul>,
); );
expect(html).toContain("yours"); expect(html).toContain("yours");
@@ -170,6 +175,7 @@ describe("EventRow provenance", () => {
<ul> <ul>
<EventRow <EventRow
row={row("genshin:windblume-festival:2026-03-14")} row={row("genshin:windblume-festival:2026-03-14")}
now={Date.parse(AT)}
completed={false} completed={false}
onOpen={() => {}} onOpen={() => {}}
/> />
@@ -177,6 +183,37 @@ describe("EventRow provenance", () => {
); );
expect(html).not.toContain(">yours<"); expect(html).not.toContain(">yours<");
}); });
test("counts from the instant it is handed, not from the wall clock", () => {
// The row used to ask `Date.now()` for its caption and its "starts in",
// which is why neither could be asserted at all: the numbers moved with
// whenever the suite happened to run. Rendering the same row at two instants
// has to produce two different countdowns, and both have to be the ones the
// injected clock implies.
const event = { ...asDisplayEvent(OWN), id: OWN.id };
const at = Date.parse(AT);
// A window opening in two days, so the row takes its "starts in" branch.
const upcoming = {
...event,
startsAt: new Date(at + 2 * 24 * 3_600_000).toISOString(),
};
const at2 = (ms: number) =>
render(
<ul>
<EventRow
row={{ event: upcoming, clock: clockFor(upcoming, "europe", ms) }}
now={ms}
completed={false}
onOpen={() => {}}
/>
</ul>,
);
expect(at2(at)).toContain("starts in 2d");
// A day later the same row says one day, with nothing about the real clock
// involved in either answer.
expect(at2(at + 24 * 3_600_000)).toContain("starts in 1d");
});
}); });
describe("Colophon freshness notice (PRD F7)", () => { describe("Colophon freshness notice (PRD F7)", () => {