diff --git a/src/client/App.tsx b/src/client/App.tsx index c68b239..64de1d6 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -695,6 +695,10 @@ export function App() { onEditGame: custom.editGame, onRemoveGame: custom.removeGame, onAddEvent: custom.addEvent, + tasks: tasks.tasks, + onAddTask: tasks.addTask, + onEditTask: tasks.editTask, + onRemoveTask: tasks.removeTask, now, // The index lists rules; the sheet opens rows. A repeating rule's // own id is never a row — the lists hold its occurrences — so it is diff --git a/src/client/components/TaskForm.tsx b/src/client/components/TaskForm.tsx new file mode 100644 index 0000000..3c3bb58 --- /dev/null +++ b/src/client/components/TaskForm.tsx @@ -0,0 +1,233 @@ +import { useState } from "react"; +import type { LaneId } from "../../shared/custom.ts"; +import { TaskSchema } from "../../shared/task.ts"; +import { useGameMeta } from "../state/gameMeta.tsx"; +import type { TaskDraft } from "../state/useTasks.ts"; +import { parseAmount } from "./AmountEditor.tsx"; + +const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; + +/** The form's fields as typed, before they are a task. */ +export interface TaskFields { + game: string; + title: string; + unit: "week" | "month"; + weekday: number; + monthDay: number; + kind: "days" | "amount"; + required: string; + target: string; + amountUnit: string; +} + +/** + * The task these fields describe, or null while they do not describe one the + * store would accept. Checked against `TaskSchema` itself, so the form can + * never offer Save on something the store would then refuse. + */ +export function taskDraftFrom(f: TaskFields): TaskDraft | null { + const period = + f.unit === "week" + ? ({ unit: "week", startsOn: f.weekday } as const) + : ({ unit: "month", startsOn: f.monthDay } as const); + const unit = f.amountUnit.trim(); + const target = parseAmount(f.target); + const measure = + f.kind === "days" + ? ({ kind: "days", required: Number.parseInt(f.required, 10) } as const) + : target === null + ? null + : ({ kind: "amount", target, ...(unit === "" ? {} : { unit }) } as const); + if (measure === null) return null; + const draft: TaskDraft = { game: f.game, title: f.title.trim(), period, measure }; + const check = TaskSchema.safeParse({ ...draft, id: "mytask:check00000", at: "", updatedAt: "" }); + return check.success ? draft : null; +} + +function pill(on: boolean): string { + return `rounded-full border px-3 py-1.5 text-xs font-medium transition-colors duration-150 ${ + on ? "border-near/70 bg-near/15 text-near" : "border-hairline text-faint hover:text-muted" + }`; +} + +const field = + "rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none"; + +/** + * Add or change a weekly or monthly task. + * + * Every answer has a default a reader can accept unread — weekly, from Monday, + * ticked off once — because most of these games roll their week on Monday at + * reset and most chores are done-or-not. The number is the one thing with no + * sensible default, so choosing "Reach a number" asks for it. + */ +export function TaskForm({ + lanes, + initial, + onSave, + onCancel, +}: { + lanes: LaneId[]; + initial?: TaskDraft | undefined; + onSave: (draft: TaskDraft) => void; + onCancel: () => void; +}) { + const gameMeta = useGameMeta(); + const [f, setF] = useState(() => ({ + game: initial?.game ?? lanes[0] ?? "", + title: initial?.title ?? "", + unit: initial?.period.unit ?? "week", + weekday: initial?.period.unit === "week" ? initial.period.startsOn : 1, + monthDay: initial?.period.unit === "month" ? initial.period.startsOn : 1, + kind: initial?.measure.kind ?? "days", + required: initial?.measure.kind === "days" ? String(initial.measure.required) : "1", + target: initial?.measure.kind === "amount" ? String(initial.measure.target) : "", + amountUnit: initial?.measure.kind === "amount" ? (initial.measure.unit ?? "") : "", + })); + const set = (patch: Partial) => setF((prev) => ({ ...prev, ...patch })); + const draft = taskDraftFrom(f); + + return ( +
{ + e.preventDefault(); + if (draft !== null) onSave(draft); + }} + > +
+ + +
+ +

How often

+
+ + + {f.unit === "week" ? ( + + ) : ( + + )} +
+

+ It rolls over at the game's daily reset on that day, on your server. +

+ +

Done when

+
+ + +
+
+ {f.kind === "days" ? ( + <> + set({ required: e.target.value })} + className={`tnum w-14 ${field}`} + /> + {f.required === "1" ? "day" : "days"} this {f.unit} + + ) : ( + <> + set({ target: e.target.value })} + placeholder="2100" + className={`tnum w-20 ${field}`} + /> + set({ amountUnit: e.target.value })} + placeholder="points" + className={`w-24 ${field}`} + /> + this {f.unit} + + )} +
+ +
+ + +
+
+ ); +} diff --git a/src/client/components/YourOwn.tsx b/src/client/components/YourOwn.tsx index 25470de..e6c8727 100644 --- a/src/client/components/YourOwn.tsx +++ b/src/client/components/YourOwn.tsx @@ -4,6 +4,9 @@ import { formatAbsolute } from "../../shared/time.ts"; import type { EventDraft } from "../state/useCustom.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { cadenceLabel, EventForm, GameForm } from "./CustomForms.tsx"; +import { taskCaption, type Task } from "../../shared/task.ts"; +import type { TaskDraft } from "../state/useTasks.ts"; +import { TaskForm } from "./TaskForm.tsx"; /** * The reader's own games and events — a group of the settings panel (PRD F13). @@ -61,6 +64,10 @@ export function YourOwn({ onAddEvent, now, onOpen, + tasks = [], + onAddTask, + onEditTask, + onRemoveTask, }: { games: CustomGames; events: CustomEvents; @@ -77,9 +84,15 @@ export function YourOwn({ * actually show. */ onOpen: (id: string) => void; + /** Weekly and monthly tasks (PRD F17). The section shows when it can be edited. */ + tasks?: Task[] | undefined; + onAddTask?: ((draft: TaskDraft) => void) | undefined; + onEditTask?: ((id: string, draft: TaskDraft) => void) | undefined; + onRemoveTask?: ((id: string) => void) | undefined; }) { const gameMeta = useGameMeta(); - const [adding, setAdding] = useState<"game" | "event" | null>(null); + const [adding, setAdding] = useState<"game" | "event" | "task" | null>(null); + const [editingTask, setEditingTask] = useState(null); const [editing, setEditing] = useState(null); const [refusal, setRefusal] = useState(null); @@ -100,8 +113,8 @@ export function YourOwn({ // already carries both, and drew a second border under the first one.

- Track something this app doesn't cover, or an event a source missed. Your - dates are yours — they're never presented as coming from a wiki, and they + Track something this app doesn't cover, an event a source missed, or a + weekly or monthly chore. Your dates are yours — they're never presented as coming from a wiki, and they travel in your export.

@@ -250,8 +263,69 @@ export function YourOwn({ /> )} + {/* Tasks are not events — no window, no end — so they get their own list + rather than nesting under a game like an event does: the game is + already on each row, and a task of a tracked game has no row above. */} + {onAddTask !== undefined && tasks.length > 0 && ( + <> +

Your weekly and monthly tasks

+
    + {tasks.map((task) => ( +
  • +
    + {gameMeta(task.game).short} + {task.title} + + {taskCaption(task)} + + + {/* Deletes the task and nothing else: its ticks and numbers + stay where they are, as every log here does. */} + +
    +

    {taskCaption(task)}

    + {editingTask === task.id && ( + { + onEditTask?.(task.id, draft); + setEditingTask(null); + }} + onCancel={() => setEditingTask(null)} + /> + )} +
  • + ))} +
+ + )} + + {adding === "task" && onAddTask !== undefined && ( + { + onAddTask(draft); + setAdding(null); + }} + onCancel={() => setAdding(null)} + /> + )} + {adding === null && ( -
+
+ {onAddTask !== undefined && ( + + )}
)}
diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index 2d61014..e00be35 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -10,6 +10,7 @@ import { strandedNotice, } from "../src/client/components/CustomForms.tsx"; import { eventCaption, YourOwn } from "../src/client/components/YourOwn.tsx"; +import { TaskForm, taskDraftFrom } from "../src/client/components/TaskForm.tsx"; import { EventRow } from "../src/client/components/EventRow.tsx"; import { EventDetail } from "../src/client/components/EventDetail.tsx"; import { AUTHOR, Colophon, REPO_URL, RESET_CLOCK_CREDIT } from "../src/client/components/Colophon.tsx"; @@ -1248,3 +1249,65 @@ describe("the index covers events filed under a tracked game", () => { expect(html).toContain("ended"); }); }); + +describe("the reader's weekly and monthly tasks in settings", () => { + const noop = () => {}; + const base = { + games: GAMES, + events: {}, + lanes: ["zzz", "mygame:limbus-company"], + onAddGame: noop, + onEditGame: noop, + onRemoveGame: () => ({ removed: true, blockedBy: 0 }), + onAddEvent: noop, + now: Date.parse("2026-10-01T12:00:00.000Z"), + onOpen: noop, + onAddTask: noop, + onEditTask: noop, + onRemoveTask: noop, + }; + const task = { + id: "mytask:aaaaaa1111", game: "zzz", title: "Activity points", + period: { unit: "week" as const, startsOn: 1 }, + measure: { kind: "amount" as const, target: 2100, unit: "points" }, + at: AT, updatedAt: AT, + }; + + test("lists a task with what it asks for", () => { + const html = render(); + expect(html).toContain("Activity points"); + expect(html).toContain("every week from Monday · reach 2,100 points"); + expect(html).toContain("Add a task"); + }); + + test("with none yet, the way to add one is still there", () => { + expect(render()).toContain("Add a task"); + }); + + test("the form starts weekly, from Monday, ticked off once", () => { + const html = render(); + expect(html).toMatch(/aria-pressed="true"[^>]*>Every weekMonday<\/option>/); + expect(html).toMatch(/aria-pressed="true"[^>]*>Tick it off { + const fields = { + game: "zzz", title: "Activity points", unit: "week" as const, weekday: 1, monthDay: 1, + kind: "amount" as const, required: "1", target: "2,100", amountUnit: "points", + }; + expect(taskDraftFrom(fields)).toEqual({ + game: "zzz", title: "Activity points", + period: { unit: "week", startsOn: 1 }, + measure: { kind: "amount", target: 2100, unit: "points" }, + }); + expect(taskDraftFrom({ ...fields, title: " " })).toBeNull(); + expect(taskDraftFrom({ ...fields, target: "" })).toBeNull(); + expect(taskDraftFrom({ ...fields, kind: "days", required: "9", unit: "month", monthDay: 31 })) + .toEqual({ + game: "zzz", title: "Activity points", + period: { unit: "month", startsOn: 31 }, + measure: { kind: "days", required: 9 }, + }); + }); +});