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 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-13 14:19:16 +02:00
1 parent f7ba86e6e5
commit 1c7df637a4
7 files changed
+65 -10

No files matched your search

+5 -1
View File
@@ -222,7 +222,11 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
// timelineDayWidth, timelineGroup, showUpcoming, // timelineDayWidth, timelineGroup, showUpcoming,
// timelineSplitUpcoming, detectDaily, showChores, // timelineSplitUpcoming, detectDaily, showChores,
// showCompleted, showIgnored, theme, regionConfirmed, // 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 // timelineDayWidth is px per day on the board, stored as the
// measurement rather than a step number and read through // measurement rather than a step number and read through
// snapDayWidth — so a value from an older ladder still opens // 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. number is visibly dated rather than presented as current.
- **Asking.** When a game with built-ins becomes visible — first-run picker, or switched on - **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 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 answer records the game in `routinesAsked`; Yes also adds it to `routineGames`. Never a
modal, and never asked twice. modal, and never asked twice.
- **Toggle.** The games settings group shows a “Weekly tasks” switch on each game that has - **Toggle.** *What you see* shows a “Built-in weekly tasks for <game>” switch, beside the daily
built-ins, writing `routineGames`. Off hides the chips; their ticks and amounts stay. chores switch, for each game that has built-ins, writing `routineGames` (and answering the
- **Make it mine.** From a built-in chip's editor or the settings list: copies the template 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 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 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. 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 { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts"; import { goalStatus, readGoal, type GoalStatus } from "../shared/goal.ts";
import { taskStatus, type TaskShape } from "../shared/task.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 { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts"; import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
@@ -212,10 +218,9 @@ export function App() {
const makeItMine = (routine: Routine) => { const makeItMine = (routine: Routine) => {
if (tasks.tasks.some((t) => t.from === routine.id)) return; if (tasks.tasks.some((t) => t.from === routine.id)) return;
const id = tasks.addTask(mineFrom(routine)); const id = tasks.addTask(mineFrom(routine));
const log = daily.logs[routine.id]; const copy = copyRoutineState(daily.logs, amounts.amounts, routine.id, id);
if (log !== undefined) daily.merge({ [id]: log }); daily.merge(copy.daily);
const numbers = amounts.amounts[routine.id]; amounts.merge(copy.amounts);
if (numbers !== undefined) amounts.merge({ [id]: numbers });
}; };
const taskStatusOf = (task: TaskShape): GoalStatus => const taskStatusOf = (task: TaskShape): GoalStatus =>
+2
View File
@@ -384,6 +384,7 @@ export function Dailies({
<button <button
type="button" type="button"
onClick={() => ask.onAnswer(true)} 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" className="font-medium text-near transition-colors hover:text-ink"
> >
Yes Yes
@@ -391,6 +392,7 @@ export function Dailies({
<button <button
type="button" type="button"
onClick={() => ask.onAnswer(false)} 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" className="text-faint transition-colors hover:text-muted"
> >
No thanks No thanks
+20
View File
@@ -123,3 +123,23 @@ export function mineFrom(routine: Routine): {
from: routine.id, 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, routinesFor,
routineToAsk, routineToAsk,
mineFrom, mineFrom,
copyRoutineState,
} from "../src/shared/routines.ts"; } from "../src/shared/routines.ts";
import { restorePrefsValue } from "../src/client/state/usePrefs.ts"; import { restorePrefsValue } from "../src/client/state/usePrefs.ts";
import { taskStatus } from "../src/shared/task.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("Track Zenless Zone Zero");
expect(html).toContain("weekly tasks?"); expect(html).toContain("weekly tasks?");
expect(html).toContain("No thanks"); 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", () => { test("with nothing to show and nothing to ask, the strip is absent", () => {