@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.mjs CHANGED
@@ -435,6 +435,369 @@ function monthVisibleCount(total, capacity) {
435
435
  return Math.max(1, capacity.withMore);
436
436
  }
437
437
  //#endregion
438
+ //#region src/utils/timezone.ts
439
+ /**
440
+ * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
441
+ * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
442
+ * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
443
+ * passing zoned dates makes it render in `timeZone` regardless of the device.
444
+ *
445
+ * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
446
+ * web). The result is for display/layout only; it no longer points at the
447
+ * original UTC instant, so don't round-trip it back to a real time.
448
+ */
449
+ function toZonedTime(date, timeZone) {
450
+ const parts = new Intl.DateTimeFormat("en-US", {
451
+ timeZone,
452
+ hourCycle: "h23",
453
+ year: "numeric",
454
+ month: "2-digit",
455
+ day: "2-digit",
456
+ hour: "2-digit",
457
+ minute: "2-digit",
458
+ second: "2-digit"
459
+ }).formatToParts(date);
460
+ const field = (type) => {
461
+ const part = parts.find((p) => p.type === type);
462
+ return part ? Number(part.value) : 0;
463
+ };
464
+ const hour = field("hour") % 24;
465
+ return new Date(field("year"), field("month") - 1, field("day"), hour, field("minute"), field("second"), date.getMilliseconds());
466
+ }
467
+ function zoneOffsetMs(instant, timeZone) {
468
+ const z = toZonedTime(instant, timeZone);
469
+ return Date.UTC(z.getFullYear(), z.getMonth(), z.getDate(), z.getHours(), z.getMinutes(), z.getSeconds(), z.getMilliseconds()) - instant.getTime();
470
+ }
471
+ /**
472
+ * The inverse of {@link toZonedTime}: given a wall-clock time in `timeZone`,
473
+ * return the absolute UTC instant. Pass the wall clock as a `Date` whose **UTC**
474
+ * fields hold the components (e.g. `new Date(Date.UTC(y, m, d, h, min))`). Used to
475
+ * resolve iCal `TZID` times; DST-correct via a two-pass offset (ambiguous
476
+ * fall-back times resolve to the post-transition offset).
477
+ */
478
+ function zonedTimeToUtc(wallClock, timeZone) {
479
+ const guess = wallClock.getTime();
480
+ const firstPass = zoneOffsetMs(new Date(guess), timeZone);
481
+ const secondPass = zoneOffsetMs(new Date(guess - firstPass), timeZone);
482
+ return new Date(guess - secondPass);
483
+ }
484
+ /**
485
+ * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
486
+ * displays them in `timeZone`. Other fields are preserved. Memoize the result
487
+ * (e.g. with `useMemo`) since it allocates new dates.
488
+ */
489
+ function eventsInTimeZone(events, timeZone) {
490
+ return events.map((event) => ({
491
+ ...event,
492
+ start: toZonedTime(event.start, timeZone),
493
+ end: toZonedTime(event.end, timeZone)
494
+ }));
495
+ }
496
+ //#endregion
497
+ //#region src/utils/ical.ts
498
+ const RRULE_FREQ = {
499
+ DAILY: "daily",
500
+ WEEKLY: "weekly",
501
+ MONTHLY: "monthly",
502
+ YEARLY: "yearly"
503
+ };
504
+ const FREQ_RRULE = {
505
+ daily: "DAILY",
506
+ weekly: "WEEKLY",
507
+ monthly: "MONTHLY",
508
+ yearly: "YEARLY"
509
+ };
510
+ const BYDAY = [
511
+ "SU",
512
+ "MO",
513
+ "TU",
514
+ "WE",
515
+ "TH",
516
+ "FR",
517
+ "SA"
518
+ ];
519
+ const pad = (n, width = 2) => String(n).padStart(width, "0");
520
+ function escapeText(value) {
521
+ return value.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n");
522
+ }
523
+ function unescapeText(value) {
524
+ let out = "";
525
+ for (let i = 0; i < value.length; i++) {
526
+ const ch = value[i];
527
+ if (ch === "\\" && i + 1 < value.length) {
528
+ const next = value[++i];
529
+ out += next === "n" || next === "N" ? "\n" : next;
530
+ } else out += ch;
531
+ }
532
+ return out;
533
+ }
534
+ function foldLine(line) {
535
+ if (line.length <= 75) return line;
536
+ const parts = [line.slice(0, 75)];
537
+ let rest = line.slice(75);
538
+ while (rest.length > 74) {
539
+ parts.push(` ${rest.slice(0, 74)}`);
540
+ rest = rest.slice(74);
541
+ }
542
+ parts.push(` ${rest}`);
543
+ return parts.join("\r\n");
544
+ }
545
+ function unfoldLines(text) {
546
+ const raw = text.split(/\r\n|\r|\n/);
547
+ const out = [];
548
+ for (const line of raw) if ((line.startsWith(" ") || line.startsWith(" ")) && out.length) out[out.length - 1] += line.slice(1);
549
+ else out.push(line);
550
+ return out;
551
+ }
552
+ /** Serialize a Date as a UTC iCal date-time (`YYYYMMDDTHHMMSSZ`). */
553
+ function formatUtc(d) {
554
+ return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
555
+ }
556
+ /** Serialize a Date as an iCal date-only value (`YYYYMMDD`) from its local parts. */
557
+ function formatDateOnly(d) {
558
+ return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
559
+ }
560
+ /**
561
+ * Parse an iCal DATE / DATE-TIME value. `dateOnly` marks all-day (`VALUE=DATE`);
562
+ * `tzid` is the IANA zone from a `TZID=` param, used to resolve the local time to
563
+ * the correct UTC instant.
564
+ */
565
+ function parseIcalDate(value, dateOnly, tzid) {
566
+ const y = Number(value.slice(0, 4));
567
+ const mo = Number(value.slice(4, 6)) - 1;
568
+ const d = Number(value.slice(6, 8));
569
+ if (dateOnly || !value.includes("T")) return new Date(y, mo, d);
570
+ const h = Number(value.slice(9, 11));
571
+ const mi = Number(value.slice(11, 13));
572
+ const s = Number(value.slice(13, 15));
573
+ if (value.endsWith("Z")) return new Date(Date.UTC(y, mo, d, h, mi, s));
574
+ if (tzid) try {
575
+ return zonedTimeToUtc(new Date(Date.UTC(y, mo, d, h, mi, s)), tzid);
576
+ } catch {}
577
+ return new Date(y, mo, d, h, mi, s);
578
+ }
579
+ /** Parse an iCal/ISO-8601 DURATION (e.g. `PT1H30M`, `P1D`, `P1W`) to milliseconds. */
580
+ function parseDuration(value) {
581
+ const m = /^([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(value.trim());
582
+ if (!m || !m[2] && !m[3] && !m[4] && !m[5] && !m[6]) return null;
583
+ const sign = m[1] === "-" ? -1 : 1;
584
+ const [w, d, h, mi, s] = [
585
+ m[2],
586
+ m[3],
587
+ m[4],
588
+ m[5],
589
+ m[6]
590
+ ].map((x) => Number(x ?? 0));
591
+ return sign * ((((w * 7 + d) * 24 + h) * 60 + mi) * 60 + s) * 1e3;
592
+ }
593
+ function parseRRule(value) {
594
+ const parts = /* @__PURE__ */ new Map();
595
+ for (const pair of value.split(";")) {
596
+ const [k, v] = pair.split("=");
597
+ if (k && v) parts.set(k.toUpperCase(), v);
598
+ }
599
+ const freq = parts.get("FREQ");
600
+ if (!freq || !RRULE_FREQ[freq]) return void 0;
601
+ const rule = { freq: RRULE_FREQ[freq] };
602
+ const interval = parts.get("INTERVAL");
603
+ if (interval) rule.interval = Number(interval);
604
+ const count = parts.get("COUNT");
605
+ if (count) rule.count = Number(count);
606
+ const until = parts.get("UNTIL");
607
+ if (until) rule.until = parseIcalDate(until, !until.includes("T"));
608
+ const byday = parts.get("BYDAY");
609
+ if (byday) {
610
+ const tokens = byday.split(",").map((t) => t.trim().toUpperCase());
611
+ const monthly = rule.freq === "monthly" || rule.freq === "yearly";
612
+ const ordinal = /^([+-]?\d+)(SU|MO|TU|WE|TH|FR|SA)$/;
613
+ const plain = [];
614
+ for (const token of tokens) {
615
+ const m = monthly ? ordinal.exec(token) : null;
616
+ if (m) {
617
+ const weekday = BYDAY.indexOf(m[2]);
618
+ if (weekday >= 0 && !rule.nthWeekday) rule.nthWeekday = {
619
+ week: Number(m[1]),
620
+ weekday
621
+ };
622
+ continue;
623
+ }
624
+ const index = BYDAY.indexOf(token);
625
+ if (index >= 0) plain.push(index);
626
+ }
627
+ if (plain.length) rule.weekdays = plain;
628
+ }
629
+ const bymonthday = parts.get("BYMONTHDAY");
630
+ if (bymonthday) {
631
+ const days = bymonthday.split(",").map((d) => Number(d.trim())).filter((d) => Number.isInteger(d) && d !== 0);
632
+ if (days.length) rule.monthDays = days;
633
+ }
634
+ const bymonth = parts.get("BYMONTH");
635
+ if (bymonth) {
636
+ const months = bymonth.split(",").map((m) => Number(m.trim())).filter((m) => Number.isInteger(m) && m >= 1 && m <= 12);
637
+ if (months.length) rule.months = months;
638
+ }
639
+ return rule;
640
+ }
641
+ function formatRRule(rule) {
642
+ const parts = [`FREQ=${FREQ_RRULE[rule.freq]}`];
643
+ if (rule.interval && rule.interval !== 1) parts.push(`INTERVAL=${rule.interval}`);
644
+ if (rule.count != null) parts.push(`COUNT=${rule.count}`);
645
+ if (rule.until) parts.push(`UNTIL=${formatUtc(rule.until)}`);
646
+ if (rule.nthWeekday) parts.push(`BYDAY=${rule.nthWeekday.week}${BYDAY[rule.nthWeekday.weekday]}`);
647
+ else if (rule.weekdays?.length) parts.push(`BYDAY=${rule.weekdays.map((d) => BYDAY[d]).join(",")}`);
648
+ if (rule.monthDays?.length) parts.push(`BYMONTHDAY=${rule.monthDays.join(",")}`);
649
+ if (rule.months?.length) parts.push(`BYMONTH=${rule.months.join(",")}`);
650
+ return parts.join(";");
651
+ }
652
+ function parseLine(line) {
653
+ const colon = line.indexOf(":");
654
+ const head = colon === -1 ? line : line.slice(0, colon);
655
+ const value = colon === -1 ? "" : line.slice(colon + 1);
656
+ const [name, ...paramParts] = head.split(";");
657
+ const params = /* @__PURE__ */ new Map();
658
+ for (const p of paramParts) {
659
+ const eq = p.indexOf("=");
660
+ if (eq !== -1) params.set(p.slice(0, eq).toUpperCase(), p.slice(eq + 1));
661
+ }
662
+ return {
663
+ name: name.toUpperCase(),
664
+ params,
665
+ value
666
+ };
667
+ }
668
+ /**
669
+ * Parse an iCalendar (`.ics`) string into events. Reads every `VEVENT`; ignores
670
+ * VTODO/VJOURNAL/VTIMEZONE and unknown properties. Events without a usable
671
+ * `DTSTART` are skipped. All-day events (`VALUE=DATE`) with no `DTEND` get a
672
+ * one-day span.
673
+ *
674
+ * @example
675
+ * ```ts
676
+ * const events = parseICalendar(await file.text());
677
+ * ```
678
+ */
679
+ function parseICalendar(ics) {
680
+ const lines = unfoldLines(ics);
681
+ const events = [];
682
+ let current = null;
683
+ let allDay = false;
684
+ let durationMs = null;
685
+ let exdates = [];
686
+ let rdates = [];
687
+ for (const raw of lines) {
688
+ const line = parseLine(raw);
689
+ if (line.name === "BEGIN" && line.value === "VEVENT") {
690
+ current = {};
691
+ allDay = false;
692
+ durationMs = null;
693
+ exdates = [];
694
+ rdates = [];
695
+ continue;
696
+ }
697
+ if (line.name === "END" && line.value === "VEVENT") {
698
+ if (current?.start) {
699
+ if (!current.end && durationMs != null) current.end = new Date(current.start.getTime() + durationMs);
700
+ if (current.recurrence && exdates.length) current.recurrence.exdates = exdates;
701
+ if (current.recurrence && rdates.length) current.recurrence.rdates = rdates;
702
+ if (allDay) {
703
+ current.allDay = true;
704
+ if (!current.end) current.end = new Date(current.start.getTime() + 864e5);
705
+ } else if (!current.end) current.end = current.start;
706
+ events.push(current);
707
+ }
708
+ current = null;
709
+ continue;
710
+ }
711
+ if (!current) continue;
712
+ const dateOnly = line.params.get("VALUE") === "DATE";
713
+ const tzid = line.params.get("TZID");
714
+ switch (line.name) {
715
+ case "SUMMARY":
716
+ current.title = unescapeText(line.value);
717
+ break;
718
+ case "DESCRIPTION":
719
+ current.description = unescapeText(line.value);
720
+ break;
721
+ case "LOCATION":
722
+ current.location = unescapeText(line.value);
723
+ break;
724
+ case "UID":
725
+ current.uid = line.value;
726
+ break;
727
+ case "DTSTART":
728
+ current.start = parseIcalDate(line.value, dateOnly, tzid);
729
+ if (dateOnly) allDay = true;
730
+ break;
731
+ case "DTEND":
732
+ current.end = parseIcalDate(line.value, dateOnly, tzid);
733
+ break;
734
+ case "DURATION":
735
+ durationMs = parseDuration(line.value);
736
+ break;
737
+ case "EXDATE":
738
+ for (const v of line.value.split(",")) if (v) exdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
739
+ break;
740
+ case "RDATE":
741
+ for (const v of line.value.split(",")) if (v) rdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
742
+ break;
743
+ case "RRULE": {
744
+ const rule = parseRRule(line.value);
745
+ if (rule) current.recurrence = rule;
746
+ break;
747
+ }
748
+ }
749
+ }
750
+ return events;
751
+ }
752
+ function line(name, value) {
753
+ return foldLine(`${name}:${value}`);
754
+ }
755
+ function stableUid(event) {
756
+ if (event.uid) return event.uid;
757
+ const title = (event.title ?? "event").replace(/\s+/g, "-");
758
+ return `${formatUtc(event.start)}-${title}@super-calendar`;
759
+ }
760
+ /**
761
+ * Serialize events to an iCalendar (`.ics`) string. Timed events are written in
762
+ * UTC (`...Z`); all-day events (`allDay: true`) use `VALUE=DATE`. A `recurrence`
763
+ * becomes an `RRULE`, and `uid` / `description` / `location` round-trip.
764
+ *
765
+ * @example
766
+ * ```ts
767
+ * const ics = toICalendar(events);
768
+ * ```
769
+ */
770
+ function toICalendar(events, options = {}) {
771
+ const stamp = formatUtc(options.now ?? /* @__PURE__ */ new Date());
772
+ const out = [
773
+ "BEGIN:VCALENDAR",
774
+ "VERSION:2.0",
775
+ line("PRODID", options.prodId ?? "-//super-calendar//EN"),
776
+ "CALSCALE:GREGORIAN"
777
+ ];
778
+ for (const event of events) {
779
+ out.push("BEGIN:VEVENT");
780
+ out.push(line("UID", stableUid(event)));
781
+ out.push(line("DTSTAMP", stamp));
782
+ if (event.allDay) {
783
+ out.push(line("DTSTART;VALUE=DATE", formatDateOnly(event.start)));
784
+ out.push(line("DTEND;VALUE=DATE", formatDateOnly(event.end)));
785
+ } else {
786
+ out.push(line("DTSTART", formatUtc(event.start)));
787
+ out.push(line("DTEND", formatUtc(event.end)));
788
+ }
789
+ if (event.title) out.push(line("SUMMARY", escapeText(event.title)));
790
+ if (event.description) out.push(line("DESCRIPTION", escapeText(event.description)));
791
+ if (event.location) out.push(line("LOCATION", escapeText(event.location)));
792
+ if (event.recurrence) out.push(line("RRULE", formatRRule(event.recurrence)));
793
+ 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(",")));
794
+ 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(",")));
795
+ out.push("END:VEVENT");
796
+ }
797
+ out.push("END:VCALENDAR");
798
+ return out.join("\r\n");
799
+ }
800
+ //#endregion
438
801
  //#region src/utils/layout.ts
439
802
  const MINUTES_PER_HOUR = 60;
440
803
  const MIN_DURATION_HOURS = .25;
@@ -572,13 +935,27 @@ function closedHourBands(day, businessHours, minHour = 0, maxHour = 24) {
572
935
  }
573
936
  //#endregion
574
937
  //#region src/utils/monthGrid.ts
938
+ /** date-fns tokens for each {@link WeekdayFormat}. */
939
+ const WEEKDAY_TOKENS = {
940
+ narrow: "EEEEE",
941
+ short: "EEE",
942
+ long: "EEEE"
943
+ };
944
+ /**
945
+ * The date-fns format token for a {@link WeekdayFormat}. Exposed so a renderer
946
+ * that formats its own weekday header (rather than reading {@link buildMonthGrid})
947
+ * keeps the same mapping.
948
+ */
949
+ function weekdayFormatToken(format) {
950
+ return WEEKDAY_TOKENS[format];
951
+ }
575
952
  /**
576
953
  * Pure month-grid builder: the weeks and weekday headers for `month`, each day
577
954
  * annotated with selection/disabled/today state. Use this when you need the
578
955
  * data outside React; inside a component prefer {@link useMonthGrid}.
579
956
  */
580
957
  function buildMonthGrid(month, options = {}) {
581
- const { weekStartsOn = 0, showSixWeeks = false, isRTL = false, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
958
+ const { weekStartsOn = 0, weekdayFormat = "short", showSixWeeks = false, isRTL = false, selectedDates, selectedRange, minDate, maxDate, isDateDisabled, locale } = options;
582
959
  const rows = buildMonthWeeks(month, weekStartsOn, {
583
960
  showSixWeeks,
584
961
  isRTL
@@ -605,7 +982,7 @@ function buildMonthGrid(month, options = {}) {
605
982
  })),
606
983
  weekdays: rows[0].map((date) => ({
607
984
  date,
608
- label: format(date, "EEE", { locale })
985
+ label: format(date, WEEKDAY_TOKENS[weekdayFormat], { locale })
609
986
  }))
610
987
  };
611
988
  }
@@ -648,6 +1025,16 @@ function withTimeOf(date, source) {
648
1025
  next.setHours(source.getHours(), source.getMinutes(), source.getSeconds(), source.getMilliseconds());
649
1026
  return next;
650
1027
  }
1028
+ function nthWeekdayOfMonth(year, month, week, weekday) {
1029
+ if (week === -1) {
1030
+ const lastDay = new Date(year, month + 1, 0);
1031
+ const back = (lastDay.getDay() - weekday + 7) % 7;
1032
+ return new Date(year, month, lastDay.getDate() - back);
1033
+ }
1034
+ const day = 1 + (weekday - new Date(year, month, 1).getDay() + 7) % 7 + (week - 1) * 7;
1035
+ const date = new Date(year, month, day);
1036
+ return date.getMonth() === month ? date : null;
1037
+ }
651
1038
  function* occurrenceStarts(start, rule, rangeEnd) {
652
1039
  const interval = Math.max(1, Math.trunc(rule.interval ?? 1));
653
1040
  let produced = 0;
@@ -670,6 +1057,64 @@ function* occurrenceStarts(start, rule, rangeEnd) {
670
1057
  weekStart = nextWeek;
671
1058
  }
672
1059
  }
1060
+ if (rule.freq === "monthly" && rule.monthDays?.length) {
1061
+ let year = start.getFullYear();
1062
+ let month = start.getMonth();
1063
+ while (true) {
1064
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
1065
+ 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);
1066
+ for (const d of days) {
1067
+ const date = withTimeOf(new Date(year, month, d), start);
1068
+ if (date.getTime() < start.getTime()) continue;
1069
+ if (!within(date)) return;
1070
+ produced += 1;
1071
+ yield date;
1072
+ }
1073
+ month += interval;
1074
+ year += Math.floor(month / 12);
1075
+ month = (month % 12 + 12) % 12;
1076
+ if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
1077
+ }
1078
+ }
1079
+ if ((rule.freq === "monthly" || rule.freq === "yearly") && rule.nthWeekday) {
1080
+ const { week, weekday } = rule.nthWeekday;
1081
+ const stepMonths = rule.freq === "monthly" ? interval : 12 * interval;
1082
+ let year = start.getFullYear();
1083
+ let month = start.getMonth();
1084
+ while (true) {
1085
+ const day = nthWeekdayOfMonth(year, month, week, weekday);
1086
+ if (day) {
1087
+ const date = withTimeOf(day, start);
1088
+ if (date.getTime() >= start.getTime()) {
1089
+ if (!within(date)) return;
1090
+ produced += 1;
1091
+ yield date;
1092
+ }
1093
+ }
1094
+ month += stepMonths;
1095
+ year += Math.floor(month / 12);
1096
+ month = (month % 12 + 12) % 12;
1097
+ if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
1098
+ }
1099
+ }
1100
+ if (rule.freq === "yearly" && rule.months?.length) {
1101
+ const day = start.getDate();
1102
+ const months = [...new Set(rule.months)].filter((m) => m >= 1 && m <= 12).sort((a, b) => a - b);
1103
+ let year = start.getFullYear();
1104
+ while (true) {
1105
+ for (const m of months) {
1106
+ const month = m - 1;
1107
+ if (day > new Date(year, month + 1, 0).getDate()) continue;
1108
+ const date = withTimeOf(new Date(year, month, day), start);
1109
+ if (date.getTime() < start.getTime()) continue;
1110
+ if (!within(date)) return;
1111
+ produced += 1;
1112
+ yield date;
1113
+ }
1114
+ year += interval;
1115
+ if (new Date(year, 0, 1).getTime() > rangeEnd.getTime()) return;
1116
+ }
1117
+ }
673
1118
  let cursor = start;
674
1119
  while (within(cursor)) {
675
1120
  produced += 1;
@@ -700,54 +1145,19 @@ function expandRecurringEvents(events, rangeStart, rangeEnd) {
700
1145
  continue;
701
1146
  }
702
1147
  const durationMs = event.end.getTime() - event.start.getTime();
703
- for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
1148
+ const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
1149
+ const excluded = new Set((event.recurrence.exdates ?? []).map(dayKey));
1150
+ const starts = /* @__PURE__ */ new Map();
1151
+ for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) starts.set(start.getTime(), start);
1152
+ for (const rdate of event.recurrence.rdates ?? []) if (rdate.getTime() <= rangeEnd.getTime()) starts.set(rdate.getTime(), rdate);
1153
+ const ordered = [...starts.values()].sort((a, b) => a.getTime() - b.getTime());
1154
+ for (const start of ordered) {
704
1155
  if (start.getTime() + durationMs < rangeStart.getTime()) continue;
1156
+ if (excluded.has(dayKey(start))) continue;
705
1157
  out.push(instanceAt(event, start, durationMs));
706
1158
  }
707
1159
  }
708
1160
  return out;
709
1161
  }
710
1162
  //#endregion
711
- //#region src/utils/timezone.ts
712
- /**
713
- * Reinterpret an instant as its wall-clock time in `timeZone`, returned as a
714
- * device-local `Date` whose fields (hours, minutes, …) read back as that zone's
715
- * clock. The calendar lays events out from `getHours()`/`getMinutes()`, so
716
- * passing zoned dates makes it render in `timeZone` regardless of the device.
717
- *
718
- * DST-correct via `Intl` (available on modern React Native Hermes/JSC and the
719
- * web). The result is for display/layout only; it no longer points at the
720
- * original UTC instant, so don't round-trip it back to a real time.
721
- */
722
- function toZonedTime(date, timeZone) {
723
- const parts = new Intl.DateTimeFormat("en-US", {
724
- timeZone,
725
- hourCycle: "h23",
726
- year: "numeric",
727
- month: "2-digit",
728
- day: "2-digit",
729
- hour: "2-digit",
730
- minute: "2-digit",
731
- second: "2-digit"
732
- }).formatToParts(date);
733
- const field = (type) => {
734
- const part = parts.find((p) => p.type === type);
735
- return part ? Number(part.value) : 0;
736
- };
737
- const hour = field("hour") % 24;
738
- return new Date(field("year"), field("month") - 1, field("day"), hour, field("minute"), field("second"), date.getMilliseconds());
739
- }
740
- /**
741
- * Map every event's `start`/`end` through {@link toZonedTime} so the calendar
742
- * displays them in `timeZone`. Other fields are preserved. Memoize the result
743
- * (e.g. with `useMemo`) since it allocates new dates.
744
- */
745
- function eventsInTimeZone(events, timeZone) {
746
- return events.map((event) => ({
747
- ...event,
748
- start: toZonedTime(event.start, timeZone),
749
- end: toZonedTime(event.end, timeZone)
750
- }));
751
- }
752
- //#endregion
753
- export { CalendarSelectionProvider, MIN_BOX_HEIGHT_FOR_TIME, bandRounding, buildMonthGrid, buildMonthWeeks, cellRangeFromDrag, closedHourBands, compareDayEvents, darkColors, dayBadgeKind, daySelectionState, eventAccessibilityLabel, eventChipLayout, eventDayKeys, eventTimeLabel, eventsInTimeZone, expandRecurringEvents, formatHour, getIsToday, getViewDays, getWeekDays, groupEventsByDay, isAllDayEvent, isDateSelectable, isRangeEndpoint, isSameCalendarDay, isTimeVisibleAtHeight, isWeekend, isWithinDateRange, layoutDayEvents, lightColors, minutesIntoDay, monthEventCapacity, monthVisibleCount, nextDateRange, rangeBandKind, resolveDraggedBounds, shiftMinutes, snapDeltaMinutes, titleEllipsizeMode, titleNumberOfLines, toZonedTime, useCalendarSelection, useDateRange, useMonthGrid, viewDayCount, weekDaysCount };
1163
+ export { CalendarSelectionProvider, MIN_BOX_HEIGHT_FOR_TIME, bandRounding, buildMonthGrid, buildMonthWeeks, cellRangeFromDrag, closedHourBands, compareDayEvents, darkColors, dayBadgeKind, daySelectionState, eventAccessibilityLabel, eventChipLayout, eventDayKeys, eventTimeLabel, eventsInTimeZone, expandRecurringEvents, formatHour, getIsToday, getViewDays, getWeekDays, groupEventsByDay, isAllDayEvent, isDateSelectable, isRangeEndpoint, isSameCalendarDay, isTimeVisibleAtHeight, isWeekend, isWithinDateRange, layoutDayEvents, lightColors, minutesIntoDay, monthEventCapacity, monthVisibleCount, nextDateRange, parseICalendar, rangeBandKind, resolveDraggedBounds, shiftMinutes, snapDeltaMinutes, titleEllipsizeMode, titleNumberOfLines, toICalendar, toZonedTime, useCalendarSelection, useDateRange, useMonthGrid, viewDayCount, weekDaysCount, weekdayFormatToken, zonedTimeToUtc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@super-calendar/core",
3
- "version": "2.1.4",
3
+ "version": "2.2.0",
4
4
  "description": "Render-agnostic core for super-calendar: date math, selection model, event layout, the month-grid builder, headless hooks, and neutral theme tokens. No renderer.",
5
5
  "keywords": [
6
6
  "calendar",
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export * from "./utils/dateRange";
23
23
  export * from "./utils/dates";
24
24
  export * from "./utils/drag";
25
25
  export * from "./utils/eventDisplay";
26
+ export * from "./utils/ical";
26
27
  export * from "./utils/layout";
27
28
  export * from "./utils/monthGrid";
28
29
  export * from "./utils/recurrence";
package/src/types.ts CHANGED
@@ -51,6 +51,38 @@ export interface RecurrenceRule {
51
51
  * the event's time of day. Omit to repeat on the start date's own weekday.
52
52
  */
53
53
  weekdays?: WeekStartsOn[];
54
+ /**
55
+ * For `monthly`/`yearly`: repeat on the Nth weekday of the period instead of the
56
+ * start date's day-of-month — e.g. `{ week: 3, weekday: 1 }` is the 3rd Monday.
57
+ * `week` is 1–5, or -1 for the last such weekday. Maps to an ordinal iCal `BYDAY`
58
+ * (e.g. `3MO`, `-1FR`).
59
+ */
60
+ nthWeekday?: { week: number; weekday: WeekStartsOn };
61
+ /**
62
+ * For `monthly`: the day(s) of the month to repeat on — 1–31, or negative to
63
+ * count from the month's end (-1 is the last day, -2 the second-to-last). Days
64
+ * that don't exist in a given month (e.g. the 31st in February) are skipped.
65
+ * Takes precedence over the start date's own day-of-month. Maps to iCal
66
+ * `BYMONTHDAY`.
67
+ */
68
+ monthDays?: number[];
69
+ /**
70
+ * For `yearly`: the month(s) to repeat in (1 = January … 12 = December), keeping
71
+ * the start date's day-of-month. Years where a listed month lacks that day are
72
+ * skipped. Maps to iCal `BYMONTH`.
73
+ */
74
+ months?: number[];
75
+ /**
76
+ * Dates to skip (exceptions). An occurrence whose day matches one of these is
77
+ * dropped by `expandRecurringEvents`. Maps to iCal `EXDATE`.
78
+ */
79
+ exdates?: Date[];
80
+ /**
81
+ * Extra one-off start dates added to the set, even ones the rule wouldn't
82
+ * produce. Merged in chronological order and de-duplicated against rule
83
+ * occurrences; `exdates` still apply. Maps to iCal `RDATE`.
84
+ */
85
+ rdates?: Date[];
54
86
  }
55
87
 
56
88
  /**
@@ -1,5 +1,5 @@
1
1
  import { format } from "date-fns";
2
- import type { CalendarMode } from "../types";
2
+ import type { CalendarEvent, CalendarMode } from "../types";
3
3
 
4
4
  /**
5
5
  * Minimum event-box height (px) before the built-in renderer shows the time line
@@ -32,6 +32,31 @@ export function eventAccessibilityLabel(args: {
32
32
  return [args.title, time].filter(Boolean).join(", ");
33
33
  }
34
34
 
35
+ /**
36
+ * Context describing how an event is being rendered, passed to a consumer's
37
+ * {@link EventAccessibilityLabeler} so the label can adapt to the view (e.g. omit
38
+ * the time in month mode, or read the 12-hour clock when `ampm` is set).
39
+ */
40
+ export interface EventAccessibilityLabelContext {
41
+ /** The view the event is rendered in. */
42
+ mode: CalendarMode;
43
+ /** Whether the event sits in the all-day lane (or is an all-day event in month view). */
44
+ isAllDay: boolean;
45
+ /** Whether times are formatted as 12-hour AM/PM. */
46
+ ampm: boolean;
47
+ }
48
+
49
+ /**
50
+ * Override for an event's screen-reader label. Return the full text to announce
51
+ * for `event`; each renderer uses it verbatim in place of the built-in
52
+ * {@link eventAccessibilityLabel}. Shared by both renderers, so a custom label
53
+ * reads the same on web and native.
54
+ */
55
+ export type EventAccessibilityLabeler<T = unknown> = (
56
+ event: CalendarEvent<T>,
57
+ context: EventAccessibilityLabelContext,
58
+ ) => string;
59
+
35
60
  /**
36
61
  * Month cells and the all-day lane show a single clipped line; timed-grid titles
37
62
  * (`undefined`) wrap to fill the box.