import { useState } from "react"; import type { LaneId } from "../../shared/custom.ts"; import { AmountTarget } from "../../shared/goal.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"; import { ExampleRow, field, labelClass, pill, StillNeeded } from "./formParts.tsx"; import { TASK_TEMPLATES, type TaskTemplate } from "../../shared/templates.ts"; 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; /** As typed, so the field can be emptied and retyped; read through `parseMonthDay`. */ monthDay: string | number; kind: "days" | "amount"; required: string; target: string; amountUnit: string; } /** A typed day of the month, clamped to 1–31; null while empty or unreadable. */ export function parseMonthDay(raw: string): number | null { const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? Math.min(31, Math.max(1, n)) : null; } /** * 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 { // Never a draft the still-needed line has an objection to, so Save and the // line beside it cannot disagree. if (taskStillNeeded(f).length > 0) return null; const monthDay = parseMonthDay(String(f.monthDay)); if (f.unit === "month" && monthDay === null) return null; const period = f.unit === "week" ? ({ unit: "week", startsOn: f.weekday } as const) : ({ unit: "month", startsOn: monthDay ?? 1 } as const); const unit = f.amountUnit.trim(); const target = parseAmount(f.target); const measure = f.kind === "days" ? ({ kind: "days", required: Number(f.required.trim()) } 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; } /** * These fields with an example's shape, and nothing else: the game, the name, * the target, the weekday and the month day stay as the reader typed them. */ export function withTaskTemplate(f: TaskFields, t: TaskTemplate): TaskFields { return { ...f, unit: t.sets.unit, kind: t.sets.kind, ...(t.sets.required === undefined ? {} : { required: t.sets.required }), ...(t.sets.amountUnit === undefined ? {} : { amountUnit: t.sets.amountUnit }), }; } /** * 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). */ export function taskStillNeeded(f: TaskFields): string[] { const needs: string[] = []; if (f.title.trim() === "") needs.push("a name"); if (f.unit === "month" && parseMonthDay(String(f.monthDay)) === null) { needs.push("a day of the month"); } if (f.kind === "days") { const n = Number(f.required.trim()); const most = f.unit === "week" ? 7 : 31; if (f.required.trim() === "" || !Number.isInteger(n) || n < 1 || n > most) { needs.push("how many days"); } } else { const target = parseAmount(f.target); if (target === null) needs.push("the target"); else if (target < 1) needs.push("a target of at least 1"); else if (!AmountTarget.safeParse(target).success) needs.push("a smaller target"); } return needs; } /** * 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 === "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 set = (patch: Partial) => setF((prev) => ({ ...prev, ...patch })); const [chosen, setChosen] = useState(null); const draft = taskDraftFrom(f); const needs = taskStillNeeded(f); return (
{ e.preventDefault(); if (draft !== null) onSave(draft); }} > {initial === undefined && ( { setChosen(t); setF((prev) => withTaskTemplate(prev, t)); }} /> )}

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} )}
); }