From 47c395379b471f5efc585b90286c98c4b12c4bd4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 02:20:18 +0200 Subject: [PATCH] feat(prefs): switch chores and built-in tasks per game, on one rule Chores had one global switch and built-ins only a per-game opt-in, so the two answered the same question two different ways. perGame.ts is now the one rule for both: a global answer, then the games that differ. Nothing is migrated. routineGames keeps its meaning as the built-ins' on-list and an absent showRoutines still reads as off, so every reader's answers, and an older cached tab, see what they saw before. The strip stops asking about one game once the reader has answered for all. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 23 +++++--- src/client/components/Dailies.tsx | 10 ++-- src/client/state/usePrefs.ts | 41 ++++++++++++-- src/shared/perGame.ts | 55 +++++++++++++++++++ src/shared/routines.ts | 25 ++++----- test/per-game.test.ts | 89 +++++++++++++++++++++++++++++++ test/routines.test.ts | 72 ++++++++++++++++++++++--- test/views.test.tsx | 11 ++++ 8 files changed, 289 insertions(+), 37 deletions(-) create mode 100644 src/shared/perGame.ts create mode 100644 test/per-game.test.ts diff --git a/src/client/App.tsx b/src/client/App.tsx index 549ad79..a63d024 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -19,7 +19,7 @@ import { useProgress } from "./state/useProgress.ts"; import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts"; import { useTasks } from "./state/useTasks.ts"; import { useAmounts } from "./state/useAmounts.ts"; -import { adoptNewLanes, firstRunPrefs, usePrefs, type Prefs } from "./state/usePrefs.ts"; +import { adoptNewLanes, choreRule, firstRunPrefs, routineRule, usePrefs, type Prefs } from "./state/usePrefs.ts"; import { buildExportData, parseImportData, triggerDownload } from "./state/export.ts"; import { snapDayWidth } from "./state/zoom.ts"; import { useCustom, type EventDraft } from "./state/useCustom.ts"; @@ -51,6 +51,7 @@ import { routineToAsk, type Routine, } from "../shared/routines.ts"; +import { setFor, shownFor } from "../shared/perGame.ts"; import { ENTRIES, NOTES, newestId, unseen } from "../shared/changelog.ts"; import { ChangelogSheet } from "./components/ChangelogSheet.tsx"; import { WhatsNew } from "./components/WhatsNew.tsx"; @@ -220,7 +221,7 @@ export function App() { const copiedFrom = new Set( tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])), ); - const routines = activeRoutines(prefs.routineGames, copiedFrom); + const routines = activeRoutines(routineRule(prefs), copiedFrom); const stripTasks = [...tasks.tasks, ...routines]; /** @@ -661,7 +662,7 @@ export function App() { the lanes out here would also cost a reader's own game its place in the order their repeating events are grouped under. */} shownFor(choreRule(prefs), lane)} games={focus === null ? enabled : [focus]} events={todo.filter(repeatsDaily).map((r) => r.event)} region={prefs.region} @@ -679,20 +680,26 @@ export function App() { edit={stripEditor} taskStatusFor={taskStatusOf} ask={(() => { + const rule = routineRule(prefs); const game = routineToAsk( focus === null ? enabled : [focus], - prefs.routineGames, + rule, prefs.routinesAsked, + prefs.showRoutines !== undefined, ); if (game === null) return undefined; return { game, - // Either answer is recorded, so the question is asked once. - onAnswer: (yes: boolean) => + // Either answer is recorded, so the question is asked once. Yes + // is the same per-game answer the settings chip gives — with + // the global switch unanswered, that lands in `routineGames`. + onAnswer: (yes: boolean) => { + const lists = setFor(rule, game, true); update({ routinesAsked: [...(prefs.routinesAsked ?? []), game], - ...(yes ? { routineGames: [...(prefs.routineGames ?? []), game] } : {}), - }), + ...(yes ? { routineGames: lists.on, routinesOff: lists.off } : {}), + }); + }, }; })()} onSetAmount={amounts.setAmount} diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index f03b66a..2db539a 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -116,7 +116,7 @@ export function dailyGroups( region: Region, meta: (id: LaneId) => GameMeta, startOf: (event: DisplayEvent) => number, - showChores = true, + showChores: boolean | ((lane: LaneId) => boolean) = true, goalFor: (id: string) => GoalStatus | undefined = () => undefined, tasks: readonly StripTask[] = [], taskStatusFor: (task: StripTask) => GoalStatus | undefined = () => undefined, @@ -143,7 +143,9 @@ export function dailyGroups( // or writes its ticks here, so `dailies:` keeps every day the reader // ever logged and switching back on restores the lot. A game the reader // invented never had one to begin with. - if (showChores && !isCustomGameId(lane)) { + // Per game where the reader has said so (`choreRule`, perGame.ts). + const chore = typeof showChores === "function" ? showChores(lane) : showChores; + if (chore && !isCustomGameId(lane)) { items.push({ key: dailiesId(lane as GameId), game: lane, @@ -315,8 +317,8 @@ export function Dailies({ */ events: DisplayEvent[]; region: Region; - /** Whether to carry each game's standing chore — `prefs.showChores`. */ - showChores: boolean; + /** Whether to carry a game's standing chore — `choreRule(prefs)`, or one answer for all. */ + showChores: boolean | ((lane: LaneId) => boolean); now: number; daysFor: (id: string) => string[]; onToggleDay: (id: string, day: string) => void; diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 126d354..608bf51 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -8,6 +8,7 @@ import type { TimelineGroup } from "./lanes.ts"; import { DEFAULT_THEME_CHOICE, type ThemeChoice } from "./theme.ts"; import { DEFAULT_DAY_WIDTH, snapDayWidth } from "./zoom.ts"; import { ENTRIES, newestId } from "../../shared/changelog.ts"; +import type { PerGame } from "../../shared/perGame.ts"; /** * Which of the two views the reader is looking at. @@ -168,6 +169,13 @@ export interface Prefs { * here, so switching back on restores every one of them. */ showChores: boolean; + /** + * Games whose chore differs from `showChores` — shown although it is off, or + * hidden although it is on (`src/shared/perGame.ts`). Absent means none, so + * every install from before these existed reads exactly as it did. + */ + choresOn?: LaneId[] | undefined; + choresOff?: LaneId[] | undefined; /** * Whether weekly tasks — the reader's own and any built-ins they turned on — * sit in the strip's row under the dailies. `showMonthlyTasks` is the same @@ -198,11 +206,20 @@ export interface Prefs { /** False until the reader has picked their games on first run. */ onboarded: boolean; /** - * Games whose built-in weekly tasks the reader turned on (routines.ts, PRD - * F17). Absent means none: built-ins are opt-in, so an install from before - * they existed gets none rather than all. + * The global answer for built-in weekly tasks (routines.ts, PRD F17). Absent + * means *never answered*, read as off — built-ins are opt-in — and is what + * lets the strip still ask about one game at a time. + */ + showRoutines?: boolean | undefined; + /** + * Games whose built-in weekly tasks the reader turned on — the on-exceptions + * to `showRoutines`, and the same list it was before the global answer + * existed, so nothing was migrated. Absent means none: built-ins are opt-in, + * so an install from before they existed gets none rather than all. */ routineGames?: LaneId[] | undefined; + /** Games whose built-ins stay off although `showRoutines` is on. */ + routinesOff?: LaneId[] | undefined; /** Games already asked "track its weekly tasks?", so the question is asked once. */ routinesAsked?: LaneId[] | undefined; /** @@ -235,6 +252,16 @@ export function firstRunPrefs( }; } +/** Whose standing chore the strip carries. */ +export function choreRule(p: Prefs): PerGame { + return { all: p.showChores, on: p.choresOn, off: p.choresOff }; +} + +/** Whose built-in weekly tasks are on. An unanswered global switch is off. */ +export function routineRule(p: Prefs): PerGame { + return { all: p.showRoutines ?? false, on: p.routineGames, off: p.routinesOff }; +} + export function defaults(): Prefs { return { region: guessRegion(), @@ -368,8 +395,8 @@ export function restorePrefsValue(incoming: unknown): Prefs { result.changelogSeen = undefined; } - // Built-in tasks: string arrays, or absent. - for (const key of ["routineGames", "routinesAsked"] as const) { + // Per-game exceptions and built-in tasks: string arrays, or absent. + for (const key of ["routineGames", "routinesAsked", "routinesOff", "choresOn", "choresOff"] as const) { const value: unknown = result[key]; if (value === undefined) continue; result[key] = Array.isArray(value) @@ -377,6 +404,10 @@ export function restorePrefsValue(incoming: unknown): Prefs { : undefined; } + if (result.showRoutines !== undefined && typeof result.showRoutines !== "boolean") { + result.showRoutines = undefined; + } + // focusGame if (result.focusGame !== null && typeof result.focusGame !== "string") { result.focusGame = null; diff --git a/src/shared/perGame.ts b/src/shared/perGame.ts new file mode 100644 index 0000000..dda7b20 --- /dev/null +++ b/src/shared/perGame.ts @@ -0,0 +1,55 @@ +/** + * Whether the app shows something it supplies on a game's behalf — a daily + * chore, a built-in weekly task — for one game. + * + * One global answer, and the games that differ from it. Two lists rather than + * one so an exception never changes meaning: a game on `off` is off whatever + * `all` says, and flipping `all` rewrites nothing else. That is also what lets + * `prefs.routineGames` — a list of games turned *on* since before this existed — + * be the built-ins' `on` list unchanged, with no migration and no reader's + * answer moved. + */ +export interface PerGame { + all: boolean; + on?: readonly string[] | undefined; + off?: readonly string[] | undefined; +} + +/** The answer for one game. A game in both lists can only come from hand-edited storage; it is shown. */ +export function shownFor(rule: PerGame, game: string): boolean { + if (rule.on?.includes(game)) return true; + if (rule.off?.includes(game)) return false; + return rule.all; +} + +/** + * The lists after the reader answers for one game. An exception is a + * difference, not a restatement: an answer matching the global one clears the + * game from both lists rather than recording it. + */ +export function setFor( + rule: PerGame, + game: string, + value: boolean, +): { on: string[]; off: string[] } { + const on = (rule.on ?? []).filter((g) => g !== game); + const off = (rule.off ?? []).filter((g) => g !== game); + if (value !== rule.all) (value ? on : off).push(game); + return { on, off }; +} + +/** + * The rule as one short line — `Every game`, `All but Genshin Impact`, + * `Only 3 games` — counting only the games offered, so it never reports an + * exception the reader cannot see beside it. + */ +export function exceptionLine( + rule: PerGame, + games: readonly string[], + name: (game: string) => string, +): string { + const odd = games.filter((g) => shownFor(rule, g) !== rule.all); + if (odd.length === 0) return rule.all ? "Every game" : "No game"; + const which = odd.length === 1 ? name(odd[0]!) : `${odd.length} games`; + return rule.all ? `All but ${which}` : `Only ${which}`; +} diff --git a/src/shared/routines.ts b/src/shared/routines.ts index f49f044..f712dd7 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { PeriodSchema } from "./period.ts"; import { GameId } from "./schema.ts"; +import { shownFor, type PerGame } from "./perGame.ts"; import { MeasureSchema, type TaskShape } from "./task.ts"; /** @@ -13,7 +14,8 @@ import { MeasureSchema, type TaskShape } from "./task.ts"; * then visibly ten months old rather than presented as current. Nothing here is * inferred; add one only when a page states it. * - * Off unless the reader says yes, per game (`prefs.routineGames`). Its id is in + * Off unless the reader says yes — for every game (`prefs.showRoutines`) or for + * one (`prefs.routineGames`, `prefs.routinesOff`; see `perGame.ts`). Its id is in * the reserved `routine:` space, and its ticks and numbers are stored under that * id exactly as a reader's task's are. */ @@ -65,16 +67,12 @@ export function routinesFor(game: string): Routine[] { } /** - * The built-ins on screen: those of games the reader said yes to, less any - * they made their own (`Task.from`) — the copy replaces the original while it + * The built-ins on screen: those of games the rule shows, less any the reader + * made their own (`Task.from`) — the copy replaces the original while it * exists, and deleting the copy brings the original back. */ -export function activeRoutines( - routineGames: readonly string[] | undefined, - copiedFrom: ReadonlySet, -): Routine[] { - const on = new Set(routineGames ?? []); - return ROUTINES.filter((r) => on.has(r.game) && !copiedFrom.has(r.id)); +export function activeRoutines(rule: PerGame, copiedFrom: ReadonlySet): Routine[] { + return ROUTINES.filter((r) => shownFor(rule, r.game) && !copiedFrom.has(r.id)); } /** @@ -82,15 +80,18 @@ export function activeRoutines( * * Only a game on screen — asking about a game the reader switched off or is not * focused on would be asking about something they are not looking at — and - * only once: an answer either way is recorded in `asked`. + * only once: an answer either way is recorded in `asked`. Never once the reader + * has `answered` the global switch: that answer covers every game at once. */ export function routineToAsk( visible: readonly string[], - routineGames: readonly string[] | undefined, + rule: PerGame, asked: readonly string[] | undefined, + answered: boolean, ): string | null { + if (answered) return null; for (const game of visible) { - if (routineGames?.includes(game) || asked?.includes(game)) continue; + if (shownFor(rule, game) || asked?.includes(game)) continue; if (routinesFor(game).length > 0) return game; } return null; diff --git a/test/per-game.test.ts b/test/per-game.test.ts new file mode 100644 index 0000000..a745d6f --- /dev/null +++ b/test/per-game.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { exceptionLine, setFor, shownFor } from "../src/shared/perGame.ts"; + +/** + * One rule for "the app supplies this for a game": a global answer, and the + * games that differ from it. Chores and built-in weekly tasks both read it, so + * a reader's per-game answer means the same thing in both places. + */ +describe("shownFor", () => { + test("with no exceptions, every game takes the global answer", () => { + expect(shownFor({ all: true }, "genshin")).toBe(true); + expect(shownFor({ all: false }, "genshin")).toBe(false); + }); + + test("an exception overrides the global answer for its game only", () => { + expect(shownFor({ all: false, on: ["zzz"] }, "zzz")).toBe(true); + expect(shownFor({ all: false, on: ["zzz"] }, "genshin")).toBe(false); + expect(shownFor({ all: true, off: ["hsr"] }, "hsr")).toBe(false); + expect(shownFor({ all: true, off: ["hsr"] }, "genshin")).toBe(true); + }); + + test("an exception survives the global answer flipping", () => { + // Flipping the switch rewrites nothing else; a game the reader singled out + // stays singled out. + expect(shownFor({ all: true, on: ["zzz"] }, "zzz")).toBe(true); + expect(shownFor({ all: false, off: ["hsr"] }, "hsr")).toBe(false); + }); + + test("a game in both lists — hand-edited storage — is shown", () => { + expect(shownFor({ all: false, on: ["zzz"], off: ["zzz"] }, "zzz")).toBe(true); + expect(shownFor({ all: true, on: ["zzz"], off: ["zzz"] }, "zzz")).toBe(true); + }); +}); + +describe("setFor", () => { + test("an answer that differs from the global one is recorded as an exception", () => { + expect(setFor({ all: false }, "zzz", true)).toEqual({ on: ["zzz"], off: [] }); + expect(setFor({ all: true }, "hsr", false)).toEqual({ on: [], off: ["hsr"] }); + }); + + test("an answer that matches the global one clears the exception", () => { + expect(setFor({ all: false, on: ["zzz", "endfield"] }, "zzz", false)).toEqual({ + on: ["endfield"], + off: [], + }); + expect(setFor({ all: true, off: ["hsr"] }, "hsr", true)).toEqual({ on: [], off: [] }); + }); + + test("a game never ends up in both lists", () => { + expect(setFor({ all: true, on: ["zzz"], off: ["zzz"] }, "zzz", false)).toEqual({ + on: [], + off: ["zzz"], + }); + }); + + test("other games' exceptions are left where they were", () => { + expect(setFor({ all: true, off: ["hsr"] }, "genshin", false)).toEqual({ + on: [], + off: ["hsr", "genshin"], + }); + }); +}); + +describe("exceptionLine", () => { + const name = (g: string) => ({ genshin: "Genshin Impact", zzz: "Zenless Zone Zero" })[g] ?? g; + const games = ["genshin", "hsr", "zzz", "endfield"]; + + test("on for everyone, or off for everyone", () => { + expect(exceptionLine({ all: true }, games, name)).toBe("Every game"); + expect(exceptionLine({ all: false }, games, name)).toBe("No game"); + }); + + test("one exception is named", () => { + expect(exceptionLine({ all: true, off: ["genshin"] }, games, name)).toBe("All but Genshin Impact"); + expect(exceptionLine({ all: false, on: ["zzz"] }, games, name)).toBe("Only Zenless Zone Zero"); + }); + + test("several are counted", () => { + expect(exceptionLine({ all: true, off: ["genshin", "hsr", "zzz"] }, games, name)).toBe( + "All but 3 games", + ); + expect(exceptionLine({ all: false, on: ["genshin", "zzz"] }, games, name)).toBe("Only 2 games"); + }); + + test("only the games listed count — an exception agreeing with the global answer is none", () => { + expect(exceptionLine({ all: true, off: ["fgo"] }, games, name)).toBe("Every game"); + expect(exceptionLine({ all: true, on: ["zzz"] }, games, name)).toBe("Every game"); + }); +}); diff --git a/test/routines.test.ts b/test/routines.test.ts index 5c8fdbe..15d26ac 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -8,7 +8,8 @@ import { mineFrom, copyRoutineState, } from "../src/shared/routines.ts"; -import { restorePrefsValue } from "../src/client/state/usePrefs.ts"; +import { choreRule, defaults, restorePrefsValue, routineRule } from "../src/client/state/usePrefs.ts"; +import { shownFor } from "../src/shared/perGame.ts"; import { taskStatus } from "../src/shared/task.ts"; /** @@ -57,23 +58,65 @@ describe("ROUTINES", () => { }); describe("which built-ins are on", () => { + const off = { all: false }; test("only for games the reader said yes to", () => { - expect(activeRoutines(undefined, new Set()).map((r) => r.id)).toEqual([]); - expect(activeRoutines(["zzz"], new Set()).map((r) => r.id)).toEqual(["routine:zzz:ridu-weekly"]); + expect(activeRoutines(off, new Set()).map((r) => r.id)).toEqual([]); + expect(activeRoutines({ all: false, on: ["zzz"] }, new Set()).map((r) => r.id)).toEqual(["routine:zzz:ridu-weekly"]); }); test("one the reader made their own is hidden while the copy exists", () => { - expect(activeRoutines(["zzz", "endfield"], new Set(["routine:zzz:ridu-weekly"])).map((r) => r.id)) + expect(activeRoutines({ all: false, on: ["zzz", "endfield"] }, new Set(["routine:zzz:ridu-weekly"])).map((r) => r.id)) + .toEqual(["routine:endfield:weekly-routine"]); + }); + + test("switched on globally, every game's — less the ones switched off", () => { + expect(activeRoutines({ all: true }, new Set()).map((r) => r.id)) + .toEqual(["routine:zzz:ridu-weekly", "routine:endfield:weekly-routine"]); + expect(activeRoutines({ all: true, off: ["zzz"] }, new Set()).map((r) => r.id)) .toEqual(["routine:endfield:weekly-routine"]); }); }); +describe("a reader's answers from before the global switch carry over", () => { + // Nothing is migrated: `routineGames` is the built-ins' on-list as it stands, + // and an absent `showRoutines` is the opt-in it always was. + test("routineGames alone still means exactly those games", () => { + const rule = routineRule({ ...defaults(), routineGames: ["zzz"] }); + expect(activeRoutines(rule, new Set()).map((r) => r.id)).toEqual(["routine:zzz:ridu-weekly"]); + }); + + test("an install that never answered gets none", () => { + expect(activeRoutines(routineRule(defaults()), new Set())).toEqual([]); + }); + + test("showRoutines and routinesOff are read", () => { + const rule = routineRule({ ...defaults(), showRoutines: true, routinesOff: ["zzz"] }); + expect(activeRoutines(rule, new Set()).map((r) => r.id)).toEqual(["routine:endfield:weekly-routine"]); + }); + + test("showChores alone still means every game's chore, and choresOff narrows it", () => { + expect(shownFor(choreRule(defaults()), "genshin")).toBe(true); + expect(shownFor(choreRule({ ...defaults(), showChores: false }), "genshin")).toBe(false); + const rule = choreRule({ ...defaults(), choresOff: ["hsr"] }); + expect([shownFor(rule, "genshin"), shownFor(rule, "hsr")]).toEqual([true, false]); + expect(shownFor(choreRule({ ...defaults(), showChores: false, choresOn: ["hsr"] }), "hsr")).toBe(true); + }); +}); + describe("which game to ask about", () => { + const off = { all: false }; test("the first game on screen with built-ins, not asked and not on", () => { - expect(routineToAsk(["genshin", "zzz", "endfield"], undefined, undefined)).toBe("zzz"); - expect(routineToAsk(["genshin", "zzz", "endfield"], undefined, ["zzz"])).toBe("endfield"); - expect(routineToAsk(["zzz"], ["zzz"], undefined)).toBeNull(); - expect(routineToAsk(["genshin"], undefined, undefined)).toBeNull(); + expect(routineToAsk(["genshin", "zzz", "endfield"], off, undefined, false)).toBe("zzz"); + expect(routineToAsk(["genshin", "zzz", "endfield"], off, ["zzz"], false)).toBe("endfield"); + expect(routineToAsk(["zzz"], { all: false, on: ["zzz"] }, undefined, false)).toBeNull(); + expect(routineToAsk(["genshin"], off, undefined, false)).toBeNull(); + }); + + test("never once the reader has answered the global switch", () => { + // Either answer covers every game at once, so asking about one is asking + // a question they already answered. + expect(routineToAsk(["zzz"], off, undefined, true)).toBeNull(); + expect(routineToAsk(["zzz"], { all: true, off: ["zzz"] }, undefined, true)).toBeNull(); }); }); @@ -84,6 +127,19 @@ describe("restoring prefs with the new fields", () => { expect(r.routinesAsked).toBeUndefined(); expect(restorePrefsValue({}).routineGames).toBeUndefined(); }); + + test("the exception lists and the global built-ins answer are sanitised too", () => { + const r = restorePrefsValue({ choresOn: ["hsr", null], choresOff: "x", routinesOff: [3, "zzz"], showRoutines: 1 }); + expect(r.choresOn).toEqual(["hsr"]); + expect(r.choresOff).toBeUndefined(); + expect(r.routinesOff).toEqual(["zzz"]); + expect(r.showRoutines).toBeUndefined(); + expect(restorePrefsValue({ showRoutines: false }).showRoutines).toBe(false); + const empty = restorePrefsValue({}); + expect([empty.choresOn, empty.choresOff, empty.routinesOff, empty.showRoutines]).toEqual([ + undefined, undefined, undefined, undefined, + ]); + }); }); describe("making a built-in your own", () => { diff --git a/test/views.test.tsx b/test/views.test.tsx index b1764ab..86cf9f0 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -1106,6 +1106,17 @@ describe("dailyGroups with the chores switched off", () => { }); }); +describe("dailyGroups with the chores switched per game", () => { + const NOW = Date.parse("2026-08-17T12:00:00.000Z"); + const meta = (id: string) => metaFor(id, {}); + const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt); + + test("a game the rule hides loses its chore, and its neighbour keeps one", () => { + const groups = dailyGroups(["genshin", "hsr"], [], NOW, "europe", meta, startOf, (lane) => lane === "genshin"); + expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([["dailies:genshin"]]); + }); +}); + describe("the chores toggle reaches the screen", () => { // `dailyGroups` is pure and well covered, but nothing tied `prefs.showChores` // to what renders. Review proved four plausible breaks shipped green —