@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 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
- The agenda expands one UTC month at a time, with previous/next navigation.
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.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.37.0",
24
- "@meith/ui": "^0.37.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"
@@ -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.0',
25
+ version: '0.37.1',
26
26
  description: en['calendar.definition.description'],
27
27
  descriptionKey: 'calendar.definition.description',
28
28
  apiVersion: '0',
@@ -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 { byStart, type CalendarEvent, type EventDraft, occurrences, type Repeat } from './events'
3
+ import {
4
+ byStart,
5
+ type CalendarEvent,
6
+ type EventDraft,
7
+ isUpcoming,
8
+ occurrences,
9
+ type Repeat,
10
+ } from './events'
4
11
 
5
12
  interface EventRow extends Record<string, unknown> {
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 month = /^\d{4}-(0[1-9]|1[0-2])$/.test(rawMonth) ? rawMonth : now.toISOString().slice(0, 7)
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 [events, organisers] = await Promise.all([
315
- windowEvents(context.data, from, to).catch(() => [] as readonly CalendarEvent[]),
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
  ) : (