From a39c6a83985ef392fb9c6802f08d11eaeb5a1971 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 02:20:33 +0200 Subject: [PATCH] feat(settings): choose games for chores and built-ins behind one switch each What you see drew a checkbox per game for built-ins, a list that grows with every game and every built-in, and offered no per-game answer for chores at all. Each now has one switch, a line naming the games that differ, and Choose games to change them, in the focus chip's colours. Co-Authored-By: Claude Opus 5.5 --- src/client/components/Controls.tsx | 204 ++++++++++++++++++++++++----- test/controls.test.tsx | 77 +++++++++-- 2 files changed, 237 insertions(+), 44 deletions(-) diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 0ec2846..6283426 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -5,8 +5,10 @@ import { REGION_LABEL } from "../../shared/time.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { moveGame } from "../state/gameOrder.ts"; import type { ThemeChoice } from "../state/theme.ts"; -import type { Prefs } from "../state/usePrefs.ts"; +import { choreRule, routineRule, type Prefs } from "../state/usePrefs.ts"; import { YourOwn } from "./YourOwn.tsx"; +import { isCustomGameId } from "../../shared/custom.ts"; +import { exceptionLine, setFor, shownFor, type PerGame } from "../../shared/perGame.ts"; import { asOfLabel, routinesFor } from "../../shared/routines.ts"; const REGIONS: Array<{ id: Region; label: string }> = ( @@ -92,15 +94,22 @@ export function Controls({ /** Everything the reader entered themselves, and the ways to change it. */ own: React.ComponentProps; }) { - const gameMeta = useGameMeta(); const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length; const ownGames = Object.keys(own.games).length; const ownEvents = Object.keys(own.events).length; + const chores = choreRule(prefs); + const routines = routineRule(prefs); + // A hidden game draws no chore, and a reader's own game never had one, so + // neither is offered — a chip for it would be a control that does nothing. + const choreGames = games.filter((g) => !prefs.hiddenGames.includes(g) && !isCustomGameId(g)); + const routineGames = games.filter((g) => routinesFor(g).length > 0); // Which periods have a task at all — the reader's own, or a built-in they // turned on — so a switch is offered only with something behind it. const periods = [ ...(own.tasks ?? []).map((t) => t.period.unit), - ...(prefs.routineGames ?? []).flatMap((g) => routinesFor(g).map((r) => r.period.unit)), + ...routineGames + .filter((g) => shownFor(routines, g)) + .flatMap((g) => routinesFor(g).map((r) => r.period.unit)), ]; const taskPeriods = { week: periods.includes("week"), month: periods.includes("month") }; @@ -247,14 +256,55 @@ export function Controls({ "Commissions, resin" — and they were the one part of the strip with no way out. Named for what it removes rather than for "dailies", which would read as broken while the strip stayed on - screen showing the reader's own events. */} - onUpdate({ showChores })} label="Show each game's own daily chores" hint="Commissions, resin, daily training and the rest. Nobody publishes these, so the app keeps a fixed list per game. Off, the strip shows only events with a checklist. Your ticks and streaks are kept either way." + rule={chores} + games={choreGames} + chipsLabel="Daily chores per game" + onSet={(game, value) => { + const { on, off } = setFor(chores, game, value); + onUpdate({ choresOn: on, choresOff: off }); + }} /> + {/* Opt-in: these are someone else's figures, each dated by its + source, so an unanswered switch is off. One answer for every + game that has them, and the games that differ behind *Choose + games* — where the per-game checkboxes used to be, one per game + and growing with every built-in we add. */} + {routineGames.length > 0 && ( + onUpdate({ showRoutines })} + label="Show built-in weekly tasks" + hint="Weekly tasks the app knows for some games, each from a cited page and dated by it. Off hides them; your ticks and numbers are kept." + rule={routines} + games={routineGames} + chipsLabel="Built-in weekly tasks per game" + titleFor={(game) => { + const list = routinesFor(game); + return `${list.map((r) => r.title).join(", ")} — from a source as of ${asOfLabel(list[0]!.asOf)}`; + }} + onSet={(game, value) => { + const { on, off } = setFor(routines, game, value); + onUpdate({ + routineGames: on, + routinesOff: off, + // Answering here answers the strip's question too. + routinesAsked: [ + ...(prefs.routinesAsked ?? []).filter((g) => g !== game), + game, + ], + }); + }} + /> + )} + {/* Where weekly and monthly tasks sit: their own row under the dailies. Only offered once there is a task of that period — a switch with nothing behind it is a control that does nothing. */} @@ -275,36 +325,6 @@ export function Controls({ /> )} - {/* Beside the chores rather than on each game's row, which already - carries a handle, two arrows and a switch. Opt-in per game: these - are someone else's figures, each dated by its source. */} - {games - .filter((game) => routinesFor(game).length > 0) - .map((game) => { - const on = prefs.routineGames?.includes(game) ?? false; - const list = routinesFor(game); - return ( - - onUpdate({ - routineGames: checked - ? [...(prefs.routineGames ?? []), game] - : (prefs.routineGames ?? []).filter((g) => g !== game), - // Answering here answers the strip's question too. - routinesAsked: [ - ...(prefs.routinesAsked ?? []).filter((g) => g !== game), - game, - ], - }) - } - label={`Built-in weekly tasks for ${gameMeta(game).name}`} - hint={`${list.map((r) => r.title).join(", ")} — from a source as of ${asOfLabel(list[0]!.asOf)}. Off hides them; your numbers are kept.`} - /> - ); - })} - {ignoredCount > 0 && ( void; + label: string; + hint: string; + rule: PerGame; + games: readonly LaneId[]; + chipsLabel: string; + titleFor?: (game: LaneId) => string; + onSet: (game: LaneId, value: boolean) => void; +}) { + const gameMeta = useGameMeta(); + const [open, setOpen] = useState(false); + return ( +
+ + {games.length > 0 && ( +
+

+ {exceptionLine(rule, games, (g) => gameMeta(g).name)} + {" · "} + +

+ {open && ( + + )} +
+ )} +
+ ); +} + +/** + * One chip per game, pressed when that game's are shown. The game-focus chip's + * look — its hue when on, faint when off — so it reads as the same kind of + * answer. Exported so its states are tested without a click. + */ +export function PerGameChips({ + games, + rule, + label, + titleFor, + onSet, +}: { + games: readonly LaneId[]; + rule: PerGame; + label: string; + titleFor?: (game: LaneId) => string; + onSet: (game: LaneId, value: boolean) => void; +}) { + const gameMeta = useGameMeta(); + return ( +
+ {games.map((id) => { + const game = gameMeta(id); + const on = shownFor(rule, id); + return ( + + ); + })} +
+ ); +} + /** * What the visibility group is letting through. * diff --git a/test/controls.test.tsx b/test/controls.test.tsx index a190cc2..527041f 100644 --- a/test/controls.test.tsx +++ b/test/controls.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import { Controls, ownState } from "../src/client/components/Controls.tsx"; +import { Controls, ownState, PerGameChips } 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"; @@ -305,17 +305,69 @@ describe("Controls: backup and export actions", () => { describe("Controls: built-in weekly tasks", () => { - test("a switch per game that has them, reflecting the reader's answer", () => { - const off = render(PREFS, 0, ["genshin", "zzz"]); - expect(off).toContain("Built-in weekly tasks for Zenless Zone Zero"); - expect(off).not.toContain("for Genshin Impact"); - const on = render({ ...PREFS, routineGames: ["zzz"] }, 0, ["genshin", "zzz"]); - const box = on.match(/]*>(?=[^<]*Built-in weekly tasks for Zenless)/); - expect(box?.[0]).toContain('checked=""'); + test("one global switch, not a checkbox per game", () => { + const html = render(PREFS, 0, ["genshin", "zzz", "endfield"]); + expect(html).toContain("Show built-in weekly tasks"); + expect(html).not.toContain("Built-in weekly tasks for"); + }); + + test("it reads showRoutines, and an unanswered one is off", () => { + const games = ["genshin", "zzz"]; + const box = (html: string) => + html.match(/]*>(?=[^<]*Show built-in weekly tasks)/)?.[0]; + expect(box(render(PREFS, 0, games))).not.toContain('checked=""'); + expect(box(render({ ...PREFS, showRoutines: true }, 0, games))).toContain('checked=""'); + }); + + test("a reader who said yes to one game before is told so", () => { + const html = render({ ...PREFS, routineGames: ["zzz"] }, 0, ["genshin", "zzz", "endfield"]); + expect(html).toContain("Only Zenless Zone Zero"); }); test("no switch when no game on the list has any", () => { - expect(render(PREFS)).not.toContain("Built-in weekly tasks"); + expect(render(PREFS)).not.toContain("built-in weekly tasks"); + }); +}); + +describe("Controls: daily chores per game", () => { + test("the line under the switch states the exceptions", () => { + expect(render(PREFS)).toContain("Every game"); + expect(render({ ...PREFS, choresOff: ["hsr"] })).toContain("All but Honkai: Star Rail"); + expect(render({ ...PREFS, showChores: false, choresOn: ["genshin", "hsr"] })).toContain("Only 2 games"); + }); + + test("the games are behind Choose games, closed until asked for", () => { + const html = render(PREFS); + expect(html).toMatch(/]*aria-expanded="false"[^>]*>Choose games<\/button>/); + expect(html).not.toContain('aria-label="Daily chores per game"'); + }); + + test("a game switched off for the reader is not offered, nor counted", () => { + // Its chore is not drawn anyway; a chip for it would be a control that does nothing. + expect(render({ ...PREFS, hiddenGames: ["hsr"], choresOff: ["hsr"] })).toContain("Every game"); + }); + + test("each chip says whether that game's are on", () => { + const html = renderToStaticMarkup( + metaFor(id, {})}> + {}} /> + , + ); + expect(html).toMatch(/aria-pressed="true"[^>]*>]*>Genshin Impact/); + expect(html).toMatch(/aria-pressed="false"[^>]*>]*>Honkai: Star Rail/); + }); + + test("an off chip stays readable, in the focus chip's colours", () => { + // Faint ink was about 2.7:1 on the dark surface — below the bar for a + // control. The game-focus chip's muted ink and \`.hue-chip\` hover are the house answer. + const html = renderToStaticMarkup( + metaFor(id, {})}> + {}} /> + , + ); + expect(html).toContain("hue-chip"); + expect(html).toContain("color:var(--color-muted)"); + expect(html).not.toContain("color:var(--color-faint)"); }); }); @@ -329,6 +381,13 @@ describe("Controls: the Your own summary counts tasks", () => { }); describe("Controls: weekly and monthly tasks under the dailies", () => { + test("built-ins switched on globally count as a weekly task", () => { + expect(render({ ...PREFS, showRoutines: true }, 0, ["zzz"])).toContain("Show weekly tasks"); + expect(render({ ...PREFS, showRoutines: true, routinesOff: ["zzz"] }, 0, ["zzz"])).not.toContain( + "Show weekly tasks", + ); + }); + test("each switch appears once there is a task of its period, and reads its own pref", () => { // ZZZ's built-ins are weekly; there is no monthly task, so no monthly switch. const prefs = { ...PREFS, routineGames: ["zzz"] };