@axium/calendar 0.4.7 → 0.5.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
@@ -107,6 +107,16 @@
107
107
  }
108
108
  }
109
109
  }
110
+ },
111
+ {
112
+ "delta": true,
113
+ "alter_tables": {
114
+ "calendars": {
115
+ "add_columns": {
116
+ "isDefault": { "type": "boolean", "required": true, "default": false }
117
+ }
118
+ }
119
+ }
110
120
  }
111
121
  ],
112
122
  "wipe": ["calendars", "events", "acl.calendars"],
package/dist/common.d.ts CHANGED
@@ -118,12 +118,14 @@ export declare function formatEventTimes(event: Event): string;
118
118
  export declare const CalendarInit: z.ZodObject<{
119
119
  name: z.ZodString;
120
120
  color: any;
121
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
121
122
  }, z.core.$strip>;
122
123
  export interface CalendarInit extends z.infer<typeof CalendarInit> {
123
124
  }
124
125
  export declare const Calendar: z.ZodObject<{
125
126
  name: z.ZodString;
126
127
  color: any;
128
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
127
129
  id: z.ZodUUID;
128
130
  userId: z.ZodUUID;
129
131
  created: z.ZodCoercedDate<unknown>;
@@ -162,9 +164,11 @@ declare const CalendarAPI: {
162
164
  readonly PUT: readonly [z.ZodObject<{
163
165
  name: z.ZodString;
164
166
  color: any;
167
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
165
168
  }, z.core.$strip>, z.ZodObject<{
166
169
  name: z.ZodString;
167
170
  color: any;
171
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
168
172
  id: z.ZodUUID;
169
173
  userId: z.ZodUUID;
170
174
  created: z.ZodCoercedDate<unknown>;
@@ -193,6 +197,7 @@ declare const CalendarAPI: {
193
197
  readonly GET: z.ZodArray<z.ZodObject<{
194
198
  name: z.ZodString;
195
199
  color: any;
200
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
196
201
  id: z.ZodUUID;
197
202
  userId: z.ZodUUID;
198
203
  created: z.ZodCoercedDate<unknown>;
@@ -223,6 +228,7 @@ declare const CalendarAPI: {
223
228
  readonly GET: z.ZodObject<{
224
229
  name: z.ZodString;
225
230
  color: any;
231
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
226
232
  id: z.ZodUUID;
227
233
  userId: z.ZodUUID;
228
234
  created: z.ZodCoercedDate<unknown>;
@@ -251,9 +257,11 @@ declare const CalendarAPI: {
251
257
  readonly PATCH: readonly [z.ZodObject<{
252
258
  name: z.ZodString;
253
259
  color: any;
260
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
254
261
  }, z.core.$strip>, z.ZodObject<{
255
262
  name: z.ZodString;
256
263
  color: any;
264
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
257
265
  id: z.ZodUUID;
258
266
  userId: z.ZodUUID;
259
267
  created: z.ZodCoercedDate<unknown>;
@@ -282,6 +290,7 @@ declare const CalendarAPI: {
282
290
  readonly DELETE: z.ZodObject<{
283
291
  name: z.ZodString;
284
292
  color: any;
293
+ isDefault: z.ZodOptional<z.ZodCoercedBoolean<unknown>>;
285
294
  id: z.ZodUUID;
286
295
  userId: z.ZodUUID;
287
296
  created: z.ZodCoercedDate<unknown>;
@@ -478,8 +487,14 @@ declare module '@axium/core/api' {
478
487
  * Convert a `Date` to an iCalendar datetime
479
488
  */
480
489
  export declare function toDateTime(date: Date): string;
490
+ export type ByDay = 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA';
481
491
  /** e.g. `FR`, `SA` */
482
- export declare function toByDay(date: Date): string;
492
+ export declare function toByDay(date: Date): ByDay;
493
+ export declare const weekdayInfo: {
494
+ narrow: string;
495
+ short: string;
496
+ rrule: ByDay;
497
+ }[];
483
498
  export declare function eventToICS(event: Event): string;
484
499
  export declare function eventFromICS(ics: string): Event;
485
500
  export {};
package/dist/common.js CHANGED
@@ -106,7 +106,6 @@ export const EventData = z.object({
106
106
  isAllDay: z.coerce.boolean(),
107
107
  description: z.string().max(2000).nullish(),
108
108
  color: Color.nullish(),
109
- // note: recurrences are not support yet
110
109
  recurrence: z.string().max(1000).nullish(),
111
110
  recurrenceExcludes: z.string().max(100).array().max(100).nullish(),
112
111
  recurrenceId: z.uuid().nullish(),
@@ -132,6 +131,7 @@ export function formatEventTimes(event) {
132
131
  export const CalendarInit = z.object({
133
132
  name: z.string(),
134
133
  color: Color.nullish(),
134
+ isDefault: z.coerce.boolean().optional(),
135
135
  });
136
136
  export const Calendar = CalendarInit.extend({
137
137
  id: z.uuid(),
@@ -191,6 +191,17 @@ export function toDateTime(date) {
191
191
  export function toByDay(date) {
192
192
  return date.toLocaleString('en', { weekday: 'short' }).slice(0, 2).toUpperCase();
193
193
  }
194
+ // Generate weekday labels/names via Intl — Sunday-anchored (getDay() === 0)
195
+ const sunday = new Date(2000, 0, 2); // a known Sunday
196
+ export const weekdayInfo = Array.from({ length: 7 }, (_, i) => {
197
+ const d = new Date(sunday);
198
+ d.setDate(sunday.getDate() + i);
199
+ return {
200
+ narrow: d.toLocaleString('default', { weekday: 'narrow' }),
201
+ short: d.toLocaleString('default', { weekday: 'short' }),
202
+ rrule: toByDay(d),
203
+ };
204
+ });
194
205
  export function eventToICS(event) {
195
206
  const lines = [
196
207
  'BEGIN:VCALENDAR',
@@ -40,9 +40,17 @@ addRoute({
40
40
  async PUT(request, { id: userId }) {
41
41
  const init = await parseBody(request, CalendarInit);
42
42
  await checkAuthForUser(request, userId);
43
+ // A user's first calendar becomes their default so a default always exists.
44
+ const existing = await database
45
+ .selectFrom('calendars')
46
+ .select('id')
47
+ .where('userId', '=', userId)
48
+ .limit(1)
49
+ .executeTakeFirst()
50
+ .catch(withError('Could not create calendar'));
43
51
  return withEncoded(await database
44
52
  .insertInto('calendars')
45
- .values({ ...withDecoded(init), userId })
53
+ .values({ ...withDecoded(init), userId, isDefault: !existing })
46
54
  .returningAll()
47
55
  .executeTakeFirstOrThrow()
48
56
  .catch(withError('Could not create calendar')));
@@ -57,14 +65,38 @@ addRoute({
57
65
  },
58
66
  async PATCH(request, { id }) {
59
67
  const body = await parseBody(request, CalendarInit);
60
- await authRequestForItem(request, 'calendars', id, { edit: true });
61
- return withEncoded(await database
62
- .updateTable('calendars')
63
- .set(withDecoded(body))
64
- .where('id', '=', id)
65
- .returningAll()
66
- .executeTakeFirstOrThrow()
67
- .catch(withError('Could not update calendar')));
68
+ const { item } = await authRequestForItem(request, 'calendars', id, { edit: true });
69
+ if (!body.isDefault) {
70
+ return withEncoded(await database
71
+ .updateTable('calendars')
72
+ .set(withDecoded(body))
73
+ .where('id', '=', id)
74
+ .returningAll()
75
+ .executeTakeFirstOrThrow()
76
+ .catch(withError('Could not update calendar')));
77
+ }
78
+ const tx = await database.startTransaction().execute();
79
+ try {
80
+ // A calendar being made default must be the only default for its owner, so clear the others first.
81
+ await tx
82
+ .updateTable('calendars')
83
+ .set({ isDefault: false })
84
+ .where('userId', '=', item.userId)
85
+ .where('isDefault', '=', true)
86
+ .execute();
87
+ const calendar = await tx
88
+ .updateTable('calendars')
89
+ .set(withDecoded(body))
90
+ .where('id', '=', id)
91
+ .returningAll()
92
+ .executeTakeFirstOrThrow();
93
+ await tx.commit().execute();
94
+ return withEncoded(calendar);
95
+ }
96
+ catch (e) {
97
+ await tx.rollback().execute();
98
+ throw withError('Could not update calendar')(e);
99
+ }
68
100
  },
69
101
  async DELETE(request, { id }) {
70
102
  await authRequestForItem(request, 'calendars', id, { manage: true });
package/locales/en.json CHANGED
@@ -13,7 +13,9 @@
13
13
  "list_owned": "My Calendars",
14
14
  "list_shared": "Shared Calendars",
15
15
  "delete_confirm": "Are you sure you want to delete the calendar \"{name}\"?",
16
- "edit": "Edit"
16
+ "edit": "Edit",
17
+ "make_default": "Make default",
18
+ "default": "(default)"
17
19
  },
18
20
  "calendar_init": {
19
21
  "name": "Name",
@@ -23,6 +25,29 @@
23
25
  "event_delete": {
24
26
  "confirm": "Are you sure you want to delete this event?"
25
27
  },
28
+ "RecurrenceDialog": {
29
+ "title": "Custom recurrence",
30
+ "confirm": "Done",
31
+ "repeat_every": "Repeat every",
32
+ "on": "On",
33
+ "freq": {
34
+ "day": "day",
35
+ "week": "week",
36
+ "month": "month",
37
+ "year": "year"
38
+ },
39
+ "monthly": {
40
+ "on_day": "On the {day}",
41
+ "on_weekday": "On the {day}"
42
+ },
43
+ "end": {
44
+ "label": "End",
45
+ "never": "Never",
46
+ "on": "On",
47
+ "after": "After",
48
+ "occurrences": "occurrences"
49
+ }
50
+ },
26
51
  "event_init": {
27
52
  "all_day": "All day",
28
53
  "description_placeholder": "Add description",
@@ -32,7 +57,8 @@
32
57
  "monthly_on": "Every month on the {day}",
33
58
  "none": "Does not repeat",
34
59
  "weekly": "Every week on {day}",
35
- "yearly": "Every year on {date}"
60
+ "yearly": "Every year on {date}",
61
+ "custom": "Custom..."
36
62
  },
37
63
  "submit_edit": "Update",
38
64
  "title_placeholder": "Add title",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/calendar",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "Calendar for Axium",
6
6
  "funding": {
@@ -42,7 +42,7 @@
42
42
  "@axium/core": ">=0.23.0",
43
43
  "@axium/server": ">=0.47.0",
44
44
  "@sveltejs/kit": "^2.27.3",
45
- "kysely": "^0.28.15",
45
+ "kysely": "^0.29.0",
46
46
  "utilium": "^3.0.0"
47
47
  },
48
48
  "dependencies": {
@@ -1,30 +1,20 @@
1
1
  <script lang="ts">
2
2
  import { getEvents, type EventInitFormData, type EventInitProp } from '@axium/calendar/client';
3
- import type { Event } from '@axium/calendar/common';
4
- import {
5
- CalendarInit,
6
- dateToInputValue,
7
- fromRRuleDate,
8
- getCalPermissionsInfo,
9
- longWeekDay,
10
- toByDay,
11
- toRRuleDate,
12
- weekDayOfMonth,
13
- weekDaysFor,
14
- withOrdinalSuffix,
15
- } from '@axium/calendar/common';
3
+ import type { Calendar, Event } from '@axium/calendar/common';
4
+ import { CalendarInit, dateToInputValue, fromRRuleDate, getCalPermissionsInfo, toRRuleDate, weekDaysFor } from '@axium/calendar/common';
16
5
  import * as Cal from '@axium/calendar/components';
17
6
  import { fetchAPI, text } from '@axium/client';
18
7
  import { contextMenu, dynamicRows } from '@axium/client/attachments';
19
8
  import { AccessControlDialog, ColorPicker, Discovery, discovery, FormDialog, Icon, Popover } from '@axium/client/components';
20
9
  import { toast } from '@axium/client/toast';
21
- import { colorHashHex, encodeColor } from '@axium/core/color';
10
+ import { colorHashHex, decodeColor, encodeColor } from '@axium/core/color';
22
11
  import { rrulestr } from 'rrule';
23
12
  import { useSwipe } from 'svelte-gestures';
24
- import { SvelteDate } from 'svelte/reactivity';
13
+ import { SvelteDate, SvelteSet } from 'svelte/reactivity';
25
14
  import { _throw } from 'utilium';
26
15
  import * as z from 'zod';
27
16
  import './cal.css';
17
+ import RecurrenceSelect from './RecurrenceSelect.svelte';
28
18
 
29
19
  const { data } = $props();
30
20
 
@@ -55,13 +45,16 @@
55
45
  const spanDays = $derived(span == 'week' ? 7 : _throw('Invalid span value'));
56
46
  const weekDays = $derived(weekDaysFor(start));
57
47
 
58
- let dialogs = $state<Record<string, HTMLDialogElement>>({});
48
+ let dialogs = $state<Record<string, HTMLDialogElement>>({}),
49
+ hiddenCalIds = new SvelteSet<string>();
50
+
51
+ const defaultCalendar = $derived(calendars.find(cal => cal.userId == user.id && cal.isDefault) ?? calendars[0]);
59
52
 
60
53
  const defaultEventInit = $derived<any>({
61
54
  attendees: [],
62
55
  recurrenceExcludes: [],
63
56
  recurrenceId: null,
64
- calId: calendars[0]?.id,
57
+ calId: defaultCalendar?.id,
65
58
  start: new Date(defaultStart),
66
59
  end: new Date(defaultStart.getTime() + 3600_000),
67
60
  });
@@ -81,7 +74,7 @@
81
74
 
82
75
  const recurringEvents = $derived(
83
76
  events
84
- .filter(ev => ev.recurrence)
77
+ .filter(ev => ev.recurrence && !hiddenCalIds.has(ev.calId))
85
78
  .map(ev => {
86
79
  const rule = rrulestr('RRULE:' + ev.recurrence, { dtstart: toRRuleDate(ev.start) });
87
80
  const recurrences = rule
@@ -92,7 +85,13 @@
92
85
  );
93
86
 
94
87
  const defaultCalColor = encodeColor(colorHashHex(user.name));
95
- const defaultEventColor = $derived((eventInit.calendar || calendars[0])?.color || defaultCalColor);
88
+ const defaultEventColor = $derived((eventInit.calendar || defaultCalendar)?.color || defaultCalColor);
89
+
90
+ async function makeDefault(cal: Calendar) {
91
+ const result = await fetchAPI('PATCH', 'calendars/:id', { ...cal, isDefault: true }, cal.id);
92
+ for (const other of calendars) other.isDefault = other.userId == user.id && other.id == cal.id;
93
+ Object.assign(cal, result);
94
+ }
96
95
 
97
96
  let calSidebar = $state<HTMLDivElement>();
98
97
  </script>
@@ -156,11 +155,27 @@
156
155
  class="cal-sidebar-item"
157
156
  {@attach contextMenu(
158
157
  { i: 'pencil', text: text('calendar.edit'), action: edit },
158
+ ...(cal.isDefault ? [] : [{ i: 'star', text: text('calendar.make_default'), action: () => makeDefault(cal) }]),
159
159
  { i: 'user-group', text: text('generic.share'), action: () => dialogs['share:' + cal.id].showModal() },
160
160
  { i: 'trash', text: text('generic.delete'), action: () => dialogs['delete:' + cal.id].showModal() }
161
161
  )}
162
162
  >
163
- <span>{cal.name}</span>
163
+ <span
164
+ class="icon-text"
165
+ role="checkbox"
166
+ aria-checked={!hiddenCalIds.has(cal.id)}
167
+ tabindex="0"
168
+ onclick={() => {
169
+ if (hiddenCalIds.has(cal.id)) hiddenCalIds.delete(cal.id);
170
+ else hiddenCalIds.add(cal.id);
171
+ }}
172
+ >
173
+ <label class="checkbox" style:--cal-color={decodeColor(cal.color || defaultCalColor)}>
174
+ {#if !hiddenCalIds.has(cal.id)}<Icon i="check" --size="1.3em" />{/if}
175
+ </label>
176
+ <span>{cal.name}</span>
177
+ {#if cal.isDefault}<span class="subtle">{text('calendar.default')}</span>{/if}
178
+ </span>
164
179
  <Popover showToggle="hover">
165
180
  <button
166
181
  class="reset menu-item"
@@ -174,6 +189,12 @@
174
189
  <Icon i="pencil" />
175
190
  <span>{text('calendar.edit')}</span>
176
191
  </button>
192
+ {#if !cal.isDefault}
193
+ <div class="menu-item" onclick={() => makeDefault(cal)}>
194
+ <Icon i="star" />
195
+ <span>{text('calendar.make_default')}</span>
196
+ </div>
197
+ {/if}
177
198
  <div class="menu-item" onclick={() => dialogs['share:' + cal.id].showModal()}>
178
199
  <Icon i="user-group" />
179
200
  <span>{text('generic.share')}</span>
@@ -245,6 +266,7 @@
245
266
  {@const eventsForWeekDays = Object.groupBy(
246
267
  events.filter(
247
268
  e =>
269
+ !hiddenCalIds.has(e.calId) &&
248
270
  e.start < new Date(weekDays[6].getFullYear(), weekDays[6].getMonth(), weekDays[6].getDate() + 1) &&
249
271
  e.end > weekDays[0]
250
272
  ),
@@ -314,7 +336,6 @@
314
336
  <div class="event-times">
315
337
  <input
316
338
  type="datetime-local"
317
- name="start"
318
339
  id="eventInit.start"
319
340
  bind:value={eventInitStart}
320
341
  onchange={e => (eventInit.start = new Date(e.currentTarget.value))}
@@ -322,7 +343,6 @@
322
343
  />
323
344
  <input
324
345
  type="datetime-local"
325
- name="end"
326
346
  id="eventInit.end"
327
347
  bind:value={eventInitEnd}
328
348
  onchange={e => (eventInit.end = new Date(e.currentTarget.value))}
@@ -335,25 +355,7 @@
335
355
  </label>
336
356
  <label for="eventInit.isAllDay:checkbox">{text('event_init.all_day')}</label>
337
357
  <div class="spacing"></div>
338
- <select name="recurrence" bind:value={eventInit.recurrence}>
339
- <option value="">{text('event_init.recurrence.none')}</option>
340
- <option value="FREQ=DAILY">{text('event_init.recurrence.daily')}</option>
341
- <option value="FREQ=WEEKLY;BYDAY={toByDay(eventInit.start)}">
342
- {text('event_init.recurrence.weekly', { day: longWeekDay(eventInit.start) })}
343
- </option>
344
- <option value="FREQ=MONTHLY;BYDAY={Math.ceil(eventInit.start.getDate() / 7) + toByDay(eventInit.start)}"
345
- >{text('event_init.recurrence.monthly_on', { day: weekDayOfMonth(eventInit.start) })}
346
- </option>
347
- <option value="FREQ=MONTHLY;BYMONTHDAY={eventInit.start.getDate()}">
348
- {text('event_init.recurrence.monthly_on', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
349
- </option>
350
- <option value="FREQ=YEARLY;BYMONTH={eventInit.start.getMonth()};BYMONTHDAY={eventInit.start.getDate()}">
351
- {text('event_init.recurrence.yearly', {
352
- date: eventInit.start.toLocaleDateString('default', { month: 'long', day: 'numeric' }),
353
- })}
354
- </option>
355
- <!-- @todo <option value="">Custom</option> -->
356
- </select>
358
+ <RecurrenceSelect bind:eventInit />
357
359
  </div>
358
360
  </div>
359
361
  </div>
@@ -0,0 +1,201 @@
1
+ <script lang="ts">
2
+ import { withOrdinalSuffix, toByDay, weekDayOfMonth, weekdayInfo } from '@axium/calendar/common';
3
+ import type { EventInitProp } from '@axium/calendar/client';
4
+ import { text } from '@axium/client';
5
+ import { FormDialog } from '@axium/client/components';
6
+ import { SvelteSet } from 'svelte/reactivity';
7
+
8
+ let {
9
+ dialog = $bindable(),
10
+ eventInit = $bindable(),
11
+ }: {
12
+ dialog?: HTMLDialogElement;
13
+ eventInit: EventInitProp;
14
+ } = $props();
15
+
16
+ let interval = $state(1),
17
+ freq = $state<'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY'>('WEEKLY');
18
+
19
+ let byWeekday = $state<SvelteSet<number>>(new SvelteSet());
20
+ $effect(() => {
21
+ byWeekday = new SvelteSet([eventInit.start.getDay()]);
22
+ });
23
+
24
+ let monthlyMode = $state<'day' | 'weekday'>('day');
25
+
26
+ let endType = $state<'never' | 'on' | 'after'>('never'),
27
+ endDate = $state(''),
28
+ endCount = $state(1);
29
+
30
+ function buildRRule(): string {
31
+ const parts: string[] = [`FREQ=${freq}`, `INTERVAL=${interval}`];
32
+
33
+ if (freq === 'WEEKLY') {
34
+ parts.push(
35
+ `BYDAY=${[...byWeekday]
36
+ .sort()
37
+ .map(i => weekdayInfo[i].rrule)
38
+ .join(',')}`
39
+ );
40
+ } else if (freq === 'MONTHLY') {
41
+ if (monthlyMode === 'weekday') {
42
+ const nth = Math.ceil(eventInit.start.getDate() / 7);
43
+ parts.push(`BYDAY=${nth}${toByDay(eventInit.start)}`);
44
+ } else {
45
+ parts.push(`BYMONTHDAY=${eventInit.start.getDate()}`);
46
+ }
47
+ } else if (freq === 'YEARLY') {
48
+ parts.push(`BYMONTH=${eventInit.start.getMonth() + 1};BYMONTHDAY=${eventInit.start.getDate()}`);
49
+ }
50
+
51
+ if (endType === 'on' && endDate) {
52
+ const d = new Date(endDate + 'T00:00:00');
53
+ const pad = (n: number) => String(n).padStart(2, '0');
54
+ parts.push(`UNTIL=${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}T000000Z`);
55
+ } else if (endType === 'after') {
56
+ parts.push(`COUNT=${endCount}`);
57
+ }
58
+
59
+ return parts.join(';');
60
+ }
61
+ </script>
62
+
63
+ <FormDialog
64
+ bind:dialog
65
+ id="recurrence-custom"
66
+ submitText={text('RecurrenceDialog.confirm')}
67
+ submit={async () => (eventInit.recurrence = buildRRule())}
68
+ >
69
+ {#snippet header()}
70
+ <h3>{text('RecurrenceDialog.title')}</h3>
71
+ {/snippet}
72
+
73
+ <div class="row">
74
+ <span>{text('RecurrenceDialog.repeat_every')}</span>
75
+ <input type="number" min="1" max="999" bind:value={interval} />
76
+ <select bind:value={freq}>
77
+ <option value="DAILY">{text('RecurrenceDialog.freq.day', { n: interval })}</option>
78
+ <option value="WEEKLY">{text('RecurrenceDialog.freq.week', { n: interval })}</option>
79
+ <option value="MONTHLY">{text('RecurrenceDialog.freq.month', { n: interval })}</option>
80
+ <option value="YEARLY">{text('RecurrenceDialog.freq.year', { n: interval })}</option>
81
+ </select>
82
+ </div>
83
+
84
+ {#if freq === 'WEEKLY'}
85
+ <div class="on-section">
86
+ <span class="subtle">{text('RecurrenceDialog.on')}</span>
87
+ <div class="weekday-toggles">
88
+ {#each weekdayInfo as { narrow, short }, i}
89
+ <button
90
+ type="button"
91
+ class={['weekday', byWeekday.has(i) && 'active']}
92
+ onclick={() => {
93
+ if (!byWeekday.has(i)) byWeekday.add(i);
94
+ else if (byWeekday.size && i !== eventInit.start.getDay()) byWeekday.delete(i);
95
+ }}
96
+ aria-label={short}
97
+ aria-pressed={byWeekday.has(i)}>{narrow}</button
98
+ >
99
+ {/each}
100
+ </div>
101
+ </div>
102
+ {:else if freq === 'MONTHLY'}
103
+ <div class="on-section">
104
+ <span class="subtle">{text('RecurrenceDialog.on')}</span>
105
+ <div class="monthly-options">
106
+ <label>
107
+ <input type="radio" bind:group={monthlyMode} value="day" />
108
+ {text('RecurrenceDialog.monthly.on_day', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
109
+ </label>
110
+ <label>
111
+ <input type="radio" bind:group={monthlyMode} value="weekday" />
112
+ {text('RecurrenceDialog.monthly.on_weekday', { day: weekDayOfMonth(eventInit.start) })}
113
+ </label>
114
+ </div>
115
+ </div>
116
+ {/if}
117
+
118
+ <div class="end-section">
119
+ <span class="subtle">{text('RecurrenceDialog.end.label')}</span>
120
+ <label>
121
+ <input type="radio" bind:group={endType} value="never" />
122
+ {text('RecurrenceDialog.end.never')}
123
+ </label>
124
+ <label class="end-row">
125
+ <input type="radio" bind:group={endType} value="on" />
126
+ {text('RecurrenceDialog.end.on')}
127
+ <input type="date" bind:value={endDate} disabled={endType !== 'on'} onclick={() => (endType = 'on')} />
128
+ </label>
129
+ <label class="end-row">
130
+ <input type="radio" bind:group={endType} value="after" />
131
+ {text('RecurrenceDialog.end.after')}
132
+ <input
133
+ type="number"
134
+ min="1"
135
+ max="999"
136
+ bind:value={endCount}
137
+ disabled={endType !== 'after'}
138
+ onclick={() => (endType = 'after')}
139
+ />
140
+ {text('RecurrenceDialog.end.occurrences')}
141
+ </label>
142
+ </div>
143
+ </FormDialog>
144
+
145
+ <style>
146
+ h3 {
147
+ margin: 0 0 0.25em;
148
+ padding: 0.75em 1em 0;
149
+ font-size: 1.1em;
150
+ }
151
+
152
+ .row {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 0.5em;
156
+
157
+ input[type='number'] {
158
+ width: 4em;
159
+ }
160
+ }
161
+
162
+ .on-section,
163
+ .end-section {
164
+ display: flex;
165
+ flex-direction: column;
166
+ gap: 0.4em;
167
+ }
168
+
169
+ .weekday-toggles {
170
+ display: flex;
171
+ gap: 0.3em;
172
+ }
173
+
174
+ .weekday {
175
+ width: 2.25em;
176
+ height: 2.25em;
177
+ border-radius: 50%;
178
+ padding: 0;
179
+
180
+ &.active {
181
+ background-color: var(--bg-strong);
182
+ border-color: var(--border-strong);
183
+ }
184
+ }
185
+
186
+ .monthly-options {
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 0.4em;
190
+ }
191
+
192
+ .end-row {
193
+ display: flex;
194
+ align-items: center;
195
+ gap: 0.4em;
196
+
197
+ input[type='number'] {
198
+ width: 4em;
199
+ }
200
+ }
201
+ </style>
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import type { EventInitProp } from '@axium/calendar/client';
3
+ import { longWeekDay, toByDay, weekDayOfMonth, withOrdinalSuffix } from '@axium/calendar/common';
4
+ import { text } from '@axium/client';
5
+ import type { Entries } from 'utilium';
6
+ import RecurrenceDialog from './RecurrenceDialog.svelte';
7
+
8
+ let { eventInit = $bindable() }: { eventInit: EventInitProp } = $props();
9
+
10
+ const recurrences = $derived({
11
+ none: '',
12
+ daily: 'FREQ=DAILY',
13
+ weekly: `FREQ=WEEKLY;BYDAY=${toByDay(eventInit.start)}`,
14
+ monthly_by_day: `FREQ=MONTHLY;BYDAY=${Math.ceil(eventInit.start.getDate() / 7) + toByDay(eventInit.start)}`,
15
+ monthly_by_weekday: `FREQ=MONTHLY;BYMONTHDAY=${eventInit.start.getDate()}`,
16
+ yearly: `FREQ=YEARLY;BYMONTH=${eventInit.start.getMonth()};BYMONTHDAY=${eventInit.start.getDate()}`,
17
+ });
18
+
19
+ const recurrenceKind = $derived(
20
+ !eventInit.recurrence
21
+ ? 'none'
22
+ : (Object.entries(recurrences) as Entries<typeof recurrences>).find(([_, v]) => v === eventInit.recurrence)?.[0] || 'custom'
23
+ );
24
+
25
+ let dialog = $state<HTMLDialogElement>();
26
+ </script>
27
+
28
+ <select
29
+ value={recurrenceKind}
30
+ onchange={e => {
31
+ const { value } = e.currentTarget;
32
+ if (value === 'custom')
33
+ e.currentTarget.value = recurrenceKind; // to prevent "Custom..." being shown when dialog cancelled
34
+ else if (value in recurrences) eventInit.recurrence = recurrences[value as keyof typeof recurrences];
35
+ }}
36
+ >
37
+ <option value="none">{text('event_init.recurrence.none')}</option>
38
+ <option value="daily">{text('event_init.recurrence.daily')}</option>
39
+ <option value="weekly">
40
+ {text('event_init.recurrence.weekly', { day: longWeekDay(eventInit.start) })}
41
+ </option>
42
+ <option value="monthly_by_weekday">
43
+ {text('event_init.recurrence.monthly_on', { day: weekDayOfMonth(eventInit.start) })}
44
+ </option>
45
+ <option value="monthly_by_day">
46
+ {text('event_init.recurrence.monthly_on', { day: withOrdinalSuffix(eventInit.start.getDate()) })}
47
+ </option>
48
+ <option value="yearly">
49
+ {text('event_init.recurrence.yearly', {
50
+ date: eventInit.start.toLocaleDateString('default', { month: 'long', day: 'numeric' }),
51
+ })}
52
+ </option>
53
+ <option value="custom" onclick={() => dialog?.showModal()}>
54
+ {text('event_init.recurrence.custom')}
55
+ </option>
56
+ </select>
57
+
58
+ <RecurrenceDialog bind:dialog bind:eventInit />
@@ -113,7 +113,18 @@ div.attendees {
113
113
  .cal-sidebar-item {
114
114
  display: flex;
115
115
  align-items: center;
116
+ gap: 0.5em;
116
117
  justify-content: space-between;
118
+
119
+ label.checkbox {
120
+ border-color: var(--cal-color);
121
+ flex-shrink: 0;
122
+ }
123
+
124
+ & > span {
125
+ flex: 1;
126
+ cursor: pointer;
127
+ }
117
128
  }
118
129
  }
119
130