@craftguild/jscalendar 0.1.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 (45) hide show
  1. package/LICENSE.md +7 -0
  2. package/README.md +295 -0
  3. package/dist/__tests__/calendar-extra.test.d.ts +1 -0
  4. package/dist/__tests__/calendar-extra.test.js +185 -0
  5. package/dist/__tests__/calendar.test.d.ts +1 -0
  6. package/dist/__tests__/calendar.test.js +104 -0
  7. package/dist/__tests__/ical-extra.test.d.ts +1 -0
  8. package/dist/__tests__/ical-extra.test.js +87 -0
  9. package/dist/__tests__/ical.test.d.ts +1 -0
  10. package/dist/__tests__/ical.test.js +72 -0
  11. package/dist/__tests__/index.test.d.ts +1 -0
  12. package/dist/__tests__/index.test.js +9 -0
  13. package/dist/__tests__/patch.test.d.ts +1 -0
  14. package/dist/__tests__/patch.test.js +47 -0
  15. package/dist/__tests__/recurrence.test.d.ts +1 -0
  16. package/dist/__tests__/recurrence.test.js +498 -0
  17. package/dist/__tests__/search.test.d.ts +1 -0
  18. package/dist/__tests__/search.test.js +237 -0
  19. package/dist/__tests__/timezones.test.d.ts +1 -0
  20. package/dist/__tests__/timezones.test.js +12 -0
  21. package/dist/__tests__/utils.test.d.ts +1 -0
  22. package/dist/__tests__/utils.test.js +116 -0
  23. package/dist/__tests__/validation.test.d.ts +1 -0
  24. package/dist/__tests__/validation.test.js +91 -0
  25. package/dist/ical.d.ts +7 -0
  26. package/dist/ical.js +202 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.js +2 -0
  29. package/dist/jscal.d.ts +129 -0
  30. package/dist/jscal.js +504 -0
  31. package/dist/patch.d.ts +5 -0
  32. package/dist/patch.js +91 -0
  33. package/dist/recurrence.d.ts +15 -0
  34. package/dist/recurrence.js +674 -0
  35. package/dist/search.d.ts +14 -0
  36. package/dist/search.js +208 -0
  37. package/dist/timezones.d.ts +4 -0
  38. package/dist/timezones.js +441 -0
  39. package/dist/types.d.ts +219 -0
  40. package/dist/types.js +1 -0
  41. package/dist/utils.d.ts +10 -0
  42. package/dist/utils.js +80 -0
  43. package/dist/validate.d.ts +6 -0
  44. package/dist/validate.js +745 -0
  45. package/package.json +33 -0
package/LICENSE.md ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Progressive Works, Inc.
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,295 @@
1
+ # RFC 8984 (JSCalendar) TypeScript Library
2
+
3
+ This library provides a thin, practical TypeScript API for working with
4
+ RFC 8984 (JSCalendar) objects. It focuses on creation, mutation, search,
5
+ and export. It does **not** implement a calendar application or server.
6
+ The goal is to keep the data model easy to use in web apps and CLIs while
7
+ preserving access to the full RFC object structure when you need it.
8
+
9
+ Primary object types are **Event**, **Task**, and **Group**. A **Group**
10
+ acts as a container when you want to bundle multiple objects. The API is
11
+ intentionally small: you create objects, mutate them with safe helpers,
12
+ query them with search utilities, and export them to iCalendar as needed.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pnpm add @craftguild/jscalendar
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```ts
23
+ import { JsCal } from "@craftguild/jscalendar";
24
+
25
+ const event = new JsCal.Event({
26
+ title: "Kickoff",
27
+ start: "2026-02-03T09:00:00",
28
+ timeZone: "America/New_York",
29
+ duration: "PT1H",
30
+ });
31
+
32
+ const items = [event.eject()];
33
+ ```
34
+
35
+ ## Object Creation
36
+
37
+ Objects are created with `new JsCal.Event`, `new JsCal.Task`, and
38
+ `new JsCal.Group`. Each constructor accepts a plain JSCalendar-like
39
+ object and normalizes required fields (e.g., `uid`, `updated`).
40
+
41
+ ### Event
42
+
43
+ ```ts
44
+ const event = new JsCal.Event({
45
+ title: "Team Sync",
46
+ start: "2026-02-10T10:00:00",
47
+ timeZone: "America/New_York",
48
+ duration: "PT30M",
49
+ });
50
+ ```
51
+
52
+ ### Task
53
+
54
+ ```ts
55
+ const task = new JsCal.Task({
56
+ title: "Write report",
57
+ start: "2026-02-11T09:00:00",
58
+ due: "2026-02-11T17:00:00",
59
+ percentComplete: 10,
60
+ });
61
+ ```
62
+
63
+ ### Group
64
+
65
+ ```ts
66
+ const group = new JsCal.Group({
67
+ title: "Project A",
68
+ entries: [event.eject(), task.eject()],
69
+ });
70
+ ```
71
+
72
+ ## Updates and Mutations
73
+
74
+ Mutation helpers update the underlying data and keep metadata such as
75
+ `updated` and `sequence` consistent. Use `update` for shallow updates
76
+ and `patch` for RFC 8984 PatchObject semantics.
77
+
78
+ ```ts
79
+ event.update({ title: "Updated title" });
80
+ event.patch({ title: "Patched title" });
81
+
82
+ event.addLocation({ name: "Room A" });
83
+ event.addVirtualLocation({ name: "Zoom", uri: "https://example.com" });
84
+ event.addParticipant({ roles: { attendee: true }, email: "a@example.com" });
85
+ event.addAlert({
86
+ trigger: { "@type": "AbsoluteTrigger", when: "2026-02-10T09:00:00Z" },
87
+ });
88
+ ```
89
+
90
+ ## Date Inputs and Time Zones
91
+
92
+ Date fields accept either RFC 8984 strings or JavaScript `Date`. When a
93
+ `Date` is given, `start`/`due` are converted to LocalDateTime strings and
94
+ `updated`/`created` are converted to UTCDateTime (`Z` suffix). If a
95
+ `timeZone` is set, it is normalized through the bundled time zone list.
96
+
97
+ `start`, `due`, `updated`, and `created` accept `string` or `Date`.
98
+
99
+ ```ts
100
+ const eventFromDate = new JsCal.Event({
101
+ title: "Kickoff",
102
+ start: new Date(),
103
+ });
104
+
105
+ const eventWithSeconds = new JsCal.Event({
106
+ title: "Kickoff",
107
+ start: new Date(),
108
+ duration: 90 * 60, // seconds
109
+ });
110
+
111
+ const eventWithZone = new JsCal.Event({
112
+ title: "Kickoff",
113
+ start: new Date(),
114
+ timeZone: JsCal.timeZone("asia/tokyo"), // => Asia/Tokyo
115
+ });
116
+ ```
117
+
118
+ ## Utility Methods
119
+
120
+ All utilities are available on `JsCal`.
121
+
122
+ The search helpers operate on JSCalendar objects or `JsCal` instances.
123
+ They are intended for lightweight filtering and text matching in
124
+ application code. For more complex queries, use your datastore’s
125
+ query engine or build a custom index.
126
+
127
+ ```ts
128
+ const results = JsCal.filterByDateRange(items, {
129
+ start: "2026-02-01T00:00:00Z",
130
+ end: "2026-02-28T23:59:59Z",
131
+ });
132
+
133
+ const found = JsCal.findByUid(items, "uid-123");
134
+ const byType = JsCal.filterByType(items, "Event");
135
+ const grouped = JsCal.groupByType(items);
136
+ const textHits = JsCal.filterByText(items, "meeting");
137
+
138
+ const uid = JsCal.createUid();
139
+ const id = JsCal.createId();
140
+
141
+ const d1 = JsCal.duration.minutes(90); // PT1H30M
142
+ const d2 = JsCal.duration.from({ hours: 1, minutes: 15 }); // PT1H15M
143
+
144
+ const tz = JsCal.timeZone("asia/tokyo"); // => Asia/Tokyo
145
+ const tzList = JsCal.timeZones;
146
+ ```
147
+
148
+ ## Recurrence Expansion
149
+
150
+ The recurrence expansion API is a generator.
151
+
152
+ Expansion follows RFC 8984 semantics for recurrence rules, including
153
+ overrides and exclusions. The output instances contain `recurrenceId`
154
+ and preserve the base object’s data unless a patch modifies fields.
155
+
156
+ ```ts
157
+ for (const occ of JsCal.expandRecurrence(
158
+ [event],
159
+ { from: new Date("2026-02-01"), to: new Date("2026-03-01") }
160
+ )) {
161
+ console.log(occ);
162
+ }
163
+ ```
164
+
165
+ ### Paged Expansion (for infinite scroll)
166
+
167
+ `expandRecurrencePaged` wraps the generator and provides a cursor-based
168
+ paging interface. This is convenient when rendering a list with virtual
169
+ scrolling or an infinite feed.
170
+
171
+ ```ts
172
+ const page1 = JsCal.expandRecurrencePaged(
173
+ [event],
174
+ { from: new Date("2026-02-01"), to: new Date("2026-03-01") },
175
+ { limit: 50 }
176
+ );
177
+
178
+ const page2 = JsCal.expandRecurrencePaged(
179
+ [event],
180
+ { from: new Date("2026-02-01"), to: new Date("2026-03-01") },
181
+ { limit: 50, cursor: page1.nextCursor }
182
+ );
183
+ ```
184
+
185
+ ## Time Zone Aware Filtering (Date range)
186
+
187
+ If `timeZone` is set, `Date` inputs are converted to that time zone
188
+ via `date-fns-tz` before comparison.
189
+
190
+ This ensures that “wall-clock time” comparisons respect the event’s
191
+ time zone, including DST transitions, as long as the event defines
192
+ `timeZone`. Floating LocalDateTime values without `timeZone` are
193
+ compared as simple strings.
194
+
195
+ ```ts
196
+ const event = new JsCal.Event({
197
+ start: "2026-02-01T10:00:00",
198
+ timeZone: "Asia/Tokyo",
199
+ });
200
+
201
+ // 2026-02-01T01:00:00Z is 10:00 in Asia/Tokyo
202
+ const results = JsCal.filterByDateRange(
203
+ [event],
204
+ { start: new Date("2026-02-01T01:00:00Z"), end: new Date("2026-02-01T01:00:00Z") }
205
+ );
206
+ console.log(results.length); // 1
207
+ ```
208
+
209
+ ## iCalendar Export
210
+
211
+ Export uses a minimal mapping from JSCalendar to iCalendar. Any fields
212
+ that cannot be represented are preserved in `X-JSCALENDAR` to avoid data
213
+ loss. This keeps exports reversible while staying compatible with most
214
+ consumers.
215
+
216
+ ```ts
217
+ // includeXJSCalendar defaults to true.
218
+ const ical = JsCal.toICal([event]);
219
+ const icalNoX = JsCal.toICal([event], { includeXJSCalendar: false });
220
+ console.log(ical);
221
+
222
+ const icalMany = JsCal.toICal([event, task], { includeXJSCalendar: true });
223
+ console.log(icalMany);
224
+ ```
225
+
226
+ ## Practical Example
227
+
228
+ **Weekly engineering sync (every Wednesday 10:30, 1 hour)**
229
+
230
+ ```ts
231
+ const weekly = new JsCal.Event({
232
+ title: "Engineering Sync",
233
+ start: "2026-02-04T10:30:00",
234
+ timeZone: "Asia/Tokyo",
235
+ duration: JsCal.duration.hours(1),
236
+ recurrenceRules: [
237
+ {
238
+ "@type": "RecurrenceRule",
239
+ frequency: "weekly",
240
+ byDay: [{ "@type": "NDay", day: "we" }],
241
+ },
242
+ ],
243
+ });
244
+ ```
245
+
246
+ ## Compliance and Deviations
247
+
248
+ ### RFC 8984 Conformance (Implemented)
249
+
250
+ These points are implemented directly as specified in RFC 8984 and are
251
+ covered by tests.
252
+
253
+ - Core JSCalendar object model (Event / Task / Group) as TypeScript types.
254
+ - Recurrence rules and overrides (RRULE/EXRULE semantics in RFC 8984).
255
+ - Default values for fields defined by RFC 8984 (e.g., `sequence`, `priority`, `freeBusyStatus`, etc.).
256
+ - LocalDateTime and UTCDateTime handling with explicit types.
257
+ - iCalendar export with `X-JSCALENDAR` for data preservation.
258
+
259
+ ### Deviations / Partial Implementations
260
+
261
+ This library is designed to be practical and lightweight, not a full RFC engine.
262
+
263
+ The items below are the known deltas between a strict RFC implementation
264
+ and this library’s behavior.
265
+
266
+ - **rscale**: only `gregorian` is supported; any other value throws.
267
+ - **Validation**: strict type/format validation is enforced by default (RFC-style date/time and duration rules),
268
+ but can be disabled with `{ validate: false }` in create/update/patch.
269
+ - **Time zone and DST**:
270
+ - Range filtering and recurrence comparisons use `date-fns-tz`.
271
+ - Recurrence generation still operates on LocalDateTime arithmetic and does not fully normalize
272
+ DST gaps/overlaps into canonical UTC instants for all cases.
273
+ - **iCalendar export**: minimal mapping + `X-JSCALENDAR`; no VTIMEZONE generation.
274
+
275
+ If you require strict, formal compliance, please treat this as a foundation
276
+ and extend the validation/export behavior in your own application.
277
+
278
+ ## Reference Materials
279
+
280
+ - RFC 8984: JSCalendar specification.
281
+ - RFC 7529: Non-Gregorian recurrence rules used by JSCalendar (rscale).
282
+ - Time zone identifiers: IANA Time Zone Database (tzdata), using Area/Location IDs
283
+ (e.g., America/New_York, Asia/Tokyo).
284
+
285
+ ## Code of Conduct
286
+
287
+ See `CODE_OF_CONDUCT.md`.
288
+
289
+ ## Contributing
290
+
291
+ See `CONTRIBUTING.md`.
292
+
293
+ ## License
294
+
295
+ See `LICENSE.md`.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,185 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JsCal, createId, isEvent, isGroup, isTask } from "../jscal.js";
3
+ const fixedNow = () => "2026-02-01T00:00:00Z";
4
+ function makeEvent() {
5
+ return new JsCal.Event({
6
+ title: "Kickoff",
7
+ start: "2026-02-02T10:00:00",
8
+ }, { now: fixedNow });
9
+ }
10
+ describe("JsCal helpers", () => {
11
+ it("creates group and adds entry", () => {
12
+ const group = new JsCal.Group({ entries: [] }, { now: fixedNow });
13
+ const event = makeEvent();
14
+ group.addEntry(event.data);
15
+ expect(group.data.entries.length).toBe(1);
16
+ });
17
+ it("does not expose addEntry on non-group", () => {
18
+ const event = makeEvent();
19
+ expect("addEntry" in event).toBe(false);
20
+ });
21
+ it("adds locations and virtual locations", () => {
22
+ const event = makeEvent();
23
+ const locId = event.addLocation({ name: "Room A" });
24
+ const vlocId = event.addVirtualLocation({ name: "Zoom", uri: "https://example.com" });
25
+ const locations = event.data.locations;
26
+ const virtualLocations = event.data.virtualLocations;
27
+ expect(locations).toBeDefined();
28
+ expect(virtualLocations).toBeDefined();
29
+ if (!locations || !virtualLocations)
30
+ throw new Error("Missing locations");
31
+ expect(locations[locId]?.name).toBe("Room A");
32
+ expect(virtualLocations[vlocId]?.uri).toBe("https://example.com");
33
+ });
34
+ it("adds participants and alerts", () => {
35
+ const event = makeEvent();
36
+ const participantId = event.addParticipant({ roles: { attendee: true }, email: "a@example.com" });
37
+ const alertId = event.addAlert({ trigger: { "@type": "AbsoluteTrigger", when: "2026-02-01T01:00:00Z" } });
38
+ const participants = event.data.participants;
39
+ const alerts = event.data.alerts;
40
+ expect(participants).toBeDefined();
41
+ expect(alerts).toBeDefined();
42
+ if (!participants || !alerts)
43
+ throw new Error("Missing participants/alerts");
44
+ expect(participants[participantId]?.email).toBe("a@example.com");
45
+ expect(alerts[alertId]?.trigger).toBeTruthy();
46
+ expect(participants[participantId]?.participationStatus).toBe("needs-action");
47
+ expect(participants[participantId]?.expectReply).toBe(false);
48
+ expect(participants[participantId]?.scheduleAgent).toBe("server");
49
+ expect(participants[participantId]?.scheduleForceSend).toBe(false);
50
+ expect(participants[participantId]?.scheduleSequence).toBe(0);
51
+ expect(alerts[alertId]?.action).toBe("display");
52
+ });
53
+ it("defaults relativeTo for offset triggers", () => {
54
+ const event = makeEvent();
55
+ const alertId = event.addAlert({ trigger: { "@type": "OffsetTrigger", offset: "PT15M" } });
56
+ const alerts = event.data.alerts;
57
+ expect(alerts).toBeDefined();
58
+ if (!alerts)
59
+ throw new Error("Missing alerts");
60
+ const trigger = alerts[alertId]?.trigger;
61
+ if (!trigger || trigger["@type"] !== "OffsetTrigger")
62
+ throw new Error("Missing trigger");
63
+ expect(trigger.relativeTo).toBe("start");
64
+ });
65
+ it("updates sequence for alerts but not for participants", () => {
66
+ const event = makeEvent();
67
+ const initialSequence = event.data.sequence ?? 0;
68
+ event.addParticipant({ roles: { attendee: true }, email: "a@example.com" });
69
+ expect(event.data.sequence ?? 0).toBe(initialSequence);
70
+ event.addAlert({ trigger: { "@type": "AbsoluteTrigger", when: "2026-02-01T01:00:00Z" } });
71
+ expect(event.data.sequence ?? 0).toBe(initialSequence + 1);
72
+ });
73
+ it("clones and eject return deep copies", () => {
74
+ const event = makeEvent();
75
+ const clone = event.clone();
76
+ clone.data.title = "Changed";
77
+ expect(event.data.title).toBe("Kickoff");
78
+ const json = event.eject();
79
+ json.title = "Changed";
80
+ expect(event.data.title).toBe("Kickoff");
81
+ });
82
+ it("createId returns base64url without padding", () => {
83
+ const id = createId();
84
+ expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
85
+ expect(id).not.toContain("=");
86
+ });
87
+ it("supports task creation", () => {
88
+ const task = new JsCal.Task({
89
+ title: "Do work",
90
+ start: "2026-02-02T10:00:00",
91
+ }, { now: fixedNow });
92
+ expect(task.data["@type"]).toBe("Task");
93
+ expect(task.data.created).toBe("2026-02-01T00:00:00Z");
94
+ });
95
+ it("supports group creation", () => {
96
+ const group = new JsCal.Group({ entries: [] }, { now: fixedNow });
97
+ expect(group.data["@type"]).toBe("Group");
98
+ expect(group.data.created).toBe("2026-02-01T00:00:00Z");
99
+ });
100
+ it("accepts Date inputs and null time zones", () => {
101
+ const start = new Date("2026-02-02T10:00:00");
102
+ const event = new JsCal.Event({ title: "Date input", start, timeZone: null }, { now: fixedNow });
103
+ expect(event.data.start).toMatch(/^2026-02-02T/);
104
+ expect(event.data.timeZone).toBeNull();
105
+ const task = new JsCal.Task({ title: "No TZ", start, timeZone: null }, { now: fixedNow });
106
+ expect(task.data.timeZone).toBeNull();
107
+ });
108
+ it("accepts Date inputs for updated/created", () => {
109
+ const updated = new Date("2026-02-02T10:00:00Z");
110
+ const created = new Date("2026-02-01T09:00:00Z");
111
+ const event = new JsCal.Event({
112
+ title: "Dates",
113
+ start: "2026-02-02T10:00:00",
114
+ updated,
115
+ created,
116
+ });
117
+ expect(event.data.updated).toBe("2026-02-02T10:00:00Z");
118
+ expect(event.data.created).toBe("2026-02-01T09:00:00Z");
119
+ });
120
+ it("rejects invalid event start", () => {
121
+ expect(() => new JsCal.Event({ title: "Bad", start: "" })).toThrow();
122
+ });
123
+ it("resolves task time zones", () => {
124
+ const task = new JsCal.Task({ title: "TZ", start: "2026-02-02T10:00:00", timeZone: "asia/tokyo" }, { now: fixedNow });
125
+ expect(task.data.timeZone).toBe("Asia/Tokyo");
126
+ });
127
+ it("derives task progress from participant statuses", () => {
128
+ const completed = new JsCal.Task({
129
+ title: "Completed",
130
+ participants: {
131
+ p1: { "@type": "Participant", roles: { attendee: true }, progress: "completed" },
132
+ },
133
+ }, { now: fixedNow });
134
+ expect(completed.data.progress).toBe("completed");
135
+ const failed = new JsCal.Task({
136
+ title: "Failed",
137
+ participants: {
138
+ p1: { "@type": "Participant", roles: { attendee: true }, progress: "failed" },
139
+ },
140
+ }, { now: fixedNow });
141
+ expect(failed.data.progress).toBe("failed");
142
+ const inProcess = new JsCal.Task({
143
+ title: "In process",
144
+ participants: {
145
+ p1: { "@type": "Participant", roles: { attendee: true }, progress: "in-process" },
146
+ },
147
+ }, { now: fixedNow });
148
+ expect(inProcess.data.progress).toBe("in-process");
149
+ const fallback = new JsCal.Task({
150
+ title: "Fallback",
151
+ participants: {
152
+ p1: { "@type": "Participant", roles: { attendee: true }, progress: "delegated" },
153
+ },
154
+ }, { now: fixedNow });
155
+ expect(fallback.data.progress).toBe("needs-action");
156
+ });
157
+ it("clones task and group objects", () => {
158
+ const task = new JsCal.Task({ title: "Task", start: "2026-02-02T10:00:00" }, { now: fixedNow });
159
+ const taskClone = task.clone();
160
+ taskClone.data.title = "Changed";
161
+ expect(task.data.title).toBe("Task");
162
+ const group = new JsCal.Group({ entries: [] }, { now: fixedNow });
163
+ const groupClone = group.clone();
164
+ groupClone.data.title = "Changed";
165
+ expect(group.data.title).toBe("");
166
+ });
167
+ it("requires entries for group creation", () => {
168
+ // @ts-expect-error entries are required
169
+ expect(() => new JsCal.Group({})).toThrow();
170
+ });
171
+ it("supports get/set helpers", () => {
172
+ const event = makeEvent();
173
+ expect(event.get("title")).toBe("Kickoff");
174
+ event.set("title", "Updated");
175
+ expect(event.get("title")).toBe("Updated");
176
+ });
177
+ it("exposes type guards", () => {
178
+ const event = makeEvent().eject();
179
+ const task = new JsCal.Task({ title: "Task", start: "2026-02-02T10:00:00" }).eject();
180
+ const group = new JsCal.Group({ entries: [] }).eject();
181
+ expect(isEvent(event)).toBe(true);
182
+ expect(isTask(task)).toBe(true);
183
+ expect(isGroup(group)).toBe(true);
184
+ });
185
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JsCal, createUid } from "../jscal.js";
3
+ const fixedNow = () => "2026-02-01T00:00:00Z";
4
+ function makeEvent() {
5
+ return new JsCal.Event({
6
+ title: "Kickoff",
7
+ start: "2026-02-02T10:00:00",
8
+ }, { now: fixedNow });
9
+ }
10
+ describe("JsCal.Event", () => {
11
+ it("fills uid and updated", () => {
12
+ const event = makeEvent();
13
+ expect(event.data.uid).toBeTruthy();
14
+ expect(event.data.updated).toBe("2026-02-01T00:00:00Z");
15
+ expect(event.data["@type"]).toBe("Event");
16
+ expect(event.data.sequence).toBe(0);
17
+ expect(event.data.duration).toBe("PT0S");
18
+ expect(event.data.status).toBe("confirmed");
19
+ expect(event.data.created).toBe("2026-02-01T00:00:00Z");
20
+ expect(event.data.descriptionContentType).toBe("text/plain");
21
+ expect(event.data.showWithoutTime).toBe(false);
22
+ expect(event.data.freeBusyStatus).toBe("busy");
23
+ expect(event.data.privacy).toBe("public");
24
+ expect(event.data.useDefaultAlerts).toBe(false);
25
+ expect(event.data.excluded).toBe(false);
26
+ expect(event.data.timeZone).toBeUndefined();
27
+ });
28
+ it("throws when required fields are missing", () => {
29
+ expect(() => {
30
+ // @ts-expect-error start is required
31
+ new JsCal.Event({ title: "Missing start" }, { now: fixedNow });
32
+ }).toThrow(/Event.start/);
33
+ });
34
+ it("accepts explicit @type", () => {
35
+ const event = new JsCal.Event({ start: "2026-02-02T10:00:00" }, { now: fixedNow });
36
+ expect(event.data["@type"]).toBe("Event");
37
+ });
38
+ it("accepts Date for start", () => {
39
+ const event = new JsCal.Event({ start: new Date("2026-02-02T10:00:00") }, { now: fixedNow });
40
+ expect(event.data.start).toBe("2026-02-02T10:00:00");
41
+ });
42
+ it("does not set timeZone when missing", () => {
43
+ const event = new JsCal.Event({ start: new Date("2026-02-02T10:00:00") }, { now: fixedNow });
44
+ expect(event.data.timeZone).toBeUndefined();
45
+ });
46
+ it("accepts duration in seconds", () => {
47
+ const event = new JsCal.Event({ start: new Date("2026-02-02T10:00:00"), duration: 3600 }, { now: fixedNow });
48
+ expect(event.data.duration).toBe("PT1H");
49
+ });
50
+ it("handles duration boundaries", () => {
51
+ const zero = new JsCal.Event({ start: new Date("2026-02-02T10:00:00"), duration: 0 }, { now: fixedNow });
52
+ expect(zero.data.duration).toBe("PT0S");
53
+ const seconds = new JsCal.Event({ start: new Date("2026-02-02T10:00:00"), duration: 59 }, { now: fixedNow });
54
+ expect(seconds.data.duration).toBe("PT59S");
55
+ const negative = new JsCal.Event({ start: new Date("2026-02-02T10:00:00"), duration: -1 }, { now: fixedNow });
56
+ expect(negative.data.duration).toBe("PT0S");
57
+ });
58
+ });
59
+ describe("Event.update", () => {
60
+ it("increments sequence on non-participant updates", () => {
61
+ const event = makeEvent();
62
+ event.update({ title: "Updated" }, { now: fixedNow });
63
+ expect(event.data.sequence).toBe(1);
64
+ });
65
+ it("does not increment sequence when only participants change", () => {
66
+ const event = makeEvent();
67
+ const initialSequence = event.data.sequence ?? 0;
68
+ event.addParticipant({ roles: { attendee: true }, email: "a@example.com" });
69
+ expect(event.data.sequence ?? 0).toBe(initialSequence);
70
+ });
71
+ it("respects touch=false", () => {
72
+ const event = makeEvent();
73
+ const before = event.data.updated;
74
+ event.update({ title: "No touch" }, { touch: false, now: () => "2026-02-02T00:00:00Z" });
75
+ expect(event.data.updated).toBe(before);
76
+ });
77
+ });
78
+ describe("Event.patch", () => {
79
+ it("applies patch and updates metadata", () => {
80
+ const event = makeEvent();
81
+ event.patch({ title: "Patched" }, { now: fixedNow });
82
+ expect(event.data.title).toBe("Patched");
83
+ expect(event.data.updated).toBe("2026-02-01T00:00:00Z");
84
+ expect(event.data.sequence).toBe(1);
85
+ });
86
+ it("does not increment sequence for participants-only patch", () => {
87
+ const event = makeEvent();
88
+ event.patch({
89
+ participants: {
90
+ p1: {
91
+ "@type": "Participant",
92
+ roles: { attendee: true },
93
+ },
94
+ },
95
+ }, { now: fixedNow });
96
+ expect(event.data.sequence ?? 0).toBe(0);
97
+ });
98
+ });
99
+ describe("createUid", () => {
100
+ it("generates unique-ish id format", () => {
101
+ const uid = createUid();
102
+ expect(uid).toMatch(/^[0-9a-f-]{36}$/);
103
+ });
104
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { JsCal } from "../jscal.js";
3
+ const task = {
4
+ "@type": "Task",
5
+ uid: "t1",
6
+ updated: "2026-02-01T00:00:00Z",
7
+ title: "Write notes",
8
+ description: "Prepare summary",
9
+ sequence: 1,
10
+ start: "2026-02-02T09:00:00",
11
+ due: "2026-02-02T17:00:00",
12
+ timeZone: "America/Los_Angeles",
13
+ percentComplete: 50,
14
+ progress: "in-process",
15
+ };
16
+ const group = {
17
+ "@type": "Group",
18
+ uid: "g1",
19
+ updated: "2026-02-01T00:00:00Z",
20
+ title: "Group",
21
+ entries: [task],
22
+ };
23
+ const event = {
24
+ "@type": "Event",
25
+ uid: "e2",
26
+ updated: "2026-02-01T00:00:00Z",
27
+ title: "Planning",
28
+ start: "2026-02-03T09:00:00",
29
+ };
30
+ const taskWithMethod = {
31
+ "@type": "Task",
32
+ uid: "t2",
33
+ updated: "2026-02-01T00:00:00Z",
34
+ title: "Method test",
35
+ method: "request",
36
+ };
37
+ const taskWithoutTimeZone = {
38
+ "@type": "Task",
39
+ uid: "t3",
40
+ updated: "2026-02-01T00:00:00Z",
41
+ title: "No TZ",
42
+ start: "2026-02-05T09:00:00",
43
+ due: "2026-02-05T17:00:00",
44
+ };
45
+ describe("toICal extras", () => {
46
+ it("exports VTODO with key fields", () => {
47
+ const ical = JsCal.toICal([task], { includeXJSCalendar: false });
48
+ expect(ical).toContain("BEGIN:VTODO");
49
+ expect(ical).toContain("UID:t1");
50
+ expect(ical).toContain("DTSTART;TZID=America/Los_Angeles:20260202T090000");
51
+ expect(ical).toContain("DUE;TZID=America/Los_Angeles:20260202T170000");
52
+ expect(ical).toContain("PERCENT-COMPLETE:50");
53
+ expect(ical).toContain("STATUS:IN-PROCESS");
54
+ });
55
+ it("exports group as VCALENDAR with entries", () => {
56
+ const ical = JsCal.toICal([group], { includeXJSCalendar: true });
57
+ expect(ical).toContain("BEGIN:VCALENDAR");
58
+ expect(ical).toContain("X-JSCALENDAR-GROUP:");
59
+ expect(ical).toContain("BEGIN:VTODO");
60
+ });
61
+ it("exports mixed arrays (group + event + task)", () => {
62
+ const ical = JsCal.toICal([group, event, task]);
63
+ expect(ical).toContain("BEGIN:VEVENT");
64
+ expect(ical).toContain("BEGIN:VTODO");
65
+ });
66
+ it("uses method from first object that defines it", () => {
67
+ const ical = JsCal.toICal([event, taskWithMethod]);
68
+ expect(ical).toContain("METHOD:REQUEST");
69
+ });
70
+ it("exports VTODO without time zone parameters", () => {
71
+ const ical = JsCal.toICal([taskWithoutTimeZone], { includeXJSCalendar: false });
72
+ expect(ical).toContain("DTSTART:20260205T090000");
73
+ expect(ical).toContain("DUE:20260205T170000");
74
+ });
75
+ it("ignores unknown object types", () => {
76
+ const unknownObject = {
77
+ "@type": "Unknown",
78
+ uid: "x1",
79
+ updated: "2026-02-01T00:00:00Z",
80
+ };
81
+ // @ts-expect-error unknown object type
82
+ const ical = JsCal.toICal([unknownObject], { includeXJSCalendar: false });
83
+ expect(ical).toContain("BEGIN:VCALENDAR");
84
+ expect(ical).not.toContain("BEGIN:VEVENT");
85
+ expect(ical).not.toContain("BEGIN:VTODO");
86
+ });
87
+ });