feat: "show events that haven't started" governs both views

It was the board's alone, sitting between two app-wide filters and
carrying a line of small print to explain why it was different. It is the
same events answering the same question in either view, so it is now one
switch: the board plots them, and the checklist keeps its "Not started
yet" section.

That means the section is off by default, which is a visible change for
every existing reader — deliberate, and the same argument the board makes.
This app answers what expires next; on fourteen lanes the queued patches
are more rows than the thing they came for.

`timelineUpcoming` becomes `showUpcoming`, because the old name would now
be false, and `adoptRenamed` carries a stored answer across on load.
Nothing would be lost by dropping it — `prefs` is one blob under one key,
not a key space — but a reader who had switched the future on would find
it off with no explanation, and they should not have to say a thing
twice. A stored new name always wins, so it cannot overwrite a fresher
answer with a stale one.

Gating the section alone would have opened a hole: nothing running and
everything held back rendered an empty column, because the "nothing to
show" line keyed off there being no rows at all rather than none listed.
It now counts what is held back and names the switch, as the board does.

The split pills stay the board's: the checklist gives these a section
with a heading either way, so there is nothing there to mix them into.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-19 06:20:14 +02:00
co-authored by Claude Opus 5
parent d3066aefdc
commit 2cb4dd214c
6 changed files with 145 additions and 53 deletions
+24 -6
View File
@@ -286,6 +286,15 @@ export function App() {
const live = visible.filter((r) => r.clock.live); const live = visible.filter((r) => r.clock.live);
const upcoming = visible.filter((r) => r.clock.upcoming); const upcoming = visible.filter((r) => r.clock.upcoming);
/**
* The unstarted events the checklist actually lists.
*
* `upcoming` stays the full count either way, because the page header states
* it — a section that is simply absent is indistinguishable from a quiet
* fortnight, and this app does not leave a reader to infer what it is not
* showing them.
*/
const listedUpcoming = prefs.showUpcoming ? upcoming : [];
/** /**
* What the page is telling the reader to *do*, as opposed to what it is * What the page is telling the reader to *do*, as opposed to what it is
@@ -507,13 +516,13 @@ export function App() {
</Section> </Section>
)} )}
{upcoming.length > 0 && ( {listedUpcoming.length > 0 && (
<Section <Section
title="Not started yet" title="Not started yet"
// The ordering control lives with the first list on the page, so // The ordering control lives with the first list on the page, so
// it is never missing when there is something to order. // it is never missing when there is something to order.
action={ action={
live.length === 0 && upcoming.length > 1 ? ( live.length === 0 && listedUpcoming.length > 1 ? (
<SortControl <SortControl
value={prefs.sort} value={prefs.sort}
onChange={(sort) => update({ sort })} onChange={(sort) => update({ sort })}
@@ -521,13 +530,22 @@ export function App() {
) : undefined ) : undefined
} }
> >
<EventList rows={upcoming} render={renderRow} /> <EventList rows={listedUpcoming} render={renderRow} />
</Section> </Section>
)} )}
{visible.length === 0 && ( {/* Nothing listed is three different situations, and the reader can
only act on the one they are actually in. Held-back events come
first because that one has a switch behind it. */}
{live.length === 0 && listedUpcoming.length === 0 && (
<p className="px-4 py-12 text-sm leading-relaxed text-muted"> <p className="px-4 py-12 text-sm leading-relaxed text-muted">
{focus !== null {upcoming.length > 0
? `Nothing running right now. ${
upcoming.length === 1
? "One event has"
: `${upcoming.length} events have`
} not started yet — switch on “Show events that haven't started” below to list them.`
: focus !== null
? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.` ? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.`
: "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."} : "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."}
</p> </p>
@@ -551,7 +569,7 @@ export function App() {
// The board holds these back itself rather than being handed a // The board holds these back itself rather than being handed a
// shorter list, so it can say how many are waiting when there is // shorter list, so it can say how many are waiting when there is
// nothing else left to draw. The switch is in settings. // nothing else left to draw. The switch is in settings.
showUpcoming={prefs.timelineUpcoming} showUpcoming={prefs.showUpcoming}
splitUpcoming={prefs.timelineSplitUpcoming} splitUpcoming={prefs.timelineSplitUpcoming}
onOpen={setOpenId} onOpen={setOpenId}
isDone={isDone} isDone={isDone}
+13 -16
View File
@@ -34,7 +34,7 @@ const SPLITS: Array<{ split: boolean; label: string; hint: string }> = [
{ {
split: true, split: true,
label: "In their own group", label: "In their own group",
hint: "Each lane runs out, then a \u201cNot started yet\u201d heading and what is queued behind it.", hint: "Each lane runs out, then a \u201cNot started yet\u201d heading and what is queued behind it \u2014 the shape the checklist has either way.",
}, },
{ {
split: false, split: false,
@@ -156,28 +156,24 @@ export function Controls({
Show events I've finished Show events I've finished
</label> </label>
{/* Sits with the other two "what am I allowed to look at" rows {/* One of the three "what am I allowed to look at" rows, and it
because that is the question it answers — but unlike them it reaches both views: the checklist's "Not started yet" section
is answered for the board only, and a row that did not say so and the board's future bars are the same events answering the
would read as a promise about the whole app. The checklist same question. Off is the default because this app answers
keeps its own "Not started yet" section either way, which is *what expires next* — see PRD F1. */}
why switching this off costs a reader nothing they cannot
still go and read. */}
<label className="flex cursor-pointer select-none items-start gap-2 text-xs text-muted"> <label className="flex cursor-pointer select-none items-start gap-2 text-xs text-muted">
<input <input
type="checkbox" type="checkbox"
checked={prefs.timelineUpcoming} checked={prefs.showUpcoming}
onChange={(e) => onChange={(e) => onUpdate({ showUpcoming: e.target.checked })}
onUpdate({ timelineUpcoming: e.target.checked })
}
className="mt-px size-4 accent-[var(--color-near)]" className="mt-px size-4 accent-[var(--color-near)]"
/> />
<span> <span>
Show events that haven't started Show events that haven't started
<span className="mt-0.5 block max-w-xs leading-relaxed text-faint"> <span className="mt-0.5 block max-w-xs leading-relaxed text-faint">
On the timeline, which draws its span from what it plots Adds the checklist's "Not started yet" section, and plots
so this stretches the board weeks past today. The checklist them on the timeline — which draws its span from what it
lists them under "Not started yet" regardless. plots, so the board stretches weeks past today.
</span> </span>
</span> </span>
</label> </label>
@@ -187,7 +183,7 @@ export function Controls({
are on it, and offering it anyway is a control that does are on it, and offering it anyway is a control that does
nothing — the stored answer is kept either way, so switching nothing — the stored answer is kept either way, so switching
the row above back on restores it rather than a default. */} the row above back on restores it rather than a default. */}
{prefs.timelineUpcoming && ( {prefs.showUpcoming && (
<div className="ml-6 flex flex-col gap-1.5"> <div className="ml-6 flex flex-col gap-1.5">
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{SPLITS.map((s) => ( {SPLITS.map((s) => (
@@ -209,6 +205,7 @@ export function Controls({
))} ))}
</div> </div>
<p className="max-w-xs text-xs leading-relaxed text-faint"> <p className="max-w-xs text-xs leading-relaxed text-faint">
On the timeline.{" "}
{SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming) {SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming)
?.hint} ?.hint}
</p> </p>
+5 -4
View File
@@ -113,10 +113,11 @@ export function Timeline({
/** /**
* Whether events that have not started yet are plotted. * Whether events that have not started yet are plotted.
* *
* Off by default (`prefs.timelineUpcoming`). The board is asked "how does the * Off by default (`prefs.showUpcoming`, which governs the checklist's own
* time I am in lay out?", and every lane has a next patch queued behind it — * "Not started yet" section too). The board is asked "how does the time I am
* plotting those unasked stretches the window weeks past today and squeezes * in lay out?", and every lane has a next patch queued behind it — plotting
* the running bars the reader came for. * those unasked stretches the window weeks past today and squeezes the
* running bars the reader came for.
* *
* Read-only here: the switch lives in settings with the other two answers to * Read-only here: the switch lives in settings with the other two answers to
* "what am I allowed to look at" (`Controls`), not in the board's own header * "what am I allowed to look at" (`Controls`), not in the board's own header
+44 -14
View File
@@ -87,18 +87,21 @@ export interface Prefs {
*/ */
timelineGroup: TimelineGroup; timelineGroup: TimelineGroup;
/** /**
* Whether the board plots events that have not started yet. * Whether events that have not started yet are shown at all — the board
* plots them, and the checklist keeps its "Not started yet" section.
* *
* Off by default, and that is a claim about what the board is *for*: it * Off by default, and that is a claim about what this app is for: it answers
* answers "how does the time I am in lay out?", and a reader with fourteen * *what expires next*, and a reader with fourteen lanes has a next patch
* lanes has a next patch queued behind every one of them. Plotting those by * queued behind every one of them. On the board it is also structural, since
* default pushes the right edge of the board weeks past today, shrinks every * the window is drawn from what is plotted — showing the future pushes the
* running bar to make room, and fills the space with things nobody can do * right edge weeks past today and shrinks every running bar to make room.
* yet. Nothing is lost by leaving them off — the checklist's "Not started *
* yet" section has listed them all along, and the toggle in the board's * It sits with `showCompleted` and `showIgnored` because it is the same
* header says how many are being held back. * question: what is the reader allowed to look at. It was called
* `timelineUpcoming` while it governed only the board — see `adoptRenamed`,
* which carries a reader's stored answer across rather than resetting it.
*/ */
timelineUpcoming: boolean; showUpcoming: boolean;
/** /**
* Whether those unstarted events keep to their own block on the board, under * Whether those unstarted events keep to their own block on the board, under
* a "Not started yet" heading, or sit in one deadline order with everything * a "Not started yet" heading, or sit in one deadline order with everything
@@ -111,9 +114,12 @@ export interface Prefs {
* Sunday is a nearer deadline than one running now until October, and the * Sunday is a nearer deadline than one running now until October, and the
* split order can never show that. * split order can never show that.
* *
* The board only: the checklist splits them structurally, into a section with
* a heading of its own, and always has.
*
* Defaults to `true`, the board as it was before this existed. Only read when * Defaults to `true`, the board as it was before this existed. Only read when
* `timelineUpcoming` is on — with nothing unstarted plotted there is no block * `showUpcoming` is on — with nothing unstarted plotted there is no block to
* to keep apart — but stored either way, so switching the parent back on * keep apart — but stored either way, so switching the parent back on
* restores the answer they gave rather than a default. * restores the answer they gave rather than a default.
*/ */
timelineSplitUpcoming: boolean; timelineSplitUpcoming: boolean;
@@ -155,7 +161,7 @@ function defaults(): Prefs {
view: "soon", view: "soon",
timelineDayWidth: DEFAULT_DAY_WIDTH, timelineDayWidth: DEFAULT_DAY_WIDTH,
timelineGroup: "game", timelineGroup: "game",
timelineUpcoming: false, showUpcoming: false,
timelineSplitUpcoming: true, timelineSplitUpcoming: true,
detectDaily: false, detectDaily: false,
showCompleted: true, showCompleted: true,
@@ -204,10 +210,34 @@ export function adoptNewLanes(
}; };
} }
/**
* A stored `prefs` object with the one renamed field carried across.
*
* `timelineUpcoming` became `showUpcoming` when it stopped being about the
* board alone. Dropping the old name would not lose data — this is one blob
* under one key, not a key space — but it would silently reset the answer of
* every reader who had switched the future on, and they would have to find the
* setting again to say a thing they already said.
*
* A stored new name always wins, so this can never overwrite a fresher answer
* with a stale one; and once written back under the new name the old one is
* simply an unread leftover. Pure and exported so that is a test rather than a
* claim.
*/
export function adoptRenamed(
stored: Partial<Prefs> & { timelineUpcoming?: boolean },
): Partial<Prefs> {
const { timelineUpcoming, ...rest } = stored;
if (timelineUpcoming === undefined || rest.showUpcoming !== undefined) {
return rest;
}
return { ...rest, showUpcoming: timelineUpcoming };
}
export function usePrefs() { export function usePrefs() {
const [prefs, setPrefs] = useState<Prefs>(() => ({ const [prefs, setPrefs] = useState<Prefs>(() => ({
...defaults(), ...defaults(),
...readJson<Partial<Prefs>>(KEYS.prefs, {}), ...adoptRenamed(readJson<Partial<Prefs>>(KEYS.prefs, {})),
})); }));
useEffect(() => { useEffect(() => {
+16 -10
View File
@@ -22,7 +22,7 @@ const PREFS: Prefs = {
view: "soon", view: "soon",
timelineDayWidth: 32, timelineDayWidth: 32,
timelineGroup: "game", timelineGroup: "game",
timelineUpcoming: false, showUpcoming: false,
timelineSplitUpcoming: true, timelineSplitUpcoming: true,
detectDaily: false, detectDaily: false,
showCompleted: true, showCompleted: true,
@@ -75,20 +75,26 @@ describe("Controls: what am I allowed to look at", () => {
expect(html).toContain("Show events I&#x27;ve finished"); expect(html).toContain("Show events I&#x27;ve finished");
}); });
test("it says the board is what it applies to", () => { test("it names both views, because it reaches both", () => {
// Sitting between two app-wide filters, a row that named no scope would // It began as the board's alone. Sitting between two app-wide filters, a
// read as a promise about the whole app — and the checklist lists these // row that still said "on the timeline" would understate what a tick does.
// whatever this says.
const html = render(PREFS); const html = render(PREFS);
expect(html).toContain("On the timeline");
expect(html).toContain("Not started yet"); expect(html).toContain("Not started yet");
expect(html).toContain("timeline");
});
test("the split pills say they are the board's alone", () => {
// Unlike the row above them, these really are one view — the checklist
// splits unstarted events into a section of their own either way.
const html = render({ ...PREFS, showUpcoming: true });
expect(html).toContain("On the timeline.");
}); });
test("it reads its own preference and not a neighbour's", () => { test("it reads its own preference and not a neighbour's", () => {
// Both neighbours are on and this one is off, so a checkbox bound to the // Both neighbours are on and this one is off, so a checkbox bound to the
// wrong key shows up as the wrong count of ticks. // wrong key shows up as the wrong count of ticks.
const off = checkboxes(render(PREFS)); const off = checkboxes(render(PREFS));
const on = checkboxes(render({ ...PREFS, timelineUpcoming: true })); const on = checkboxes(render({ ...PREFS, showUpcoming: true }));
expect(off.filter(Boolean)).toHaveLength(1); expect(off.filter(Boolean)).toHaveLength(1);
expect(on.filter(Boolean)).toHaveLength(2); expect(on.filter(Boolean)).toHaveLength(2);
}); });
@@ -97,7 +103,7 @@ describe("Controls: what am I allowed to look at", () => {
// A choice about arranging them is unanswerable with none on the board, // A choice about arranging them is unanswerable with none on the board,
// and a control that changes nothing visible is worse than none. // and a control that changes nothing visible is worse than none.
expect(render(PREFS)).not.toContain("Mixed in"); expect(render(PREFS)).not.toContain("Mixed in");
const on = render({ ...PREFS, timelineUpcoming: true }); const on = render({ ...PREFS, showUpcoming: true });
expect(on).toContain("In their own group"); expect(on).toContain("In their own group");
expect(on).toContain("Mixed in"); expect(on).toContain("Mixed in");
}); });
@@ -105,10 +111,10 @@ describe("Controls: what am I allowed to look at", () => {
test("it is a pair of answers, not one answer and its absence", () => { test("it is a pair of answers, not one answer and its absence", () => {
// "Mixed in" is a different order, not a heading switched off, so both // "Mixed in" is a different order, not a heading switched off, so both
// states name themselves and the panel says which is on. // states name themselves and the panel says which is on.
const split = render({ ...PREFS, timelineUpcoming: true }); const split = render({ ...PREFS, showUpcoming: true });
const mixed = render({ const mixed = render({
...PREFS, ...PREFS,
timelineUpcoming: true, showUpcoming: true,
timelineSplitUpcoming: false, timelineSplitUpcoming: false,
}); });
const pressed = (html: string) => const pressed = (html: string) =>
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { adoptNewLanes } from "../src/client/state/usePrefs.ts"; import { adoptNewLanes, adoptRenamed } from "../src/client/state/usePrefs.ts";
import type { LaneId } from "../src/shared/custom.ts"; import type { LaneId } from "../src/shared/custom.ts";
/** /**
@@ -66,3 +66,43 @@ describe("adoptNewLanes", () => {
expect(patch?.hiddenGames).toEqual(["hsr", "fgo"]); expect(patch?.hiddenGames).toEqual(["hsr", "fgo"]);
}); });
}); });
/**
* A preference that changed its name.
*
* `timelineUpcoming` governed the board alone; `showUpcoming` governs the
* checklist too. Nothing is lost by dropping the old name — `prefs` is one blob
* under one key — but a reader who had switched the future on would find it off
* again with no explanation, which is the same failure as forgetting their view.
*/
describe("adoptRenamed", () => {
test("a reader's old answer is carried across", () => {
expect(adoptRenamed({ timelineUpcoming: true })).toEqual({
showUpcoming: true,
});
// Both directions: having said no is also an answer.
expect(adoptRenamed({ timelineUpcoming: false })).toEqual({
showUpcoming: false,
});
});
test("the old name never overwrites a fresher one", () => {
// Once written back under the new name, the leftover must not undo it —
// otherwise the setting would spring back on every load.
expect(
adoptRenamed({ timelineUpcoming: true, showUpcoming: false }),
).toEqual({ showUpcoming: false });
});
test("it is dropped rather than carried into the stored object", () => {
// Kept, it would be written straight back and outlive the migration.
expect(
Object.keys(adoptRenamed({ timelineUpcoming: true, sort: "doing" })),
).toEqual(["sort", "showUpcoming"]);
});
test("a reader with neither is left alone", () => {
expect(adoptRenamed({ sort: "doing" })).toEqual({ sort: "doing" });
expect(adoptRenamed({})).toEqual({});
});
});