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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-08-28 05:04:39 +02:00
1 parent 4c3aa4c7e3
commit 6dad359b2a
5 files changed
+434 -140

No files matched your search

+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?". */
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.
*