diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 608bf51..0e46c8d 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -8,7 +8,8 @@ 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"; +import { setFor, shownFor, type PerGame } from "../../shared/perGame.ts"; +import { hiddenAfter, routinesFor, type Routine } from "../../shared/routines.ts"; /** * Which of the two views the reader is looking at. @@ -222,6 +223,13 @@ export interface Prefs { routinesOff?: LaneId[] | undefined; /** Games already asked "track its weekly tasks?", so the question is asked once. */ routinesAsked?: LaneId[] | undefined; + /** + * Built-ins the reader stopped tracking one at a time, by `routine:` id — + * Endfield's weekly items but not its Stock Redistribution. Narrows the game + * rule above and never widens it; absent means none, and their ticks and + * numbers stay stored under the id. + */ + routinesHidden?: string[] | undefined; /** * The newest changelog entry the reader dismissed (PRD F18). Absent means * unrecorded, not "seen nothing": first-run setup sets it, so an absent value @@ -262,6 +270,32 @@ export function routineRule(p: Prefs): PerGame { return { all: p.showRoutines ?? false, on: p.routineGames, off: p.routinesOff }; } +/** + * What tracking one built-in, or stopping, writes (the strip's Edit panel). + * + * Stopping hides that one and nothing else. Tracking one whose game is on + * unhides it. Tracking one whose game is off turns the game on — the same + * answer the strip's question records — with its siblings hidden, because the + * reader asked for one item and not the game's whole list. + */ +export function trackRoutinePatch(p: Prefs, routine: Routine, track: boolean): Partial { + const rule = routineRule(p); + if (!track || shownFor(rule, routine.game)) { + return { routinesHidden: hiddenAfter(p.routinesHidden, routine.id, track) }; + } + const { on, off } = setFor(rule, routine.game, true); + const siblings = routinesFor(routine.game) + .map((r) => r.id) + .filter((id) => id !== routine.id); + const hidden = [...(p.routinesHidden ?? []).filter((id) => id !== routine.id && !siblings.includes(id)), ...siblings]; + return { + routineGames: on, + routinesOff: off, + routinesAsked: [...(p.routinesAsked ?? []).filter((g) => g !== routine.game), routine.game], + routinesHidden: hidden.length > 0 ? hidden : undefined, + }; +} + export function defaults(): Prefs { return { region: guessRegion(), @@ -396,7 +430,7 @@ export function restorePrefsValue(incoming: unknown): Prefs { } // Per-game exceptions and built-in tasks: string arrays, or absent. - for (const key of ["routineGames", "routinesAsked", "routinesOff", "choresOn", "choresOff"] as const) { + for (const key of ["routineGames", "routinesAsked", "routinesOff", "routinesHidden", "choresOn", "choresOff"] as const) { const value: unknown = result[key]; if (value === undefined) continue; result[key] = Array.isArray(value) diff --git a/src/shared/routines.ts b/src/shared/routines.ts index f712dd7..bf19219 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -69,10 +69,34 @@ export function routinesFor(game: string): Routine[] { /** * 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. + * exists, and deleting the copy brings the original back — and less any they + * stopped tracking one by one (`prefs.routinesHidden`). Leave `hidden` out to + * get every built-in the reader could track, which is what settings lists. */ -export function activeRoutines(rule: PerGame, copiedFrom: ReadonlySet): Routine[] { - return ROUTINES.filter((r) => shownFor(rule, r.game) && !copiedFrom.has(r.id)); +export function activeRoutines( + rule: PerGame, + copiedFrom: ReadonlySet, + hidden: ReadonlySet = new Set(), +): Routine[] { + return ROUTINES.filter( + (r) => shownFor(rule, r.game) && !copiedFrom.has(r.id) && !hidden.has(r.id), + ); +} + +/** + * `prefs.routinesHidden` after the reader tracks one built-in or stops. Per + * item and never per game, so it moves nothing the game's switch says, and a + * built-in we ship later for that game still arrives. Absent when empty, like + * the other lists. + */ +export function hiddenAfter( + hidden: readonly string[] | undefined, + id: string, + track: boolean, +): string[] | undefined { + const rest = (hidden ?? []).filter((h) => h !== id); + const next = track ? rest : [...rest, id]; + return next.length > 0 ? next : undefined; } /** diff --git a/test/routines.test.ts b/test/routines.test.ts index 15d26ac..b67fb4c 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -7,8 +7,9 @@ import { routineToAsk, mineFrom, copyRoutineState, + hiddenAfter, } from "../src/shared/routines.ts"; -import { choreRule, defaults, restorePrefsValue, routineRule } from "../src/client/state/usePrefs.ts"; +import { choreRule, defaults, restorePrefsValue, routineRule, trackRoutinePatch } from "../src/client/state/usePrefs.ts"; import { shownFor } from "../src/shared/perGame.ts"; import { taskStatus } from "../src/shared/task.ts"; @@ -37,14 +38,23 @@ describe("ROUTINES", () => { expect(RoutineSchema.safeParse(bare).success).toBe(false); }); - test("the first two, as sourced", () => { - expect(routinesFor("zzz").map((r) => [r.title, r.measure])).toEqual([ - ["Ridu Weekly", { kind: "amount", target: 1300, unit: "points" }], + // Pinned to docs/superpowers/specs/2026-10-14-built-in-routines-design.md: + // a figure that moves here moves there, with the page that says so. + test("each game's, as sourced", () => { + const shape = (game: string): unknown[] => + routinesFor(game).map((r) => [r.title, r.period, r.measure]); + const week = { unit: "week" as const, startsOn: 1 }; + expect(shape("genshin")).toEqual([]); + expect(shape("hsr")).toEqual([]); + expect(shape("zzz")).toEqual([ + ["Ridu Weekly", week, { kind: "amount", target: 1300, unit: "points" }], ]); - expect(routinesFor("endfield").map((r) => [r.title, r.measure])).toEqual([ - ["Weekly Routine", { kind: "amount", target: 10, unit: "points" }], + expect(shape("endfield")).toEqual([ + ["Weekly Routine", week, { kind: "amount", target: 10, unit: "points" }], ]); - expect(routinesFor("genshin")).toEqual([]); + expect(shape("wuwa")).toEqual([]); + expect(shape("nte")).toEqual([]); + expect(shape("nikki")).toEqual([]); }); test("a routine has a status like any task", () => { @@ -57,23 +67,37 @@ describe("ROUTINES", () => { }); }); +const OFFERED = ROUTINES; +const idsOf = (...games: string[]) => + OFFERED.filter((r) => games.includes(r.game)).map((r) => r.id); +const RIDU = "routine:zzz:ridu-weekly"; + describe("which built-ins are on", () => { const off = { all: false }; test("only for games the reader said yes to", () => { 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"]); + expect(activeRoutines({ all: false, on: ["zzz"] }, new Set()).map((r) => r.id)).toEqual(idsOf("zzz")); }); test("one the reader made their own is hidden while the copy exists", () => { - expect(activeRoutines({ all: false, on: ["zzz", "endfield"] }, new Set(["routine:zzz:ridu-weekly"])).map((r) => r.id)) - .toEqual(["routine:endfield:weekly-routine"]); + expect(activeRoutines({ all: false, on: ["zzz", "endfield"] }, new Set([RIDU])).map((r) => r.id)) + .toEqual(idsOf("zzz", "endfield").filter((id) => id !== RIDU)); + }); + + test("one the reader stopped tracking is hidden, and nothing else moves", () => { + // Endfield's weekly items on, one of them not: the game's own answer is + // untouched, so the rest stay and a new built-in for it still arrives. + const rule = { all: false, on: ["zzz", "endfield"] }; + expect(activeRoutines(rule, new Set(), new Set([RIDU])).map((r) => r.id)) + .toEqual(idsOf("zzz", "endfield").filter((id) => id !== RIDU)); + expect(shownFor(rule, "zzz")).toBe(true); }); 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"]); + .toEqual(OFFERED.map((r) => r.id)); expect(activeRoutines({ all: true, off: ["zzz"] }, new Set()).map((r) => r.id)) - .toEqual(["routine:endfield:weekly-routine"]); + .toEqual(OFFERED.filter((r) => r.game !== "zzz").map((r) => r.id)); }); }); @@ -82,7 +106,7 @@ describe("a reader's answers from before the global switch carry over", () => { // 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"]); + expect(activeRoutines(rule, new Set()).map((r) => r.id)).toEqual(idsOf("zzz")); }); test("an install that never answered gets none", () => { @@ -91,7 +115,8 @@ describe("a reader's answers from before the global switch carry over", () => { 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"]); + expect(activeRoutines(rule, new Set()).map((r) => r.id)) + .toEqual(OFFERED.filter((r) => r.game !== "zzz").map((r) => r.id)); }); test("showChores alone still means every game's chore, and choresOff narrows it", () => { @@ -142,6 +167,49 @@ describe("restoring prefs with the new fields", () => { }); }); +describe("tracking one built-in, or not", () => { + test("stopping adds its id once; tracking again removes it; the list is absent when empty", () => { + expect(hiddenAfter(undefined, "routine:zzz:ridu-weekly", false)).toEqual(["routine:zzz:ridu-weekly"]); + expect(hiddenAfter(["routine:zzz:ridu-weekly"], "routine:zzz:ridu-weekly", false)) + .toEqual(["routine:zzz:ridu-weekly"]); + expect(hiddenAfter(["routine:zzz:ridu-weekly", "routine:x:y"], "routine:zzz:ridu-weekly", true)) + .toEqual(["routine:x:y"]); + expect(hiddenAfter(["routine:zzz:ridu-weekly"], "routine:zzz:ridu-weekly", true)).toBeUndefined(); + }); + + test("the pref survives a restore, and junk in it is dropped", () => { + expect(restorePrefsValue({ routinesHidden: ["routine:zzz:ridu-weekly", 4] }).routinesHidden) + .toEqual(["routine:zzz:ridu-weekly"]); + expect(restorePrefsValue({ routinesHidden: "routine:zzz:ridu-weekly" }).routinesHidden).toBeUndefined(); + expect(restorePrefsValue({}).routinesHidden).toBeUndefined(); + }); +}); + +describe("tracking a built-in from the strip", () => { + const ridu = routinesFor("zzz")[0]!; + + test("stopping hides that one and leaves the game's answer alone", () => { + const p = { ...defaults(), routineGames: ["zzz"] }; + expect(trackRoutinePatch(p, ridu, false)).toEqual({ routinesHidden: [ridu.id] }); + }); + + test("tracking one of a game that is on just unhides it", () => { + const p = { ...defaults(), routineGames: ["zzz"], routinesHidden: [ridu.id] }; + expect(trackRoutinePatch(p, ridu, true)).toEqual({ routinesHidden: undefined }); + }); + + test("tracking one of a game that is off turns the game on with only that one", () => { + // The reader asked for one item, not the game's whole list: its siblings + // arrive hidden, and the game counts as asked so the strip does not ask again. + const p = { ...defaults(), showRoutines: false }; + const patch = trackRoutinePatch(p, ridu, true); + const after = { ...p, ...patch }; + const shown = activeRoutines(routineRule(after), new Set(), new Set(after.routinesHidden ?? [])); + expect(shown.map((r) => r.id)).toEqual([ridu.id]); + expect(after.routinesAsked).toContain("zzz"); + }); +}); + describe("making a built-in your own", () => { test("copies its shape and remembers where it came from", () => { const ridu = routinesFor("zzz")[0]!;