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? +

+
+ + +
+
+ ); + + if (items.length === 0) { + return
{question}
; + } // With mixed reset clocks there is no single "resets in", so the header // reports the next one to land and says that it is the next one. @@ -361,6 +412,7 @@ export function Dailies({ return (
{burst > 0 && } + {question !== null &&
{question}
}

diff --git a/src/shared/routines.ts b/src/shared/routines.ts index f0143aa..fdccb2e 100644 --- a/src/shared/routines.ts +++ b/src/shared/routines.ts @@ -95,3 +95,11 @@ export function routineToAsk( } return null; } + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** `2025-12-02` → `2 Dec 2025`: the same in every locale, so it reads the same wherever it is quoted. */ +export function asOfLabel(day: string): string { + const [y, m, d] = day.split("-").map(Number); + return `${d} ${MONTHS[(m ?? 1) - 1]} ${y}`; +} diff --git a/test/views.test.tsx b/test/views.test.tsx index 221c4ef..15bfca3 100644 --- a/test/views.test.tsx +++ b/test/views.test.tsx @@ -25,6 +25,7 @@ import { } from "../src/client/components/GoalControl.tsx"; import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx"; import type { Task } from "../src/shared/task.ts"; +import { routinesFor } from "../src/shared/routines.ts"; import { DailyChecklist } from "../src/client/components/DailyChecklist.tsx"; import { timelineLanes } from "../src/client/state/lanes.ts"; import { GameMetaProvider } from "../src/client/state/gameMeta.tsx"; @@ -1352,7 +1353,8 @@ describe("weekly and monthly tasks in the dailies strip", () => { scope: "month", measure: "days", periodStart: "2026-10-01", required: 1, count: 1, met: true, needed: 0, open: 16, shortfall: false, }; - const statusOf = (t: Task) => (t.measure.kind === "amount" ? amountStatus : monthStatus); + const statusOf = (t: Pick) => + t.measure.kind === "amount" ? amountStatus : monthStatus; const ev = { id: "z1", game: "zzz", title: "Login", type: "login", summary: null, startsAt: "2026-10-01T00:00:00.000Z", startPrecision: "day", endsAt: null, @@ -1481,3 +1483,37 @@ describe("review fixes: an event's points goal is not a daily", () => { expect(tally(total)).toMatchObject({ total: 0, week: { total: 0 }, month: { total: 0 } }); }); }); + +describe("built-in weekly tasks in the strip", () => { + const NOW = Date.parse("2026-10-15T12:00:00.000Z"); + const ridu = routinesFor("zzz")[0]!; + const status: GoalStatus = { + scope: "week", measure: "amount", periodStart: "2026-10-12", required: 1300, count: 0, + met: false, needed: 1300, open: 4, shortfall: false, + }; + + test("a routine draws like a task, and its tooltip says its source date", () => { + const [group] = dailyGroups(["zzz"], [], NOW, "europe", (id) => metaFor(id, {}), + (e) => Date.parse(e.startsAt), false, undefined, [ridu], () => status); + expect(group!.items[0]!.key).toBe("routine:zzz:ridu-weekly"); + expect(group!.items[0]!.title).toContain("as of 2 Dec 2025"); + }); + + test("the question shows on its own, and names the game", () => { + const html = render( + []} onToggleDay={() => {}} ask={{ game: "zzz", onAnswer: () => {} }} />, + ); + expect(html).toContain("Track Zenless Zone Zero"); + expect(html).toContain("weekly tasks?"); + expect(html).toContain("No thanks"); + }); + + test("with nothing to show and nothing to ask, the strip is absent", () => { + const html = render( + []} onToggleDay={() => {}} />, + ); + expect(html).toBe(""); + }); +});