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:
1 parent
bfd79c295e
commit
228ce9132d
4 files changed
+136
-12
No files matched your search
+31
-3
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user