@meith/plugin-calendar 0.36.1 → 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 +55 -1
- package/package.json +3 -3
- package/src/definition.tsx +32 -1
- package/src/events.ts +96 -1
- package/src/handlers.ts +28 -1
- package/src/ics.ts +5 -0
- package/src/messages/en.json +25 -0
- package/src/reminders.ts +59 -0
- package/src/schema.ts +29 -0
- package/src/store.ts +83 -27
- package/src/ui/page.tsx +120 -11
- package/src/ui/rsvp.tsx +66 -0
- package/src/ui/thread-card.tsx +6 -1
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
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@meith/ui": "^0.
|
|
23
|
+
"@meith/plugin-kit": "^0.37.0",
|
|
24
|
+
"@meith/ui": "^0.37.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"react": "^19.2.0"
|
package/src/definition.tsx
CHANGED
|
@@ -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.
|
|
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(
|
|
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)}`]),
|
package/src/messages/en.json
CHANGED
|
@@ -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
|
}
|
package/src/reminders.ts
ADDED
|
@@ -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
|
|
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
|
|
84
|
+
export async function windowEvents(
|
|
76
85
|
data: PluginData,
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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]">
|
|
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
|
-
(
|
|
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">
|
package/src/ui/rsvp.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/ui/thread-card.tsx
CHANGED
|
@@ -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
|
)
|