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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-14 03:10:15 +02:00
co-authored by Claude Opus 5.5
parent 0701e5e16e
commit 6c66b812b1
7 changed files with 1151 additions and 365 deletions
File diff suppressed because it is too large Load Diff
+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>
);
+81
View File
@@ -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>
);
}
+114
View File
@@ -0,0 +1,114 @@
import type { Cadence, RepeatMode } from "./recurrence.ts";
import type { EventType } from "./schema.ts";
/**
* Examples to start *Add an event* and *Add a task* from.
*
* A reader who knows what they want — a banner that ends on the 28th, 1300
* points a week — could not see which answers in the form produce it. One tap
* here gives the form that shape, and the reader fills in the rest.
*
* **An example sets a shape, never a fact.** No title (the example's text is
* only a placeholder), no date, no time, no target, no game: those are claims
* about a game, and a figure the reader did not type would read as one we
* sourced — the bar `routines.ts` holds built-ins to. No date is guessed
* either, not even "today": games reset on different days, and a guessed date
* that looks deliberate is the failure this product exists to avoid. The form
* keeps Save disabled until every date and figure the shape needs is typed, so
* nothing an example set can be saved unread.
*/
export interface EventTemplate {
/** Kebab-case, unique within the list. */
id: string;
/** The chip's text. */
label: string;
/** The name field's placeholder while this example is chosen. */
placeholder: string;
/** One line: what is left for the reader to fill in. */
hint: string;
sets: { type: EventType; cadence: Cadence; repeatMode: RepeatMode; endKnown: boolean };
}
export interface TaskTemplate {
id: string;
label: string;
placeholder: string;
hint: string;
sets: {
unit: "week" | "month";
kind: "days" | "amount";
/** As typed; "" empties the field. Only "once" is ever a shape. */
required?: string;
amountUnit?: string;
};
}
export const EVENT_TEMPLATES: readonly EventTemplate[] = [
{
id: "banner",
label: "Banner with a known end",
placeholder: "Limited banner",
hint: "Fill in when it starts and when it ends.",
sets: { type: "banner", cadence: "one-off", repeatMode: "never", endKnown: true },
},
{
id: "login",
label: "Login event",
placeholder: "Login bonus",
hint: "Fill in when it starts and when it ends, or tick that you don't know.",
sets: { type: "login", cadence: "one-off", repeatMode: "never", endKnown: true },
},
{
id: "monthly-shop",
label: "Monthly shop reset",
placeholder: "Shop reset",
hint: "Fill in a day it resets on. Each one runs until the next.",
sets: { type: "shop", cadence: "monthly", repeatMode: "never", endKnown: true },
},
{
id: "endgame-cycle",
label: "Recurring endgame cycle",
placeholder: "Endgame cycle",
hint: "Fill in one cycle's start and end; how often it comes back is worked out from them.",
sets: { type: "challenge", cadence: "custom", repeatMode: "forever", endKnown: true },
},
{
id: "maintenance",
label: "Maintenance",
placeholder: "Maintenance",
hint: "Fill in when it starts and ends, with times.",
sets: { type: "maintenance", cadence: "one-off", repeatMode: "never", endKnown: true },
},
];
export const TASK_TEMPLATES: readonly TaskTemplate[] = [
{
id: "weekly-points",
label: "Weekly points target",
placeholder: "Weekly points",
hint: "Fill in the target, and the day the week rolls over if it isn't Monday.",
sets: { unit: "week", kind: "amount", amountUnit: "points" },
},
{
id: "few-days",
label: "A few days each week",
placeholder: "Weekly chore",
hint: "Fill in how many days a week it needs.",
sets: { unit: "week", kind: "days", required: "" },
},
{
id: "monthly-shop",
label: "Monthly shop buy-out",
placeholder: "Monthly shop",
hint: "Fill in the day the month rolls over.",
sets: { unit: "month", kind: "days", required: "1" },
},
{
id: "monthly-target",
label: "Monthly target",
placeholder: "Monthly target",
hint: "Fill in the target and what it counts.",
sets: { unit: "month", kind: "amount" },
},
];
+8 -7
View File
@@ -555,7 +555,7 @@ describe("stating a repeat", () => {
/>,
);
expect(html).toContain("Repeat");
expect(html).toContain("with a delay");
expect(html).toContain("Comes back after a wait");
});
test("a rule that reopens as it closes shows its cadence rather than a control", () => {
@@ -940,10 +940,11 @@ describe("the cadence control", () => {
test("a fresh form offers all five answers and opens on one-off", () => {
const html = render();
expect(html).toContain("Cadence");
for (const answer of ["one-off", "daily", "weekly", "monthly", "custom"]) {
expect(html).toContain("How often");
for (const answer of ["Once", "Every day", "Every week", "Every month", "On its own cycle"]) {
expect(html).toContain(answer);
}
expect(html).toMatch(/aria-pressed="true"[^>]*>Once</);
// A one-off is what the form was before any of this, so it still asks for
// an end and still lets the reader say they do not know it.
expect(html).toContain("Ends");
@@ -971,25 +972,25 @@ describe("the cadence control", () => {
const daily = render(
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "days", interval: 1, until: null } }),
);
expect(daily).toContain('value="daily" selected=""');
expect(daily).toMatch(/aria-pressed="true"[^>]*>Every day</);
const monthly = render(
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "months", interval: 1, until: null } }),
);
expect(monthly).toContain('value="monthly" selected=""');
expect(monthly).toMatch(/aria-pressed="true"[^>]*>Every month</);
});
test("a dated recurring event reopens as a custom, with its window intact", () => {
// Presets carry no window, so anything that states one has to land here —
// and the end date it stated has to still be on screen.
const html = render(event({ repeat: { unit: "days", interval: 26, until: null } }));
expect(html).toContain('value="custom" selected=""');
expect(html).toMatch(/aria-pressed="true"[^>]*>On its own cycle</);
expect(html).toContain("Ends");
expect(html).toContain("Repeat");
});
test("a one-off shows no repeat machinery at all", () => {
const html = render(event());
expect(html).toContain('value="one-off" selected=""');
expect(html).toMatch(/aria-pressed="true"[^>]*>Once</);
expect(html).not.toContain("Cycle length");
expect(html).not.toContain("Wait");
expect(html).not.toContain("worked out from your dates");
+340
View File
@@ -0,0 +1,340 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { stillNeededLine } from "../src/client/components/formParts.tsx";
import {
TaskForm,
taskDraftFrom,
taskStillNeeded,
withTaskTemplate,
type TaskFields,
} from "../src/client/components/TaskForm.tsx";
import { TASK_TEMPLATES } from "../src/shared/templates.ts";
/**
* The examples row and the "still needed" line (spec 2026-10-14-form-examples).
*
* Nothing in this suite can click, so what a tap does is proved on the pure
* functions the forms call, and the markup tests only pin where the row shows.
*/
const noop = () => {};
const typed: TaskFields = {
game: "zzz",
title: "Ridu Weekly",
unit: "week",
weekday: 3,
monthDay: "15",
kind: "amount",
required: "2",
target: "1300",
amountUnit: "points",
};
describe("the still-needed line", () => {
test("reads as a sentence, and says nothing when nothing is missing", () => {
expect(stillNeededLine(["a name"])).toBe("Still needed: a name.");
expect(stillNeededLine(["a name", "a start date"])).toBe(
"Still needed: a name and a start date.",
);
expect(stillNeededLine(["a name", "a start date", "an end date"])).toBe(
"Still needed: a name, a start date and an end date.",
);
expect(stillNeededLine([])).toBeNull();
});
});
describe("a task example", () => {
const byId = (id: string) => TASK_TEMPLATES.find((t) => t.id === id)!;
test("keeps everything the reader typed that is not the shape", () => {
const after = withTaskTemplate(typed, byId("monthly-target"));
expect(after.game).toBe("zzz");
expect(after.title).toBe("Ridu Weekly");
expect(after.target).toBe("1300");
expect(after.weekday).toBe(3);
expect(after.monthDay).toBe("15");
expect(after.unit).toBe("month");
expect(after.kind).toBe("amount");
});
test("a few days each week leaves the count for the reader, and Save says so", () => {
const after = withTaskTemplate(typed, byId("few-days"));
expect(after.required).toBe("");
expect(taskStillNeeded(after)).toEqual(["how many days"]);
expect(taskDraftFrom(after)).toBeNull();
});
test("every example, once named and given its figure, can be saved", () => {
for (const t of TASK_TEMPLATES) {
const blank = { ...typed, title: "", target: "", required: "" };
const shaped = withTaskTemplate(blank, t);
expect(taskStillNeeded(shaped)).toContain("a name");
const filled = {
...shaped,
title: "x",
target: "100",
required: shaped.required === "" ? "2" : shaped.required,
};
expect(taskStillNeeded(filled)).toEqual([]);
expect(taskDraftFrom(filled)).not.toBeNull();
}
});
});
describe("what a task still needs", () => {
test("names each gap in the order the form asks", () => {
expect(taskStillNeeded({ ...typed, title: " " })).toEqual(["a name"]);
expect(taskStillNeeded({ ...typed, unit: "month", monthDay: "" })).toEqual([
"a day of the month",
]);
expect(taskStillNeeded({ ...typed, kind: "days", required: "" })).toEqual(["how many days"]);
expect(taskStillNeeded({ ...typed, kind: "days", required: "9" })).toEqual(["how many days"]);
expect(taskStillNeeded({ ...typed, unit: "month", kind: "days", required: "9" })).toEqual([]);
expect(taskStillNeeded({ ...typed, target: "" })).toEqual(["the target"]);
expect(taskStillNeeded(typed)).toEqual([]);
});
});
describe("the task form", () => {
test("adding offers the examples, and names its field Name", () => {
const html = renderToStaticMarkup(<TaskForm lanes={["zzz"]} onSave={noop} onCancel={noop} />);
expect(html).toContain("Start from an example");
for (const t of TASK_TEMPLATES) expect(html).toContain(t.label);
expect(html).toContain("Name");
// A fresh form has no name yet, and says so rather than greying Save out
// in silence.
expect(html).toContain("Still needed: a name.");
});
test("editing never shows the examples", () => {
const html = renderToStaticMarkup(
<TaskForm
lanes={["zzz"]}
initial={{
game: "zzz",
title: "Ridu Weekly",
period: { unit: "week", startsOn: 1 },
measure: { kind: "amount", target: 1300, unit: "points" },
}}
onSave={noop}
onCancel={noop}
/>,
);
expect(html).not.toContain("Start from an example");
expect(html).not.toContain("Still needed");
});
});
import {
EventForm,
eventFormState,
eventStillNeeded,
withCadence,
withEventTemplate,
type EventFormValues,
type EventShape,
} from "../src/client/components/CustomForms.tsx";
import { EVENT_TEMPLATES } from "../src/shared/templates.ts";
const blankEvent: EventFormValues = {
game: "mygame:limbus-company",
title: "",
summary: "",
type: "other",
cadence: "one-off",
repeatMode: "never",
endKnown: true,
ownCadence: false,
startDate: "",
startTime: "",
endDate: "",
endTime: "",
cadenceUnit: "weeks",
cadenceAmount: "1",
};
const eventById = (id: string) => EVENT_TEMPLATES.find((t) => t.id === id)!;
const shaped = (v: EventFormValues, id: string): EventFormValues => ({
...v,
...withEventTemplate(v, eventById(id)),
});
describe("an event example", () => {
test("sets the shape and nothing the reader typed", () => {
const typedEvent = {
...blankEvent,
title: "Walpurgisnacht",
startDate: "2026-10-01",
endDate: "2026-10-15",
summary: "pull for her",
ownCadence: true,
};
const after = shaped(typedEvent, "endgame-cycle");
expect(after.title).toBe("Walpurgisnacht");
expect(after.startDate).toBe("2026-10-01");
expect(after.endDate).toBe("2026-10-15");
expect(after.summary).toBe("pull for her");
expect(after.type).toBe("challenge");
expect(after.cadence).toBe("custom");
expect(after.repeatMode).toBe("forever");
// A cycle the reader stated by hand under another example is not theirs
// to keep under this one: the measured reading is shown again.
expect(after.ownCadence).toBe(false);
});
test("every example, once named and dated, can be saved", () => {
for (const t of EVENT_TEMPLATES) {
const v = {
...shaped(blankEvent, t.id),
title: "x",
startDate: "2026-10-01",
endDate: "2026-10-15",
};
const state = eventFormState(v, null);
expect({ id: t.id, needs: state.needs, valid: state.valid }).toEqual({
id: t.id,
needs: [],
valid: true,
});
expect(state.draft).not.toBeNull();
}
});
test("a fresh banner says what it still needs", () => {
expect(eventFormState(shaped(blankEvent, "banner"), null).needs).toEqual([
"a name",
"a start date",
"an end date",
]);
});
test("moving from a cycle to a banner leaves nothing repeating", () => {
const dated = { ...blankEvent, title: "x", startDate: "2026-10-01", endDate: "2026-10-15" };
const cycle = eventFormState(shaped(dated, "endgame-cycle"), null);
expect(cycle.repeat).not.toBeNull();
const banner = eventFormState(shaped(shaped(dated, "endgame-cycle"), "banner"), null);
expect(banner.repeat).toBeNull();
expect(banner.valid).toBe(true);
});
});
describe("choosing how often", () => {
const shape: EventShape = {
type: "other",
cadence: "one-off",
repeatMode: "never",
endKnown: true,
ownCadence: false,
};
test("a cycle of its own never opens on 'doesn't repeat'", () => {
expect(withCadence(shape, "custom").repeatMode).toBe("forever");
});
test("an answer the reader already gave is kept", () => {
expect(withCadence({ ...shape, repeatMode: "delay" }, "custom").repeatMode).toBe("delay");
expect(withCadence({ ...shape, cadence: "custom", repeatMode: "forever" }, "one-off")).toEqual({
...shape,
repeatMode: "forever",
});
});
});
describe("what an event still needs", () => {
const base = {
title: "x",
startDate: "2026-10-01",
datedWindow: true,
endKnown: true,
endDate: "2026-10-15",
delayNeedsEnd: false,
needsCycle: false,
};
test("names each gap in the order the form asks", () => {
expect(eventStillNeeded(base)).toEqual([]);
expect(eventStillNeeded({ ...base, title: " " })).toEqual(["a name"]);
expect(eventStillNeeded({ ...base, startDate: "" })).toEqual(["a start date"]);
expect(eventStillNeeded({ ...base, endDate: "" })).toEqual(["an end date"]);
expect(eventStillNeeded({ ...base, endDate: "", endKnown: false })).toEqual([]);
expect(eventStillNeeded({ ...base, endDate: "", datedWindow: false })).toEqual([]);
expect(eventStillNeeded({ ...base, endKnown: false, delayNeedsEnd: true })).toEqual([
"an end date",
]);
expect(eventStillNeeded({ ...base, needsCycle: true })).toEqual(["a cycle length"]);
});
});
describe("the event form", () => {
const props = {
lanes: ["mygame:limbus-company"],
customGames: {},
onSave: noop,
onCancel: noop,
};
test("adding offers the examples, in plain words, and says what Save needs", () => {
const html = renderToStaticMarkup(<EventForm {...props} />);
expect(html).toContain("Start from an example");
for (const t of EVENT_TEMPLATES) expect(html).toContain(t.label);
expect(html).toContain("How often");
for (const answer of ["Once", "Every day", "Every week", "Every month", "On its own cycle"]) {
expect(html).toContain(`>${answer}</button>`);
}
expect(html).toMatch(/aria-pressed="true"[^>]*>Once</);
expect(html).toContain(">Login rewards</option>");
expect(html).toContain("Name");
expect(html).toContain("Still needed: a name, a start date and an end date.");
});
test("the name is asked before how often, and the kind after the dates", () => {
const html = renderToStaticMarkup(<EventForm {...props} />);
const at = (s: string) => html.indexOf(s);
expect(at("Name")).toBeLessThan(at("How often"));
expect(at("How often")).toBeLessThan(at("Starts"));
expect(at("Starts")).toBeLessThan(at("Kind"));
expect(at("Kind")).toBeLessThan(at("Note (optional)"));
});
});
describe("review fixes: Save never sits disabled without a reason", () => {
test("a task's needs are empty exactly when it would save", () => {
const cases: TaskFields[] = [
typed,
{ ...typed, target: "0" },
{ ...typed, target: "0.4" },
{ ...typed, target: "2000000000" },
{ ...typed, target: "" },
{ ...typed, title: "" },
{ ...typed, kind: "days", required: "" },
{ ...typed, kind: "days", required: "7" },
{ ...typed, kind: "days", required: "8" },
{ ...typed, unit: "month", kind: "days", required: "31" },
{ ...typed, unit: "month", monthDay: "" },
];
for (const f of cases) {
expect({ target: f.target, required: f.required, empty: taskStillNeeded(f).length === 0 })
.toEqual({ target: f.target, required: f.required, empty: taskDraftFrom(f) !== null });
}
expect(taskStillNeeded({ ...typed, target: "0" })).toEqual(["a target of at least 1"]);
});
test("an emptied wait is asked for, not blamed on the times", () => {
const v: EventFormValues = {
...blankEvent,
title: "x",
cadence: "custom",
repeatMode: "delay",
startDate: "2026-10-01",
endDate: "2026-10-07",
cadenceAmount: "",
};
const s = eventFormState(v, null);
expect(s.valid).toBe(false);
expect(s.needs).toEqual(["a wait"]);
expect(s.unstatableWait).toBe(false);
const filled = eventFormState({ ...v, cadenceAmount: "1" }, null);
expect(filled.valid).toBe(true);
expect(filled.needs).toEqual([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { EVENT_TEMPLATES, TASK_TEMPLATES } from "../src/shared/templates.ts";
// An example shapes a form; it never states a fact about a game. These pin
// that bar on the data itself, so a new example cannot slip a date or a
// figure in under the label of a shape.
describe("the examples", () => {
test("ids are unique within each list", () => {
for (const list of [EVENT_TEMPLATES, TASK_TEMPLATES]) {
const ids = list.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
}
});
test("an event example sets only the shape", () => {
for (const t of EVENT_TEMPLATES) {
expect(Object.keys(t.sets).sort()).toEqual(["cadence", "endKnown", "repeatMode", "type"]);
}
});
test("a task example sets only the shape, and never a target", () => {
const shape = new Set(["unit", "kind", "required", "amountUnit"]);
for (const t of TASK_TEMPLATES) {
for (const key of Object.keys(t.sets)) expect(shape.has(key)).toBe(true);
}
});
test("a day count is set only to say 'once', or to empty the field", () => {
const set = TASK_TEMPLATES.filter((t) => t.sets.required !== undefined).map((t) => [
t.id,
t.sets.required,
]);
expect(set).toEqual([
["few-days", ""],
["monthly-shop", "1"],
]);
});
test("every example names itself, explains what is left, and offers a placeholder", () => {
for (const t of [...EVENT_TEMPLATES, ...TASK_TEMPLATES]) {
expect(t.label.length).toBeGreaterThan(0);
expect(t.hint.startsWith("Fill in")).toBe(true);
expect(t.placeholder.length).toBeGreaterThan(0);
}
});
});