fix: stop the running-now header counting down to "ended"

The section header said "next after this ends in {live[1]}", which got the
question wrong in both of the ways lens.ts exists to prevent.

An event whose end was never announced has no time remaining, and the `?? 0`
that stood in for it made `formatRemaining` return its expiry string — so a
second row with `endsAt: null` had the header reading "next after this ends in
ended". That is the one rule this product is built on, inverted: an unknown end
announced as an expiry.

And `live[1]` is the second *row*, not the second deadline. The list is sorted
by whatever mode the reader picked, so under "doing first" the header named
whatever they were second-most partway through — the same mistake firstToExpire
was written to keep out of the headline, one row further down.

`followingDeadlineMs` asks the deadlines instead and returns null when there is
no second dated end, and the header then says nothing at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-20 06:12:38 +02:00
co-authored by Claude Opus 5
parent 447ea25632
commit 4fe563dcbb
3 changed files with 73 additions and 5 deletions
+35
View File
@@ -3,6 +3,7 @@ import {
advanceFocus,
countByGame,
firstToExpire,
followingDeadlineMs,
nextToExpire,
outstanding,
resolveFocus,
@@ -46,6 +47,40 @@ describe("outstanding", () => {
});
});
describe("followingDeadlineMs", () => {
const HOUR = 3_600_000;
test("reads the second deadline, not the second row", () => {
// The "Running now" header says what falls due after the top row. The list
// arrives in whichever order the reader chose, so under "doing first" its
// second row is the second thing they are partway through — the mistake
// `firstToExpire` prevents one row up.
const rows = [
row("mid-run", "genshin", 9 * 24 * HOUR),
row("next-week", "hsr", 5 * 24 * HOUR),
row("tonight", "zzz", 3 * HOUR),
];
expect(followingDeadlineMs(rows)).toBe(5 * 24 * HOUR);
});
test("an unannounced end is not a deadline of zero", () => {
// The old code took `rows[1].msRemaining ?? 0`, and `formatRemaining(0)` is
// the string "ended" — so a second row with `endsAt: null` had the header
// reading "next after this ends in ended". Null means there is nothing to
// say, and the caller says nothing.
expect(followingDeadlineMs([row("tonight", "genshin", 3 * HOUR), row("undated", "hsr", null)]))
.toBeNull();
});
test("one deadline has nothing behind it", () => {
expect(followingDeadlineMs([row("only", "genshin", 3 * HOUR)])).toBeNull();
});
test("an empty list is null, not a zero", () => {
expect(followingDeadlineMs([])).toBeNull();
});
});
describe("firstToExpire", () => {
test("takes the soonest, not the first row", () => {
// The list arrives sorted by whatever mode the reader chose. Under "doing