{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "recurrence-input",
  "title": "Recurrence Input",
  "description": "A structured editor for a repeat: frequency and interval, the weekdays or the day-of-month beneath them, and a three-way ending — answering with an RFC 5545 RRULE string, with the rule written back out as a sentence and the next dates listed beside it so it can be checked. Reach for it wherever a form asks *how often* rather than *when*: a recurring appointment, class or booking; a reminder or task that repeats; recurring invoices, subscriptions and billing cycles; a scheduled report, digest or export; an on-call rota or shift pattern; a publishing or content calendar; medication and habit reminders; maintenance windows and payroll runs; a retention or backup policy stated for people rather than for cron; and the “Custom…” dialog behind every “Repeats: weekly” dropdown. Common asks it answers: “recurrence input react”, “rrule editor react”, “RRULE builder component”, “recurring event form react”, “repeat options component react”, “shadcn recurrence picker”, “shadcn repeat schedule input”, “react-rrule-generator alternative”, “rrule.js UI component”, “build a recurrence rule UI”, “custom recurrence dialog like Google Calendar”, “outlook recurrence dialog react”, “iCalendar RRULE form”, “FREQ BYDAY BYMONTHDAY editor”, “repeat weekly on selected days react”, “every second Tuesday of the month”, “last Friday of the month picker”, “monthly on the last day of the month”, “end after N occurrences”, “recurrence until date”, “human readable recurrence rule”, “describe an rrule in words”, “preview next occurrence dates”, “rrule skips february”, “monthly on the 31st skips months”, “why does my monthly rule miss a month”, “recurrence count does not match the end date”, “COUNT and UNTIL together rrule”, “BYDAY ordinal not allowed weekly”, “dtstart not synchronized with recurrence rule”, “rrule until must be UTC”, 繰り返し設定 UI, 定期予定の入力フォーム, 「毎月第2月曜」の選択, RRULE エディタ, 毎週 曜日 複数選択, 月末 繰り返し, 繰り返しルール コンポーネント, 終了条件 回数 期日. Official shadcn/ui has nothing of the kind, and the measurement says so precisely: across its whole registry today — 65 items in the catalogue, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks), 223,287 bytes of files[].content concatenated — RRULE, recurrence, FREQ, BYDAY, BYMONTHDAY, BYSETPOS, recurring and interval are every one of them a zero hit. Its calendar is a react-day-picker wrapper that chooses one day and has no notion of repetition at all. Distinct from the neighbours, and the pair worth knowing is cron-expression: that one reads a machine's cron string and renders it, where this one is the editor a person drives, and the two vocabularies genuinely differ — cron has no COUNT, no UNTIL and no “last weekday of the month”. weekly-hours sets opening times across a fixed seven rows rather than a rule that grows; date-range-preset picks a one-off period to look at; repeatable-fields repeats a row of form fields; duration-input measures a length of time rather than a cadence. Five things go wrong in the hand-rolled version, and the first is the one that costs money. “Monthly on the 31st” is not monthly. RFC 5545 does not clamp: it says a rule may generate an instance with an invalid date such as February 30, and that such instances “MUST be ignored and MUST NOT be counted as part of the recurrence set”. So BYMONTHDAY=31 fires in seven months of the year, and because a skipped instance is not counted, pairing it with COUNT=12 does not end in a year — starting in January 2026 the twelfth firing is 31 August 2027. Nothing errors; the schedule a person reads back as “monthly for a year” is neither monthly nor a year. Several products clamp instead, and that is a different schedule which cannot be written as BYMONTHDAY=31 at all — it is BYMONTHDAY=-1, the last day of the month, which exists in every month. This component makes that a separate, named choice and shows how many months the other one skips, rather than guessing, because either guess is invisible. (RFC 7529's SKIP=BACKWARD, the standardised way to ask for clamping, is recognised on parse so a rule carrying it is not mistaken for a plain one and stripped of the part that made it clamp.) Second, changing the frequency does not just swap the panel: “on the 2nd Tuesday” cannot mean anything once a rule repeats weekly — RFC 5545 forbids a numbered weekday outside MONTHLY and YEARLY — so it is dropped, and frequencyChangeDrops exists so the form can say what went instead of silently keeping or silently losing it. Third, the ending is one choice of three, not a times box and a date box: the spec says UNTIL and COUNT “MUST NOT occur in the same 'recur'”, and a form with both live eventually emits both. Fourth, a rule you cannot read back is a rule you cannot check — “the 2nd” and “the second Monday” are different schedules that look identical in a dropdown — so the sentence and the next dates are part of the control, and the dates are computed without reading the clock, so the same props always render the same markup. Fifth, the zone: this component edits a calendar day and does not own DTSTART, and RFC 5545 requires UNTIL to match DTSTART's value type — UTC when DTSTART carries a TZID — so untilForm is an explicit option rather than a guess, and a start date the rule does not actually generate (RFC 5545 calls that combination undefined) is reported rather than papered over. The date arithmetic is cross-checked against rrule.js across 26 rules covering every BY* part this editor can produce, because matching the implementation the rest of the ecosystem is built on matters more than being defensibly different — a preview is only worth having if its dates are the ones the caller's calendar will later produce. That comparison is what settled the DTSTART question; it added no dependency here. What you get: RecurrenceInput, controlled with value + onValueChange or uncontrolled with defaultValue, taking dtstart for the preview and the field seeding, previewCount, a per-part classNames, disabled on a real fieldset so your own fields go with it, full labels for translation, and name to post the RRULE with a plain form or a server action. The hard decisions are exported as pure functions and can be asserted on their own: parseRRule (which refuses a sub-daily frequency and lists the parts it cannot edit instead of rewriting them), formatRRule, describeRecurrence, nextOccurrences (reporting how many instances were skipped and whether DTSTART is one of them), monthDaySkip, changeFrequency, frequencyChangeDrops, setMonthlyMode, toggleWeekday, setEnding and recurrenceIssues. Keyboard and screen reader throughout: a real fieldset and legend, radiogroups for the monthly shape and the ending, aria-pressed weekday toggles whose last remaining day is aria-disabled with the reason wired up rather than silently inert, and a permanently mounted polite live region carrying the sentence so a change is announced rather than only redrawn. Styled entirely with shadcn tokens (primary, input, accent, ring, muted, muted-foreground, destructive, border) so it follows light and dark mode, and it ships zero npm dependencies — no date library, no rrule library, no icon package.",
  "files": [
    {
      "path": "registry/ui/recurrence-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The frequencies a person schedules things in.\n *\n * RFC 5545 also defines `SECONDLY`, `MINUTELY` and `HOURLY`. They are deliberately absent: this is\n * the editor beside \"repeats\", and nobody sets a dentist appointment to every 30 seconds. A rule\n * carrying one of them is reported as unrepresentable by {@link parseRRule} rather than quietly\n * rewritten into something the person did not choose — see {@link ParsedRRule}.\n */\nexport type Frequency = \"DAILY\" | \"WEEKLY\" | \"MONTHLY\" | \"YEARLY\"\n\n/** Weekday codes exactly as RFC 5545 spells them, so they serialise without a lookup. */\nexport type Weekday = \"SU\" | \"MO\" | \"TU\" | \"WE\" | \"TH\" | \"FR\" | \"SA\"\n\nexport const WEEKDAYS: readonly Weekday[] = [\"SU\", \"MO\", \"TU\", \"WE\", \"TH\", \"FR\", \"SA\"]\n\n/**\n * One `BYDAY` entry: a weekday, optionally with the ordinal that picks which one in the period.\n *\n * `{ weekday: \"MO\", ordinal: 2 }` is the second Monday; `{ weekday: \"FR\", ordinal: -1 }` the last\n * Friday. **The ordinal is only legal when `FREQ` is `MONTHLY` or `YEARLY`** — RFC 5545 is explicit\n * that \"the BYDAY rule part MUST NOT be specified with a numeric value when the FREQ rule part is\n * not set to MONTHLY or YEARLY\" — which is why {@link changeFrequency} strips ordinals on the way to\n * `WEEKLY` instead of leaving a rule that no other calendar will accept.\n */\nexport interface ByDay {\n  weekday: Weekday\n  /** `±1`–`53`. Omitted means \"every one of them in the period\". */\n  ordinal?: number\n}\n\n/**\n * A recurrence rule, in the shape an editor needs rather than the shape it serialises to.\n *\n * Every `by*` field is an array even where the UI only ever sets one value, because the parser has\n * to round-trip rules written elsewhere: `BYMONTHDAY=1,15` is an ordinary rule and collapsing it to\n * a single number on the way in would silently rewrite somebody's data the first time they opened\n * the form.\n *\n * `count` and `until` are a pair with a hard constraint: RFC 5545 says the two \"MUST NOT occur in\n * the same 'recur'\", so the end condition is a three-way choice rather than two independent fields.\n * {@link setEnding} is the only safe way to move between them.\n */\nexport interface RecurrenceRule {\n  freq: Frequency\n  /** `INTERVAL`. A positive integer; `1` is the RFC default and is omitted when serialising. */\n  interval: number\n  byDay: ByDay[]\n  /** `BYMONTHDAY`. `1`–`31` or `-31`–`-1`, where `-1` is the last day of the month. */\n  byMonthDay: number[]\n  /** `BYMONTH`. `1`–`12`. */\n  byMonth: number[]\n  /** `BYSETPOS`. `±1`–`366`; only meaningful alongside another `BY*` part. */\n  bySetPos: number[]\n  /** `COUNT`. Null unless the ending is \"after N times\". **Counts `DTSTART` as the first.** */\n  count: number | null\n  /** `UNTIL`, as a calendar day `YYYY-MM-DD`. Null unless the ending is \"on a date\". */\n  until: string | null\n  /** `WKST`. RFC default is `MO`. Only changes results for `WEEKLY` rules with `INTERVAL` > 1. */\n  weekStart: Weekday\n}\n\n/** How the monthly panel is being edited. Derived from the rule, never stored beside it. */\nexport type MonthlyMode = \"dayOfMonth\" | \"nthWeekday\" | \"lastDay\"\n\nexport type Ending = \"never\" | \"count\" | \"until\"\n\n/** A blank rule. `WEEKLY` because it is what \"repeats\" means to most people most of the time. */\nexport function defaultRecurrence(freq: Frequency = \"WEEKLY\"): RecurrenceRule {\n  return {\n    freq,\n    interval: 1,\n    byDay: [],\n    byMonthDay: [],\n    byMonth: [],\n    bySetPos: [],\n    count: null,\n    until: null,\n    weekStart: \"MO\",\n  }\n}\n\n// ---------------------------------------------------------------------------------------------\n// Calendar arithmetic, done on plain {y,m,d} triples through Date.UTC only.\n//\n// Never on a local Date. A recurrence is a sequence of calendar days, and local Date arithmetic\n// moves a day by an hour twice a year: `setDate(d.getDate() + 1)` across a spring-forward boundary\n// in a zone that shifts at midnight lands on the same calendar day it started on. The whole series\n// then silently doubles one date and skips another. Working in UTC removes the question, and the\n// zone stays where it belongs — on DTSTART, which lives outside this component.\n// ---------------------------------------------------------------------------------------------\n\n/** A calendar day with no time and no zone. */\nexport interface PlainDay {\n  y: number\n  m: number\n  d: number\n}\n\nexport function daysInMonth(y: number, m: number): number {\n  return new Date(Date.UTC(y, m, 0)).getUTCDate()\n}\n\n/** 0 = Sunday … 6 = Saturday, matching {@link WEEKDAYS}. */\nexport function dayOfWeek(day: PlainDay): number {\n  return new Date(Date.UTC(day.y, day.m - 1, day.d)).getUTCDay()\n}\n\nexport function isValidDay(day: PlainDay): boolean {\n  return (\n    Number.isInteger(day.y) &&\n    Number.isInteger(day.m) &&\n    Number.isInteger(day.d) &&\n    day.m >= 1 &&\n    day.m <= 12 &&\n    day.d >= 1 &&\n    day.d <= daysInMonth(day.y, day.m)\n  )\n}\n\n/** Parses `YYYY-MM-DD` strictly. Returns null for anything else, including `2026-02-30`. */\nexport function parseDay(text: string | null | undefined): PlainDay | null {\n  if (typeof text !== \"string\") return null\n  const m = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(text.trim())\n  if (!m) return null\n  const day = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) }\n  return isValidDay(day) ? day : null\n}\n\nexport function formatDay(day: PlainDay): string {\n  const pad = (n: number, w = 2) => String(n).padStart(w, \"0\")\n  return `${pad(day.y, 4)}-${pad(day.m)}-${pad(day.d)}`\n}\n\nfunction dayNumber(day: PlainDay): number {\n  return Date.UTC(day.y, day.m - 1, day.d) / 86400000\n}\n\nfunction fromDayNumber(n: number): PlainDay {\n  const date = new Date(n * 86400000)\n  return { y: date.getUTCFullYear(), m: date.getUTCMonth() + 1, d: date.getUTCDate() }\n}\n\nexport function addDays(day: PlainDay, n: number): PlainDay {\n  return fromDayNumber(dayNumber(day) + n)\n}\n\nfunction addMonths(day: PlainDay, n: number): PlainDay {\n  const total = day.y * 12 + (day.m - 1) + n\n  return { y: Math.floor(total / 12), m: (total % 12) + 1, d: day.d }\n}\n\nfunction compareDays(a: PlainDay, b: PlainDay): number {\n  return dayNumber(a) - dayNumber(b)\n}\n\n/** The weekday index {@link WEEKDAYS} uses, for a code. `-1` if the code is not one. */\nfunction weekdayIndex(code: Weekday): number {\n  return WEEKDAYS.indexOf(code)\n}\n\n// ---------------------------------------------------------------------------------------------\n// Editing the rule. Every transition is a pure function, because these are the decisions that are\n// hard to get right and the only ones worth asserting on without a DOM.\n// ---------------------------------------------------------------------------------------------\n\nfunction uniqueSorted(values: number[]): number[] {\n  return [...new Set(values)].sort((a, b) => a - b)\n}\n\nfunction dedupeByDay(entries: ByDay[]): ByDay[] {\n  const seen = new Set<string>()\n  const out: ByDay[] = []\n  for (const entry of entries) {\n    if (weekdayIndex(entry.weekday) < 0) continue\n    const key = `${entry.ordinal ?? \"\"}${entry.weekday}`\n    if (seen.has(key)) continue\n    seen.add(key)\n    out.push(entry.ordinal === undefined ? { weekday: entry.weekday } : entry)\n  }\n  return out.sort(\n    (a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || weekdayIndex(a.weekday) - weekdayIndex(b.weekday),\n  )\n}\n\n/** Which monthly shape a rule is in. `dayOfMonth` is the fallback, because it is `BYMONTHDAY`'s. */\nexport function monthlyMode(rule: RecurrenceRule): MonthlyMode {\n  if (rule.byMonthDay.length === 1 && rule.byMonthDay[0] === -1) return \"lastDay\"\n  if (rule.byDay.length > 0) return \"nthWeekday\"\n  return \"dayOfMonth\"\n}\n\n/**\n * The parts of `rule` that cannot survive a move to `freq`.\n *\n * Exported so the control can *say* what a frequency change is about to throw away. The panel\n * swapping under the person is the visible half of changing frequency; the invisible half is that\n * \"on the 2nd Tuesday\" cannot mean anything once the rule repeats weekly, and a form that silently\n * drops it leaves them believing they saved something they did not. RFC 5545 forbids most of these\n * combinations outright, so keeping them is not an option either — only announcing them is.\n */\nexport function frequencyChangeDrops(rule: RecurrenceRule, freq: Frequency): string[] {\n  if (rule.freq === freq) return []\n  const drops: string[] = []\n  const ordinals = rule.byDay.some((d) => d.ordinal !== undefined)\n  if (freq === \"DAILY\") {\n    if (rule.byDay.length) drops.push(\"byDay\")\n    if (rule.byMonthDay.length) drops.push(\"byMonthDay\")\n    if (rule.byMonth.length) drops.push(\"byMonth\")\n    if (rule.bySetPos.length) drops.push(\"bySetPos\")\n  } else if (freq === \"WEEKLY\") {\n    if (ordinals) drops.push(\"byDayOrdinal\")\n    if (rule.byMonthDay.length) drops.push(\"byMonthDay\")\n    if (rule.byMonth.length) drops.push(\"byMonth\")\n    if (rule.bySetPos.length) drops.push(\"bySetPos\")\n  } else if (freq === \"MONTHLY\") {\n    if (rule.byMonth.length) drops.push(\"byMonth\")\n    if (rule.byDay.length && rule.byMonthDay.length) drops.push(\"byMonthDay\")\n  }\n  return drops\n}\n\n/**\n * Moves a rule to another frequency, keeping what still means something and dropping what cannot.\n *\n * `seed` is the start date the rule hangs off — `DTSTART` — and is what a newly relevant field is\n * filled from: switching to monthly with nothing to go on would otherwise have to invent a day of\n * the month, and inventing the 1st when the series starts on the 17th is how these forms end up\n * quietly scheduling the wrong thing. Pair it with {@link frequencyChangeDrops} to tell the person\n * what went.\n */\nexport function changeFrequency(\n  rule: RecurrenceRule,\n  freq: Frequency,\n  seed?: PlainDay | string | null,\n): RecurrenceRule {\n  if (rule.freq === freq) return rule\n  const start = typeof seed === \"string\" ? parseDay(seed) : (seed ?? null)\n  const next: RecurrenceRule = { ...rule, freq, bySetPos: [] }\n\n  if (freq === \"DAILY\") {\n    next.byDay = []\n    next.byMonthDay = []\n    next.byMonth = []\n    return next\n  }\n\n  if (freq === \"WEEKLY\") {\n    // Ordinals are illegal outside MONTHLY/YEARLY, so \"2nd Tuesday\" becomes \"Tuesday\" rather than\n    // being thrown away whole — the weekday is the part the person can still see on screen.\n    next.byDay = dedupeByDay(rule.byDay.map((d) => ({ weekday: d.weekday })))\n    if (!next.byDay.length && start) next.byDay = [{ weekday: WEEKDAYS[dayOfWeek(start)] }]\n    next.byMonthDay = []\n    next.byMonth = []\n    return next\n  }\n\n  // MONTHLY and YEARLY both pick days within a month, so they share the choice between a day\n  // number and an nth weekday. Carrying both would mean their intersection, which no panel here\n  // can show, so one of them wins and the other is reported as dropped.\n  const keepsWeekday = rule.byDay.length > 0 && rule.byMonthDay.length === 0\n  if (keepsWeekday) {\n    next.byDay = dedupeByDay(\n      rule.byDay.map((d) => ({ weekday: d.weekday, ordinal: d.ordinal ?? 1 })),\n    )\n    next.byMonthDay = []\n  } else {\n    next.byDay = []\n    next.byMonthDay = rule.byMonthDay.length\n      ? uniqueSorted(rule.byMonthDay)\n      : start\n        ? [start.d]\n        : [1]\n  }\n  next.byMonth = freq === \"YEARLY\" ? (rule.byMonth.length ? uniqueSorted(rule.byMonth) : start ? [start.m] : [1]) : []\n  return next\n}\n\n/** Switches the monthly/yearly panel between a day number, an nth weekday, and the last day. */\nexport function setMonthlyMode(\n  rule: RecurrenceRule,\n  mode: MonthlyMode,\n  seed?: PlainDay | string | null,\n): RecurrenceRule {\n  const start = typeof seed === \"string\" ? parseDay(seed) : (seed ?? null)\n  if (mode === \"lastDay\") return { ...rule, byDay: [], byMonthDay: [-1], bySetPos: [] }\n  if (mode === \"dayOfMonth\") {\n    const day = rule.byMonthDay.find((d) => d > 0) ?? start?.d ?? 1\n    return { ...rule, byDay: [], byMonthDay: [day], bySetPos: [] }\n  }\n  const weekday = rule.byDay[0]?.weekday ?? (start ? WEEKDAYS[dayOfWeek(start)] : \"MO\")\n  const ordinal = rule.byDay[0]?.ordinal ?? (start ? Math.ceil(start.d / 7) : 1)\n  return { ...rule, byDay: [{ weekday, ordinal }], byMonthDay: [], bySetPos: [] }\n}\n\n/** Adds or removes one weekday from a `WEEKLY` rule, refusing to empty the set. */\nexport function toggleWeekday(rule: RecurrenceRule, weekday: Weekday): RecurrenceRule {\n  const present = rule.byDay.some((d) => d.weekday === weekday)\n  if (present) {\n    // A weekly rule with no weekday is not \"every day\"; it falls back to DTSTART's weekday, which\n    // is a different rule than the one on screen. Keeping the last one checked keeps the two equal.\n    if (rule.byDay.length <= 1) return rule\n    return { ...rule, byDay: rule.byDay.filter((d) => d.weekday !== weekday) }\n  }\n  return { ...rule, byDay: dedupeByDay([...rule.byDay, { weekday }]) }\n}\n\n/**\n * Moves between the three endings, which is a choice of one rather than two switches.\n *\n * RFC 5545: `UNTIL` and `COUNT` \"MUST NOT occur in the same 'recur'\". A form with a \"times\" box and\n * a \"date\" box both live will eventually emit both, and a rule carrying both is rejected or\n * arbitrarily resolved by whatever reads it next.\n */\nexport function setEnding(\n  rule: RecurrenceRule,\n  ending: Ending,\n  value?: number | string | null,\n): RecurrenceRule {\n  if (ending === \"never\") return { ...rule, count: null, until: null }\n  if (ending === \"count\") {\n    const n = typeof value === \"number\" ? Math.floor(value) : Number(value)\n    return { ...rule, count: Number.isFinite(n) && n >= 1 ? n : (rule.count ?? 1), until: null }\n  }\n  const day = typeof value === \"string\" ? value : rule.until\n  return { ...rule, count: null, until: day ?? null }\n}\n\n/** Which ending a rule is in. */\nexport function endingOf(rule: RecurrenceRule): Ending {\n  if (rule.count !== null) return \"count\"\n  if (rule.until !== null) return \"until\"\n  return \"never\"\n}\n\n// ---------------------------------------------------------------------------------------------\n// The month-end problem, which is arithmetic rather than a preference.\n// ---------------------------------------------------------------------------------------------\n\n/** What a day-of-month number costs in missed months. */\nexport interface MonthDaySkip {\n  day: number\n  /** Months (1–12) that never contain this day, in a common year. */\n  months: number[]\n  /** True for the 29th, which exists in February in leap years and not otherwise. */\n  leapSensitive: boolean\n}\n\n/**\n * Whether \"on the Nth\" silently skips months, and which.\n *\n * This is the one place where a recurrence editor is routinely, confidently wrong, and the reason is\n * worth stating exactly. RFC 5545 does not clamp: \"Recurrence rules may generate recurrence\n * instances with an invalid date (e.g., February 30)\" and \"such recurrence instances MUST be ignored\n * and MUST NOT be counted as part of the recurrence set.\" So `FREQ=MONTHLY;BYMONTHDAY=31` fires in\n * seven months of the year, not twelve — and because a skipped instance is not counted, pairing it\n * with `COUNT=12` does not end in a year: it ends after the twelfth month that actually has a 31st,\n * roughly twenty months later. Nothing errors, and the schedule a person reads back as \"monthly for\n * a year\" is neither.\n *\n * Several products clamp instead — \"the 31st\" meaning the 30th in April. That behaviour is not RFC\n * 5545's, and a rule that means it cannot be written as `BYMONTHDAY=31`; it is `BYMONTHDAY=-1`, the\n * last day of the month, which exists in every month and is what {@link MonthlyMode}'s `lastDay`\n * emits. The two are different schedules and this component makes the person choose between them\n * rather than guessing, because guessing is invisible either way.\n */\nexport function monthDaySkip(day: number): MonthDaySkip | null {\n  if (!Number.isInteger(day) || day <= 28 || day > 31) return null\n  const months: number[] = []\n  for (let m = 1; m <= 12; m += 1) {\n    // 2027 is a common year: February has 28 days, so the 29th counts as missing here and\n    // `leapSensitive` carries the nuance rather than the month list pretending to be absolute.\n    if (daysInMonth(2027, m) < day) months.push(m)\n  }\n  return { day, months, leapSensitive: day === 29 }\n}\n\n// ---------------------------------------------------------------------------------------------\n// Validation. Each issue names the RFC constraint it enforces, because \"invalid\" on its own sends\n// the reader to the spec anyway.\n// ---------------------------------------------------------------------------------------------\n\nexport interface RecurrenceIssue {\n  field: \"interval\" | \"byDay\" | \"byMonthDay\" | \"byMonth\" | \"bySetPos\" | \"count\" | \"until\"\n  message: string\n}\n\nexport function recurrenceIssues(rule: RecurrenceRule): RecurrenceIssue[] {\n  const issues: RecurrenceIssue[] = []\n  if (!Number.isInteger(rule.interval) || rule.interval < 1) {\n    issues.push({ field: \"interval\", message: \"Interval must be a whole number of 1 or more.\" })\n  }\n  if (rule.freq !== \"MONTHLY\" && rule.freq !== \"YEARLY\") {\n    if (rule.byDay.some((d) => d.ordinal !== undefined)) {\n      issues.push({\n        field: \"byDay\",\n        message: \"A numbered weekday such as “2nd Tuesday” is only allowed when repeating monthly or yearly.\",\n      })\n    }\n  }\n  for (const entry of rule.byDay) {\n    if (entry.ordinal !== undefined && (entry.ordinal === 0 || Math.abs(entry.ordinal) > 53)) {\n      issues.push({ field: \"byDay\", message: \"A weekday’s position must be between 1 and 53, or -1 and -53.\" })\n      break\n    }\n  }\n  if (rule.freq === \"WEEKLY\" && rule.byMonthDay.length) {\n    issues.push({ field: \"byMonthDay\", message: \"A day of the month cannot be set on a weekly rule.\" })\n  }\n  for (const day of rule.byMonthDay) {\n    if (day === 0 || Math.abs(day) > 31) {\n      issues.push({ field: \"byMonthDay\", message: \"A day of the month must be 1 to 31, or -1 to -31.\" })\n      break\n    }\n  }\n  for (const month of rule.byMonth) {\n    if (month < 1 || month > 12) {\n      issues.push({ field: \"byMonth\", message: \"A month must be 1 to 12.\" })\n      break\n    }\n  }\n  if (rule.bySetPos.length && !rule.byDay.length && !rule.byMonthDay.length && !rule.byMonth.length) {\n    issues.push({\n      field: \"bySetPos\",\n      message: \"A position such as “the last one” needs something to count, so it cannot be the only rule.\",\n    })\n  }\n  for (const pos of rule.bySetPos) {\n    if (pos === 0 || Math.abs(pos) > 366) {\n      issues.push({ field: \"bySetPos\", message: \"A position must be 1 to 366, or -1 to -366.\" })\n      break\n    }\n  }\n  if (rule.count !== null && rule.until !== null) {\n    issues.push({\n      field: \"count\",\n      message: \"A rule can end after a number of times or on a date, not both.\",\n    })\n  }\n  if (rule.count !== null && (!Number.isInteger(rule.count) || rule.count < 1)) {\n    issues.push({ field: \"count\", message: \"A total must be a whole number of 1 or more.\" })\n  }\n  if (rule.until !== null && !parseDay(rule.until)) {\n    issues.push({ field: \"until\", message: \"An end date must be a real date, as YYYY-MM-DD.\" })\n  }\n  return issues\n}\n\n// ---------------------------------------------------------------------------------------------\n// Serialising. The output is an RRULE value — `FREQ=…;…` — with no `RRULE:` prefix unless asked,\n// because it is normally handed to a library or concatenated into a VEVENT by the caller.\n// ---------------------------------------------------------------------------------------------\n\nexport interface FormatRRuleOptions {\n  /**\n   * How `UNTIL` is written, and the one thing this component genuinely cannot decide for you.\n   *\n   * RFC 5545 ties it to `DTSTART`: `UNTIL` \"MUST have the same value type as the DTSTART property\",\n   * and when `DTSTART` is a date-time with a `TZID` — the normal case for anything with a clock time\n   * — \"the UNTIL rule part MUST be specified as a date with UTC time\". This component edits a\n   * calendar day and does not own `DTSTART`, so it cannot know whether to emit `20261231` or\n   * `20261231T140000Z`, and the conversion from \"the last day I want\" to an instant in UTC needs the\n   * zone and the hour, both of which live outside here.\n   *\n   * `\"date\"` (the default) is right when `DTSTART` is a `VALUE=DATE` — an all-day series.\n   * `\"utc-end-of-day\"` emits `T235959Z`, which is right when the series has a clock time *and* runs\n   * in UTC, and is wrong by up to a day for a zone far from it: in UTC+13 the last local day is\n   * already over, and in UTC-10 a 23:59:59Z cut-off lands mid-afternoon and includes a day the\n   * person did not ask for. Pass a function to do it properly with the zone you have.\n   */\n  untilForm?: \"date\" | \"utc-end-of-day\" | ((day: string) => string)\n  /** Prefix the result with `RRULE:`. Off by default. */\n  prefix?: boolean\n}\n\nfunction formatByDay(entries: ByDay[]): string {\n  return entries.map((d) => `${d.ordinal !== undefined ? d.ordinal : \"\"}${d.weekday}`).join(\",\")\n}\n\nexport function formatRRule(rule: RecurrenceRule, options: FormatRRuleOptions = {}): string {\n  const parts: string[] = [`FREQ=${rule.freq}`]\n  if (rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`)\n  // Emitted only when it is not the RFC default, because WKST=MO on every rule is noise that\n  // readers then wonder about. It changes results for weekly rules with an interval above 1.\n  if (rule.weekStart !== \"MO\") parts.push(`WKST=${rule.weekStart}`)\n  if (rule.byMonth.length) parts.push(`BYMONTH=${uniqueSorted(rule.byMonth).join(\",\")}`)\n  if (rule.byMonthDay.length) parts.push(`BYMONTHDAY=${uniqueSorted(rule.byMonthDay).join(\",\")}`)\n  if (rule.byDay.length) parts.push(`BYDAY=${formatByDay(dedupeByDay(rule.byDay))}`)\n  if (rule.bySetPos.length) parts.push(`BYSETPOS=${uniqueSorted(rule.bySetPos).join(\",\")}`)\n  if (rule.count !== null) parts.push(`COUNT=${rule.count}`)\n  else if (rule.until !== null) {\n    const day = parseDay(rule.until)\n    if (day) {\n      const compact = formatDay(day).replace(/-/g, \"\")\n      const form = options.untilForm ?? \"date\"\n      parts.push(\n        `UNTIL=${\n          typeof form === \"function\"\n            ? form(formatDay(day))\n            : form === \"utc-end-of-day\"\n              ? `${compact}T235959Z`\n              : compact\n        }`,\n      )\n    }\n  }\n  return `${options.prefix ? \"RRULE:\" : \"\"}${parts.join(\";\")}`\n}\n\n// ---------------------------------------------------------------------------------------------\n// Parsing. The job here is to round-trip the rules this editor can show and to *refuse* the rest\n// out loud, because the usual failure of a recurrence form is not a crash — it is being bolted onto\n// data written by a real calendar and quietly simplifying a rule the first time somebody opens the\n// form to change the end date.\n// ---------------------------------------------------------------------------------------------\n\n/** A rule part this editor understands but cannot edit. The caller decides what to do about it. */\nexport interface UnsupportedPart {\n  name: string\n  value: string\n  reason: string\n}\n\nexport type ParsedRRule =\n  | { ok: true; rule: RecurrenceRule; unsupported: UnsupportedPart[] }\n  | { ok: false; error: string }\n\nconst EDITABLE_FREQ = new Set<string>([\"DAILY\", \"WEEKLY\", \"MONTHLY\", \"YEARLY\"])\nconst CLOCK_FREQ = new Set([\"SECONDLY\", \"MINUTELY\", \"HOURLY\"])\n\nconst UNSUPPORTED_REASONS: Record<string, string> = {\n  BYWEEKNO: \"week-of-the-year rules are not editable here\",\n  BYYEARDAY: \"day-of-the-year rules are not editable here\",\n  BYHOUR: \"times of day belong to DTSTART, not to this editor\",\n  BYMINUTE: \"times of day belong to DTSTART, not to this editor\",\n  BYSECOND: \"times of day belong to DTSTART, not to this editor\",\n  // RFC 7529's answer to the month-end question: SKIP=BACKWARD is the standardised way to ask for\n  // \"the 31st means the 30th in April\". It is recognised here so that a rule carrying it is not\n  // mistaken for a plain BYMONTHDAY rule and stripped of the very part that made it clamp.\n  RSCALE: \"a non-Gregorian calendar scale (RFC 7529) is not editable here\",\n  SKIP: \"RFC 7529 month-end clamping is not editable here; this editor offers “last day” instead\",\n}\n\nfunction parseNumberList(value: string): number[] | null {\n  const out: number[] = []\n  for (const token of value.split(\",\")) {\n    if (!/^[+-]?\\d{1,3}$/.test(token.trim())) return null\n    out.push(Number(token.trim()))\n  }\n  return out\n}\n\nfunction parseByDayList(value: string): ByDay[] | null {\n  const out: ByDay[] = []\n  for (const raw of value.split(\",\")) {\n    const token = raw.trim().toUpperCase()\n    const m = /^([+-]?\\d{1,2})?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token)\n    if (!m) return null\n    out.push(m[1] === undefined ? { weekday: m[2] as Weekday } : { weekday: m[2] as Weekday, ordinal: Number(m[1]) })\n  }\n  return out\n}\n\n/** Reads the `UNTIL` value in any of its three legal shapes back to a calendar day. */\nfunction parseUntil(value: string): string | null {\n  const m = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})Z?)?$/.exec(value.trim())\n  if (!m) return null\n  const day = { y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) }\n  return isValidDay(day) ? formatDay(day) : null\n}\n\nexport function parseRRule(text: string): ParsedRRule {\n  if (typeof text !== \"string\" || text.trim() === \"\") return { ok: false, error: \"The rule is empty.\" }\n  const body = text.trim().replace(/^RRULE:/i, \"\")\n  const rule = defaultRecurrence()\n  const unsupported: UnsupportedPart[] = []\n  let sawFreq = false\n\n  for (const segment of body.split(\";\")) {\n    if (segment.trim() === \"\") continue\n    const eq = segment.indexOf(\"=\")\n    if (eq < 1) return { ok: false, error: `“${segment}” is not a NAME=VALUE pair.` }\n    const name = segment.slice(0, eq).trim().toUpperCase()\n    const value = segment.slice(eq + 1).trim()\n\n    if (name === \"FREQ\") {\n      const freq = value.toUpperCase()\n      if (CLOCK_FREQ.has(freq)) {\n        return { ok: false, error: `FREQ=${freq} repeats within a day, which this editor does not cover.` }\n      }\n      if (!EDITABLE_FREQ.has(freq)) return { ok: false, error: `FREQ=${value} is not a frequency.` }\n      rule.freq = freq as Frequency\n      sawFreq = true\n      continue\n    }\n    if (name === \"INTERVAL\") {\n      const n = Number(value)\n      if (!/^\\d+$/.test(value) || !Number.isInteger(n) || n < 1) {\n        return { ok: false, error: `INTERVAL=${value} is not a positive whole number.` }\n      }\n      rule.interval = n\n      continue\n    }\n    if (name === \"WKST\") {\n      const code = value.toUpperCase()\n      if (weekdayIndex(code as Weekday) < 0) return { ok: false, error: `WKST=${value} is not a weekday.` }\n      rule.weekStart = code as Weekday\n      continue\n    }\n    if (name === \"COUNT\") {\n      const n = Number(value)\n      if (!/^\\d+$/.test(value) || n < 1) return { ok: false, error: `COUNT=${value} is not a positive whole number.` }\n      rule.count = n\n      continue\n    }\n    if (name === \"UNTIL\") {\n      const day = parseUntil(value)\n      if (!day) return { ok: false, error: `UNTIL=${value} is not a date.` }\n      rule.until = day\n      continue\n    }\n    if (name === \"BYDAY\") {\n      const list = parseByDayList(value)\n      if (!list) return { ok: false, error: `BYDAY=${value} is not a list of weekdays.` }\n      rule.byDay = dedupeByDay(list)\n      continue\n    }\n    if (name === \"BYMONTHDAY\" || name === \"BYMONTH\" || name === \"BYSETPOS\") {\n      const list = parseNumberList(value)\n      if (!list) return { ok: false, error: `${name}=${value} is not a list of numbers.` }\n      if (name === \"BYMONTHDAY\") rule.byMonthDay = uniqueSorted(list)\n      else if (name === \"BYMONTH\") rule.byMonth = uniqueSorted(list)\n      else rule.bySetPos = uniqueSorted(list)\n      continue\n    }\n    unsupported.push({\n      name,\n      value,\n      reason: UNSUPPORTED_REASONS[name] ?? \"this rule part is not editable here\",\n    })\n  }\n\n  if (!sawFreq) return { ok: false, error: \"A rule must have a FREQ.\" }\n  // Both present is a rule no calendar agrees on, so it is refused rather than resolved — picking\n  // one here would be this component deciding which of the two somebody meant.\n  if (rule.count !== null && rule.until !== null) {\n    return { ok: false, error: \"The rule has both COUNT and UNTIL, which RFC 5545 forbids.\" }\n  }\n  return { ok: true, rule, unsupported }\n}\n\n// ---------------------------------------------------------------------------------------------\n// Reading it back in words. Without this the person has built a rule they cannot check: an RRULE\n// string is not a sentence, and \"did I pick the second Monday or the 2nd?\" is exactly the mistake\n// the editor is supposed to prevent.\n// ---------------------------------------------------------------------------------------------\n\nexport interface RecurrenceLabels {\n  weekdays: Record<Weekday, string>\n  weekdaysShort: Record<Weekday, string>\n  months: string[]\n  /** `first`…`fifth`, then `last`, used for a numbered weekday. */\n  positions: { first: string; second: string; third: string; fourth: string; fifth: string; last: string }\n  frequency: Record<Frequency, { every: string; plural: string }>\n  lastDayOfMonth: string\n  /** Turns `15` into `15th`. Replace for languages that do not work that way. */\n  ordinalDay: (day: number) => string\n  /** Turns `2026-12-31` into something readable. */\n  formatDate: (day: string) => string\n  on: string\n  inMonth: string\n  and: string\n  until: string\n  /** `(n) => \"10 times\"`. */\n  times: (count: number) => string\n  forever: string\n}\n\nfunction englishOrdinal(day: number): string {\n  const abs = Math.abs(day)\n  const suffix =\n    abs % 100 >= 11 && abs % 100 <= 13 ? \"th\" : abs % 10 === 1 ? \"st\" : abs % 10 === 2 ? \"nd\" : abs % 10 === 3 ? \"rd\" : \"th\"\n  return `${abs}${suffix}`\n}\n\nexport const defaultRecurrenceLabels: RecurrenceLabels = {\n  weekdays: {\n    SU: \"Sunday\",\n    MO: \"Monday\",\n    TU: \"Tuesday\",\n    WE: \"Wednesday\",\n    TH: \"Thursday\",\n    FR: \"Friday\",\n    SA: \"Saturday\",\n  },\n  weekdaysShort: { SU: \"Sun\", MO: \"Mon\", TU: \"Tue\", WE: \"Wed\", TH: \"Thu\", FR: \"Fri\", SA: \"Sat\" },\n  months: [\n    \"January\",\n    \"February\",\n    \"March\",\n    \"April\",\n    \"May\",\n    \"June\",\n    \"July\",\n    \"August\",\n    \"September\",\n    \"October\",\n    \"November\",\n    \"December\",\n  ],\n  positions: { first: \"first\", second: \"second\", third: \"third\", fourth: \"fourth\", fifth: \"fifth\", last: \"last\" },\n  frequency: {\n    DAILY: { every: \"Every day\", plural: \"days\" },\n    WEEKLY: { every: \"Every week\", plural: \"weeks\" },\n    MONTHLY: { every: \"Every month\", plural: \"months\" },\n    YEARLY: { every: \"Every year\", plural: \"years\" },\n  },\n  lastDayOfMonth: \"last day of the month\",\n  ordinalDay: englishOrdinal,\n  // Deliberately not Intl.DateTimeFormat. A locale-dependent string renders one way on the server\n  // and another in the browser, which React reports as a hydration mismatch; and the ICU data a\n  // runtime ships with is not guaranteed to match the next one's. Replace this to localise.\n  formatDate: (day: string) => {\n    const parsed = parseDay(day)\n    if (!parsed) return day\n    return `${parsed.d} ${defaultRecurrenceLabels.months[parsed.m - 1]} ${parsed.y}`\n  },\n  on: \"on\",\n  inMonth: \"in\",\n  and: \"and\",\n  until: \"until\",\n  times: (count: number) => `${count} ${count === 1 ? \"time\" : \"times\"}`,\n  forever: \"with no end date\",\n}\n\nfunction joinList(items: string[], and: string): string {\n  if (items.length <= 1) return items[0] ?? \"\"\n  if (items.length === 2) return `${items[0]} ${and} ${items[1]}`\n  return `${items.slice(0, -1).join(\", \")} ${and} ${items[items.length - 1]}`\n}\n\nfunction positionWord(ordinal: number, labels: RecurrenceLabels): string {\n  if (ordinal < 0) return labels.positions.last\n  return (\n    [labels.positions.first, labels.positions.second, labels.positions.third, labels.positions.fourth, labels.positions.fifth][\n      ordinal - 1\n    ] ?? labels.ordinalDay(ordinal)\n  )\n}\n\n/** The rule as a sentence. Pair it with {@link nextOccurrences} — words plus dates catch more. */\nexport function describeRecurrence(rule: RecurrenceRule, labels: RecurrenceLabels = defaultRecurrenceLabels): string {\n  const freq = labels.frequency[rule.freq]\n  let head = rule.interval > 1 ? `Every ${rule.interval} ${freq.plural}` : freq.every\n\n  if (rule.freq === \"WEEKLY\" && rule.byDay.length) {\n    head += ` ${labels.on} ${joinList(\n      rule.byDay.map((d) => labels.weekdays[d.weekday]),\n      labels.and,\n    )}`\n  }\n\n  if (rule.freq === \"MONTHLY\" || rule.freq === \"YEARLY\") {\n    if (rule.freq === \"YEARLY\" && rule.byMonth.length) {\n      head += ` ${labels.inMonth} ${joinList(\n        rule.byMonth.map((m) => labels.months[m - 1] ?? String(m)),\n        labels.and,\n      )}`\n    }\n    if (rule.byMonthDay.length) {\n      const words = rule.byMonthDay.map((d) =>\n        d === -1 ? labels.lastDayOfMonth : d < 0 ? `${labels.ordinalDay(-d)} from the end` : labels.ordinalDay(d),\n      )\n      head += ` ${labels.on} the ${joinList(words, labels.and)}`\n    } else if (rule.byDay.length) {\n      const words = rule.byDay.map((d) =>\n        d.ordinal === undefined\n          ? labels.weekdays[d.weekday]\n          : `${positionWord(d.ordinal, labels)} ${labels.weekdays[d.weekday]}`,\n      )\n      head += ` ${labels.on} the ${joinList(words, labels.and)}`\n    }\n  }\n\n  if (rule.count !== null) return `${head}, ${labels.times(rule.count)}`\n  if (rule.until !== null) return `${head}, ${labels.until} ${labels.formatDate(rule.until)}`\n  return head\n}\n\n// ---------------------------------------------------------------------------------------------\n// Generating the next dates. This exists for checking: words can be read two ways, and dates\n// cannot. It is also the only thing that makes the skip rule visible before it costs somebody a\n// missed invoice.\n// ---------------------------------------------------------------------------------------------\n\nexport interface OccurrenceResult {\n  /** Calendar days, ascending. */\n  days: string[]\n  /**\n   * Whether `DTSTART` is itself one of the occurrences.\n   *\n   * False means the rule and the start date disagree — a series starting on the 15th that repeats\n   * on the 29th, or starting on a Monday that repeats on weekends. RFC 5545 calls that combination\n   * \"not synchronized with the recurrence rule\" and declares the resulting set *undefined*, so\n   * every implementation is free to resolve it differently and they do not all agree. Surfacing it\n   * is the only honest option: the caller can show \"your start date is not one of the dates\" rather\n   * than having this component pick a reading of an undefined rule and hide the choice.\n   */\n  startIsOccurrence: boolean\n  /** True when the series actually ran out — `COUNT` reached or past `UNTIL` — rather than hitting `limit`. */\n  complete: boolean\n  /**\n   * Instances the rule asked for and RFC 5545 requires be ignored, within the periods scanned.\n   *\n   * A February 30th, or a fifth Friday in a month with four. Non-zero here is the number that tells\n   * a person their \"monthly\" rule is not monthly, and it is why this is reported rather than being\n   * silently absorbed the way a clamping implementation absorbs it.\n   */\n  skipped: number\n}\n\n/** Every day in `y`/`m` that the rule's `BY*` parts pick out, plus how many it asked for and missed. */\nfunction candidatesInMonth(\n  y: number,\n  m: number,\n  rule: RecurrenceRule,\n  start: PlainDay,\n): { days: PlainDay[]; skipped: number } {\n  const length = daysInMonth(y, m)\n  const days: PlainDay[] = []\n  let skipped = 0\n\n  if (rule.byMonthDay.length) {\n    for (const spec of rule.byMonthDay) {\n      const d = spec > 0 ? spec : length + spec + 1\n      if (d >= 1 && d <= length) days.push({ y, m, d })\n      else skipped += 1\n    }\n  } else if (rule.byDay.length) {\n    for (const entry of rule.byDay) {\n      const target = weekdayIndex(entry.weekday)\n      if (entry.ordinal === undefined) {\n        const first = 1 + ((target - dayOfWeek({ y, m, d: 1 }) + 7) % 7)\n        for (let d = first; d <= length; d += 7) days.push({ y, m, d })\n        continue\n      }\n      let d: number\n      if (entry.ordinal > 0) {\n        const first = 1 + ((target - dayOfWeek({ y, m, d: 1 }) + 7) % 7)\n        d = first + (entry.ordinal - 1) * 7\n      } else {\n        const last = length - ((dayOfWeek({ y, m, d: length }) - target + 7) % 7)\n        d = last + (entry.ordinal + 1) * 7\n      }\n      // A fifth Monday exists in some months and not others. RFC 5545 is clear that the months\n      // without one produce nothing rather than falling back to the fourth.\n      if (d >= 1 && d <= length) days.push({ y, m, d })\n      else skipped += 1\n    }\n  } else {\n    // No BY* part: the day comes from DTSTART, which is where a monthly rule's day lives when the\n    // rule does not spell it out — and a 31st start date skips the short months exactly the same way.\n    if (start.d <= length) days.push({ y, m, d: start.d })\n    else skipped += 1\n  }\n\n  return { days, skipped }\n}\n\nfunction applySetPos(days: PlainDay[], bySetPos: number[]): PlainDay[] {\n  if (!bySetPos.length) return days\n  const picked: PlainDay[] = []\n  for (const pos of bySetPos) {\n    const index = pos > 0 ? pos - 1 : days.length + pos\n    const day = days[index]\n    if (day) picked.push(day)\n  }\n  return picked\n}\n\nfunction sortDays(days: PlainDay[]): PlainDay[] {\n  const seen = new Set<number>()\n  return days\n    .filter((d) => {\n      const n = dayNumber(d)\n      if (seen.has(n)) return false\n      seen.add(n)\n      return true\n    })\n    .sort(compareDays)\n}\n\n/**\n * The first `limit` days of the series.\n *\n * `DTSTART` appears when, and only when, the rule actually generates it. That is the behaviour of\n * rrule.js — the implementation most calendars and most of this ecosystem are built on — and it was\n * arrived at by comparison against it rather than from the spec, because the spec does not settle\n * it: RFC 5545 says a `DTSTART` \"not synchronized with the recurrence rule\" produces an *undefined*\n * recurrence set. Matching the reference matters more than being defensibly different, because the\n * whole value of a preview is that the dates it shows are the dates the caller's calendar will\n * later produce. {@link OccurrenceResult.startIsOccurrence} reports which case you are in.\n *\n * `COUNT` counts what this emits, so `COUNT=1` is one date — and because RFC 5545 requires that an\n * instance falling on an invalid date \"MUST NOT be counted as part of the recurrence set\", a\n * `COUNT` paired with a day that does not exist every month runs far longer in wall-clock time than\n * it reads. {@link OccurrenceResult.skipped} is how that becomes visible.\n *\n * Periods are stepped rather than days, and the scan is bounded: a rule that can never match at all\n * — `BYMONTH=2` with `BYMONTHDAY=30` — ends empty rather than spinning. Nothing here reads the\n * clock, so the same arguments always give the same answer.\n */\nexport function nextOccurrences(\n  rule: RecurrenceRule,\n  dtstart: PlainDay | string,\n  limit = 5,\n  options: { maxPeriods?: number } = {},\n): OccurrenceResult {\n  const start = typeof dtstart === \"string\" ? parseDay(dtstart) : dtstart\n  const maxPeriods = options.maxPeriods ?? 600\n  const empty = { days: [], complete: false, skipped: 0, startIsOccurrence: false }\n  // An impossible start date is refused rather than normalised. `new Date(2026, 1, 29)` is 1 March\n  // 2026, and a component that accepted it would move somebody's series by a day without a word.\n  if (!start || !isValidDay(start) || limit < 1) return empty\n  if (recurrenceIssues(rule).length) return empty\n\n  const untilDay = rule.until ? parseDay(rule.until) : null\n  const monthFilter = rule.byMonth.length ? new Set(rule.byMonth) : null\n  const out: PlainDay[] = []\n  let skipped = 0\n  let complete = false\n\n  const weekStartOffset = (dayOfWeek(start) - weekdayIndex(rule.weekStart) + 7) % 7\n  const alignedWeekStart = addDays(start, -weekStartOffset)\n  const weeklyCodes = rule.byDay.length ? rule.byDay.map((d) => d.weekday) : [WEEKDAYS[dayOfWeek(start)]]\n\n  for (let period = 0; !complete && out.length < limit && period < maxPeriods; period += 1) {\n    let candidates: PlainDay[] = []\n\n    if (rule.freq === \"DAILY\") {\n      candidates = [addDays(start, period * rule.interval)]\n    } else if (rule.freq === \"WEEKLY\") {\n      const base = addDays(alignedWeekStart, period * rule.interval * 7)\n      candidates = weeklyCodes.map((code) =>\n        addDays(base, (weekdayIndex(code) - weekdayIndex(rule.weekStart) + 7) % 7),\n      )\n    } else if (rule.freq === \"MONTHLY\") {\n      const month = addMonths({ y: start.y, m: start.m, d: 1 }, period * rule.interval)\n      const found = candidatesInMonth(month.y, month.m, rule, start)\n      skipped += found.skipped\n      candidates = applySetPos(sortDays(found.days), rule.bySetPos)\n    } else {\n      const year = start.y + period * rule.interval\n      const months = rule.byMonth.length ? uniqueSorted(rule.byMonth) : [start.m]\n      const collected: PlainDay[] = []\n      for (const m of months) {\n        if (m < 1 || m > 12) continue\n        const found = candidatesInMonth(year, m, rule, start)\n        skipped += found.skipped\n        collected.push(...found.days)\n      }\n      // BYSETPOS counts within one interval of the rule, so for a yearly rule the whole year is\n      // the set — not each month inside it.\n      candidates = applySetPos(sortDays(collected), rule.bySetPos)\n    }\n\n    for (const candidate of sortDays(candidates)) {\n      if (rule.freq !== \"YEARLY\" && monthFilter && !monthFilter.has(candidate.m)) continue\n      if (compareDays(candidate, start) < 0) continue\n      if (untilDay && compareDays(candidate, untilDay) > 0) {\n        complete = true\n        break\n      }\n      out.push(candidate)\n      if (rule.count !== null && out.length >= rule.count) {\n        complete = true\n        break\n      }\n      if (out.length >= limit) break\n    }\n  }\n\n  const days = out.map(formatDay)\n  return { days, complete, skipped, startIsOccurrence: days[0] === formatDay(start) }\n}\n\n// ---------------------------------------------------------------------------------------------\n// The control.\n// ---------------------------------------------------------------------------------------------\n\nexport interface RecurrenceInputLabels extends RecurrenceLabels {\n  legend: string\n  description: string\n  every: string\n  frequencyLabel: string\n  intervalLabel: string\n  weekdaysLegend: string\n  monthlyLegend: string\n  monthLabel: string\n  modeDayOfMonth: string\n  modeNthWeekday: string\n  modeLastDay: string\n  dayOfMonthLabel: string\n  positionLabel: string\n  weekdayLabel: string\n  endingLegend: string\n  endingNever: string\n  endingCount: string\n  endingUntil: string\n  countLabel: string\n  untilLabel: string\n  summaryLabel: string\n  previewLabel: string\n  previewMore: string\n  previewComplete: string\n  skipNotice: (skip: MonthDaySkip, monthNames: string[]) => string\n  droppedNotice: (dropped: string[]) => string\n  unsyncedNotice: (dtstart: string) => string\n}\n\nexport const defaultRecurrenceInputLabels: RecurrenceInputLabels = {\n  ...defaultRecurrenceLabels,\n  legend: \"Repeats\",\n  description: \"How often this happens, and when it stops.\",\n  every: \"Every\",\n  frequencyLabel: \"Frequency\",\n  intervalLabel: \"Repeat every\",\n  weekdaysLegend: \"On these days\",\n  monthlyLegend: \"On\",\n  monthLabel: \"Month\",\n  modeDayOfMonth: \"Day of the month\",\n  modeNthWeekday: \"Weekday of the month\",\n  modeLastDay: \"Last day of the month\",\n  dayOfMonthLabel: \"Day\",\n  positionLabel: \"Which one\",\n  weekdayLabel: \"Weekday\",\n  endingLegend: \"Ends\",\n  endingNever: \"Never\",\n  endingCount: \"After a number of times\",\n  endingUntil: \"On a date\",\n  countLabel: \"Times\",\n  untilLabel: \"End date\",\n  summaryLabel: \"In words\",\n  previewLabel: \"Next dates\",\n  previewMore: \"…\",\n  previewComplete: \"That is all of them.\",\n  skipNotice: (skip, monthNames) =>\n    `The ${englishOrdinal(skip.day)} does not exist in ${skip.months\n      .map((m) => monthNames[m - 1])\n      .join(\", \")}${skip.leapSensitive ? \" outside leap years\" : \"\"}, so those months are skipped, and a skipped date does not count towards a total. Choose “last day of the month” instead if you mean the end of every month.`,\n  droppedNotice: (dropped) => `Changing the frequency cleared: ${dropped.join(\", \")}.`,\n  unsyncedNotice: (dtstart) =>\n    `The start date (${defaultRecurrenceLabels.formatDate(dtstart)}) does not match this rule, so it is not one of the dates below.`,\n}\n\nexport interface RecurrenceInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\" | \"onChange\" | \"value\" | \"defaultValue\"> {\n  /** Controlled rule. Pair with {@link onValueChange}. */\n  value?: RecurrenceRule\n  /** Uncontrolled starting rule. */\n  defaultValue?: RecurrenceRule\n  onValueChange?: (rule: RecurrenceRule) => void\n  /**\n   * The series' start date, `YYYY-MM-DD` — `DTSTART`, which this component reads and never owns.\n   *\n   * It is needed for three separate things and guessing it is wrong for all three: it seeds a field\n   * that becomes relevant when the frequency changes, it is the first occurrence in the preview, and\n   * it is the day an unqualified monthly rule repeats on. Omit it and the preview is hidden rather\n   * than being computed from today's date — a preview that moves when the page is reloaded is worse\n   * than none.\n   */\n  dtstart?: string\n  /** Posts the RRULE value with a plain form submit or a server action. */\n  name?: string\n  /** How many dates the preview shows. */\n  previewCount?: number\n  /** Passed through to {@link formatRRule} for the emitted string and the `name` field. */\n  formatOptions?: FormatRRuleOptions\n  labels?: Partial<RecurrenceInputLabels>\n  disabled?: boolean\n  classNames?: {\n    fieldset?: string\n    row?: string\n    control?: string\n    summary?: string\n    notice?: string\n  }\n}\n\nconst SELECT_CLASS =\n  \"h-8 rounded-md border border-input bg-background px-2 text-sm text-foreground shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\nconst NUMBER_CLASS = `${SELECT_CLASS} w-16 tabular-nums`\n\nexport function RecurrenceInput({\n  value,\n  defaultValue,\n  onValueChange,\n  dtstart,\n  name,\n  previewCount = 5,\n  formatOptions,\n  labels: labelOverrides,\n  disabled = false,\n  classNames,\n  className,\n  ...rest\n}: RecurrenceInputProps) {\n  const labels = React.useMemo(\n    () => ({ ...defaultRecurrenceInputLabels, ...labelOverrides }),\n    [labelOverrides],\n  )\n  const [internal, setInternal] = React.useState<RecurrenceRule>(\n    () => defaultValue ?? defaultRecurrence(),\n  )\n  const isControlled = value !== undefined\n  const rule = isControlled ? value : internal\n  const [dropped, setDropped] = React.useState<string[]>([])\n  const reactId = React.useId()\n\n  const commit = React.useCallback(\n    (next: RecurrenceRule) => {\n      // Always set local state, controlled or not. A controlled parent that re-renders with the same\n      // object is then still consistent, and an uncontrolled one does not need a second path.\n      setInternal(next)\n      onValueChange?.(next)\n    },\n    [onValueChange],\n  )\n\n  const seed = dtstart ?? null\n  const issues = React.useMemo(() => recurrenceIssues(rule), [rule])\n  const rrule = React.useMemo(() => formatRRule(rule, formatOptions), [rule, formatOptions])\n  const sentence = React.useMemo(() => describeRecurrence(rule, labels), [rule, labels])\n  const preview = React.useMemo(\n    () => (seed ? nextOccurrences(rule, seed, Math.max(1, previewCount)) : null),\n    [rule, seed, previewCount],\n  )\n\n  const mode = monthlyMode(rule)\n  const skip = React.useMemo(() => {\n    if (rule.freq !== \"MONTHLY\" && rule.freq !== \"YEARLY\") return null\n    if (rule.freq === \"YEARLY\") return null\n    const day = rule.byMonthDay.find((d) => d > 0)\n    return day === undefined ? null : monthDaySkip(day)\n  }, [rule])\n\n  const ending = endingOf(rule)\n  const id = (part: string) => `${reactId}-${part}`\n\n  const onFrequency = (freq: Frequency) => {\n    setDropped(frequencyChangeDrops(rule, freq))\n    commit(changeFrequency(rule, freq, seed))\n  }\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-3 text-sm\", className)} {...rest}>\n      <fieldset\n        disabled={disabled}\n        className={cn(\"flex flex-col gap-3 rounded-lg border border-border p-3\", classNames?.fieldset)}\n      >\n        <legend className=\"px-1 text-xs font-medium text-muted-foreground\">{labels.legend}</legend>\n\n        <div className={cn(\"flex flex-wrap items-center gap-2\", classNames?.row)}>\n          <label htmlFor={id(\"interval\")} className=\"text-muted-foreground\">\n            {labels.every}\n          </label>\n          <input\n            id={id(\"interval\")}\n            type=\"number\"\n            min={1}\n            step={1}\n            inputMode=\"numeric\"\n            value={rule.interval}\n            aria-label={labels.intervalLabel}\n            aria-invalid={issues.some((i) => i.field === \"interval\") || undefined}\n            className={cn(NUMBER_CLASS, classNames?.control)}\n            onChange={(event) => {\n              const n = Number(event.target.value)\n              commit({ ...rule, interval: Number.isFinite(n) ? Math.max(1, Math.floor(n)) : 1 })\n            }}\n          />\n          <select\n            aria-label={labels.frequencyLabel}\n            value={rule.freq}\n            className={cn(SELECT_CLASS, classNames?.control)}\n            onChange={(event) => onFrequency(event.target.value as Frequency)}\n          >\n            {([\"DAILY\", \"WEEKLY\", \"MONTHLY\", \"YEARLY\"] as const).map((freq) => (\n              <option key={freq} value={freq}>\n                {labels.frequency[freq].plural}\n              </option>\n            ))}\n          </select>\n        </div>\n\n        {rule.freq === \"WEEKLY\" ? (\n          <div role=\"group\" aria-label={labels.weekdaysLegend} className=\"flex flex-wrap gap-1\">\n            {WEEKDAYS.map((code) => {\n              const on = rule.byDay.some((d) => d.weekday === code)\n              const isLast = on && rule.byDay.length === 1\n              return (\n                <button\n                  key={code}\n                  type=\"button\"\n                  aria-pressed={on}\n                  // The last checked weekday is refused, not removed, and says why rather than\n                  // looking inert: `aria-disabled` keeps the tab stop and the announcement.\n                  aria-disabled={isLast || undefined}\n                  aria-describedby={isLast ? id(\"weekday-floor\") : undefined}\n                  onClick={() => {\n                    if (isLast) return\n                    commit(toggleWeekday(rule, code))\n                  }}\n                  className={cn(\n                    \"h-8 min-w-10 rounded-md border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                    on\n                      ? \"border-primary bg-primary text-primary-foreground\"\n                      : \"border-input bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground\",\n                    isLast && \"cursor-not-allowed\",\n                  )}\n                >\n                  {labels.weekdaysShort[code]}\n                </button>\n              )\n            })}\n            <span id={id(\"weekday-floor\")} className=\"sr-only\">\n              At least one day has to stay selected.\n            </span>\n          </div>\n        ) : null}\n\n        {rule.freq === \"YEARLY\" ? (\n          <div className={cn(\"flex flex-wrap items-center gap-2\", classNames?.row)}>\n            <label htmlFor={id(\"month\")} className=\"text-muted-foreground\">\n              {labels.monthLabel}\n            </label>\n            <select\n              id={id(\"month\")}\n              value={rule.byMonth[0] ?? 1}\n              className={cn(SELECT_CLASS, classNames?.control)}\n              onChange={(event) => commit({ ...rule, byMonth: [Number(event.target.value)] })}\n            >\n              {labels.months.map((label, index) => (\n                <option key={label} value={index + 1}>\n                  {label}\n                </option>\n              ))}\n            </select>\n          </div>\n        ) : null}\n\n        {rule.freq === \"MONTHLY\" || rule.freq === \"YEARLY\" ? (\n          <div role=\"radiogroup\" aria-label={labels.monthlyLegend} className=\"flex flex-col gap-2\">\n            {(\n              [\n                [\"dayOfMonth\", labels.modeDayOfMonth],\n                [\"nthWeekday\", labels.modeNthWeekday],\n                [\"lastDay\", labels.modeLastDay],\n              ] as const\n            ).map(([key, label]) => (\n              <div key={key} className=\"flex flex-wrap items-center gap-2\">\n                <label className=\"flex items-center gap-2\">\n                  <input\n                    type=\"radio\"\n                    name={id(\"mode\")}\n                    checked={mode === key}\n                    onChange={() => commit(setMonthlyMode(rule, key, seed))}\n                    className=\"size-4 cursor-pointer accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                  />\n                  <span className={mode === key ? \"text-foreground\" : \"text-muted-foreground\"}>{label}</span>\n                </label>\n\n                {key === \"dayOfMonth\" && mode === \"dayOfMonth\" ? (\n                  <input\n                    type=\"number\"\n                    min={1}\n                    max={31}\n                    step={1}\n                    inputMode=\"numeric\"\n                    aria-label={labels.dayOfMonthLabel}\n                    value={rule.byMonthDay.find((d) => d > 0) ?? 1}\n                    className={cn(NUMBER_CLASS, classNames?.control)}\n                    onChange={(event) => {\n                      const n = Number(event.target.value)\n                      if (!Number.isFinite(n)) return\n                      commit({ ...rule, byMonthDay: [Math.min(31, Math.max(1, Math.floor(n)))], byDay: [] })\n                    }}\n                  />\n                ) : null}\n\n                {key === \"nthWeekday\" && mode === \"nthWeekday\" ? (\n                  <>\n                    <select\n                      aria-label={labels.positionLabel}\n                      value={rule.byDay[0]?.ordinal ?? 1}\n                      className={cn(SELECT_CLASS, classNames?.control)}\n                      onChange={(event) =>\n                        commit({\n                          ...rule,\n                          byMonthDay: [],\n                          byDay: [\n                            {\n                              weekday: rule.byDay[0]?.weekday ?? \"MO\",\n                              ordinal: Number(event.target.value),\n                            },\n                          ],\n                        })\n                      }\n                    >\n                      {([1, 2, 3, 4, 5, -1] as const).map((ordinal) => (\n                        <option key={ordinal} value={ordinal}>\n                          {positionWord(ordinal, labels)}\n                        </option>\n                      ))}\n                    </select>\n                    <select\n                      aria-label={labels.weekdayLabel}\n                      value={rule.byDay[0]?.weekday ?? \"MO\"}\n                      className={cn(SELECT_CLASS, classNames?.control)}\n                      onChange={(event) =>\n                        commit({\n                          ...rule,\n                          byMonthDay: [],\n                          byDay: [\n                            {\n                              weekday: event.target.value as Weekday,\n                              ordinal: rule.byDay[0]?.ordinal ?? 1,\n                            },\n                          ],\n                        })\n                      }\n                    >\n                      {WEEKDAYS.map((code) => (\n                        <option key={code} value={code}>\n                          {labels.weekdays[code]}\n                        </option>\n                      ))}\n                    </select>\n                  </>\n                ) : null}\n              </div>\n            ))}\n          </div>\n        ) : null}\n\n        <div role=\"radiogroup\" aria-label={labels.endingLegend} className=\"flex flex-col gap-2\">\n          {(\n            [\n              [\"never\", labels.endingNever],\n              [\"count\", labels.endingCount],\n              [\"until\", labels.endingUntil],\n            ] as const\n          ).map(([key, label]) => (\n            <div key={key} className=\"flex flex-wrap items-center gap-2\">\n              <label className=\"flex items-center gap-2\">\n                <input\n                  type=\"radio\"\n                  name={id(\"ending\")}\n                  checked={ending === key}\n                  onChange={() =>\n                    commit(setEnding(rule, key, key === \"count\" ? (rule.count ?? 10) : (rule.until ?? seed)))\n                  }\n                  className=\"size-4 cursor-pointer accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                />\n                <span className={ending === key ? \"text-foreground\" : \"text-muted-foreground\"}>{label}</span>\n              </label>\n              {key === \"count\" && ending === \"count\" ? (\n                <input\n                  type=\"number\"\n                  min={1}\n                  step={1}\n                  inputMode=\"numeric\"\n                  aria-label={labels.countLabel}\n                  aria-invalid={issues.some((i) => i.field === \"count\") || undefined}\n                  value={rule.count ?? 1}\n                  className={cn(NUMBER_CLASS, classNames?.control)}\n                  onChange={(event) => commit(setEnding(rule, \"count\", Number(event.target.value)))}\n                />\n              ) : null}\n              {key === \"until\" && ending === \"until\" ? (\n                <input\n                  type=\"date\"\n                  aria-label={labels.untilLabel}\n                  aria-invalid={issues.some((i) => i.field === \"until\") || undefined}\n                  value={rule.until ?? \"\"}\n                  className={cn(SELECT_CLASS, classNames?.control)}\n                  onChange={(event) => commit(setEnding(rule, \"until\", event.target.value))}\n                />\n              ) : null}\n            </div>\n          ))}\n        </div>\n      </fieldset>\n\n      {skip && skip.months.length ? (\n        <p className={cn(\"text-xs text-muted-foreground\", classNames?.notice)}>\n          {labels.skipNotice(skip, labels.months)}\n        </p>\n      ) : null}\n\n      {/* An unsynchronised start date is the other silent one: the rule is valid, the preview is\n          populated, and the day the person typed into the start field is simply not in it. */}\n      {preview && seed && preview.days.length && !preview.startIsOccurrence ? (\n        <p className={cn(\"text-xs text-muted-foreground\", classNames?.notice)}>\n          {labels.unsyncedNotice(seed)}\n        </p>\n      ) : null}\n\n      {issues.length ? (\n        <ul className=\"flex flex-col gap-1 text-xs text-destructive\">\n          {issues.map((issue) => (\n            <li key={`${issue.field}-${issue.message}`}>{issue.message}</li>\n          ))}\n        </ul>\n      ) : null}\n\n      <div className={cn(\"flex flex-col gap-1 rounded-md bg-muted/50 p-2 text-xs\", classNames?.summary)}>\n        <span className=\"text-muted-foreground\">{labels.summaryLabel}</span>\n        <strong className=\"font-medium text-foreground\">{sentence}</strong>\n        {preview && preview.days.length ? (\n          <>\n            <span className=\"mt-1 text-muted-foreground\">{labels.previewLabel}</span>\n            <span className=\"font-mono text-foreground\">\n              {preview.days.map((day) => labels.formatDate(day)).join(\" · \")}\n              {preview.complete ? \"\" : ` ${labels.previewMore}`}\n            </span>\n          </>\n        ) : null}\n      </div>\n\n      {/* Permanently mounted, so a change is announced rather than the region arriving with its own\n          text — a live region inserted along with its message is often not read at all. */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {dropped.length ? `${labels.droppedNotice(dropped)} ` : \"\"}\n        {sentence}\n      </span>\n\n      {name ? <input type=\"hidden\" name={name} value={rrule} /> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
