fix(soon): stop pointing at events you've finished or ignored

Two symptoms of one bug. The "next to expire" headline counted events
the reader had marked done or ignored, and the dailies strip kept a
tickable chip for a repeating event they had already finished.

showCompleted and showIgnored decide what a reader can *look at*. The
headline and the strip are *instructions*, so they answer a different
question — what is still on your plate — and both now go through one
`outstanding` lens.

Also fixes a second bug in the same line: `next` took the head of the
list, which under "doing first" sorting is whatever you're partway
through, not the soonest deadline. It reads the minimum now.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-16 20:40:46 +02:00
co-authored by Claude Opus 5
parent c3e9b9064d
commit edc6f3e4bb
7 changed files with 186 additions and 12 deletions
+64
View File
@@ -0,0 +1,64 @@
import type { GameId } from "../../shared/schema.ts";
/**
* Which rows each part of the page gets to see.
*
* These decisions used to sit inline in `App`, where they were untestable and
* quietly inconsistent with each other — the "next to expire" headline counted
* events the reader had finished or ignored, and the dailies strip listed a
* repeating event they had already marked done. They are the same question
* asked twice, so they are one function asked twice, and pure so a test can
* pin them down.
*/
/** The shape every lens here needs. Structural so this module stays cheap. */
interface Row {
event: { id: string; game: GameId };
clock: { msRemaining: number | null };
}
/**
* Rows the reader still has something to do with.
*
* "Done" and "ignored" mean different things everywhere else in the app —
* a done event stays visible and counted, an ignored one disappears — but to
* anything answering *what is still on your plate?* they are the same answer:
* not this one. The headline and the dailies strip are both that question.
*
* Note this is deliberately not the same as the main list's filters, which
* honour `showCompleted` / `showIgnored`. Those preferences control what the
* reader can *look at*; this controls what the app *tells them to do*, and
* being reminded of a job you already finished is the bug either way.
*/
export function outstanding<T extends Row>(
rows: readonly T[],
isDone: (id: string) => boolean,
isIgnored: (id: string) => boolean,
): T[] {
return rows.filter((r) => !isDone(r.event.id) && !isIgnored(r.event.id));
}
/**
* The single row closest to expiring.
*
* 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.
*
* 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.
*/
export function firstToExpire<T extends Row>(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;
}