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 ( +
{ + e.preventDefault(); + if (parsed !== null) onSave(parsed); + }} + onKeyDown={(e) => { + if (e.key === "Escape" && onCancel !== undefined) onCancel(); + }} + > + + + / {target.toLocaleString("en-US")} + {unit ? ` ${unit}` : ""} + + + {onCancel !== undefined && ( + + )} +
+ ); +} 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({

- Today's dailies · {complete}/{total} + {total > 0 ? <>Today's dailies · {complete}/{total} : "Your tasks"} + {week.total > 0 && <> · Weekly {week.complete}/{week.total}} + {month.total > 0 && <> · Monthly {month.complete}/{month.total}}

{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({

    {items.map((item) => (
  • - onToggleDay(item.key, item.today)} - /> + {item.goal?.measure === "amount" ? ( + setEditing(editing === item.key ? null : item.key)} + /> + ) : ( + onToggleDay(item.key, item.today)} + /> + )}
  • ))}
)} + {/* 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(/]*Activity points \(Zenless[^>]*>/)![0]; + expect(chip).not.toContain("aria-pressed"); + expect(chip).toContain('aria-expanded="false"'); + }); + + test("a strip of only tasks still renders", () => { + const html = render( + []} onToggleDay={() => {}} tasks={[points]} taskStatusFor={statusOf} + onSetAmount={() => {}} amountFor={() => undefined} />, + ); + expect(html).toContain("Activity points"); + }); + + test("labels for amounts and months", () => { + expect(chipLabel("X", false, amountStatus)).toBe("X, 1,900 of 2,100 this week"); + expect(chipLabel("X", false, monthStatus)).toBe("X, done this month"); + }); +}); + +describe("AmountEditor", () => { + test("shows the value against the target", () => { + const html = render( + {}} onCancel={() => {}} />, + ); + expect(html).toContain('value="1900"'); + expect(html).toContain("/ 2,100 points"); + expect(html).toContain("Save"); + }); + + test("amounts are whole, non-negative numbers; anything else is mid-edit", () => { + expect(parseAmount("1900")).toBe(1900); + expect(parseAmount("1,900")).toBe(1900); + expect(parseAmount(" 2 100 ")).toBe(2100); + expect(parseAmount("1900.6")).toBe(1901); + expect(parseAmount("")).toBeNull(); + expect(parseAmount("abc")).toBeNull(); + expect(parseAmount("-5")).toBeNull(); + }); +});