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:
co-authored by
Claude Opus 5.5
parent
f7ba86e6e5
commit
1c7df637a4
+5
-1
@@ -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
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("'", "'"));
|
||||
expect(html).toContain(`aria-label="No thanks to Zenless Zone Zero's built-in weekly tasks"`.replace("'", "'"));
|
||||
});
|
||||
|
||||
test("with nothing to show and nothing to ask, the strip is absent", () => {
|
||||
|
||||
Reference in New Issue
Block a user