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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-14 02:20:33 +02:00
co-authored by Claude Opus 5.5
parent 47c395379b
commit a39c6a8398
2 changed files with 237 additions and 44 deletions
+169 -35
View File
@@ -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<typeof YourOwn>;
}) {
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. */}
<Check
screen showing the reader's own events. One answer for every
game, and the games that differ behind *Choose games*. */}
<PerGameSwitch
checked={prefs.showChores}
onChange={(showChores) => 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 && (
<PerGameSwitch
checked={routines.all}
onChange={(showRoutines) => 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 (
<Check
key={game}
checked={on}
onChange={(checked) =>
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 && (
<Check
checked={prefs.showIgnored}
@@ -395,6 +415,120 @@ function gamesState(total: number, shown: number, ordered: boolean): string {
return `${shown} of ${total} on · ${ordered ? "your order" : "A–Z"}`;
}
/**
* A switch the app answers per game: one checkbox for every game, a line naming
* the games that differ, and *Choose games* to change them.
*
* The games sit behind a disclosure rather than as a checkbox each, because
* that list grows with every game we add and every built-in we ship, and most
* readers answer once for all of them. Closed is per-visit state, like *show
* all N*: nothing about it is a preference.
*/
function PerGameSwitch({
checked,
onChange,
label,
hint,
rule,
games,
chipsLabel,
titleFor,
onSet,
}: {
checked: boolean;
onChange: (checked: boolean) => 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 (
<div>
<Check checked={checked} onChange={onChange} label={label} hint={hint} />
{games.length > 0 && (
<div className="ml-6 mt-1.5">
<p className="text-xs text-faint">
{exceptionLine(rule, games, (g) => gameMeta(g).name)}
{" · "}
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="text-muted underline decoration-hairline underline-offset-2 transition-colors hover:text-ink"
>
{open ? "Done" : "Choose games"}
</button>
</p>
{open && (
<PerGameChips
games={games}
rule={rule}
label={chipsLabel}
onSet={onSet}
{...(titleFor !== undefined ? { titleFor } : {})}
/>
)}
</div>
)}
</div>
);
}
/**
* 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 (
<div role="group" aria-label={label} className="mt-2 flex max-w-2xl flex-wrap gap-1.5">
{games.map((id) => {
const game = gameMeta(id);
const on = shownFor(rule, id);
return (
<button
key={id}
type="button"
aria-pressed={on}
onClick={() => onSet(id, !on)}
{...(titleFor !== undefined ? { title: titleFor(id) } : {})}
// `.hue-chip` gives the focus chip's hover off `--hue` and
// `aria-pressed`; off is muted ink, not faint, which was too dim
// to read as a control on the dark ground.
className="hue-chip rounded-full border px-2.5 py-1 text-xs font-medium"
style={{
["--hue" as string]: game.hue,
borderColor: on ? game.hue : `color-mix(in srgb, ${game.hue} 30%, transparent)`,
color: on ? game.hue : "var(--color-muted)",
background: on ? `color-mix(in srgb, ${game.hue} 12%, transparent)` : "transparent",
}}
>
<span className="hue-chip-label">{game.name}</span>
</button>
);
})}
</div>
);
}
/**
* What the visibility group is letting through.
*
+68 -9
View File
@@ -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(/<input type="checkbox"[^>]*>(?=[^<]*<span>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(/<input type="checkbox"[^>]*>(?=[^<]*<span>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(/<button[^>]*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(
<GameMetaProvider value={(id) => metaFor(id, {})}>
<PerGameChips games={["genshin", "hsr"]} rule={{ all: true, off: ["hsr"] }} label="chores" onSet={() => {}} />
</GameMetaProvider>,
);
expect(html).toMatch(/aria-pressed="true"[^>]*><span[^>]*>Genshin Impact/);
expect(html).toMatch(/aria-pressed="false"[^>]*><span[^>]*>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(
<GameMetaProvider value={(id) => metaFor(id, {})}>
<PerGameChips games={["hsr"]} rule={{ all: false }} label="chores" onSet={() => {}} />
</GameMetaProvider>,
);
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"] };