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 <noreply@anthropic.com>
This commit is contained in:
1 parent
47c395379b
commit
a39c6a8398
2 files changed
+237
-44
No files matched your search
@@ -5,8 +5,10 @@ import { REGION_LABEL } from "../../shared/time.ts";
|
|||||||
import { useGameMeta } from "../state/gameMeta.tsx";
|
import { useGameMeta } from "../state/gameMeta.tsx";
|
||||||
import { moveGame } from "../state/gameOrder.ts";
|
import { moveGame } from "../state/gameOrder.ts";
|
||||||
import type { ThemeChoice } from "../state/theme.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 { 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";
|
import { asOfLabel, routinesFor } from "../../shared/routines.ts";
|
||||||
|
|
||||||
const REGIONS: Array<{ id: Region; label: string }> = (
|
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. */
|
/** Everything the reader entered themselves, and the ways to change it. */
|
||||||
own: React.ComponentProps<typeof YourOwn>;
|
own: React.ComponentProps<typeof YourOwn>;
|
||||||
}) {
|
}) {
|
||||||
const gameMeta = useGameMeta();
|
|
||||||
const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length;
|
const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length;
|
||||||
const ownGames = Object.keys(own.games).length;
|
const ownGames = Object.keys(own.games).length;
|
||||||
const ownEvents = Object.keys(own.events).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
|
// 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.
|
// turned on — so a switch is offered only with something behind it.
|
||||||
const periods = [
|
const periods = [
|
||||||
...(own.tasks ?? []).map((t) => t.period.unit),
|
...(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") };
|
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
|
"Commissions, resin" — and they were the one part of the strip
|
||||||
with no way out. Named for what it removes rather than for
|
with no way out. Named for what it removes rather than for
|
||||||
"dailies", which would read as broken while the strip stayed on
|
"dailies", which would read as broken while the strip stayed on
|
||||||
screen showing the reader's own events. */}
|
screen showing the reader's own events. One answer for every
|
||||||
<Check
|
game, and the games that differ behind *Choose games*. */}
|
||||||
|
<PerGameSwitch
|
||||||
checked={prefs.showChores}
|
checked={prefs.showChores}
|
||||||
onChange={(showChores) => onUpdate({ showChores })}
|
onChange={(showChores) => onUpdate({ showChores })}
|
||||||
label="Show each game's own daily chores"
|
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."
|
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
|
{/* Where weekly and monthly tasks sit: their own row under the
|
||||||
dailies. Only offered once there is a task of that period — a
|
dailies. Only offered once there is a task of that period — a
|
||||||
switch with nothing behind it is a control that does nothing. */}
|
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 && (
|
{ignoredCount > 0 && (
|
||||||
<Check
|
<Check
|
||||||
checked={prefs.showIgnored}
|
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"}`;
|
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.
|
* What the visibility group is letting through.
|
||||||
*
|
*
|
||||||
|
|||||||
+68
-9
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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 { GameMetaProvider } from "../src/client/state/gameMeta.tsx";
|
||||||
import { metaFor } from "../src/shared/games.ts";
|
import { metaFor } from "../src/shared/games.ts";
|
||||||
import type { Prefs } from "../src/client/state/usePrefs.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", () => {
|
describe("Controls: built-in weekly tasks", () => {
|
||||||
test("a switch per game that has them, reflecting the reader's answer", () => {
|
test("one global switch, not a checkbox per game", () => {
|
||||||
const off = render(PREFS, 0, ["genshin", "zzz"]);
|
const html = render(PREFS, 0, ["genshin", "zzz", "endfield"]);
|
||||||
expect(off).toContain("Built-in weekly tasks for Zenless Zone Zero");
|
expect(html).toContain("Show built-in weekly tasks");
|
||||||
expect(off).not.toContain("for Genshin Impact");
|
expect(html).not.toContain("Built-in weekly tasks for");
|
||||||
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("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", () => {
|
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", () => {
|
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", () => {
|
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.
|
// ZZZ's built-ins are weekly; there is no monthly task, so no monthly switch.
|
||||||
const prefs = { ...PREFS, routineGames: ["zzz"] };
|
const prefs = { ...PREFS, routineGames: ["zzz"] };
|
||||||
|
|||||||
Reference in new issue
Block a user