From 6e95e54ec6fd7acf991fbc5686b9a05494c1b657 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 18 Aug 2026 03:25:46 +0200 Subject: [PATCH] feat(lens): order the deadlines, not just the closest one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline panel asked for one row and got one row, so nothing else could ask this question. `nextToExpire` answers it for any count and `firstToExpire` is now that function asked for one — one definition, so a big countdown and the lines under it can never disagree about which deadline is next. Unannounced ends still sort behind every dated one however long they have been running: a panel of deadlines that leads with "unknown" is not a panel of deadlines. It sorts a copy, because the array it is handed is the one the list on screen is rendering from. Co-Authored-By: Claude Opus 5 (1M context) --- src/client/state/lens.ts | 44 +++++++++++++++++++------------- test/lens.test.ts | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/client/state/lens.ts b/src/client/state/lens.ts index 85d5f81..50879ef 100644 --- a/src/client/state/lens.ts +++ b/src/client/state/lens.ts @@ -39,28 +39,36 @@ export function outstanding( } /** - * The single row closest to expiring. + * The rows closest to expiring, soonest first. * - * Reads the minimum rather than taking the first row, because the list it is - * given is sorted by whatever mode the reader chose — under "doing first" the - * head of the list is what they are partway through, which is not what a panel - * headed "next to expire" is claiming to show. + * Ordered here rather than taken off the top of the list, because the list it + * is given is sorted by whatever mode the reader chose — under "doing first" + * the head of the list is what they are partway through, which is not what a + * panel headed "next to expire" is claiming to show. * - * An event with no announced end can only ever be the answer when nothing else - * is running: it is real, but it is not a deadline. + * An event with no announced end sorts behind every dated one however long it + * has been running: it is real, but it is not a deadline, and it can only + * surface here once the deadlines run out. + */ +export function nextToExpire( + rows: readonly T[], + count: number, +): T[] { + const dated = rows + .filter((r) => r.clock.msRemaining !== null) + .sort((a, b) => (a.clock.msRemaining ?? 0) - (b.clock.msRemaining ?? 0)); + const undated = rows.filter((r) => r.clock.msRemaining === null); + return [...dated, ...undated].slice(0, Math.max(0, count)); +} + +/** + * The single row closest to expiring — the headline's own event. + * + * One definition, asked for one row, so the big countdown and the lines under + * it can never disagree about which deadline is next. */ export function firstToExpire(rows: readonly T[]): T | null { - let best: T | null = null; - let bestMs = Infinity; - for (const row of rows) { - const ms = row.clock.msRemaining; - if (ms === null) continue; - if (ms < bestMs) { - best = row; - bestMs = ms; - } - } - return best ?? rows[0] ?? null; + return nextToExpire(rows, 1)[0] ?? null; } /** diff --git a/test/lens.test.ts b/test/lens.test.ts index dd17c21..5bea56f 100644 --- a/test/lens.test.ts +++ b/test/lens.test.ts @@ -3,6 +3,7 @@ import { advanceFocus, countByGame, firstToExpire, + nextToExpire, outstanding, resolveFocus, } from "../src/client/state/lens.ts"; @@ -73,6 +74,60 @@ describe("firstToExpire", () => { }); }); +describe("nextToExpire", () => { + test("orders by deadline, whatever order it was handed", () => { + // Same argument as firstToExpire, three rows deep: the reader asked for the + // three closest deadlines, not the first three rows of a list they had + // sorted by what they are partway through. + const rows = [ + row("mid-run", "genshin", 9 * 86_400_000), + row("tomorrow", "hsr", 30 * 3_600_000), + row("tonight", "zzz", 3 * 3_600_000), + ]; + expect(nextToExpire(rows, 3).map((r) => r.event.id)).toEqual([ + "tonight", + "tomorrow", + "mid-run", + ]); + }); + + test("takes only as many as asked for", () => { + const rows = [ + row("a", "genshin", 1000), + row("b", "hsr", 2000), + row("c", "zzz", 3000), + ]; + expect(nextToExpire(rows, 2).map((r) => r.event.id)).toEqual(["a", "b"]); + }); + + test("asking for more than there is returns what there is", () => { + expect(nextToExpire([row("a", "genshin", 1000)], 3)).toHaveLength(1); + expect(nextToExpire([], 3)).toEqual([]); + }); + + test("unannounced ends sort behind every real deadline", () => { + // A panel of deadlines that leads with "unknown" is not a panel of + // deadlines. They still appear once the dated ones run out, because the + // event is real — it just is not a countdown. + const rows = [ + row("unknown", "zzz", null), + row("late", "wuwa", 90 * 86_400_000), + ]; + expect(nextToExpire(rows, 2).map((r) => r.event.id)).toEqual([ + "late", + "unknown", + ]); + }); + + test("leaves the list it was given alone", () => { + // It is handed the same array the list on screen is rendering from, and + // sorting that in place would reorder the reader's list from under them. + const rows = [row("b", "hsr", 2000), row("a", "genshin", 1000)]; + nextToExpire(rows, 2); + expect(rows.map((r) => r.event.id)).toEqual(["b", "a"]); + }); +}); + describe("resolveFocus", () => { const enabled: GameId[] = ["genshin", "hsr"];