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 —