feat(dailies): offer a game's built-in weekly tasks, and show them once on

The strip asks once, in one line, about a game on screen that has
built-ins. Yes puts them beside its dailies, drawn and counted like the
reader's own tasks, with a tooltip giving the source's date.

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:09:25 +02:00
1 parent bfd79c295e
commit 228ce9132d
4 files changed
+136 -12

No files matched your search

+31 -3
View File
@@ -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}
/>
+60 -8
View File
@@ -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 : (
<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?
</p>
<div className="flex gap-3">
<button
type="button"
onClick={() => ask.onAnswer(true)}
className="font-medium text-near transition-colors hover:text-ink"
>
Yes
</button>
<button
type="button"
onClick={() => ask.onAnswer(false)}
className="text-faint transition-colors hover:text-muted"
>
No thanks
</button>
</div>
</div>
);
if (items.length === 0) {
return <section className="relative border-b border-hairline px-4 py-3">{question}</section>;
}
// 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 (
<section className="relative border-b border-hairline px-4 py-4">
{burst > 0 && <Fireworks key={burst} />}
{question !== null && <div className="mb-3">{question}</div>}
<div className="relative flex items-baseline justify-between gap-3">
<h2 className="eyebrow">
+8
View File
@@ -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}`;
}
+37 -1
View File
@@ -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<Task, "measure">) =>
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(
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
daysFor={() => []} 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(
<Dailies games={["zzz"]} events={[]} region="europe" now={NOW} showChores={false}
daysFor={() => []} onToggleDay={() => {}} />,
);
expect(html).toBe("");
});
});