@craftguild/jscalendar 0.1.0 → 0.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.
Files changed (99) hide show
  1. package/README.md +98 -1
  2. package/dist/__tests__/builders.test.d.ts +1 -0
  3. package/dist/__tests__/builders.test.js +82 -0
  4. package/dist/__tests__/calendar-extra.test.js +36 -0
  5. package/dist/__tests__/recurrence.test.js +123 -0
  6. package/dist/__tests__/search.test.js +27 -0
  7. package/dist/__tests__/utils.test.js +3 -0
  8. package/dist/__tests__/validation.test.js +113 -0
  9. package/dist/ical.d.ts +6 -0
  10. package/dist/ical.js +71 -3
  11. package/dist/jscal/base.d.ts +90 -0
  12. package/dist/jscal/base.js +181 -0
  13. package/dist/jscal/builders.d.ts +135 -0
  14. package/dist/jscal/builders.js +220 -0
  15. package/dist/jscal/constants.d.ts +11 -0
  16. package/dist/jscal/constants.js +11 -0
  17. package/dist/jscal/datetime.d.ts +14 -0
  18. package/dist/jscal/datetime.js +42 -0
  19. package/dist/jscal/defaults.d.ts +31 -0
  20. package/dist/jscal/defaults.js +102 -0
  21. package/dist/jscal/duration.d.ts +43 -0
  22. package/dist/jscal/duration.js +72 -0
  23. package/dist/jscal/event.d.ts +17 -0
  24. package/dist/jscal/event.js +71 -0
  25. package/dist/jscal/group.d.ts +25 -0
  26. package/dist/jscal/group.js +62 -0
  27. package/dist/jscal/guards.d.ts +19 -0
  28. package/dist/jscal/guards.js +25 -0
  29. package/dist/jscal/ids.d.ts +11 -0
  30. package/dist/jscal/ids.js +77 -0
  31. package/dist/jscal/normalize.d.ts +32 -0
  32. package/dist/jscal/normalize.js +45 -0
  33. package/dist/jscal/task.d.ts +17 -0
  34. package/dist/jscal/task.js +60 -0
  35. package/dist/jscal/types.d.ts +38 -0
  36. package/dist/jscal/types.js +1 -0
  37. package/dist/jscal.d.ts +77 -70
  38. package/dist/jscal.js +77 -465
  39. package/dist/patch.d.ts +13 -0
  40. package/dist/patch.js +166 -41
  41. package/dist/recurrence/constants.d.ts +13 -0
  42. package/dist/recurrence/constants.js +13 -0
  43. package/dist/recurrence/date-utils.d.ts +125 -0
  44. package/dist/recurrence/date-utils.js +259 -0
  45. package/dist/recurrence/expand.d.ts +23 -0
  46. package/dist/recurrence/expand.js +294 -0
  47. package/dist/recurrence/rule-candidates.d.ts +21 -0
  48. package/dist/recurrence/rule-candidates.js +120 -0
  49. package/dist/recurrence/rule-generate.d.ts +11 -0
  50. package/dist/recurrence/rule-generate.js +36 -0
  51. package/dist/recurrence/rule-matchers.d.ts +34 -0
  52. package/dist/recurrence/rule-matchers.js +120 -0
  53. package/dist/recurrence/rule-normalize.d.ts +9 -0
  54. package/dist/recurrence/rule-normalize.js +57 -0
  55. package/dist/recurrence/rule-selectors.d.ts +7 -0
  56. package/dist/recurrence/rule-selectors.js +21 -0
  57. package/dist/recurrence/rules.d.ts +14 -0
  58. package/dist/recurrence/rules.js +57 -0
  59. package/dist/recurrence/types.d.ts +27 -0
  60. package/dist/recurrence/types.js +1 -0
  61. package/dist/recurrence.d.ts +2 -15
  62. package/dist/recurrence.js +1 -674
  63. package/dist/search.d.ts +30 -0
  64. package/dist/search.js +92 -8
  65. package/dist/timezones/chunk_1.d.ts +2 -0
  66. package/dist/timezones/chunk_1.js +72 -0
  67. package/dist/timezones/chunk_2.d.ts +2 -0
  68. package/dist/timezones/chunk_2.js +72 -0
  69. package/dist/timezones/chunk_3.d.ts +2 -0
  70. package/dist/timezones/chunk_3.js +72 -0
  71. package/dist/timezones/chunk_4.d.ts +2 -0
  72. package/dist/timezones/chunk_4.js +72 -0
  73. package/dist/timezones/chunk_5.d.ts +2 -0
  74. package/dist/timezones/chunk_5.js +72 -0
  75. package/dist/timezones/chunk_6.d.ts +2 -0
  76. package/dist/timezones/chunk_6.js +72 -0
  77. package/dist/timezones/chunk_7.d.ts +2 -0
  78. package/dist/timezones/chunk_7.js +6 -0
  79. package/dist/timezones.d.ts +5 -0
  80. package/dist/timezones.js +14 -3
  81. package/dist/utils.d.ts +72 -0
  82. package/dist/utils.js +85 -1
  83. package/dist/validate/asserts.d.ts +155 -0
  84. package/dist/validate/asserts.js +381 -0
  85. package/dist/validate/constants.d.ts +25 -0
  86. package/dist/validate/constants.js +33 -0
  87. package/dist/validate/error.d.ts +19 -0
  88. package/dist/validate/error.js +25 -0
  89. package/dist/validate/validators-common.d.ts +64 -0
  90. package/dist/validate/validators-common.js +385 -0
  91. package/dist/validate/validators-objects.d.ts +8 -0
  92. package/dist/validate/validators-objects.js +70 -0
  93. package/dist/validate/validators-recurrence.d.ts +15 -0
  94. package/dist/validate/validators-recurrence.js +115 -0
  95. package/dist/validate/validators.d.ts +1 -0
  96. package/dist/validate/validators.js +1 -0
  97. package/dist/validate.d.ts +2 -6
  98. package/dist/validate.js +2 -745
  99. package/package.json +1 -1
package/README.md CHANGED
@@ -62,13 +62,110 @@ const task = new JsCal.Task({
62
62
 
63
63
  ### Group
64
64
 
65
+ `entries` accepts either plain JSCalendar objects (including `eject()` results)
66
+ or `JsCal.Event`/`JsCal.Task` instances.
67
+
65
68
  ```ts
66
69
  const group = new JsCal.Group({
67
70
  title: "Project A",
68
- entries: [event.eject(), task.eject()],
71
+ entries: [event, task.eject()],
69
72
  });
70
73
  ```
71
74
 
75
+ ## Builder Helpers (Strict, Validated Inputs)
76
+
77
+ JSCalendar objects require `@type` fields for nested objects like
78
+ participants, locations, alerts, and recurrence rules. Requiring every
79
+ caller to manually specify `@type` is noisy and error-prone. To avoid
80
+ leaking RFC-specific details into app code, this library provides
81
+ **builder helpers** that:
82
+
83
+ - Fill in the correct `@type`
84
+ - Validate the result against RFC 8984 immediately
85
+
86
+ You can still pass strict plain JSCalendar objects directly (e.g., from
87
+ your database), but builders offer a safer, clearer option for app code.
88
+
89
+ ### Using builders (recommended for app code)
90
+
91
+ ```ts
92
+ const task = new JsCal.Task({
93
+ title: "Write report",
94
+ start: "2026-02-11T09:00:00",
95
+ participants: JsCal.participants([
96
+ JsCal.Participant({ name: "Alice", email: "a@example.com" }),
97
+ JsCal.Participant({ name: "Bob" }),
98
+ ]),
99
+ locations: JsCal.locations([
100
+ JsCal.Location({ name: "Room A" }),
101
+ ]),
102
+ alerts: JsCal.alerts([
103
+ JsCal.Alert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } }),
104
+ ]),
105
+ });
106
+ ```
107
+
108
+ ### Using strict plain objects (for DB/imported data)
109
+
110
+ ```ts
111
+ const task = new JsCal.Task({
112
+ title: "Imported task",
113
+ start: "2026-02-11T09:00:00",
114
+ participants: {
115
+ p1: { "@type": "Participant", name: "Alice", email: "a@example.com" },
116
+ },
117
+ locations: {
118
+ l1: { "@type": "Location", name: "Room A" },
119
+ },
120
+ alerts: {
121
+ a1: { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } },
122
+ },
123
+ });
124
+ ```
125
+
126
+ ### Validation behavior
127
+
128
+ Builders validate on creation, and constructors validate on object
129
+ creation. That means:
130
+
131
+ - Builder inputs are validated immediately
132
+ - `new JsCal.Event/Task/Group(...)` also validates the final object
133
+
134
+ This double validation is intentional for safety: even if data originates
135
+ from an untrusted or inconsistent source, you still get strict RFC 8984
136
+ checks at the point of object creation.
137
+
138
+ ## Ejecting to plain objects
139
+
140
+ `JsCal.Event`/`JsCal.Task` are class instances with helper methods and a
141
+ `data` field that stores the RFC 8984 object. `eject()` returns a deep
142
+ clone of that underlying JSCalendar object for serialization, storage,
143
+ or passing across app boundaries.
144
+
145
+ Why `eject()` exists:
146
+ - Class instances are convenient for building and mutating objects with
147
+ helpers like `update`, `patch`, and `addParticipant`.
148
+ - External APIs, storage layers, and JSON stringify expect plain objects.
149
+ - A deep clone makes it safe to hand off data without accidental mutation
150
+ from the original instance (and vice versa).
151
+
152
+ What changes after `eject()`:
153
+ - You lose helper methods; the result is just a plain JSCalendar object.
154
+ - Mutating the plain object does not affect the original instance.
155
+ - The instance can still be used and updated independently.
156
+
157
+ ```ts
158
+ const event = new JsCal.Event({ title: "Kickoff", start: "2026-02-02T10:00:00" });
159
+ const plain = event.eject();
160
+
161
+ // Plain object is ready for JSON / storage / network.
162
+ JSON.stringify(plain);
163
+
164
+ // Changes do not affect each other.
165
+ plain.title = "Exported";
166
+ event.update({ title: "Live" });
167
+ ```
168
+
72
169
  ## Updates and Mutations
73
170
 
74
171
  Mutation helpers update the underlying data and keep metadata such as
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JsCal } from "../jscal.js";
3
+ import { buildAlert, buildIdMap, buildLink, buildLocation, buildNDay, buildRecurrenceRule, buildRelation, buildTimeZone, buildTimeZoneMap, buildTimeZoneRule, buildVirtualLocation, } from "../jscal/builders.js";
4
+ describe("builders", () => {
5
+ it("builds strict objects with @type", () => {
6
+ const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
7
+ const location = buildLocation({ name: "Room A" });
8
+ const vloc = buildVirtualLocation({ name: "Zoom", uri: "https://example.com" });
9
+ const link = buildLink({ href: "https://example.com" });
10
+ const relation = buildRelation({ relation: { parent: true } });
11
+ const alert = buildAlert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } });
12
+ const nday = buildNDay({ day: "mo" });
13
+ const rule = buildRecurrenceRule({ frequency: "daily" });
14
+ const tzRule = buildTimeZoneRule({
15
+ start: "2026-01-01T00:00:00",
16
+ offsetFrom: "+09:00",
17
+ offsetTo: "+09:00",
18
+ });
19
+ const tz = buildTimeZone({
20
+ tzId: "Asia/Tokyo",
21
+ standard: [tzRule],
22
+ });
23
+ expect(participant["@type"]).toBe("Participant");
24
+ expect(location["@type"]).toBe("Location");
25
+ expect(vloc["@type"]).toBe("VirtualLocation");
26
+ expect(link["@type"]).toBe("Link");
27
+ expect(relation["@type"]).toBe("Relation");
28
+ expect(alert["@type"]).toBe("Alert");
29
+ expect(nday["@type"]).toBe("NDay");
30
+ expect(rule["@type"]).toBe("RecurrenceRule");
31
+ expect(tz["@type"]).toBe("TimeZone");
32
+ });
33
+ it("builds id maps with generated ids", () => {
34
+ const participants = JsCal.participants([
35
+ { name: "Alice", roles: { attendee: true } },
36
+ { name: "Bob", roles: { attendee: true } },
37
+ ]);
38
+ expect(Object.keys(participants).length).toBe(2);
39
+ const locations = JsCal.locations([{ name: "Room A" }]);
40
+ expect(Object.keys(locations).length).toBe(1);
41
+ const links = JsCal.links([{ href: "https://example.com" }]);
42
+ expect(Object.keys(links).length).toBe(1);
43
+ const related = JsCal.relatedTo([{ relation: { parent: true } }]);
44
+ expect(Object.keys(related).length).toBe(1);
45
+ });
46
+ it("builds a time zone map keyed by tzId", () => {
47
+ const map = buildTimeZoneMap([
48
+ { tzId: "Asia/Tokyo", standard: [{ "@type": "TimeZoneRule", start: "2026-01-01T00:00:00", offsetFrom: "+09:00", offsetTo: "+09:00" }] },
49
+ ]);
50
+ expect(map["Asia/Tokyo"]?.tzId).toBe("Asia/Tokyo");
51
+ });
52
+ it("buildIdMap uses a custom id function", () => {
53
+ const map = buildIdMap([{ name: "A" }], (item) => item, (_item, index) => `id-${index}`);
54
+ expect(Object.keys(map)).toEqual(["id-0"]);
55
+ });
56
+ it("throws when @type mismatches", () => {
57
+ // Intentionally bypass type safety to assert @type validation errors.
58
+ const bad = { "@type": "Location" };
59
+ expect(() => JsCal.Participant(bad)).toThrowError("participant: must have @type Participant");
60
+ });
61
+ it("throws for mismatched @type across builders", () => {
62
+ // Intentionally bypass type safety to assert @type validation errors.
63
+ expect(() => buildLocation({ "@type": "Participant" }))
64
+ .toThrowError("location: must have @type Location");
65
+ expect(() => buildVirtualLocation({ "@type": "Location" }))
66
+ .toThrowError("virtualLocation: must have @type VirtualLocation");
67
+ expect(() => buildAlert({ "@type": "Link" }))
68
+ .toThrowError("alert: must have @type Alert");
69
+ expect(() => buildRelation({ "@type": "Link" }))
70
+ .toThrowError("relation: must have @type Relation");
71
+ expect(() => buildLink({ "@type": "Relation" }))
72
+ .toThrowError("link: must have @type Link");
73
+ expect(() => buildTimeZone({ "@type": "Alert" }))
74
+ .toThrowError("timeZone: must have @type TimeZone");
75
+ expect(() => buildTimeZoneRule({ "@type": "TimeZone" }))
76
+ .toThrowError("timeZoneRule: must have @type TimeZoneRule");
77
+ expect(() => buildRecurrenceRule({ "@type": "NDay" }))
78
+ .toThrowError("recurrenceRule: must have @type RecurrenceRule");
79
+ expect(() => buildNDay({ "@type": "RecurrenceRule" }))
80
+ .toThrowError("nday: must have @type NDay");
81
+ });
82
+ });
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { JsCal, createId, isEvent, isGroup, isTask } from "../jscal.js";
3
+ import { Base } from "../jscal/base.js";
3
4
  const fixedNow = () => "2026-02-01T00:00:00Z";
4
5
  function makeEvent() {
5
6
  return new JsCal.Event({
@@ -14,6 +15,16 @@ describe("JsCal helpers", () => {
14
15
  group.addEntry(event.data);
15
16
  expect(group.data.entries.length).toBe(1);
16
17
  });
18
+ it("accepts JsCal instances and ejected entries in groups", () => {
19
+ const event = makeEvent();
20
+ const task = new JsCal.Task({ title: "Task", start: "2026-02-02T10:00:00" }, { now: fixedNow });
21
+ const group = new JsCal.Group({
22
+ entries: [event, task.eject()],
23
+ }, { now: fixedNow });
24
+ expect(group.data.entries.length).toBe(2);
25
+ expect(group.data.entries[0]?.["@type"]).toBe("Event");
26
+ expect(group.data.entries[1]?.["@type"]).toBe("Task");
27
+ });
17
28
  it("does not expose addEntry on non-group", () => {
18
29
  const event = makeEvent();
19
30
  expect("addEntry" in event).toBe(false);
@@ -79,6 +90,31 @@ describe("JsCal helpers", () => {
79
90
  json.title = "Changed";
80
91
  expect(event.data.title).toBe("Kickoff");
81
92
  });
93
+ it("clones Base instances with deep-copied data", () => {
94
+ const base = new Base({ "@type": "Event", uid: "base", updated: "2026-02-01T00:00:00Z", start: "2026-02-01T10:00:00" });
95
+ const cloned = base.clone();
96
+ cloned.data.uid = "changed";
97
+ expect(base.data.uid).toBe("base");
98
+ });
99
+ it("builds participant inputs without @type", () => {
100
+ const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
101
+ const task = new JsCal.Task({
102
+ start: "2026-02-02T10:00:00",
103
+ participants: { p1: participant },
104
+ });
105
+ expect(task.data.participants?.p1?.["@type"]).toBe("Participant");
106
+ });
107
+ it("builds participant maps with stable ids", () => {
108
+ const participants = JsCal.participants([
109
+ { name: "Alice", roles: { attendee: true } },
110
+ { name: "Bob", roles: { attendee: true } },
111
+ ]);
112
+ const task = new JsCal.Task({
113
+ start: "2026-02-02T10:00:00",
114
+ participants,
115
+ });
116
+ expect(Object.keys(task.data.participants ?? {}).length).toBe(2);
117
+ });
82
118
  it("createId returns base64url without padding", () => {
83
119
  const id = createId();
84
120
  expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
@@ -449,6 +449,129 @@ describe("recurrence expansion", () => {
449
449
  "2026-02-02T09:00:00",
450
450
  ]);
451
451
  });
452
+ it("uses implicit byMonthDay for monthly rules", () => {
453
+ const event = new JsCal.Event({
454
+ title: "Monthly Default",
455
+ start: "2026-02-10T09:00:00",
456
+ recurrenceRules: [
457
+ { "@type": "RecurrenceRule", frequency: "monthly", count: 2 },
458
+ ],
459
+ });
460
+ const occ = collect(JsCal.expandRecurrence([event], {
461
+ from: new Date("2026-02-01"),
462
+ to: new Date("2026-03-31"),
463
+ }));
464
+ const starts = occ.map((o) => o.recurrenceId);
465
+ expect(starts).toEqual([
466
+ "2026-02-10T09:00:00",
467
+ "2026-03-10T09:00:00",
468
+ ]);
469
+ });
470
+ it("adds byMonth defaults for yearly rules with byMonthDay", () => {
471
+ const event = new JsCal.Event({
472
+ title: "Yearly ByMonthDay",
473
+ start: "2026-02-01T09:00:00",
474
+ recurrenceRules: [
475
+ { "@type": "RecurrenceRule", frequency: "yearly", byMonthDay: [1], count: 2 },
476
+ ],
477
+ });
478
+ const occ = collect(JsCal.expandRecurrence([event], {
479
+ from: new Date("2026-02-01"),
480
+ to: new Date("2027-02-02"),
481
+ }));
482
+ const starts = occ.map((o) => o.recurrenceId);
483
+ expect(starts).toEqual([
484
+ "2026-02-01T09:00:00",
485
+ "2027-02-01T09:00:00",
486
+ ]);
487
+ });
488
+ it("adds byDay defaults for yearly byWeekNo rules", () => {
489
+ const event = new JsCal.Event({
490
+ title: "Week 1 Default Day",
491
+ start: "2026-01-01T09:00:00",
492
+ recurrenceRules: [
493
+ { "@type": "RecurrenceRule", frequency: "yearly", byWeekNo: [1], count: 1 },
494
+ ],
495
+ });
496
+ const occ = collect(JsCal.expandRecurrence([event], {
497
+ from: new Date("2026-01-01"),
498
+ to: new Date("2026-01-10"),
499
+ }));
500
+ const starts = occ.map((o) => o.recurrenceId);
501
+ expect(starts).toEqual([
502
+ "2026-01-01T09:00:00",
503
+ ]);
504
+ });
505
+ it("supports negative bySetPosition values", () => {
506
+ const event = new JsCal.Event({
507
+ title: "Last Wednesday",
508
+ start: "2026-01-07T10:00:00",
509
+ recurrenceRules: [
510
+ {
511
+ "@type": "RecurrenceRule",
512
+ frequency: "monthly",
513
+ byDay: [{ "@type": "NDay", day: "we" }],
514
+ bySetPosition: [-1],
515
+ },
516
+ ],
517
+ });
518
+ const occ = collect(JsCal.expandRecurrence([event], {
519
+ from: new Date("2026-01-01"),
520
+ to: new Date("2026-03-31"),
521
+ }));
522
+ const starts = occ.map((o) => o.recurrenceId);
523
+ expect(starts).toEqual([
524
+ "2026-01-07T10:00:00",
525
+ "2026-01-28T10:00:00",
526
+ "2026-02-25T10:00:00",
527
+ "2026-03-25T10:00:00",
528
+ ]);
529
+ });
530
+ it("expands tasks that only have due dates", () => {
531
+ const task = new JsCal.Task({
532
+ title: "Due Only",
533
+ due: "2026-02-01T10:00:00",
534
+ recurrenceRules: [
535
+ { "@type": "RecurrenceRule", frequency: "daily", count: 2 },
536
+ ],
537
+ recurrenceOverrides: {
538
+ "2026-02-02T10:00:00": { "/due": "2026-02-05T10:00:00" },
539
+ },
540
+ });
541
+ const occ = Array.from(JsCal.expandRecurrence([task], {
542
+ from: new Date("2026-02-01"),
543
+ to: new Date("2026-02-03"),
544
+ }));
545
+ expect(occ.map((o) => o.recurrenceId)).toEqual([
546
+ "2026-02-01T10:00:00",
547
+ "2026-02-02T10:00:00",
548
+ ]);
549
+ // Intentional cast to Task for test-only access to due.
550
+ const second = occ[1];
551
+ expect(second?.due).toBe("2026-02-05T10:00:00");
552
+ });
553
+ it("skips tasks without start or due dates", () => {
554
+ const task = new JsCal.Task({ title: "No Dates" });
555
+ const occ = Array.from(JsCal.expandRecurrence([task], {
556
+ from: new Date("2026-02-01"),
557
+ to: new Date("2026-02-02"),
558
+ }));
559
+ expect(occ.length).toBe(0);
560
+ });
561
+ it("throws on unsupported rscale during expansion", () => {
562
+ const event = new JsCal.Event({
563
+ title: "Bad Rscale",
564
+ start: "2026-02-01T10:00:00",
565
+ recurrenceRules: [
566
+ { "@type": "RecurrenceRule", frequency: "daily", rscale: "hebrew" },
567
+ ],
568
+ }, { validate: false });
569
+ expect(() => {
570
+ for (const _ of JsCal.expandRecurrence([event], { from: new Date("2026-02-01"), to: new Date("2026-02-02") })) {
571
+ void 0;
572
+ }
573
+ }).toThrow("Unsupported rscale");
574
+ });
452
575
  it("pages recurrence expansion with cursor and limit", () => {
453
576
  const event = new JsCal.Event({
454
577
  title: "Weekly",
@@ -84,6 +84,33 @@ describe("search helpers", () => {
84
84
  throw new Error("Missing result");
85
85
  expect(first.uid).toBe("e1");
86
86
  });
87
+ it("handles Date range for UTC events without time zones", () => {
88
+ const utcEvent = {
89
+ "@type": "Event",
90
+ uid: "eUtc",
91
+ updated: "2026-02-01T00:00:00Z",
92
+ start: "2026-02-01T10:00:00Z",
93
+ duration: "PT1H",
94
+ };
95
+ const results = filterByDateRange([utcEvent], {
96
+ start: new Date("2026-02-01T09:30:00Z"),
97
+ end: new Date("2026-02-01T10:30:00Z"),
98
+ });
99
+ expect(results.map((item) => item.uid)).toEqual(["eUtc"]);
100
+ });
101
+ it("handles Date range for UTC tasks without time zones", () => {
102
+ const utcTask = {
103
+ "@type": "Task",
104
+ uid: "tUtc",
105
+ updated: "2026-02-01T00:00:00Z",
106
+ start: "2026-02-01T10:00:00Z",
107
+ };
108
+ const results = filterByDateRange([utcTask], {
109
+ start: new Date("2026-02-01T09:30:00Z"),
110
+ end: new Date("2026-02-01T10:30:00Z"),
111
+ });
112
+ expect(results.map((item) => item.uid)).toEqual(["tUtc"]);
113
+ });
87
114
  it("uses JsCal.groupByType wrapper", () => {
88
115
  const grouped = JsCal.groupByType([event, task]);
89
116
  expect(Object.keys(grouped)).toContain("Event");
@@ -25,6 +25,9 @@ describe("utils", () => {
25
25
  expect(compareDateTime("2026-02-01T00:00:00Z", "2026-02-01T01:00:00Z")).toBe(-1);
26
26
  expect(compareDateTime("2026-02-01T01:00:00Z", "2026-02-01T00:00:00Z")).toBe(1);
27
27
  });
28
+ it("returns null for invalid UTC date-times", () => {
29
+ expect(compareDateTime("2026-99-99T00:00:00Z", "2026-02-01T00:00:00Z")).toBeNull();
30
+ });
28
31
  it("compares local date-times lexicographically", () => {
29
32
  expect(compareDateTime("2026-02-01T00:00:00", "2026-02-01T00:00:00")).toBe(0);
30
33
  expect(compareDateTime("2026-02-01T00:00:00", "2026-02-02T00:00:00")).toBe(-1);
@@ -16,6 +16,12 @@ describe("validation", () => {
16
16
  descriptionContentType: "application/json",
17
17
  })).toThrowError("object.descriptionContentType: must be a text/* media type");
18
18
  });
19
+ it("rejects non-utf8 charset parameters", () => {
20
+ expect(() => new JsCal.Event({
21
+ start: "2026-02-01T10:00:00",
22
+ descriptionContentType: "text/plain; charset=ascii",
23
+ })).toThrowError("object.descriptionContentType: charset parameter must be utf-8");
24
+ });
19
25
  it("allows validation to be disabled for create, update, and patch", () => {
20
26
  const event = new JsCal.Event({ start: "2026-02-01T10:00:00Z" }, { validate: false });
21
27
  expect(event.get("start")).toBe("2026-02-01T10:00:00Z");
@@ -62,6 +68,113 @@ describe("validation", () => {
62
68
  },
63
69
  })).toThrowError("object.locations.bad id: must use base64url characters");
64
70
  });
71
+ it("rejects invalid content-id formats", () => {
72
+ expect(() => new JsCal.Event({
73
+ start: "2026-02-01T10:00:00",
74
+ links: {
75
+ l1: { "@type": "Link", href: "https://example.com", cid: "<a@example.com" },
76
+ },
77
+ })).toThrowError("object.links.l1.cid: must use matching angle brackets");
78
+ });
79
+ it("rejects boolean map entries that are not true", () => {
80
+ expect(() => new JsCal.Event({
81
+ start: "2026-02-01T10:00:00",
82
+ participants: {
83
+ // Intentionally violate the BooleanMap type to assert validation errors.
84
+ p1: { "@type": "Participant", roles: { attendee: false } },
85
+ },
86
+ })).toThrowError("object.participants.p1.roles.attendee: must be true");
87
+ });
88
+ it("rejects invalid time zone ids", () => {
89
+ expect(() => new JsCal.Event({
90
+ start: "2026-02-01T10:00:00",
91
+ // Intentionally bypass TimeZoneInput typing to test invalid IDs.
92
+ timeZone: "Mars/Phobos",
93
+ })).toThrowError("Unknown time zone: Mars/Phobos");
94
+ });
95
+ it("rejects invalid recurrence rule values", () => {
96
+ expect(() => new JsCal.Event({
97
+ start: "2026-02-01T10:00:00",
98
+ recurrenceRules: [
99
+ { "@type": "RecurrenceRule", frequency: "daily", byMonthDay: [0] },
100
+ ],
101
+ })).toThrowError("object.recurrenceRules[0].byMonthDay[0]: must be an integer between -31 and 31, excluding 0");
102
+ });
103
+ it("accepts extended common fields and nested validation", () => {
104
+ const event = new JsCal.Event({
105
+ start: "2026-02-01T10:00:00",
106
+ method: "publish",
107
+ keywords: { sprint: true },
108
+ categories: { meeting: true },
109
+ relatedTo: {
110
+ rel1: { "@type": "Relation", relation: { parent: true } },
111
+ },
112
+ replyTo: {
113
+ "mailto:team@example.com": "mailto:team@example.com",
114
+ },
115
+ locations: {
116
+ loc1: {
117
+ "@type": "Location",
118
+ name: "Room A",
119
+ links: {
120
+ link1: { "@type": "Link", href: "https://example.com" },
121
+ },
122
+ },
123
+ },
124
+ virtualLocations: {
125
+ v1: {
126
+ "@type": "VirtualLocation",
127
+ uri: "https://example.com",
128
+ features: { chat: true },
129
+ },
130
+ },
131
+ participants: {
132
+ p1: {
133
+ "@type": "Participant",
134
+ roles: { attendee: true },
135
+ sendTo: { imap: "mailto:a@example.com" },
136
+ kind: "individual",
137
+ },
138
+ },
139
+ alerts: {
140
+ a1: {
141
+ "@type": "Alert",
142
+ trigger: { "@type": "AbsoluteTrigger", when: "2026-02-01T00:00:00Z" },
143
+ },
144
+ },
145
+ localizations: {
146
+ en: { title: "Localized", keywords: ["a"] },
147
+ },
148
+ recurrenceOverrides: {
149
+ "2026-02-02T10:00:00": { title: null, locations: ["x"] },
150
+ },
151
+ timeZones: {
152
+ "Asia/Tokyo": {
153
+ "@type": "TimeZone",
154
+ tzId: "Asia/Tokyo",
155
+ aliases: { JST: true },
156
+ standard: [
157
+ {
158
+ "@type": "TimeZoneRule",
159
+ start: "2026-01-01T00:00:00",
160
+ offsetFrom: "+09:00",
161
+ offsetTo: "+09:00",
162
+ comments: ["note"],
163
+ },
164
+ ],
165
+ daylight: [
166
+ {
167
+ "@type": "TimeZoneRule",
168
+ start: "2026-06-01T00:00:00",
169
+ offsetFrom: "+09:00",
170
+ offsetTo: "+10:00",
171
+ },
172
+ ],
173
+ },
174
+ },
175
+ });
176
+ expect(event.get("method")).toBe("publish");
177
+ });
65
178
  it("rejects non-gregorian rscale values", () => {
66
179
  expect(() => new JsCal.Event({
67
180
  start: "2026-02-01T10:00:00",
package/dist/ical.d.ts CHANGED
@@ -4,4 +4,10 @@ export type ICalOptions = {
4
4
  method?: string;
5
5
  includeXJSCalendar?: boolean;
6
6
  };
7
+ /**
8
+ * Convert JSCalendar objects into an iCalendar string.
9
+ * @param objects JSCalendar objects to export.
10
+ * @param options Export options.
11
+ * @return iCalendar text.
12
+ */
7
13
  export declare function toICal(objects: JSCalendarObject[], options?: ICalOptions): string;