From 624c725cbfb51dc4a0303747368784fc1bdfc2f3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 14:10:58 +0200 Subject: [PATCH] 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 --- src/client/App.tsx | 19 ++++++++++++- src/client/components/Controls.tsx | 32 ++++++++++++++++++++++ src/client/components/YourOwn.tsx | 44 ++++++++++++++++++++++++++++++ src/client/state/useTasks.ts | 2 ++ src/shared/routines.ts | 20 ++++++++++++++ test/controls.test.tsx | 19 +++++++++++-- test/custom-ui.test.tsx | 17 ++++++++++++ test/routines.test.ts | 11 ++++++++ 8 files changed, 161 insertions(+), 3 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 1a9f517..708b58e 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -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 diff --git a/src/client/components/Controls.tsx b/src/client/components/Controls.tsx index 8605bd8..4d8a530 100644 --- a/src/client/components/Controls.tsx +++ b/src/client/components/Controls.tsx @@ -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; }) { + 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 ( + + 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 && ( 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 && ( + <> +

Built-in weekly tasks

+
    + {routines.map((routine) => ( +
  • +
    + {gameMeta(routine.game).short} + {routine.title} + + as of {asOfLabel(routine.asOf)} + + +
    +

    + {taskCaption(routine)} + {routine.note !== undefined && ` · ${routine.note}`} +

    +
  • + ))} +
+ + )} + {adding === "task" && onAddTask !== undefined && (