@super-calendar/core 2.1.5 → 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.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
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// iCalendar (RFC 5545) import/export. Converts between `.ics` text and the
|
|
2
|
+
// library's `CalendarEvent` shape so events interoperate with Google Calendar,
|
|
3
|
+
// Apple Calendar, Outlook, and anything else that speaks iCal. This is a
|
|
4
|
+
// pragmatic subset: VEVENT with SUMMARY / DESCRIPTION / LOCATION / UID,
|
|
5
|
+
// DTSTART / DTEND (date, UTC, or floating local), all-day events, and a
|
|
6
|
+
// round-trippable RRULE that maps onto the library's `RecurrenceRule`.
|
|
7
|
+
|
|
8
|
+
import type { ICalendarEvent, RecurrenceFrequency, RecurrenceRule, WeekStartsOn } from "../types";
|
|
9
|
+
import { zonedTimeToUtc } from "./timezone";
|
|
10
|
+
|
|
11
|
+
/** An event parsed from iCal, carrying the standard fields it also round-trips. */
|
|
12
|
+
export interface ICalEvent extends ICalendarEvent {
|
|
13
|
+
/** The VEVENT `UID`, if present. */
|
|
14
|
+
uid?: string;
|
|
15
|
+
/** The VEVENT `DESCRIPTION`, if present. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** The VEVENT `LOCATION`, if present. */
|
|
18
|
+
location?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Options for {@link toICalendar}. */
|
|
22
|
+
export interface ToICalendarOptions {
|
|
23
|
+
/** `PRODID` written to the calendar header. Default `-//super-calendar//EN`. */
|
|
24
|
+
prodId?: string;
|
|
25
|
+
/** The `DTSTAMP` stamped on every event (when it was written). Default: now. */
|
|
26
|
+
now?: Date;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const RRULE_FREQ: Record<string, RecurrenceFrequency> = {
|
|
30
|
+
DAILY: "daily",
|
|
31
|
+
WEEKLY: "weekly",
|
|
32
|
+
MONTHLY: "monthly",
|
|
33
|
+
YEARLY: "yearly",
|
|
34
|
+
};
|
|
35
|
+
const FREQ_RRULE: Record<RecurrenceFrequency, string> = {
|
|
36
|
+
daily: "DAILY",
|
|
37
|
+
weekly: "WEEKLY",
|
|
38
|
+
monthly: "MONTHLY",
|
|
39
|
+
yearly: "YEARLY",
|
|
40
|
+
};
|
|
41
|
+
// BYDAY tokens are Sunday-first, matching the library's 0=Sunday weekday index.
|
|
42
|
+
const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] as const;
|
|
43
|
+
|
|
44
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
|
45
|
+
|
|
46
|
+
// --- Text escaping (RFC 5545 §3.3.11) ------------------------------------
|
|
47
|
+
|
|
48
|
+
function escapeText(value: string): string {
|
|
49
|
+
return value
|
|
50
|
+
.replace(/\\/g, "\\\\")
|
|
51
|
+
.replace(/;/g, "\\;")
|
|
52
|
+
.replace(/,/g, "\\,")
|
|
53
|
+
.replace(/\n/g, "\\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unescapeText(value: string): string {
|
|
57
|
+
let out = "";
|
|
58
|
+
for (let i = 0; i < value.length; i++) {
|
|
59
|
+
const ch = value[i];
|
|
60
|
+
if (ch === "\\" && i + 1 < value.length) {
|
|
61
|
+
const next = value[++i];
|
|
62
|
+
out += next === "n" || next === "N" ? "\n" : next;
|
|
63
|
+
} else {
|
|
64
|
+
out += ch;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Line folding (RFC 5545 §3.1) ----------------------------------------
|
|
71
|
+
|
|
72
|
+
function foldLine(line: string): string {
|
|
73
|
+
if (line.length <= 75) return line;
|
|
74
|
+
const parts = [line.slice(0, 75)];
|
|
75
|
+
let rest = line.slice(75);
|
|
76
|
+
while (rest.length > 74) {
|
|
77
|
+
parts.push(` ${rest.slice(0, 74)}`);
|
|
78
|
+
rest = rest.slice(74);
|
|
79
|
+
}
|
|
80
|
+
parts.push(` ${rest}`);
|
|
81
|
+
return parts.join("\r\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Undo folding: a physical line beginning with a space or tab continues the one
|
|
85
|
+
// before it (with that leading whitespace removed).
|
|
86
|
+
function unfoldLines(text: string): string[] {
|
|
87
|
+
const raw = text.split(/\r\n|\r|\n/);
|
|
88
|
+
const out: string[] = [];
|
|
89
|
+
for (const line of raw) {
|
|
90
|
+
if ((line.startsWith(" ") || line.startsWith("\t")) && out.length) {
|
|
91
|
+
out[out.length - 1] += line.slice(1);
|
|
92
|
+
} else {
|
|
93
|
+
out.push(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Dates ---------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/** Serialize a Date as a UTC iCal date-time (`YYYYMMDDTHHMMSSZ`). */
|
|
102
|
+
function formatUtc(d: Date): string {
|
|
103
|
+
return (
|
|
104
|
+
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
|
105
|
+
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Serialize a Date as an iCal date-only value (`YYYYMMDD`) from its local parts. */
|
|
110
|
+
function formatDateOnly(d: Date): string {
|
|
111
|
+
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parse an iCal DATE / DATE-TIME value. `dateOnly` marks all-day (`VALUE=DATE`);
|
|
116
|
+
* `tzid` is the IANA zone from a `TZID=` param, used to resolve the local time to
|
|
117
|
+
* the correct UTC instant.
|
|
118
|
+
*/
|
|
119
|
+
function parseIcalDate(value: string, dateOnly: boolean, tzid?: string): Date {
|
|
120
|
+
const y = Number(value.slice(0, 4));
|
|
121
|
+
const mo = Number(value.slice(4, 6)) - 1;
|
|
122
|
+
const d = Number(value.slice(6, 8));
|
|
123
|
+
if (dateOnly || !value.includes("T")) return new Date(y, mo, d);
|
|
124
|
+
const h = Number(value.slice(9, 11));
|
|
125
|
+
const mi = Number(value.slice(11, 13));
|
|
126
|
+
const s = Number(value.slice(13, 15));
|
|
127
|
+
// Trailing Z → UTC.
|
|
128
|
+
if (value.endsWith("Z")) return new Date(Date.UTC(y, mo, d, h, mi, s));
|
|
129
|
+
// TZID → resolve the wall-clock time in that IANA zone to a real instant.
|
|
130
|
+
if (tzid) {
|
|
131
|
+
try {
|
|
132
|
+
return zonedTimeToUtc(new Date(Date.UTC(y, mo, d, h, mi, s)), tzid);
|
|
133
|
+
} catch {
|
|
134
|
+
// Unknown zone id: fall through to a floating/local time.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// No zone → a floating/local time.
|
|
138
|
+
return new Date(y, mo, d, h, mi, s);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Parse an iCal/ISO-8601 DURATION (e.g. `PT1H30M`, `P1D`, `P1W`) to milliseconds. */
|
|
142
|
+
function parseDuration(value: string): number | null {
|
|
143
|
+
const m = /^([+-]?)P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(
|
|
144
|
+
value.trim(),
|
|
145
|
+
);
|
|
146
|
+
if (!m || (!m[2] && !m[3] && !m[4] && !m[5] && !m[6])) return null;
|
|
147
|
+
const sign = m[1] === "-" ? -1 : 1;
|
|
148
|
+
const [w, d, h, mi, s] = [m[2], m[3], m[4], m[5], m[6]].map((x) => Number(x ?? 0));
|
|
149
|
+
return sign * ((((w * 7 + d) * 24 + h) * 60 + mi) * 60 + s) * 1000;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- RRULE ---------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function parseRRule(value: string): RecurrenceRule | undefined {
|
|
155
|
+
const parts = new Map<string, string>();
|
|
156
|
+
for (const pair of value.split(";")) {
|
|
157
|
+
const [k, v] = pair.split("=");
|
|
158
|
+
if (k && v) parts.set(k.toUpperCase(), v);
|
|
159
|
+
}
|
|
160
|
+
const freq = parts.get("FREQ");
|
|
161
|
+
if (!freq || !RRULE_FREQ[freq]) return undefined;
|
|
162
|
+
const rule: RecurrenceRule = { freq: RRULE_FREQ[freq] };
|
|
163
|
+
const interval = parts.get("INTERVAL");
|
|
164
|
+
if (interval) rule.interval = Number(interval);
|
|
165
|
+
const count = parts.get("COUNT");
|
|
166
|
+
if (count) rule.count = Number(count);
|
|
167
|
+
const until = parts.get("UNTIL");
|
|
168
|
+
if (until) rule.until = parseIcalDate(until, !until.includes("T"));
|
|
169
|
+
const byday = parts.get("BYDAY");
|
|
170
|
+
if (byday) {
|
|
171
|
+
const tokens = byday.split(",").map((t) => t.trim().toUpperCase());
|
|
172
|
+
const monthly = rule.freq === "monthly" || rule.freq === "yearly";
|
|
173
|
+
// An ordinal token (e.g. `3MO`, `-1FR`) on a monthly/yearly rule is an "Nth
|
|
174
|
+
// weekday" repeat; plain tokens (`MO`, `WE`) are the weekly weekday set.
|
|
175
|
+
const ordinal = /^([+-]?\d+)(SU|MO|TU|WE|TH|FR|SA)$/;
|
|
176
|
+
const plain: WeekStartsOn[] = [];
|
|
177
|
+
for (const token of tokens) {
|
|
178
|
+
const m = monthly ? ordinal.exec(token) : null;
|
|
179
|
+
if (m) {
|
|
180
|
+
const weekday = BYDAY.indexOf(m[2] as (typeof BYDAY)[number]);
|
|
181
|
+
if (weekday >= 0 && !rule.nthWeekday) {
|
|
182
|
+
rule.nthWeekday = { week: Number(m[1]), weekday: weekday as WeekStartsOn };
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const index = BYDAY.indexOf(token as (typeof BYDAY)[number]);
|
|
187
|
+
if (index >= 0) plain.push(index as WeekStartsOn);
|
|
188
|
+
}
|
|
189
|
+
if (plain.length) rule.weekdays = plain;
|
|
190
|
+
}
|
|
191
|
+
const bymonthday = parts.get("BYMONTHDAY");
|
|
192
|
+
if (bymonthday) {
|
|
193
|
+
const days = bymonthday
|
|
194
|
+
.split(",")
|
|
195
|
+
.map((d) => Number(d.trim()))
|
|
196
|
+
.filter((d) => Number.isInteger(d) && d !== 0);
|
|
197
|
+
if (days.length) rule.monthDays = days;
|
|
198
|
+
}
|
|
199
|
+
const bymonth = parts.get("BYMONTH");
|
|
200
|
+
if (bymonth) {
|
|
201
|
+
const months = bymonth
|
|
202
|
+
.split(",")
|
|
203
|
+
.map((m) => Number(m.trim()))
|
|
204
|
+
.filter((m) => Number.isInteger(m) && m >= 1 && m <= 12);
|
|
205
|
+
if (months.length) rule.months = months;
|
|
206
|
+
}
|
|
207
|
+
return rule;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function formatRRule(rule: RecurrenceRule): string {
|
|
211
|
+
const parts = [`FREQ=${FREQ_RRULE[rule.freq]}`];
|
|
212
|
+
if (rule.interval && rule.interval !== 1) parts.push(`INTERVAL=${rule.interval}`);
|
|
213
|
+
if (rule.count != null) parts.push(`COUNT=${rule.count}`);
|
|
214
|
+
if (rule.until) parts.push(`UNTIL=${formatUtc(rule.until)}`);
|
|
215
|
+
if (rule.nthWeekday) {
|
|
216
|
+
parts.push(`BYDAY=${rule.nthWeekday.week}${BYDAY[rule.nthWeekday.weekday]}`);
|
|
217
|
+
} else if (rule.weekdays?.length) {
|
|
218
|
+
parts.push(`BYDAY=${rule.weekdays.map((d) => BYDAY[d]).join(",")}`);
|
|
219
|
+
}
|
|
220
|
+
if (rule.monthDays?.length) parts.push(`BYMONTHDAY=${rule.monthDays.join(",")}`);
|
|
221
|
+
if (rule.months?.length) parts.push(`BYMONTH=${rule.months.join(",")}`);
|
|
222
|
+
return parts.join(";");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- Parse ---------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
interface RawLine {
|
|
228
|
+
name: string;
|
|
229
|
+
params: Map<string, string>;
|
|
230
|
+
value: string;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseLine(line: string): RawLine {
|
|
234
|
+
const colon = line.indexOf(":");
|
|
235
|
+
const head = colon === -1 ? line : line.slice(0, colon);
|
|
236
|
+
const value = colon === -1 ? "" : line.slice(colon + 1);
|
|
237
|
+
const [name, ...paramParts] = head.split(";");
|
|
238
|
+
const params = new Map<string, string>();
|
|
239
|
+
for (const p of paramParts) {
|
|
240
|
+
const eq = p.indexOf("=");
|
|
241
|
+
if (eq !== -1) params.set(p.slice(0, eq).toUpperCase(), p.slice(eq + 1));
|
|
242
|
+
}
|
|
243
|
+
return { name: name.toUpperCase(), params, value };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Parse an iCalendar (`.ics`) string into events. Reads every `VEVENT`; ignores
|
|
248
|
+
* VTODO/VJOURNAL/VTIMEZONE and unknown properties. Events without a usable
|
|
249
|
+
* `DTSTART` are skipped. All-day events (`VALUE=DATE`) with no `DTEND` get a
|
|
250
|
+
* one-day span.
|
|
251
|
+
*
|
|
252
|
+
* @example
|
|
253
|
+
* ```ts
|
|
254
|
+
* const events = parseICalendar(await file.text());
|
|
255
|
+
* ```
|
|
256
|
+
*/
|
|
257
|
+
export function parseICalendar(ics: string): ICalEvent[] {
|
|
258
|
+
const lines = unfoldLines(ics);
|
|
259
|
+
const events: ICalEvent[] = [];
|
|
260
|
+
let current: Partial<ICalEvent> | null = null;
|
|
261
|
+
let allDay = false;
|
|
262
|
+
// A VEVENT may carry DURATION instead of DTEND; resolve it once DTSTART is known.
|
|
263
|
+
let durationMs: number | null = null;
|
|
264
|
+
// EXDATE/RDATE(s) may appear before or after RRULE; collect, then attach at END.
|
|
265
|
+
let exdates: Date[] = [];
|
|
266
|
+
let rdates: Date[] = [];
|
|
267
|
+
|
|
268
|
+
for (const raw of lines) {
|
|
269
|
+
const line = parseLine(raw);
|
|
270
|
+
if (line.name === "BEGIN" && line.value === "VEVENT") {
|
|
271
|
+
current = {};
|
|
272
|
+
allDay = false;
|
|
273
|
+
durationMs = null;
|
|
274
|
+
exdates = [];
|
|
275
|
+
rdates = [];
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (line.name === "END" && line.value === "VEVENT") {
|
|
279
|
+
if (current?.start) {
|
|
280
|
+
if (!current.end && durationMs != null) {
|
|
281
|
+
current.end = new Date(current.start.getTime() + durationMs);
|
|
282
|
+
}
|
|
283
|
+
if (current.recurrence && exdates.length) current.recurrence.exdates = exdates;
|
|
284
|
+
if (current.recurrence && rdates.length) current.recurrence.rdates = rdates;
|
|
285
|
+
if (allDay) {
|
|
286
|
+
current.allDay = true;
|
|
287
|
+
// iCal all-day DTEND is exclusive; default to a one-day span.
|
|
288
|
+
if (!current.end) current.end = new Date(current.start.getTime() + 86_400_000);
|
|
289
|
+
} else if (!current.end) {
|
|
290
|
+
current.end = current.start;
|
|
291
|
+
}
|
|
292
|
+
events.push(current as ICalEvent);
|
|
293
|
+
}
|
|
294
|
+
current = null;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (!current) continue;
|
|
298
|
+
|
|
299
|
+
const dateOnly = line.params.get("VALUE") === "DATE";
|
|
300
|
+
const tzid = line.params.get("TZID");
|
|
301
|
+
switch (line.name) {
|
|
302
|
+
case "SUMMARY":
|
|
303
|
+
current.title = unescapeText(line.value);
|
|
304
|
+
break;
|
|
305
|
+
case "DESCRIPTION":
|
|
306
|
+
current.description = unescapeText(line.value);
|
|
307
|
+
break;
|
|
308
|
+
case "LOCATION":
|
|
309
|
+
current.location = unescapeText(line.value);
|
|
310
|
+
break;
|
|
311
|
+
case "UID":
|
|
312
|
+
current.uid = line.value;
|
|
313
|
+
break;
|
|
314
|
+
case "DTSTART":
|
|
315
|
+
current.start = parseIcalDate(line.value, dateOnly, tzid);
|
|
316
|
+
if (dateOnly) allDay = true;
|
|
317
|
+
break;
|
|
318
|
+
case "DTEND":
|
|
319
|
+
current.end = parseIcalDate(line.value, dateOnly, tzid);
|
|
320
|
+
break;
|
|
321
|
+
case "DURATION":
|
|
322
|
+
durationMs = parseDuration(line.value);
|
|
323
|
+
break;
|
|
324
|
+
case "EXDATE":
|
|
325
|
+
for (const v of line.value.split(",")) {
|
|
326
|
+
if (v) exdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
case "RDATE":
|
|
330
|
+
for (const v of line.value.split(",")) {
|
|
331
|
+
if (v) rdates.push(parseIcalDate(v, dateOnly || !v.includes("T"), tzid));
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
case "RRULE": {
|
|
335
|
+
const rule = parseRRule(line.value);
|
|
336
|
+
if (rule) current.recurrence = rule;
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return events;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// --- Serialize -----------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
function line(name: string, value: string): string {
|
|
347
|
+
return foldLine(`${name}:${value}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function stableUid(event: ICalEvent): string {
|
|
351
|
+
if (event.uid) return event.uid;
|
|
352
|
+
const title = (event.title ?? "event").replace(/\s+/g, "-");
|
|
353
|
+
return `${formatUtc(event.start)}-${title}@super-calendar`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Serialize events to an iCalendar (`.ics`) string. Timed events are written in
|
|
358
|
+
* UTC (`...Z`); all-day events (`allDay: true`) use `VALUE=DATE`. A `recurrence`
|
|
359
|
+
* becomes an `RRULE`, and `uid` / `description` / `location` round-trip.
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```ts
|
|
363
|
+
* const ics = toICalendar(events);
|
|
364
|
+
* ```
|
|
365
|
+
*/
|
|
366
|
+
export function toICalendar(events: ICalEvent[], options: ToICalendarOptions = {}): string {
|
|
367
|
+
const stamp = formatUtc(options.now ?? new Date());
|
|
368
|
+
const out: string[] = [
|
|
369
|
+
"BEGIN:VCALENDAR",
|
|
370
|
+
"VERSION:2.0",
|
|
371
|
+
line("PRODID", options.prodId ?? "-//super-calendar//EN"),
|
|
372
|
+
"CALSCALE:GREGORIAN",
|
|
373
|
+
];
|
|
374
|
+
|
|
375
|
+
for (const event of events) {
|
|
376
|
+
out.push("BEGIN:VEVENT");
|
|
377
|
+
out.push(line("UID", stableUid(event)));
|
|
378
|
+
out.push(line("DTSTAMP", stamp));
|
|
379
|
+
if (event.allDay) {
|
|
380
|
+
out.push(line("DTSTART;VALUE=DATE", formatDateOnly(event.start)));
|
|
381
|
+
out.push(line("DTEND;VALUE=DATE", formatDateOnly(event.end)));
|
|
382
|
+
} else {
|
|
383
|
+
out.push(line("DTSTART", formatUtc(event.start)));
|
|
384
|
+
out.push(line("DTEND", formatUtc(event.end)));
|
|
385
|
+
}
|
|
386
|
+
if (event.title) out.push(line("SUMMARY", escapeText(event.title)));
|
|
387
|
+
if (event.description) out.push(line("DESCRIPTION", escapeText(event.description)));
|
|
388
|
+
if (event.location) out.push(line("LOCATION", escapeText(event.location)));
|
|
389
|
+
if (event.recurrence) out.push(line("RRULE", formatRRule(event.recurrence)));
|
|
390
|
+
if (event.recurrence?.exdates?.length) {
|
|
391
|
+
out.push(
|
|
392
|
+
event.allDay
|
|
393
|
+
? line("EXDATE;VALUE=DATE", event.recurrence.exdates.map(formatDateOnly).join(","))
|
|
394
|
+
: line("EXDATE", event.recurrence.exdates.map(formatUtc).join(",")),
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if (event.recurrence?.rdates?.length) {
|
|
398
|
+
out.push(
|
|
399
|
+
event.allDay
|
|
400
|
+
? line("RDATE;VALUE=DATE", event.recurrence.rdates.map(formatDateOnly).join(","))
|
|
401
|
+
: line("RDATE", event.recurrence.rdates.map(formatUtc).join(",")),
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
out.push("END:VEVENT");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
out.push("END:VCALENDAR");
|
|
408
|
+
return out.join("\r\n");
|
|
409
|
+
}
|
package/src/utils/monthGrid.ts
CHANGED
|
@@ -54,10 +54,34 @@ export interface MonthGrid {
|
|
|
54
54
|
weekdays: MonthGridWeekday[];
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Weekday header label width: `narrow` ("M"), `short` ("Mon", the default), or
|
|
59
|
+
* `long` ("Monday").
|
|
60
|
+
*/
|
|
61
|
+
export type WeekdayFormat = "narrow" | "short" | "long";
|
|
62
|
+
|
|
63
|
+
/** date-fns tokens for each {@link WeekdayFormat}. */
|
|
64
|
+
const WEEKDAY_TOKENS: Record<WeekdayFormat, string> = {
|
|
65
|
+
narrow: "EEEEE",
|
|
66
|
+
short: "EEE",
|
|
67
|
+
long: "EEEE",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The date-fns format token for a {@link WeekdayFormat}. Exposed so a renderer
|
|
72
|
+
* that formats its own weekday header (rather than reading {@link buildMonthGrid})
|
|
73
|
+
* keeps the same mapping.
|
|
74
|
+
*/
|
|
75
|
+
export function weekdayFormatToken(format: WeekdayFormat): string {
|
|
76
|
+
return WEEKDAY_TOKENS[format];
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
/** Options for {@link buildMonthGrid} and {@link useMonthGrid}. */
|
|
58
80
|
export interface UseMonthGridOptions extends DateSelectionConstraints {
|
|
59
81
|
/** First day of the week. Sunday = 0 (default) … Saturday = 6. */
|
|
60
82
|
weekStartsOn?: WeekStartsOn;
|
|
83
|
+
/** Weekday header label width. Default `short` ("Mon"). */
|
|
84
|
+
weekdayFormat?: WeekdayFormat;
|
|
61
85
|
/** Always return six week rows for a fixed-height grid. Default false. */
|
|
62
86
|
showSixWeeks?: boolean;
|
|
63
87
|
/** Reverse each week's day order (right-to-left). Default false. */
|
|
@@ -78,6 +102,7 @@ export interface UseMonthGridOptions extends DateSelectionConstraints {
|
|
|
78
102
|
export function buildMonthGrid(month: Date, options: UseMonthGridOptions = {}): MonthGrid {
|
|
79
103
|
const {
|
|
80
104
|
weekStartsOn = 0,
|
|
105
|
+
weekdayFormat = "short",
|
|
81
106
|
showSixWeeks = false,
|
|
82
107
|
isRTL = false,
|
|
83
108
|
selectedDates,
|
|
@@ -113,7 +138,7 @@ export function buildMonthGrid(month: Date, options: UseMonthGridOptions = {}):
|
|
|
113
138
|
// Weekday labels depend only on the first row's dates (already ordered).
|
|
114
139
|
const weekdays: MonthGridWeekday[] = rows[0].map((date) => ({
|
|
115
140
|
date,
|
|
116
|
-
label: format(date,
|
|
141
|
+
label: format(date, WEEKDAY_TOKENS[weekdayFormat], { locale }),
|
|
117
142
|
}));
|
|
118
143
|
|
|
119
144
|
return { weeks, weekdays };
|
package/src/utils/recurrence.ts
CHANGED
|
@@ -24,6 +24,25 @@ function withTimeOf(date: Date, source: Date): Date {
|
|
|
24
24
|
return next;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The date of the `week`th `weekday` in a given month (`week` 1–5, or -1 for the
|
|
28
|
+
// last). Returns null when that week doesn't exist (e.g. a 5th Monday it lacks).
|
|
29
|
+
function nthWeekdayOfMonth(
|
|
30
|
+
year: number,
|
|
31
|
+
month: number,
|
|
32
|
+
week: number,
|
|
33
|
+
weekday: number,
|
|
34
|
+
): Date | null {
|
|
35
|
+
if (week === -1) {
|
|
36
|
+
const lastDay = new Date(year, month + 1, 0);
|
|
37
|
+
const back = (lastDay.getDay() - weekday + 7) % 7;
|
|
38
|
+
return new Date(year, month, lastDay.getDate() - back);
|
|
39
|
+
}
|
|
40
|
+
const firstWeekday = new Date(year, month, 1).getDay();
|
|
41
|
+
const day = 1 + ((weekday - firstWeekday + 7) % 7) + (week - 1) * 7;
|
|
42
|
+
const date = new Date(year, month, day);
|
|
43
|
+
return date.getMonth() === month ? date : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
// Occurrence start dates from `event.start` forward, in chronological order, up
|
|
28
47
|
// to `rangeEnd` (and the rule's own `count`/`until`).
|
|
29
48
|
function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): Generator<Date> {
|
|
@@ -55,6 +74,74 @@ function* occurrenceStarts(start: Date, rule: RecurrenceRule, rangeEnd: Date): G
|
|
|
55
74
|
}
|
|
56
75
|
}
|
|
57
76
|
|
|
77
|
+
if (rule.freq === "monthly" && rule.monthDays?.length) {
|
|
78
|
+
let year = start.getFullYear();
|
|
79
|
+
let month = start.getMonth();
|
|
80
|
+
while (true) {
|
|
81
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
82
|
+
// Resolve each configured day to a concrete date-of-month (negatives count
|
|
83
|
+
// from the end), drop days this month lacks, and emit in chronological order.
|
|
84
|
+
const days = [...new Set(rule.monthDays.map((d) => (d < 0 ? daysInMonth + d + 1 : d)))]
|
|
85
|
+
.filter((d) => d >= 1 && d <= daysInMonth)
|
|
86
|
+
.sort((a, b) => a - b);
|
|
87
|
+
for (const d of days) {
|
|
88
|
+
const date = withTimeOf(new Date(year, month, d), start);
|
|
89
|
+
if (date.getTime() < start.getTime()) continue; // before the first occurrence
|
|
90
|
+
if (!within(date)) return;
|
|
91
|
+
produced += 1;
|
|
92
|
+
yield date;
|
|
93
|
+
}
|
|
94
|
+
month += interval;
|
|
95
|
+
year += Math.floor(month / 12);
|
|
96
|
+
month = ((month % 12) + 12) % 12;
|
|
97
|
+
// No occurrence yielded yet but we've run past the range: stop.
|
|
98
|
+
if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if ((rule.freq === "monthly" || rule.freq === "yearly") && rule.nthWeekday) {
|
|
103
|
+
const { week, weekday } = rule.nthWeekday;
|
|
104
|
+
const stepMonths = rule.freq === "monthly" ? interval : 12 * interval;
|
|
105
|
+
let year = start.getFullYear();
|
|
106
|
+
let month = start.getMonth();
|
|
107
|
+
while (true) {
|
|
108
|
+
const day = nthWeekdayOfMonth(year, month, week, weekday);
|
|
109
|
+
if (day) {
|
|
110
|
+
const date = withTimeOf(day, start);
|
|
111
|
+
if (date.getTime() >= start.getTime()) {
|
|
112
|
+
if (!within(date)) return;
|
|
113
|
+
produced += 1;
|
|
114
|
+
yield date;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
month += stepMonths;
|
|
118
|
+
year += Math.floor(month / 12);
|
|
119
|
+
month = ((month % 12) + 12) % 12;
|
|
120
|
+
// No occurrence yielded yet but we've run past the range: stop.
|
|
121
|
+
if (new Date(year, month, 1).getTime() > rangeEnd.getTime()) return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (rule.freq === "yearly" && rule.months?.length) {
|
|
126
|
+
const day = start.getDate();
|
|
127
|
+
const months = [...new Set(rule.months)].filter((m) => m >= 1 && m <= 12).sort((a, b) => a - b);
|
|
128
|
+
let year = start.getFullYear();
|
|
129
|
+
while (true) {
|
|
130
|
+
for (const m of months) {
|
|
131
|
+
const month = m - 1;
|
|
132
|
+
// Skip a year whose listed month lacks the start's day (e.g. Feb 29).
|
|
133
|
+
if (day > new Date(year, month + 1, 0).getDate()) continue;
|
|
134
|
+
const date = withTimeOf(new Date(year, month, day), start);
|
|
135
|
+
if (date.getTime() < start.getTime()) continue; // before the first occurrence
|
|
136
|
+
if (!within(date)) return;
|
|
137
|
+
produced += 1;
|
|
138
|
+
yield date;
|
|
139
|
+
}
|
|
140
|
+
year += interval;
|
|
141
|
+
if (new Date(year, 0, 1).getTime() > rangeEnd.getTime()) return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
58
145
|
let cursor = start;
|
|
59
146
|
while (within(cursor)) {
|
|
60
147
|
produced += 1;
|
|
@@ -88,9 +175,23 @@ export function expandRecurringEvents<T>(
|
|
|
88
175
|
continue;
|
|
89
176
|
}
|
|
90
177
|
const durationMs = event.end.getTime() - event.start.getTime();
|
|
178
|
+
// Exception days (EXDATE): an occurrence landing on one of these is dropped.
|
|
179
|
+
const dayKey = (d: Date) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
|
180
|
+
const excluded = new Set((event.recurrence.exdates ?? []).map(dayKey));
|
|
181
|
+
// Union the rule's occurrences with any explicit RDATE additions, keyed by
|
|
182
|
+
// exact start time so a date the rule already produces isn't duplicated.
|
|
183
|
+
const starts = new Map<number, Date>();
|
|
91
184
|
for (const start of occurrenceStarts(event.start, event.recurrence, rangeEnd)) {
|
|
185
|
+
starts.set(start.getTime(), start);
|
|
186
|
+
}
|
|
187
|
+
for (const rdate of event.recurrence.rdates ?? []) {
|
|
188
|
+
if (rdate.getTime() <= rangeEnd.getTime()) starts.set(rdate.getTime(), rdate);
|
|
189
|
+
}
|
|
190
|
+
const ordered = [...starts.values()].sort((a, b) => a.getTime() - b.getTime());
|
|
191
|
+
for (const start of ordered) {
|
|
92
192
|
// Skip occurrences that end before the range opens, but keep iterating.
|
|
93
193
|
if (start.getTime() + durationMs < rangeStart.getTime()) continue;
|
|
194
|
+
if (excluded.has(dayKey(start))) continue;
|
|
94
195
|
out.push(instanceAt(event, start, durationMs));
|
|
95
196
|
}
|
|
96
197
|
}
|
package/src/utils/timezone.ts
CHANGED
|
@@ -40,6 +40,36 @@ export function toZonedTime(date: Date, timeZone: string): Date {
|
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// `timeZone`'s offset from UTC (ms) at `instant`: the zone's wall clock read as
|
|
44
|
+
// if it were UTC, minus the real instant.
|
|
45
|
+
function zoneOffsetMs(instant: Date, timeZone: string): number {
|
|
46
|
+
const z = toZonedTime(instant, timeZone);
|
|
47
|
+
const wallAsUtc = Date.UTC(
|
|
48
|
+
z.getFullYear(),
|
|
49
|
+
z.getMonth(),
|
|
50
|
+
z.getDate(),
|
|
51
|
+
z.getHours(),
|
|
52
|
+
z.getMinutes(),
|
|
53
|
+
z.getSeconds(),
|
|
54
|
+
z.getMilliseconds(),
|
|
55
|
+
);
|
|
56
|
+
return wallAsUtc - instant.getTime();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The inverse of {@link toZonedTime}: given a wall-clock time in `timeZone`,
|
|
61
|
+
* return the absolute UTC instant. Pass the wall clock as a `Date` whose **UTC**
|
|
62
|
+
* fields hold the components (e.g. `new Date(Date.UTC(y, m, d, h, min))`). Used to
|
|
63
|
+
* resolve iCal `TZID` times; DST-correct via a two-pass offset (ambiguous
|
|
64
|
+
* fall-back times resolve to the post-transition offset).
|
|
65
|
+
*/
|
|
66
|
+
export function zonedTimeToUtc(wallClock: Date, timeZone: string): Date {
|
|
67
|
+
const guess = wallClock.getTime();
|
|
68
|
+
const firstPass = zoneOffsetMs(new Date(guess), timeZone);
|
|
69
|
+
const secondPass = zoneOffsetMs(new Date(guess - firstPass), timeZone);
|
|
70
|
+
return new Date(guess - secondPass);
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/**
|
|
44
74
|
* Map every event's `start`/`end` through {@link toZonedTime} so the calendar
|
|
45
75
|
* displays them in `timeZone`. Other fields are preserved. Memoize the result
|