@meith/plugin-calendar 0.36.2 → 0.37.1

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,70 @@ 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
+ Upcoming and Past paginate all occurrences across months, 50 per page,
86
+ including ongoing events in Upcoming. Previous/Next page links work without
87
+ JavaScript; no stored events are removed or hidden by a total-count limit. Non-repeating events retain their
88
+ original dates after an upgrade. Previous/next month navigation explicitly
89
+ filters the agenda to one UTC month.
90
+ The thread card selects from recurring occurrences within a year on either
91
+ side of now. ICS downloads contain the series with a standard RRULE.
92
+
93
+ Calendar inputs and labels use UTC explicitly. Unlike core TimeModel
94
+ timestamps, these plugin labels do not use the viewer's time zone; imported
95
+ calendar entries are displayed in the calendar application's zone. Local
96
+ wall-clock time can shift at daylight-saving boundaries. Reminders are described below.
97
+
98
+ ## Calendar reminders
99
+
100
+ The cron scheduler runs `plugin.calendar.reminders` every five minutes in UTC.
101
+ The operator's **Reminder lead time (hours)** setting defaults to 2 and
102
+ accepts 0–168 hours, including fractions; 0 disables reminders. Members
103
+ who answered Yes or Maybe receive `plugin.calendar.reminder` with a link
104
+ to that occurrence. No and cleared responses receive nothing. Notifications
105
+ appear on the board; email is off by default and members can enable it in
106
+ their notification preferences.
107
+
108
+ The task catches up on unsent reminders inside the lead-time window,
109
+ including late RSVPs, but never sends for an occurrence that has started.
110
+ Delivery depends on the board's system tick. Each run handles up to 100
111
+ recipients, earliest events first; a larger backlog drains on later ticks.
112
+
113
+ A fourth forward-only migration records handled event/date/member triples
114
+ in `plugin_calendar_reminders`. Reading a notification, changing an RSVP,
115
+ or editing the time within the same UTC date does not cause another reminder.
116
+ Moving to a different date creates a new occurrence. Event deletion cascades
117
+ to these records. Deleted members are recorded as skipped.
118
+
119
+ The existing scheduler prevents concurrent normal runs. Failed notification
120
+ sends remain eligible for retry. Sending and recording delivery use separate
121
+ host APIs, so a process crash between them can retry a delivered notification;
122
+ the host coalesces an unread duplicate, but this is not exactly-once delivery.
123
+
124
+ Calendar load failures are reported by the host instead of being shown as
125
+ an empty agenda. The agenda expands a bounded range per recurring series
126
+ around the page cursor before sorting and taking the requested count.
127
+ The cursor uses occurrence time and event ID, so simultaneous events can
128
+ span pages without being skipped. Page links use the current clock when
129
+ opened; events may move from Upcoming to Past as time passes. It allows 62 days per
130
+ requested occurrence, covering monthly rules that skip a missing day and
131
+ therefore have gaps of up to 61 days.
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.1",
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.1",
24
+ "@meith/ui": "^0.37.1"
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.1',
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,46 @@
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",
37
+ "calendar.page.nextPage": "Next page",
38
+ "calendar.page.pagination": "Calendar pages",
31
39
  "calendar.page.past": "Past",
40
+ "calendar.page.previous": "Previous month",
41
+ "calendar.page.previousPage": "Previous page",
32
42
  "calendar.page.title": "Calendar",
33
43
  "calendar.page.upcoming": "Upcoming",
34
44
  "calendar.page.views": "Calendar views",
45
+ "calendar.reminder.body": "Starts at {startsAt} UTC. Open the event to review your response.",
46
+ "calendar.reminder.description": "Upcoming calendar events you answered Yes or Maybe to.",
47
+ "calendar.reminder.subject": "Upcoming event: {title}",
48
+ "calendar.reminder.title": "Event reminders",
49
+ "calendar.repeat.fortnightly": "Fortnightly",
50
+ "calendar.repeat.monthly": "Monthly",
51
+ "calendar.repeat.none": "Does not repeat",
52
+ "calendar.repeat.weekly": "Weekly",
53
+ "calendar.rsvp.attendees": "Responses",
54
+ "calendar.rsvp.clear": "Clear response",
55
+ "calendar.rsvp.maybe": "Maybe",
56
+ "calendar.rsvp.no": "No",
57
+ "calendar.rsvp.own": "Your response",
58
+ "calendar.rsvp.respond": "View event / RSVP",
59
+ "calendar.rsvp.unanswered": "No response",
60
+ "calendar.rsvp.yes": "Yes",
35
61
  "calendar.setting.anyMember.description": "When off, only the organisers named under Admin → Plugins → Calendar may add events.",
36
62
  "calendar.setting.anyMember.label": "Any member may add an event",
63
+ "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.",
64
+ "calendar.setting.reminders.label": "Reminder lead time (hours)",
37
65
  "calendar.thread.card": "This thread discusses an event."
38
66
  }
@@ -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,17 @@
1
1
  import type { PluginData } from '@meith/plugin-kit'
2
2
 
3
- import type { CalendarEvent, EventDraft } from './events'
3
+ import {
4
+ byStart,
5
+ type CalendarEvent,
6
+ type EventDraft,
7
+ isUpcoming,
8
+ occurrences,
9
+ type Repeat,
10
+ } from './events'
4
11
 
5
12
  interface EventRow extends Record<string, unknown> {
13
+ readonly repeat?: Repeat
14
+ readonly repeat_until?: string | Date | null
6
15
  readonly id: string | number
7
16
  readonly title: string
8
17
  readonly starts_at: Date | string
@@ -16,6 +25,9 @@ interface EventRow extends Record<string, unknown> {
16
25
 
17
26
  function toEvent(row: EventRow): CalendarEvent {
18
27
  return {
28
+ repeat: row.repeat ?? 'none',
29
+ repeatUntil:
30
+ row.repeat_until == null ? null : new Date(row.repeat_until).toISOString().slice(0, 10),
19
31
  id: String(row.id),
20
32
  title: row.title,
21
33
  startsAt: new Date(row.starts_at),
@@ -29,7 +41,7 @@ function toEvent(row: EventRow): CalendarEvent {
29
41
  }
30
42
 
31
43
  const COLUMNS = `id, title, starts_at, ends_at, location, thread_id, created_by_user_id,
32
- link_url, link_label`
44
+ link_url, link_label, repeat, repeat_until`
33
45
 
34
46
  export async function createEvent(
35
47
  data: PluginData,
@@ -38,8 +50,8 @@ export async function createEvent(
38
50
  ): Promise<void> {
39
51
  await data.query(
40
52
  `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)`,
53
+ (title, starts_at, ends_at, location, thread_id, created_by_user_id, link_url, link_label, repeat, repeat_until)
54
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
43
55
  [
44
56
  draft.title,
45
57
  draft.startsAt,
@@ -49,6 +61,8 @@ export async function createEvent(
49
61
  createdByUserId,
50
62
  draft.linkUrl,
51
63
  draft.linkLabel,
64
+ draft.repeat ?? 'none',
65
+ draft.repeatUntil ?? null,
52
66
  ],
53
67
  )
54
68
  }
@@ -57,7 +71,7 @@ export async function updateEvent(data: PluginData, id: string, draft: EventDraf
57
71
  await data.query(
58
72
  `update plugin_calendar_event
59
73
  set title = $2, starts_at = $3, ends_at = $4, location = $5, thread_id = $6,
60
- link_url = $7, link_label = $8
74
+ link_url = $7, link_label = $8, repeat = $9, repeat_until = $10
61
75
  where id = $1`,
62
76
  [
63
77
  id,
@@ -68,36 +82,73 @@ export async function updateEvent(data: PluginData, id: string, draft: EventDraf
68
82
  draft.threadId,
69
83
  draft.linkUrl,
70
84
  draft.linkLabel,
85
+ draft.repeat ?? 'none',
86
+ draft.repeatUntil ?? null,
71
87
  ],
72
88
  )
73
89
  }
74
90
 
75
- export async function upcomingEvents(
91
+ export async function windowEvents(
76
92
  data: PluginData,
77
- limit: number,
93
+ from: Date,
94
+ to: Date,
78
95
  ): Promise<readonly CalendarEvent[]> {
79
96
  const rows = await data.query<EventRow>(
80
97
  `select ${COLUMNS} from plugin_calendar_event
81
- where coalesce(ends_at, starts_at) >= now()
82
- order by starts_at
83
- limit $1`,
84
- [limit],
98
+ where starts_at < $2 and
99
+ (starts_at >= $1 or (repeat <> 'none' and
100
+ (repeat_until is null or repeat_until >= $1::date)))
101
+ order by starts_at`,
102
+ [from, to],
85
103
  )
86
- return rows.map(toEvent)
104
+ return rows.flatMap((row) => occurrences(toEvent(row), from, to)).sort(byStart)
87
105
  }
88
106
 
89
- export async function pastEvents(
107
+ export async function agendaEvents(
90
108
  data: PluginData,
91
- limit: number,
109
+ now: Date,
110
+ past = false,
111
+ limit = 50,
112
+ cursor?: Pick<CalendarEvent, 'startsAt' | 'id'>,
113
+ backwards = false,
92
114
  ): Promise<readonly CalendarEvent[]> {
93
115
  const rows = await data.query<EventRow>(
94
116
  `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],
117
+ where repeat <> 'none' or coalesce(ends_at, starts_at) ${past ? '<' : '>='} $1`,
118
+ [now],
99
119
  )
100
- return rows.map(toEvent)
120
+ const span = limit * 62 * 86_400_000
121
+ const descending = past !== backwards
122
+ const compare = (
123
+ a: Pick<CalendarEvent, 'startsAt' | 'id'>,
124
+ b: Pick<CalendarEvent, 'startsAt' | 'id'>,
125
+ ) => a.startsAt.getTime() - b.startsAt.getTime() || a.id.localeCompare(b.id)
126
+ const events = rows
127
+ .flatMap((row) => {
128
+ const event = toEvent(row)
129
+ if (event.repeat === 'none') return isUpcoming(event, now) !== past ? [event] : []
130
+ const duration = event.endsAt === null ? 0 : event.endsAt.getTime() - event.startsAt.getTime()
131
+ const boundary = now.getTime() - duration
132
+ const until =
133
+ event.repeatUntil == null
134
+ ? Number.POSITIVE_INFINITY
135
+ : new Date(event.repeatUntil + 'T00:00:00Z').getTime() + 86_400_000
136
+ const lower = Math.max(past ? -Infinity : boundary, event.startsAt.getTime())
137
+ const upper = Math.min(past ? boundary : Infinity, until)
138
+ const start = Math.max(lower, cursor?.startsAt.getTime() ?? lower)
139
+ const end = Math.min(upper, cursor ? cursor.startsAt.getTime() + 1 : upper)
140
+ return occurrences(
141
+ event,
142
+ new Date(descending ? Math.max(lower, end - span) : start),
143
+ new Date(descending ? end : Math.min(upper, start + span)),
144
+ )
145
+ })
146
+ .filter(
147
+ (event) => !cursor || (descending ? compare(event, cursor) < 0 : compare(event, cursor) > 0),
148
+ )
149
+ .sort((a, b) => (descending ? compare(b, a) : compare(a, b)))
150
+ .slice(0, limit)
151
+ return backwards ? events.reverse() : events
101
152
  }
102
153
 
103
154
  export async function eventById(data: PluginData, id: string): Promise<CalendarEvent | null> {
@@ -121,7 +172,13 @@ export async function eventsForThread(
121
172
  limit $2`,
122
173
  [threadId, THREAD_EVENT_SCAN],
123
174
  )
124
- return rows.map(toEvent)
175
+ const now = new Date()
176
+ const from = new Date(now.getTime() - 366 * 86_400_000)
177
+ const to = new Date(now.getTime() + 366 * 86_400_000)
178
+ return rows.flatMap((row) => {
179
+ const event = toEvent(row)
180
+ return event.repeat === 'none' ? [event] : occurrences(event, from, to)
181
+ })
125
182
  }
126
183
 
127
184
  export async function deleteEvent(data: PluginData, id: string): Promise<void> {
@@ -151,3 +208,56 @@ export async function addOrganiser(
151
208
  export async function removeOrganiser(data: PluginData, userId: number): Promise<void> {
152
209
  await data.query(`delete from plugin_calendar_organiser where user_id = $1`, [userId])
153
210
  }
211
+
212
+ export const RSVP_STATUSES = ['yes', 'no', 'maybe'] as const
213
+ export const RSVP_LABELS = {
214
+ yes: 'calendar.rsvp.yes',
215
+ no: 'calendar.rsvp.no',
216
+ maybe: 'calendar.rsvp.maybe',
217
+ clear: 'calendar.rsvp.clear',
218
+ } as const
219
+ export type RsvpStatus = (typeof RSVP_STATUSES)[number]
220
+
221
+ export async function saveRsvp(
222
+ data: PluginData,
223
+ event: CalendarEvent,
224
+ userId: number,
225
+ status: RsvpStatus | null,
226
+ ): Promise<void> {
227
+ const params = [event.id, event.startsAt.toISOString().slice(0, 10), userId]
228
+ if (status === null) {
229
+ await data.query(
230
+ `delete from plugin_calendar_rsvps where event_id = $1 and occurrence_date = $2 and user_id = $3`,
231
+ params,
232
+ )
233
+ } else {
234
+ await data.query(
235
+ `insert into plugin_calendar_rsvps (event_id, occurrence_date, user_id, status)
236
+ values ($1, $2, $3, $4)
237
+ on conflict (event_id, occurrence_date, user_id)
238
+ do update set status = excluded.status, updated_at = now()`,
239
+ [...params, status],
240
+ )
241
+ }
242
+ }
243
+
244
+ export async function rsvpSummary(data: PluginData, event: CalendarEvent, userId: number | null) {
245
+ const params = [event.id, event.startsAt.toISOString().slice(0, 10)]
246
+ const counts = await data.query<{ status: RsvpStatus; count: string }>(
247
+ `select status, count(*) as count from plugin_calendar_rsvps
248
+ where event_id = $1 and occurrence_date = $2 group by status`,
249
+ params,
250
+ )
251
+ const own =
252
+ userId === null
253
+ ? null
254
+ : await data.one<{ status: RsvpStatus }>(
255
+ `select status from plugin_calendar_rsvps
256
+ where event_id = $1 and occurrence_date = $2 and user_id = $3`,
257
+ [...params, userId],
258
+ )
259
+ return {
260
+ counts: Object.fromEntries(counts.map((row) => [row.status, Number(row.count)])),
261
+ own: own?.status ?? null,
262
+ }
263
+ }
package/src/ui/page.tsx CHANGED
@@ -14,15 +14,24 @@ 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
+ agendaEvents,
26
+ eventById,
27
+ organiserIds,
28
+ RSVP_LABELS,
29
+ type RsvpStatus,
30
+ rsvpSummary,
31
+ windowEvents,
32
+ } from '../store'
21
33
  import { EventLink } from './event-link'
22
-
23
- export const UPCOMING_LIMIT = 50
24
-
25
- export const PAST_LIMIT = 30
34
+ import { Rsvp } from './rsvp'
26
35
 
27
36
  function translated(context: PluginPageContext, key: keyof typeof en): string {
28
37
  return context.t.has(key) ? context.t.t(key) : en[key]
@@ -61,7 +70,11 @@ function EventRow({
61
70
  <DateBlock event={event} locale={locale} />
62
71
 
63
72
  <div className="flex min-w-0 flex-col gap-1">
64
- <p className="font-semibold leading-snug [overflow-wrap:anywhere]">{event.title}</p>
73
+ <p className="font-semibold leading-snug [overflow-wrap:anywhere]">
74
+ <a className={textLinkVariants()} href={occurrenceHref(event)}>
75
+ {event.title}
76
+ </a>
77
+ </p>
65
78
 
66
79
  <p className="text-muted-foreground text-sm">
67
80
  <time dateTime={event.startsAt.toISOString()}>
@@ -130,7 +143,7 @@ function Agenda({
130
143
  <ul className="divide-border divide-y">
131
144
  {month.events.map((event) => (
132
145
  <EventRow
133
- key={event.id}
146
+ key={event.id + event.startsAt.toISOString()}
134
147
  event={event}
135
148
  locale={locale}
136
149
  now={now}
@@ -173,7 +186,31 @@ function EventForm({
173
186
  <h2 className="font-semibold">{heading}</h2>
174
187
  {event !== null && <input type="hidden" name="id" value={event.id} />}
175
188
 
189
+ <p className={PLUGIN_NOTE}>{translated(context, 'calendar.event.utc')}</p>
176
190
  <div className="grid gap-3 sm:grid-cols-2">
191
+ <label className="flex flex-col gap-2 text-sm">
192
+ {translated(context, 'calendar.event.repeat')}
193
+ <select
194
+ name="repeat"
195
+ defaultValue={event?.repeat ?? 'none'}
196
+ className={controlVariants()}
197
+ >
198
+ {REPEATS.map((repeat) => (
199
+ <option key={repeat} value={repeat}>
200
+ {translated(context, REPEAT_LABELS[repeat])}
201
+ </option>
202
+ ))}
203
+ </select>
204
+ </label>
205
+ <label className="flex flex-col gap-2 text-sm">
206
+ {translated(context, 'calendar.event.repeatUntil')}
207
+ <input
208
+ type="date"
209
+ name="repeat_until"
210
+ defaultValue={event?.repeatUntil ?? ''}
211
+ className={controlVariants()}
212
+ />
213
+ </label>
177
214
  <label className="flex min-w-0 flex-col gap-2 text-sm sm:col-span-2">
178
215
  {translated(context, 'calendar.event.title')}
179
216
  <input
@@ -267,13 +304,66 @@ export async function CalendarPage(context: PluginPageContext) {
267
304
  const showingPast = context.query.show === 'past'
268
305
  const now = new Date()
269
306
 
270
- 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[]),
307
+ const rawMonth = context.query.month ?? ''
308
+ const filteringMonth = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth)
309
+ const pageSize = 50
310
+ const rawCursor = context.query.after ?? context.query.before ?? ''
311
+ const match = /^(-?\d{1,16}):(\d+)$/.exec(rawCursor)
312
+ const cursor =
313
+ match && Number.isFinite(new Date(Number(match[1])).getTime())
314
+ ? { startsAt: new Date(Number(match[1])), id: match[2]! }
315
+ : undefined
316
+ const backwards = cursor !== undefined && context.query.after === undefined
317
+ const pageHref = (event: CalendarEvent, direction: 'after' | 'before') =>
318
+ `?${showingPast ? 'show=past&' : ''}${direction}=${event.startsAt.getTime()}:${event.id}`
319
+ const month = filteringMonth ? rawMonth : now.toISOString().slice(0, 7)
320
+ const from = new Date(month + '-01T00:00:00Z')
321
+ const to = new Date(from)
322
+ to.setUTCMonth(to.getUTCMonth() + 1)
323
+ const previous = new Date(from)
324
+ previous.setUTCMonth(previous.getUTCMonth() - 1)
325
+ const [results, organisers] = await Promise.all([
326
+ filteringMonth
327
+ ? windowEvents(context.data, from, to)
328
+ : agendaEvents(context.data, now, showingPast, pageSize + 1, cursor, backwards),
275
329
  organiserIds(context.data).catch(() => [] as readonly number[]),
276
330
  ])
331
+ const hasMore = results.length > pageSize
332
+ const events = filteringMonth
333
+ ? results
334
+ : backwards
335
+ ? results.slice(-pageSize)
336
+ : results.slice(0, pageSize)
337
+ const first = events[0]
338
+ const last = events.at(-1)
339
+ const selectedId = context.query.event ?? ''
340
+ const series = /^\d+$/.test(selectedId) ? await eventById(context.data, selectedId) : null
341
+ const selected =
342
+ series === null
343
+ ? null
344
+ : occurrenceOn(series, context.query.occurrence ?? series.startsAt.toISOString().slice(0, 10))
345
+ const summary =
346
+ selected === null ? null : await rsvpSummary(context.data, selected, context.viewer.userId)
347
+ const maySeeAttendees =
348
+ selected !== null &&
349
+ mayManage({
350
+ userId: context.viewer.userId,
351
+ createdByUserId: selected.createdByUserId,
352
+ organisers,
353
+ })
354
+ const attendees =
355
+ selected === null || !maySeeAttendees
356
+ ? []
357
+ : await context.data.query<{ user_id: number; status: RsvpStatus }>(
358
+ `select user_id, status from plugin_calendar_rsvps where event_id = $1 and occurrence_date = $2 order by updated_at`,
359
+ [selected.id, selected.startsAt.toISOString().slice(0, 10)],
360
+ )
361
+ const names = await Promise.all(
362
+ attendees.map(async (row) => ({
363
+ ...row,
364
+ member: await context.users.byId(row.user_id),
365
+ })),
366
+ )
277
367
 
278
368
  const verdict = mayAdd({ userId: context.viewer.userId, config, organisers })
279
369
 
@@ -295,13 +385,53 @@ export async function CalendarPage(context: PluginPageContext) {
295
385
 
296
386
  return (
297
387
  <div className="flex flex-col gap-6">
388
+ <p className={PLUGIN_NOTE}>{translated(context, 'calendar.event.utc')}</p>
389
+ {selected !== null && summary !== null && (
390
+ <section className={PLUGIN_CARD}>
391
+ <h2 className="font-semibold">{selected.title}</h2>
392
+ <time dateTime={selected.startsAt.toISOString()}>
393
+ {formatRange(selected.startsAt, selected.endsAt, context.locale)}
394
+ </time>
395
+ {selected.location !== '' && <p>{selected.location}</p>}
396
+ <EventLink event={selected} label={translated(context, 'calendar.event.linkFallback')} />
397
+ <a
398
+ className={textLinkVariants()}
399
+ href={`/api/plugins/calendar/events/ics?id=${selected.id}`}
400
+ >
401
+ {translated(context, 'calendar.event.download')}
402
+ </a>
403
+ <Rsvp event={selected} summary={summary} context={context} form />
404
+ {maySeeAttendees && (
405
+ <div>
406
+ <h3>{translated(context, 'calendar.rsvp.attendees')}</h3>
407
+ <ul>
408
+ {names.map(({ user_id, status, member }) =>
409
+ member === null ? null : (
410
+ <li key={user_id}>
411
+ {member.username}: {translated(context, RSVP_LABELS[status])}
412
+ </li>
413
+ ),
414
+ )}
415
+ </ul>
416
+ </div>
417
+ )}
418
+ </section>
419
+ )}
420
+ <nav className="flex gap-4" aria-label={translated(context, 'calendar.page.months')}>
421
+ <a href={`?month=${previous.toISOString().slice(0, 7)}`}>
422
+ {translated(context, 'calendar.page.previous')}
423
+ </a>
424
+ <a href={`?month=${to.toISOString().slice(0, 7)}`}>
425
+ {translated(context, 'calendar.page.next')}
426
+ </a>
427
+ </nav>
298
428
  <nav aria-label={translated(context, 'calendar.page.views')}>
299
429
  <ul data-nav-tabs className={PLUGIN_TAB_LIST}>
300
430
  <li className="shrink-0">
301
431
  <a
302
432
  href="/plugins/calendar"
303
- {...(showingPast ? {} : { 'aria-current': 'page' as const })}
304
- className={pluginTabClass(!showingPast)}
433
+ {...(showingPast || filteringMonth ? {} : { 'aria-current': 'page' as const })}
434
+ className={pluginTabClass(!showingPast && !filteringMonth)}
305
435
  >
306
436
  {translated(context, 'calendar.page.upcoming')}
307
437
  </a>
@@ -309,8 +439,8 @@ export async function CalendarPage(context: PluginPageContext) {
309
439
  <li className="shrink-0">
310
440
  <a
311
441
  href="/plugins/calendar?show=past"
312
- {...(showingPast ? { 'aria-current': 'page' as const } : {})}
313
- className={pluginTabClass(showingPast)}
442
+ {...(showingPast && !filteringMonth ? { 'aria-current': 'page' as const } : {})}
443
+ className={pluginTabClass(showingPast && !filteringMonth)}
314
444
  >
315
445
  {translated(context, 'calendar.page.past')}
316
446
  </a>
@@ -332,6 +462,19 @@ export async function CalendarPage(context: PluginPageContext) {
332
462
  />
333
463
  )}
334
464
 
465
+ {!filteringMonth && (cursor !== undefined || hasMore) && (
466
+ <nav className="flex gap-4" aria-label={translated(context, 'calendar.page.pagination')}>
467
+ {first && (backwards ? hasMore : cursor !== undefined) && (
468
+ <a href={pageHref(first, 'before')}>
469
+ {translated(context, 'calendar.page.previousPage')}
470
+ </a>
471
+ )}
472
+ {last && (backwards ? cursor !== undefined : hasMore) && (
473
+ <a href={pageHref(last, 'after')}>{translated(context, 'calendar.page.nextPage')}</a>
474
+ )}
475
+ </nav>
476
+ )}
477
+
335
478
  {editing !== null ? (
336
479
  <EventForm context={context} event={editing} />
337
480
  ) : (
@@ -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
  )