@super-calendar/core 2.1.5 → 2.3.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.d.mts +118 -1
- package/dist/index.d.ts +118 -1
- package/dist/index.js +459 -45
- package/dist/index.mjs +456 -46
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/types.ts +32 -0
- package/src/utils/eventDisplay.ts +26 -1
- package/src/utils/ical.ts +409 -0
- package/src/utils/monthGrid.ts +26 -1
- package/src/utils/recurrence.ts +101 -0
- package/src/utils/timezone.ts +30 -0
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,
|
|
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
|
-
|
|
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;
|