@acms-subir/core 0.3.46 → 0.3.48

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +14 -11
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +34 -12
  18. package/src/lib/calendar/event-words.ts +133 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/feeds.ts +11 -3
  21. package/src/lib/calendar/filter.ts +140 -0
  22. package/src/lib/calendar/ics-write.ts +25 -2
  23. package/src/lib/calendar/moves.ts +291 -0
  24. package/src/lib/calendar/render.ts +26 -18
  25. package/src/lib/calendar/schedule.ts +73 -61
  26. package/src/lib/email/booking-words.ts +7 -1
  27. package/src/lib/email/service.ts +6 -1
  28. package/src/lib/email/templates.ts +6 -2
  29. package/src/lib/mobile/calendar.ts +32 -14
  30. package/src/lib/mobile/http.ts +5 -3
  31. package/src/lib/mobile/schemas.ts +7 -3
  32. package/src/lib/push/service.ts +44 -10
  33. package/src/lib/push/types.ts +2 -0
  34. package/src/lib/repo/migrations.ts +5 -2
  35. package/src/lib/repo/sql.ts +28 -10
  36. package/src/lib/repo/types.ts +6 -3
  37. package/src/lib/schema.ts +21 -0
  38. package/src/lib/sections.ts +5 -2
  39. package/src/lib/sync/catalog.ts +1 -0
  40. package/src/pages/api/calendar/events/index.ts +24 -12
  41. package/src/pages/calendar/[slug].ics.ts +8 -2
  42. package/src/pages/calendar/feed.ics.ts +9 -2
  43. package/src/pages/calendar/index.astro +104 -48
  44. package/src/pages/embed/calendar/[slug].astro +29 -9
  45. package/src/pages/members/_Layout.astro +1 -0
  46. package/src/pages/members/events/index.astro +1 -1
  47. package/src/server/index.ts +33 -0
  48. package/src/styles/editor.css +19 -0
  49. package/src/styles/schedule.css +2 -0
  50. package/src/styles/site.css +16 -0
  51. package/src/theme-sdk/calendar.ts +3 -2
@@ -4,9 +4,13 @@
4
4
  // live through the cached ICS loader. Visibility rules live in events.ts.
5
5
  import type { ContentRepository } from '../repo/types';
6
6
  import { roomColor, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
7
- import { eventIsListed, eventIsPublic, expandEvents, safeColor } from './events';
7
+ import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
8
8
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
9
9
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
10
+ import { occurrenceMatches, tagsMatch, untaggedMatches, type ResolvedEventFilter } from './filter';
11
+ import { eventStrings } from './event-words';
12
+ import { localePath } from '../i18n';
13
+ import { localizeDoc } from '../translations';
10
14
 
11
15
  export interface ViewEvent extends CalendarEvent {
12
16
  url?: string;
@@ -19,6 +23,10 @@ export interface ViewEvent extends CalendarEvent {
19
23
  // native events: the rooms this date holds; public views name only rooms
20
24
  // set to show on public events (Resource.showOnEvents)
21
25
  rooms?: ViewRoom[];
26
+ // native events: the date's occurrence key (a moved date keeps its original one)
27
+ key?: string;
28
+ // this date was moved here: where it was (UTC ms) and the public note
29
+ moved?: OccurrenceMove;
22
30
  }
23
31
 
24
32
  // color: '#rrggbb', or '' when the room has none
@@ -34,6 +42,12 @@ export interface ViewScope {
34
42
  includeFeeds?: boolean;
35
43
  // "Reserved" blocks from resources set to show on the calendar
36
44
  includeBookings?: boolean;
45
+ // the address's cal/tag/notag/room (filter.ts); its calendars narrow
46
+ // calendarIds when both are given
47
+ filter?: ResolvedEventFilter;
48
+ // the request's language (requestLocale): translated titles, event links
49
+ // under its /<locale> prefix, and "Reserved" in its words
50
+ locale?: string;
37
51
  }
38
52
 
39
53
  const pad = (n: number) => String(n).padStart(2, '0');
@@ -46,7 +60,9 @@ export async function collectViewEvents(
46
60
  ): Promise<{ events: ViewEvent[]; calendars: Calendar[]; errors: string[] }> {
47
61
  const all = await repo.listCalendars();
48
62
  const byId = new Map(all.map((c) => [c.id, c]));
49
- const requested = scope.calendarIds ? new Set(scope.calendarIds) : null;
63
+ const f = scope.filter;
64
+ const ids = scope.calendarIds && f?.calendarIds ? scope.calendarIds.filter((id) => f.calendarIds!.includes(id)) : scope.calendarIds ?? f?.calendarIds;
65
+ const requested = ids ? new Set(ids) : null;
50
66
  const visible = all.filter((c) => {
51
67
  if (scope.audience === 'admin') return true;
52
68
  // a directly requested calendar may be unlisted; private never is
@@ -60,6 +76,7 @@ export async function collectViewEvents(
60
76
  const source = scope.audience === 'admin' ? (await repo.listEvents()).filter((e) => e.status !== 'archived') : await repo.listPublishedEvents();
61
77
  const natives = source.filter((e) => {
62
78
  if (scope.audience === 'public' && !eventIsPublic(e, byId)) return false;
79
+ if (f && !tagsMatch(f, e.tags, { internal: f.internal })) return false;
63
80
  if (!e.calendarIds.length) {
64
81
  if (requested) return false;
65
82
  return scope.includeUncategorized ?? true;
@@ -73,39 +90,44 @@ export async function collectViewEvents(
73
90
  const shown = new Map<string, CalEvent>();
74
91
  const view = (e: CalEvent) => {
75
92
  if (scope.audience === 'admin') return e;
76
- if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(e));
93
+ if (!shown.has(e.id)) shown.set(e.id, withoutPrivateFields(localizeDoc(e, scope.locale)));
77
94
  return shown.get(e.id)!;
78
95
  };
79
- const resources = natives.some((e) => e.roomHold.resourceIds.length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
96
+ const resources = natives.some((e) => eventRoomIds(e).length) ? new Map((await repo.listResources()).map((r) => [r.id, r])) : new Map<string, Resource>();
80
97
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
81
98
  .filter((r): r is Resource => Boolean(r) && (scope.audience === 'admin' || r!.showOnEvents))
82
99
  .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
100
+ const words = eventStrings(scope.locale).calendar;
83
101
  for (const o of expandEvents(natives, settings, win)) {
84
- const e = o.event;
102
+ // rooms are per date (a moved date can change them)
103
+ if (f?.roomIds && !occurrenceMatches(f, o)) continue;
104
+ const e = view(o.event);
85
105
  const cal = e.calendarIds.map((id) => byId.get(id)).find((c) => c && scoped.has(c.id));
86
106
  const p = tzParts(o.start, tz);
87
107
  const on = e.rrule ? `?on=${p.y}-${pad(p.m)}-${pad(p.d)}` : '';
88
108
  events.push({
89
109
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
90
- title: e.title || 'Untitled event',
91
- location: [e.location.name, e.location.address].filter(Boolean).join(', '),
110
+ title: e.title || words.untitled,
111
+ location: locationLine(o.moved?.location ?? e.location),
92
112
  description: e.excerpt,
93
113
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
94
114
  ref: e.id,
95
- url: eventUrl(e.slug) + on,
115
+ url: localePath(eventUrl(e.slug), scope.locale ?? '') + on,
96
116
  color: cal ? safeColor(cal.color) : undefined,
97
117
  cancelled: o.cancelled,
98
118
  image: e.cover || undefined,
99
119
  imageAlt: e.coverAlt || undefined,
100
120
  calendarName: cal?.name,
101
- event: view(e),
121
+ event: e,
102
122
  rooms: rooms(o.roomIds),
123
+ key: o.key,
124
+ ...(o.moved ? { moved: o.moved } : {}),
103
125
  });
104
126
  }
105
127
 
106
128
  const errors: string[] = [];
107
129
  if (scope.includeFeeds ?? true) {
108
- const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl);
130
+ const feeds = inScope.filter((c) => c.kind === 'feed' && c.feedUrl && (!f || untaggedMatches(f, { calendarId: c.id })));
109
131
  const results = await Promise.all(
110
132
  feeds.map(async (c) => {
111
133
  try {
@@ -125,7 +147,7 @@ export async function collectViewEvents(
125
147
  // resources marked "show on calendar": confirmed bookings appear as
126
148
  // Reserved blocks (no guest names on public views) — only on unscoped views
127
149
  if (!requested && (scope.includeBookings ?? true) && CALENDAR.bookings) {
128
- const resources = (await repo.listResources()).filter((r) => r.showOnCalendar);
150
+ const resources = (await repo.listResources()).filter((r) => r.showOnCalendar && (!f || untaggedMatches(f, { roomId: r.id })));
129
151
  if (resources.length) {
130
152
  const booked = await repo.listBookings({ from: win.windowStartMs, to: win.windowEndMs, statuses: ['confirmed'], kinds: ['booking'] });
131
153
  const byId = new Map(resources.map((r) => [r.id, r]));
@@ -135,7 +157,7 @@ export async function collectViewEvents(
135
157
  const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
136
158
  events.push({
137
159
  start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
138
- title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `Reserved · ${r.name}`,
160
+ title: scope.audience === 'admin' ? `${r.name} · ${b.name}` : `${words.reserved} · ${r.name}`,
139
161
  location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
140
162
  url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
141
163
  });
@@ -4,6 +4,8 @@
4
4
  // describeRepeat). Plain strings with {date} and {place} slots (live/words.ts
5
5
  // `fill`); the repeat line is built by functions, since word order differs
6
6
  // per language. No server imports: the event editor shares the repeat line.
7
+ // `calendar` holds the words of the /calendar page, embeds and the calendar
8
+ // card (render.ts).
7
9
  // The event's own words (title, button, fields) stay as written. Spanish
8
10
  // matches the push notices ("Cancelado el …"). Other languages fall back to
9
11
  // English.
@@ -16,6 +18,10 @@ export interface EventStrings {
16
18
  /** one date of a repeating event */
17
19
  dateCancelled: string;
18
20
  dateCancelledNote: string;
21
+ /** one date moved to another time: the flag, then "Moved from {from}." and the note (staff's own note replaces it) */
22
+ moved: string;
23
+ dateMoved: string;
24
+ dateMovedNote: string;
19
25
  /** the side column's landmark label */
20
26
  details: string;
21
27
  joinOnline: string;
@@ -35,6 +41,58 @@ export interface EventStrings {
35
41
  fullCalendar: string;
36
42
  allDay: string;
37
43
  repeat: RepeatWords;
44
+ calendar: CalendarWords;
45
+ }
46
+
47
+ /** The calendar page, its filters, embeds and the calendar card. */
48
+ export interface CalendarWords {
49
+ title: string;
50
+ /** under the title; {site} is the site's name */
51
+ lede: string;
52
+ /** landmark labels */
53
+ calendars: string;
54
+ filters: string;
55
+ views: string;
56
+ nextTwoWeeks: string;
57
+ agenda: string;
58
+ /** the chip for every calendar */
59
+ all: string;
60
+ month: string;
61
+ week: string;
62
+ list: string;
63
+ /** a filter chip: "Without Class", "Room: Chapel" (a tag chip is the tag alone) */
64
+ without: string;
65
+ room: string;
66
+ /** the × on a chip, for screen readers */
67
+ remove: string;
68
+ clear: string;
69
+ subscribe: string;
70
+ subscribeAll: string;
71
+ subscribeNamed: string;
72
+ subscribeFiltered: string;
73
+ copyLink: string;
74
+ comingUp: string;
75
+ /** the footer: {zone} is "Eastern Daylight Time" in the page's language */
76
+ timesIn: string;
77
+ feedErrors: string;
78
+ empty: string;
79
+ emptySoon: string;
80
+ noMatch: string;
81
+ /** a confirmed booking on the public calendar: "Reserved · {room}" */
82
+ reserved: string;
83
+ untitled: string;
84
+ /** the grid's toolbar and pop-up */
85
+ today: string;
86
+ previous: string;
87
+ next: string;
88
+ upcoming: string;
89
+ noUpcoming: string;
90
+ details: string;
91
+ close: string;
92
+ /** the calendar card's own messages */
93
+ noSource: string;
94
+ unavailable: string;
95
+ openGoogle: string;
38
96
  }
39
97
 
40
98
  /** Weekdays are 0 = Sunday; `n` is the interval (every n days/weeks/months/years). */
@@ -76,6 +134,7 @@ const STRINGS: Record<string, EventStrings> = {
76
134
  en: {
77
135
  cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
78
136
  dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
137
+ moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
79
138
  details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
80
139
  directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
81
140
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
@@ -90,10 +149,25 @@ const STRINGS: Record<string, EventStrings> = {
90
149
  until: (d) => `, until ${d}`,
91
150
  other: 'Repeats',
92
151
  },
152
+ calendar: {
153
+ title: 'Calendar', lede: 'What’s happening at {site}. Subscribe to keep it in your own calendar.',
154
+ calendars: 'Calendars', filters: 'Filters', views: 'View', nextTwoWeeks: 'Next two weeks', agenda: 'Agenda',
155
+ all: 'All', month: 'Month', week: 'Week', list: 'List',
156
+ without: 'Without {tag}', room: 'Room: {room}', remove: 'Remove {filter}', clear: 'Clear filters',
157
+ subscribe: 'Subscribe', subscribeAll: 'Add this calendar to your calendar app. New events show up on their own.',
158
+ subscribeNamed: 'Add “{name}” to your calendar app. New events show up on their own.',
159
+ subscribeFiltered: 'Add these events to your calendar app. New ones show up on their own.',
160
+ copyLink: 'Copy link', comingUp: 'Coming up', timesIn: 'Times in {zone}', feedErrors: 'Some linked calendars couldn’t be loaded right now.',
161
+ empty: 'Nothing on the calendar yet.', emptySoon: 'Nothing in the next two months.', noMatch: 'Nothing matches these filters.',
162
+ reserved: 'Reserved', untitled: 'Untitled event',
163
+ today: 'Today', previous: 'Previous', next: 'Next', upcoming: 'Upcoming', noUpcoming: 'No upcoming events.', details: 'Details', close: 'Close',
164
+ noSource: 'No calendar connected.', unavailable: 'Calendar unavailable right now.', openGoogle: 'Open in Google Calendar ↗',
165
+ },
93
166
  },
94
167
  es: {
95
168
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
96
169
  dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
170
+ moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
97
171
  details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
98
172
  directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
99
173
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
@@ -108,10 +182,25 @@ const STRINGS: Record<string, EventStrings> = {
108
182
  until: (d) => `, hasta el ${d}`,
109
183
  other: 'Se repite',
110
184
  },
185
+ calendar: {
186
+ title: 'Calendario', lede: 'Lo que pasa en {site}. Suscríbete para tenerlo en tu propio calendario.',
187
+ calendars: 'Calendarios', filters: 'Filtros', views: 'Vista', nextTwoWeeks: 'Próximas dos semanas', agenda: 'Agenda',
188
+ all: 'Todos', month: 'Mes', week: 'Semana', list: 'Lista',
189
+ without: 'Sin {tag}', room: 'Sala: {room}', remove: 'Quitar {filter}', clear: 'Quitar filtros',
190
+ subscribe: 'Suscribirse', subscribeAll: 'Agrega este calendario a tu app de calendario. Los eventos nuevos aparecen solos.',
191
+ subscribeNamed: 'Agrega “{name}” a tu app de calendario. Los eventos nuevos aparecen solos.',
192
+ subscribeFiltered: 'Agrega estos eventos a tu app de calendario. Los nuevos aparecen solos.',
193
+ copyLink: 'Copiar enlace', comingUp: 'Próximamente', timesIn: 'Horario de {zone}', feedErrors: 'Algunos calendarios vinculados no se pudieron cargar en este momento.',
194
+ empty: 'Todavía no hay nada en el calendario.', emptySoon: 'Nada en los próximos dos meses.', noMatch: 'Nada coincide con estos filtros.',
195
+ reserved: 'Reservado', untitled: 'Evento sin título',
196
+ today: 'Hoy', previous: 'Anterior', next: 'Siguiente', upcoming: 'Próximos', noUpcoming: 'No hay eventos próximos.', details: 'Detalles', close: 'Cerrar',
197
+ noSource: 'No hay ningún calendario conectado.', unavailable: 'El calendario no está disponible en este momento.', openGoogle: 'Abrir en Google Calendar ↗',
198
+ },
111
199
  },
112
200
  fr: {
113
201
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
114
202
  dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
203
+ moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
115
204
  details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
116
205
  directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
117
206
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
@@ -126,10 +215,25 @@ const STRINGS: Record<string, EventStrings> = {
126
215
  until: (d) => `, jusqu’au ${d}`,
127
216
  other: 'Récurrent',
128
217
  },
218
+ calendar: {
219
+ title: 'Calendrier', lede: 'Ce qui se passe à {site}. Abonnez-vous pour le retrouver dans votre propre calendrier.',
220
+ calendars: 'Calendriers', filters: 'Filtres', views: 'Affichage', nextTwoWeeks: 'Les deux prochaines semaines', agenda: 'Agenda',
221
+ all: 'Tous', month: 'Mois', week: 'Semaine', list: 'Liste',
222
+ without: 'Sans {tag}', room: 'Salle : {room}', remove: 'Retirer {filter}', clear: 'Effacer les filtres',
223
+ subscribe: 'S’abonner', subscribeAll: 'Ajoutez ce calendrier à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
224
+ subscribeNamed: 'Ajoutez « {name} » à votre application de calendrier. Les nouveaux événements s’y ajoutent d’eux-mêmes.',
225
+ subscribeFiltered: 'Ajoutez ces événements à votre application de calendrier. Les nouveaux s’y ajoutent d’eux-mêmes.',
226
+ copyLink: 'Copier le lien', comingUp: 'À venir', timesIn: 'Heures : {zone}', feedErrors: 'Certains calendriers liés n’ont pas pu être chargés pour le moment.',
227
+ empty: 'Rien au calendrier pour l’instant.', emptySoon: 'Rien dans les deux prochains mois.', noMatch: 'Rien ne correspond à ces filtres.',
228
+ reserved: 'Réservé', untitled: 'Événement sans titre',
229
+ today: 'Aujourd’hui', previous: 'Précédent', next: 'Suivant', upcoming: 'À venir', noUpcoming: 'Aucun événement à venir.', details: 'Détails', close: 'Fermer',
230
+ noSource: 'Aucun calendrier connecté.', unavailable: 'Calendrier indisponible pour le moment.', openGoogle: 'Ouvrir dans Google Agenda ↗',
231
+ },
129
232
  },
130
233
  de: {
131
234
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
132
235
  dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
236
+ moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
133
237
  details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
134
238
  directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
135
239
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
@@ -144,10 +248,25 @@ const STRINGS: Record<string, EventStrings> = {
144
248
  until: (d) => `, bis zum ${d}`,
145
249
  other: 'Wiederkehrend',
146
250
  },
251
+ calendar: {
252
+ title: 'Kalender', lede: 'Was bei {site} los ist. Abonnieren Sie den Kalender, um alles in Ihrem eigenen Kalender zu haben.',
253
+ calendars: 'Kalender', filters: 'Filter', views: 'Ansicht', nextTwoWeeks: 'Die nächsten zwei Wochen', agenda: 'Terminliste',
254
+ all: 'Alle', month: 'Monat', week: 'Woche', list: 'Liste',
255
+ without: 'Ohne {tag}', room: 'Raum: {room}', remove: '{filter} entfernen', clear: 'Filter löschen',
256
+ subscribe: 'Abonnieren', subscribeAll: 'Fügen Sie diesen Kalender Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
257
+ subscribeNamed: 'Fügen Sie „{name}“ Ihrer Kalender-App hinzu. Neue Veranstaltungen erscheinen automatisch.',
258
+ subscribeFiltered: 'Fügen Sie diese Veranstaltungen Ihrer Kalender-App hinzu. Neue erscheinen automatisch.',
259
+ copyLink: 'Link kopieren', comingUp: 'Demnächst', timesIn: 'Zeiten in {zone}', feedErrors: 'Einige verknüpfte Kalender konnten gerade nicht geladen werden.',
260
+ empty: 'Noch nichts im Kalender.', emptySoon: 'In den nächsten zwei Monaten steht nichts an.', noMatch: 'Nichts passt zu diesen Filtern.',
261
+ reserved: 'Reserviert', untitled: 'Unbenannte Veranstaltung',
262
+ today: 'Heute', previous: 'Zurück', next: 'Weiter', upcoming: 'Demnächst', noUpcoming: 'Keine anstehenden Veranstaltungen.', details: 'Details', close: 'Schließen',
263
+ noSource: 'Kein Kalender verbunden.', unavailable: 'Kalender gerade nicht verfügbar.', openGoogle: 'In Google Kalender öffnen ↗',
264
+ },
147
265
  },
148
266
  pt: {
149
267
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
150
268
  dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
269
+ moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
151
270
  details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
152
271
  directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
153
272
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',
@@ -164,6 +283,20 @@ const STRINGS: Record<string, EventStrings> = {
164
283
  until: (d) => `, até ${d}`,
165
284
  other: 'Repete',
166
285
  },
286
+ calendar: {
287
+ title: 'Calendário', lede: 'O que acontece em {site}. Assine para ter tudo no seu próprio calendário.',
288
+ calendars: 'Calendários', filters: 'Filtros', views: 'Visualização', nextTwoWeeks: 'Próximas duas semanas', agenda: 'Agenda',
289
+ all: 'Todos', month: 'Mês', week: 'Semana', list: 'Lista',
290
+ without: 'Sem {tag}', room: 'Sala: {room}', remove: 'Remover {filter}', clear: 'Limpar filtros',
291
+ subscribe: 'Assinar', subscribeAll: 'Adicione este calendário ao seu app de calendário. Os novos eventos aparecem sozinhos.',
292
+ subscribeNamed: 'Adicione “{name}” ao seu app de calendário. Os novos eventos aparecem sozinhos.',
293
+ subscribeFiltered: 'Adicione estes eventos ao seu app de calendário. Os novos aparecem sozinhos.',
294
+ copyLink: 'Copiar link', comingUp: 'Em breve', timesIn: 'Horário de {zone}', feedErrors: 'Alguns calendários vinculados não puderam ser carregados agora.',
295
+ empty: 'Ainda não há nada no calendário.', emptySoon: 'Nada nos próximos dois meses.', noMatch: 'Nada corresponde a estes filtros.',
296
+ reserved: 'Reservado', untitled: 'Evento sem título',
297
+ today: 'Hoje', previous: 'Anterior', next: 'Próximo', upcoming: 'Próximos', noUpcoming: 'Nenhum evento programado.', details: 'Detalhes', close: 'Fechar',
298
+ noSource: 'Nenhum calendário conectado.', unavailable: 'Calendário indisponível no momento.', openGoogle: 'Abrir no Google Agenda ↗',
299
+ },
167
300
  },
168
301
  };
169
302
 
@@ -1,7 +1,7 @@
1
1
  // Native events: wall-clock time math, recurrence expansion (reusing the ICS
2
2
  // expander, so feed events and native events follow identical rules),
3
3
  // visibility, and "add to calendar" links. Pure — no repo, no fetch.
4
- import type { CalEvent, Calendar, EventLocation, Settings } from '../schema';
4
+ import type { CalEvent, Calendar, EventLocation, MovedDate, Settings } from '../schema';
5
5
  import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
6
6
  import { cleanRrule } from './rrule';
7
7
  import { eventStrings } from './event-words';
@@ -35,6 +35,9 @@ function fromMinutes(min: number, dateOnly: boolean): Wall {
35
35
  return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate(), hh: dateOnly ? 0 : dt.getUTCHours(), mm: dateOnly ? 0 : dt.getUTCMinutes(), dateOnly };
36
36
  }
37
37
  export const addWallDays = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n * 1440, w.dateOnly);
38
+ export const addWallMinutes = (w: Wall, n: number) => fromMinutes(wallMinutes(w) + n, w.dateOnly);
39
+ /** whole minutes from a to b (wall-clock, no zone) */
40
+ export const wallMinutesBetween = (a: Wall, b: Wall) => wallMinutes(b) - wallMinutes(a);
38
41
 
39
42
  export function isValidTimeZone(tz: string): boolean {
40
43
  if (!tz) return false;
@@ -122,8 +125,27 @@ export interface Occurrence {
122
125
  key: string;
123
126
  // the whole event is cancelled, or just this date
124
127
  cancelled: boolean;
125
- // the rooms this date holds (Resource ids; the event's roomHold.resourceIds)
128
+ // the rooms this date holds (Resource ids: the event's roomHold.resourceIds,
129
+ // or a moved date's own)
126
130
  roomIds: string[];
131
+ // this date was moved here (CalEvent.movedDates): where it was, and why
132
+ moved?: OccurrenceMove;
133
+ }
134
+
135
+ export interface OccurrenceMove {
136
+ /** the date's original start and end (UTC ms) */
137
+ from: { start: number; end: number };
138
+ /** staff's public note ('' for none) */
139
+ note: string;
140
+ /** the date's own place, when it moved somewhere else */
141
+ location?: EventLocation;
142
+ }
143
+
144
+ /** Every room an event may hold: its own and any a moved date holds instead. */
145
+ export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'>): string[] {
146
+ const ids = new Set(e.roomHold?.resourceIds ?? []);
147
+ for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
148
+ return [...ids];
127
149
  }
128
150
 
129
151
  /**
@@ -148,14 +170,73 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
148
170
  if (!v) continue;
149
171
  // ?? []: editor previews pass unsaved events that may lack the field
150
172
  const cancelledDates = new Set(e.cancelledDates ?? []);
173
+ const moved = movedEntries(e);
151
174
  for (const o of expandVEvents([v], tz, win, 0)) {
152
175
  const key = occurrenceKey(e, o.start, settings, win.displayTz);
176
+ // a moved date comes out at its new time below
177
+ if (moved.has(key)) continue;
153
178
  out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
154
179
  }
180
+ for (const [key, m] of moved) {
181
+ const at = movedInstants(e, m, settings, win.displayTz);
182
+ if (!at || at.end <= win.windowStartMs || at.start >= win.windowEndMs) continue;
183
+ const o = movedOccurrence(e, key, m, settings, win.displayTz);
184
+ if (o) out.push(o);
185
+ }
155
186
  }
156
187
  return out.sort((a, b) => a.start - b.start || a.event.title.localeCompare(b.event.title));
157
188
  }
158
189
 
190
+ // ---- moved dates ---------------------------------------------------------
191
+ // A moved date (CalEvent.movedDates) keeps its original key; it comes out at
192
+ // its new time, in its own rooms (and place) when it has them, with `moved`
193
+ // saying where it was. Keys that no longer match a date of the series (it
194
+ // was retimed or its rule changed) are ignored, like stale cancelled keys,
195
+ // and a key that is also cancelled stays cancelled at its original time.
196
+
197
+ // the moves that count: not on a cancelled date
198
+ function movedEntries(e: Pick<CalEvent, 'movedDates' | 'cancelledDates'>): Map<string, MovedDate> {
199
+ const out = new Map<string, MovedDate>();
200
+ const cancelled = new Set(e.cancelledDates ?? []);
201
+ for (const [key, m] of Object.entries(e.movedDates ?? {})) if (!cancelled.has(key)) out.set(key, m);
202
+ return out;
203
+ }
204
+
205
+ /** A moved date's new instants (all-day: midnight in the display zone, as the expander places them), or null when unusable. */
206
+ export function movedInstants(e: Pick<CalEvent, 'allDay' | 'timezone'>, m: Pick<MovedDate, 'start' | 'end'>, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): { start: number; end: number; days: number } | null {
207
+ const s = parseWall(m.start);
208
+ if (!s || s.dateOnly !== e.allDay) return null;
209
+ const en = parseWall(m.end) ?? s;
210
+ if (e.allDay) {
211
+ const days = Math.max(1, Math.round((wallMinutes({ ...en, hh: 0, mm: 0 }) - wallMinutes(s)) / 1440) + 1);
212
+ const start = zonedToUtc(s.y, s.m, s.d, 0, 0, 0, displayTz || 'UTC');
213
+ return { start, end: start + days * DAY, days };
214
+ }
215
+ const tz = eventTimeZone(e, settings);
216
+ const start = wallToMs(s, tz), end = wallToMs(en, tz);
217
+ return end > start ? { start, end, days: 1 } : null;
218
+ }
219
+
220
+ // the date as the series alone makes it (no move), or null when it isn't one
221
+ function seriesOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
222
+ const w = parseWall(key);
223
+ if (!w || w.dateOnly !== e.allDay) return null;
224
+ const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
225
+ return expandEvents([{ ...e, movedDates: {} }], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
226
+ .find((o) => o.key === key && o.start === at) ?? null;
227
+ }
228
+
229
+ function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<Settings, 'timezone'>, displayTz: string): Occurrence | null {
230
+ const at = movedInstants(e, m, settings, displayTz);
231
+ const was = at && seriesOccurrence(e, key, settings, displayTz);
232
+ if (!at || !was) return null;
233
+ return {
234
+ event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: e.cancelled,
235
+ roomIds: m.roomIds ?? e.roomHold?.resourceIds ?? [],
236
+ moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
237
+ };
238
+ }
239
+
159
240
  // When RSVPs to one date close (UTC ms), or null when they stay open until it
160
241
  // ends: the event's closesAt (wall time in its zone, one instant for every
161
242
  // date), else closeBefore minutes before this date's start. A deadline at or
@@ -168,18 +249,17 @@ export function rsvpDeadline(o: Pick<Occurrence, 'event' | 'start' | 'end'>, set
168
249
  }
169
250
 
170
251
  // The occurrence of a series with this key (null when the series has no
171
- // such date: never generated, skipped by exdates, or the series moved)
252
+ // such date: never generated, skipped by exdates, or the series moved). A
253
+ // moved date comes back at its new time.
172
254
  export function findOccurrence(e: CalEvent, key: string, settings: Pick<Settings, 'timezone'>, displayTz = settings.timezone || 'UTC'): Occurrence | null {
173
- const w = parseWall(key);
174
- if (!w || w.dateOnly !== e.allDay) return null;
175
- const at = e.allDay ? zonedToUtc(w.y, w.m, w.d, 0, 0, 0, displayTz || 'UTC') : wallToMs(w, eventTimeZone(e, settings));
176
- return expandEvents([e], settings, { displayTz, now: at, windowStartMs: at - DAY, windowEndMs: at + DAY })
177
- .find((o) => o.key === key && o.start === at) ?? null;
255
+ const m = movedEntries(e).get(key);
256
+ return m ? movedOccurrence(e, key, m, settings, displayTz) : seriesOccurrence(e, key, settings, displayTz);
178
257
  }
179
258
 
180
259
  // The occurrence to feature for an event: the next one that hasn't ended,
181
260
  // else the most recent. `on` ('YYYY-MM-DD') picks a specific day's instance
182
- // (links from the calendar grid carry it).
261
+ // (links from the calendar grid carry it); a moved date answers both its new
262
+ // day and the day it was moved from.
183
263
  export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, on?: string): Occurrence | null {
184
264
  const tz = eventTimeZone(e, settings);
185
265
  const displayTz = settings.timezone || 'UTC';
@@ -190,6 +270,11 @@ export function featuredOccurrence(e: CalEvent, settings: Pick<Settings, 'timezo
190
270
  const hit = expandEvents([e], settings, { displayTz, now, windowStartMs: from, windowEndMs: from + DAY })
191
271
  .find((o) => o.start >= from - DAY);
192
272
  if (hit) return hit;
273
+ for (const [key, m] of movedEntries(e)) {
274
+ if (key.slice(0, 10) !== on) continue;
275
+ const o = movedOccurrence(e, key, m, settings, displayTz);
276
+ if (o) return o;
277
+ }
193
278
  }
194
279
  }
195
280
  if (!e.rrule) {
@@ -5,6 +5,7 @@ import type { CalEvent, Calendar, Settings } from '../schema';
5
5
  import { buildFeed } from './ics-write';
6
6
  import { eventIsListed, eventIsPublic } from './events';
7
7
  import { eventUrl } from './config';
8
+ import { eventMatches, type ResolvedEventFilter } from './filter';
8
9
 
9
10
  export function icsResponse(body: string, filename: string, cacheSeconds = 300): Response {
10
11
  return new Response(body, {
@@ -21,15 +22,22 @@ export async function feedFor(
21
22
  repo: ContentRepository,
22
23
  settings: Settings,
23
24
  origin: string,
24
- scope: { calendar?: Calendar; allowPrivate?: boolean }
25
+ // filter: the address's cal/tag/notag/room (filter.ts); its calendars
26
+ // stand in for `calendar` on the site-wide feed
27
+ scope: { calendar?: Calendar; allowPrivate?: boolean; filter?: ResolvedEventFilter }
25
28
  ): Promise<{ events: CalEvent[]; body: string }> {
26
29
  const [published, calendars] = await Promise.all([repo.listPublishedEvents(), repo.listCalendars()]);
27
30
  const map = new Map(calendars.map((c) => [c.id, c]));
28
31
  const c = scope.calendar;
32
+ const f = scope.filter;
29
33
  const events = published.filter((e) =>
30
- c
34
+ (c
31
35
  ? e.calendarIds.includes(c.id) && (scope.allowPrivate || eventIsPublic(e, map) || c.visibility !== 'private')
32
- : eventIsListed(e, map)
36
+ : f?.calendarIds
37
+ ? eventIsPublic(e, map)
38
+ : eventIsListed(e, map))
39
+ // a feed lists series, so rooms are the series' own
40
+ && (!f || eventMatches(f, e))
33
41
  );
34
42
  const host = new URL(origin).host;
35
43
  const body = buildFeed(events, settings.timezone, {