diff --git a/src/client/App.tsx b/src/client/App.tsx
index 9c89aca..5db5bd6 100644
--- a/src/client/App.tsx
+++ b/src/client/App.tsx
@@ -34,6 +34,7 @@ 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 { strandedOccurrences } from "../shared/recurrence.ts";
import { orderGames } from "./state/gameOrder.ts";
import { GameMetaProvider, type MetaResolver } from "./state/gameMeta.tsx";
@@ -187,9 +188,19 @@ export function App() {
game: row.event.game,
now,
logged: daily.daysFor(row.event.id),
+ amounts: amounts.amountsFor(row.event.id),
});
};
+ const taskStatusOf = (task: Task): GoalStatus =>
+ taskStatus({
+ task,
+ region: prefs.region,
+ now,
+ logged: daily.daysFor(task.id),
+ amounts: amounts.amountsFor(task.id),
+ });
+
/** Today's state for a repeating event, or undefined if it does not repeat. */
const dailyBadge = (row: RowEvent): DailyBadge | undefined => {
if (!repeatsDaily(row)) return undefined;
@@ -577,6 +588,10 @@ export function App() {
const row = todo.find((r) => r.event.id === id);
return row === undefined ? undefined : goalStatusOf(row);
}}
+ tasks={tasks.tasks}
+ taskStatusFor={taskStatusOf}
+ onSetAmount={amounts.setAmount}
+ amountFor={(key, period) => amounts.amountsFor(key)[period]?.value}
/>
diff --git a/src/client/components/AmountEditor.tsx b/src/client/components/AmountEditor.tsx
new file mode 100644
index 0000000..8f159a9
--- /dev/null
+++ b/src/client/components/AmountEditor.tsx
@@ -0,0 +1,91 @@
+import { useState } from "react";
+
+/**
+ * An amount the reader typed, as a whole non-negative number — or null while
+ * the field holds nothing readable yet, which is mid-edit rather than an answer.
+ *
+ * Separators are stripped because people type the number the game shows, and
+ * the game shows `1,900`. Decimals round: no game here counts fractions of an
+ * activity point, and a stray `.6` is a typo, not a claim.
+ */
+export function parseAmount(raw: string): number | null {
+ const cleaned = raw.replace(/[\s,_]/g, "");
+ if (cleaned === "") return null;
+ const n = Number(cleaned);
+ if (!Number.isFinite(n) || n < 0) return null;
+ return Math.round(n);
+}
+
+/**
+ * Where the reader is now against a target: `1,900 / 2,100 points`.
+ *
+ * Sets the value rather than adding to it, because the game shows a running
+ * total and that is the number people have in front of them. Enter saves,
+ * Escape cancels, and an empty or unreadable field disables Save rather than
+ * saving a zero nobody typed.
+ */
+export function AmountEditor({
+ label,
+ value,
+ target,
+ unit,
+ onSave,
+ onCancel,
+ autoFocus = false,
+}: {
+ label: string;
+ value: number | undefined;
+ target: number;
+ unit?: string | undefined;
+ onSave: (value: number) => void;
+ /** Absent where the editor is always open, as in the detail sheet. */
+ onCancel?: (() => void) | undefined;
+ autoFocus?: boolean;
+}) {
+ const [draft, setDraft] = useState(value === undefined ? "" : String(value));
+ const parsed = parseAmount(draft);
+
+ return (
+
+ );
+}
diff --git a/src/client/components/Dailies.tsx b/src/client/components/Dailies.tsx
index 5dbd240..35cde12 100644
--- a/src/client/components/Dailies.tsx
+++ b/src/client/components/Dailies.tsx
@@ -12,6 +12,9 @@ import { isCustomGameId, type DisplayEvent, type LaneId } from "../../shared/cus
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 { AmountEditor } from "./AmountEditor.tsx";
import { formatRemaining } from "../../shared/time.ts";
import { DayPip } from "./DayPip.tsx";
import { Fireworks } from "./Fireworks.tsx";
@@ -33,9 +36,12 @@ import { Fireworks } from "./Fireworks.tsx";
* answerable in ten seconds and expires tonight.
*/
-/** One thing to tick: a game's standing chore, or one of its repeating events. */
+/**
+ * One thing to do: a game's standing chore, one of its repeating events, or a
+ * weekly or monthly task the reader set up.
+ */
export interface DailyItem {
- /** The day-log key — `dailies:` for a chore, the event id otherwise. */
+ /** The day-log key — `dailies:` for a chore, the event or task id otherwise. */
key: string;
game: LaneId;
label: string;
@@ -48,8 +54,20 @@ export interface DailyItem {
* which has no start because it is a routine rather than an event.
*/
notBefore: number | null;
- /** The event's check-in goal, when the reader set one. Chores have none. */
+ /**
+ * The event's check-in goal or the task's status for its period. Chores have
+ * none. An amount here makes the chip open an editor instead of ticking.
+ */
goal?: GoalStatus | undefined;
+ /**
+ * How often it is due. Chores and events are daily and make up the header's
+ * "today" count; tasks are counted by their own period beside it.
+ */
+ cadence: "day" | "week" | "month";
+ /** A task's period, for finding the one before it in Catch up. */
+ period?: Period | undefined;
+ /** A task's unit, printed after its target. */
+ unit?: string | undefined;
}
/** A game and everything of its that wants ticking today. */
@@ -90,6 +108,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,
): 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
@@ -123,6 +143,7 @@ export function dailyGroups(
today,
resetsIn,
notBefore: null,
+ cadence: "day",
});
}
@@ -138,31 +159,80 @@ export function dailyGroups(
resetsIn,
notBefore: startOf(event),
goal: goalFor(event.id),
+ cadence: "day",
});
}
+ // A game's weekly and monthly tasks come last in its group: they are the
+ // least urgent thing in it. Only for lanes the reader is looking at — unlike
+ // an event, a task of a game they switched off is not something they are in
+ // the middle of, and its ticks and numbers wait untouched.
+ if (games.includes(lane)) {
+ for (const task of tasks) {
+ if (task.game !== lane) continue;
+ items.push({
+ key: task.id,
+ game: lane,
+ label: task.title,
+ title: `${game.name} — ${task.title}`,
+ ariaLabel: `${task.title} (${game.name})`,
+ today,
+ resetsIn,
+ notBefore: null,
+ goal: taskStatusFor(task),
+ cadence: task.period.unit,
+ period: task.period,
+ unit: task.measure.kind === "amount" ? task.measure.unit : undefined,
+ });
+ }
+ }
+
if (items.length > 0) groups.push({ game: lane, meta: game, items });
}
return groups;
}
+interface PeriodTally {
+ total: number;
+ complete: number;
+}
+
/**
- * How much of the strip is finished. An item is complete when ticked today or
- * when its goal is already met — nothing is ticked on its behalf. `tickedToday`
- * is kept apart because only a real tick celebrates.
+ * How much of the strip is finished. A daily item is complete when ticked today
+ * or when its goal is already met — nothing is ticked on its behalf.
+ * `tickedToday` is kept apart because only a real tick celebrates. Weekly and
+ * monthly tasks are tallied by their own period: a task due by Sunday is not
+ * "still waiting on you today" every morning.
*/
export function dailyTally(
items: readonly DailyItem[],
daysFor: (id: string) => string[],
-): { total: number; complete: number; tickedToday: number } {
+): {
+ total: number;
+ complete: number;
+ tickedToday: number;
+ week: PeriodTally;
+ month: PeriodTally;
+} {
+ let total = 0;
let complete = 0;
let tickedToday = 0;
+ const week = { total: 0, complete: 0 };
+ const month = { total: 0, complete: 0 };
for (const i of items) {
+ const met = i.goal?.met === true;
+ if (i.cadence !== "day") {
+ const tally = i.cadence === "week" ? week : month;
+ tally.total += 1;
+ if (met) tally.complete += 1;
+ continue;
+ }
+ total += 1;
const ticked = daysFor(i.key).includes(i.today);
if (ticked) tickedToday += 1;
- if (ticked || i.goal?.met === true) complete += 1;
+ if (ticked || met) complete += 1;
}
- return { total: items.length, complete, tickedToday };
+ return { total, complete, tickedToday, week, month };
}
/** The chip's accessible name: today's tick and the goal are separate facts. */
@@ -171,6 +241,8 @@ export function chipLabel(
isDone: boolean,
goal: GoalStatus | undefined,
): string {
+ // A number has no "today": it is wherever the reader last said it was.
+ if (goal?.measure === "amount") return `${base}, ${goalLabel(goal)}`;
if (!isDone && goal?.met === true) return `${base}, ${goalLabel(goal)}`;
const today = isDone ? "done today" : "not done today";
return goal === undefined ? `${base}, ${today}` : `${base}, ${today}, ${goalLabel(goal)}`;
@@ -185,6 +257,10 @@ export function Dailies({
onToggleDay,
showChores,
goalFor,
+ tasks = [],
+ taskStatusFor,
+ onSetAmount,
+ amountFor,
}: {
/** Every lane the reader is looking at, in their own order. */
games: LaneId[];
@@ -205,6 +281,13 @@ 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;
+ /** 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. */
+ amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
}) {
const gameMeta = useGameMeta();
/**
@@ -216,6 +299,8 @@ export function Dailies({
* the app should work.
*/
const [catchUp, setCatchUp] = useState(false);
+ /** Which amount chip's editor is open, by item key. One at a time. */
+ const [editing, setEditing] = useState(null);
// Each game rolls on its own server clock, so "today" is asked per game
// rather than once for the section — Endfield's European day can still be
@@ -229,9 +314,11 @@ export function Dailies({
(event) => Date.parse(event.startsAt),
showChores,
goalFor,
+ tasks,
+ taskStatusFor,
);
const items = groups.flatMap((group) => group.items);
- const { total, complete, tickedToday } = dailyTally(items, daysFor);
+ const { total, complete, tickedToday, week, month } = dailyTally(items, daysFor);
const allDone = total > 0 && complete === total;
const [burst, setBurst] = useState(0);
@@ -259,12 +346,13 @@ export function Dailies({
return () => clearTimeout(id);
}, [burst]);
- if (total === 0) return null;
+ if (items.length === 0) return null;
// 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.
const soonest = Math.min(...items.map((i) => i.resetsIn));
const mixed = new Set(items.map((i) => i.resetsIn)).size > 1;
+ const open = items.find((i) => i.key === editing && i.goal?.measure === "amount");
return (
@@ -272,7 +360,9 @@ export function Dailies({
{mixed ? "next reset in " : "resets in "}
@@ -287,6 +377,8 @@ export function Dailies({
region={region}
daysFor={daysFor}
onToggleDay={onToggleDay}
+ onSetAmount={onSetAmount}
+ amountFor={amountFor}
/>
) : (
/* One wrapping row, with each game's chore and its events adjacent. No
@@ -297,28 +389,62 @@ export function Dailies({
)}
+ {/* Under the strip rather than inside the chip: a chip is one target, and
+ a field inside it would be a second one waiting to be mis-tapped. */}
+ {!catchUp && open?.goal !== undefined && onSetAmount !== undefined && (
+ {
+ onSetAmount(open.key, open.goal!.periodStart, value);
+ setEditing(null);
+ }}
+ onCancel={() => setEditing(null)}
+ />
+ )}
+
{catchUp
? `Tick a day you did but didn't record. The last ${CATCH_UP_DAYS} days, and nothing later than today.`
- : allDone
- ? "All done. Nothing else expires tonight."
- : `${waiting(total - complete)} still waiting on you today.`}
+ : total === 0
+ ? week.complete + month.complete === week.total + month.total
+ ? "All done for this week and month."
+ : `${waiting(week.total + month.total - week.complete - month.complete)} still open this week or month.`
+ : allDone
+ ? "All done. Nothing else expires tonight."
+ : `${waiting(total - complete)} still waiting on you today.`}
{/* The way back to a day you already did. It lives on the section rather
than on each chip because a chip is a single tick target, and a
@@ -358,12 +484,16 @@ export function CatchUpPanel({
region,
daysFor,
onToggleDay,
+ onSetAmount,
+ amountFor,
}: {
groups: DailyGroup[];
now: number;
region: Region;
daysFor: (id: string) => string[];
onToggleDay: (id: string, day: string) => void;
+ onSetAmount?: ((key: string, periodStart: string, value: number) => void) | undefined;
+ amountFor?: ((key: string, periodStart: string) => number | undefined) | undefined;
}) {
return (
@@ -375,7 +505,28 @@ export function CatchUpPanel({
>
{group.meta.name}
- {group.items.map((item) => (
+ {group.items.map((item) => {
+ // A number is caught up a period at a time: the one just gone is
+ // the one a reader forgets to record. An event's number is edited
+ // in its sheet, where its whole run is.
+ if (item.goal?.measure === "amount") {
+ if (item.period === undefined || onSetAmount === undefined) return null;
+ const previous = periodOf(item.period, addDays(item.goal.periodStart, -1)).start;
+ return (
+
+
{item.label}
+ onSetAmount(item.key, previous, value)}
+ />
+
+ );
+ }
+ return (
{item.label}
@@ -390,13 +541,67 @@ export function CatchUpPanel({
))}
- ))}
+ );
+ })}
))}
);
}
+/**
+ * A number against a target: `1,900/2,100 wk`, with a hairline under it filling
+ * to the same fraction.
+ *
+ * Not a toggle — there is nothing to tick — so no `aria-pressed`; it opens the
+ * editor under the strip and says so with `aria-expanded`. Met, it takes the
+ * goal-met look a tick chip has: full hue, no wash, since a wash means "done
+ * today" and a number is never that.
+ */
+function AmountChip({
+ label,
+ hue,
+ title,
+ ariaLabel,
+ goal,
+ open,
+ onOpen,
+}: {
+ label: string;
+ hue: string;
+ title: string;
+ ariaLabel: string;
+ goal: GoalStatus;
+ open: boolean;
+ onOpen: () => void;
+}) {
+ const share = Math.min(1, goal.required === 0 ? 0 : goal.count / goal.required);
+ return (
+
+ );
+}
+
/**
* One thing to tick off today.
*
diff --git a/test/views.test.tsx b/test/views.test.tsx
index fe2b8ff..f372d93 100644
--- a/test/views.test.tsx
+++ b/test/views.test.tsx
@@ -18,6 +18,8 @@ import {
import type { GoalStatus } from "../src/shared/goal.ts";
import { Welcome } from "../src/client/components/Welcome.tsx";
import { chooseGoal, GoalControl, parseDraft } from "../src/client/components/GoalControl.tsx";
+import { AmountEditor, parseAmount } from "../src/client/components/AmountEditor.tsx";
+import type { Task } from "../src/shared/task.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";
@@ -1198,7 +1200,7 @@ describe("goals in the dailies strip", () => {
const [group] = dailyGroups(["genshin"], [ev], NOW, "europe", meta, startOf, false, () =>
status({ count: 5, met: true, needed: 0 }),
);
- expect(dailyTally(group!.items, () => [])).toEqual({
+ expect(dailyTally(group!.items, () => [])).toMatchObject({
total: 1,
complete: 1,
tickedToday: 0,
@@ -1320,3 +1322,104 @@ describe("GoalControl decisions", () => {
expect(parseDraft("53", "week", null)).toBe(7);
});
});
+
+describe("weekly and monthly tasks in the dailies strip", () => {
+ const NOW = Date.parse("2026-10-15T12:00:00.000Z");
+ const meta = (id: string) => metaFor(id, {});
+ const startOf = (e: { startsAt: string }) => Date.parse(e.startsAt);
+ const AT = "2026-10-01T00:00:00.000Z";
+ const points: Task = {
+ id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points",
+ period: { unit: "week", startsOn: 1 },
+ measure: { kind: "amount", target: 2100, unit: "points" }, at: AT, updatedAt: AT,
+ };
+ const monthly: Task = {
+ id: "mytask:bbbbbb2222", game: "zzz", title: "Shop reset",
+ period: { unit: "month", startsOn: 1 }, measure: { kind: "days", required: 1 },
+ at: AT, updatedAt: AT,
+ };
+ const hidden: Task = { ...monthly, id: "mytask:cccccc3333", game: "hsr", title: "Hidden" };
+ const amountStatus: GoalStatus = {
+ scope: "week", measure: "amount", periodStart: "2026-10-12", required: 2100, count: 1900,
+ met: false, needed: 200, open: 4, shortfall: false,
+ };
+ const monthStatus: GoalStatus = {
+ 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 ev = {
+ id: "z1", game: "zzz", title: "Login", type: "login", summary: null,
+ startsAt: "2026-10-01T00:00:00.000Z", startPrecision: "day", endsAt: null,
+ endPrecision: "unknown", regionScoped: false, regionEnds: null, sourceUrl: "https://example.test",
+ } as never;
+
+ test("a game's tasks follow its chore and events, and a hidden game's stay hidden", () => {
+ const groups = dailyGroups(["zzz"], [ev], NOW, "europe", meta, startOf, true, undefined,
+ [points, monthly, hidden], statusOf);
+ expect(groups.map((g) => g.items.map((i) => i.key))).toEqual([
+ ["dailies:zzz", "z1", points.id, monthly.id],
+ ]);
+ expect(groups[0]!.items.map((i) => i.cadence)).toEqual(["day", "day", "week", "month"]);
+ });
+
+ test("tasks are counted apart from today's dailies", () => {
+ const [group] = dailyGroups(["zzz"], [], NOW, "europe", meta, startOf, true, undefined,
+ [points, monthly], statusOf);
+ expect(dailyTally(group!.items, () => [])).toEqual({
+ total: 1, complete: 0, tickedToday: 0,
+ week: { total: 1, complete: 0 }, month: { total: 1, complete: 1 },
+ });
+ });
+
+ test("the header shows the period counts, and an amount chip is not a toggle", () => {
+ const html = render(
+ []} onToggleDay={() => {}} tasks={[points, monthly]}
+ taskStatusFor={statusOf} onSetAmount={() => {}} amountFor={() => undefined} />,
+ );
+ expect(html).toMatch(/Weekly ()?0()?\/()?1/);
+ expect(html).toMatch(/Monthly ()?1()?\/()?1/);
+ expect(html).toContain("1,900/2,100 wk");
+ expect(html).toMatch(/aria-label="Activity points \(Zenless Zone Zero\), 1,900 of 2,100 this week"/);
+ const chip = html.match(/