diff --git a/src/client/App.tsx b/src/client/App.tsx index 0a639ee..26d53dc 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -286,6 +286,15 @@ export function App() { const live = visible.filter((r) => r.clock.live); 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 @@ -507,13 +516,13 @@ export function App() { )} - {upcoming.length > 0 && ( + {listedUpcoming.length > 0 && (
1 ? ( + live.length === 0 && listedUpcoming.length > 1 ? ( update({ sort })} @@ -521,15 +530,24 @@ export function App() { ) : undefined } > - +
)} - {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 && (

- {focus !== null - ? `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."} + {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 to show. Every game is switched off, or you've finished everything and hidden completed events."}

)} @@ -551,7 +569,7 @@ export function App() { // The board holds these back itself rather than being handed a // shorter list, so it can say how many are waiting when there is // nothing else left to draw. The switch is in settings. - showUpcoming={prefs.timelineUpcoming} + showUpcoming={prefs.showUpcoming} splitUpcoming={prefs.timelineSplitUpcoming} onOpen={setOpenId} isDone={isDone} diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index f43635e..efbdfc4 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -34,7 +34,7 @@ const SPLITS: Array<{ split: boolean; label: string; hint: string }> = [ { split: true, 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, @@ -156,28 +156,24 @@ export function Controls({ Show events I've finished - {/* Sits with the other two "what am I allowed to look at" rows - because that is the question it answers — but unlike them it - is answered for the board only, and a row that did not say so - would read as a promise about the whole app. The checklist - keeps its own "Not started yet" section either way, which is - why switching this off costs a reader nothing they cannot - still go and read. */} + {/* One of the three "what am I allowed to look at" rows, and it + reaches both views: the checklist's "Not started yet" section + and the board's future bars are the same events answering the + same question. Off is the default because this app answers + *what expires next* — see PRD F1. */} @@ -187,7 +183,7 @@ export function Controls({ are on it, and offering it anyway is a control that does nothing — the stored answer is kept either way, so switching the row above back on restores it rather than a default. */} - {prefs.timelineUpcoming && ( + {prefs.showUpcoming && (
{SPLITS.map((s) => ( @@ -209,6 +205,7 @@ export function Controls({ ))}

+ On the timeline.{" "} {SPLITS.find((s) => s.split === prefs.timelineSplitUpcoming) ?.hint}

diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index 8d0d838..dd84800 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -113,10 +113,11 @@ export function Timeline({ /** * Whether events that have not started yet are plotted. * - * Off by default (`prefs.timelineUpcoming`). The board is asked "how does the - * time I am in lay out?", and every lane has a next patch queued behind it — - * plotting those unasked stretches the window weeks past today and squeezes - * the running bars the reader came for. + * Off by default (`prefs.showUpcoming`, which governs the checklist's own + * "Not started yet" section too). The board is asked "how does the time I am + * in lay out?", and every lane has a next patch queued behind it — plotting + * 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 * "what am I allowed to look at" (`Controls`), not in the board's own header diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 690574d..6db629d 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -87,18 +87,21 @@ export interface Prefs { */ 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 - * answers "how does the time I am in lay out?", and a reader with fourteen - * lanes has a next patch queued behind every one of them. Plotting those by - * default pushes the right edge of the board weeks past today, shrinks every - * running bar to make room, and fills the space with things nobody can do - * 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 - * header says how many are being held back. + * Off by default, and that is a claim about what this app is for: it answers + * *what expires next*, and a reader with fourteen lanes has a next patch + * queued behind every one of them. On the board it is also structural, since + * the window is drawn from what is plotted — showing the future pushes the + * right edge weeks past today and shrinks every running bar to make room. + * + * It sits with `showCompleted` and `showIgnored` because it is the same + * 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 * 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 * 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 - * `timelineUpcoming` is on — with nothing unstarted plotted there is no block - * to keep apart — but stored either way, so switching the parent back on + * `showUpcoming` is on — with nothing unstarted plotted there is no block to + * keep apart — but stored either way, so switching the parent back on * restores the answer they gave rather than a default. */ timelineSplitUpcoming: boolean; @@ -155,7 +161,7 @@ function defaults(): Prefs { view: "soon", timelineDayWidth: DEFAULT_DAY_WIDTH, timelineGroup: "game", - timelineUpcoming: false, + showUpcoming: false, timelineSplitUpcoming: true, detectDaily: false, 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 & { timelineUpcoming?: boolean }, +): Partial { + const { timelineUpcoming, ...rest } = stored; + if (timelineUpcoming === undefined || rest.showUpcoming !== undefined) { + return rest; + } + return { ...rest, showUpcoming: timelineUpcoming }; +} + export function usePrefs() { const [prefs, setPrefs] = useState(() => ({ ...defaults(), - ...readJson>(KEYS.prefs, {}), + ...adoptRenamed(readJson>(KEYS.prefs, {})), })); useEffect(() => { diff --git a/test/controls.test.tsx b/test/controls.test.tsx index 31351e8..06e52ed 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -22,7 +22,7 @@ const PREFS: Prefs = { view: "soon", timelineDayWidth: 32, timelineGroup: "game", - timelineUpcoming: false, + showUpcoming: false, timelineSplitUpcoming: true, detectDaily: false, showCompleted: true, @@ -75,20 +75,26 @@ describe("Controls: what am I allowed to look at", () => { expect(html).toContain("Show events I've finished"); }); - test("it says the board is what it applies to", () => { - // Sitting between two app-wide filters, a row that named no scope would - // read as a promise about the whole app — and the checklist lists these - // whatever this says. + test("it names both views, because it reaches both", () => { + // It began as the board's alone. Sitting between two app-wide filters, a + // row that still said "on the timeline" would understate what a tick does. const html = render(PREFS); - expect(html).toContain("On the timeline"); 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", () => { // 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. 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(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, // and a control that changes nothing visible is worse than none. 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("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", () => { // "Mixed in" is a different order, not a heading switched off, so both // 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({ ...PREFS, - timelineUpcoming: true, + showUpcoming: true, timelineSplitUpcoming: false, }); const pressed = (html: string) => diff --git a/test/prefs.test.ts b/test/prefs.test.ts index 0c65223..edd6588 100644 --- a/test/prefs.test.ts +++ b/test/prefs.test.ts @@ -1,5 +1,5 @@ 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"; /** @@ -66,3 +66,43 @@ describe("adoptNewLanes", () => { 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({}); + }); +});