diff --git a/src/client/App.tsx b/src/client/App.tsx index 4389891..9b6da92 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -549,9 +549,9 @@ export function App() { group={prefs.timelineGroup} onGroup={(timelineGroup) => update({ timelineGroup })} // The board holds these back itself rather than being handed a - // shorter list, so its own control can say how many are waiting. + // 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} - onShowUpcoming={(timelineUpcoming) => update({ timelineUpcoming })} onOpen={setOpenId} isDone={isDone} /> diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 7577ef3..dd2c20d 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -134,6 +134,32 @@ 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. */} + + {/* Detection reads the source's wording and is wrong in both directions, so it ships off and says so. Off leaves only the events the reader marked, and discards nothing — every mark and diff --git a/src/client/components/Timeline.tsx b/src/client/components/Timeline.tsx index e393dde..4ab5c5b 100644 --- a/src/client/components/Timeline.tsx +++ b/src/client/components/Timeline.tsx @@ -98,7 +98,6 @@ export function Timeline({ group, onGroup, showUpcoming, - onShowUpcoming, onOpen, isDone, }: { @@ -116,11 +115,15 @@ export function Timeline({ * 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. The count is on the control, so what - * is held back is stated rather than merely absent. + * 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 + * beside the stacking and scale controls. Those two reshape what is already + * on the board, which is why they are reached for while reading it; this one + * decides what is on it at all. */ showUpcoming: boolean; - onShowUpcoming: (showUpcoming: boolean) => void; onOpen: (id: string) => void; /** * Asked rather than derived from the progress store: an entry exists there @@ -213,14 +216,7 @@ export function Timeline({ floating over the chart: pinned inside, it would sit on top of the calendar and cover the very dates it sends you back to. */}
-
- - -
+
@@ -259,7 +255,8 @@ export function Timeline({ {waiting.length === 1 ? "One event has not started yet" : `${waiting.length} events have not started yet`}{" "} - — switch “Not started” on to see when they begin. + — switch on “Show events that haven't started” below to see when they + begin.

) : (
void; -}) { - if (waiting === 0 && !on) return null; - return ( - - ); -} - /** * One step of the scale control. * diff --git a/test/controls.test.tsx b/test/controls.test.tsx new file mode 100644 index 0000000..ddf44a2 --- /dev/null +++ b/test/controls.test.tsx @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Controls } from "../src/client/components/Controls.tsx"; +import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; +import { metaFor } from "../src/shared/games.ts"; +import type { Prefs } from "../src/client/state/usePrefs.ts"; + +/** + * The settings panel's view filters. + * + * Three rows answer the same question — *what am I allowed to look at?* — and + * they are the only place two of them can be reached from, so what they are + * bound to is worth pinning. A checkbox wired to the wrong preference is + * invisible in a diff and obvious only to the reader it happens to. + */ + +const PREFS: Prefs = { + region: "europe", + hiddenGames: [], + focusGame: null, + sort: "ending", + view: "soon", + timelineDayWidth: 32, + timelineGroup: "game", + timelineUpcoming: false, + detectDaily: false, + showCompleted: true, + showIgnored: false, + theme: "dark", + regionConfirmed: true, + onboarded: true, +}; + +function render(prefs: Prefs, ignoredCount = 0): string { + return renderToStaticMarkup( + metaFor(id, {})}> + {}} + onUpdate={() => {}} + ignoredCount={ignoredCount} + onExport={() => {}} + onImport={() => {}} + own={{ + games: {}, + events: {}, + lanes: ["genshin", "hsr"], + onAddGame: () => {}, + onEditGame: () => {}, + onRemoveGame: () => ({ removed: true, blockedBy: 0 }), + onAddEvent: () => {}, + }} + /> + , + ); +} + +/** The nth checkbox's `checked` attribute, in document order. */ +function checkboxes(html: string): boolean[] { + return [...html.matchAll(/]*>/g)].map((m) => + m[0].includes('checked=""'), + ); +} + +describe("Controls: what am I allowed to look at", () => { + test("the unstarted-events switch is here, in the reader's words", () => { + // It used to be a pill in the board's own header, next to the stacking and + // scale controls. Those two reshape what is already on the board; this one + // decides what is on it at all, which is the question the two rows beside + // it answer. + const html = render(PREFS); + expect(html).toContain("Show events that haven't started"); + 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. + const html = render(PREFS); + expect(html).toContain("On the timeline"); + expect(html).toContain("Not started yet"); + }); + + 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 })); + expect(off.filter(Boolean)).toHaveLength(1); + expect(on.filter(Boolean)).toHaveLength(2); + }); + + test("the ignored row appears only once something is ignored", () => { + // Nothing to restore means nothing to offer — the row would be a filter + // over an empty set. + expect(render(PREFS)).not.toContain("I'm ignoring"); + expect(render(PREFS, 3)).toContain("Show the 3 events I'm"); + }); +}); diff --git a/test/views.test.tsx b/test/views.test.tsx index c7c81f7..38e90ea 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -261,7 +261,6 @@ describe("Timeline stacking", () => { group={group} onGroup={() => {}} showUpcoming={false} - onShowUpcoming={() => {}} onOpen={() => {}} isDone={() => false} />, @@ -316,22 +315,19 @@ describe("Timeline: events that have not started", () => { group="ending" onGroup={() => {}} showUpcoming={showUpcoming} - onShowUpcoming={() => {}} onOpen={() => {}} isDone={() => false} />, ); - test("the board holds them back by default and says how many", () => { + test("the board holds them back by default", () => { // Off is the default because the board answers "how does the time I am in - // lay out?" — but an absence nobody mentioned is indistinguishable from a - // quiet fortnight, which is the wrong thing for this app to imply. + // lay out?", and it draws its span from what it plots — so a next patch on + // every lane pushes the running bars the reader came for off to the left. const html = board(false); expect(html).toContain("Closing Ceremony"); expect(html).not.toContain("Frost Parade"); expect(html).not.toContain("Long Way Round"); - expect(html).toContain("Not started"); - expect(html).toContain(">3<"); }); test("switching it on plots them", () => { @@ -340,26 +336,20 @@ describe("Timeline: events that have not started", () => { expect(html).toContain("Long Way Round"); }); - test("the control says which way it is set", () => { - expect(board(false)).toContain('aria-pressed="false"'); - expect(board(true)).toContain('aria-pressed="true"'); - }); - - test("nothing waiting and switched off means no control at all", () => { - // A toggle that cannot change anything invites a tap that does nothing. - expect(board(false, [row("Closing Ceremony", "genshin", 100)])).not.toContain( - "Not started", - ); - }); - test("a board with only future events says so rather than reading empty", () => { // Otherwise the reader is looking at "nothing to plot" while three events - // are scheduled, and the reason is a control they did not notice. + // are scheduled, and an absence nobody mentioned is indistinguishable from + // a quiet fortnight. It names the setting, since the switch is not on the + // board any more. const html = board(false, rows.slice(1)); expect(html).toContain("Nothing is running right now"); expect(html).toContain("3 events have not started yet"); - // The control is still there to act on what the sentence just told them. - expect(html).toContain("Not started"); + expect(html).toContain("Show events that haven't started"); + }); + + test("one waiting event is counted in words, not as \"1 events\"", () => { + const html = board(false, [rows[1]!]); + expect(html).toContain("One event has not started yet"); }); test("each clump of starts is marked in words", () => {