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:
1 parent
0701e5e16e
commit
6c66b812b1
7 files changed
+1151
-365
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* What *Add an event* and *Add a task* share: one field style, one pill, the
|
||||
* row of examples, and the sentence that says why Save is not ready.
|
||||
*
|
||||
* Kept in one place because the two forms drifted apart while each grew on
|
||||
* its own — two paddings for the same input, the same choice drawn as a
|
||||
* select in one and as pills in the other.
|
||||
*/
|
||||
|
||||
export const labelClass = "block text-xs font-medium text-muted";
|
||||
|
||||
/** DESIGN.md § Inputs: ground fill, hairline border, 8px corners, 8×12 padding, faint on focus. */
|
||||
export const field =
|
||||
"rounded-lg border border-hairline bg-ground px-3 py-2 text-sm text-ink outline-none focus:border-faint";
|
||||
|
||||
export function pill(on: boolean): string {
|
||||
return `rounded-full border px-3 py-1.5 text-xs font-medium transition-colors duration-150 disabled:opacity-40 ${
|
||||
on ? "border-near/70 bg-near/15 text-near" : "border-hairline text-faint hover:text-muted"
|
||||
}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Still needed: a name and a start date." — or null when nothing is.
|
||||
*
|
||||
* Save is disabled from the same list this reads, so the sentence and the
|
||||
* button cannot disagree. Before it, both forms greyed Save out with no word
|
||||
* of why, and a reader had to guess which of a dozen fields it wanted.
|
||||
*/
|
||||
export function stillNeededLine(needs: readonly string[]): string | null {
|
||||
if (needs.length === 0) return null;
|
||||
const list =
|
||||
needs.length === 1
|
||||
? needs[0]
|
||||
: `${needs.slice(0, -1).join(", ")} and ${needs[needs.length - 1]}`;
|
||||
return `Still needed: ${list}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of examples a new entry can start from (`src/shared/templates.ts`).
|
||||
*
|
||||
* Only ever on an add: an entry being edited already has its shape, and
|
||||
* re-shaping it from here would be one tap from rewriting a schedule.
|
||||
*/
|
||||
export function ExampleRow<T extends { id: string; label: string; hint: string }>({
|
||||
examples,
|
||||
chosen,
|
||||
onChoose,
|
||||
}: {
|
||||
examples: readonly T[];
|
||||
chosen: string | null;
|
||||
onChoose: (example: T) => void;
|
||||
}) {
|
||||
const hint = examples.find((e) => e.id === chosen)?.hint ?? null;
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<p className={labelClass}>Start from an example</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{examples.map((e) => (
|
||||
<button
|
||||
key={e.id}
|
||||
type="button"
|
||||
aria-pressed={e.id === chosen}
|
||||
onClick={() => onChoose(e)}
|
||||
className={pill(e.id === chosen)}
|
||||
>
|
||||
{e.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{hint !== null && <p className="mt-1.5 text-xs leading-relaxed text-faint">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The line beside Save, when there is anything to say. */
|
||||
export function StillNeeded({ needs }: { needs: readonly string[] }) {
|
||||
const line = stillNeededLine(needs);
|
||||
return line === null ? null : (
|
||||
<p className="self-center text-xs leading-relaxed text-faint">{line}</p>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user