@meith/plugin-calendar 0.36.2 → 0.37.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/README.md CHANGED
@@ -54,7 +54,7 @@ the thread as its `URL`, as before.
54
54
 
55
55
  ## What it stores
56
56
 
57
- Two tables in its own namespace: `plugin_calendar_event` and
57
+ The original two tables in its own namespace: `plugin_calendar_event` and
58
58
  `plugin_calendar_organiser`. The link and its text are two columns added by
59
59
  a second migration rather than folded into the first, because the first has
60
60
  already been applied wherever the plugin runs and a migration that has run
@@ -62,3 +62,57 @@ is never edited. The thread id is a plain column, not a foreign
62
62
  key — a plugin's schema may not reference the board's, so an event whose
63
63
  thread has since been deleted simply links to a thread that is not there,
64
64
  rather than blocking the deletion.
65
+
66
+ ## RSVP and recurrence
67
+
68
+ Members can respond Yes, No or Maybe, change their answer, or clear it from
69
+ an occurrence page, with JavaScript disabled. Responses belong to the event
70
+ and its UTC occurrence date, never the entire series. Counts are public to
71
+ board readers; only the member's own status is shown alongside them.
72
+ Organisers and the event creator can see the response list, containing only
73
+ usernames and answers.
74
+
75
+ The third, forward-only migration adds recurrence columns and
76
+ `plugin_calendar_rsvps`, unique on event, occurrence date and user.
77
+ Its only foreign key stays inside the plugin namespace and cascades event
78
+ deletion. Changing a series retains responses for unchanged UTC dates;
79
+ responses for removed dates are no longer shown. Moving a session to a
80
+ different date requires fresh responses.
81
+
82
+ Weekly and fortnightly repeats advance the stored instant by 7 or 14 days.
83
+ Monthly repeats keep the UTC day and time, skipping months without that
84
+ day (January 31 next occurs March 31). The optional until date is inclusive.
85
+ The agenda expands one UTC month at a time, with previous/next navigation.
86
+ The thread card selects from recurring occurrences within a year on either
87
+ side of now. ICS downloads contain the series with a standard RRULE.
88
+
89
+ Calendar inputs and labels use UTC explicitly. Unlike core TimeModel
90
+ timestamps, these plugin labels do not use the viewer's time zone; imported
91
+ calendar entries are displayed in the calendar application's zone. Local
92
+ wall-clock time can shift at daylight-saving boundaries. Reminders are described below.
93
+
94
+ ## Calendar reminders
95
+
96
+ The cron scheduler runs `plugin.calendar.reminders` every five minutes in UTC.
97
+ The operator's **Reminder lead time (hours)** setting defaults to 2 and
98
+ accepts 0–168 hours, including fractions; 0 disables reminders. Members
99
+ who answered Yes or Maybe receive `plugin.calendar.reminder` with a link
100
+ to that occurrence. No and cleared responses receive nothing. Notifications
101
+ appear on the board; email is off by default and members can enable it in
102
+ their notification preferences.
103
+
104
+ The task catches up on unsent reminders inside the lead-time window,
105
+ including late RSVPs, but never sends for an occurrence that has started.
106
+ Delivery depends on the board's system tick. Each run handles up to 100
107
+ recipients, earliest events first; a larger backlog drains on later ticks.
108
+
109
+ A fourth forward-only migration records handled event/date/member triples
110
+ in `plugin_calendar_reminders`. Reading a notification, changing an RSVP,
111
+ or editing the time within the same UTC date does not cause another reminder.
112
+ Moving to a different date creates a new occurrence. Event deletion cascades
113
+ to these records. Deleted members are recorded as skipped.
114
+
115
+ The existing scheduler prevents concurrent normal runs. Failed notification
116
+ sends remain eligible for retry. Sending and recording delivery use separate
117
+ host APIs, so a process crash between them can retry a delivered notification;
118
+ the host coalesces an unread duplicate, but this is not exactly-once delivery.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-calendar",
3
- "version": "0.36.2",
3
+ "version": "0.37.0",
4
4
  "description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -20,8 +20,8 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@meith/plugin-kit": "^0.36.2",
24
- "@meith/ui": "^0.36.2"
23
+ "@meith/plugin-kit": "^0.37.0",
24
+ "@meith/ui": "^0.37.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "react": "^19.2.0"
@@ -6,9 +6,11 @@ import {
6
6
  handleDeleteEvent,
7
7
  handleEventIcs,
8
8
  handleRemoveOrganiser,
9
+ handleRsvp,
9
10
  handleUpdateEvent,
10
11
  } from './handlers'
11
12
  import en from './messages/en.json'
13
+ import { sendReminders } from './reminders'
12
14
  import { CALENDAR_MIGRATIONS } from './schema'
13
15
  import { OrganisersPage } from './ui/admin'
14
16
  import { CalendarPage } from './ui/page'
@@ -20,12 +22,21 @@ export const calendarPlugin = definePlugin({
20
22
  key: 'calendar',
21
23
  name: en['calendar.definition.name'],
22
24
  nameKey: 'calendar.definition.name',
23
- version: '0.36.2',
25
+ version: '0.37.0',
24
26
  description: en['calendar.definition.description'],
25
27
  descriptionKey: 'calendar.definition.description',
26
28
  apiVersion: '0',
27
29
 
28
30
  settings: [
31
+ {
32
+ key: 'reminder_hours',
33
+ label: en['calendar.setting.reminders.label'],
34
+ labelKey: 'calendar.setting.reminders.label',
35
+ description: en['calendar.setting.reminders.description'],
36
+ descriptionKey: 'calendar.setting.reminders.description',
37
+ type: 'number',
38
+ default: 2,
39
+ },
29
40
  {
30
41
  key: 'any_member_may_add',
31
42
  label: en['calendar.setting.anyMember.label'],
@@ -39,6 +50,19 @@ export const calendarPlugin = definePlugin({
39
50
 
40
51
  migrations: CALENDAR_MIGRATIONS,
41
52
 
53
+ tasks: [{ id: 'reminders', schedule: '*/5 * * * *', run: sendReminders }],
54
+
55
+ notifications: [
56
+ {
57
+ key: 'reminder',
58
+ title: en['calendar.reminder.title'],
59
+ titleKey: 'calendar.reminder.title',
60
+ description: en['calendar.reminder.description'],
61
+ descriptionKey: 'calendar.reminder.description',
62
+ emailByDefault: false,
63
+ },
64
+ ],
65
+
42
66
  pages: [
43
67
  {
44
68
  path: '',
@@ -69,6 +93,13 @@ export const calendarPlugin = definePlugin({
69
93
  ],
70
94
 
71
95
  routes: [
96
+ {
97
+ path: 'events/rsvp',
98
+ method: 'POST',
99
+ access: 'member',
100
+ rateLimit: ADD_RATE_LIMIT,
101
+ handler: handleRsvp,
102
+ },
72
103
  {
73
104
  path: 'events',
74
105
  method: 'POST',
package/src/events.ts CHANGED
@@ -8,7 +8,18 @@ export const MAX_LINK_URL = 500
8
8
 
9
9
  export const MAX_DURATION_HOURS = 24 * 14
10
10
 
11
+ export const REPEATS = ['none', 'weekly', 'fortnightly', 'monthly'] as const
12
+ export const REPEAT_LABELS = {
13
+ none: 'calendar.repeat.none',
14
+ weekly: 'calendar.repeat.weekly',
15
+ fortnightly: 'calendar.repeat.fortnightly',
16
+ monthly: 'calendar.repeat.monthly',
17
+ } as const
18
+ export type Repeat = (typeof REPEATS)[number]
19
+
11
20
  export interface EventDraft {
21
+ readonly repeat?: Repeat
22
+ readonly repeatUntil?: string | null
12
23
  readonly title: string
13
24
  readonly startsAt: Date
14
25
  readonly endsAt: Date | null
@@ -19,6 +30,8 @@ export interface EventDraft {
19
30
  }
20
31
 
21
32
  export type DraftProblem =
33
+ | 'repeat-invalid'
34
+ | 'until-invalid'
22
35
  | 'title-missing'
23
36
  | 'title-too-long'
24
37
  | 'location-too-long'
@@ -31,6 +44,8 @@ export type DraftProblem =
31
44
  | 'link-label-without-link'
32
45
 
33
46
  export interface CalendarEvent {
47
+ readonly repeat?: Repeat
48
+ readonly repeatUntil?: string | null
34
49
  readonly id: string
35
50
  readonly title: string
36
51
  readonly startsAt: Date
@@ -65,7 +80,9 @@ function parseDate(raw: string): Date | null {
65
80
  const trimmed = raw.trim()
66
81
  if (trimmed === '') return null
67
82
 
68
- const parsed = new Date(trimmed)
83
+ const parsed = new Date(
84
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/.test(trimmed) ? trimmed + 'Z' : trimmed,
85
+ )
69
86
  return Number.isNaN(parsed.getTime()) ? null : parsed
70
87
  }
71
88
 
@@ -115,10 +132,24 @@ export function readDraft(form: Readonly<Record<string, string>>): {
115
132
  if (linkLabel.length > MAX_LINK_LABEL) problems.push('link-label-too-long')
116
133
  if (linkLabel !== '' && rawLink === '') problems.push('link-label-without-link')
117
134
 
135
+ const repeat = form.repeat ?? 'none'
136
+ const repeatUntil = form.repeat_until?.trim() || null
137
+ if (!REPEATS.includes(repeat as Repeat)) problems.push('repeat-invalid')
138
+ if (
139
+ repeatUntil !== null &&
140
+ (!validDay(repeatUntil) ||
141
+ repeat === 'none' ||
142
+ (startsAt !== null && repeatUntil < startsAt.toISOString().slice(0, 10)))
143
+ ) {
144
+ problems.push('until-invalid')
145
+ }
146
+
118
147
  if (problems.length > 0 || startsAt === null) return { draft: null, problems }
119
148
 
120
149
  return {
121
150
  draft: {
151
+ repeat: repeat as Repeat,
152
+ repeatUntil,
122
153
  title,
123
154
  startsAt,
124
155
  endsAt,
@@ -247,3 +278,67 @@ export function byStart(a: CalendarEvent, b: CalendarEvent): number {
247
278
  export function eventHref(event: CalendarEvent): string | null {
248
279
  return event.threadId === null ? null : `/thread/${event.threadId}`
249
280
  }
281
+
282
+ export function validDay(day: string): boolean {
283
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false
284
+ const date = new Date(day + 'T00:00:00Z')
285
+ return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === day
286
+ }
287
+
288
+ export function occurrences(event: CalendarEvent, from: Date, to: Date): CalendarEvent[] {
289
+ const result: CalendarEvent[] = []
290
+ const repeat = event.repeat ?? 'none'
291
+ const start = event.startsAt
292
+ const duration = event.endsAt === null ? 0 : event.endsAt.getTime() - start.getTime()
293
+ const step = (repeat === 'fortnightly' ? 14 : 7) * 86_400_000
294
+ let index =
295
+ repeat === 'monthly'
296
+ ? Math.max(
297
+ 0,
298
+ (from.getUTCFullYear() - start.getUTCFullYear()) * 12 +
299
+ from.getUTCMonth() -
300
+ start.getUTCMonth(),
301
+ )
302
+ : repeat === 'none'
303
+ ? 0
304
+ : Math.max(0, Math.floor((from.getTime() - start.getTime()) / step))
305
+ while (true) {
306
+ const date = new Date(start)
307
+ if (repeat === 'monthly') {
308
+ date.setUTCDate(1)
309
+ date.setUTCMonth(start.getUTCMonth() + index)
310
+ const month = date.getUTCMonth()
311
+ date.setUTCDate(start.getUTCDate())
312
+ if (date.getUTCMonth() !== month) {
313
+ index++
314
+ if (date >= to) break
315
+ continue
316
+ }
317
+ } else if (repeat !== 'none') date.setTime(start.getTime() + index * step)
318
+ if (
319
+ !Number.isFinite(date.getTime()) ||
320
+ date >= to ||
321
+ (event.repeatUntil && date.toISOString().slice(0, 10) > event.repeatUntil)
322
+ )
323
+ break
324
+ if (date >= from)
325
+ result.push({
326
+ ...event,
327
+ startsAt: date,
328
+ endsAt: event.endsAt === null ? null : new Date(date.getTime() + duration),
329
+ })
330
+ if (repeat === 'none') break
331
+ index++
332
+ }
333
+ return result
334
+ }
335
+
336
+ export function occurrenceOn(event: CalendarEvent, day: string): CalendarEvent | null {
337
+ if (!validDay(day)) return null
338
+ const from = new Date(day + 'T00:00:00Z')
339
+ return occurrences(event, from, new Date(from.getTime() + 86_400_000))[0] ?? null
340
+ }
341
+
342
+ export function occurrenceHref(event: CalendarEvent): string {
343
+ return `/plugins/calendar?event=${event.id}&occurrence=${event.startsAt.toISOString().slice(0, 10)}`
344
+ }
package/src/handlers.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { PluginRequest, PluginResponse, PluginRuntimeContext } from '@meith/plugin-kit'
2
2
 
3
3
  import { mayAdd, mayManage, resolveCalendarConfig } from './access'
4
- import { type CalendarEvent, readDraft } from './events'
4
+ import { type CalendarEvent, occurrenceHref, occurrenceOn, readDraft } from './events'
5
5
  import { ICS_CONTENT_TYPE, toIcs } from './ics'
6
6
  import {
7
7
  addOrganiser,
@@ -9,7 +9,10 @@ import {
9
9
  deleteEvent,
10
10
  eventById,
11
11
  organiserIds,
12
+ RSVP_STATUSES,
13
+ type RsvpStatus,
12
14
  removeOrganiser,
15
+ saveRsvp,
13
16
  updateEvent,
14
17
  } from './store'
15
18
 
@@ -138,3 +141,27 @@ export async function handleRemoveOrganiser(
138
141
  await removeOrganiser(context.data, userId)
139
142
  return { kind: 'redirect', to: '/admin/plugins/calendar/organisers' }
140
143
  }
144
+
145
+ export async function handleRsvp(
146
+ request: PluginRequest,
147
+ context: PluginRuntimeContext,
148
+ ): Promise<PluginResponse> {
149
+ if (request.viewer.userId === null) return refused(403, 'guest')
150
+ const form = request.form
151
+ if (form === null) return refused(400, 'form-required')
152
+ if (!/^\d+$/.test(form.id ?? '')) return refused(400, 'id-required')
153
+ if (form.status !== 'clear' && !RSVP_STATUSES.includes(form.status as RsvpStatus)) {
154
+ return refused(400, 'status-invalid')
155
+ }
156
+ const series = await eventById(context.data, form.id ?? '')
157
+ if (series === null) return refused(404, 'no-such-event')
158
+ const event = occurrenceOn(series, form.occurrence ?? '')
159
+ if (event === null) return refused(400, 'occurrence-invalid')
160
+ await saveRsvp(
161
+ context.data,
162
+ event,
163
+ request.viewer.userId,
164
+ form.status === 'clear' ? null : (form.status as RsvpStatus),
165
+ )
166
+ return { kind: 'redirect', to: occurrenceHref(event) }
167
+ }
package/src/ics.ts CHANGED
@@ -77,6 +77,11 @@ export function toIcs(event: CalendarEvent, boardUrl: string, now: Date): string
77
77
  `DTSTAMP:${stamp(now)}`,
78
78
  `DTSTART:${stamp(event.startsAt)}`,
79
79
  `DTEND:${stamp(ends)}`,
80
+ ...((event.repeat ?? 'none') === 'none'
81
+ ? []
82
+ : [
83
+ `RRULE:FREQ=${event.repeat === 'monthly' ? 'MONTHLY' : 'WEEKLY'}${event.repeat === 'fortnightly' ? ';INTERVAL=2' : ''}${event.repeatUntil ? ';UNTIL=' + event.repeatUntil.replaceAll('-', '') + 'T235959Z' : ''}`,
84
+ ]),
80
85
  `SUMMARY:${escapeText(event.title)}`,
81
86
  ...(event.location === '' ? [] : [`LOCATION:${escapeText(event.location)}`]),
82
87
  ...(actionUrl === null ? [] : [`URL:${escapeText(actionUrl)}`]),
@@ -7,6 +7,7 @@
7
7
  "calendar.definition.description": "A shared calendar: events the community can see, linked to the threads that discuss them.",
8
8
  "calendar.definition.name": "Calendar",
9
9
  "calendar.error.notAnOrganiser": "Only an organiser may add an event to this calendar.",
10
+ "calendar.error.reminderHours": "calendar: reminder_hours must be a number between 0 and 168",
10
11
  "calendar.event.add": "Add event",
11
12
  "calendar.event.cancel": "Cancel",
12
13
  "calendar.event.delete": "Delete",
@@ -20,19 +21,43 @@
20
21
  "calendar.event.linkText": "Link text",
21
22
  "calendar.event.linkTextHint": "What the link should say — “Join online”, “Get tickets”.",
22
23
  "calendar.event.location": "Location",
24
+ "calendar.event.repeat": "Repeat",
25
+ "calendar.event.repeatUntil": "Repeat through (UTC date)",
23
26
  "calendar.event.save": "Save changes",
24
27
  "calendar.event.starts": "Starts",
25
28
  "calendar.event.thread": "Thread link or id",
26
29
  "calendar.event.title": "Title",
27
30
  "calendar.event.until": "Until",
31
+ "calendar.event.utc": "Times are UTC. Repeats keep the UTC time; local times may change with daylight saving.",
28
32
  "calendar.nav.label": "Calendar",
29
33
  "calendar.page.empty": "Nothing is scheduled yet.",
30
34
  "calendar.page.emptyPast": "Nothing has happened yet.",
35
+ "calendar.page.months": "Calendar months",
36
+ "calendar.page.next": "Next month",
31
37
  "calendar.page.past": "Past",
38
+ "calendar.page.previous": "Previous month",
32
39
  "calendar.page.title": "Calendar",
33
40
  "calendar.page.upcoming": "Upcoming",
34
41
  "calendar.page.views": "Calendar views",
42
+ "calendar.reminder.body": "Starts at {startsAt} UTC. Open the event to review your response.",
43
+ "calendar.reminder.description": "Upcoming calendar events you answered Yes or Maybe to.",
44
+ "calendar.reminder.subject": "Upcoming event: {title}",
45
+ "calendar.reminder.title": "Event reminders",
46
+ "calendar.repeat.fortnightly": "Fortnightly",
47
+ "calendar.repeat.monthly": "Monthly",
48
+ "calendar.repeat.none": "Does not repeat",
49
+ "calendar.repeat.weekly": "Weekly",
50
+ "calendar.rsvp.attendees": "Responses",
51
+ "calendar.rsvp.clear": "Clear response",
52
+ "calendar.rsvp.maybe": "Maybe",
53
+ "calendar.rsvp.no": "No",
54
+ "calendar.rsvp.own": "Your response",
55
+ "calendar.rsvp.respond": "View event / RSVP",
56
+ "calendar.rsvp.unanswered": "No response",
57
+ "calendar.rsvp.yes": "Yes",
35
58
  "calendar.setting.anyMember.description": "When off, only the organisers named under Admin → Plugins → Calendar may add events.",
36
59
  "calendar.setting.anyMember.label": "Any member may add an event",
60
+ "calendar.setting.reminders.description": "Remind members who answered Yes or Maybe this many hours before an occurrence. From 0 to 168 hours; 0 disables reminders. Checked every five minutes.",
61
+ "calendar.setting.reminders.label": "Reminder lead time (hours)",
37
62
  "calendar.thread.card": "This thread discusses an event."
38
63
  }
@@ -0,0 +1,59 @@
1
+ import type { PluginRuntimeContext } from '@meith/plugin-kit'
2
+
3
+ import { occurrenceHref } from './events'
4
+ import en from './messages/en.json'
5
+ import { windowEvents } from './store'
6
+
7
+ export const REMINDER_BATCH = 100
8
+
9
+ export async function sendReminders(
10
+ context: PluginRuntimeContext,
11
+ now = new Date(),
12
+ ): Promise<void> {
13
+ const hours = context.settings.reminder_hours ?? 2
14
+ if (typeof hours !== 'number' || !Number.isFinite(hours) || hours < 0 || hours > 168) {
15
+ throw new Error(en['calendar.error.reminderHours'])
16
+ }
17
+ if (hours === 0) return
18
+ const events = await windowEvents(
19
+ context.data,
20
+ new Date(now.getTime() + 1),
21
+ new Date(now.getTime() + hours * 3_600_000 + 1),
22
+ )
23
+ let remaining = REMINDER_BATCH
24
+ for (const event of events) {
25
+ const day = event.startsAt.toISOString().slice(0, 10)
26
+ const recipients = await context.data.query<{ user_id: number }>(
27
+ `select r.user_id from plugin_calendar_rsvps r
28
+ where r.event_id = $1 and r.occurrence_date = $2 and r.status in ('yes', 'maybe')
29
+ and not exists (
30
+ select 1 from plugin_calendar_reminders s
31
+ where s.event_id = r.event_id and s.occurrence_date = r.occurrence_date
32
+ and s.user_id = r.user_id
33
+ )
34
+ order by r.user_id limit $3`,
35
+ [event.id, day, remaining],
36
+ )
37
+ for (const { user_id: userId } of recipients) {
38
+ if ((await context.users.byId(userId)) !== null) {
39
+ await context.notify.send({
40
+ userId,
41
+ kind: 'reminder',
42
+ subjectKey: 'calendar.reminder.subject',
43
+ subjectArgs: { title: event.title },
44
+ bodyKey: 'calendar.reminder.body',
45
+ bodyArgs: { startsAt: event.startsAt.toISOString().slice(0, 16).replace('T', ' ') },
46
+ href: occurrenceHref(event),
47
+ dedupeKey: `calendar.reminder:${event.id}:${day}`,
48
+ })
49
+ }
50
+ await context.data.query(
51
+ `insert into plugin_calendar_reminders (event_id, occurrence_date, user_id)
52
+ values ($1, $2, $3) on conflict do nothing`,
53
+ [event.id, day, userId],
54
+ )
55
+ remaining--
56
+ if (remaining === 0) return
57
+ }
58
+ }
59
+ }
package/src/schema.ts CHANGED
@@ -35,4 +35,33 @@ export const CALENDAR_MIGRATIONS: readonly PluginMigration[] = [
35
35
  add column if not exists link_label text not null default ''`,
36
36
  ],
37
37
  },
38
+ {
39
+ id: '0003_recurrence_and_rsvps',
40
+ statements: [
41
+ `alter table plugin_calendar_event
42
+ add column repeat text not null default 'none'
43
+ check (repeat in ('none', 'weekly', 'fortnightly', 'monthly')),
44
+ add column repeat_until date`,
45
+ `create table plugin_calendar_rsvps (
46
+ event_id bigint not null references plugin_calendar_event(id) on delete cascade,
47
+ occurrence_date date not null,
48
+ user_id integer not null,
49
+ status text not null check (status in ('yes', 'no', 'maybe')),
50
+ updated_at timestamptz not null default now(),
51
+ primary key (event_id, occurrence_date, user_id)
52
+ )`,
53
+ ],
54
+ },
55
+ {
56
+ id: '0004_reminder_deliveries',
57
+ statements: [
58
+ `create table plugin_calendar_reminders (
59
+ event_id bigint not null references plugin_calendar_event(id) on delete cascade,
60
+ occurrence_date date not null,
61
+ user_id integer not null,
62
+ processed_at timestamptz not null default now(),
63
+ primary key (event_id, occurrence_date, user_id)
64
+ )`,
65
+ ],
66
+ },
38
67
  ]
package/src/store.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { PluginData } from '@meith/plugin-kit'
2
2
 
3
- import type { CalendarEvent, EventDraft } from './events'
3
+ import { byStart, type CalendarEvent, type EventDraft, occurrences, type Repeat } from './events'
4
4
 
5
5
  interface EventRow extends Record<string, unknown> {
6
+ readonly repeat?: Repeat
7
+ readonly repeat_until?: string | Date | null
6
8
  readonly id: string | number
7
9
  readonly title: string
8
10
  readonly starts_at: Date | string
@@ -16,6 +18,9 @@ interface EventRow extends Record<string, unknown> {
16
18
 
17
19
  function toEvent(row: EventRow): CalendarEvent {
18
20
  return {
21
+ repeat: row.repeat ?? 'none',
22
+ repeatUntil:
23
+ row.repeat_until == null ? null : new Date(row.repeat_until).toISOString().slice(0, 10),
19
24
  id: String(row.id),
20
25
  title: row.title,
21
26
  startsAt: new Date(row.starts_at),
@@ -29,7 +34,7 @@ function toEvent(row: EventRow): CalendarEvent {
29
34
  }
30
35
 
31
36
  const COLUMNS = `id, title, starts_at, ends_at, location, thread_id, created_by_user_id,
32
- link_url, link_label`
37
+ link_url, link_label, repeat, repeat_until`
33
38
 
34
39
  export async function createEvent(
35
40
  data: PluginData,
@@ -38,8 +43,8 @@ export async function createEvent(
38
43
  ): Promise<void> {
39
44
  await data.query(
40
45
  `insert into plugin_calendar_event
41
- (title, starts_at, ends_at, location, thread_id, created_by_user_id, link_url, link_label)
42
- values ($1, $2, $3, $4, $5, $6, $7, $8)`,
46
+ (title, starts_at, ends_at, location, thread_id, created_by_user_id, link_url, link_label, repeat, repeat_until)
47
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
43
48
  [
44
49
  draft.title,
45
50
  draft.startsAt,
@@ -49,6 +54,8 @@ export async function createEvent(
49
54
  createdByUserId,
50
55
  draft.linkUrl,
51
56
  draft.linkLabel,
57
+ draft.repeat ?? 'none',
58
+ draft.repeatUntil ?? null,
52
59
  ],
53
60
  )
54
61
  }
@@ -57,7 +64,7 @@ export async function updateEvent(data: PluginData, id: string, draft: EventDraf
57
64
  await data.query(
58
65
  `update plugin_calendar_event
59
66
  set title = $2, starts_at = $3, ends_at = $4, location = $5, thread_id = $6,
60
- link_url = $7, link_label = $8
67
+ link_url = $7, link_label = $8, repeat = $9, repeat_until = $10
61
68
  where id = $1`,
62
69
  [
63
70
  id,
@@ -68,36 +75,26 @@ export async function updateEvent(data: PluginData, id: string, draft: EventDraf
68
75
  draft.threadId,
69
76
  draft.linkUrl,
70
77
  draft.linkLabel,
78
+ draft.repeat ?? 'none',
79
+ draft.repeatUntil ?? null,
71
80
  ],
72
81
  )
73
82
  }
74
83
 
75
- export async function upcomingEvents(
84
+ export async function windowEvents(
76
85
  data: PluginData,
77
- limit: number,
86
+ from: Date,
87
+ to: Date,
78
88
  ): Promise<readonly CalendarEvent[]> {
79
89
  const rows = await data.query<EventRow>(
80
90
  `select ${COLUMNS} from plugin_calendar_event
81
- where coalesce(ends_at, starts_at) >= now()
82
- order by starts_at
83
- limit $1`,
84
- [limit],
91
+ where starts_at < $2 and
92
+ (starts_at >= $1 or (repeat <> 'none' and
93
+ (repeat_until is null or repeat_until >= $1::date)))
94
+ order by starts_at`,
95
+ [from, to],
85
96
  )
86
- return rows.map(toEvent)
87
- }
88
-
89
- export async function pastEvents(
90
- data: PluginData,
91
- limit: number,
92
- ): Promise<readonly CalendarEvent[]> {
93
- const rows = await data.query<EventRow>(
94
- `select ${COLUMNS} from plugin_calendar_event
95
- where coalesce(ends_at, starts_at) < now()
96
- order by starts_at desc
97
- limit $1`,
98
- [limit],
99
- )
100
- return rows.map(toEvent)
97
+ return rows.flatMap((row) => occurrences(toEvent(row), from, to)).sort(byStart)
101
98
  }
102
99
 
103
100
  export async function eventById(data: PluginData, id: string): Promise<CalendarEvent | null> {
@@ -121,7 +118,13 @@ export async function eventsForThread(
121
118
  limit $2`,
122
119
  [threadId, THREAD_EVENT_SCAN],
123
120
  )
124
- return rows.map(toEvent)
121
+ const now = new Date()
122
+ const from = new Date(now.getTime() - 366 * 86_400_000)
123
+ const to = new Date(now.getTime() + 366 * 86_400_000)
124
+ return rows.flatMap((row) => {
125
+ const event = toEvent(row)
126
+ return event.repeat === 'none' ? [event] : occurrences(event, from, to)
127
+ })
125
128
  }
126
129
 
127
130
  export async function deleteEvent(data: PluginData, id: string): Promise<void> {
@@ -151,3 +154,56 @@ export async function addOrganiser(
151
154
  export async function removeOrganiser(data: PluginData, userId: number): Promise<void> {
152
155
  await data.query(`delete from plugin_calendar_organiser where user_id = $1`, [userId])
153
156
  }
157
+
158
+ export const RSVP_STATUSES = ['yes', 'no', 'maybe'] as const
159
+ export const RSVP_LABELS = {
160
+ yes: 'calendar.rsvp.yes',
161
+ no: 'calendar.rsvp.no',
162
+ maybe: 'calendar.rsvp.maybe',
163
+ clear: 'calendar.rsvp.clear',
164
+ } as const
165
+ export type RsvpStatus = (typeof RSVP_STATUSES)[number]
166
+
167
+ export async function saveRsvp(
168
+ data: PluginData,
169
+ event: CalendarEvent,
170
+ userId: number,
171
+ status: RsvpStatus | null,
172
+ ): Promise<void> {
173
+ const params = [event.id, event.startsAt.toISOString().slice(0, 10), userId]
174
+ if (status === null) {
175
+ await data.query(
176
+ `delete from plugin_calendar_rsvps where event_id = $1 and occurrence_date = $2 and user_id = $3`,
177
+ params,
178
+ )
179
+ } else {
180
+ await data.query(
181
+ `insert into plugin_calendar_rsvps (event_id, occurrence_date, user_id, status)
182
+ values ($1, $2, $3, $4)
183
+ on conflict (event_id, occurrence_date, user_id)
184
+ do update set status = excluded.status, updated_at = now()`,
185
+ [...params, status],
186
+ )
187
+ }
188
+ }
189
+
190
+ export async function rsvpSummary(data: PluginData, event: CalendarEvent, userId: number | null) {
191
+ const params = [event.id, event.startsAt.toISOString().slice(0, 10)]
192
+ const counts = await data.query<{ status: RsvpStatus; count: string }>(
193
+ `select status, count(*) as count from plugin_calendar_rsvps
194
+ where event_id = $1 and occurrence_date = $2 group by status`,
195
+ params,
196
+ )
197
+ const own =
198
+ userId === null
199
+ ? null
200
+ : await data.one<{ status: RsvpStatus }>(
201
+ `select status from plugin_calendar_rsvps
202
+ where event_id = $1 and occurrence_date = $2 and user_id = $3`,
203
+ [...params, userId],
204
+ )
205
+ return {
206
+ counts: Object.fromEntries(counts.map((row) => [row.status, Number(row.count)])),
207
+ own: own?.status ?? null,
208
+ }
209
+ }
package/src/ui/page.tsx CHANGED
@@ -14,15 +14,23 @@ import {
14
14
  eventHref,
15
15
  formatRange,
16
16
  groupByMonth,
17
+ occurrenceHref,
18
+ occurrenceOn,
19
+ REPEAT_LABELS,
20
+ REPEATS,
17
21
  relativeHint,
18
22
  } from '../events'
19
23
  import en from '../messages/en.json'
20
- import { eventById, organiserIds, pastEvents, upcomingEvents } from '../store'
24
+ import {
25
+ eventById,
26
+ organiserIds,
27
+ RSVP_LABELS,
28
+ type RsvpStatus,
29
+ rsvpSummary,
30
+ windowEvents,
31
+ } from '../store'
21
32
  import { EventLink } from './event-link'
22
-
23
- export const UPCOMING_LIMIT = 50
24
-
25
- export const PAST_LIMIT = 30
33
+ import { Rsvp } from './rsvp'
26
34
 
27
35
  function translated(context: PluginPageContext, key: keyof typeof en): string {
28
36
  return context.t.has(key) ? context.t.t(key) : en[key]
@@ -61,7 +69,11 @@ function EventRow({
61
69
  <DateBlock event={event} locale={locale} />
62
70
 
63
71
  <div className="flex min-w-0 flex-col gap-1">
64
- <p className="font-semibold leading-snug [overflow-wrap:anywhere]">{event.title}</p>
72
+ <p className="font-semibold leading-snug [overflow-wrap:anywhere]">
73
+ <a className={textLinkVariants()} href={occurrenceHref(event)}>
74
+ {event.title}
75
+ </a>
76
+ </p>
65
77
 
66
78
  <p className="text-muted-foreground text-sm">
67
79
  <time dateTime={event.startsAt.toISOString()}>
@@ -130,7 +142,7 @@ function Agenda({
130
142
  <ul className="divide-border divide-y">
131
143
  {month.events.map((event) => (
132
144
  <EventRow
133
- key={event.id}
145
+ key={event.id + event.startsAt.toISOString()}
134
146
  event={event}
135
147
  locale={locale}
136
148
  now={now}
@@ -173,7 +185,31 @@ function EventForm({
173
185
  <h2 className="font-semibold">{heading}</h2>
174
186
  {event !== null && <input type="hidden" name="id" value={event.id} />}
175
187
 
188
+ <p className={PLUGIN_NOTE}>{translated(context, 'calendar.event.utc')}</p>
176
189
  <div className="grid gap-3 sm:grid-cols-2">
190
+ <label className="flex flex-col gap-2 text-sm">
191
+ {translated(context, 'calendar.event.repeat')}
192
+ <select
193
+ name="repeat"
194
+ defaultValue={event?.repeat ?? 'none'}
195
+ className={controlVariants()}
196
+ >
197
+ {REPEATS.map((repeat) => (
198
+ <option key={repeat} value={repeat}>
199
+ {translated(context, REPEAT_LABELS[repeat])}
200
+ </option>
201
+ ))}
202
+ </select>
203
+ </label>
204
+ <label className="flex flex-col gap-2 text-sm">
205
+ {translated(context, 'calendar.event.repeatUntil')}
206
+ <input
207
+ type="date"
208
+ name="repeat_until"
209
+ defaultValue={event?.repeatUntil ?? ''}
210
+ className={controlVariants()}
211
+ />
212
+ </label>
177
213
  <label className="flex min-w-0 flex-col gap-2 text-sm sm:col-span-2">
178
214
  {translated(context, 'calendar.event.title')}
179
215
  <input
@@ -267,13 +303,46 @@ export async function CalendarPage(context: PluginPageContext) {
267
303
  const showingPast = context.query.show === 'past'
268
304
  const now = new Date()
269
305
 
306
+ const rawMonth = context.query.month ?? ''
307
+ const month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : now.toISOString().slice(0, 7)
308
+ const from = new Date(month + '-01T00:00:00Z')
309
+ if (showingPast && rawMonth === '') from.setUTCMonth(from.getUTCMonth() - 1)
310
+ const to = new Date(from)
311
+ to.setUTCMonth(to.getUTCMonth() + 1)
312
+ const previous = new Date(from)
313
+ previous.setUTCMonth(previous.getUTCMonth() - 1)
270
314
  const [events, organisers] = await Promise.all([
271
- (showingPast
272
- ? pastEvents(context.data, PAST_LIMIT)
273
- : upcomingEvents(context.data, UPCOMING_LIMIT)
274
- ).catch(() => [] as readonly CalendarEvent[]),
315
+ windowEvents(context.data, from, to).catch(() => [] as readonly CalendarEvent[]),
275
316
  organiserIds(context.data).catch(() => [] as readonly number[]),
276
317
  ])
318
+ const selectedId = context.query.event ?? ''
319
+ const series = /^\d+$/.test(selectedId) ? await eventById(context.data, selectedId) : null
320
+ const selected =
321
+ series === null
322
+ ? null
323
+ : occurrenceOn(series, context.query.occurrence ?? series.startsAt.toISOString().slice(0, 10))
324
+ const summary =
325
+ selected === null ? null : await rsvpSummary(context.data, selected, context.viewer.userId)
326
+ const maySeeAttendees =
327
+ selected !== null &&
328
+ mayManage({
329
+ userId: context.viewer.userId,
330
+ createdByUserId: selected.createdByUserId,
331
+ organisers,
332
+ })
333
+ const attendees =
334
+ selected === null || !maySeeAttendees
335
+ ? []
336
+ : await context.data.query<{ user_id: number; status: RsvpStatus }>(
337
+ `select user_id, status from plugin_calendar_rsvps where event_id = $1 and occurrence_date = $2 order by updated_at`,
338
+ [selected.id, selected.startsAt.toISOString().slice(0, 10)],
339
+ )
340
+ const names = await Promise.all(
341
+ attendees.map(async (row) => ({
342
+ ...row,
343
+ member: await context.users.byId(row.user_id),
344
+ })),
345
+ )
277
346
 
278
347
  const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
279
348
 
@@ -295,6 +364,46 @@ export async function CalendarPage(context: PluginPageContext) {
295
364
 
296
365
  return (
297
366
  <div className="flex flex-col gap-6">
367
+ <p className={PLUGIN_NOTE}>{translated(context, 'calendar.event.utc')}</p>
368
+ {selected !== null && summary !== null && (
369
+ <section className={PLUGIN_CARD}>
370
+ <h2 className="font-semibold">{selected.title}</h2>
371
+ <time dateTime={selected.startsAt.toISOString()}>
372
+ {formatRange(selected.startsAt, selected.endsAt, context.locale)}
373
+ </time>
374
+ {selected.location !== '' && <p>{selected.location}</p>}
375
+ <EventLink event={selected} label={translated(context, 'calendar.event.linkFallback')} />
376
+ <a
377
+ className={textLinkVariants()}
378
+ href={`/api/plugins/calendar/events/ics?id=${selected.id}`}
379
+ >
380
+ {translated(context, 'calendar.event.download')}
381
+ </a>
382
+ <Rsvp event={selected} summary={summary} context={context} form />
383
+ {maySeeAttendees && (
384
+ <div>
385
+ <h3>{translated(context, 'calendar.rsvp.attendees')}</h3>
386
+ <ul>
387
+ {names.map(({ user_id, status, member }) =>
388
+ member === null ? null : (
389
+ <li key={user_id}>
390
+ {member.username}: {translated(context, RSVP_LABELS[status])}
391
+ </li>
392
+ ),
393
+ )}
394
+ </ul>
395
+ </div>
396
+ )}
397
+ </section>
398
+ )}
399
+ <nav className="flex gap-4" aria-label={translated(context, 'calendar.page.months')}>
400
+ <a href={`?month=${previous.toISOString().slice(0, 7)}`}>
401
+ {translated(context, 'calendar.page.previous')}
402
+ </a>
403
+ <a href={`?month=${to.toISOString().slice(0, 7)}`}>
404
+ {translated(context, 'calendar.page.next')}
405
+ </a>
406
+ </nav>
298
407
  <nav aria-label={translated(context, 'calendar.page.views')}>
299
408
  <ul data-nav-tabs className={PLUGIN_TAB_LIST}>
300
409
  <li className="shrink-0">
@@ -0,0 +1,66 @@
1
+ import type { PluginPageContext, PluginRegionContext } from '@meith/plugin-kit'
2
+ import { buttonVariants, textLinkVariants } from '@meith/ui'
3
+
4
+ import { type CalendarEvent, occurrenceHref } from '../events'
5
+ import en from '../messages/en.json'
6
+ import { RSVP_LABELS, RSVP_STATUSES, type rsvpSummary } from '../store'
7
+
8
+ export function Rsvp({
9
+ event,
10
+ summary,
11
+ context,
12
+ form = false,
13
+ }: {
14
+ event: CalendarEvent
15
+ summary: Awaited<ReturnType<typeof rsvpSummary>>
16
+ context: PluginPageContext | PluginRegionContext
17
+ form?: boolean
18
+ }) {
19
+ const t = (key: keyof typeof en) => (context.t.has(key) ? context.t.t(key) : en[key])
20
+ return (
21
+ <div className="flex flex-col gap-2 text-sm">
22
+ <p>
23
+ {RSVP_STATUSES.map((status) => (
24
+ <span key={status} className="mr-3">
25
+ {t(RSVP_LABELS[status])}: {summary.counts[status] ?? 0}
26
+ </span>
27
+ ))}
28
+ </p>
29
+ {context.viewer.userId !== null && (
30
+ <p>
31
+ {t('calendar.rsvp.own')}:{' '}
32
+ {t(summary.own === null ? 'calendar.rsvp.unanswered' : RSVP_LABELS[summary.own])}
33
+ </p>
34
+ )}
35
+ {form && context.viewer.userId !== null ? (
36
+ <form
37
+ method="post"
38
+ action="/api/plugins/calendar/events/rsvp"
39
+ className="flex flex-wrap gap-2"
40
+ >
41
+ <input type="hidden" name="id" value={event.id} />
42
+ <input
43
+ type="hidden"
44
+ name="occurrence"
45
+ value={event.startsAt.toISOString().slice(0, 10)}
46
+ />
47
+ {([...RSVP_STATUSES, 'clear'] as const).map((status) => (
48
+ <button
49
+ key={status}
50
+ type="submit"
51
+ name="status"
52
+ value={status}
53
+ className={buttonVariants({ variant: 'secondary', size: 'sm' })}
54
+ >
55
+ {t(RSVP_LABELS[status])}
56
+ </button>
57
+ ))}
58
+ </form>
59
+ ) : (
60
+ <a className={textLinkVariants()} href={occurrenceHref(event)}>
61
+ {t('calendar.rsvp.respond')}
62
+ </a>
63
+ )}
64
+ </div>
65
+ )
66
+ }
@@ -2,8 +2,9 @@ import type { PluginRegionContext, PluginRuntimeContext } from '@meith/plugin-ki
2
2
 
3
3
  import { type CalendarEvent, formatRange, pickThreadEvent } from '../events'
4
4
  import en from '../messages/en.json'
5
- import { eventsForThread } from '../store'
5
+ import { eventsForThread, rsvpSummary } from '../store'
6
6
  import { EventLink } from './event-link'
7
+ import { Rsvp } from './rsvp'
7
8
 
8
9
  function translated(context: PluginRegionContext, key: keyof typeof en): string {
9
10
  return context.t.has(key) ? context.t.t(key) : en[key]
@@ -12,10 +13,12 @@ function translated(context: PluginRegionContext, key: keyof typeof en): string
12
13
  export async function ThreadEventCard(context: PluginRegionContext) {
13
14
  if (context.subjectId === null) return null
14
15
 
16
+ let summary: Awaited<ReturnType<typeof rsvpSummary>> = { counts: {}, own: null }
15
17
  let event: CalendarEvent | null = null
16
18
  try {
17
19
  const runtime = (await context.runtime()) as PluginRuntimeContext
18
20
  event = pickThreadEvent(await eventsForThread(runtime.data, context.subjectId), new Date())
21
+ if (event !== null) summary = await rsvpSummary(runtime.data, event, context.viewer.userId)
19
22
  } catch {
20
23
  return null
21
24
  }
@@ -34,6 +37,8 @@ export async function ThreadEventCard(context: PluginRegionContext) {
34
37
  </time>
35
38
  {event.location !== '' && <span> · {event.location}</span>}
36
39
  </p>
40
+ <p>{translated(context, 'calendar.event.utc')}</p>
41
+ <Rsvp event={event} summary={summary} context={context} />
37
42
  <EventLink event={event} label={translated(context, 'calendar.event.linkFallback')} />
38
43
  </section>
39
44
  )