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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 14:07:31 +02:00
co-authored by Claude Opus 5.5
parent 8aa820e2c0
commit bfd79c295e
3 changed files with 87 additions and 1 deletions
+17
View File
@@ -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;
+32
View File
@@ -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<string>,
): 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;
}
+38 -1
View File
@@ -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();
});
});