From 4fe563dcbb93816cfa82c41ebb8c8bfd1dd7708d Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 20 Aug 2026 06:12:38 +0200 Subject: [PATCH] fix: stop the running-now header counting down to "ended" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/client/App.tsx | 18 +++++++++++++----- src/client/state/lens.ts | 25 +++++++++++++++++++++++++ test/lens.test.ts | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 23fa0b4..44c0d5e 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -24,6 +24,7 @@ import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/s import { advanceFocus, countByGame, + followingDeadlineMs, nextToExpire, outstanding, resolveFocus, @@ -300,6 +301,15 @@ export function App() { const live = visible.filter((r) => r.clock.live); const upcoming = visible.filter((r) => r.clock.upcoming); + /** + * What falls due after the row at the top of "Running now". + * + * Read off the deadlines rather than off the list, because the list is in + * whichever order the reader chose — see `followingDeadlineMs`. Null when + * there is no second dated end, and the header then says nothing rather than + * counting down to a placeholder. + */ + const following = followingDeadlineMs(live); /** * The unstarted events the checklist actually lists. * @@ -512,11 +522,9 @@ export function App() { legend title="Running now" hint={ - live.length > 1 - ? `next after this ends in ${formatRemaining( - live[1]?.clock.msRemaining ?? 0, - )}` - : undefined + following === null + ? undefined + : `next after this ends in ${formatRemaining(following)}` } action={ visible.length > 1 ? ( diff --git a/src/client/state/lens.ts b/src/client/state/lens.ts index 50879ef..3676df6 100644 --- a/src/client/state/lens.ts +++ b/src/client/state/lens.ts @@ -71,6 +71,31 @@ export function firstToExpire(rows: readonly T[]): T | null { return nextToExpire(rows, 1)[0] ?? null; } +/** + * How long the deadline *behind* the closest one has left, or null when there + * is not a second dated deadline to report. + * + * The "Running now" header uses this to say what falls due after the row at the + * top. It asked the list for `rows[1]` before, which was wrong twice over and + * in the two ways this module exists to prevent. + * + * The list arrives sorted by whatever mode the reader chose, so under "doing + * first" `rows[1]` is the second thing they are partway through — the same + * mistake `firstToExpire` was written to stop the headline making, one row + * further down. And an unannounced end has no time remaining at all, so the + * old `?? 0` handed `formatRemaining` a zero and the header read "next after + * this ends in ended": the `endsAt: null` rule turned into a claim that + * something expired, which is exactly backwards. + * + * So it reads the second *dated* deadline and returns null rather than a + * stand-in, and the caller omits the line when there is nothing to say. + */ +export function followingDeadlineMs( + rows: readonly T[], +): number | null { + return nextToExpire(rows, 2)[1]?.clock.msRemaining ?? null; +} + /** * The focused game, if it is still a game the reader can see. * diff --git a/test/lens.test.ts b/test/lens.test.ts index 5bea56f..071284f 100644 --- a/test/lens.test.ts +++ b/test/lens.test.ts @@ -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