From bfd79c295ebfb58d32358c7f992b41e7e16519b6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 14:07:31 +0200 Subject: [PATCH] feat(routines): record which games' built-ins are on, and who was asked Both prefs are absent unless the reader answers, so built-ins are opt-in for every install, old ones included. The question is asked only about a game on screen, and only once. A built-in the reader made their own stays hidden while the copy exists. Co-Authored-By: Claude Opus 5.5 --- src/client/state/usePrefs.ts | 17 ++++++++++++++++ src/shared/routines.ts | 32 +++++++++++++++++++++++++++++ test/routines.test.ts | 39 +++++++++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/client/state/usePrefs.ts b/src/client/state/usePrefs.ts index 5eec284..44a9555 100644 --- a/src/client/state/usePrefs.ts +++ b/src/client/state/usePrefs.ts @@ -184,6 +184,14 @@ export interface Prefs { regionConfirmed: boolean; /** 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. + */ + routineGames?: LaneId[] | undefined; + /** Games already asked "track its weekly tasks?", so the question is asked once. */ + routinesAsked?: LaneId[] | undefined; } export function defaults(): Prefs { @@ -313,6 +321,15 @@ export function restorePrefsValue(incoming: unknown): Prefs { } } + // Built-in tasks: string arrays, or absent. + for (const key of ["routineGames", "routinesAsked"] as const) { + const value: unknown = result[key]; + if (value === undefined) continue; + result[key] = Array.isArray(value) + ? value.filter((g): g is LaneId => typeof g === "string") + : undefined; + } + // focusGame if (result.focusGame !== null && typeof result.focusGame !== "string") { result.focusGame = null; diff --git a/src/shared/routines.ts b/src/shared/routines.ts index 2492d67..f0143aa 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -63,3 +63,35 @@ export const ROUTINES: readonly Routine[] = [ export function routinesFor(game: string): Routine[] { return ROUTINES.filter((r) => r.game === game); } + +/** + * 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 + * 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)); +} + +/** + * The one game to ask "track its weekly tasks?" about, or null. + * + * 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`. + */ +export function routineToAsk( + visible: readonly string[], + routineGames: readonly string[] | undefined, + asked: readonly string[] | undefined, +): string | null { + for (const game of visible) { + if (routineGames?.includes(game) || asked?.includes(game)) continue; + if (routinesFor(game).length > 0) return game; + } + return null; +} diff --git a/test/routines.test.ts b/test/routines.test.ts index b9e632c..1c91642 100644 --- a/test/routines.test.ts +++ b/test/routines.test.ts @@ -1,5 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { ROUTINES, RoutineSchema, routinesFor } from "../src/shared/routines.ts"; +import { + activeRoutines, + ROUTINES, + RoutineSchema, + routinesFor, + routineToAsk, +} from "../src/shared/routines.ts"; +import { restorePrefsValue } from "../src/client/state/usePrefs.ts"; import { taskStatus } from "../src/shared/task.ts"; /** @@ -46,3 +53,33 @@ describe("ROUTINES", () => { expect(s).toMatchObject({ scope: "week", count: 900, required: 1300 }); }); }); + +describe("which built-ins are on", () => { + 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"]); + }); + + 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)) + .toEqual(["routine:endfield:weekly-routine"]); + }); +}); + +describe("which game to ask about", () => { + 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(); + }); +}); + +describe("restoring prefs with the new fields", () => { + test("junk in the arrays is sanitised, and absent stays absent", () => { + const r = restorePrefsValue({ routineGames: ["zzz", 7], routinesAsked: "zzz" }); + expect(r.routineGames).toEqual(["zzz"]); + expect(r.routinesAsked).toBeUndefined(); + expect(restorePrefsValue({}).routineGames).toBeUndefined(); + }); +});