yiekheng a7a5c6821b feat(recurrence): redesign as a Temenos-style trigger + dialog picker
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>
2026-05-10 11:02:16 +08:00

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;
}