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:
co-authored by
Claude Opus 5
parent
447ea25632
commit
4fe563dcbb
+13
-5
@@ -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 ? (
|
||||
|
||||
@@ -71,6 +71,31 @@ export function firstToExpire<T extends Row>(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<T extends Row>(
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user