@axium/calendar 0.1.4 → 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.
package/db.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "../server/schemas/db.json",
3
- "format": 0,
3
+ "format": 1,
4
4
  "versions": [
5
5
  {
6
6
  "delta": false,
@@ -58,15 +58,30 @@
58
58
  }
59
59
  }
60
60
  },
61
- "indexes": [
62
- "calendars:userId",
63
- "acl.calendars:userId",
64
- "acl.calendars:itemId",
65
- "events:calId",
66
- "events:recurrenceId",
67
- "attendees:eventId",
68
- "attendees:userId"
69
- ]
61
+ "indexes": {
62
+ "calendars_userId": { "on": "calendars", "columns": ["userId"] },
63
+ "acl_calendars_userId": { "on": "acl.calendars", "columns": ["userId"] },
64
+ "acl_calendars_itemId": { "on": "acl.calendars", "columns": ["itemId"] },
65
+ "events_calId": { "on": "events", "columns": ["calId"] },
66
+ "events_recurrenceId": { "on": "events", "columns": ["recurrenceId"] },
67
+ "attendees_eventId": { "on": "attendees", "columns": ["eventId"] },
68
+ "attendees_userId": { "on": "attendees", "columns": ["userId"] }
69
+ }
70
+ },
71
+ {
72
+ "delta": true,
73
+ "alter_tables": {
74
+ "calendars": {
75
+ "add_columns": {
76
+ "color": { "type": "integer" }
77
+ }
78
+ },
79
+ "events": {
80
+ "add_columns": {
81
+ "color": { "type": "integer" }
82
+ }
83
+ }
84
+ }
70
85
  }
71
86
  ],
72
87
  "wipe": ["calendars", "events", "acl.calendars"],
package/dist/client.d.ts CHANGED
@@ -1,4 +1,8 @@
1
- import type { Calendar, Event, EventData, EventFilter } from './common.js';
1
+ import type { AttendeeInit, Calendar, Event, EventData, EventFilter } from './common.js';
2
2
  export declare function getEvents(calendars: Calendar[], filter: EventFilter): Promise<Event[]>;
3
3
  export interface EventInitFormData extends Record<Exclude<keyof EventData, 'attendees' | 'recurrenceExcludes'>, string> {
4
4
  }
5
+ export interface EventInitProp extends EventData {
6
+ attendees: AttendeeInit[];
7
+ calendar?: Calendar;
8
+ }
package/dist/common.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { type UserInternal } from '@axium/core';
2
2
  import type { WithRequired } from 'utilium';
3
3
  import * as z from 'zod';
4
+ export declare function withOrdinalSuffix(val: number): string;
5
+ export declare function toRRuleDate(date: Date): Date;
6
+ export declare function fromRRuleDate(d: Date): Date;
4
7
  export declare function dayOfYear(date: Date): number;
5
8
  /**
6
9
  *
@@ -10,6 +13,8 @@ export declare function dayOfYear(date: Date): number;
10
13
  */
11
14
  export declare function weekOfYear(date: Date, baseOnWeeks?: boolean): number;
12
15
  export declare function weekDaysFor(date: Date): Date[];
16
+ export declare function longWeekDay(date: Date): string;
17
+ export declare function weekDayOfMonth(date: Date): string;
13
18
  /**
14
19
  * Converts a date to the format expected by `<input type="datetime-local">`
15
20
  */
@@ -54,6 +59,7 @@ export declare const EventData: z.ZodObject<{
54
59
  end: z.ZodCoercedDate<unknown>;
55
60
  isAllDay: z.ZodCoercedBoolean<unknown>;
56
61
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
62
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
57
63
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
58
64
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
59
65
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -68,6 +74,7 @@ export declare const EventInit: z.ZodObject<{
68
74
  end: z.ZodCoercedDate<unknown>;
69
75
  isAllDay: z.ZodCoercedBoolean<unknown>;
70
76
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
77
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
71
78
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
72
79
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
73
80
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -89,6 +96,7 @@ export declare const Event: z.ZodObject<{
89
96
  end: z.ZodCoercedDate<unknown>;
90
97
  isAllDay: z.ZodCoercedBoolean<unknown>;
91
98
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
99
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
92
100
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
93
101
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
94
102
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -117,6 +125,7 @@ export declare const Calendar: z.ZodObject<{
117
125
  id: z.ZodUUID;
118
126
  userId: z.ZodUUID;
119
127
  created: z.ZodCoercedDate<unknown>;
128
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
120
129
  acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
121
130
  itemId: z.ZodUUID;
122
131
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -157,6 +166,7 @@ declare const CalendarAPI: {
157
166
  id: z.ZodUUID;
158
167
  userId: z.ZodUUID;
159
168
  created: z.ZodCoercedDate<unknown>;
169
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
160
170
  acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
161
171
  itemId: z.ZodUUID;
162
172
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -185,6 +195,7 @@ declare const CalendarAPI: {
185
195
  id: z.ZodUUID;
186
196
  userId: z.ZodUUID;
187
197
  created: z.ZodCoercedDate<unknown>;
198
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
188
199
  acl: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
189
200
  itemId: z.ZodUUID;
190
201
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -215,6 +226,7 @@ declare const CalendarAPI: {
215
226
  id: z.ZodUUID;
216
227
  userId: z.ZodUUID;
217
228
  created: z.ZodCoercedDate<unknown>;
229
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
218
230
  acl: z.ZodNonOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
219
231
  itemId: z.ZodUUID;
220
232
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -245,6 +257,7 @@ declare const CalendarAPI: {
245
257
  id: z.ZodUUID;
246
258
  userId: z.ZodUUID;
247
259
  created: z.ZodCoercedDate<unknown>;
260
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
248
261
  acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
249
262
  itemId: z.ZodUUID;
250
263
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -273,6 +286,7 @@ declare const CalendarAPI: {
273
286
  id: z.ZodUUID;
274
287
  userId: z.ZodUUID;
275
288
  created: z.ZodCoercedDate<unknown>;
289
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
276
290
  acl: z.ZodOptional<z.ZodArray<z.ZodObject<{
277
291
  itemId: z.ZodUUID;
278
292
  userId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -309,6 +323,7 @@ declare const CalendarAPI: {
309
323
  end: z.ZodCoercedDate<unknown>;
310
324
  isAllDay: z.ZodCoercedBoolean<unknown>;
311
325
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
326
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
312
327
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
313
328
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
314
329
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -331,6 +346,7 @@ declare const CalendarAPI: {
331
346
  end: z.ZodCoercedDate<unknown>;
332
347
  isAllDay: z.ZodCoercedBoolean<unknown>;
333
348
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
349
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
334
350
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
335
351
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
336
352
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -349,6 +365,7 @@ declare const CalendarAPI: {
349
365
  end: z.ZodCoercedDate<unknown>;
350
366
  isAllDay: z.ZodCoercedBoolean<unknown>;
351
367
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
368
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
352
369
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
353
370
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
354
371
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -373,6 +390,7 @@ declare const CalendarAPI: {
373
390
  end: z.ZodCoercedDate<unknown>;
374
391
  isAllDay: z.ZodCoercedBoolean<unknown>;
375
392
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
393
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
376
394
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
377
395
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
378
396
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -395,6 +413,7 @@ declare const CalendarAPI: {
395
413
  end: z.ZodCoercedDate<unknown>;
396
414
  isAllDay: z.ZodCoercedBoolean<unknown>;
397
415
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
416
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
398
417
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
399
418
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
400
419
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -413,6 +432,7 @@ declare const CalendarAPI: {
413
432
  end: z.ZodCoercedDate<unknown>;
414
433
  isAllDay: z.ZodCoercedBoolean<unknown>;
415
434
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
435
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
416
436
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
417
437
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
418
438
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -435,6 +455,7 @@ declare const CalendarAPI: {
435
455
  end: z.ZodCoercedDate<unknown>;
436
456
  isAllDay: z.ZodCoercedBoolean<unknown>;
437
457
  description: z.ZodOptional<z.ZodNullable<z.ZodString>>;
458
+ color: z.ZodOptional<z.ZodNullable<z.ZodCustomStringFormat<"hex">>>;
438
459
  recurrence: z.ZodOptional<z.ZodNullable<z.ZodString>>;
439
460
  recurrenceExcludes: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
440
461
  recurrenceId: z.ZodOptional<z.ZodNullable<z.ZodUUID>>;
@@ -456,6 +477,12 @@ declare module '@axium/core/api' {
456
477
  interface $API extends CalendarAPI {
457
478
  }
458
479
  }
480
+ /**
481
+ * Convert a `Date` to an iCalendar datetime
482
+ */
483
+ export declare function toDateTime(date: Date): string;
484
+ /** e.g. `FR`, `SA` */
485
+ export declare function toByDay(date: Date): string;
459
486
  export declare function eventToICS(event: Event): string;
460
487
  export declare function eventFromICS(ics: string): Event;
461
488
  export {};
package/dist/common.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { $API, AccessControl, pickPermissions } from '@axium/core';
2
2
  import * as z from 'zod';
3
3
  import $pkg from '../package.json' with { type: 'json' };
4
+ import { Color } from '@axium/core/color';
5
+ export function withOrdinalSuffix(val) {
6
+ const tens = val % 10, hundreds = val % 100;
7
+ if (tens == 1 && hundreds != 11)
8
+ return val + 'st';
9
+ if (tens == 2 && hundreds != 12)
10
+ return val + 'nd';
11
+ if (tens == 3 && hundreds != 13)
12
+ return val + 'rd';
13
+ return val + 'th';
14
+ }
15
+ export function toRRuleDate(date) {
16
+ return new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes()));
17
+ }
18
+ export function fromRRuleDate(d) {
19
+ return new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes());
20
+ }
4
21
  export function dayOfYear(date) {
5
22
  const yearStart = new Date(date.getFullYear(), 0, 1);
6
23
  return Math.round((date.getTime() - yearStart.getTime()) / 86400000 + 1);
@@ -26,6 +43,13 @@ export function weekDaysFor(date) {
26
43
  }
27
44
  return days;
28
45
  }
46
+ export function longWeekDay(date) {
47
+ return date.toLocaleString('default', { weekday: 'long' });
48
+ }
49
+ export function weekDayOfMonth(date) {
50
+ const weekOfMonth = Math.ceil(date.getDate() / 7);
51
+ return `${withOrdinalSuffix(weekOfMonth)} ${longWeekDay(date)}`;
52
+ }
29
53
  /**
30
54
  * Converts a date to the format expected by `<input type="datetime-local">`
31
55
  */
@@ -81,6 +105,7 @@ export const EventData = z.object({
81
105
  end: z.coerce.date(),
82
106
  isAllDay: z.coerce.boolean(),
83
107
  description: z.string().max(2000).nullish(),
108
+ color: Color.nullish(),
84
109
  // note: recurrences are not support yet
85
110
  recurrence: z.string().max(1000).nullish(),
86
111
  recurrenceExcludes: z.string().max(100).array().max(100).nullish(),
@@ -111,6 +136,7 @@ export const Calendar = CalendarInit.extend({
111
136
  id: z.uuid(),
112
137
  userId: z.uuid(),
113
138
  created: z.coerce.date(),
139
+ color: Color.nullish(),
114
140
  acl: AccessControl.array().optional(),
115
141
  });
116
142
  export function getCalPermissionsInfo(cal, user) {
@@ -151,19 +177,31 @@ const CalendarAPI = {
151
177
  },
152
178
  };
153
179
  Object.assign($API, CalendarAPI);
154
- function formatDate(date) {
155
- return date.toUTCString().replaceAll('-', '').replaceAll(':', '');
180
+ /**
181
+ * Convert a `Date` to an iCalendar datetime
182
+ */
183
+ export function toDateTime(date) {
184
+ return date
185
+ .toISOString()
186
+ .replaceAll('-', '')
187
+ .replaceAll(':', '')
188
+ .replace(/\.\d+Z$/, 'Z');
189
+ }
190
+ /** e.g. `FR`, `SA` */
191
+ export function toByDay(date) {
192
+ return date.toLocaleString('en', { weekday: 'short' }).slice(0, 2).toUpperCase();
156
193
  }
157
194
  export function eventToICS(event) {
158
195
  const lines = [
159
196
  'BEGIN:VCALENDAR',
160
197
  'VERSION:2.0',
161
198
  `PRODID:-//Axium//Calendar ${$pkg.version}//EN`,
199
+ 'CALSCALE:GREGORIAN',
162
200
  'BEGIN:VEVENT',
163
201
  'UID:' + event.id,
164
- 'DTSTAMP:' + formatDate(new Date()),
165
- 'DTSTART:' + formatDate(event.start),
166
- 'DTEND:' + formatDate(event.end),
202
+ 'DTSTAMP:' + toDateTime(new Date()),
203
+ 'DTSTART:' + toDateTime(event.start),
204
+ 'DTEND:' + toDateTime(event.end),
167
205
  'SUMMARY:' + event.summary,
168
206
  ];
169
207
  if (event.description)
package/dist/server.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type schema from '../db.json';
2
+ import type { FromFile as FromSchemaFile } from '@axium/server/db/schema';
2
3
  declare module '@axium/server/database' {
3
4
  interface Schema extends FromSchemaFile<typeof schema> {
4
5
  }
package/dist/server.js CHANGED
@@ -7,28 +7,37 @@ import { sql } from 'kysely';
7
7
  import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
8
8
  import * as z from 'zod';
9
9
  import { Attendee, CalendarInit, EventFilter, EventInit } from './common.js';
10
+ function withEncoded(value) {
11
+ return (value.color === undefined || value.color === null
12
+ ? value
13
+ : Object.assign(value, { color: value.color.toString(16).padStart(8, '0') }));
14
+ }
15
+ function withDecoded(value) {
16
+ return (value.color === undefined || value.color === null ? value : Object.assign(value, { color: parseInt(value.color, 16) }));
17
+ }
10
18
  addRoute({
11
19
  path: '/api/users/:id/calendars',
12
20
  params: { id: z.uuid() },
13
21
  async GET(request, { id: userId }) {
14
22
  const { user } = await checkAuthForUser(request, userId);
15
- return await database
23
+ const result = await database
16
24
  .selectFrom('calendars')
17
25
  .selectAll()
18
26
  .select(aclFrom('calendars'))
19
27
  .where(userHasAccess('calendars', user))
20
28
  .execute()
21
29
  .catch(withError('Could not get calendars'));
30
+ return result.map(c => withEncoded(c));
22
31
  },
23
32
  async PUT(request, { id: userId }) {
24
33
  const init = await parseBody(request, CalendarInit);
25
34
  await checkAuthForUser(request, userId);
26
- return await database
35
+ return withEncoded(await database
27
36
  .insertInto('calendars')
28
37
  .values({ ...init, userId })
29
38
  .returningAll()
30
39
  .executeTakeFirstOrThrow()
31
- .catch(withError('Could not create calendar'));
40
+ .catch(withError('Could not create calendar')));
32
41
  },
33
42
  });
34
43
  addRoute({
@@ -36,27 +45,27 @@ addRoute({
36
45
  params: { id: z.uuid() },
37
46
  async GET(request, { id }) {
38
47
  const { item } = await authRequestForItem(request, 'calendars', id, { read: true });
39
- return item;
48
+ return withEncoded(item);
40
49
  },
41
50
  async PATCH(request, { id }) {
42
51
  const body = await parseBody(request, CalendarInit);
43
52
  await authRequestForItem(request, 'calendars', id, { edit: true });
44
- return await database
53
+ return withEncoded(await database
45
54
  .updateTable('calendars')
46
55
  .set(body)
47
56
  .where('id', '=', id)
48
57
  .returningAll()
49
58
  .executeTakeFirstOrThrow()
50
- .catch(withError('Could not update calendar'));
59
+ .catch(withError('Could not update calendar')));
51
60
  },
52
61
  async DELETE(request, { id }) {
53
62
  await authRequestForItem(request, 'calendars', id, { manage: true });
54
- return await database
63
+ return withEncoded(await database
55
64
  .deleteFrom('calendars')
56
65
  .where('id', '=', id)
57
66
  .returningAll()
58
67
  .executeTakeFirstOrThrow()
59
- .catch(withError('Could not delete calendar'));
68
+ .catch(withError('Could not delete calendar')));
60
69
  },
61
70
  });
62
71
  function withAttendees(eb) {
@@ -78,12 +87,16 @@ addRoute({
78
87
  .selectAll()
79
88
  .select(withAttendees)
80
89
  .where('calId', '=', id)
81
- .where(sql `(${sql.ref('start')}, ${sql.ref('end')}) OVERLAPS (${sql.val(filter.start)}, ${sql.val(filter.end)})`)
90
+ .where(eb => eb.or([
91
+ sql `(${sql.ref('start')}, ${sql.ref('end')}) OVERLAPS (${sql.val(filter.start)}, ${sql.val(filter.end)})`,
92
+ eb.and([eb('recurrence', 'is not', null), eb('recurrence', '!=', '')]), // @todo try to figure out a way to only select events we know recur during the filter
93
+ ]))
82
94
  .limit(1000)
83
95
  .execute()
96
+ .then(result => result.map(withEncoded))
84
97
  .catch(withError('Could not get events'));
85
98
  for (const event of events)
86
- event.calendar = calendar;
99
+ event.calendar = withEncoded(calendar);
87
100
  return events;
88
101
  },
89
102
  async PUT(request, { id }) {
@@ -93,7 +106,7 @@ addRoute({
93
106
  try {
94
107
  const event = await tx
95
108
  .insertInto('events')
96
- .values({ ...init, calId: id })
109
+ .values({ ...withDecoded(init), calId: id })
97
110
  .returningAll()
98
111
  .executeTakeFirstOrThrow()
99
112
  .catch(withError('Could not create event'));
@@ -105,7 +118,11 @@ addRoute({
105
118
  .execute()
106
119
  : []);
107
120
  await tx.commit().execute();
108
- return Object.assign(event, { attendees, calendar });
121
+ return Object.assign(event, {
122
+ attendees,
123
+ calendar: withEncoded(calendar),
124
+ color: event.color?.toString(16)?.padStart(8, '0'),
125
+ });
109
126
  }
110
127
  catch (e) {
111
128
  await tx.rollback().execute();
@@ -128,7 +145,7 @@ addRoute({
128
145
  .catch(withError('Event does not exist', 404));
129
146
  if (event.calendar.userId != userId && event.attendees.every(a => a.userId != userId))
130
147
  error(403, 'You do not have access to this event');
131
- return event;
148
+ return withEncoded(event);
132
149
  },
133
150
  async PATCH(request, { id }) {
134
151
  const { attendees: attendeesInit = [], sendEmails, recurrenceUpdateAll, ...init } = await parseBody(request, EventInit);
@@ -145,7 +162,7 @@ addRoute({
145
162
  try {
146
163
  const event = await tx
147
164
  .updateTable('events')
148
- .set(init)
165
+ .set(withDecoded(init))
149
166
  .where('id', '=', id)
150
167
  .returningAll()
151
168
  .returning(withAttendees)
@@ -173,7 +190,7 @@ addRoute({
173
190
  */
174
191
  }
175
192
  await tx.commit().execute();
176
- return event;
193
+ return withEncoded(event);
177
194
  }
178
195
  catch (e) {
179
196
  await tx.rollback().execute();
@@ -198,7 +215,7 @@ addRoute({
198
215
  .where('id', '=', id)
199
216
  .executeTakeFirstOrThrow();
200
217
  await tx.commit().execute();
201
- return event;
218
+ return withEncoded(event);
202
219
  }
203
220
  catch (e) {
204
221
  await tx.rollback().execute();
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import type { EventInitProp } from '@axium/calendar/client';
3
+ import type { Event } from '@axium/calendar/common';
4
+ import { eventToICS, formatEventTimes, toRRuleDate } from '@axium/calendar/common';
5
+ import { contextMenu } from '@axium/client/attachments';
6
+ import { Icon, Popover } from '@axium/client/components';
7
+ import { colorHashHex, decodeColor, encodeColor } from '@axium/core/color';
8
+ import { rrulestr } from 'rrule';
9
+ import { download } from 'utilium/dom.js';
10
+
11
+ let {
12
+ event,
13
+ eventEditId = $bindable(),
14
+ eventEditCalId = $bindable(),
15
+ eventInit = $bindable(),
16
+ initData = event,
17
+ }: {
18
+ event: Event;
19
+ eventInit: EventInitProp;
20
+ eventEditId?: string;
21
+ eventEditCalId?: string;
22
+ initData?: Event;
23
+ } = $props();
24
+
25
+ const id = $props.id();
26
+
27
+ const recurrence = $derived(
28
+ !event.recurrence ? '' : rrulestr('RRULE:' + event.recurrence, { dtstart: toRRuleDate(event.start) }).toText()
29
+ );
30
+ </script>
31
+
32
+ <Popover {id} onclick={e => e.stopPropagation()}>
33
+ {#snippet toggle()}
34
+ {@const start = event.start.getHours() * 60 + event.start.getMinutes()}
35
+ {@const end = event.end.getHours() * 60 + event.end.getMinutes()}
36
+ <div
37
+ class="Event"
38
+ style:top="{start / 14.4}%"
39
+ style:height="{(end - start) / 14.4}%"
40
+ style="--event-color:{decodeColor(
41
+ event.color || event.calendar!.color || encodeColor(colorHashHex(event.calendar!.name), true)
42
+ )}"
43
+ {@attach contextMenu(
44
+ {
45
+ i: 'pencil',
46
+ text: 'Edit',
47
+ action: () => {
48
+ eventEditId = event.id;
49
+ eventEditCalId = event.calId;
50
+ eventInit = initData;
51
+ document.querySelector<HTMLDialogElement>('#event-init')!.showModal();
52
+ },
53
+ },
54
+ {
55
+ i: 'file-export',
56
+ text: 'Export .ics',
57
+ action: () => download(event.summary + '.ics', eventToICS(event)),
58
+ },
59
+ {
60
+ i: 'trash-can',
61
+ text: 'Delete',
62
+ action: () => {
63
+ eventEditId = event.id;
64
+ document.querySelector<HTMLDialogElement>('#event-delete')!.showModal();
65
+ },
66
+ }
67
+ )}
68
+ >
69
+ <span>{event.summary}</span>
70
+ <span class="subtle">{formatEventTimes(event)}</span>
71
+ </div>
72
+ {/snippet}
73
+
74
+ <div class="event-actions">
75
+ <button
76
+ class="reset"
77
+ onclick={() => {
78
+ eventEditId = event.id;
79
+ eventEditCalId = event.calId;
80
+ eventInit = initData;
81
+ }}
82
+ command="show-modal"
83
+ commandfor="event-init"><Icon i="pencil" /></button
84
+ >
85
+ <button class="reset" onclick={() => download(event.summary + '.ics', eventToICS(event))}><Icon i="file-export" /></button>
86
+ <button class="reset" onclick={() => (eventEditId = event.id)} command="show-modal" commandfor="event-delete"
87
+ ><Icon i="trash-can" /></button
88
+ >
89
+ <button class="reset" command="hide-popover" commandfor={id}><Icon i="xmark" /></button>
90
+ </div>
91
+
92
+ <h3>{event.summary}</h3>
93
+
94
+ <div>
95
+ <Icon i="clock" />
96
+ <span>
97
+ {#if event.start.getDate() == event.end.getDate()}
98
+ {event.start.toLocaleDateString()}, {formatEventTimes(event)}
99
+ {:else if event.isAllDay}
100
+ {event.start.toLocaleDateString()} - {event.end.toLocaleDateString()}
101
+ {:else}
102
+ {event.start.toLocaleString()} - {event.end.toLocaleString()}
103
+ {/if}
104
+ </span>
105
+ </div>
106
+
107
+ {#if event.recurrence}
108
+ <div>
109
+ <Icon i="arrows-rotate" />
110
+ <span>{recurrence}</span>
111
+ </div>
112
+ {/if}
113
+
114
+ {#if event.location}
115
+ <div>
116
+ <Icon i="location-dot" />
117
+ <span>{event.location}</span>
118
+ </div>
119
+ {/if}
120
+
121
+ <div>
122
+ <Icon i="calendar" />
123
+ <span>
124
+ {#if event.calendar}
125
+ {event.calendar.name}
126
+ {:else}
127
+ <i>Unknown Calendar</i>
128
+ {/if}
129
+ </span>
130
+ </div>
131
+
132
+ {#if event.attendees.length}
133
+ <div class="attendees-container">
134
+ <Icon i="user-group" />
135
+ <div class="attendees">
136
+ {#each event.attendees ?? [] as attendee (attendee.email)}
137
+ <div class="attendee">{attendee.email}</div>
138
+ {/each}
139
+ </div>
140
+ </div>
141
+ {/if}
142
+
143
+ {#if event.description}
144
+ <div class="description">
145
+ <Icon i="block-quote" />
146
+ <span>{event.description}</span>
147
+ </div>
148
+ {/if}
149
+ </Popover>
150
+
151
+ <style>
152
+ .Event {
153
+ width: calc(100% - 0.5em);
154
+ position: absolute;
155
+ border-radius: 0.5em;
156
+ padding: 0.25em;
157
+ background-color: var(--event-color, var(--bg-alt));
158
+ display: flex;
159
+ flex-direction: column;
160
+ align-items: flex-start;
161
+ justify-content: flex-start;
162
+ container-type: size;
163
+ overflow: hidden;
164
+
165
+ @container (height < 2.5em) {
166
+ .subtle {
167
+ display: none;
168
+ }
169
+ }
170
+
171
+ :global(& + :popover-open) {
172
+ gap: 0.75em;
173
+ padding: 1em;
174
+
175
+ .event-actions {
176
+ display: flex;
177
+ align-items: center;
178
+ justify-content: flex-end;
179
+ gap: 0.25em;
180
+
181
+ button {
182
+ padding: 0.5em;
183
+ border-radius: 0.5em;
184
+ }
185
+ }
186
+ }
187
+ }
188
+ </style>
package/lib/index.ts CHANGED
@@ -1 +1,2 @@
1
+ export { default as Event } from './Event.svelte';
1
2
  export { default as Select } from './Select.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/calendar",
3
- "version": "0.1.4",
3
+ "version": "0.3.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "Calendar for Axium",
6
6
  "funding": {
@@ -36,12 +36,13 @@
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@axium/client": ">=0.14.3",
39
- "@axium/core": ">=0.19.0",
39
+ "@axium/core": ">=0.20.0",
40
40
  "@axium/server": ">=0.36.0",
41
41
  "@sveltejs/kit": "^2.27.3",
42
- "utilium": "^2.6.3"
42
+ "utilium": "^2.7.0"
43
43
  },
44
44
  "dependencies": {
45
+ "rrule": "^2.8.1",
45
46
  "zod": "^4.0.5"
46
47
  },
47
48
  "axium": {
@@ -1,21 +1,38 @@
1
1
  <script lang="ts">
2
- import { getEvents, type EventInitFormData } from '@axium/calendar/client';
3
- import type { Event, EventData } from '@axium/calendar/common';
4
- import { AttendeeInit, dateToInputValue, formatEventTimes, getCalPermissionsInfo, weekDaysFor } from '@axium/calendar/common';
5
- import * as Calendar from '@axium/calendar/components';
2
+ import { getEvents, type EventInitFormData, type EventInitProp } from '@axium/calendar/client';
3
+ import type { Event } from '@axium/calendar/common';
4
+ import {
5
+ dateToInputValue,
6
+ fromRRuleDate,
7
+ getCalPermissionsInfo,
8
+ longWeekDay,
9
+ toByDay,
10
+ toRRuleDate,
11
+ weekDayOfMonth,
12
+ weekDaysFor,
13
+ withOrdinalSuffix,
14
+ } from '@axium/calendar/common';
15
+ import * as Cal from '@axium/calendar/components';
6
16
  import { contextMenu, dynamicRows } from '@axium/client/attachments';
7
- import { AccessControlDialog, FormDialog, Icon, Popover, UserDiscovery } from '@axium/client/components';
17
+ import { AccessControlDialog, ColorPicker, FormDialog, Icon, Popover, UserDiscovery } from '@axium/client/components';
8
18
  import { fetchAPI } from '@axium/client/requests';
19
+ import { colorHashHex, encodeColor } from '@axium/core/color';
20
+ import { rrulestr } from 'rrule';
9
21
  import { SvelteDate } from 'svelte/reactivity';
10
22
  import { _throw } from 'utilium';
11
- import z from 'zod';
23
+ import * as z from 'zod';
24
+
12
25
  const { data } = $props();
13
26
 
14
27
  const { user } = data.session;
15
28
 
16
29
  const now = new SvelteDate();
30
+ now.setMilliseconds(0);
17
31
  setInterval(() => now.setTime(Date.now()), 60_000);
18
32
  const today = $derived(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0));
33
+ const defaultStart = $derived(
34
+ new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), Math.round(now.getMinutes() / 30) * 30, 0, 0)
35
+ );
19
36
 
20
37
  let start = new SvelteDate(data.filter.start);
21
38
  let end = new SvelteDate(data.filter.end);
@@ -23,6 +40,7 @@
23
40
  let events = $state<Event[]>([]);
24
41
 
25
42
  $effect(() => {
43
+ for (const cal of calendars) cal.color ||= encodeColor(colorHashHex(cal.name));
26
44
  getEvents(calendars, { start: new Date(start.getTime()), end: new Date(end.getTime()) }).then(e => (events = e));
27
45
  });
28
46
 
@@ -31,27 +49,37 @@
31
49
  const span = $state('week');
32
50
  const spanDays = $derived(span == 'week' ? 7 : _throw('Invalid span value'));
33
51
  const weekDays = $derived(weekDaysFor(start));
34
- const eventsForWeekDays = $derived(
35
- Object.groupBy(
36
- events.filter(
37
- e => e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) && e.end > weekDays[0]
38
- ),
39
- ev => ev?.start.getDay()
40
- )
41
- );
42
52
 
43
53
  let dialogs = $state<Record<string, HTMLDialogElement>>({});
44
54
 
45
- let eventInit = $state<EventData & { attendees: AttendeeInit[] }>({
46
- attendees: [],
47
- recurrenceExcludes: [],
48
- recurrenceId: null,
49
- calId: calendars[0]?.id,
50
- } as any),
55
+ const defaultEventInit = $derived<any>({
56
+ attendees: [],
57
+ recurrenceExcludes: [],
58
+ recurrenceId: null,
59
+ calId: calendars[0]?.id,
60
+ start: new Date(defaultStart),
61
+ end: new Date(defaultStart.getTime() + 3600_000),
62
+ });
63
+
64
+ let eventInit = $state<EventInitProp>(defaultEventInit),
51
65
  eventInitStart = $derived(dateToInputValue(eventInit.start)),
52
66
  eventInitEnd = $derived(dateToInputValue(eventInit.end)),
53
67
  eventEditId = $state<string>(),
54
68
  eventEditCalId = $state<string>();
69
+
70
+ const recurringEvents = $derived(
71
+ events
72
+ .filter(ev => ev.recurrence)
73
+ .map(ev => {
74
+ const rule = rrulestr('RRULE:' + ev.recurrence, { dtstart: toRRuleDate(ev.start) });
75
+ const recurrences = rule
76
+ .between(toRRuleDate(new Date(start.getTime())), toRRuleDate(new Date(end.getTime())), true)
77
+ .map(fromRRuleDate);
78
+ return { ...ev, rule, recurrences };
79
+ })
80
+ );
81
+
82
+ const defaultEventColor = $derived((eventInit.calendar || calendars[0])?.color || encodeColor(colorHashHex(user.name)));
55
83
  </script>
56
84
 
57
85
  <svelte:head>
@@ -79,7 +107,7 @@
79
107
  <span class="label">{weekDays[0].toLocaleString('default', { month: 'long', year: 'numeric' })}</span>
80
108
  </div>
81
109
  <div id="cal-list">
82
- <Calendar.Select bind:start bind:end />
110
+ <Cal.Select bind:start bind:end />
83
111
  <div class="cal-list-header">
84
112
  <h4>My Calendars</h4>
85
113
  <button style:display="contents" command="show-modal" commandfor="add-calendar">
@@ -156,6 +184,14 @@
156
184
  <span class="hour empty"></span>
157
185
  </div>
158
186
  {#if span == 'week'}
187
+ {@const eventsForWeekDays = Object.groupBy(
188
+ events.filter(
189
+ e =>
190
+ e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) &&
191
+ e.end > weekDays[0]
192
+ ),
193
+ ev => ev.start.getDay()
194
+ )}
159
195
  <div class="cal-content week">
160
196
  {#each weekDays as day, i}
161
197
  <div class="day">
@@ -166,89 +202,13 @@
166
202
 
167
203
  <div class="day-content">
168
204
  {#each eventsForWeekDays[i] ?? [] as event}
169
- <Popover id="event-popover:{event.id}" onclick={e => e.stopPropagation()}>
170
- {#snippet toggle()}
171
- {@const start = event.start.getHours() * 60 + event.start.getMinutes()}
172
- {@const end = event.end.getHours() * 60 + event.end.getMinutes()}
173
- <div class="event" style:top="{start / 14.4}%" style:height="{(end - start) / 14.4}%">
174
- <span>{event.summary}</span>
175
- <span class="subtle">{formatEventTimes(event)}</span>
176
- </div>
177
- {/snippet}
178
-
179
- <div class="event-actions">
180
- <button
181
- class="reset"
182
- onclick={() => {
183
- eventEditId = event.id;
184
- eventEditCalId = event.calId;
185
- eventInit = event;
186
- }}
187
- command="show-modal"
188
- commandfor="event-init"><Icon i="pencil" /></button
189
- >
190
- <button
191
- class="reset"
192
- onclick={() => (eventEditId = event.id)}
193
- command="show-modal"
194
- commandfor="event-delete"><Icon i="trash-can" /></button
195
- >
196
- <button class="reset" command="hide-popover" commandfor="event-popover:{event.id}"
197
- ><Icon i="xmark" /></button
198
- >
199
- </div>
200
-
201
- <h3>{event.summary}</h3>
202
-
203
- <div>
204
- <Icon i="clock" />
205
- <span>
206
- {#if event.start.getDate() == event.end.getDate()}
207
- {event.start.toLocaleDateString()}, {formatEventTimes(event)}
208
- {:else if event.isAllDay}
209
- {event.start.toLocaleDateString()} - {event.end.toLocaleDateString()}
210
- {:else}
211
- {event.start.toLocaleString()} - {event.end.toLocaleString()}
212
- {/if}
213
- </span>
214
- </div>
215
-
216
- {#if event.location}
217
- <div>
218
- <Icon i="location-dot" />
219
- <span>{event.location}</span>
220
- </div>
221
- {/if}
222
-
223
- <div>
224
- <Icon i="calendar" />
225
- <span>
226
- {#if event.calendar}
227
- {event.calendar.name}
228
- {:else}
229
- <i>Unknown Calendar</i>
230
- {/if}
231
- </span>
232
- </div>
233
-
234
- {#if event.attendees.length}
235
- <div class="attendees-container">
236
- <Icon i="user-group" />
237
- <div class="attendees">
238
- {#each event.attendees ?? [] as attendee (attendee.email)}
239
- <div class="attendee">{attendee.email}</div>
240
- {/each}
241
- </div>
242
- </div>
243
- {/if}
244
-
245
- {#if event.description}
246
- <div class="description">
247
- <Icon i="block-quote" />
248
- <span>{event.description}</span>
249
- </div>
250
- {/if}
251
- </Popover>
205
+ <Cal.Event bind:eventEditId bind:eventEditCalId bind:eventInit {event} />
206
+ {/each}
207
+
208
+ {#each recurringEvents.flatMap(ev => ev.recurrences
209
+ .filter(r => r.getFullYear() == day.getFullYear() && r.getMonth() == day.getMonth() && r.getDate() == day.getDate())
210
+ .map( r => [ev, { ...ev, start: r, end: new Date(r.getTime() + ev.end.getTime() - ev.start.getTime()) }] )) as [initData, event]}
211
+ <Cal.Event bind:eventEditId bind:eventEditCalId bind:eventInit {event} {initData} />
252
212
  {/each}
253
213
 
254
214
  {#if today.getTime() == day.getTime()}
@@ -265,7 +225,7 @@
265
225
  <FormDialog
266
226
  id="event-init"
267
227
  clearOnCancel
268
- cancel={() => (eventInit = {} as any)}
228
+ cancel={() => (eventInit = defaultEventInit)}
269
229
  submitText={eventEditId ? 'Update' : 'Create'}
270
230
  submit={async (data: EventInitFormData) => {
271
231
  Object.assign(eventInit, data);
@@ -313,8 +273,22 @@
313
273
  </label>
314
274
  <label for="eventInit.isAllDay:checkbox">All day</label>
315
275
  <div class="spacing"></div>
316
- <select class="recurrence">
317
- <!-- @todo -->
276
+ <select name="recurrence" bind:value={eventInit.recurrence}>
277
+ <option value="">Does not repeat</option>
278
+ <option value="FREQ=DAILY">Every day</option>
279
+ <option value="FREQ=WEEKLY;BYDAY={toByDay(eventInit.start)}">
280
+ Every week on {longWeekDay(eventInit.start)}
281
+ </option>
282
+ <option value="FREQ=MONTHLY;BYDAY={Math.ceil(eventInit.start.getDate() / 7) + toByDay(eventInit.start)}"
283
+ >Every month on the {weekDayOfMonth(eventInit.start)}
284
+ </option>
285
+ <option value="FREQ=MONTHLY;BYMONTHDAY={eventInit.start.getDate()}">
286
+ Every month on the {withOrdinalSuffix(eventInit.start.getDate())}
287
+ </option>
288
+ <option value="FREQ=YEARLY;BYMONTH={eventInit.start.getMonth()};BYMONTHDAY={eventInit.start.getDate()}">
289
+ Every year on {eventInit.start.toLocaleDateString('default', { month: 'long', day: 'numeric' })}
290
+ </option>
291
+ <!-- @todo <option value="">Custom</option> -->
318
292
  </select>
319
293
  </div>
320
294
  </div>
@@ -327,6 +301,7 @@
327
301
  <option value={cal.id}>{cal.name}</option>
328
302
  {/each}
329
303
  </select>
304
+ <ColorPicker bind:value={eventInit.color} defaultValue={defaultEventColor} />
330
305
  </div>
331
306
 
332
307
  <div class="attendees-container">
@@ -449,15 +424,18 @@
449
424
 
450
425
  :global {
451
426
  #event-init form {
452
- width: 25em;
453
-
454
427
  .event-time-options {
455
428
  display: flex;
456
429
  align-items: center;
457
430
  gap: 0.5em;
458
431
 
459
- .spacing {
460
- flex-grow: 1;
432
+ .spacing,
433
+ select {
434
+ flex: auto;
435
+ }
436
+
437
+ label {
438
+ flex: none;
461
439
  }
462
440
  }
463
441
 
@@ -592,43 +570,6 @@
592
570
  }
593
571
  }
594
572
  }
595
-
596
- .event {
597
- width: calc(100% - 0.5em);
598
- position: absolute;
599
- border-radius: 0.5em;
600
- padding: 0.25em;
601
- background-color: var(--bg-alt);
602
- display: flex;
603
- flex-direction: column;
604
- align-items: flex-start;
605
- justify-content: flex-start;
606
- container-type: size;
607
- overflow: hidden;
608
-
609
- @container (height < 2.5em) {
610
- .subtle {
611
- display: none;
612
- }
613
- }
614
-
615
- :global(& + :popover-open) {
616
- gap: 0.75em;
617
- padding: 1em;
618
-
619
- .event-actions {
620
- display: flex;
621
- align-items: center;
622
- justify-content: flex-end;
623
- gap: 0.25em;
624
-
625
- button {
626
- padding: 0.5em;
627
- border-radius: 0.5em;
628
- }
629
- }
630
- }
631
- }
632
573
  }
633
574
  }
634
575
  </style>