{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "recurrence-editor",
  "title": "Recurrence Editor",
  "description": "Edit an RRULE repeat rule with a plain-language summary and the next occurrences.",
  "registryDependencies": [
    "utils",
    "input",
    "label",
    "@drift-ui/number-input",
    "@drift-ui/use-controllable-state"
  ],
  "files": [
    {
      "path": "registry/default/ui/recurrence-editor.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/registry/default/lib/utils\"\nimport {\n  describeDays,\n  describeRecurrence,\n  formatRRule,\n  FREQUENCIES,\n  isLastWeekOf,\n  occurrences,\n  ordinalWeekOf,\n  parseRRule,\n  WEEKDAY_NAMES,\n  WEEKDAYS,\n  weekdayOf,\n  type Frequency,\n  type Recurrence,\n} from \"@/registry/default/lib/recurrence\"\nimport { useControllableState } from \"@/registry/default/hooks/use-controllable-state\"\nimport { Input } from \"@/registry/default/ui/input\"\nimport { Label } from \"@/registry/default/ui/label\"\nimport { NumberInput } from \"@/registry/default/ui/number-input\"\n\ntype EndMode = \"never\" | \"until\" | \"count\"\n\nconst UNIT_LABELS: Record<Frequency, [string, string]> = {\n  DAILY: [\"day\", \"days\"],\n  WEEKLY: [\"week\", \"weeks\"],\n  MONTHLY: [\"month\", \"months\"],\n  YEARLY: [\"year\", \"years\"],\n}\nconst DEFAULT_COUNT = 10\nconst DEFAULT_UNTIL_MONTHS = 3\nconst SELECT_CLASS_NAME =\n  \"h-9 rounded-md border border-input bg-transparent px-2 text-base shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 md:text-sm dark:bg-input/30 [&>option]:bg-popover [&>option]:text-popover-foreground\"\n\nfunction subscribeToNothing() {\n  return () => {}\n}\n\nfunction endOfDay(year: number, month: number, day: number) {\n  return new Date(year, month, day, 23, 59, 59)\n}\n\nfunction toDateInputValue(date: Date) {\n  return [\n    date.getFullYear(),\n    String(date.getMonth() + 1).padStart(2, \"0\"),\n    String(date.getDate()).padStart(2, \"0\"),\n  ].join(\"-\")\n}\n\nfunction capitalize(text: string) {\n  return text.charAt(0).toUpperCase() + text.slice(1)\n}\n\nfunction monthlyChoices(recurrence: Recurrence, start: Date) {\n  const weekday = weekdayOf(start)\n  const patches: Pick<Recurrence, \"byDay\" | \"byMonthDay\">[] = [\n    { byDay: [], byMonthDay: [start.getDate()] },\n  ]\n  if (ordinalWeekOf(start) <= 4) {\n    patches.push({\n      byDay: [{ weekday, ordinal: ordinalWeekOf(start) }],\n      byMonthDay: [],\n    })\n  }\n  if (isLastWeekOf(start)) {\n    patches.push({ byDay: [{ weekday, ordinal: -1 }], byMonthDay: [] })\n  }\n  const current = {\n    byDay: recurrence.byDay,\n    byMonthDay:\n      recurrence.byDay.length === 0 && recurrence.byMonthDay.length === 0\n        ? [start.getDate()]\n        : recurrence.byMonthDay,\n  }\n  const choices = patches.map((patch) => ({\n    key: formatRRule({ ...recurrence, ...patch }),\n    label: capitalize(describeDays({ ...recurrence, ...patch }, start)),\n    patch,\n  }))\n  const currentKey = formatRRule({ ...recurrence, ...current })\n  if (!choices.some((choice) => choice.key === currentKey)) {\n    choices.unshift({\n      key: currentKey,\n      label: capitalize(describeDays(recurrence, start)),\n      patch: current,\n    })\n  }\n  return { choices, currentKey }\n}\n\nfunction RecurrenceEditor({\n  value,\n  defaultValue = \"FREQ=WEEKLY\",\n  onValueChange,\n  start,\n  showPreview = 0,\n  className,\n  ...props\n}: Omit<React.ComponentProps<\"fieldset\">, \"defaultValue\"> & {\n  value?: string\n  defaultValue?: string\n  onValueChange?: (rule: string) => void\n  start?: Date\n  showPreview?: number\n}) {\n  const id = React.useId()\n  const { value: rule, setValue: setRule } = useControllableState({\n    value,\n    defaultValue,\n    onChange: onValueChange,\n  })\n  const [today] = React.useState(() => new Date())\n  const isClient = React.useSyncExternalStore(\n    subscribeToNothing,\n    () => true,\n    () => false\n  )\n  const anchor = start ?? today\n  const recurrence = parseRRule(rule)\n  const { frequency, interval, count, until } = recurrence\n  const endMode: EndMode = count ? \"count\" : until ? \"until\" : \"never\"\n  const selectedWeekdays =\n    recurrence.byDay.length > 0\n      ? recurrence.byDay.map((day) => day.weekday)\n      : [weekdayOf(anchor)]\n  const monthly = monthlyChoices(recurrence, anchor)\n  const upcoming =\n    showPreview > 0 ? occurrences(recurrence, anchor, showPreview) : []\n  const dateFormatter = new Intl.DateTimeFormat(undefined, {\n    weekday: \"short\",\n    month: \"short\",\n    day: \"numeric\",\n    year: \"numeric\",\n  })\n\n  function update(patch: Partial<Recurrence>) {\n    setRule(formatRRule({ ...recurrence, ...patch }))\n  }\n\n  function changeFrequency(next: Frequency) {\n    update({\n      frequency: next,\n      byDay: next === \"WEEKLY\" ? [{ weekday: weekdayOf(anchor) }] : [],\n      byMonthDay: next === \"MONTHLY\" ? [anchor.getDate()] : [],\n    })\n  }\n\n  function toggleWeekday(weekday: (typeof WEEKDAYS)[number]) {\n    const isSelected = selectedWeekdays.includes(weekday)\n    if (isSelected && selectedWeekdays.length === 1) return\n    const next = isSelected\n      ? selectedWeekdays.filter((day) => day !== weekday)\n      : [...selectedWeekdays, weekday]\n    update({\n      byDay: WEEKDAYS.filter((day) => next.includes(day)).map((day) => ({\n        weekday: day,\n      })),\n    })\n  }\n\n  function changeEndMode(mode: EndMode) {\n    update({\n      count: mode === \"count\" ? (count ?? DEFAULT_COUNT) : undefined,\n      until:\n        mode === \"until\"\n          ? (until ??\n            endOfDay(\n              anchor.getFullYear(),\n              anchor.getMonth() + DEFAULT_UNTIL_MONTHS,\n              anchor.getDate()\n            ))\n          : undefined,\n    })\n  }\n\n  return (\n    <fieldset\n      data-slot=\"recurrence-editor\"\n      className={cn(\"grid min-w-0 gap-4 text-sm\", className)}\n      {...props}\n    >\n      <div className=\"flex flex-wrap items-center gap-2\">\n        <Label htmlFor={`${id}-interval`}>Repeat every</Label>\n        <NumberInput\n          id={`${id}-interval`}\n          min={1}\n          value={interval}\n          onValueChange={(next) => {\n            if (next !== null) update({ interval: Math.round(next) })\n          }}\n          className=\"w-28\"\n        />\n        <select\n          aria-label=\"Frequency\"\n          value={frequency}\n          onChange={(event) => changeFrequency(event.target.value as Frequency)}\n          className={SELECT_CLASS_NAME}\n        >\n          {FREQUENCIES.map((option) => (\n            <option key={option} value={option}>\n              {UNIT_LABELS[option][interval === 1 ? 0 : 1]}\n            </option>\n          ))}\n        </select>\n      </div>\n\n      {frequency === \"WEEKLY\" && (\n        <div\n          role=\"group\"\n          aria-labelledby={`${id}-weekdays`}\n          className=\"flex flex-wrap items-center gap-2\"\n        >\n          <span id={`${id}-weekdays`} className=\"mr-1 font-medium\">\n            Repeat on\n          </span>\n          {WEEKDAYS.map((weekday) => (\n            <button\n              key={weekday}\n              type=\"button\"\n              aria-label={WEEKDAY_NAMES[weekday]}\n              aria-pressed={selectedWeekdays.includes(weekday)}\n              onClick={() => toggleWeekday(weekday)}\n              className=\"flex size-8 items-center justify-center rounded-full border border-input text-xs font-medium transition-colors outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground\"\n            >\n              {WEEKDAY_NAMES[weekday].charAt(0)}\n            </button>\n          ))}\n        </div>\n      )}\n\n      {frequency === \"MONTHLY\" && (\n        <select\n          aria-label=\"Day of the month\"\n          value={monthly.currentKey}\n          onChange={(event) => {\n            const choice = monthly.choices.find(\n              (option) => option.key === event.target.value\n            )\n            if (choice) update(choice.patch)\n          }}\n          className={cn(SELECT_CLASS_NAME, \"w-fit\")}\n        >\n          {monthly.choices.map((choice) => (\n            <option key={choice.key} value={choice.key}>\n              {choice.label}\n            </option>\n          ))}\n        </select>\n      )}\n\n      <fieldset className=\"grid gap-2\">\n        <legend className=\"mb-2 font-medium\">Ends</legend>\n        <label className=\"flex h-9 items-center gap-2\">\n          <input\n            type=\"radio\"\n            name={`${id}-end`}\n            checked={endMode === \"never\"}\n            onChange={() => changeEndMode(\"never\")}\n            className=\"size-4 accent-primary\"\n          />\n          Never\n        </label>\n        <div className=\"flex items-center gap-2\">\n          <label className=\"flex w-16 items-center gap-2\">\n            <input\n              type=\"radio\"\n              name={`${id}-end`}\n              checked={endMode === \"until\"}\n              onChange={() => changeEndMode(\"until\")}\n              className=\"size-4 accent-primary\"\n            />\n            On\n          </label>\n          <Input\n            type=\"date\"\n            aria-label=\"End date\"\n            disabled={endMode !== \"until\"}\n            value={until ? toDateInputValue(until) : \"\"}\n            min={toDateInputValue(anchor)}\n            onChange={(event) => {\n              const [year, month, day] = event.target.value\n                .split(\"-\")\n                .map(Number)\n              if (year && month && day) {\n                update({ until: endOfDay(year, month - 1, day) })\n              }\n            }}\n            className=\"w-auto\"\n          />\n        </div>\n        <div className=\"flex items-center gap-2\">\n          <label className=\"flex w-16 items-center gap-2\">\n            <input\n              type=\"radio\"\n              name={`${id}-end`}\n              checked={endMode === \"count\"}\n              onChange={() => changeEndMode(\"count\")}\n              className=\"size-4 accent-primary\"\n            />\n            After\n          </label>\n          <NumberInput\n            aria-label=\"Occurrences\"\n            min={1}\n            disabled={endMode !== \"count\"}\n            value={count ?? DEFAULT_COUNT}\n            onValueChange={(next) => {\n              if (next !== null) update({ count: Math.round(next) })\n            }}\n            className=\"w-28\"\n          />\n          <span className=\"text-muted-foreground\">\n            {count === 1 ? \"occurrence\" : \"occurrences\"}\n          </span>\n        </div>\n      </fieldset>\n\n      <p\n        data-slot=\"recurrence-editor-summary\"\n        aria-live=\"polite\"\n        className=\"font-medium\"\n      >\n        {describeRecurrence(recurrence, anchor)}\n      </p>\n\n      {upcoming.length > 0 && (\n        <div className=\"grid gap-1.5\">\n          <span id={`${id}-preview`} className=\"text-muted-foreground\">\n            Next occurrences\n          </span>\n          <ol\n            aria-labelledby={`${id}-preview`}\n            data-slot=\"recurrence-editor-preview\"\n            className=\"grid gap-1 tabular-nums\"\n          >\n            {upcoming.map((date) => (\n              <li key={date.getTime()} className=\"min-h-5\">\n                {isClient && dateFormatter.format(date)}\n              </li>\n            ))}\n          </ol>\n        </div>\n      )}\n    </fieldset>\n  )\n}\n\nexport { RecurrenceEditor }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/default/lib/recurrence.ts",
      "content": "export type Frequency = \"DAILY\" | \"WEEKLY\" | \"MONTHLY\" | \"YEARLY\"\n\nexport type Weekday = \"MO\" | \"TU\" | \"WE\" | \"TH\" | \"FR\" | \"SA\" | \"SU\"\n\nexport type WeekdayRule = { weekday: Weekday; ordinal?: number }\n\nexport type Recurrence = {\n  frequency: Frequency\n  interval: number\n  byDay: WeekdayRule[]\n  byMonthDay: number[]\n  count?: number\n  until?: Date\n}\n\nexport const FREQUENCIES: Frequency[] = [\"DAILY\", \"WEEKLY\", \"MONTHLY\", \"YEARLY\"]\n\nexport const WEEKDAYS: Weekday[] = [\"MO\", \"TU\", \"WE\", \"TH\", \"FR\", \"SA\", \"SU\"]\n\nexport const WEEKDAY_NAMES: Record<Weekday, string> = {\n  MO: \"Monday\",\n  TU: \"Tuesday\",\n  WE: \"Wednesday\",\n  TH: \"Thursday\",\n  FR: \"Friday\",\n  SA: \"Saturday\",\n  SU: \"Sunday\",\n}\n\nconst UNITS: Record<Frequency, string> = {\n  DAILY: \"day\",\n  WEEKLY: \"week\",\n  MONTHLY: \"month\",\n  YEARLY: \"year\",\n}\n\nconst ADVERBS: Record<Frequency, string> = {\n  DAILY: \"Daily\",\n  WEEKLY: \"Weekly\",\n  MONTHLY: \"Monthly\",\n  YEARLY: \"Yearly\",\n}\n\nconst ORDINALS: Record<number, string> = {\n  1: \"first\",\n  2: \"second\",\n  3: \"third\",\n  4: \"fourth\",\n  5: \"fifth\",\n  [-1]: \"last\",\n  [-2]: \"second to last\",\n}\n\nconst BY_DAY = /^([+-]?\\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$/\nconst UNTIL = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})(Z)?)?$/\nconst DAY_MS = 86_400_000\nconst SEARCH_LIMIT_DAYS = 366 * 100\nconst SUMMARY_DATE = new Intl.DateTimeFormat(\"en-US\", { dateStyle: \"medium\" })\n\nfunction positiveInteger(text: string | undefined) {\n  const parsed = Number(text)\n  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined\n}\n\nfunction parseByDay(text = \"\"): WeekdayRule[] {\n  return text.split(\",\").flatMap((part) => {\n    const match = BY_DAY.exec(part)\n    if (!match) return []\n    const [, ordinal, weekday] = match\n    return [\n      ordinal\n        ? { weekday: weekday as Weekday, ordinal: Number(ordinal) }\n        : { weekday: weekday as Weekday },\n    ]\n  })\n}\n\nfunction parseUntil(text = \"\") {\n  const match = UNTIL.exec(text)\n  if (!match) return undefined\n  const [\n    ,\n    year,\n    month,\n    day,\n    hours = \"23\",\n    minutes = \"59\",\n    seconds = \"59\",\n    utc,\n  ] = match\n  const fields = [\n    Number(year),\n    Number(month) - 1,\n    Number(day),\n    Number(hours),\n    Number(minutes),\n    Number(seconds),\n  ] as const\n  return utc ? new Date(Date.UTC(...fields)) : new Date(...fields)\n}\n\nexport function parseRRule(rule: string): Recurrence {\n  const parts = new Map(\n    rule\n      .trim()\n      .replace(/^RRULE:/i, \"\")\n      .split(\";\")\n      .map((part) => {\n        const [key, value = \"\"] = part.split(\"=\")\n        return [key.trim().toUpperCase(), value.trim().toUpperCase()] as const\n      })\n  )\n  const frequency = parts.get(\"FREQ\") as Frequency\n  return {\n    frequency: FREQUENCIES.includes(frequency) ? frequency : \"WEEKLY\",\n    interval: positiveInteger(parts.get(\"INTERVAL\")) ?? 1,\n    byDay: parseByDay(parts.get(\"BYDAY\")),\n    byMonthDay: (parts.get(\"BYMONTHDAY\") ?? \"\")\n      .split(\",\")\n      .map(Number)\n      .filter(\n        (day) => Number.isInteger(day) && day !== 0 && Math.abs(day) <= 31\n      ),\n    count: positiveInteger(parts.get(\"COUNT\")),\n    until: parseUntil(parts.get(\"UNTIL\")),\n  }\n}\n\nexport function formatRRule(recurrence: Recurrence) {\n  const { frequency, interval, byDay, byMonthDay, count, until } = recurrence\n  const parts = [`FREQ=${frequency}`]\n  if (interval > 1) parts.push(`INTERVAL=${interval}`)\n  if (byDay.length > 0) {\n    parts.push(\n      `BYDAY=${byDay.map((rule) => `${rule.ordinal ?? \"\"}${rule.weekday}`).join(\",\")}`\n    )\n  }\n  if (byMonthDay.length > 0) parts.push(`BYMONTHDAY=${byMonthDay.join(\",\")}`)\n  if (count) parts.push(`COUNT=${count}`)\n  else if (until) {\n    parts.push(\n      `UNTIL=${until\n        .toISOString()\n        .replace(/[-:]/g, \"\")\n        .replace(/\\.\\d{3}/, \"\")}`\n    )\n  }\n  return parts.join(\";\")\n}\n\nexport function weekdayOf(date: Date): Weekday {\n  return WEEKDAYS[(date.getDay() + 6) % 7]\n}\n\nexport function ordinalWeekOf(date: Date) {\n  return Math.ceil(date.getDate() / 7)\n}\n\nexport function isLastWeekOf(date: Date) {\n  return date.getDate() + 7 > daysInMonth(date)\n}\n\nfunction daysInMonth(date: Date) {\n  return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()\n}\n\nfunction dayNumber(date: Date) {\n  return Math.round(\n    Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / DAY_MS\n  )\n}\n\nfunction weekdaysOf(recurrence: Recurrence, start: Date) {\n  return recurrence.byDay.length > 0\n    ? recurrence.byDay.map((rule) => rule.weekday)\n    : [weekdayOf(start)]\n}\n\nfunction matchesDayOfMonth(recurrence: Recurrence, start: Date, date: Date) {\n  const lastDay = daysInMonth(date)\n  if (recurrence.byDay.length > 0) {\n    const fromEnd = -Math.ceil((lastDay - date.getDate() + 1) / 7)\n    return recurrence.byDay.some(\n      (rule) =>\n        rule.weekday === weekdayOf(date) &&\n        (rule.ordinal === undefined ||\n          rule.ordinal === ordinalWeekOf(date) ||\n          rule.ordinal === fromEnd)\n    )\n  }\n  const monthDays =\n    recurrence.byMonthDay.length > 0 ? recurrence.byMonthDay : [start.getDate()]\n  return monthDays.some(\n    (day) => date.getDate() === (day > 0 ? day : lastDay + day + 1)\n  )\n}\n\nfunction matches(recurrence: Recurrence, start: Date, date: Date) {\n  const { frequency, interval } = recurrence\n  const days = dayNumber(date) - dayNumber(start)\n  const months =\n    (date.getFullYear() - start.getFullYear()) * 12 +\n    date.getMonth() -\n    start.getMonth()\n\n  switch (frequency) {\n    case \"DAILY\":\n      return days % interval === 0\n    case \"WEEKLY\": {\n      const weeks = Math.floor((days + ((start.getDay() + 6) % 7)) / 7)\n      return (\n        weeks % interval === 0 &&\n        weekdaysOf(recurrence, start).includes(weekdayOf(date))\n      )\n    }\n    case \"MONTHLY\":\n      return (\n        months % interval === 0 && matchesDayOfMonth(recurrence, start, date)\n      )\n    case \"YEARLY\":\n      return (\n        months % (12 * interval) === 0 && date.getDate() === start.getDate()\n      )\n  }\n}\n\nexport function occurrences(\n  recurrence: Recurrence,\n  start: Date,\n  limit: number\n) {\n  const found: Date[] = []\n  const total = Math.min(limit, recurrence.count ?? Infinity)\n  const date = new Date(start)\n  for (let day = 0; day < SEARCH_LIMIT_DAYS && found.length < total; day++) {\n    if (recurrence.until && date > recurrence.until) break\n    if (matches(recurrence, start, date)) found.push(new Date(date))\n    date.setDate(date.getDate() + 1)\n  }\n  return found\n}\n\nfunction joinWords(words: string[]) {\n  if (words.length < 2) return words.join(\"\")\n  return `${words.slice(0, -1).join(\", \")} and ${words.at(-1)}`\n}\n\nfunction ordinalWord(ordinal: number) {\n  return ORDINALS[ordinal] ?? String(ordinal)\n}\n\nexport function describeDays(recurrence: Recurrence, start: Date) {\n  switch (recurrence.frequency) {\n    case \"WEEKLY\": {\n      const weekdays = weekdaysOf(recurrence, start)\n      return `on ${joinWords(\n        WEEKDAYS.filter((weekday) => weekdays.includes(weekday)).map(\n          (weekday) => WEEKDAY_NAMES[weekday]\n        )\n      )}`\n    }\n    case \"MONTHLY\":\n      if (recurrence.byDay.length > 0) {\n        return `on ${joinWords(\n          recurrence.byDay.map((rule) =>\n            rule.ordinal === undefined\n              ? `every ${WEEKDAY_NAMES[rule.weekday]}`\n              : `the ${ordinalWord(rule.ordinal)} ${WEEKDAY_NAMES[rule.weekday]}`\n          )\n        )}`\n      }\n      return `on ${joinWords(\n        (recurrence.byMonthDay.length > 0\n          ? recurrence.byMonthDay\n          : [start.getDate()]\n        ).map((day) => (day > 0 ? `day ${day}` : `the ${ordinalWord(day)} day`))\n      )}`\n    case \"YEARLY\":\n      return `on ${start.toLocaleDateString(\"en-US\", { month: \"long\", day: \"numeric\" })}`\n    default:\n      return \"\"\n  }\n}\n\nexport function describeRecurrence(recurrence: Recurrence, start: Date) {\n  const { frequency, interval, byDay, count, until } = recurrence\n  const isWorkweek =\n    frequency === \"WEEKLY\" &&\n    interval === 1 &&\n    byDay.length === 5 &&\n    WEEKDAYS.slice(0, 5).every((weekday) =>\n      byDay.some((rule) => rule.weekday === weekday)\n    )\n  const base = isWorkweek\n    ? \"Every weekday\"\n    : interval === 1\n      ? ADVERBS[frequency]\n      : `Every ${interval} ${UNITS[frequency]}s`\n  const days = isWorkweek ? \"\" : describeDays(recurrence, start)\n  const end = count\n    ? count === 1\n      ? \", once\"\n      : `, ${count} times`\n    : until\n      ? ` until ${SUMMARY_DATE.format(until)}`\n      : \"\"\n  return `${[base, days].filter(Boolean).join(\" \")}${end}`\n}\n",
      "type": "registry:lib"
    }
  ],
  "type": "registry:ui"
}