The previous flat radio list with N-minutes / N-hours / Custom-cron
options is gone. Per the Temenos UUX `date-recurrence-picker` pattern
(developer.temenos.com/uux/docs/components/date-recurrence-picker), the
form now shows a single read-only trigger field summarising the saved
rule:
┌────────────────────────────────────────────────────┐
│ 📅 Don't repeat ▾ │
└────────────────────────────────────────────────────┘
Clicking the trigger opens a modal with the recurrence types as a
tab strip and per-type config swapped in below:
┌──────────────────────────────────────────────────┐
│ Repeat schedule ✕ │
├──────────────────────────────────────────────────┤
│ [Don't repeat] [Daily] [Weekly] [Monthly] [Yearly] │
│ │
│ <per-type config> │
│ │
│ Fires: <plain-text confirmation> │
│ │
│ [Reset] [Cancel] [Save] │
└──────────────────────────────────────────────────┘
Per-tab config:
- Don't repeat — informational text only
- Daily — radio: "Every day" / "Every weekday (Mon–Fri)"
- Weekly — Mon..Sun chip multi-select
- Monthly — day-of-month input (1-31)
- Yearly — month select + day input
The "Fires: …" sentence updates live as the user edits and reflects
the outer time-picker's HH:MM. Save commits, Cancel discards.
Removed:
- Every N minutes
- Every N hours
- Custom cron expression…
- The standalone helpers `flowToCron` / `flowFromCron` /
`freqChoices` / `defaultFlowState` / `FlowState` / `FreqChoice`
in `lib/recurrence.ts`. Their job (compile a UI state to a cron
string and parse one back) now lives privately inside the picker.
Storage / runtime
- Output is still a `CRON:` prefixed rule in `reminders.rrule`. The
bot's `nextOccurrence` already dispatches cron rules through
cron-parser, so no schema or scheduler changes were needed.
Tests (132 web)
- recurrence.test.ts trimmed to keep only what survives: CRON-rule
round-trip via buildRrule + specFromRrule, and the ISO→cron
weekday helper.
- Existing wizard / edit-when-form integration tests are unaffected
because the picker exports the same `<RecurrencePicker>` props.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
7.0 KiB
TypeScript
222 lines
7.0 KiB
TypeScript
import { DateTime } from "luxon";
|
|
|
|
export type RecurrenceKind = "none" | "daily" | "weekly" | "monthly" | "yearly" | "cron";
|
|
export type EndKind = "never" | "after" | "on";
|
|
|
|
const CRON_PREFIX = "CRON:";
|
|
|
|
const WEEKDAY_CODES = ["MO", "TU", "WE", "TH", "FR", "SA", "SU"] as const;
|
|
|
|
export const WEEKDAY_LABELS: Array<{ iso: number; code: string; short: string; long: string }> = [
|
|
{ iso: 1, code: "MO", short: "Mon", long: "Monday" },
|
|
{ iso: 2, code: "TU", short: "Tue", long: "Tuesday" },
|
|
{ iso: 3, code: "WE", short: "Wed", long: "Wednesday" },
|
|
{ iso: 4, code: "TH", short: "Thu", long: "Thursday" },
|
|
{ iso: 5, code: "FR", short: "Fri", long: "Friday" },
|
|
{ iso: 6, code: "SA", short: "Sat", long: "Saturday" },
|
|
{ iso: 7, code: "SU", short: "Sun", long: "Sunday" },
|
|
];
|
|
|
|
export interface RecurrenceSpec {
|
|
kind: RecurrenceKind;
|
|
/** Every N units. Defaults to 1. Ignored for `none` and `cron`. */
|
|
interval: number;
|
|
/** ISO weekday numbers (1=Mon..7=Sun). Used for `weekly`. */
|
|
weeklyDays: number[];
|
|
/** Day-of-month for `monthly` (1-31). If omitted, falls back to firstFire.day. */
|
|
monthDay?: number;
|
|
/** Cron expression — only meaningful when kind === "cron". */
|
|
cron?: string;
|
|
/** End condition. */
|
|
end:
|
|
| { kind: "never" }
|
|
| { kind: "after"; count: number }
|
|
| { kind: "on"; until: string /* ISO date YYYY-MM-DD */ };
|
|
}
|
|
|
|
export const DEFAULT_RECURRENCE: RecurrenceSpec = {
|
|
kind: "none",
|
|
interval: 1,
|
|
weeklyDays: [],
|
|
end: { kind: "never" },
|
|
};
|
|
|
|
function clampInterval(n: number): number {
|
|
if (!Number.isFinite(n) || n < 1) return 1;
|
|
return Math.floor(n);
|
|
}
|
|
|
|
/**
|
|
* Build an RRULE string. Supports interval, weekday list, monthday, and the
|
|
* end condition (COUNT or UNTIL). Returns null for one-off reminders.
|
|
*/
|
|
export function buildRrule(spec: RecurrenceSpec, firstFire: DateTime): string | null {
|
|
if (spec.kind === "none") return null;
|
|
if (spec.kind === "cron") {
|
|
return spec.cron ? `${CRON_PREFIX}${spec.cron.trim()}` : null;
|
|
}
|
|
|
|
const parts: string[] = [];
|
|
switch (spec.kind) {
|
|
case "daily":
|
|
parts.push("FREQ=DAILY");
|
|
break;
|
|
case "weekly": {
|
|
parts.push("FREQ=WEEKLY");
|
|
const days =
|
|
spec.weeklyDays.length > 0
|
|
? spec.weeklyDays
|
|
: [firstFire.weekday];
|
|
const codes = days
|
|
.slice()
|
|
.sort((a, b) => a - b)
|
|
.map((d) => WEEKDAY_CODES[d - 1])
|
|
.filter(Boolean);
|
|
parts.push(`BYDAY=${codes.join(",")}`);
|
|
break;
|
|
}
|
|
case "monthly":
|
|
parts.push("FREQ=MONTHLY");
|
|
parts.push(`BYMONTHDAY=${spec.monthDay ?? firstFire.day}`);
|
|
break;
|
|
case "yearly":
|
|
parts.push("FREQ=YEARLY");
|
|
parts.push(`BYMONTH=${firstFire.month}`);
|
|
parts.push(`BYMONTHDAY=${firstFire.day}`);
|
|
break;
|
|
}
|
|
|
|
const interval = clampInterval(spec.interval);
|
|
if (interval !== 1) parts.push(`INTERVAL=${interval}`);
|
|
|
|
if (spec.end.kind === "after" && spec.end.count > 0) {
|
|
parts.push(`COUNT=${Math.floor(spec.end.count)}`);
|
|
} else if (spec.end.kind === "on" && spec.end.until) {
|
|
// RRULE UNTIL is a UTC timestamp. Translate the user's "on this date"
|
|
// into 23:59:59 UTC of that day so the last occurrence is included.
|
|
const dt = DateTime.fromISO(`${spec.end.until}T23:59:59`, { zone: "utc" });
|
|
if (dt.isValid) {
|
|
parts.push(`UNTIL=${dt.toFormat("yyyyMMdd'T'HHmmss'Z'")}`);
|
|
}
|
|
}
|
|
|
|
return parts.join(";");
|
|
}
|
|
|
|
const FREQ_UNIT: Record<string, string> = {
|
|
daily: "day",
|
|
weekly: "week",
|
|
monthly: "month",
|
|
yearly: "year",
|
|
};
|
|
|
|
/**
|
|
* Render the spec as a human sentence, e.g.
|
|
* "Every day"
|
|
* "Every 2 weeks on Mon, Wed, Fri"
|
|
* "Every month on day 14, 12 times"
|
|
* "Every year on May 13, until 2027-05-13"
|
|
*/
|
|
export function describeRecurrence(spec: RecurrenceSpec, firstFire: DateTime): string {
|
|
if (spec.kind === "none") return "One-off";
|
|
if (spec.kind === "cron") {
|
|
return spec.cron ? `Cron: ${spec.cron}` : "Cron (not configured)";
|
|
}
|
|
|
|
const interval = clampInterval(spec.interval);
|
|
const unit = FREQ_UNIT[spec.kind]!;
|
|
const head =
|
|
interval === 1 ? `Every ${unit}` : `Every ${interval} ${unit}s`;
|
|
|
|
let body = "";
|
|
if (spec.kind === "weekly") {
|
|
const days = spec.weeklyDays.length > 0 ? spec.weeklyDays : [firstFire.weekday];
|
|
const labels = days
|
|
.slice()
|
|
.sort((a, b) => a - b)
|
|
.map((d) => WEEKDAY_LABELS[d - 1]?.short)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
body = ` on ${labels}`;
|
|
} else if (spec.kind === "monthly") {
|
|
body = ` on day ${spec.monthDay ?? firstFire.day}`;
|
|
} else if (spec.kind === "yearly") {
|
|
body = ` on ${firstFire.toFormat("MMM d")}`;
|
|
}
|
|
|
|
let tail = "";
|
|
if (spec.end.kind === "after" && spec.end.count > 0) {
|
|
tail = `, ${Math.floor(spec.end.count)} time${spec.end.count === 1 ? "" : "s"}`;
|
|
} else if (spec.end.kind === "on" && spec.end.until) {
|
|
tail = `, until ${spec.end.until}`;
|
|
}
|
|
|
|
return head + body + tail;
|
|
}
|
|
|
|
/** Parse a stored RRULE back into a spec for resuming the wizard / editing. */
|
|
export function specFromRrule(rrule: string | null | undefined): RecurrenceSpec {
|
|
if (!rrule) return { ...DEFAULT_RECURRENCE };
|
|
|
|
if (rrule.startsWith(CRON_PREFIX)) {
|
|
return {
|
|
kind: "cron",
|
|
interval: 1,
|
|
weeklyDays: [],
|
|
cron: rrule.slice(CRON_PREFIX.length),
|
|
end: { kind: "never" },
|
|
};
|
|
}
|
|
|
|
const tokens = rrule
|
|
.split(";")
|
|
.map((t) => t.trim())
|
|
.filter(Boolean)
|
|
.reduce<Record<string, string>>((acc, t) => {
|
|
const [k, v] = t.split("=");
|
|
if (k && v !== undefined) acc[k.toUpperCase()] = v;
|
|
return acc;
|
|
}, {});
|
|
|
|
const freq = (tokens.FREQ ?? "").toUpperCase();
|
|
let kind: RecurrenceKind = "none";
|
|
if (freq === "DAILY") kind = "daily";
|
|
else if (freq === "WEEKLY") kind = "weekly";
|
|
else if (freq === "MONTHLY") kind = "monthly";
|
|
else if (freq === "YEARLY") kind = "yearly";
|
|
|
|
const interval = tokens.INTERVAL ? clampInterval(Number(tokens.INTERVAL)) : 1;
|
|
|
|
const weeklyDays: number[] = [];
|
|
if (tokens.BYDAY) {
|
|
for (const code of tokens.BYDAY.split(",")) {
|
|
const idx = WEEKDAY_CODES.indexOf(code.toUpperCase() as (typeof WEEKDAY_CODES)[number]);
|
|
if (idx >= 0) weeklyDays.push(idx + 1);
|
|
}
|
|
}
|
|
|
|
const monthDay = tokens.BYMONTHDAY ? Number(tokens.BYMONTHDAY) || undefined : undefined;
|
|
|
|
let end: RecurrenceSpec["end"] = { kind: "never" };
|
|
if (tokens.COUNT) {
|
|
const n = Number(tokens.COUNT);
|
|
if (Number.isFinite(n) && n > 0) end = { kind: "after", count: Math.floor(n) };
|
|
} else if (tokens.UNTIL) {
|
|
// UNTIL is `YYYYMMDDTHHMMSSZ` per RFC. Pull the date.
|
|
const m = tokens.UNTIL.match(/^(\d{4})(\d{2})(\d{2})/);
|
|
if (m) end = { kind: "on", until: `${m[1]}-${m[2]}-${m[3]}` };
|
|
}
|
|
|
|
return { kind, interval, weeklyDays, monthDay, end };
|
|
}
|
|
|
|
/** Backwards-compatible helper for callers that only need the kind. */
|
|
export function kindFromRrule(rrule: string | null | undefined): RecurrenceKind {
|
|
return specFromRrule(rrule).kind;
|
|
}
|
|
|
|
/** Map ISO weekday (1=Mon..7=Sun) → cron weekday (0=Sun..6=Sat). */
|
|
export function isoWeekdayToCron(iso: number): number {
|
|
return iso === 7 ? 0 : iso;
|
|
}
|