@meith/plugin-calendar 0.37.0 → 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 +14 -1
- package/package.json +3 -3
- package/src/definition.tsx +1 -1
- package/src/messages/en.json +3 -0
- package/src/store.ts +55 -1
- package/src/ui/page.tsx +42 -8
package/README.md
CHANGED
|
@@ -82,7 +82,11 @@ different date requires fresh responses.
|
|
|
82
82
|
Weekly and fortnightly repeats advance the stored instant by 7 or 14 days.
|
|
83
83
|
Monthly repeats keep the UTC day and time, skipping months without that
|
|
84
84
|
day (January 31 next occurs March 31). The optional until date is inclusive.
|
|
85
|
-
|
|
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.
|
|
86
90
|
The thread card selects from recurring occurrences within a year on either
|
|
87
91
|
side of now. ICS downloads contain the series with a standard RRULE.
|
|
88
92
|
|
|
@@ -116,3 +120,12 @@ The existing scheduler prevents concurrent normal runs. Failed notification
|
|
|
116
120
|
sends remain eligible for retry. Sending and recording delivery use separate
|
|
117
121
|
host APIs, so a process crash between them can retry a delivered notification;
|
|
118
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.37.
|
|
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.37.
|
|
24
|
-
"@meith/ui": "^0.37.
|
|
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
|
@@ -22,7 +22,7 @@ export const calendarPlugin = definePlugin({
|
|
|
22
22
|
key: 'calendar',
|
|
23
23
|
name: en['calendar.definition.name'],
|
|
24
24
|
nameKey: 'calendar.definition.name',
|
|
25
|
-
version: '0.37.
|
|
25
|
+
version: '0.37.1',
|
|
26
26
|
description: en['calendar.definition.description'],
|
|
27
27
|
descriptionKey: 'calendar.definition.description',
|
|
28
28
|
apiVersion: '0',
|
package/src/messages/en.json
CHANGED
|
@@ -34,8 +34,11 @@
|
|
|
34
34
|
"calendar.page.emptyPast": "Nothing has happened yet.",
|
|
35
35
|
"calendar.page.months": "Calendar months",
|
|
36
36
|
"calendar.page.next": "Next month",
|
|
37
|
+
"calendar.page.nextPage": "Next page",
|
|
38
|
+
"calendar.page.pagination": "Calendar pages",
|
|
37
39
|
"calendar.page.past": "Past",
|
|
38
40
|
"calendar.page.previous": "Previous month",
|
|
41
|
+
"calendar.page.previousPage": "Previous page",
|
|
39
42
|
"calendar.page.title": "Calendar",
|
|
40
43
|
"calendar.page.upcoming": "Upcoming",
|
|
41
44
|
"calendar.page.views": "Calendar views",
|
package/src/store.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
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> {
|
|
6
13
|
readonly repeat?: Repeat
|
|
@@ -97,6 +104,53 @@ export async function windowEvents(
|
|
|
97
104
|
return rows.flatMap((row) => occurrences(toEvent(row), from, to)).sort(byStart)
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
export async function agendaEvents(
|
|
108
|
+
data: PluginData,
|
|
109
|
+
now: Date,
|
|
110
|
+
past = false,
|
|
111
|
+
limit = 50,
|
|
112
|
+
cursor?: Pick<CalendarEvent, 'startsAt' | 'id'>,
|
|
113
|
+
backwards = false,
|
|
114
|
+
): Promise<readonly CalendarEvent[]> {
|
|
115
|
+
const rows = await data.query<EventRow>(
|
|
116
|
+
`select ${COLUMNS} from plugin_calendar_event
|
|
117
|
+
where repeat <> 'none' or coalesce(ends_at, starts_at) ${past ? '<' : '>='} $1`,
|
|
118
|
+
[now],
|
|
119
|
+
)
|
|
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
|
|
152
|
+
}
|
|
153
|
+
|
|
100
154
|
export async function eventById(data: PluginData, id: string): Promise<CalendarEvent | null> {
|
|
101
155
|
const row = await data.one<EventRow>(
|
|
102
156
|
`select ${COLUMNS} from plugin_calendar_event where id = $1`,
|
package/src/ui/page.tsx
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from '../events'
|
|
23
23
|
import en from '../messages/en.json'
|
|
24
24
|
import {
|
|
25
|
+
agendaEvents,
|
|
25
26
|
eventById,
|
|
26
27
|
organiserIds,
|
|
27
28
|
RSVP_LABELS,
|
|
@@ -304,17 +305,37 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
304
305
|
const now = new Date()
|
|
305
306
|
|
|
306
307
|
const rawMonth = context.query.month ?? ''
|
|
307
|
-
const
|
|
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)
|
|
308
320
|
const from = new Date(month + '-01T00:00:00Z')
|
|
309
|
-
if (showingPast && rawMonth === '') from.setUTCMonth(from.getUTCMonth() - 1)
|
|
310
321
|
const to = new Date(from)
|
|
311
322
|
to.setUTCMonth(to.getUTCMonth() + 1)
|
|
312
323
|
const previous = new Date(from)
|
|
313
324
|
previous.setUTCMonth(previous.getUTCMonth() - 1)
|
|
314
|
-
const [
|
|
315
|
-
|
|
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),
|
|
316
329
|
organiserIds(context.data).catch(() => [] as readonly number[]),
|
|
317
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)
|
|
318
339
|
const selectedId = context.query.event ?? ''
|
|
319
340
|
const series = /^\d+$/.test(selectedId) ? await eventById(context.data, selectedId) : null
|
|
320
341
|
const selected =
|
|
@@ -409,8 +430,8 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
409
430
|
<li className="shrink-0">
|
|
410
431
|
<a
|
|
411
432
|
href="/plugins/calendar"
|
|
412
|
-
{...(showingPast ? {} : { 'aria-current': 'page' as const })}
|
|
413
|
-
className={pluginTabClass(!showingPast)}
|
|
433
|
+
{...(showingPast || filteringMonth ? {} : { 'aria-current': 'page' as const })}
|
|
434
|
+
className={pluginTabClass(!showingPast && !filteringMonth)}
|
|
414
435
|
>
|
|
415
436
|
{translated(context, 'calendar.page.upcoming')}
|
|
416
437
|
</a>
|
|
@@ -418,8 +439,8 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
418
439
|
<li className="shrink-0">
|
|
419
440
|
<a
|
|
420
441
|
href="/plugins/calendar?show=past"
|
|
421
|
-
{...(showingPast ? { 'aria-current': 'page' as const } : {})}
|
|
422
|
-
className={pluginTabClass(showingPast)}
|
|
442
|
+
{...(showingPast && !filteringMonth ? { 'aria-current': 'page' as const } : {})}
|
|
443
|
+
className={pluginTabClass(showingPast && !filteringMonth)}
|
|
423
444
|
>
|
|
424
445
|
{translated(context, 'calendar.page.past')}
|
|
425
446
|
</a>
|
|
@@ -441,6 +462,19 @@ export async function CalendarPage(context: PluginPageContext) {
|
|
|
441
462
|
/>
|
|
442
463
|
)}
|
|
443
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
|
+
|
|
444
478
|
{editing !== null ? (
|
|
445
479
|
<EventForm context={context} event={editing} />
|
|
446
480
|
) : (
|