From 1edce3121029aed201891afef13d45878e771da4 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 13 Oct 2026 13:47:46 +0200 Subject: [PATCH] feat(settings): add, edit and delete weekly and monthly tasks They live in Your own, beside the reader's games and events. Every field has a default the reader can accept unread (weekly, from Monday, ticked off once) except the target, which nobody can guess for them. Save is offered only when the store would accept what was typed. Co-Authored-By: Claude Opus 5.5 --- src/client/App.tsx | 4 + src/client/components/TaskForm.tsx | 233 +++++++++++++++++++++++++++++ src/client/components/YourOwn.tsx | 92 +++++++++++- test/custom-ui.test.tsx | 63 ++++++++ 4 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 src/client/components/TaskForm.tsx 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 }, + }); + }); +});