feat(forms): start an event or a task from an example, and say what Save needs

Readers who knew what they wanted could not see which answers in Add an
event or Add a task produced it. Both add forms now open with a row of
examples (templates.ts) that set the form's shape only: kind, how often,
how it repeats, how it is measured. Never a name, date, target or game,
and no date is guessed, so nothing an example set can be saved unread.

Both forms also had a pass: plain words and pills for how often and
repeat, the name asked first and the kind after the dates, each message
beside its field, and a "Still needed" line read from the same check that
disables Save. The event form's derived state is now one pure function,
eventFormState, so the examples are proved to complete into a saveable
event without a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-14 03:10:15 +02:00
1 parent 0701e5e16e
commit 6c66b812b1
7 files changed
+1151 -365

No files matched your search

+64 -13
View File
@@ -1,9 +1,12 @@
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"];
@@ -33,6 +36,9 @@ export function parseMonthDay(raw: string): number | null {
* 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 =
@@ -43,7 +49,7 @@ export function taskDraftFrom(f: TaskFields): TaskDraft | null {
const target = parseAmount(f.target);
const measure =
f.kind === "days"
? ({ kind: "days", required: Number.parseInt(f.required, 10) } as const)
? ({ kind: "days", required: Number(f.required.trim()) } as const)
: target === null
? null
: ({ kind: "amount", target, ...(unit === "" ? {} : { unit }) } as const);
@@ -53,14 +59,45 @@ export function taskDraftFrom(f: TaskFields): TaskDraft | null {
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"
}`;
/**
* 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 }),
};
}
const field =
"rounded-lg border border-hairline bg-ground px-2 py-1 text-sm text-ink focus:border-faint focus:outline-none";
/**
* 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.
@@ -94,7 +131,9 @@ export function TaskForm({
amountUnit: initial?.measure.kind === "amount" ? (initial.measure.unit ?? "") : "",
}));
const set = (patch: Partial<TaskFields>) => setF((prev) => ({ ...prev, ...patch }));
const [chosen, setChosen] = useState<TaskTemplate | null>(null);
const draft = taskDraftFrom(f);
const needs = taskStillNeeded(f);
return (
<form
@@ -104,8 +143,19 @@ export function TaskForm({
if (draft !== null) onSave(draft);
}}
>
{initial === undefined && (
<ExampleRow
examples={TASK_TEMPLATES}
chosen={chosen?.id ?? null}
onChoose={(t) => {
setChosen(t);
setF((prev) => withTaskTemplate(prev, t));
}}
/>
)}
<div className="flex flex-wrap gap-3">
<label className="block text-xs font-medium text-muted">
<label className={labelClass}>
Game
<select
value={f.game}
@@ -119,14 +169,14 @@ export function TaskForm({
))}
</select>
</label>
<label className="block min-w-0 flex-1 text-xs font-medium text-muted">
Task
<label className={`${labelClass} min-w-48 flex-1`}>
Name
<input
autoFocus
value={f.title}
maxLength={80}
onChange={(e) => set({ title: e.target.value })}
placeholder="Weekly activity points"
placeholder={chosen?.placeholder ?? "Weekly activity points"}
className={`mt-1 block w-full ${field}`}
/>
</label>
@@ -221,10 +271,10 @@ export function TaskForm({
)}
</div>
<div className="mt-4 flex gap-2">
<div className="mt-4 flex flex-wrap gap-2">
<button
type="submit"
disabled={draft === null}
disabled={draft === null || needs.length > 0}
className="rounded-lg border border-transparent bg-ink px-3 py-1.5 text-xs font-medium text-ground transition-colors disabled:opacity-40"
>
Save task
@@ -236,6 +286,7 @@ export function TaskForm({
>
Cancel
</button>
<StillNeeded needs={needs} />
</div>
</form>
);