@super-calendar/core 2.1.4 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -436,6 +436,369 @@ function monthVisibleCount(total, capacity) {
436
436
  return Math.max(1, capacity.withMore);
437
437
  }
438
438
  //#endregion
439
+ //#region src/utils/timezone.ts
440
+ /**
441
+ * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
442
+ * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
443
+ * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
444
+ * passing zoned dates makes it render in `timeZone` regardless of the device.
445
+ *
446
+ * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
447
+ * web). The result is for display/layout only; it no longer points at the
448
+ * original UTC instant, so don't round-trip it back to a real time.
449
+ */
450
+ function toZonedTime(date, timeZone) {
451
+ const parts = new Intl.DateTimeFormat("en-US", {
452
+ timeZone,
453
+ hourCycle: "h23",
454
+ year: "numeric",
455
+ month: "2-digit",
456
+ day: "2-digit",
457
+ hour: "2-digit",
458
+ minute: "2-digit",
459
+ second: "2-digit"
460
+ }).formatToParts(date);
461
+ const field = (type) => {
462
+ const part = parts.find((p) => p.type === type);
463
+ return part ? Number(part.value) : 0;
464
+ };
465
+ const hour = field("hour") % 24;
466
+ return new Date(field("year"), field("month") - 1, field("day"), hour, field("minute"), field("second"), date.getMilliseconds());
467
+ }
468
+ function zoneOffsetMs(instant, timeZone) {
469
+ const z = toZonedTime(instant, timeZone);
470
+ return Date.UTC(z.getFullYear(), z.getMonth(), z.getDate(), z.getHours(), z.getMinutes(), z.getSeconds(), z.getMilliseconds()) - instant.getTime();
471
+ }
472
+ /**
473
+ * The inverse of {@link toZonedTime}: given a wall-clock time in `timeZone`,
474
+ * return the absolute UTC instant. Pass the wall clock as a `Date` whose **UTC**
475
+ * fields hold the components (e.g. `new Date(Date.UTC(y, m, d, h, min))`). Used to
476
+ * resolve iCal `TZID` times; DST-correct via a two-pass offset (ambiguous
477
+ * fall-back times resolve to the post-transition offset).
478
+ */
479
+ function zonedTimeToUtc(wallClock, timeZone) {
480
+ const guess = wallClock.getTime();
481
+ const firstPass = zoneOffsetMs(new Date(guess), timeZone);
482
+ const secondPass = zoneOffsetMs(new Date(guess - firstPass), timeZone);
483
+ return new Date(guess - secondPass);
484
+ }
485
+ /**
486
+ * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
487
+ * displays them in `timeZone`. Other fields are preserved. Memoize the result
488
+ * (e.g. with `useMemo`) since it allocates new dates.
489
+ */
490
+ function eventsInTimeZone(events, timeZone) {
491
+ return events.map((event) => ({
492
+ ...event,
493
+ start: toZonedTime(event.start, timeZone),
494
+ end: toZonedTime(event.end, timeZone)
495
+ }));
496
+ }
497
+ //#endregion
498
+ //#region src/utils/ical.ts
499
+ const RRULE_FREQ = {
500
+ DAILY: "daily",
501
+ WEEKLY: "weekly",
502
+ MONTHLY: "monthly",
503
+ YEARLY: "yearly"
504
+ };
505
+ const FREQ_RRULE = {
506
+ daily: "DAILY",
507
+ weekly: "WEEKLY",
508
+ monthly: "MONTHLY",
509
+ yearly: "YEARLY"
510
+ };
511
+ const BYDAY = [
512
+ "SU",
513
+ "MO",
514
+ "TU",
515
+ "WE",
516
+ "TH",
517
+ "FR",
518
+ "SA"
519
+ ];
520
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
521
+ function escapeText(value) {
522
+ return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
523
+ }
524
+ function unescapeText(value) {
525
+ let out = "";
526
+ for (let i = 0; i < value.length; i++) {
527
+ const ch = value[i];
528
+ if (ch === "\\" && i + 1 < value.length) {
529
+ const next = value[++i];
530
+ out += next === "n" || next === "N" ? "\n" : next;
531
+ } else out += ch;
532
+ }
533
+ return out;
534
+ }
535
+ function foldLine(line) {
536
+ if (line.length <= 75) return line;
537
+ const parts = [line.slice(0, 75)];
538
+ let rest = line.slice(75);
539
+ while (rest.length > 74) {
540
+ parts.push(` ${rest.slice(0, 74)}`);
541
+ rest = rest.slice(74);
542
+ }
543
+ parts.push(` ${rest}`);
544
+ return parts.join("\r\n");
545
+ }
546
+ function unfoldLines(text) {
547
+ const raw = text.split(/\r\n|\r|\n/);
548
+ const out = [];
549
+ for (const line of raw) if ((line.startsWith(" ") || line.startsWith(" ")) && out.length) out[out.length - 1] += line.slice(1);
550
+ else out.push(line);
551
+ return out;
552
+ }
553
+ /** Serialize a Date as a UTC iCal date-time (`YYYYMMDDTHHMMSSZ`). */
554
+ function formatUtc(d) {
555
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
556
+ }
557
+ /** Serialize a Date as an iCal date-only value (`YYYYMMDD`) from its local parts. */
558
+ function formatDateOnly(d) {
559
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
560
+ }
561
+ /**
562
+ * Parse an iCal DATE / DATE-TIME value. `dateOnly` marks all-day (`VALUE=DATE`);
563
+ * `tzid` is the IANA zone from a `TZID=` param, used to resolve the local time to
564
+ * the correct UTC instant.
565
+ */
566
+ function parseIcalDate(value, dateOnly, tzid) {
567
+ const y = Number(value.slice(0, 4));
568
+ const mo = Number(value.slice(4, 6)) - 1;
569
+ const d = Number(value.slice(6, 8));
570
+ if (dateOnly || !value.includes("T")) return new Date(y, mo, d);
571
+ const h = Number(value.slice(9, 11));
572
+ const mi = Number(value.slice(11, 13));
573
+ const s = Number(value.slice(13, 15));
574
+ if (value.endsWith("Z")) return new Date(Date.UTC(y, mo, d, h, mi, s));
575
+ if (tzid) try {
576
+ return zonedTimeToUtc(new Date(Date.UTC(y, mo, d, h, mi, s)), tzid);
577
+ } catch {}
578
+ return new Date(y, mo, d, h, mi, s);
579
+ }
580
+ /** Parse an iCal/ISO-8601 DURATION (e.g. `PT1H30M`, `P1D`, `P1W`) to milliseconds. */
581
+ function parseDuration(value) {
582
+ const m = /^([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim());
583
+ if (!m || !m[2] && !m[3] && !m[4] && !m[5] && !m[6]) return null;
584
+ const sign = m[1] === "-" ? -1 : 1;
585
+ const [w, d, h, mi, s] = [
586
+ m[2],
587
+ m[3],
588
+ m[4],
589
+ m[5],
590
+ m[6]
591
+ ].map((x) => Number(x ?? 0));
592
+ return sign * ((((w * 7 + d) * 24 + h) * 60 + mi) * 60 + s) * 1e3;
593
+ }
594
+ function parseRRule(value) {
595
+ const parts = /* @__PURE__ */ new Map();
596
+ for (const pair of value.split(";")) {
597
+ const [k, v] = pair.split("=");
598
+ if (k && v) parts.set(k.toUpperCase(), v);
599
+ }
600
+ const freq = parts.get("FREQ");
601
+ if (!freq || !RRULE_FREQ[freq]) return void 0;
602
+ const rule = { freq: RRULE_FREQ[freq] };
603
+ const interval = parts.get("INTERVAL");
604
+ if (interval) rule.interval = Number(interval);
605
+ const count = parts.get("COUNT");
606
+ if (count) rule.count = Number(count);
607
+ const until = parts.get("UNTIL");
608
+ if (until) rule.until = parseIcalDate(until, !until.includes("T"));
609
+ const byday = parts.get("BYDAY");
610
+ if (byday) {
611
+ const tokens = byday.split(",").map((t) => t.trim().toUpperCase());
612
+ const monthly = rule.freq === "monthly" || rule.freq === "yearly";
613
+ const ordinal = /^([+-]?\d+)(SU|MO|TU|WE|TH|FR|SA)$/;
614
+ const plain = [];
615
+ for (const token of tokens) {
616
+ const m = monthly ? ordinal.exec(token) : null;
617
+ if (m) {
618
+ const weekday = BYDAY.indexOf(m[2]);
619
+ if (weekday >= 0 && !rule.nthWeekday) rule.nthWeekday = {
620
+ week: Number(m[1]),
621
+ weekday
622
+ };
623
+ continue;
624
+ }
625
+ const index = BYDAY.indexOf(token);
626
+ if (index >= 0) plain.push(index);
627
+ }
628
+ if (plain.length) rule.weekdays = plain;
629
+ }
630
+ const bymonthday = parts.get("BYMONTHDAY");
631
+ if (bymonthday) {
632
+ const days = bymonthday.split(",").map((d) => Number(d.trim())).filter((d) => Number.isInteger(d) && d !== 0);
633
+ if (days.length) rule.monthDays = days;
634
+ }
635
+ const bymonth = parts.get("BYMONTH");
636
+ if (bymonth) {
637
+ const months = bymonth.split(",").map((m) => Number(m.trim())).filter((m) => Number.isInteger(m) && m >= 1 && m <= 12);
638
+ if (months.length) rule.months = months;
639
+ }
640
+ return rule;
641
+ }
642
+ function formatRRule(rule) {
643
+ const parts = [`FREQ=${FREQ_RRULE[rule.freq]}`];
644
+ if (rule.interval && rule.interval !== 1) parts.push(`INTERVAL=${rule.interval}`);
645
+ if (rule.count != null) parts.push(`COUNT=${rule.count}`);
646
+ if (rule.until) parts.push(`UNTIL=${formatUtc(rule.until)}`);
647
+ if (rule.nthWeekday) parts.push(`BYDAY=${rule.nthWeekday.week}${BYDAY[rule.nthWeekday.weekday]}`);
648
+ else if (rule.weekdays?.length) parts.push(`BYDAY=${rule.weekdays.map((d) => BYDAY[d]).join(",")}`);
649
+ if (rule.monthDays?.length) parts.push(`BYMONTHDAY=${rule.monthDays.join(",")}`);
650
+ if (rule.months?.length) parts.push(`BYMONTH=${rule.months.join(",")}`);
651
+ return parts.join(";");
652
+ }
653
+ function parseLine(line) {
654
+ const colon = line.indexOf(":");
655
+ const head = colon === -1 ? line : line.slice(0, colon);
656
+ const value = colon === -1 ? "" : line.slice(colon + 1);
657
+ const [name, ...paramParts] = head.split(";");
658
+ const params = /* @__PURE__ */ new Map();
659
+ for (const p of paramParts) {
660
+ const eq = p.indexOf("=");
661
+ if (eq !== -1) params.set(p.slice(0, eq).toUpperCase(), p.slice(eq + 1));
662
+ }
663
+ return {
664
+ name: name.toUpperCase(),
665
+ params,
666
+ value
667
+ };
668
+ }
669
+ /**
670
+ * Parse an iCalendar (`.ics`) string into events. Reads every `VEVENT`; ignores
671
+ * VTODO/VJOURNAL/VTIMEZONE and unknown properties. Events without a usable
672
+ * `DTSTART` are skipped. All-day events (`VALUE=DATE`) with no `DTEND` get a
673
+ * one-day span.
674
+ *
675
+ * @example
676
+ * ```ts
677
+ * const events = parseICalendar(await file.text());
678
+ * ```
679
+ */
680
+ function parseICalendar(ics) {
681
+ const lines = unfoldLines(ics);
682
+ const events = [];
683
+ let current = null;
684
+ let allDay = false;
685
+ let durationMs = null;
686
+ let exdates = [];
687
+ let rdates = [];
688
+ for (const raw of lines) {
689
+ const line = parseLine(raw);
690
+ if (line.name === "BEGIN" && line.value === "VEVENT") {
691
+ current = {};
692
+ allDay = false;
693
+ durationMs = null;
694
+ exdates = [];
695
+ rdates = [];
696
+ continue;
697
+ }
698
+ if (line.name === "END" && line.value === "VEVENT") {
699
+ if (current?.start) {
700
+ if (!current.end && durationMs != null) current.end = new Date(current.start.getTime() + durationMs);
701
+ if (current.recurrence && exdates.length) current.recurrence.exdates = exdates;
702
+ if (current.recurrence && rdates.length) current.recurrence.rdates = rdates;
703
+ if (allDay) {
704
+ current.allDay = true;
705
+ if (!current.end) current.end = new Date(current.start.getTime() + 864e5);
706
+ } else if (!current.end) current.end = current.start;
707
+ events.push(current);
708
+ }
709
+ current = null;
710
+ continue;
711
+ }
712
+ if (!current) continue;
713
+ const dateOnly = line.params.get("VALUE") === "DATE";
714
+ const tzid = line.params.get("TZID");
715
+ switch (line.name) {
716
+ case "SUMMARY":
717
+ current.title = unescapeText(line.value);
718
+ break;
719
+ case "DESCRIPTION":
720
+ current.description = unescapeText(line.value);
721
+ break;
722
+ case "LOCATION":
723
+ current.location = unescapeText(line.value);
724
+ break;
725
+ case "UID":
726
+ current.uid = line.value;
727
+ break;
728
+ case "DTSTART":
729
+ current.start = parseIcalDate(line.value, dateOnly, tzid);
730
+ if (dateOnly) allDay = true;
731
+ break;
732
+ case "DTEND":
733
+ current.end = parseIcalDate(line.value, dateOnly, tzid);
734
+ break;
735
+ case "DURATION":
736
+ durationMs = parseDuration(line.value);
737
+ break;
738
+ case "EXDATE":
739
+ for (const v of line.value.split(",")) if (v) exdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
740
+ break;
741
+ case "RDATE":
742
+ for (const v of line.value.split(",")) if (v) rdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
743
+ break;
744
+ case "RRULE": {
745
+ const rule = parseRRule(line.value);
746
+ if (rule) current.recurrence = rule;
747
+ break;
748
+ }
749
+ }
750
+ }
751
+ return events;
752
+ }
753
+ function line(name, value) {
754
+ return foldLine(`${name}:${value}`);
755
+ }
756
+ function stableUid(event) {
757
+ if (event.uid) return event.uid;
758
+ const title = (event.title ?? "event").replace(/\s+/g, "-");
759
+ return `${formatUtc(event.start)}-${title}@super-calendar`;
760
+ }
761
+ /**
762
+ * Serialize events to an iCalendar (`.ics`) string. Timed events are written in
763
+ * UTC (`...Z`); all-day events (`allDay: true`) use `VALUE=DATE`. A `recurrence`
764
+ * becomes an `RRULE`, and `uid` / `description` / `location` round-trip.
765
+ *
766
+ * @example
767
+ * ```ts
768
+ * const ics = toICalendar(events);
769
+ * ```
770
+ */
771
+ function toICalendar(events, options = {}) {
772
+ const stamp = formatUtc(options.now ?? /* @__PURE__ */ new Date());
773
+ const out = [
774
+ "BEGIN:VCALENDAR",
775
+ "VERSION:2.0",
776
+ line("PRODID", options.prodId ?? "-//super-calendar//EN"),
777
+ "CALSCALE:GREGORIAN"
778
+ ];
779
+ for (const event of events) {
780
+ out.push("BEGIN:VEVENT");
781
+ out.push(line("UID", stableUid(event)));
782
+ out.push(line("DTSTAMP", stamp));
783
+ if (event.allDay) {
784
+ out.push(line("DTSTART;VALUE=DATE", formatDateOnly(event.start)));
785
+ out.push(line("DTEND;VALUE=DATE", formatDateOnly(event.end)));
786
+ } else {
787
+ out.push(line("DTSTART", formatUtc(event.start)));
788
+ out.push(line("DTEND", formatUtc(event.end)));
789
+ }
790
+ if (event.title) out.push(line("SUMMARY", escapeText(event.title)));
791
+ if (event.description) out.push(line("DESCRIPTION", escapeText(event.description)));
792
+ if (event.location) out.push(line("LOCATION", escapeText(event.location)));
793
+ if (event.recurrence) out.push(line("RRULE", formatRRule(event.recurrence)));
794
+ if (event.recurrence?.exdates?.length) out.push(event.allDay ? line("EXDATE;VALUE=DATE", event.recurrence.exdates.map(formatDateOnly).join(",")) : line("EXDATE", event.recurrence.exdates.map(formatUtc).join(",")));
795
+ if (event.recurrence?.rdates?.length) out.push(event.allDay ? line("RDATE;VALUE=DATE", event.recurrence.rdates.map(formatDateOnly).join(",")) : line("RDATE", event.recurrence.rdates.map(formatUtc).join(",")));
796
+ out.push("END:VEVENT");
797
+ }
798
+ out.push("END:VCALENDAR");
799
+ return out.join("\r\n");
800
+ }
801
+ //#endregion
439
802
  //#region src/utils/layout.ts
440
803
  const MINUTES_PER_HOUR = 60;
441
804
  const MIN_DURATION_HOURS = .25;
@@ -573,13 +936,27 @@ function closedHourBands(day, businessHours, minHour = 0, maxHour = 24) {
573
936
  }
574
937
  //#endregion
575
938
  //#region src/utils/monthGrid.ts
939
+ /** date-fns tokens for each {@link WeekdayFormat}. */
940
+ const WEEKDAY_TOKENS = {
941
+ narrow: "EEEEE",
942
+ short: "EEE",
943
+ long: "EEEE"
944
+ };
945
+ /**
946
+ * The date-fns format token for a {@link WeekdayFormat}. Exposed so a renderer
947
+ * that formats its own weekday header (rather than reading {@link buildMonthGrid})
948
+ * keeps the same mapping.
949
+ */
950
+ function weekdayFormatToken(format) {
951
+ return WEEKDAY_TOKENS[format];
952
+ }
576
953
  /**
577
954
  * Pure month-grid builder: the weeks and weekday headers for `month`, each day
578
955
  * annotated with selection/disabled/today state. Use this when you need the
579
956
  * data outside React; inside a component prefer {@link useMonthGrid}.
580
957
  */
581
958
  function buildMonthGrid(month, options = {}) {
582
- const { weekStartsOn = 0, showSixWeeks = false, isRTL = false, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
959
+ const { weekStartsOn = 0, weekdayFormat = "short", showSixWeeks = false, isRTL = false, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
583
960
  const rows = buildMonthWeeks(month, weekStartsOn, {
584
961
  showSixWeeks,
585
962
  isRTL
@@ -606,7 +983,7 @@ function buildMonthGrid(month, options = {}) {
606
983
  })),
607
984
  weekdays: rows[0].map((date) => ({
608
985
  date,
609
- label: (0, date_fns.format)(date, "EEE", { locale })
986
+ label: (0, date_fns.format)(date, WEEKDAY_TOKENS[weekdayFormat], { locale })
610
987
  }))
611
988
  };
612
989
  }
@@ -649,6 +1026,16 @@ function withTimeOf(date, source) {
649
1026
  next.setHours(source.getHours(), source.getMinutes(), source.getSeconds(), source.getMilliseconds());
650
1027
  return next;
651
1028
  }
1029
+ function nthWeekdayOfMonth(year, month, week, weekday) {
1030
+ if (week === -1) {
1031
+ const lastDay = new Date(year, month + 1, 0);
1032
+ const back = (lastDay.getDay() - weekday + 7) % 7;
1033
+ return new Date(year, month, lastDay.getDate() - back);
1034
+ }
1035
+ const day = 1 + (weekday - new Date(year, month, 1).getDay() + 7) % 7 + (week - 1) * 7;
1036
+ const date = new Date(year, month, day);
1037
+ return date.getMonth() === month ? date : null;
1038
+ }
652
1039
  function* occurrenceStarts(start, rule, rangeEnd) {
653
1040
  const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
654
1041
  let produced = 0;
@@ -671,6 +1058,64 @@ function* occurrenceStarts(start, rule, rangeEnd) {
671
1058
  weekStart = nextWeek;
672
1059
  }
673
1060
  }
1061
+ if (rule.freq === "monthly" && rule.monthDays?.length) {
1062
+ let year = start.getFullYear();
1063
+ let month = start.getMonth();
1064
+ while (true) {
1065
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
1066
+ const days = [...new Set(rule.monthDays.map((d) => d < 0 ? daysInMonth + d + 1 : d))].filter((d) => d >= 1 && d <= daysInMonth).sort((a, b) => a - b);
1067
+ for (const d of days) {
1068
+ const date = withTimeOf(new Date(year, month, d), start);
1069
+ if (date.getTime() < start.getTime()) continue;
1070
+ if (!within(date)) return;
1071
+ produced += 1;
1072
+ yield date;
1073
+ }
1074
+ month += interval;
1075
+ year += Math.floor(month / 12);
1076
+ month = (month % 12 + 12) % 12;
1077
+ if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
1078
+ }
1079
+ }
1080
+ if ((rule.freq === "monthly" || rule.freq === "yearly") && rule.nthWeekday) {
1081
+ const { week, weekday } = rule.nthWeekday;
1082
+ const stepMonths = rule.freq === "monthly" ? interval : 12 * interval;
1083
+ let year = start.getFullYear();
1084
+ let month = start.getMonth();
1085
+ while (true) {
1086
+ const day = nthWeekdayOfMonth(year, month, week, weekday);
1087
+ if (day) {
1088
+ const date = withTimeOf(day, start);
1089
+ if (date.getTime() >= start.getTime()) {
1090
+ if (!within(date)) return;
1091
+ produced += 1;
1092
+ yield date;
1093
+ }
1094
+ }
1095
+ month += stepMonths;
1096
+ year += Math.floor(month / 12);
1097
+ month = (month % 12 + 12) % 12;
1098
+ if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
1099
+ }
1100
+ }
1101
+ if (rule.freq === "yearly" && rule.months?.length) {
1102
+ const day = start.getDate();
1103
+ const months = [...new Set(rule.months)].filter((m) => m >= 1 && m <= 12).sort((a, b) => a - b);
1104
+ let year = start.getFullYear();
1105
+ while (true) {
1106
+ for (const m of months) {
1107
+ const month = m - 1;
1108
+ if (day > new Date(year, month + 1, 0).getDate()) continue;
1109
+ const date = withTimeOf(new Date(year, month, day), start);
1110
+ if (date.getTime() < start.getTime()) continue;
1111
+ if (!within(date)) return;
1112
+ produced += 1;
1113
+ yield date;
1114
+ }
1115
+ year += interval;
1116
+ if (new Date(year, 0, 1).getTime() > rangeEnd.getTime()) return;
1117
+ }
1118
+ }
674
1119
  let cursor = start;
675
1120
  while (within(cursor)) {
676
1121
  produced += 1;
@@ -701,56 +1146,21 @@ function expandRecurringEvents(events, rangeStart, rangeEnd) {
701
1146
  continue;
702
1147
  }
703
1148
  const durationMs = event.end.getTime() - event.start.getTime();
704
- for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
1149
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
1150
+ const excluded = new Set((event.recurrence.exdates ?? []).map(dayKey));
1151
+ const starts = /* @__PURE__ */ new Map();
1152
+ for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) starts.set(start.getTime(), start);
1153
+ for (const rdate of event.recurrence.rdates ?? []) if (rdate.getTime() <= rangeEnd.getTime()) starts.set(rdate.getTime(), rdate);
1154
+ const ordered = [...starts.values()].sort((a, b) => a.getTime() - b.getTime());
1155
+ for (const start of ordered) {
705
1156
  if (start.getTime() + durationMs < rangeStart.getTime()) continue;
1157
+ if (excluded.has(dayKey(start))) continue;
706
1158
  out.push(instanceAt(event, start, durationMs));
707
1159
  }
708
1160
  }
709
1161
  return out;
710
1162
  }
711
1163
  //#endregion
712
- //#region src/utils/timezone.ts
713
- /**
714
- * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
715
- * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
716
- * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
717
- * passing zoned dates makes it render in `timeZone` regardless of the device.
718
- *
719
- * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
720
- * web). The result is for display/layout only; it no longer points at the
721
- * original UTC instant, so don't round-trip it back to a real time.
722
- */
723
- function toZonedTime(date, timeZone) {
724
- const parts = new Intl.DateTimeFormat("en-US", {
725
- timeZone,
726
- hourCycle: "h23",
727
- year: "numeric",
728
- month: "2-digit",
729
- day: "2-digit",
730
- hour: "2-digit",
731
- minute: "2-digit",
732
- second: "2-digit"
733
- }).formatToParts(date);
734
- const field = (type) => {
735
- const part = parts.find((p) => p.type === type);
736
- return part ? Number(part.value) : 0;
737
- };
738
- const hour = field("hour") % 24;
739
- return new Date(field("year"), field("month") - 1, field("day"), hour, field("minute"), field("second"), date.getMilliseconds());
740
- }
741
- /**
742
- * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
743
- * displays them in `timeZone`. Other fields are preserved. Memoize the result
744
- * (e.g. with `useMemo`) since it allocates new dates.
745
- */
746
- function eventsInTimeZone(events, timeZone) {
747
- return events.map((event) => ({
748
- ...event,
749
- start: toZonedTime(event.start, timeZone),
750
- end: toZonedTime(event.end, timeZone)
751
- }));
752
- }
753
- //#endregion
754
1164
  exports.CalendarSelectionProvider = CalendarSelectionProvider;
755
1165
  exports.MIN_BOX_HEIGHT_FOR_TIME = MIN_BOX_HEIGHT_FOR_TIME;
756
1166
  exports.bandRounding = bandRounding;
@@ -786,15 +1196,19 @@ exports.minutesIntoDay = minutesIntoDay;
786
1196
  exports.monthEventCapacity = monthEventCapacity;
787
1197
  exports.monthVisibleCount = monthVisibleCount;
788
1198
  exports.nextDateRange = nextDateRange;
1199
+ exports.parseICalendar = parseICalendar;
789
1200
  exports.rangeBandKind = rangeBandKind;
790
1201
  exports.resolveDraggedBounds = resolveDraggedBounds;
791
1202
  exports.shiftMinutes = shiftMinutes;
792
1203
  exports.snapDeltaMinutes = snapDeltaMinutes;
793
1204
  exports.titleEllipsizeMode = titleEllipsizeMode;
794
1205
  exports.titleNumberOfLines = titleNumberOfLines;
1206
+ exports.toICalendar = toICalendar;
795
1207
  exports.toZonedTime = toZonedTime;
796
1208
  exports.useCalendarSelection = useCalendarSelection;
797
1209
  exports.useDateRange = useDateRange;
798
1210
  exports.useMonthGrid = useMonthGrid;
799
1211
  exports.viewDayCount = viewDayCount;
800
1212
  exports.weekDaysCount = weekDaysCount;
1213
+ exports.weekdayFormatToken = weekdayFormatToken;
1214
+ exports.zonedTimeToUtc = zonedTimeToUtc;