{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"event-calendar-recurrence","type":"registry:ui","title":"RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.","description":"RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.","dependencies":["@date-fns/tz","date-fns"],"registryDependencies":["@neui/event-calendar-types"],"files":[{"path":"event-calendar-recurrence.tsx","type":"registry:ui","content":"// Title: Event Calendar Recurrence\n// Description: RFC 5545 subset recurrence expansion for the event calendar - structured rules or raw RRULE strings, with a hard occurrence cap.\n\nimport type {\n CalendarEvent,\n EventCalendarDateRange,\n EventCalendarOccurrence,\n EventCalendarRecurrenceRule,\n EventCalendarWeekday,\n} from \"@/components/neui/event-calendar/event-calendar-types\"\nimport { TZDate } from \"@date-fns/tz\"\nimport { addDays, addMonths, addWeeks, addYears } from \"date-fns\"\n\n/** Guard: max window-intersecting occurrences per event per expansion. */\nconst MAX_OCCURRENCES = 1000\n\n/** Runaway guard: absolute cap on period iterations regardless of visibility. */\nconst MAX_ITERATIONS = 10000\n\n/** Gregorian mean period lengths for the O(1) fast-forward approximation. */\nconst PERIOD_MS: Record = {\n daily: 86400000,\n weekly: 604800000,\n monthly: 2629746000, // 365.2425 / 12 days\n yearly: 31556952000, // 365.2425 days\n}\n\nconst WEEKDAYS: EventCalendarWeekday[] = [\n \"SU\",\n \"MO\",\n \"TU\",\n \"WE\",\n \"TH\",\n \"FR\",\n \"SA\",\n]\n\nclass EventCalendarRecurrenceError extends Error {\n constructor(part: string) {\n super(\n `Unsupported recurrence part: ${part}. Use the getOccurrences prop to plug a full RRULE engine for exotic rules.`\n )\n this.name = \"EventCalendarRecurrenceError\"\n }\n}\n\n/**\n * Parses a raw RRULE line (with or without the \"RRULE:\" prefix) into the\n * structured subset. Pass the display time zone so Z-less UNTIL values are\n * interpreted as wall time in that zone rather than the machine zone.\n */\nfunction parseRRuleString(\n input: string,\n timeZone?: string\n): EventCalendarRecurrenceRule {\n const body = input.trim().replace(/^RRULE:/i, \"\")\n const rule: Partial = {}\n\n for (const pair of body.split(\";\")) {\n if (!pair) continue\n const [rawKey, rawValue] = pair.split(\"=\")\n const key = rawKey?.toUpperCase()\n const value = rawValue ?? \"\"\n\n switch (key) {\n case \"FREQ\": {\n const freq = value.toLowerCase()\n if (\n freq !== \"daily\" &&\n freq !== \"weekly\" &&\n freq !== \"monthly\" &&\n freq !== \"yearly\"\n ) {\n throw new EventCalendarRecurrenceError(`FREQ=${value}`)\n }\n rule.freq = freq\n break\n }\n case \"INTERVAL\":\n rule.interval = Math.max(1, parseInt(value, 10) || 1)\n break\n case \"COUNT\":\n rule.count = Math.max(1, parseInt(value, 10) || 1)\n break\n case \"UNTIL\":\n rule.until = parseRRuleDate(value, timeZone)\n break\n case \"BYDAY\":\n rule.byWeekday = value.split(\",\").map((token) => {\n // RFC 5545 3.1: enumerated values are case-insensitive, and FREQ is\n // already folded above - rejecting \"mo\" here would be inconsistent\n const match = /^(-?\\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(\n token.trim().toUpperCase()\n )\n if (!match) throw new EventCalendarRecurrenceError(`BYDAY=${token}`)\n const day = match[2] as EventCalendarWeekday\n return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day\n })\n break\n case \"BYMONTHDAY\":\n rule.byMonthDay = value.split(\",\").map((v) => {\n const day = parseInt(v, 10)\n // NaN would survive parsing, match no day in any month and leave the\n // event permanently invisible with no error anywhere\n if (Number.isNaN(day)) {\n throw new EventCalendarRecurrenceError(`BYMONTHDAY=${v}`)\n }\n return day\n })\n break\n case \"BYMONTH\":\n rule.byMonth = value.split(\",\").map((v) => parseInt(v, 10))\n break\n case \"WKST\": {\n const day = value.trim().toUpperCase() as EventCalendarWeekday\n if (!WEEKDAYS.includes(day)) {\n throw new EventCalendarRecurrenceError(`WKST=${value}`)\n }\n rule.weekStart = day\n break\n }\n default:\n throw new EventCalendarRecurrenceError(key ?? pair)\n }\n }\n\n if (!rule.freq) throw new EventCalendarRecurrenceError(\"missing FREQ\")\n return rule as EventCalendarRecurrenceRule\n}\n\nfunction parseRRuleDate(value: string, timeZone?: string): Date {\n // RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z). The T and Z\n // designators are case-insensitive too (RFC 5545 3.1), so fold before matching.\n const match = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})(Z)?)?$/.exec(\n value.trim().toUpperCase()\n )\n if (!match) throw new EventCalendarRecurrenceError(`UNTIL=${value}`)\n const [, y, m, d, hh = \"23\", mm = \"59\", ss = \"59\", z] = match\n // Z-less values (including date-only ones, which mean end of that day\n // inclusive) are wall time in the display zone, not the machine zone.\n const date = z\n ? new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}Z`)\n : timeZone\n ? new Date(new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone).getTime())\n : new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}`)\n if (Number.isNaN(date.getTime())) {\n throw new EventCalendarRecurrenceError(`UNTIL=${value}`)\n }\n return date\n}\n\n/** Serializes the structured subset back to an RRULE line (without prefix). */\nfunction formatRRuleString(rule: EventCalendarRecurrenceRule): string {\n const parts: string[] = [`FREQ=${rule.freq.toUpperCase()}`]\n if (rule.interval && rule.interval > 1)\n parts.push(`INTERVAL=${rule.interval}`)\n if (rule.count) parts.push(`COUNT=${rule.count}`)\n if (rule.until) {\n const u = rule.until\n const pad = (n: number) => String(n).padStart(2, \"0\")\n parts.push(\n `UNTIL=${u.getUTCFullYear()}${pad(u.getUTCMonth() + 1)}${pad(u.getUTCDate())}T${pad(u.getUTCHours())}${pad(u.getUTCMinutes())}${pad(u.getUTCSeconds())}Z`\n )\n }\n if (rule.byWeekday?.length) {\n parts.push(\n `BYDAY=${rule.byWeekday\n .map((d) => (typeof d === \"string\" ? d : `${d.ordinal}${d.day}`))\n .join(\",\")}`\n )\n }\n if (rule.byMonthDay?.length)\n parts.push(`BYMONTHDAY=${rule.byMonthDay.join(\",\")}`)\n if (rule.byMonth?.length) parts.push(`BYMONTH=${rule.byMonth.join(\",\")}`)\n if (rule.weekStart) parts.push(`WKST=${rule.weekStart}`)\n return parts.join(\";\")\n}\n\nfunction resolveRule(\n recurrence: EventCalendarRecurrenceRule | string,\n timeZone?: string\n): EventCalendarRecurrenceRule {\n return typeof recurrence === \"string\"\n ? parseRRuleString(recurrence, timeZone)\n : recurrence\n}\n\n/**\n * Expands one event into its occurrences intersecting the range.\n * Non-recurring events yield at most one occurrence. Recurrence iteration is\n * wall-time based in the display zone (DST-safe day/week/month steps), and a\n * span that is a whole number of local days keeps that day count across a DST\n * transition (timed spans keep their absolute length instead).\n *\n * exDates remove exactly-matching instants (after COUNT numbering,\n * Google-style: an exception still consumes its COUNT slot); rDates add extra\n * instants with the same duration. RECURRENCE-ID override replacement lives\n * in buildEventIndex, where the override event and its parent series meet.\n */\nfunction expandRecurrence(\n event: CalendarEvent,\n range: EventCalendarDateRange,\n ctx: { timeZone: string }\n): EventCalendarOccurrence[] {\n const allDay = event.allDay ?? false\n\n if (!event.recurrence) {\n // The exclusive `end > start` test is right for anything with duration,\n // but it also drops a zero-length milestone pinned to the first visible\n // instant - which reads as an event that randomly disappears until you\n // page one period back. A point occurrence only has to be inside.\n const isPoint = event.end.getTime() === event.start.getTime()\n if (\n event.start < range.end &&\n (event.end > range.start || (isPoint && event.start >= range.start))\n ) {\n return [\n {\n key: `${event.id}::${event.start.toISOString()}`,\n eventId: event.id,\n event,\n start: event.start,\n end: event.end,\n allDay,\n isRecurring: false,\n },\n ]\n }\n return []\n }\n\n const rule = resolveRule(event.recurrence, ctx.timeZone)\n const interval = Math.max(1, rule.interval ?? 1)\n const durationMs = event.end.getTime() - event.start.getTime()\n const zonedStart = new TZDate(event.start.getTime(), ctx.timeZone)\n // A span of whole local days is wall time, not an absolute delta: a 3 day\n // all-day bar crossing spring forward would otherwise end at 01:00 and\n // occupy a fourth day in the month grid. Timed spans stay absolute so a two\n // hour meeting is still two hours.\n const daySpan = Math.round(durationMs / 86400000)\n const wallDaySpan =\n daySpan > 0 &&\n addDays(zonedStart, daySpan).getTime() === event.end.getTime()\n ? daySpan\n : null\n const endFor = (start: Date): Date =>\n wallDaySpan === null\n ? new Date(start.getTime() + durationMs)\n : new Date(\n addDays(\n new TZDate(start.getTime(), ctx.timeZone),\n wallDaySpan\n ).getTime()\n )\n // Excluded instants matched exactly; filtering happens at push time so an\n // exception still consumes its COUNT slot (Google-style numbering).\n const exTimes = new Set((rule.exDates ?? []).map((d) => d.getTime()))\n\n const weeklyDays: number[] | null =\n rule.freq === \"weekly\" && rule.byWeekday?.length\n ? [\n ...new Set(\n rule.byWeekday.map((d) => {\n if (typeof d !== \"string\") {\n throw new EventCalendarRecurrenceError(\n \"BYDAY ordinal outside monthly/yearly\"\n )\n }\n return WEEKDAYS.indexOf(d)\n })\n ),\n ]\n : null\n\n // BYDAY resolved inside a month: monthly, and yearly within each BYMONTH\n // (FREQ=YEARLY;BYMONTH=11;BYDAY=4TH is Thanksgiving, not \"the anchor's day\")\n const monthlyByDay =\n (rule.freq === \"monthly\" || rule.freq === \"yearly\") &&\n rule.byWeekday?.length\n ? rule.byWeekday\n : null\n\n // RFC 5545 3.3.10 week numbering: with INTERVAL > 1 the week start decides\n // which selected days share a period, so an ignored WKST puts half of every\n // biweekly series a week off. Default MO, per the RFC.\n const weekStartIndex = WEEKDAYS.indexOf(rule.weekStart ?? \"MO\")\n /** Days from the WKST-aligned week start to `day` (0-6). */\n const fromWeekStart = (day: number) => (day - weekStartIndex + 7) % 7\n\n // yearly BYMONTH filter (1-12), ascending; defaults to the anchor's month\n const validByMonth = rule.byMonth?.filter((m) => m >= 1 && m <= 12) ?? []\n const yearlyMonths: number[] =\n validByMonth.length > 0\n ? [...new Set(validByMonth)].sort((a, b) => a - b)\n : [zonedStart.getMonth() + 1]\n\n // month-shaped BY* parts make the per-period occurrence count variable\n const hasMonthDayParts =\n (rule.freq === \"monthly\" &&\n Boolean(rule.byMonthDay?.length || monthlyByDay)) ||\n (rule.freq === \"yearly\" &&\n Boolean(rule.byMonth?.length || rule.byMonthDay?.length || monthlyByDay))\n\n const daysInMonth = (year: number, month: number) =>\n new Date(year, month + 1, 0).getDate()\n\n // RFC 5545 3.3.10 LIMIT filters: at these frequencies a BY* part narrows the\n // set instead of reshaping it. Dropping them silently expanded the series as\n // if the part were absent (FREQ=DAILY;BYDAY=MO filled every day). Filtering\n // here rather than at push time keeps COUNT numbering RFC-correct: a\n // candidate the filter removes is not an occurrence and consumes no slot.\n const limitByMonth =\n rule.freq !== \"yearly\" && validByMonth.length > 0 ? validByMonth : null\n const limitByMonthDay =\n rule.freq === \"daily\" && rule.byMonthDay?.length ? rule.byMonthDay : null\n const limitByWeekday =\n rule.freq === \"daily\" && rule.byWeekday?.length\n ? rule.byWeekday.map((d) =>\n WEEKDAYS.indexOf(typeof d === \"string\" ? d : d.day)\n )\n : null\n const hasLimits = Boolean(limitByMonth || limitByMonthDay || limitByWeekday)\n\n const passesLimits = (candidate: TZDate): boolean => {\n if (limitByMonth && !limitByMonth.includes(candidate.getMonth() + 1)) {\n return false\n }\n if (limitByWeekday && !limitByWeekday.includes(candidate.getDay())) {\n return false\n }\n if (limitByMonthDay) {\n const total = daysInMonth(candidate.getFullYear(), candidate.getMonth())\n const day = candidate.getDate()\n // negative BYMONTHDAY counts back from month end, as in monthDays\n if (!limitByMonthDay.some((n) => (n < 0 ? total + 1 + n : n) === day)) {\n return false\n }\n }\n return true\n }\n\n /** Wall-clock instant in the display zone carrying DTSTART's time-of-day. */\n const zonedDate = (year: number, month: number, day: number) =>\n new TZDate(\n year,\n month,\n day,\n zonedStart.getHours(),\n zonedStart.getMinutes(),\n zonedStart.getSeconds(),\n zonedStart.getMilliseconds(),\n ctx.timeZone\n )\n\n /** Selected days-of-month, ascending: BYMONTHDAY (and) BYDAY, else the clamped anchor day. */\n const monthDays = (year: number, month: number): number[] => {\n const total = daysInMonth(year, month)\n let days: number[] | null = null\n if (rule.byMonthDay?.length) {\n // negative BYMONTHDAY counts back from month end; nonexistent days skip\n days = rule.byMonthDay\n .map((n) => (n < 0 ? total + 1 + n : n))\n .filter((n) => n >= 1 && n <= total)\n }\n if (monthlyByDay) {\n const byDayMatches: number[] = []\n for (const entry of monthlyByDay) {\n const day = typeof entry === \"string\" ? entry : entry.day\n const ordinal = typeof entry === \"string\" ? 0 : entry.ordinal\n const weekday = WEEKDAYS.indexOf(day)\n const matches: number[] = []\n for (let d = 1; d <= total; d++) {\n if (new Date(year, month, d).getDay() === weekday) matches.push(d)\n }\n if (ordinal === 0) {\n byDayMatches.push(...matches) // plain BYDAY: every matching weekday\n } else {\n // 2TU = 2nd Tuesday, -1FR = last Friday; absent ordinals skip\n const pick =\n ordinal > 0\n ? matches[ordinal - 1]\n : matches[matches.length + ordinal]\n if (pick !== undefined) byDayMatches.push(pick)\n }\n }\n days = days ? days.filter((n) => byDayMatches.includes(n)) : byDayMatches\n }\n if (!days) days = [Math.min(zonedStart.getDate(), total)] // clamp to month end\n return [...new Set(days)].sort((a, b) => a - b)\n }\n\n /** Chronological candidates of one period, derived from the DTSTART anchor by index (no drift). */\n const periodCandidates = (period: number): TZDate[] => {\n if (rule.freq === \"daily\") return [addDays(zonedStart, period * interval)]\n if (rule.freq === \"weekly\") {\n const base = addWeeks(zonedStart, period * interval)\n if (!weeklyDays) return [base]\n const week: TZDate[] = []\n // walk the WKST-aligned week so candidates stay chronological\n const baseOffset = fromWeekStart(base.getDay())\n for (let offset = 0; offset < 7; offset++) {\n const candidate = addDays(base, offset - baseOffset)\n if (!weeklyDays.includes(candidate.getDay())) continue\n // days of the DTSTART week before DTSTART are not part of the series\n if (candidate.getTime() < zonedStart.getTime()) continue\n week.push(candidate)\n }\n return week\n }\n if (rule.freq === \"monthly\") {\n const anchor = addMonths(zonedStart, period * interval)\n const year = anchor.getFullYear()\n const month = anchor.getMonth()\n return monthDays(year, month)\n .map((day) => zonedDate(year, month, day))\n .filter((c) => c.getTime() >= zonedStart.getTime())\n }\n // yearly\n const year = addYears(zonedStart, period * interval).getFullYear()\n const dates: TZDate[] = []\n for (const month of yearlyMonths) {\n for (const day of monthDays(year, month - 1)) {\n dates.push(zonedDate(year, month - 1, day))\n }\n }\n return dates.filter((c) => c.getTime() >= zonedStart.getTime())\n }\n\n /** Candidates of one period with the LIMIT filters applied. */\n const candidatesFor = (period: number): TZDate[] =>\n hasLimits\n ? periodCandidates(period).filter(passesLimits)\n : periodCandidates(period)\n\n /** Earliest/latest instant a period can produce - loop bounds without expanding it. */\n const periodEdge = (period: number, edge: \"first\" | \"last\"): TZDate => {\n if (rule.freq === \"daily\") return addDays(zonedStart, period * interval)\n if (rule.freq === \"weekly\") {\n const base = addWeeks(zonedStart, period * interval)\n if (!weeklyDays) return base\n return addDays(\n base,\n (edge === \"first\" ? 0 : 6) - fromWeekStart(base.getDay())\n )\n }\n if (rule.freq === \"monthly\") {\n const anchor = addMonths(zonedStart, period * interval)\n const year = anchor.getFullYear()\n const month = anchor.getMonth()\n return zonedDate(\n year,\n month,\n edge === \"first\" ? 1 : daysInMonth(year, month)\n )\n }\n const year = addYears(zonedStart, period * interval).getFullYear()\n const month =\n (edge === \"first\"\n ? yearlyMonths[0]\n : yearlyMonths[yearlyMonths.length - 1]) - 1\n return zonedDate(\n year,\n month,\n edge === \"first\" ? 1 : daysInMonth(year, month)\n )\n }\n\n // O(1) fast-forward: land a couple of periods before the window instead of\n // iterating from DTSTART, so years-old series still reach the visible range.\n const aheadMs = range.start.getTime() - durationMs - zonedStart.getTime()\n const stepMs = PERIOD_MS[rule.freq] * interval\n let startPeriod =\n aheadMs > 0 ? Math.max(0, Math.floor(aheadMs / stepMs) - 2) : 0\n // mean-length drift is bounded well under one period - refine forward\n while (\n periodEdge(startPeriod, \"last\").getTime() + durationMs <=\n range.start.getTime()\n ) {\n startPeriod++\n }\n\n // series ordinal at startPeriod, so COUNT and recurrenceIndex stay exact\n let index = 0\n if (startPeriod > 0) {\n if (hasMonthDayParts || hasLimits) {\n // per-period counts vary (skipped days, 4-vs-5 weekday months, a LIMIT\n // filter that empties a whole period) - sum them\n for (let period = 0; period < startPeriod; period++) {\n index += candidatesFor(period).length\n }\n } else if (weeklyDays) {\n // week 0 only counts selected weekdays at/after DTSTART's, inside the\n // WKST-aligned week that holds it\n const anchorOffset = fromWeekStart(zonedStart.getDay())\n const firstWeek = weeklyDays.filter(\n (d) => fromWeekStart(d) >= anchorOffset\n ).length\n index = firstWeek + (startPeriod - 1) * weeklyDays.length\n } else {\n index = startPeriod // one occurrence per period\n }\n }\n\n const occurrences: EventCalendarOccurrence[] = []\n\n const pushIfVisible = (rawStart: Date) => {\n // normalize to a plain instant so consumers never receive zone-carrying\n // TZDate instances (mixed-zone formatting bugs)\n const start = new Date(rawStart.getTime())\n if (exTimes.has(start.getTime())) return\n const end = endFor(start)\n if (start < range.end && end > range.start) {\n occurrences.push({\n key: `${event.id}::${start.toISOString()}`,\n eventId: event.id,\n event,\n start,\n end,\n allDay,\n isRecurring: true,\n recurrenceIndex: index,\n })\n }\n }\n\n let iterations = 0\n let period = startPeriod\n while (iterations < MAX_ITERATIONS && occurrences.length < MAX_OCCURRENCES) {\n iterations++\n if (rule.count !== undefined && index >= rule.count) break\n // whole-period bounds: never break on a mid-period weekday/day-of-month,\n // so earlier candidates of the final period are still emitted\n const earliest = periodEdge(period, \"first\")\n if (earliest.getTime() >= range.end.getTime()) break\n if (rule.until && earliest.getTime() > rule.until.getTime()) break\n\n let ended = false\n for (const candidate of candidatesFor(period)) {\n if (rule.count !== undefined && index >= rule.count) {\n ended = true\n break\n }\n if (rule.until && candidate.getTime() > rule.until.getTime()) {\n ended = true\n break\n }\n pushIfVisible(candidate)\n index++\n if (occurrences.length >= MAX_OCCURRENCES) {\n ended = true\n break\n }\n }\n if (ended) break\n period++\n }\n\n // RDATE: extra instants join the set (deduped against generated starts and\n // exclusions) with the same wall-time duration. Sorted so direct consumers\n // still receive chronological order (buildEventIndex re-sorts regardless).\n if (rule.rDates?.length) {\n const seen = new Set(occurrences.map((o) => o.start.getTime()))\n for (const rDate of rule.rDates) {\n const start = new Date(rDate.getTime())\n if (seen.has(start.getTime()) || exTimes.has(start.getTime())) continue\n const end = endFor(start)\n if (start >= range.end || end <= range.start) continue\n seen.add(start.getTime())\n occurrences.push({\n key: `${event.id}::${start.toISOString()}`,\n eventId: event.id,\n event,\n start,\n end,\n allDay,\n isRecurring: true,\n // keep counting past the generated instants: an RDATE with no index\n // would fall back to undefined and break any consumer that identifies\n // an instance by its position in the series\n recurrenceIndex: index++,\n })\n }\n occurrences.sort((a, b) => a.start.getTime() - b.start.getTime())\n }\n\n return occurrences\n}\n\nexport {\n EventCalendarRecurrenceError,\n expandRecurrence,\n formatRRuleString,\n MAX_OCCURRENCES,\n parseRRuleString,\n}","target":"components/neui/event-calendar/event-calendar-recurrence.tsx"}]}