feat(routines): say "weekly and monthly" for built-ins

Built-ins now include monthly items, so the strip's question, Settings and Your
own say "weekly and monthly". The per-game tooltip quotes the oldest source
date when a game has several.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-16 03:00:21 +02:00
1 parent 3fd2b3c2ed
commit f322061c88
7 files changed
+22 -19

No files matched your search

+7 -4
View File
@@ -281,14 +281,17 @@ export function Controls({
<PerGameSwitch
checked={routines.all}
onChange={(showRoutines) => onUpdate({ showRoutines })}
label="Show built-in weekly tasks"
hint="Weekly tasks the app knows for some games, each from a cited page and dated by it. Off hides them; your ticks and numbers are kept."
label="Show built-in weekly and monthly tasks"
hint="Weekly and monthly tasks the app knows for some games, each from a cited page and dated by it. Off hides them; your ticks and numbers are kept."
rule={routines}
games={routineGames}
chipsLabel="Built-in weekly tasks per game"
chipsLabel="Built-in weekly and monthly tasks per game"
titleFor={(game) => {
const list = routinesFor(game);
return `${list.map((r) => r.title).join(", ")} — from a source as of ${asOfLabel(list[0]!.asOf)}`;
// Several pages per game now: quote the oldest, so the
// tooltip never makes a year-old figure look current.
const oldest = list.map((r) => r.asOf).sort()[0]!;
return `${list.map((r) => r.title).join(", ")} — ${list.length > 1 ? "from sources, the oldest" : "from a source"} as of ${asOfLabel(oldest)}`;
}}
onSet={(game, value) => {
const { on, off } = setFor(routines, game, value);
+4 -4
View File
@@ -334,7 +334,7 @@ export function Dailies({
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,
* A game whose built-in weekly and monthly 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.
*/
@@ -427,13 +427,13 @@ export function Dailies({
ask === undefined ? null : (
<div className="relative flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
<p className="text-muted">
Track {gameMeta(ask.game).name}'s built-in weekly tasks?
Track {gameMeta(ask.game).name}'s built-in weekly and monthly tasks?
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => ask.onAnswer(true)}
aria-label={`Yes, track ${gameMeta(ask.game).name}'s built-in weekly tasks`}
aria-label={`Yes, track ${gameMeta(ask.game).name}'s built-in weekly and monthly tasks`}
className="font-medium text-near transition-colors hover:text-ink"
>
Yes
@@ -441,7 +441,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`}
aria-label={`No thanks to ${gameMeta(ask.game).name}'s built-in weekly and monthly tasks`}
className="text-faint transition-colors hover:text-muted"
>
No thanks
+1 -1
View File
@@ -332,7 +332,7 @@ export function YourOwn({
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>
<p className="mt-4 text-xs text-faint">Built-in weekly and monthly tasks</p>
<ul className="mt-1.5 flex flex-col gap-1">
{routines.map((routine) => {
const tracked = !(routinesHidden?.has(routine.id) ?? false);
+1 -1
View File
@@ -5,7 +5,7 @@ import { shownFor, type PerGame } from "./perGame.ts";
import { MeasureSchema, type TaskShape } from "./task.ts";
/**
* Weekly tasks the app ships for a game — the built-in counterpart of a
* Weekly and monthly tasks the app ships for a game — the built-in counterpart of a
* reader's own `mytask:` (PRD F17).
*
* Each is a claim about a game we did not write, so it is held to the bar the
+4 -4
View File
@@ -304,17 +304,17 @@ describe("Controls: backup and export actions", () => {
});
describe("Controls: built-in weekly tasks", () => {
describe("Controls: built-in weekly and monthly tasks", () => {
test("one global switch, not a checkbox per game", () => {
const html = render(PREFS, 0, ["genshin", "zzz", "endfield"]);
expect(html).toContain("Show built-in weekly tasks");
expect(html).toContain("Show built-in weekly and monthly tasks");
expect(html).not.toContain("Built-in weekly tasks for");
});
test("it reads showRoutines, and an unanswered one is off", () => {
const games = ["genshin", "zzz"];
const box = (html: string) =>
html.match(/<input type="checkbox"[^>]*>(?=[^<]*<span>Show built-in weekly tasks)/)?.[0];
html.match(/<input type="checkbox"[^>]*>(?=[^<]*<span>Show built-in weekly and monthly tasks)/)?.[0];
expect(box(render(PREFS, 0, games))).not.toContain('checked=""');
expect(box(render({ ...PREFS, showRoutines: true }, 0, games))).toContain('checked=""');
});
@@ -325,7 +325,7 @@ describe("Controls: built-in weekly tasks", () => {
});
test("no switch when no game on the list has any", () => {
expect(render(PREFS)).not.toContain("built-in weekly tasks");
expect(render(PREFS)).not.toContain("built-in weekly and monthly tasks");
});
});
+1 -1
View File
@@ -1322,7 +1322,7 @@ describe("review fixes: the month-day field", () => {
});
});
describe("built-in weekly tasks in Your own", () => {
describe("built-in weekly and monthly 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 = () => {};
+4 -4
View File
@@ -1495,7 +1495,7 @@ describe("review fixes: an event's points goal is not a daily", () => {
});
});
describe("built-in weekly tasks in the strip", () => {
describe("built-in weekly and monthly tasks in the strip", () => {
const NOW = Date.parse("2026-10-15T12:00:00.000Z");
const ridu = routinesFor("zzz")[0]!;
const status: GoalStatus = {
@@ -1516,11 +1516,11 @@ describe("built-in weekly tasks in the strip", () => {
daysFor={() => []} onToggleDay={() => {}} ask={{ game: "zzz", onAnswer: () => {} }} />,
);
expect(html).toContain("Track Zenless Zone Zero");
expect(html).toContain("weekly tasks?");
expect(html).toContain("weekly and monthly 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("'", "&#x27;"));
expect(html).toContain(`aria-label="No thanks to Zenless Zone Zero's built-in weekly tasks"`.replace("'", "&#x27;"));
expect(html).toContain(`aria-label="Yes, track Zenless Zone Zero's built-in weekly and monthly tasks"`.replace("'", "&#x27;"));
expect(html).toContain(`aria-label="No thanks to Zenless Zone Zero's built-in weekly and monthly tasks"`.replace("'", "&#x27;"));
});
test("with nothing to show and nothing to ask, the strip is absent", () => {