@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 +68 -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 +28 -0
- package/src/reminders.ts +59 -0
- package/src/schema.ts +29 -0
- package/src/store.ts +130 -20
- package/src/ui/page.tsx +159 -16
- 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,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.
|
|
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.
|
|
24
|
-
"@meith/ui": "^0.
|
|
23
|
+
"@meith/plugin-kit": "^0.37.1",
|
|
24
|
+
"@meith/ui": "^0.37.1"
|
|
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.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(
|
|
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,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
|
}
|
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,17 @@
|
|
|
1
1
|
import type { PluginData } from '@meith/plugin-kit'
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
|
91
|
+
export async function windowEvents(
|
|
76
92
|
data: PluginData,
|
|
77
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
104
|
+
return rows.flatMap((row) => occurrences(toEvent(row), from, to)).sort(byStart)
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
export async function
|
|
107
|
+
export async function agendaEvents(
|
|
90
108
|
data: PluginData,
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
limit $1`,
|
|
98
|
-
[limit],
|
|
117
|
+
where repeat <> 'none' or coalesce(ends_at, starts_at) ${past ? '<' : '>='} $1`,
|
|
118
|
+
[now],
|
|
99
119
|
)
|
|
100
|
-
|
|
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
|
-
|
|
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 {
|
|
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]">
|
|
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
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
) : (
|
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
|
)
|