fix(routines): test the copy, name what Yes agrees to, and true up the docs

Make it mine's copying was inline in App and untested; it is now a pure
function with a test, so a change to either store can't make the copy
silently arrive empty. The question's buttons now say what they answer
when heard out of context. The prefs block and the spec now match what
shipped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-13 14:19:16 +02:00
co-authored by Claude Opus 5.5
parent f7ba86e6e5
commit 1c7df637a4
7 changed files with 65 additions and 10 deletions
+5 -1
View File
@@ -222,7 +222,11 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
// timelineDayWidth, timelineGroup, showUpcoming,
// timelineSplitUpcoming, detectDaily, showChores,
// showCompleted, showIgnored, theme, regionConfirmed,
// onboarded }
// onboarded, routineGames?, routinesAsked? }
// routineGames is the games whose built-in weekly tasks
// the reader turned on; routinesAsked the games already
// asked about. Both absent means none — built-ins are
// opt-in for every install. PRD F17.
// timelineDayWidth is px per day on the board, stored as the
// measurement rather than a step number and read through
// snapDayWidth — so a value from an older ladder still opens
@@ -133,12 +133,14 @@ Monday; monthly to the 1st.
number is visibly dated rather than presented as current.
- **Asking.** When a game with built-ins becomes visible — first-run picker, or switched on
in settings — and is not in `routinesAsked`, a one-line card at the top of the dailies
strip asks: `Track Zenless Zone Zero's weekly and monthly tasks? Yes · No thanks`. Either
strip asks: `Track Zenless Zone Zero's built-in weekly tasks? Yes · No thanks`. Either
answer records the game in `routinesAsked`; Yes also adds it to `routineGames`. Never a
modal, and never asked twice.
- **Toggle.** The games settings group shows a “Weekly tasks” switch on each game that has
built-ins, writing `routineGames`. Off hides the chips; their ticks and amounts stay.
- **Make it mine.** From a built-in chip's editor or the settings list: copies the template
- **Toggle.** *What you see* shows a “Built-in weekly tasks for <game>” switch, beside the daily
chores switch, for each game that has built-ins, writing `routineGames` (and answering the
strip's question). Off hides the chips; their ticks and amounts stay. Each built-in shows the
date its source gives (`asOf`), not the date we read it.
- **Make it mine.** From the settings list: copies the template
into a `mytask:` record with `from` set, and copies the built-in's ticks and amounts to
the new id (additive; the originals stay). A built-in whose id is some task's `from` is
hidden; deleting that copy brings it back.
+10 -5
View File
@@ -35,7 +35,13 @@ import { clockFor, formatRemaining } from "../shared/time.ts";
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { taskStatus, type TaskShape } from "../shared/task.ts";
import { activeRoutines, mineFrom, routineToAsk, type Routine } from "../shared/routines.ts";
import {
activeRoutines,
copyRoutineState,
mineFrom,
routineToAsk,
type Routine,
} from "../shared/routines.ts";
import { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
@@ -212,10 +218,9 @@ export function App() {
const makeItMine = (routine: Routine) => {
if (tasks.tasks.some((t) => t.from === routine.id)) return;
const id = tasks.addTask(mineFrom(routine));
const log = daily.logs[routine.id];
if (log !== undefined) daily.merge({ [id]: log });
const numbers = amounts.amounts[routine.id];
if (numbers !== undefined) amounts.merge({ [id]: numbers });
const copy = copyRoutineState(daily.logs, amounts.amounts, routine.id, id);
daily.merge(copy.daily);
amounts.merge(copy.amounts);
};
const taskStatusOf = (task: TaskShape): GoalStatus =>
+2
View File
@@ -384,6 +384,7 @@ export function Dailies({
<button
type="button"
onClick={() => ask.onAnswer(true)}
aria-label={`Yes, track ${gameMeta(ask.game).name}'s built-in weekly tasks`}
className="font-medium text-near transition-colors hover:text-ink"
>
Yes
@@ -391,6 +392,7 @@ export function Dailies({
<button
type="button"
onClick={() => ask.onAnswer(false)}
aria-label={`No thanks to ${gameMeta(ask.game).name}'s built-in weekly tasks`}
className="text-faint transition-colors hover:text-muted"
>
No thanks
+20
View File
@@ -123,3 +123,23 @@ export function mineFrom(routine: Routine): {
from: routine.id,
};
}
/**
* What *Make it mine* hands the day log and the amounts store: the built-in's
* record, filed under the copy's id. Pure, and only ever merged in — the
* originals stay under the routine's id, so deleting the copy shows the
* built-in exactly as it was.
*/
export function copyRoutineState<L, A>(
logs: Readonly<Record<string, L>>,
amounts: Readonly<Record<string, A>>,
fromId: string,
toId: string,
): { daily: Record<string, L>; amounts: Record<string, A> } {
const log = logs[fromId];
const numbers = amounts[fromId];
return {
daily: log === undefined ? {} : { [toId]: log },
amounts: numbers === undefined ? {} : { [toId]: numbers },
};
}
+19
View File
@@ -6,6 +6,7 @@ import {
routinesFor,
routineToAsk,
mineFrom,
copyRoutineState,
} from "../src/shared/routines.ts";
import { restorePrefsValue } from "../src/client/state/usePrefs.ts";
import { taskStatus } from "../src/shared/task.ts";
@@ -94,3 +95,21 @@ describe("making a built-in your own", () => {
});
});
});
describe("copying a built-in's record to the reader's copy", () => {
const logs = { "routine:zzz:ridu-weekly": { days: ["2026-10-12"], at: "2026-10-12T10:00:00.000Z" } };
const amounts = { "routine:zzz:ridu-weekly": { "2026-10-12": { value: 900, at: "2026-10-13T10:00:00.000Z" } } };
test("ticks and numbers land under the new id; the original's are not touched", () => {
const copy = copyRoutineState(logs, amounts, "routine:zzz:ridu-weekly", "mytask:abcdef1234");
expect(copy.daily).toEqual({ "mytask:abcdef1234": logs["routine:zzz:ridu-weekly"] });
expect(copy.amounts).toEqual({ "mytask:abcdef1234": amounts["routine:zzz:ridu-weekly"] });
expect(Object.keys(copy.daily)).not.toContain("routine:zzz:ridu-weekly");
});
test("nothing recorded, nothing copied", () => {
expect(copyRoutineState({}, {}, "routine:zzz:ridu-weekly", "mytask:abcdef1234")).toEqual({
daily: {}, amounts: {},
});
});
});
+3
View File
@@ -1507,6 +1507,9 @@ describe("built-in weekly tasks in the strip", () => {
expect(html).toContain("Track Zenless Zone Zero");
expect(html).toContain("weekly tasks?");
expect(html).toContain("No thanks");
// Heard out of context, "Yes" has to say what it agrees to.
expect(html).toContain(`aria-label="Yes, track Zenless Zone Zero's built-in weekly tasks"`.replace("'", "&#x27;"));
expect(html).toContain(`aria-label="No thanks to Zenless Zone Zero's built-in weekly tasks"`.replace("'", "&#x27;"));
});
test("with nothing to show and nothing to ask, the strip is absent", () => {