feat(routines): let a reader stop tracking one built-in
A new pref, routinesHidden, lists built-ins stopped one at a time by id. It narrows the game's answer and never widens it, so stopping one changes nothing else, and a built-in shipped later still arrives. Tracking one of a game that is off turns the game on with that item alone (trackRoutinePatch). Absent means none; ticks and numbers stay under the id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2c6c6859f0
commit
612e0a23e4
@@ -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<Prefs> {
|
||||
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)
|
||||
|
||||
+27
-3
@@ -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<string>): Routine[] {
|
||||
return ROUTINES.filter((r) => shownFor(rule, r.game) && !copiedFrom.has(r.id));
|
||||
export function activeRoutines(
|
||||
rule: PerGame,
|
||||
copiedFrom: ReadonlySet<string>,
|
||||
hidden: ReadonlySet<string> = 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+82
-14
@@ -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]!;
|
||||
|
||||
Reference in New Issue
Block a user