feat(settings): switch built-in weekly tasks per game, and make one your own

The switch sits beside the daily chores and answers the strip's question
too. Built-ins are listed with a link to their dated source. Make it mine
copies one into the reader's tasks, numbers and ticks included, and hides
the original while the copy exists. It's additive, so deleting the copy
brings the original back as it was.

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:10:58 +02:00
1 parent 228ce9132d
commit 624c725cbf
8 files changed
+161 -3

No files matched your search

+18 -1
View File
@@ -35,7 +35,7 @@ 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, routineToAsk } from "../shared/routines.ts";
import { activeRoutines, 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";
@@ -203,6 +203,21 @@ export function App() {
const routines = activeRoutines(prefs.routineGames, copiedFrom);
const stripTasks = [...tasks.tasks, ...routines];
/**
* Copy a built-in into the reader's own tasks, ticks and numbers included.
* Additive: the built-in's own log stays under its id, so deleting the copy
* brings the original back as it was. A second press while a copy exists
* does nothing rather than making two.
*/
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 taskStatusOf = (task: TaskShape): GoalStatus =>
taskStatus({
task,
@@ -727,6 +742,8 @@ export function App() {
onAddTask: tasks.addTask,
onEditTask: tasks.editTask,
onRemoveTask: tasks.removeTask,
routines,
onMakeItMine: makeItMine,
now,
// The index lists rules; the sheet opens rows. A repeating rule's
// own id is never a row — the lists hold its occurrences — so it is
+32
View File
@@ -7,6 +7,7 @@ import { moveGame } from "../state/gameOrder.ts";
import type { ThemeChoice } from "../state/theme.ts";
import type { Prefs } from "../state/usePrefs.ts";
import { YourOwn } from "./YourOwn.tsx";
import { asOfLabel, routinesFor } from "../../shared/routines.ts";
const REGIONS: Array<{ id: Region; label: string }> = (
["america", "europe", "asia"] as const
@@ -91,6 +92,7 @@ export function Controls({
/** Everything the reader entered themselves, and the ways to change it. */
own: React.ComponentProps<typeof YourOwn>;
}) {
const gameMeta = useGameMeta();
const shown = games.filter((g) => !prefs.hiddenGames.includes(g)).length;
const ownGames = Object.keys(own.games).length;
const ownEvents = Object.keys(own.events).length;
@@ -246,6 +248,36 @@ export function Controls({
hint="Commissions, resin, daily training and the rest. Nobody publishes these, so the app keeps a fixed list per game. Off, the strip shows only events with a checklist. Your ticks and streaks are kept either way."
/>
{/* Beside the chores rather than on each game's row, which already
carries a handle, two arrows and a switch. Opt-in per game: these
are someone else's figures, each dated by its source. */}
{games
.filter((game) => routinesFor(game).length > 0)
.map((game) => {
const on = prefs.routineGames?.includes(game) ?? false;
const list = routinesFor(game);
return (
<Check
key={game}
checked={on}
onChange={(checked) =>
onUpdate({
routineGames: checked
? [...(prefs.routineGames ?? []), game]
: (prefs.routineGames ?? []).filter((g) => g !== game),
// Answering here answers the strip's question too.
routinesAsked: [
...(prefs.routinesAsked ?? []).filter((g) => g !== game),
game,
],
})
}
label={`Built-in weekly tasks for ${gameMeta(game).name}`}
hint={`${list.map((r) => r.title).join(", ")} — from a source as of ${asOfLabel(list[0]!.asOf)}. Off hides them; your numbers are kept.`}
/>
);
})}
{ignoredCount > 0 && (
<Check
checked={prefs.showIgnored}
+44
View File
@@ -7,6 +7,7 @@ import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx";
import { taskCaption, type Task } from "../../shared/task.ts";
import type { TaskDraft } from "../state/useTasks.ts";
import { TaskForm } from "./TaskForm.tsx";
import { asOfLabel, type Routine } from "../../shared/routines.ts";
/**
* The reader's own games and events — a group of the settings panel (PRD F13).
@@ -68,6 +69,8 @@ export function YourOwn({
onAddTask,
onEditTask,
onRemoveTask,
routines = [],
onMakeItMine,
}: {
games: CustomGames;
events: CustomEvents;
@@ -89,6 +92,9 @@ export function YourOwn({
onAddTask?: ((draft: TaskDraft) => void) | undefined;
onEditTask?: ((id: string, draft: TaskDraft) => void) | undefined;
onRemoveTask?: ((id: string) => void) | undefined;
/** Built-in weekly tasks the reader has on, and how to copy one into their own. */
routines?: readonly Routine[] | undefined;
onMakeItMine?: ((routine: Routine) => void) | undefined;
}) {
const gameMeta = useGameMeta();
const [adding, setAdding] = useState<"game" | "event" | "task" | null>(null);
@@ -313,6 +319,44 @@ export function YourOwn({
</>
)}
{/* Built-ins are not editable in place — they are someone else's figures,
dated by their source — so the way to change one is to make it yours:
a copy that replaces it while it exists, numbers and ticks included. */}
{routines.length > 0 && onMakeItMine !== undefined && (
<>
<p className="mt-4 text-xs text-faint">Built-in weekly tasks</p>
<ul className="mt-1.5 flex flex-col gap-1">
{routines.map((routine) => (
<li key={routine.id}>
<div className="flex items-baseline gap-2">
<span className="shrink-0 text-xs text-faint">{gameMeta(routine.game).short}</span>
<span className="min-w-0 flex-1 truncate text-sm text-muted">{routine.title}</span>
<a
href={routine.source}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-faint underline decoration-hairline underline-offset-2 transition-colors hover:text-muted"
>
as of {asOfLabel(routine.asOf)}
</a>
<button
type="button"
onClick={() => onMakeItMine(routine)}
className="shrink-0 text-xs text-faint transition-colors hover:text-ink"
>
Make it mine
</button>
</div>
<p className="text-xs text-faint">
{taskCaption(routine)}
{routine.note !== undefined && ` · ${routine.note}`}
</p>
</li>
))}
</ul>
</>
)}
{adding === "task" && onAddTask !== undefined && (
<TaskForm
lanes={lanes}
+2
View File
@@ -10,6 +10,8 @@ export interface TaskDraft {
title: string;
period: Period;
measure: Measure;
/** The built-in this copies, when it does (routines.ts). */
from?: string | undefined;
}
/** The store as it sits on disk, every record kept whether or not this build reads it. */
+20
View File
@@ -103,3 +103,23 @@ export function asOfLabel(day: string): string {
const [y, m, d] = day.split("-").map(Number);
return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`;
}
/**
* A reader's own task copied from a built-in, remembering where it came from.
* The copy is theirs to edit; the original stays hidden while it exists.
*/
export function mineFrom(routine: Routine): {
game: string;
title: string;
period: Routine["period"];
measure: Routine["measure"];
from: string;
} {
return {
game: routine.game,
title: routine.title,
period: routine.period,
measure: routine.measure,
from: routine.id,
};
}
+17 -2
View File
@@ -38,11 +38,11 @@ const PREFS: Prefs = {
onboarded: true,
};
function render(prefs: Prefs, ignoredCount = 0): string {
function render(prefs: Prefs, ignoredCount = 0, games = ["genshin", "hsr"]): string {
return renderToStaticMarkup(
<GameMetaProvider value={(id) => metaFor(id, {})}>
<Controls
games={["genshin", "hsr"]}
games={games}
prefs={prefs}
onToggleGame={() => {}}
onUpdate={() => {}}
@@ -301,3 +301,18 @@ describe("Controls: backup and export actions", () => {
});
});
describe("Controls: built-in weekly tasks", () => {
test("a switch per game that has them, reflecting the reader's answer", () => {
const off = render(PREFS, 0, ["genshin", "zzz"]);
expect(off).toContain("Built-in weekly tasks for Zenless Zone Zero");
expect(off).not.toContain("for Genshin Impact");
const on = render({ ...PREFS, routineGames: ["zzz"] }, 0, ["genshin", "zzz"]);
const box = on.match(/<input type="checkbox"[^>]*>(?=[^<]*<span>Built-in weekly tasks for Zenless)/);
expect(box?.[0]).toContain('checked=""');
});
test("no switch when no game on the list has any", () => {
expect(render(PREFS)).not.toContain("Built-in weekly tasks");
});
});
+17
View File
@@ -1320,3 +1320,20 @@ describe("review fixes: the month-day field", () => {
expect(parseMonthDay("0")).toBe(1);
});
});
describe("built-in weekly tasks in Your own", () => {
test("listed with their source's date, and a way to make one yours", async () => {
const { routinesFor } = await import("../src/shared/routines.ts");
const noop = () => {};
const html = render(
<YourOwn games={GAMES} events={{}} lanes={["zzz"]} now={0} onOpen={noop}
onAddGame={noop} onEditGame={noop} onRemoveGame={() => ({ removed: true, blockedBy: 0 })}
onAddEvent={noop} onAddTask={noop} onEditTask={noop} onRemoveTask={noop} tasks={[]}
routines={routinesFor("zzz")} onMakeItMine={noop} />,
);
expect(html).toContain("Ridu Weekly");
expect(html).toContain("as of 2 Dec 2025");
expect(html).toContain('href="https://game8.co/games/Zenless-Zone-Zero/archives/484723"');
expect(html).toContain("Make it mine");
});
});
+11
View File
@@ -5,6 +5,7 @@ import {
RoutineSchema,
routinesFor,
routineToAsk,
mineFrom,
} from "../src/shared/routines.ts";
import { restorePrefsValue } from "../src/client/state/usePrefs.ts";
import { taskStatus } from "../src/shared/task.ts";
@@ -83,3 +84,13 @@ describe("restoring prefs with the new fields", () => {
expect(restorePrefsValue({}).routineGames).toBeUndefined();
});
});
describe("making a built-in your own", () => {
test("copies its shape and remembers where it came from", () => {
const ridu = routinesFor("zzz")[0]!;
expect(mineFrom(ridu)).toEqual({
game: "zzz", title: "Ridu Weekly", period: { unit: "week", startsOn: 1 },
measure: { kind: "amount", target: 1300, unit: "points" }, from: "routine:zzz:ridu-weekly",
});
});
});