{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt-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/gantt-types"],"files":[{"path":"gantt-recurrence.tsx","type":"registry:ui","content":"// Title: Gantt 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 GanttDateRange,\n GanttEvent,\n GanttOccurrence,\n GanttRecurrenceRule,\n GanttWeekday,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { TZDate } from \"@date-fns/tz\"\nimport { addDays, addMonths, addWeeks, addYears } from \"date-fns\"\n\n/** Guard: max occurrences per event per expansion. */\nconst MAX_OCCURRENCES = 1000\n\nconst WEEKDAYS: GanttWeekday[] = [\"SU\", \"MO\", \"TU\", \"WE\", \"TH\", \"FR\", \"SA\"]\n\nclass GanttRecurrenceError 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 = \"GanttRecurrenceError\"\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 a floating UNTIL\n * (no trailing Z) resolves there instead of in the runtime's local zone.\n */\nfunction parseRRuleString(\n input: string,\n timeZone?: string\n): GanttRecurrenceRule {\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 GanttRecurrenceError(`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 const match = /^(-?\\d+)?(SU|MO|TU|WE|TH|FR|SA)$/.exec(token.trim())\n if (!match) throw new GanttRecurrenceError(`BYDAY=${token}`)\n const day = match[2] as GanttWeekday\n return match[1] ? { day, ordinal: parseInt(match[1], 10) } : day\n })\n break\n case \"BYMONTHDAY\":\n rule.byMonthDay = value.split(\",\").map((v) => parseInt(v, 10))\n break\n case \"BYMONTH\":\n rule.byMonth = value.split(\",\").map((v) => parseInt(v, 10))\n break\n case \"WKST\": {\n if (!WEEKDAYS.includes(value as GanttWeekday)) {\n throw new GanttRecurrenceError(`WKST=${value}`)\n }\n rule.weekStart = value as GanttWeekday\n break\n }\n default:\n throw new GanttRecurrenceError(key ?? pair)\n }\n }\n\n if (!rule.freq) throw new GanttRecurrenceError(\"missing FREQ\")\n return rule as GanttRecurrenceRule\n}\n\nfunction parseRRuleDate(value: string, timeZone?: string): Date {\n // RFC 5545 basic formats: YYYYMMDD or YYYYMMDDTHHMMSS(Z)\n const match = /^(\\d{4})(\\d{2})(\\d{2})(?:T(\\d{2})(\\d{2})(\\d{2})(Z)?)?$/.exec(\n value\n )\n if (!match) throw new GanttRecurrenceError(`UNTIL=${value}`)\n const [, y, m, d, hh = \"23\", mm = \"59\", ss = \"59\", z] = match\n // Floating (non-Z) boundaries resolve in the DISPLAY zone when known -\n // local-zone parsing would shift the series end per visitor machine.\n const date =\n !z && timeZone\n ? new TZDate(+y, +m - 1, +d, +hh, +mm, +ss, timeZone)\n : new Date(`${y}-${m}-${d}T${hh}:${mm}:${ss}${z ? \"Z\" : \"\"}`)\n if (Number.isNaN(date.getTime())) {\n throw new GanttRecurrenceError(`UNTIL=${value}`)\n }\n return new Date(date.getTime())\n}\n\n/** Serializes the structured subset back to an RRULE line (without prefix). */\nfunction formatRRuleString(rule: GanttRecurrenceRule): 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: GanttRecurrenceRule | string,\n timeZone?: string\n): GanttRecurrenceRule {\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).\n *\n * Supported subset: FREQ daily/weekly/monthly/yearly, INTERVAL, COUNT, UNTIL,\n * weekly BYDAY (no ordinals). Parsed-but-unimplemented filters (BYMONTHDAY,\n * BYMONTH, BYDAY outside weekly) throw a GanttRecurrenceError instead of\n * silently mis-expanding; plug the getOccurrences prop for a full engine.\n * WKST parses and round-trips; week emission is Sunday-anchored.\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: GanttEvent,\n range: GanttDateRange,\n ctx: { timeZone: string }\n): GanttOccurrence[] {\n const allDay = event.allDay ?? false\n\n if (!event.recurrence) {\n if (event.start < range.end && event.end > range.start) {\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 // Loud contract: silently ignoring a filter would emit WRONG occurrences.\n if (rule.byMonthDay?.length) throw new GanttRecurrenceError(\"BYMONTHDAY\")\n if (rule.byMonth?.length) throw new GanttRecurrenceError(\"BYMONTH\")\n if (rule.byWeekday?.length && rule.freq !== \"weekly\") {\n throw new GanttRecurrenceError(\"BYDAY outside FREQ=WEEKLY\")\n }\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 // 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 ? rule.byWeekday.map((d) => {\n if (typeof d !== \"string\") {\n throw new GanttRecurrenceError(\n \"BYDAY ordinal outside monthly/yearly\"\n )\n }\n return WEEKDAYS.indexOf(d)\n })\n : null\n\n const occurrences: GanttOccurrence[] = []\n let produced = 0\n let index = 0\n let cursor = zonedStart\n\n const advance = (from: TZDate, steps: number): TZDate =>\n rule.freq === \"daily\"\n ? addDays(from, steps * interval)\n : rule.freq === \"weekly\"\n ? addWeeks(from, steps * interval)\n : rule.freq === \"monthly\"\n ? addMonths(from, steps * interval)\n : addYears(from, steps * interval)\n\n // Fast-forward past periods entirely before the range: they produce\n // nothing and must not consume the occurrence cap (an old-enough daily\n // series would otherwise exhaust MAX_OCCURRENCES before reaching the\n // window and silently vanish). COUNT rules jump too: the skipped periods\n // are credited to `index`, which is what terminates the series, so the\n // count still ends it on exactly the right instant. Leaving them on full\n // iteration would hide any series whose count exceeds MAX_OCCURRENCES.\n //\n // Daily and weekly ONLY. Their step is a fixed wall-time length, so one jump\n // of N steps lands exactly where N single steps land. addMonths/addYears\n // CLAMP instead: a Jan 31 monthly anchor steps to Feb 28 and never returns to\n // the 31st, while a single jump from the anchor clamps at most once. Jumping\n // those would make the same occurrence render on a different day depending on\n // which window the viewer scrolled in from, so they always iterate.\n const canFastForward = rule.freq === \"daily\" || rule.freq === \"weekly\"\n // weekly BYDAY emits across the cursor's whole Sunday week\n const weekSlackMs = weeklyDays ? 6 * 86_400_000 : 0\n // divide by the LONGEST possible step so the jump can never overshoot\n const maxStepMs =\n (rule.freq === \"daily\"\n ? 24\n : rule.freq === \"weekly\"\n ? 7 * 24\n : rule.freq === \"monthly\"\n ? 31 * 24\n : 366 * 24) *\n 3_600_000 *\n interval +\n 3_600_000\n for (let pass = 0; canFastForward && pass < 2; pass++) {\n const gap =\n range.start.getTime() - durationMs - weekSlackMs - cursor.getTime()\n const skip = Math.floor(gap / maxStepMs)\n if (skip <= 0) break\n cursor = advance(cursor, skip)\n index += skip * (weeklyDays ? weeklyDays.length : 1)\n }\n // close the remainder step by step (bounded by the jump math)\n let guard = 0\n while (\n guard++ < 10_000 &&\n !(rule.until && cursor.getTime() > rule.until.getTime()) &&\n cursor.getTime() + durationMs + weekSlackMs < range.start.getTime()\n ) {\n cursor = advance(cursor, 1)\n index += weeklyDays ? weeklyDays.length : 1\n }\n // The jump credits a whole week of selected days per skipped week, but full\n // iteration never counts the selected days that fall BEFORE the anchor\n // inside the anchor's own week. Drop them once so both paths number the\n // same instant identically (index counts occurrences at or after the\n // anchor, and only those).\n if (weeklyDays && index > 0) {\n index -= weeklyDays.filter((day) => day < zonedStart.getDay()).length\n }\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 = new Date(start.getTime() + durationMs)\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 while (produced < MAX_OCCURRENCES) {\n if (rule.until && cursor.getTime() > rule.until.getTime()) break\n // COUNT is series-absolute, so it reads `index` (the position in the\n // series, fast-forward included) rather than `produced` (emissions in\n // this loop, which MAX_OCCURRENCES caps).\n if (rule.count !== undefined && index >= rule.count) break\n // Past the visible window with no count to honor - stop iterating. For\n // weekly BYDAY the WEEK START decides: selected days earlier in the\n // anchor's week can still fall before range.end.\n const horizonMs = weeklyDays\n ? addDays(cursor, -cursor.getDay()).getTime()\n : cursor.getTime()\n if (horizonMs >= range.end.getTime() && rule.count === undefined) {\n break\n }\n\n if (rule.freq === \"weekly\" && weeklyDays) {\n // Emit each selected weekday within the cursor's week\n for (let d = 0; d < 7; d++) {\n const candidate = addDays(cursor, d - cursor.getDay())\n if (!weeklyDays.includes(candidate.getDay())) continue\n if (candidate.getTime() < zonedStart.getTime()) continue\n if (rule.until && candidate.getTime() > rule.until.getTime()) continue\n // the cap is checked here too, or a week that crosses it mid-loop\n // still emits its remaining selected days\n if (produced >= MAX_OCCURRENCES) break\n if (rule.count !== undefined && index >= rule.count) break\n pushIfVisible(candidate)\n produced++\n index++\n }\n } else {\n pushIfVisible(cursor)\n produced++\n index++\n }\n\n cursor = advance(cursor, 1)\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 = new Date(start.getTime() + durationMs)\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 0 and collide with the series' first occurrence\n // in any consumer that identifies an instance by its position\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 GanttRecurrenceError,\n expandRecurrence,\n formatRRuleString,\n MAX_OCCURRENCES,\n parseRRuleString,\n}","target":"components/neui/gantt/gantt-recurrence.tsx"}]}