From be3c1408d2ec5a8acbc087d74b18ca03d52f32d6 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 16 Oct 2026 05:30:32 +0200 Subject: [PATCH] feat(tasks): set up a task that repeats every N days Add a task gains "Every N days": a length of 2 to 366 days and the date a cycle began. Editing one keeps its length and start, and "days needed" is capped at the cycle's length. Co-Authored-By: Claude Opus 5.5 --- src/client/components/TaskForm.tsx | 106 +++++++++++++++++++++++------ test/custom-ui.test.tsx | 1 + test/form-examples.test.tsx | 50 ++++++++++++++ 3 files changed, 138 insertions(+), 19 deletions(-) diff --git a/src/client/components/TaskForm.tsx b/src/client/components/TaskForm.tsx index a341bc9..bc7037f 100644 --- a/src/client/components/TaskForm.tsx +++ b/src/client/components/TaskForm.tsx @@ -14,7 +14,7 @@ const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Satur export interface TaskFields { game: string; title: string; - unit: "week" | "month"; + unit: "week" | "month" | "cycle"; weekday: number; /** As typed, so the field can be emptied and retyped; read through `parseMonthDay`. */ monthDay: string | number; @@ -22,6 +22,10 @@ export interface TaskFields { required: string; target: string; amountUnit: string; + /** A cycle's length in days, as typed; read through `parseCycleDays`. */ + cycleDays: string; + /** The game-day a cycle began, `YYYY-MM-DD` from the date input; "" until chosen. */ + cycleFrom: string; } /** A typed day of the month, clamped to 1–31; null while empty or unreadable. */ @@ -30,6 +34,42 @@ export function parseMonthDay(raw: string): number | null { return Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : null; } +/** A typed cycle length: a whole number of days from 2 to 366, else null. */ +export function parseCycleDays(raw: string): number | null { + const n = Number(raw.trim()); + return raw.trim() !== "" && Number.isInteger(n) && n >= 2 && n <= 366 ? n : null; +} + +const isDayKey = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s); + +/** + * The fields for a task being edited, or the defaults for a new one. A cycle + * keeps its length and start, so saving an edit never turns it into a week. + */ +export function taskFieldsFrom(initial: TaskDraft | undefined, lanes: readonly LaneId[]): TaskFields { + const p = initial?.period; + return { + game: initial?.game ?? lanes[0] ?? "", + title: initial?.title ?? "", + unit: p?.unit ?? "week", + weekday: p?.unit === "week" ? p.startsOn : 1, + monthDay: String(p?.unit === "month" ? p.startsOn : 1), + cycleDays: String(p?.unit === "cycle" ? p.days : 14), + cycleFrom: p?.unit === "cycle" ? p.from : "", + 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 ?? "") : "", + }; +} + +/** The most days a "tick it off" count can ask for in this period. */ +function mostDays(f: TaskFields): number { + if (f.unit === "week") return 7; + if (f.unit === "month") return 31; + return Math.min(31, parseCycleDays(f.cycleDays) ?? 31); +} + /** * 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 @@ -44,7 +84,9 @@ export function taskDraftFrom(f: TaskFields): TaskDraft | null { const period = f.unit === "week" ? ({ unit: "week", startsOn: f.weekday } as const) - : ({ unit: "month", startsOn: monthDay ?? 1 } as const); + : f.unit === "month" + ? ({ unit: "month", startsOn: monthDay ?? 1 } as const) + : ({ unit: "cycle", days: parseCycleDays(f.cycleDays) ?? 0, from: f.cycleFrom } as const); const unit = f.amountUnit.trim(); const target = parseAmount(f.target); const measure = @@ -76,7 +118,8 @@ export function withTaskTemplate(f: TaskFields, t: TaskTemplate): TaskFields { /** * What these fields still lack before they are a task, in the order the form * asks. Empty exactly when `taskDraftFrom` would return a draft — the bounds - * here are the schema's (`AmountTarget`, 1–7 days a week, 1–31 a month). + * here are the schema's (`AmountTarget`, 1–7 days a week, 1–31 a month, a + * cycle's length up to 31). */ export function taskStillNeeded(f: TaskFields): string[] { const needs: string[] = []; @@ -84,9 +127,13 @@ export function taskStillNeeded(f: TaskFields): string[] { if (f.unit === "month" && parseMonthDay(String(f.monthDay)) === null) { needs.push("a day of the month"); } + if (f.unit === "cycle") { + if (parseCycleDays(f.cycleDays) === null) needs.push("a length of 2 to 366 days"); + if (!isDayKey(f.cycleFrom)) needs.push("a start date"); + } if (f.kind === "days") { const n = Number(f.required.trim()); - const most = f.unit === "week" ? 7 : 31; + const most = mostDays(f); if (f.required.trim() === "" || !Number.isInteger(n) || n < 1 || n > most) { needs.push("how many days"); } @@ -100,7 +147,7 @@ export function taskStillNeeded(f: TaskFields): string[] { } /** - * Add or change a weekly or monthly task. + * Add or change a weekly, monthly or every-N-days 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 @@ -119,17 +166,7 @@ export function TaskForm({ onCancel: () => void; }) { const gameMeta = useGameMeta(); - const [f, setF] = useState(() => ({ - game: initial?.game ?? lanes[0] ?? "", - title: initial?.title ?? "", - unit: initial?.period.unit === "month" ? "month" : "week", - weekday: initial?.period.unit === "week" ? initial.period.startsOn : 1, - monthDay: String(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 [f, setF] = useState(() => taskFieldsFrom(initial, lanes)); const set = (patch: Partial) => setF((prev) => ({ ...prev, ...patch })); const [chosen, setChosen] = useState(null); const draft = taskDraftFrom(f); @@ -190,7 +227,36 @@ export function TaskForm({ - {f.unit === "week" ? ( + + {f.unit === "cycle" ? ( + + + + + ) : f.unit === "week" ? (