@axium/calendar 0.2.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.
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,15 @@
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
70
  },
71
71
  {
72
72
  "delta": true,
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
  */
@@ -472,6 +477,12 @@ declare module '@axium/core/api' {
472
477
  interface $API extends CalendarAPI {
473
478
  }
474
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;
475
486
  export declare function eventToICS(event: Event): string;
476
487
  export declare function eventFromICS(ics: string): Event;
477
488
  export {};
package/dist/common.js CHANGED
@@ -2,6 +2,22 @@ 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
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
+ }
5
21
  export function dayOfYear(date) {
6
22
  const yearStart = new Date(date.getFullYear(), 0, 1);
7
23
  return Math.round((date.getTime() - yearStart.getTime()) / 86400000 + 1);
@@ -27,6 +43,13 @@ export function weekDaysFor(date) {
27
43
  }
28
44
  return days;
29
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
+ }
30
53
  /**
31
54
  * Converts a date to the format expected by `<input type="datetime-local">`
32
55
  */
@@ -154,19 +177,31 @@ const CalendarAPI = {
154
177
  },
155
178
  };
156
179
  Object.assign($API, CalendarAPI);
157
- function formatDate(date) {
158
- 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();
159
193
  }
160
194
  export function eventToICS(event) {
161
195
  const lines = [
162
196
  'BEGIN:VCALENDAR',
163
197
  'VERSION:2.0',
164
198
  `PRODID:-//Axium//Calendar ${$pkg.version}//EN`,
199
+ 'CALSCALE:GREGORIAN',
165
200
  'BEGIN:VEVENT',
166
201
  'UID:' + event.id,
167
- 'DTSTAMP:' + formatDate(new Date()),
168
- 'DTSTART:' + formatDate(event.start),
169
- 'DTEND:' + formatDate(event.end),
202
+ 'DTSTAMP:' + toDateTime(new Date()),
203
+ 'DTSTART:' + toDateTime(event.start),
204
+ 'DTEND:' + toDateTime(event.end),
170
205
  'SUMMARY:' + event.summary,
171
206
  ];
172
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
@@ -87,7 +87,10 @@ addRoute({
87
87
  .selectAll()
88
88
  .select(withAttendees)
89
89
  .where('calId', '=', id)
90
- .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
+ ]))
91
94
  .limit(1000)
92
95
  .execute()
93
96
  .then(result => result.map(withEncoded))
@@ -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.2.0",
3
+ "version": "0.3.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "Calendar for Axium",
6
6
  "funding": {
@@ -42,6 +42,7 @@
42
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,22 +1,38 @@
1
1
  <script lang="ts">
2
- import { getEvents, type EventInitFormData } from '@axium/calendar/client';
3
- import type { Calendar, Event, EventData } from '@axium/calendar/common';
4
- import { AttendeeInit, dateToInputValue, formatEventTimes, getCalPermissionsInfo, weekDaysFor } from '@axium/calendar/common';
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';
5
15
  import * as Cal from '@axium/calendar/components';
6
16
  import { contextMenu, dynamicRows } from '@axium/client/attachments';
7
17
  import { AccessControlDialog, ColorPicker, FormDialog, Icon, Popover, UserDiscovery } from '@axium/client/components';
8
18
  import { fetchAPI } from '@axium/client/requests';
9
- import { colorHashHex, encodeColor, decodeColor } from '@axium/core/color';
19
+ import { colorHashHex, encodeColor } from '@axium/core/color';
20
+ import { rrulestr } from 'rrule';
10
21
  import { SvelteDate } from 'svelte/reactivity';
11
22
  import { _throw } from 'utilium';
12
- import z from 'zod';
23
+ import * as z from 'zod';
24
+
13
25
  const { data } = $props();
14
26
 
15
27
  const { user } = data.session;
16
28
 
17
29
  const now = new SvelteDate();
30
+ now.setMilliseconds(0);
18
31
  setInterval(() => now.setTime(Date.now()), 60_000);
19
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
+ );
20
36
 
21
37
  let start = new SvelteDate(data.filter.start);
22
38
  let end = new SvelteDate(data.filter.end);
@@ -33,30 +49,36 @@
33
49
  const span = $state('week');
34
50
  const spanDays = $derived(span == 'week' ? 7 : _throw('Invalid span value'));
35
51
  const weekDays = $derived(weekDaysFor(start));
36
- const eventsForWeekDays = $derived(
37
- Object.groupBy(
38
- events.filter(
39
- e => e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) && e.end > weekDays[0]
40
- ),
41
- ev => ev?.start.getDay()
42
- )
43
- );
44
52
 
45
53
  let dialogs = $state<Record<string, HTMLDialogElement>>({});
46
54
 
47
- const defaultEventInit = {
55
+ const defaultEventInit = $derived<any>({
48
56
  attendees: [],
49
57
  recurrenceExcludes: [],
50
58
  recurrenceId: null,
51
59
  calId: calendars[0]?.id,
52
- } as any;
60
+ start: new Date(defaultStart),
61
+ end: new Date(defaultStart.getTime() + 3600_000),
62
+ });
53
63
 
54
- let eventInit = $state<EventData & { attendees: AttendeeInit[]; calendar?: Calendar }>(defaultEventInit),
64
+ let eventInit = $state<EventInitProp>(defaultEventInit),
55
65
  eventInitStart = $derived(dateToInputValue(eventInit.start)),
56
66
  eventInitEnd = $derived(dateToInputValue(eventInit.end)),
57
67
  eventEditId = $state<string>(),
58
68
  eventEditCalId = $state<string>();
59
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
+
60
82
  const defaultEventColor = $derived((eventInit.calendar || calendars[0])?.color || encodeColor(colorHashHex(user.name)));
61
83
  </script>
62
84
 
@@ -162,6 +184,14 @@
162
184
  <span class="hour empty"></span>
163
185
  </div>
164
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
+ )}
165
195
  <div class="cal-content week">
166
196
  {#each weekDays as day, i}
167
197
  <div class="day">
@@ -172,98 +202,13 @@
172
202
 
173
203
  <div class="day-content">
174
204
  {#each eventsForWeekDays[i] ?? [] as event}
175
- <Popover id="event-popover:{event.id}" onclick={e => e.stopPropagation()}>
176
- {#snippet toggle()}
177
- {@const start = event.start.getHours() * 60 + event.start.getMinutes()}
178
- {@const end = event.end.getHours() * 60 + event.end.getMinutes()}
179
- <div
180
- class="event"
181
- style:top="{start / 14.4}%"
182
- style:height="{(end - start) / 14.4}%"
183
- style="--event-color:{decodeColor(
184
- event.color ||
185
- event.calendar!.color ||
186
- encodeColor(colorHashHex(event.calendar!.name), true)
187
- )}"
188
- >
189
- <span>{event.summary}</span>
190
- <span class="subtle">{formatEventTimes(event)}</span>
191
- </div>
192
- {/snippet}
193
-
194
- <div class="event-actions">
195
- <button
196
- class="reset"
197
- onclick={() => {
198
- eventEditId = event.id;
199
- eventEditCalId = event.calId;
200
- eventInit = event;
201
- }}
202
- command="show-modal"
203
- commandfor="event-init"><Icon i="pencil" /></button
204
- >
205
- <button
206
- class="reset"
207
- onclick={() => (eventEditId = event.id)}
208
- command="show-modal"
209
- commandfor="event-delete"><Icon i="trash-can" /></button
210
- >
211
- <button class="reset" command="hide-popover" commandfor="event-popover:{event.id}"
212
- ><Icon i="xmark" /></button
213
- >
214
- </div>
215
-
216
- <h3>{event.summary}</h3>
217
-
218
- <div>
219
- <Icon i="clock" />
220
- <span>
221
- {#if event.start.getDate() == event.end.getDate()}
222
- {event.start.toLocaleDateString()}, {formatEventTimes(event)}
223
- {:else if event.isAllDay}
224
- {event.start.toLocaleDateString()} - {event.end.toLocaleDateString()}
225
- {:else}
226
- {event.start.toLocaleString()} - {event.end.toLocaleString()}
227
- {/if}
228
- </span>
229
- </div>
230
-
231
- {#if event.location}
232
- <div>
233
- <Icon i="location-dot" />
234
- <span>{event.location}</span>
235
- </div>
236
- {/if}
237
-
238
- <div>
239
- <Icon i="calendar" />
240
- <span>
241
- {#if event.calendar}
242
- {event.calendar.name}
243
- {:else}
244
- <i>Unknown Calendar</i>
245
- {/if}
246
- </span>
247
- </div>
248
-
249
- {#if event.attendees.length}
250
- <div class="attendees-container">
251
- <Icon i="user-group" />
252
- <div class="attendees">
253
- {#each event.attendees ?? [] as attendee (attendee.email)}
254
- <div class="attendee">{attendee.email}</div>
255
- {/each}
256
- </div>
257
- </div>
258
- {/if}
259
-
260
- {#if event.description}
261
- <div class="description">
262
- <Icon i="block-quote" />
263
- <span>{event.description}</span>
264
- </div>
265
- {/if}
266
- </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} />
267
212
  {/each}
268
213
 
269
214
  {#if today.getTime() == day.getTime()}
@@ -328,8 +273,22 @@
328
273
  </label>
329
274
  <label for="eventInit.isAllDay:checkbox">All day</label>
330
275
  <div class="spacing"></div>
331
- <select class="recurrence">
332
- <!-- @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> -->
333
292
  </select>
334
293
  </div>
335
294
  </div>
@@ -465,15 +424,18 @@
465
424
 
466
425
  :global {
467
426
  #event-init form {
468
- width: 25em;
469
-
470
427
  .event-time-options {
471
428
  display: flex;
472
429
  align-items: center;
473
430
  gap: 0.5em;
474
431
 
475
- .spacing {
476
- flex-grow: 1;
432
+ .spacing,
433
+ select {
434
+ flex: auto;
435
+ }
436
+
437
+ label {
438
+ flex: none;
477
439
  }
478
440
  }
479
441
 
@@ -608,43 +570,6 @@
608
570
  }
609
571
  }
610
572
  }
611
-
612
- .event {
613
- width: calc(100% - 0.5em);
614
- position: absolute;
615
- border-radius: 0.5em;
616
- padding: 0.25em;
617
- background-color: var(--event-color, var(--bg-alt));
618
- display: flex;
619
- flex-direction: column;
620
- align-items: flex-start;
621
- justify-content: flex-start;
622
- container-type: size;
623
- overflow: hidden;
624
-
625
- @container (height < 2.5em) {
626
- .subtle {
627
- display: none;
628
- }
629
- }
630
-
631
- :global(& + :popover-open) {
632
- gap: 0.75em;
633
- padding: 1em;
634
-
635
- .event-actions {
636
- display: flex;
637
- align-items: center;
638
- justify-content: flex-end;
639
- gap: 0.25em;
640
-
641
- button {
642
- padding: 0.5em;
643
- border-radius: 0.5em;
644
- }
645
- }
646
- }
647
- }
648
573
  }
649
574
  }
650
575
  </style>