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:
co-authored by
Claude Opus 5.5
parent
0701e5e16e
commit
6c66b812b1
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>
|
||||
);
|
||||
}
|
||||
@@ -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" },
|
||||
},
|
||||
];
|
||||
@@ -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");
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user