diff --git a/src/client/App.tsx b/src/client/App.tsx index 64de1d6..1a9f517 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -34,7 +34,8 @@ import { 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 Task } from "../shared/task.ts"; +import { taskStatus, type TaskShape } from "../shared/task.ts"; +import { activeRoutines, routineToAsk } from "../shared/routines.ts"; import { strandedOccurrences } from "../shared/recurrence.ts"; import { orderGames } from "./state/gameOrder.ts"; import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx"; @@ -192,7 +193,17 @@ export function App() { }); }; - const taskStatusOf = (task: Task): GoalStatus => + /** + * Built-ins the reader turned on, less any they made their own — then theirs + * and the built-ins together, as the strip draws them. + */ + const copiedFrom = new Set( + tasks.tasks.flatMap((t) => (t.from === undefined ? [] : [t.from])), + ); + const routines = activeRoutines(prefs.routineGames, copiedFrom); + const stripTasks = [...tasks.tasks, ...routines]; + + const taskStatusOf = (task: TaskShape): GoalStatus => taskStatus({ task, region: prefs.region, @@ -588,8 +599,25 @@ export function App() { const row = todo.find((r) => r.event.id === id); return row === undefined ? undefined : goalStatusOf(row); }} - tasks={tasks.tasks} + tasks={stripTasks} taskStatusFor={taskStatusOf} + ask={(() => { + const game = routineToAsk( + focus === null ? enabled : [focus], + prefs.routineGames, + prefs.routinesAsked, + ); + if (game === null) return undefined; + return { + game, + // Either answer is recorded, so the question is asked once. + onAnswer: (yes: boolean) => + update({ + routinesAsked: [...(prefs.routinesAsked ?? []), game], + ...(yes ? { routineGames: [...(prefs.routineGames ?? []), game] } : {}), + }), + }; + })()} onSetAmount={amounts.setAmount} amountFor={(key, period) => amounts.amountsFor(key)[period]?.value} /> diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx index 816e8ef..6d359ea 100644 --- a/src/client/components/Dailies.tsx +++ b/src/client/components/Dailies.tsx @@ -13,7 +13,8 @@ import type { GameMeta } from "../../shared/games.ts"; import type { GameId, Region } from "../../shared/schema.ts"; import { goalCount, goalLabel, type GoalStatus } from "../../shared/goal.ts"; import { addDays, periodOf, type Period } from "../../shared/period.ts"; -import type { Task } from "../../shared/task.ts"; +import type { TaskShape } from "../../shared/task.ts"; +import { asOfLabel } from "../../shared/routines.ts"; import { AmountEditor } from "./AmountEditor.tsx"; import { formatRemaining } from "../../shared/time.ts"; import { DayPip } from "./DayPip.tsx"; @@ -72,6 +73,12 @@ export interface DailyItem { unit?: string | undefined; } +/** + * A task as the strip needs it: a reader's own, or a built-in routine, which + * also says what date its source gives (`asOf`) so the tooltip can show it. + */ +export type StripTask = TaskShape & { asOf?: string | undefined }; + /** A game and everything of its that wants ticking today. */ export interface DailyGroup { game: LaneId; @@ -110,8 +117,8 @@ export function dailyGroups( startOf: (event: DisplayEvent) => number, showChores = true, goalFor: (id: string) => GoalStatus | undefined = () => undefined, - tasks: readonly Task[] = [], - taskStatusFor: (task: Task) => GoalStatus | undefined = () => undefined, + tasks: readonly StripTask[] = [], + taskStatusFor: (task: StripTask) => GoalStatus | undefined = () => undefined, ): DailyGroup[] { // A lane can arrive through an event without being in `games` — an event on a // game the reader has since switched off, say — and dropping it here would @@ -177,7 +184,12 @@ export function dailyGroups( key: task.id, game: lane, label: task.title, - title: `${game.name} — ${task.title}`, + // A built-in is someone else's figure; say how old it is where the + // reader can find it without opening settings. + title: + task.asOf === undefined + ? `${game.name} — ${task.title}` + : `${game.name} — ${task.title} · built in, from a source as of ${asOfLabel(task.asOf)}`, ariaLabel: `${task.title} (${game.name})`, today, resetsIn, @@ -265,6 +277,7 @@ export function Dailies({ taskStatusFor, onSetAmount, amountFor, + ask, }: { /** Every lane the reader is looking at, in their own order. */ games: LaneId[]; @@ -285,9 +298,18 @@ export function Dailies({ * completes its item for the header without ticking anything. */ goalFor?: ((id: string) => GoalStatus | undefined) | undefined; - /** The reader's weekly and monthly tasks, all of them; the strip picks its lanes'. */ - tasks?: readonly Task[] | undefined; - taskStatusFor?: ((task: Task) => GoalStatus | undefined) | undefined; + /** + * Weekly and monthly tasks — the reader's own and any built-ins they turned + * on — all of them; the strip picks its lanes'. + */ + tasks?: readonly StripTask[] | undefined; + taskStatusFor?: ((task: StripTask) => GoalStatus | undefined) | undefined; + /** + * A game whose built-in weekly tasks the reader has not been asked about yet, + * and how to record the answer. Asked here because this is where the tasks + * would appear; one line, never a modal, never twice. + */ + ask?: { game: LaneId; onAnswer: (yes: boolean) => void } | undefined; /** Record a number for an item's period. */ onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined; /** The number recorded for an item's period, if any. */ @@ -350,7 +372,36 @@ export function Dailies({ return () => clearTimeout(id); }, [burst]); - if (items.length === 0) return null; + if (items.length === 0 && ask === undefined) return null; + + const question = + ask === undefined ? null : ( +
+ Track {gameMeta(ask.game).name}'s built-in weekly tasks? +
+