From 6c66b812b1f37bda180043af699cb98ef342f349 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 14 Oct 2026 03:10:15 +0200 Subject: [PATCH] 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 --- src/client/components/CustomForms.tsx | 843 +++++++++++++++----------- src/client/components/TaskForm.tsx | 77 ++- src/client/components/formParts.tsx | 81 +++ src/shared/templates.ts | 114 ++++ test/custom-ui.test.tsx | 15 +- test/form-examples.test.tsx | 340 +++++++++++ test/templates.test.ts | 46 ++ 7 files changed, 1151 insertions(+), 365 deletions(-) create mode 100644 src/client/components/formParts.tsx create mode 100644 src/shared/templates.ts create mode 100644 test/form-examples.test.tsx create mode 100644 test/templates.test.ts diff --git a/src/client/components/CustomForms.tsx b/src/client/components/CustomForms.tsx index eb239da..708a9b6 100644 --- a/src/client/components/CustomForms.tsx +++ b/src/client/components/CustomForms.tsx @@ -21,6 +21,8 @@ import { import { EventType } from "../../shared/schema.ts"; import { useGameMeta } from "../state/gameMeta.tsx"; import { readerInstant, type EventDraft } from "../state/useCustom.ts"; +import { EVENT_TEMPLATES, type EventTemplate } from "../../shared/templates.ts"; +import { ExampleRow, field, pill, StillNeeded } from "./formParts.tsx"; /** * Entering a game and an event yourself (PRD F13). @@ -275,7 +277,7 @@ function labelClass(): string { } function inputClass(): string { - return "mt-1 w-full rounded-lg border border-hairline bg-ground px-3 py-2 text-sm text-ink outline-none focus:border-faint"; + return `mt-1 w-full ${field}`; } export function GameForm({ @@ -359,6 +361,242 @@ function fields(iso: string | null): { date: string; time: string } { }; } +/** The kinds, in the words a reader uses. What is stored stays the enum value. */ +const KIND_LABEL: Record = { + banner: "Banner", + story: "Story", + rerun: "Rerun", + challenge: "Challenge / endgame", + login: "Login rewards", + shop: "Shop", + maintenance: "Maintenance", + other: "Other", +}; + +const CADENCES: readonly { value: Cadence; label: string }[] = [ + { value: "one-off", label: "Once" }, + { value: "daily", label: "Every day" }, + { value: "weekly", label: "Every week" }, + { value: "monthly", label: "Every month" }, + { value: "custom", label: "On its own cycle" }, +]; + +const REPEAT_MODES: readonly { value: RepeatMode; label: string }[] = [ + { value: "never", label: "Doesn't repeat" }, + { value: "forever", label: "Comes back as it ends" }, + { value: "delay", label: "Comes back after a wait" }, +]; + +/** + * The answers that decide which questions the event form asks — what an + * example sets (`src/shared/templates.ts`), plus whether the reader has taken + * the cycle length over from the measured one. + */ +export interface EventShape { + type: EventType; + cadence: Cadence; + repeatMode: RepeatMode; + endKnown: boolean; + ownCadence: boolean; +} + +/** Everything the event form holds, as typed. */ +export interface EventFormValues extends EventShape { + game: LaneId; + title: string; + summary: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + cadenceUnit: RepeatUnit; + cadenceAmount: string; +} + +/** + * A shape with an example applied. Only the shape: the name, the dates, the + * note and the game are the reader's and stay as typed. A cycle length they + * took over by hand goes back to the measured one, because it was stated for + * whatever they had before, not for this. + */ +export function withEventTemplate(shape: EventShape, t: EventTemplate): EventShape { + return { ...shape, ...t.sets, ownCadence: false }; +} + +/** + * A shape with a new "how often". Choosing *On its own cycle* while the repeat + * says *Doesn't repeat* moves it to *Comes back as it ends*: a cycle that never + * comes back is a contradiction on screen, and the plain one-off is already + * *Once*. Any other answer the reader gave is kept. + */ +export function withCadence(shape: EventShape, cadence: Cadence): EventShape { + return { + ...shape, + cadence, + repeatMode: cadence === "custom" && shape.repeatMode === "never" ? "forever" : shape.repeatMode, + }; +} + +/** + * What the event still lacks before it can be saved, in the order the form + * asks. Mistakes — dates the wrong way round, a cycle that comes round early — + * are not in it: those are said in critical beside their field. + */ +export function eventStillNeeded(s: { + title: string; + startDate: string; + datedWindow: boolean; + endKnown: boolean; + endDate: string; + delayNeedsEnd: boolean; + needsCycle: boolean; + needsWait?: boolean; +}): string[] { + const needs: string[] = []; + if (s.title.trim() === "") needs.push("a name"); + if (s.startDate === "") needs.push("a start date"); + if ((s.datedWindow && s.endKnown && s.endDate === "") || s.delayNeedsEnd) { + needs.push("an end date"); + } + if (s.needsCycle) needs.push("a cycle length"); + if (s.needsWait) needs.push("a wait"); + return needs; +} + +/** + * Everything the event form works out from what is typed — the instants, the + * rule, each mistake, what is still needed, and the draft a save would write. + * + * One pure function rather than a block inside the render, so the examples + * can be proved to complete into a saveable event without a click, and so the + * still-needed line and Save's disabled state read the same answer. + */ +export function eventFormState(v: EventFormValues, existingUntil: string | null) { + // A preset's period is its window, so it has no end to state. The end the + // reader may have typed under a different cadence is kept in state rather + // than cleared — hiding a field and quietly discarding what is in it is how + // a form loses somebody's work when they change their mind back. + const preset = v.cadence === "daily" || v.cadence === "weekly" || v.cadence === "monthly"; + const datedWindow = v.cadence === "one-off" || v.cadence === "custom"; + + const startsAt = v.startDate === "" ? null : readerInstant(v.startDate, v.startTime, "start"); + const endsAt = + !datedWindow || !v.endKnown || v.endDate === "" + ? null + : readerInstant(v.endDate, v.endTime, "end"); + + const endMissing = datedWindow && v.endKnown && v.endDate !== "" && endsAt === null; + const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; + + const startMs = startsAt === null ? null : Date.parse(startsAt); + const endMs = endsAt === null ? null : Date.parse(endsAt); + + const amount = Number(v.cadenceAmount); + const amountValid = Number.isInteger(amount) && amount >= 1 && amount <= 365; + + // What the dates already say, when they say anything. Null covers both "no + // end given yet" and a window that is no whole number of any unit — two + // exact times a few hours apart — where rounding would move a boundary the + // reader chose, so the form asks instead. + const contiguousMs = endMs === null ? null : contiguousOpening(endMs, v.endTime !== ""); + const measured = + startMs === null || contiguousMs === null ? null : repeatSpanning(startMs, contiguousMs); + const measuring = v.repeatMode === "forever" && !v.ownCadence && measured !== null; + + // A delay is measured from the end, so it has nothing to work with until one + // is given. Forever still does: the reader states the cadence and each + // occurrence runs until the next opens. + const delayNeedsEnd = v.cadence === "custom" && v.repeatMode === "delay" && endMs === null; + + const repeat = repeatOf({ + cadence: v.cadence, + mode: v.repeatMode, + measuring, + measured, + startMs, + contiguousMs, + unit: v.cadenceUnit, + amount, + until: existingUntil, + }); + + // Only `custom` can be incomplete. A preset is one unit with no window and + // is therefore always sayable, and a one-off has no repeat to get wrong. + const needsCycle = + v.cadence === "custom" && v.repeatMode === "forever" && !measuring && !amountValid; + const needsWait = v.cadence === "custom" && v.repeatMode === "delay" && !amountValid; + // A wait that is given, against an end that is given, and still lands on no + // whole unit from the start: unstatable rather than unstated, so it is a + // mistake said beside the wait, not a gap in the still-needed line. + const unstatableWait = + v.cadence === "custom" && v.repeatMode === "delay" && !delayNeedsEnd && amountValid && + repeat === null; + const repeatIncomplete = + needsCycle || + (v.cadence === "custom" && v.repeatMode === "delay" && (delayNeedsEnd || repeat === null)); + + // The same predicate the schema refines on, so the form cannot start + // refusing saves the schema would accept or promising ones it will reject. + const earlyReturn = + startsAt !== null && + comesRoundEarly(Date.parse(startsAt), endsAt === null ? null : Date.parse(endsAt), repeat); + + const needs = eventStillNeeded({ + title: v.title, + startDate: v.startDate, + datedWindow, + endKnown: v.endKnown, + endDate: v.endDate, + delayNeedsEnd, + needsCycle, + needsWait, + }); + + const valid = + needs.length === 0 && + v.game !== "" && + startsAt !== null && + !backwards && + !endMissing && + endStated(datedWindow, v.endKnown, v.endDate) && + !earlyReturn && + !repeatIncomplete; + + const draft: EventDraft | null = + startsAt === null + ? null + : { + game: v.game, + title: v.title, + type: v.type, + summary: v.summary === "" ? null : v.summary, + startsAt, + startHasTime: v.startTime !== "", + endsAt, + endHasTime: v.endTime !== "", + repeat, + }; + + return { + preset, + datedWindow, + startsAt, + endsAt, + endMs, + measured, + measuring, + delayNeedsEnd, + repeat, + backwards, + endMissing, + earlyReturn, + unstatableWait, + needs, + valid, + draft, + }; +} + export function EventForm({ lanes, customGames, @@ -383,26 +621,6 @@ export function EventForm({ strandedBy?: ((draft: EventDraft) => number) | undefined; }) { const gameMeta = useGameMeta(); - const start = fields(initial?.startsAt ?? null); - const end = fields(initial?.endsAt ?? null); - - // Round-tripped through the same fields-then-readerInstant path the live - // form uses, rather than read straight off the record. The two disagree: a - // day-precision end is stored as whatever instant it was written at, and the - // form re-resolves it to the end of the reader's own day. Deriving the - // opening state from the stored value and everything after it from the - // re-resolved one is how the control opens saying "with a delay" about a - // rule that has no gap at all. - const initialStartTime = initial?.startPrecision === "exact" ? start.time : ""; - const initialEndTime = initial?.endPrecision === "exact" ? end.time : ""; - const initialStartsAt = - start.date === "" - ? null - : (readerInstant(start.date, initialStartTime, "start") ?? null); - const initialEndsAt = - initial?.endsAt == null || end.date === "" - ? null - : (readerInstant(end.date, initialEndTime, "end") ?? null); // The reader's own games first, and so the default too. Someone filling this // in by hand is usually doing it *because* the game isn't tracked; making @@ -412,139 +630,72 @@ export function EventForm({ (a, b) => Number(isCustomGameId(b)) - Number(isCustomGameId(a)), ); - const [game, setGame] = useState( - initial?.game ?? ordered[0] ?? Object.keys(customGames)[0] ?? "", - ); - const [title, setTitle] = useState(initial?.title ?? ""); - const [type, setType] = useState(initial?.type ?? "other"); - const [summary, setSummary] = useState(initial?.summary ?? ""); - const [startDate, setStartDate] = useState(start.date); - const [startTime, setStartTime] = useState( - initialStartTime, - ); - // Separate from an empty end date so "I don't know" is a thing the reader - // states, not a field they leave blank and hope about. - const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true); - // Asked before the dates, because it decides which of them are even - // questions: a preset's period is its window, so there is no end to type. - const [cadence, setCadence] = useState(() => - cadenceOf(initial?.endsAt ?? null, initial?.repeat ?? null), - ); - const [endDate, setEndDate] = useState(end.date); - const [endTime, setEndTime] = useState( - initialEndTime, - ); - // Three answers rather than a unit and a number, because "how often" is - // rarely the question a reader actually has. They know it comes back the - // moment it ends, or that it comes back after a wait; the cadence follows - // from that and from dates they have already typed. - const [repeatMode, setRepeatMode] = useState(() => - initial === undefined - ? "never" - : repeatModeOf( - Date.parse(initialStartsAt ?? initial.startsAt), - initialEndsAt === null - ? null - : contiguousOpening(Date.parse(initialEndsAt), initialEndTime !== ""), - initial.repeat, - ), - ); - // Measuring is the convenience, not a cage: an irregular rotation still has - // to be sayable when the first window does not describe it. - const [ownCadence, setOwnCadence] = useState(false); - const opening = openingControls(initialStartsAt, initialEndsAt, initialEndTime !== "", initial?.repeat ?? null); - const [cadenceUnit, setCadenceUnit] = useState(opening.unit); - const [cadenceAmount, setCadenceAmount] = useState(String(opening.amount)); - - // A preset's period is its window, so it has no end to state. The end the - // reader may have typed under a different cadence is kept in state rather - // than cleared — hiding a field and quietly discarding what is in it is how - // a form loses somebody's work when they change their mind back. - const preset = cadence === "daily" || cadence === "weekly" || cadence === "monthly"; - const datedWindow = cadence === "one-off" || cadence === "custom"; - - const startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start"); - const endsAt = - !datedWindow || !endKnown || endDate === "" - ? null - : readerInstant(endDate, endTime, "end"); - - const endMissing = datedWindow && endKnown && endDate !== "" && endsAt === null; - const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; - - const startMs = startsAt === null ? null : Date.parse(startsAt); - const endMs = endsAt === null ? null : Date.parse(endsAt); - - const amount = Number(cadenceAmount); - const amountValid = Number.isInteger(amount) && amount >= 1 && amount <= 365; - const existingUntil = initial?.repeat?.until ?? null; - - // What the dates already say, when they say anything. Null covers both "no - // end given yet" and a window that is no whole number of any unit — two - // exact times a few hours apart — where rounding would move a boundary the - // reader chose, so the form asks instead. - const contiguousMs = - endMs === null ? null : contiguousOpening(endMs, endTime !== ""); - const measured = - startMs === null || contiguousMs === null - ? null - : repeatSpanning(startMs, contiguousMs); - const measuring = repeatMode === "forever" && !ownCadence && measured !== null; - - // A delay is measured from the end, so it has nothing to work with until one - // is given. Forever still does: the reader states the cadence and each - // occurrence runs until the next opens. - const delayNeedsEnd = repeatMode === "delay" && endMs === null; - - const repeat = repeatOf({ - cadence, - mode: repeatMode, - measuring, - measured, - startMs, - contiguousMs, - unit: cadenceUnit, - amount, - until: existingUntil, - }); - - // Only `custom` can be incomplete. A preset is one unit with no window and - // is therefore always sayable, and a one-off has no repeat to get wrong. - const repeatIncomplete = - cadence === "custom" && - ((repeatMode === "forever" && !measuring && !amountValid) || - (repeatMode === "delay" && (delayNeedsEnd || repeat === null))); - - // The same predicate the schema refines on, so the form cannot start - // refusing saves the schema would accept or promising ones it will reject. - const earlyReturn = - startsAt !== null && - comesRoundEarly( - Date.parse(startsAt), - endsAt === null ? null : Date.parse(endsAt), - repeat, + const [v, setV] = useState(() => { + const start = fields(initial?.startsAt ?? null); + const end = fields(initial?.endsAt ?? null); + // Round-tripped through the same fields-then-readerInstant path the live + // form uses, rather than read straight off the record. The two disagree: a + // day-precision end is stored as whatever instant it was written at, and + // the form re-resolves it to the end of the reader's own day. Deriving the + // opening state from the stored value and everything after it from the + // re-resolved one is how the control opens saying "with a delay" about a + // rule that has no gap at all. + const startTime = initial?.startPrecision === "exact" ? start.time : ""; + const endTime = initial?.endPrecision === "exact" ? end.time : ""; + const initialStartsAt = + start.date === "" ? null : (readerInstant(start.date, startTime, "start") ?? null); + const initialEndsAt = + initial?.endsAt == null || end.date === "" + ? null + : (readerInstant(end.date, endTime, "end") ?? null); + const opening = openingControls( + initialStartsAt, + initialEndsAt, + endTime !== "", + initial?.repeat ?? null, ); + return { + game: initial?.game ?? ordered[0] ?? Object.keys(customGames)[0] ?? "", + title: initial?.title ?? "", + type: initial?.type ?? "other", + summary: initial?.summary ?? "", + startDate: start.date, + startTime, + // Separate from an empty end date so "I don't know" is a thing the + // reader states, not a field they leave blank and hope about. + endKnown: initial ? initial.endsAt !== null : true, + // Asked before the dates, because it decides which of them are even + // questions: a preset's period is its window, so there is no end to type. + cadence: cadenceOf(initial?.endsAt ?? null, initial?.repeat ?? null), + endDate: end.date, + endTime, + // Three answers rather than a unit and a number, because "how often" is + // rarely the question a reader actually has. They know it comes back the + // moment it ends, or that it comes back after a wait; the cadence follows + // from that and from dates they have already typed. + repeatMode: + initial === undefined + ? "never" + : repeatModeOf( + Date.parse(initialStartsAt ?? initial.startsAt), + initialEndsAt === null + ? null + : contiguousOpening(Date.parse(initialEndsAt), endTime !== ""), + initial.repeat, + ), + // Measuring is the convenience, not a cage: an irregular rotation still + // has to be sayable when the first window does not describe it. + ownCadence: false, + cadenceUnit: opening.unit, + cadenceAmount: String(opening.amount), + }; + }); + const set = (patch: Partial) => setV((prev) => ({ ...prev, ...patch })); + const [chosen, setChosen] = useState(null); - const valid = - title.trim().length > 0 && - game !== "" && - startsAt !== null && - !backwards && - !endMissing && - endStated(datedWindow, endKnown, endDate) && - !earlyReturn && - !repeatIncomplete; + const s = eventFormState(v, initial?.repeat?.until ?? null); + const { preset, datedWindow, endMs, measured, measuring, repeat, draft } = s; - const draft: EventDraft | null = - startsAt === null - ? null - : { - game, title, type, - summary: summary === "" ? null : summary, - startsAt, startHasTime: startTime !== "", - endsAt, endHasTime: endTime !== "", - repeat, - }; // Only a schedule change re-keys anything. Renaming does not — the token is // random precisely so fixing a typo never costs the marks attached to it. const stranded = @@ -554,20 +705,48 @@ export function EventForm({ : 0; const notice = strandedNotice(stranded); + const unitSelect = ( + + ); + return (
{ e.preventDefault(); - if (!valid || draft === null) return; + if (!s.valid || draft === null) return; onSave(draft); }} > + {initial === undefined && ( + { + setChosen(t); + setV((prev) => ({ ...prev, ...withEventTemplate(prev, t) })); + }} + /> + )} + - - {/* Asked before the dates because it decides which of them are even questions. A weekly chore has no end date worth typing — the week is the window — and a form that asks anyway is asking something with no - honest answer. */} - + honest answer. Pills rather than a select, so every answer is in view: + not seeing which answer produced what they wanted was the confusion. */} +

How often

+
+ {CADENCES.map((c) => ( + + ))} +
{preset && (

@@ -645,8 +810,8 @@ export function EventForm({ Starts setStartDate(e.target.value)} + value={v.startDate} + onChange={(e) => set({ startDate: e.target.value })} className={inputClass()} /> @@ -654,12 +819,18 @@ export function EventForm({ Time (optional) setStartTime(e.target.value)} + value={v.startTime} + onChange={(e) => set({ startTime: e.target.value })} className={inputClass()} /> + {v.startTime === "" && v.startDate !== "" && ( +

+ No time given, so this counts from the start of the day where you are — + and to the end of the day it finishes on. +

+ )} {/* Only a cadence that carries its own window asks about an end. The end is allowed to be unknown there, and says so out loud: making it @@ -670,21 +841,24 @@ export function EventForm({ + {!v.endKnown && ( +

{unknownEndNote(repeat)}

+ )} - {endKnown && ( + {v.endKnown && (
@@ -692,205 +866,183 @@ export function EventForm({ Time (optional) setEndTime(e.target.value)} + value={v.endTime} + onChange={(e) => set({ endTime: e.target.value })} className={inputClass()} />
)} + {s.backwards && ( +

That ends before it starts.

+ )} + {s.endMissing && ( +

That end date isn't a real date.

+ )} )} {/* Only a custom cadence needs any of this: a preset answers it by construction, and a one-off has nothing to answer. */} - {cadence === "custom" && ( + {v.cadence === "custom" && ( <> - - - {repeatMode !== "never" && endMs === null && ( -

- A delay needs an end date to be measured from. With none, each one - just runs until the next opens. -

- )} - - {/* Measured, and said out loud — a cadence the form worked out silently - would be a date the reader never agreed to, which is the one thing - this product does not do. */} - {measuring && measured !== null && ( -

- {cadenceLabel(measured)} · worked out from your dates.{" "} - -

- )} - - {repeatMode === "forever" && !measuring && ( -
- - + {m.label} + + ))}
- )} - {/* The way back. Taking the cadence over is meant to be a decision the - reader can undo, and without this it was a one-way door: nothing - else ever cleared `ownCadence`, so the measured reading could not - be recovered without abandoning the form. Only offered when there - is something to go back to. */} - {repeatMode === "forever" && ownCadence && measured !== null && ( -

- {" "} - · {cadenceLabel(measured)} -

- )} + {v.repeatMode !== "never" && endMs === null && ( +

+ A delay needs an end date to be measured from. With none, each one + just runs until the next opens. +

+ )} - {repeatMode === "delay" && ( - <> + {/* Measured, and said out loud — a cadence the form worked out silently + would be a date the reader never agreed to, which is the one thing + this product does not do. */} + {measuring && measured !== null && ( +

+ {cadenceLabel(measured)} · worked out from your dates.{" "} + +

+ )} + + {v.repeatMode === "forever" && !measuring && (
- + {unitSelect}
- {/* Both readings, so the reader can check one against the other: a - week's wait after a week's window is a fortnightly rule, and - seeing that spelled out is how they catch a wrong number. */} + )} + + {/* The way back. Taking the cadence over is meant to be a decision the + reader can undo, and without this it was a one-way door: nothing + else ever cleared `ownCadence`, so the measured reading could not + be recovered without abandoning the form. Only offered when there + is something to go back to. */} + {v.repeatMode === "forever" && v.ownCadence && measured !== null && (

- after it ends - {repeat !== null ? ` · ${cadenceLabel(repeat)}` : ""} + {" "} + · {cadenceLabel(measured)}

- {/* Every occurrence is anchored to the start, so the wait has to - land a whole number of units from it. It never can when one - boundary has a time of day and the other does not — nothing - steps from 10:00 to a midnight — and that is unstatable rather - than merely unstated, so it says so instead of leaving Save - disabled with no reason given. */} - {repeat === null && !delayNeedsEnd && ( -

- Give the start and the end both a time, or neither — a wait has - to land a whole number of days from the start, and these don't - line up. + )} + + {v.repeatMode === "delay" && ( + <> +

+ + {unitSelect} +
+ {/* Both readings, so the reader can check one against the other: a + week's wait after a week's window is a fortnightly rule, and + seeing that spelled out is how they catch a wrong number. */} +

+ after it ends + {repeat !== null ? ` · ${cadenceLabel(repeat)}` : ""}

- )} - - )} + {/* Every occurrence is anchored to the start, so the wait has to + land a whole number of units from it. It never can when one + boundary has a time of day and the other does not — nothing + steps from 10:00 to a midnight — and that is unstatable rather + than merely unstated, so it says so instead of leaving Save + disabled with no reason given. */} + {s.unstatableWait && ( +

+ Give the start and the end both a time, or neither — a wait has + to land a whole number of days from the start, and these don't + line up. +

+ )} + + )} )} - {earlyReturn && ( + {s.earlyReturn && (

That comes round before it ends.

)} + {/* A label rather than a question that changes the form, so it follows + the questions that do. An example sets it already. */} + + - {datedWindow && !endKnown && ( -

- {unknownEndNote(repeat)} -

- )} - {backwards && ( -

- That ends before it starts. -

- )} - {endMissing && ( -

That end date isn't a real date.

- )} - {startTime === "" && startDate !== "" && ( -

- No time given, so this counts from the start of the day where you are — - and to the end of the day it finishes on. -

- )} - {notice !== null && (

{notice}

)} -
+
+
); diff --git a/src/client/components/TaskForm.tsx b/src/client/components/TaskForm.tsx index 26270f6..4dc4d6d 100644 --- a/src/client/components/TaskForm.tsx +++ b/src/client/components/TaskForm.tsx @@ -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) => setF((prev) => ({ ...prev, ...patch })); + const [chosen, setChosen] = useState(null); const draft = taskDraftFrom(f); + const needs = taskStillNeeded(f); return (
+ {initial === undefined && ( + { + setChosen(t); + setF((prev) => withTaskTemplate(prev, t)); + }} + /> + )} +
-
-
+
+
); diff --git a/src/client/components/formParts.tsx b/src/client/components/formParts.tsx new file mode 100644 index 0000000..53e6de6 --- /dev/null +++ b/src/client/components/formParts.tsx @@ -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({ + examples, + chosen, + onChoose, +}: { + examples: readonly T[]; + chosen: string | null; + onChoose: (example: T) => void; +}) { + const hint = examples.find((e) => e.id === chosen)?.hint ?? null; + return ( +
+

Start from an example

+
+ {examples.map((e) => ( + + ))} +
+ {hint !== null &&

{hint}

} +
+ ); +} + +/** 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 : ( +

{line}

+ ); +} diff --git a/src/shared/templates.ts b/src/shared/templates.ts new file mode 100644 index 0000000..1834354 --- /dev/null +++ b/src/shared/templates.ts @@ -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" }, + }, +]; diff --git a/test/custom-ui.test.tsx b/test/custom-ui.test.tsx index b32437c..2f7d1e9 100644 --- a/test/custom-ui.test.tsx +++ b/test/custom-ui.test.tsx @@ -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 { 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]*>Every month { // 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 { const html = render(event()); - expect(html).toContain('value="one-off" selected=""'); + expect(html).toMatch(/aria-pressed="true"[^>]*>Once {}; + +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(); + 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( + , + ); + 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(); + 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}`); + } + expect(html).toMatch(/aria-pressed="true"[^>]*>OnceLogin rewards"); + 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(); + 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([]); + }); +}); diff --git a/test/templates.test.ts b/test/templates.test.ts new file mode 100644 index 0000000..1753494 --- /dev/null +++ b/test/templates.test.ts @@ -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); + } + }); +});