Ask the cadence first, then only the dates it needs

The repeat controls sat after the dates and applied to whatever was there,
which meant a weekly chore still had to answer an end date it has no honest
answer to. Asking first inverts that: the cadence decides which dates are
even questions.

A preset carries no window. Weekly means the week is the window, so there is
no end to type and no ignorance to admit — five fields instead of eight, and
it stores exactly what the model already renders as back-to-back
occurrences. A dated recurring event is therefore a custom, which is where
the forever/delay control now lives; a one-off shows no repeat machinery at
all.

cadenceOf derives which of the five a saved event opens in, so a rule made
before this control existed opens in whichever answer describes it. Nothing
about the schema moved.

Switching cadence hides the end date rather than clearing it: hiding a field
and quietly discarding what is in it is how a form loses somebody's work
when they change their mind back.

The duplicate note was found by rendering the form and looking at it. Both
of the older notes explain the end-date field, so a preset — which has no
such field — was showing two sentences that said nearly the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-08-28 05:04:39 +02:00
co-authored by Claude Opus 5
parent 4c3aa4c7e3
commit 6dad359b2a
5 changed files with 434 additions and 140 deletions
+28 -4
View File
@@ -323,10 +323,34 @@ Four constraints, each protecting something that already exists:
which is the same argument the code already makes for streaks. This is the *only* copy — there is which is the same argument the code already makes for streaks. This is the *only* copy — there is
no server to restore from. no server to restore from.
A reader's event may also state how it comes round again — every N days, weeks A reader's event may also state how it comes round again. The form asks that
or months. The rule is stored; its occurrences are derived, and each one is an directly after **Kind**, before it asks for any date, because the answer
ordinary event everywhere in the app: its own countdown, its own completion, decides which dates are even questions:
its own daily checklist.
| Cadence | What it asks for | What it stores |
|---|---|---|
| one-off | start, end, or "I don't know when it ends" | no rule |
| daily / weekly / monthly | a start, and nothing else | no end, a one-unit cycle |
| custom | start, end, and how it repeats | whatever the reader states |
**A preset carries no window.** Choosing weekly says the week *is* the window —
each occurrence runs until the next opens — so there is no end date to type and
no ignorance to admit. A dated recurring event ("116 September, and again
every month") is therefore a `custom`, not a preset.
Under `custom` the reader says whether it repeats **forever** — reopening the
moment it closes — or **after a delay**, and the cadence is measured from the
dates they already gave rather than asked for again. A delay is stated as the
wait between closing and reopening, so it cannot describe a rule that comes
round before it ends; a hand-stated cycle length can, and is refused.
Which of the five a saved event opens in is derived from the rule itself, so an
event made before this control existed, or one that arrived by import, opens in
whichever answer actually describes it.
The rule is stored; its occurrences are derived, and each one is an ordinary
event everywhere in the app: its own countdown, its own completion, its own
daily checklist.
**The schedule can stop on a date (`until`), but the form has no control for **The schedule can stop on a date (`until`), but the form has no control for
setting one.** The field exists in the schema — descoped from the form during setting one.** The field exists in the schema — descoped from the form during
+187 -127
View File
@@ -7,11 +7,14 @@ import {
} from "../../shared/custom.ts"; } from "../../shared/custom.ts";
import { import {
addUnits, addUnits,
cadenceOf,
comesRoundEarly, comesRoundEarly,
movesOccurrences, movesOccurrences,
PRESET_UNIT,
repeatModeOf, repeatModeOf,
repeatSpanning, repeatSpanning,
RepeatUnit, RepeatUnit,
type Cadence,
type Repeat, type Repeat,
type RepeatMode, type RepeatMode,
} from "../../shared/recurrence.ts"; } from "../../shared/recurrence.ts";
@@ -375,6 +378,11 @@ export function EventForm({
// Separate from an empty end date so "I don't know" is a thing the reader // 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. // states, not a field they leave blank and hope about.
const [endKnown, setEndKnown] = useState(initial ? initial.endsAt !== null : true); 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<Cadence>(() =>
cadenceOf(initial?.endsAt ?? null, initial?.repeat ?? null),
);
const [endDate, setEndDate] = useState(end.date); const [endDate, setEndDate] = useState(end.date);
const [endTime, setEndTime] = useState( const [endTime, setEndTime] = useState(
initialEndTime, initialEndTime,
@@ -401,11 +409,20 @@ export function EventForm({
const [cadenceUnit, setCadenceUnit] = useState<RepeatUnit>(opening.unit); const [cadenceUnit, setCadenceUnit] = useState<RepeatUnit>(opening.unit);
const [cadenceAmount, setCadenceAmount] = useState(String(opening.amount)); 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 startsAt = startDate === "" ? null : readerInstant(startDate, startTime, "start");
const endsAt = const endsAt =
!endKnown || endDate === "" ? null : readerInstant(endDate, endTime, "end"); !datedWindow || !endKnown || endDate === ""
? null
: readerInstant(endDate, endTime, "end");
const endMissing = endKnown && endDate !== "" && endsAt === null; const endMissing = datedWindow && endKnown && endDate !== "" && endsAt === null;
const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt; const backwards = startsAt !== null && endsAt !== null && endsAt <= startsAt;
const startMs = startsAt === null ? null : Date.parse(startsAt); const startMs = startsAt === null ? null : Date.parse(startsAt);
@@ -432,20 +449,27 @@ export function EventForm({
// occurrence runs until the next opens. // occurrence runs until the next opens.
const delayNeedsEnd = repeatMode === "delay" && endMs === null; const delayNeedsEnd = repeatMode === "delay" && endMs === null;
const repeat = repeatOf({ const repeat = preset
mode: repeatMode, ? repeatFrom(PRESET_UNIT[cadence], 1, existingUntil)
measuring, : cadence === "one-off"
measured, ? null
startMs, : repeatOf({
contiguousMs, mode: repeatMode,
unit: cadenceUnit, measuring,
amount, measured,
until: existingUntil, 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 = const repeatIncomplete =
(repeatMode === "forever" && !measuring && !amountValid) || cadence === "custom" &&
(repeatMode === "delay" && (delayNeedsEnd || repeat === null)); ((repeatMode === "forever" && !measuring && !amountValid) ||
(repeatMode === "delay" && (delayNeedsEnd || repeat === null)));
// The same predicate the schema refines on, so the form cannot start // The same predicate the schema refines on, so the form cannot start
// refusing saves the schema would accept or promising ones it will reject. // refusing saves the schema would accept or promising ones it will reject.
@@ -547,6 +571,31 @@ export function EventForm({
</select> </select>
</label> </label>
{/* 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. */}
<label className={`${labelClass()} mt-3`}>
Cadence
<select
value={cadence}
onChange={(e) => setCadence(e.target.value as Cadence)}
className={inputClass()}
>
<option value="one-off">one-off</option>
<option value="daily">daily</option>
<option value="weekly">weekly</option>
<option value="monthly">monthly</option>
<option value="custom">custom</option>
</select>
</label>
{preset && (
<p className="mt-1.5 text-xs leading-relaxed text-faint">
Each one runs until the next opens, so there is no end date to give.
</p>
)}
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
<label className={labelClass()}> <label className={labelClass()}>
Starts Starts
@@ -568,115 +617,93 @@ export function EventForm({
</label> </label>
</div> </div>
{/* The end is allowed to be unknown, and says so out loud. Making it {/* 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
mandatory would push the reader into inventing a date, which is mandatory would push the reader into inventing a date, which is
exactly the failure the parsers are forbidden from committing. */} exactly the failure the parsers are forbidden from committing. */}
<label className="mt-3 flex cursor-pointer select-none items-center gap-2 text-xs text-muted"> {datedWindow && (
<input
type="checkbox"
checked={!endKnown}
onChange={(e) => setEndKnown(!e.target.checked)}
className="size-4 accent-[var(--color-near)]"
/>
I don't know when it ends
</label>
{endKnown && (
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Ends
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
Time (optional)
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className={inputClass()}
/>
</label>
</div>
)}
<label className={`${labelClass()} mt-3`}>
Repeat
<select
value={repeatMode}
onChange={(e) => setRepeatMode(e.target.value as RepeatMode)}
className={inputClass()}
>
<option value="never">never</option>
<option value="forever">forever</option>
<option value="delay" disabled={endMs === null}>
with a delay
</option>
</select>
</label>
{repeatMode !== "never" && endMs === null && (
<p className="mt-1.5 text-xs leading-relaxed text-faint">
A delay needs an end date to be measured from. With none, each one
just runs until the next opens.
</p>
)}
{/* 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 && (
<p className="mt-2 text-xs leading-relaxed text-faint">
{cadenceLabel(measured)} · worked out from your dates.{" "}
<button
type="button"
onClick={() => setOwnCadence(true)}
className="underline transition-colors hover:text-ink"
>
state it myself
</button>
</p>
)}
{repeatMode === "forever" && !measuring && (
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Cycle length
<input
type="number"
min={1}
max={365}
value={cadenceAmount}
onChange={(e) => setCadenceAmount(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
<span className="invisible">Unit</span>
<select
value={cadenceUnit}
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
className={inputClass()}
>
{RepeatUnit.options.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
</div>
)}
{repeatMode === "delay" && (
<> <>
<label className="mt-3 flex cursor-pointer select-none items-center gap-2 text-xs text-muted">
<input
type="checkbox"
checked={!endKnown}
onChange={(e) => setEndKnown(!e.target.checked)}
className="size-4 accent-[var(--color-near)]"
/>
I don't know when it ends
</label>
{endKnown && (
<div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}>
Ends
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
Time (optional)
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className={inputClass()}
/>
</label>
</div>
)}
</>
)}
{/* Only a custom cadence needs any of this: a preset answers it by
construction, and a one-off has nothing to answer. */}
{cadence === "custom" && (
<>
<label className={`${labelClass()} mt-3`}>
Repeat
<select
value={repeatMode}
onChange={(e) => setRepeatMode(e.target.value as RepeatMode)}
className={inputClass()}
>
<option value="never">never</option>
<option value="forever">forever</option>
<option value="delay" disabled={endMs === null}>
with a delay
</option>
</select>
</label>
{repeatMode !== "never" && endMs === null && (
<p className="mt-1.5 text-xs leading-relaxed text-faint">
A delay needs an end date to be measured from. With none, each one
just runs until the next opens.
</p>
)}
{/* 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 && (
<p className="mt-2 text-xs leading-relaxed text-faint">
{cadenceLabel(measured)} · worked out from your dates.{" "}
<button
type="button"
onClick={() => setOwnCadence(true)}
className="underline transition-colors hover:text-ink"
>
state it myself
</button>
</p>
)}
{repeatMode === "forever" && !measuring && (
<div className="mt-2 grid grid-cols-2 gap-2"> <div className="mt-2 grid grid-cols-2 gap-2">
<label className={labelClass()}> <label className={labelClass()}>
Wait Cycle length
<input <input
type="number" type="number"
min={1} min={1}
@@ -701,13 +728,46 @@ export function EventForm({
</select> </select>
</label> </label>
</div> </div>
{/* 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. */} {repeatMode === "delay" && (
<p className="mt-1.5 text-xs leading-relaxed text-faint"> <>
after it ends <div className="mt-2 grid grid-cols-2 gap-2">
{repeat !== null ? ` · ${cadenceLabel(repeat)}` : ""} <label className={labelClass()}>
</p> Wait
<input
type="number"
min={1}
max={365}
value={cadenceAmount}
onChange={(e) => setCadenceAmount(e.target.value)}
className={inputClass()}
/>
</label>
<label className={labelClass()}>
<span className="invisible">Unit</span>
<select
value={cadenceUnit}
onChange={(e) => setCadenceUnit(e.target.value as RepeatUnit)}
className={inputClass()}
>
{RepeatUnit.options.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</select>
</label>
</div>
{/* 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. */}
<p className="mt-1.5 text-xs leading-relaxed text-faint">
after it ends
{repeat !== null ? ` · ${cadenceLabel(repeat)}` : ""}
</p>
</>
)}
</> </>
)} )}
@@ -728,13 +788,13 @@ export function EventForm({
/> />
</label> </label>
{!endKnown && repeatMode === "never" && ( {datedWindow && !endKnown && cadence === "one-off" && (
<p className="mt-2 text-xs leading-relaxed text-faint"> <p className="mt-2 text-xs leading-relaxed text-faint">
It'll show with no countdown and no daily checklist, the same as an It'll show with no countdown and no daily checklist, the same as an
event whose source hasn't announced an end. event whose source hasn't announced an end.
</p> </p>
)} )}
{!endKnown && repeatMode !== "never" && ( {datedWindow && !endKnown && cadence === "custom" && (
/* Not a degraded answer here — the interval bounds it. */ /* Not a degraded answer here — the interval bounds it. */
<p className="mt-2 text-xs leading-relaxed text-faint"> <p className="mt-2 text-xs leading-relaxed text-faint">
Each one runs until the next one opens, so it still counts down. Each one runs until the next one opens, so it still counts down.
+46
View File
@@ -151,6 +151,52 @@ export function repeatSpanning(fromMs: number, toMs: number): Repeat | null {
/** The three answers the form offers for "does this come round again?". */ /** The three answers the form offers for "does this come round again?". */
export type RepeatMode = "never" | "forever" | "delay"; export type RepeatMode = "never" | "forever" | "delay";
/** The five answers the form offers for "how often is this?". */
export type Cadence = "one-off" | "daily" | "weekly" | "monthly" | "custom";
const PRESET_OF: Record<RepeatUnit, Cadence> = {
days: "daily",
weeks: "weekly",
months: "monthly",
};
/**
* The unit each preset stands for — the inverse of the map above, kept beside
* it so the two cannot drift apart. A preset is always an interval of one:
* that is what makes it a preset rather than a cycle length.
*/
export const PRESET_UNIT: Record<"daily" | "weekly" | "monthly", RepeatUnit> = {
daily: "days",
weekly: "weeks",
monthly: "months",
};
/**
* Which of the form's five answers describes a saved event.
*
* Derived rather than stored, for the reason `repeatModeOf` is: a rule made
* before this control existed, or one that arrived by import, has to open in
* whichever answer actually fits it rather than in whichever happens to be the
* default.
*
* **A preset carries no window.** Choosing daily, weekly or monthly says the
* period *is* the window — each occurrence runs until the next opens — so an
* event that states its own end is saying something no preset can, and belongs
* under `custom` where that is sayable. The same goes for a series that stops:
* there is no control for `until` in a preset, and opening one there would
* offer to save a rule quietly stripped of the date it ends on.
*/
export function cadenceOf(
endsAt: string | null,
repeat: Repeat | null,
): Cadence {
if (repeat === null) return "one-off";
if (endsAt !== null || repeat.until !== null || repeat.interval !== 1) {
return "custom";
}
return PRESET_OF[repeat.unit];
}
/** /**
* Which of the three states a saved rule belongs to. * Which of the three states a saved rule belongs to.
* *
+133 -8
View File
@@ -375,16 +375,21 @@ describe("stating a repeat", () => {
// case; `forever()` narrows the interval to exactly that eight-day step. // case; `forever()` narrows the interval to exactly that eight-day step.
const forever = () => repeating({ repeat: { unit: "days", interval: 8, until: null } }); const forever = () => repeating({ repeat: { unit: "days", interval: 8, until: null } });
test("a fresh form offers the three answers, set to never", () => { test("a custom cadence offers the three answers", () => {
// The three-way control lives under `custom` now: a preset answers the
// question by construction and a one-off has nothing to answer, so this
// is the only cadence that has to ask.
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<EventForm lanes={["mygame:limbus-company"]} customGames={GAMES} onSave={() => {}} onCancel={() => {}} />, <EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={repeating()}
onSave={() => {}}
onCancel={() => {}}
/>,
); );
expect(html).toContain("Repeat"); expect(html).toContain("Repeat");
expect(html).toContain("with a delay"); expect(html).toContain("with a delay");
// Nothing to configure until they pick one, so the form a reader already
// knows is unchanged until they reach for this.
expect(html).not.toContain("Every");
expect(html).not.toContain("Wait");
}); });
test("a rule that reopens as it closes shows its cadence rather than a control", () => { test("a rule that reopens as it closes shows its cadence rather than a control", () => {
@@ -441,7 +446,7 @@ describe("stating a repeat", () => {
<EventForm <EventForm
lanes={["mygame:limbus-company"]} lanes={["mygame:limbus-company"]}
customGames={GAMES} customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })} initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 2, until: null } })}
onSave={() => {}} onSave={() => {}}
onCancel={() => {}} onCancel={() => {}}
/>, />,
@@ -454,7 +459,7 @@ describe("stating a repeat", () => {
<EventForm <EventForm
lanes={["mygame:limbus-company"]} lanes={["mygame:limbus-company"]}
customGames={GAMES} customGames={GAMES}
initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 1, until: null } })} initial={repeating({ endsAt: null, endPrecision: "unknown", repeat: { unit: "weeks", interval: 2, until: null } })}
onSave={() => {}} onSave={() => {}}
onCancel={() => {}} onCancel={() => {}}
/>, />,
@@ -675,3 +680,123 @@ describe("the derived-boundary note", () => {
expect(html).not.toContain("server reset"); expect(html).not.toContain("server reset");
}); });
}); });
describe("the cadence control", () => {
const event = (over: Record<string, unknown> = {}) =>
CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Mirror Dungeon",
type: "challenge",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: "2026-09-08T00:00:00.000Z",
endPrecision: "day",
repeat: null,
at: AT,
updatedAt: AT,
...over,
});
const render = (initial?: ReturnType<typeof event>) =>
renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={initial}
onSave={() => {}}
onCancel={() => {}}
/>,
);
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(answer);
}
// 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");
expect(html).toContain("I don&#x27;t know when it ends");
});
test("a preset asks for a start and nothing else", () => {
// The period is the window, so there is no end to type and no ignorance
// to admit. This is the whole reason the presets exist.
const html = render(
event({
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "weeks", interval: 1, until: null },
}),
);
expect(html).toContain("Starts");
expect(html).not.toContain("Ends");
expect(html).not.toContain("I don&#x27;t know when it ends");
expect(html).not.toContain("Cycle length");
expect(html).not.toContain("Wait");
});
test("each preset reopens as itself", () => {
const daily = render(
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "days", interval: 1, until: null } }),
);
expect(daily).toContain('value="daily" selected=""');
const monthly = render(
event({ endsAt: null, endPrecision: "unknown", repeat: { unit: "months", interval: 1, until: null } }),
);
expect(monthly).toContain('value="monthly" selected=""');
});
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).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).not.toContain("Cycle length");
expect(html).not.toContain("Wait");
expect(html).not.toContain("worked out from your dates");
});
});
describe("a preset says its piece exactly once", () => {
test("no end-date note tags along when there is no end-date field", () => {
// Both of the older notes explain the end-date field. A preset has no such
// field, so rendering them there left two sentences saying almost the same
// thing — caught by looking at the form, not by any assertion on content.
const html = renderToStaticMarkup(
<EventForm
lanes={["mygame:limbus-company"]}
customGames={GAMES}
initial={CustomEvent.parse({
id: "myevent:k3f9qa2m01",
game: "mygame:limbus-company",
title: "Weekly missions",
type: "challenge",
summary: null,
startsAt: "2026-09-01T00:00:00.000Z",
startPrecision: "day",
endsAt: null,
endPrecision: "unknown",
repeat: { unit: "weeks", interval: 1, until: null },
at: AT,
updatedAt: AT,
})}
onSave={() => {}}
onCancel={() => {}}
/>,
);
expect(html).toContain("no end date to give");
expect(html).not.toContain("still counts down");
expect(html).not.toContain("no countdown");
});
});
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts"; import { addUnits, comesRoundEarly, Repeat, repeatSpanning, repeatModeOf, cadenceOf, isOccurrenceId, occurrenceId, occurrenceForId, ruleIdOf, movesOccurrences, nextOccurrences, occurrencesOf, strandedOccurrences, type RepeatingEvent } from "../src/shared/recurrence.ts";
import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts"; import { CustomEventId, isCustomEventId } from "../src/shared/custom.ts";
// Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and // Pinned so the DST cases mean something. Copenhagen is UTC+1 in winter and
@@ -551,3 +551,42 @@ describe("repeatModeOf", () => {
.toBe("forever"); .toBe("forever");
}); });
}); });
describe("cadenceOf", () => {
// Which of the form's five answers describes a saved event. Derived rather
// than stored, like `repeatModeOf`, so a rule made before the control
// existed — or one that arrived by import — opens in whichever answer
// actually fits it rather than in whichever happens to be the default.
const weekly = { unit: "weeks", interval: 1, until: null } as const;
test("no rule at all is a one-off", () => {
expect(cadenceOf(null, null)).toBe("one-off");
expect(cadenceOf("2026-09-08T00:00:00.000Z", null)).toBe("one-off");
});
test("a single unit with no end is the matching preset", () => {
expect(cadenceOf(null, { unit: "days", interval: 1, until: null })).toBe("daily");
expect(cadenceOf(null, weekly)).toBe("weekly");
expect(cadenceOf(null, { unit: "months", interval: 1, until: null })).toBe("monthly");
});
test("a longer cycle is a custom", () => {
expect(cadenceOf(null, { unit: "days", interval: 26, until: null })).toBe("custom");
expect(cadenceOf(null, { unit: "weeks", interval: 2, until: null })).toBe("custom");
});
test("a stated end is a custom, whatever the cycle", () => {
// The presets carry no window — picking one means the period *is* the
// window. An event that states its own end is saying something the preset
// cannot, so it belongs where that is sayable.
expect(cadenceOf("2026-09-08T00:00:00.000Z", weekly)).toBe("custom");
});
test("a series that stops is a custom", () => {
// There is no control for `until` in a preset, so opening one there would
// offer to save a rule quietly stripped of the date it stops on.
expect(
cadenceOf(null, { unit: "weeks", interval: 1, until: "2027-01-01T00:00:00.000Z" }),
).toBe("custom");
});
});