@acms-subir/core 0.3.34 → 0.3.36

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 (47) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +7 -1
  3. package/docs/mobile.md +9 -6
  4. package/docs/theme-authoring.md +5 -4
  5. package/integration.mjs +3 -1
  6. package/mobile-template/client/AGENTS.md +2 -1
  7. package/mobile-template/client/README.md +1 -1
  8. package/mobile-template/recipes/events/files/app/events/[slug].tsx +50 -22
  9. package/mobile-template/recipes/events/files/app/events/index.tsx +4 -3
  10. package/mobile-template/recipes/events/files/components/RsvpPanel.tsx +270 -0
  11. package/mobile-template/recipes/events/recipe.json +6 -3
  12. package/mobile-template/recipes/rsvp/files/components/RsvpPanel.tsx +270 -0
  13. package/mobile-template/recipes/rsvp/recipe.json +15 -0
  14. package/package.json +1 -1
  15. package/src/components/Announcements.tsx +14 -1
  16. package/src/components/LiveCard.astro +23 -10
  17. package/src/components/MobileApp.tsx +54 -4
  18. package/src/layouts/Site.astro +45 -38
  19. package/src/lib/announcements/api.ts +8 -4
  20. package/src/lib/announcements/store.ts +4 -0
  21. package/src/lib/cache/policy.ts +2 -2
  22. package/src/lib/calendar/hooks.ts +5 -1
  23. package/src/lib/legal/app-privacy.ts +5 -3
  24. package/src/lib/legal/text-en.ts +1 -0
  25. package/src/lib/legal/text-es.ts +1 -0
  26. package/src/lib/live/view.ts +28 -24
  27. package/src/lib/live/words.ts +94 -0
  28. package/src/lib/mobile/bookings.ts +17 -11
  29. package/src/lib/mobile/forms.ts +21 -11
  30. package/src/lib/mobile/manage.ts +2 -0
  31. package/src/lib/mobile/openapi.ts +8 -3
  32. package/src/lib/mobile/rsvp.ts +65 -0
  33. package/src/lib/mobile/schemas.ts +9 -1
  34. package/src/lib/mobile/site.ts +3 -1
  35. package/src/lib/push/api.ts +13 -1
  36. package/src/lib/push/service.ts +211 -24
  37. package/src/lib/push/store.ts +35 -8
  38. package/src/lib/push/types.ts +23 -2
  39. package/src/lib/repo/migrations.ts +6 -0
  40. package/src/lib/schema.ts +2 -1
  41. package/src/lib/search-i18n.ts +41 -4
  42. package/src/lib/site-chrome.ts +8 -0
  43. package/src/lib/site-words.ts +62 -0
  44. package/src/pages/admin/calendar/bookings.astro +3 -1
  45. package/src/pages/api/v1/events/[slug]/rsvp.ts +8 -0
  46. package/src/styles/mobile-app.css +7 -0
  47. package/src/theme-sdk/index.ts +2 -0
@@ -1,18 +1,21 @@
1
1
  // Push notifications: topics, audiences, the Expo sender pipeline, automatic
2
- // notices (first publish of a post/event, "starting soon", "we're live") and receipts.
2
+ // notices (first publish of a post/event, "starting soon", "we're live", event
3
+ // changes) and receipts.
3
4
  import type { ContentRepository } from '../repo/types';
4
5
  import type { CalEvent, Calendar, Post, Settings } from '../schema';
5
6
  import { CONTENT, collectionUrl } from '../content';
6
7
  import { CALENDAR, eventUrl } from '../calendar/config';
7
- import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
8
- import { I18N } from '../i18n';
8
+ import { eventIsListed, eventTimeZone, expandEvents, findOccurrence, type Occurrence } from '../calendar/events';
9
+ import { tzParts } from '../calendar/ics';
10
+ import { I18N, stripLocale } from '../i18n';
9
11
  import { localizeDoc } from '../translations';
10
12
  import { localizedUrl } from '../mobile/locale';
11
13
  import { effectiveSnapshot } from '../live/service';
14
+ import { ANNOUNCEMENTS } from '../announcements/config';
12
15
  import type { LiveSnapshot } from '../live/types';
13
16
  import { PUSH } from './config';
14
17
  import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
15
- import type { PushNotification, PushSource, PushTopic } from './types';
18
+ import { EventSnapshot, type PushAuto, type PushNotification, type PushSource, type PushTopic } from './types';
16
19
 
17
20
  const MINUTE = 60_000, HOUR = 60 * MINUTE, DAY = 24 * HOUR;
18
21
  /** automatic notices queued longer ago than this are skipped, never sent late */
@@ -22,13 +25,14 @@ export const RECEIPT_DELAY = 15 * MINUTE;
22
25
 
23
26
  // ---- topics ------------------------------------------------------------------
24
27
 
25
- // names of the built-in topics; other languages show English
26
- const TOPIC_WORDS: Record<string, { news: string; events: string; live: string }> = {
27
- en: { news: 'News', events: 'Events', live: 'Live video' },
28
- es: { news: 'Noticias', events: 'Eventos', live: 'Video en vivo' },
29
- fr: { news: 'Actualités', events: 'Événements', live: 'Vidéo en direct' },
30
- de: { news: 'Neuigkeiten', events: 'Veranstaltungen', live: 'Live-Video' },
31
- pt: { news: 'Notícias', events: 'Eventos', live: 'Vídeo ao vivo' },
28
+ // names of the built-in topics; other languages show English. `eventsChanges`
29
+ // names `events` while event-change notices go to it.
30
+ const TOPIC_WORDS: Record<string, { news: string; events: string; eventsChanges: string; live: string }> = {
31
+ en: { news: 'News', events: 'Events', eventsChanges: 'Events and changes', live: 'Live video' },
32
+ es: { news: 'Noticias', events: 'Eventos', eventsChanges: 'Eventos y cambios', live: 'Video en vivo' },
33
+ fr: { news: 'Actualités', events: 'Événements', eventsChanges: 'Événements et changements', live: 'Vidéo en direct' },
34
+ de: { news: 'Neuigkeiten', events: 'Veranstaltungen', eventsChanges: 'Veranstaltungen und Änderungen', live: 'Live-Video' },
35
+ pt: { news: 'Notícias', events: 'Eventos', eventsChanges: 'Eventos e mudanças', live: 'Vídeo ao vivo' },
32
36
  };
33
37
  /** A site topic's words in a language: exact code, its base ("es-MX" → "es"), every language ('*'), the site's main language, then any. */
34
38
  export function topicWords(words: Record<string, string>, locale: string): string {
@@ -46,17 +50,20 @@ export function topicWords(words: Record<string, string>, locale: string): strin
46
50
  * first registration) choose: a new topic never reaches existing devices on its own.
47
51
  */
48
52
  export async function pushTopics(repo: ContentRepository, locale?: string | null): Promise<PushTopic[]> {
49
- const lang = locale || I18N.defaultLocale || (await repo.getSettings()).locale || 'en';
53
+ const settings = await repo.getSettings();
54
+ const lang = locale || I18N.defaultLocale || settings.locale || 'en';
50
55
  const w = TOPIC_WORDS[lang.toLowerCase().split('-')[0]] ?? TOPIC_WORDS.en;
51
56
  const collections = Object.entries(CONTENT.collections);
52
57
  const out: PushTopic[] = [{ id: 'news', label: w.news, kind: 'general' }];
53
58
  if (collections.length > 1) out.push(...collections.map(([key, c]): PushTopic => ({ id: `collection:${key}`, label: c.plural, kind: 'collection' })));
54
59
  if (CALENDAR.enabled) {
55
- out.push({ id: 'events', label: w.events, kind: 'general' });
60
+ const changes = settings.push.auto.eventChanges;
61
+ // event-change notices go to `events` unless their rule names other topics
62
+ out.push({ id: 'events', label: changes.calendars.length && !changes.topics.length ? w.eventsChanges : w.events, kind: 'general' });
56
63
  const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
57
64
  if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
58
65
  }
59
- if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
66
+ if (settings.live.mode !== 'off') out.push({ id: 'live', label: w.live, kind: 'general' });
60
67
  for (const t of PUSH.topics) {
61
68
  const description = topicWords(t.description, lang);
62
69
  out.push({ id: t.id, label: topicWords(t.label, lang), kind: 'site', ...(description ? { description } : {}) });
@@ -84,12 +91,28 @@ export function deviceLocale(raw: string): string | null {
84
91
  return match && match !== I18N.defaultLocale ? match : null;
85
92
  }
86
93
 
87
- const STRINGS: Record<string, { startsIn: (m: number) => string; now: string; live: string }> = {
88
- en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live' },
89
- es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo' },
90
- fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct' },
91
- de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live' },
92
- pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo' },
94
+ interface Words {
95
+ startsIn: (m: number) => string; now: string; live: string;
96
+ // event changes: dates come formatted, `more` ends a list of cancelled dates
97
+ moved: (to: string, was: string) => string; movedTo: (to: string, place: string) => string; place: (p: string) => string;
98
+ cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
99
+ }
100
+ const STRINGS: Record<string, Words> = {
101
+ en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
102
+ moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
103
+ cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more` },
104
+ es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
105
+ moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
106
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más` },
107
+ fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
108
+ moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
109
+ cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres` },
110
+ de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
111
+ moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
112
+ cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere` },
113
+ pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
114
+ moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
115
+ cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}` },
93
116
  };
94
117
  export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
95
118
 
@@ -97,8 +120,8 @@ const clip = (s: string, n: number) => {
97
120
  const t = s.replace(/\s+/g, ' ').trim();
98
121
  return t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t;
99
122
  };
100
- function when(start: number, allDay: boolean, tz: string, locale: string, timeOnly = false) {
101
- const opts: Intl.DateTimeFormatOptions = timeOnly ? { hour: 'numeric', minute: '2-digit' }
123
+ function when(start: number, allDay: boolean, tz: string, locale: string, only?: 'time' | 'monthDay') {
124
+ const opts: Intl.DateTimeFormatOptions = only === 'time' ? { hour: 'numeric', minute: '2-digit' } : only === 'monthDay' ? { month: 'short', day: 'numeric' }
102
125
  : allDay ? { weekday: 'short', month: 'short', day: 'numeric' } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' };
103
126
  try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: tz }).format(start); }
104
127
  catch { return new Date(start).toISOString().slice(0, 16).replace('T', ' '); }
@@ -201,6 +224,11 @@ export async function sendNotification(repo: ContentRepository, input: SendInput
201
224
 
202
225
  const listedCalendars = (calendars: Calendar[]) => new Map(calendars.map((c) => [c.id, c]));
203
226
  const inRule = (rule: string[], e: CalEvent) => rule.includes('*') || e.calendarIds.some((id) => rule.includes(id));
227
+ // the change rule: its calendars, and one of its tags when it lists some (any case)
228
+ function inChangeRule(rule: PushAuto['eventChanges'], e: CalEvent) {
229
+ const tags = new Set(rule.tags.map((t) => t.toLowerCase()));
230
+ return inRule(rule.calendars, e) && (!tags.size || e.tags.some((t) => tags.has(t.toLowerCase())));
231
+ }
204
232
  function eventTopics(e: CalEvent, calendars: Map<string, Calendar>) {
205
233
  return ['events', ...e.calendarIds.map((id) => calendars.get(id)).filter((c): c is Calendar => c?.visibility === 'public').map((c) => `calendar:${c.slug}`)];
206
234
  }
@@ -224,7 +252,7 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
224
252
  if (soon) {
225
253
  const s = pushStrings(locale);
226
254
  const mins = Math.max(0, Math.round((soon.start - now) / MINUTE));
227
- body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale, true)}${place}`;
255
+ body = `${mins <= 1 ? s.now : s.startsIn(mins)} · ${when(soon.start, false, tz, locale, 'time')}${place}`;
228
256
  } else body = `${when(next.start, next.allDay, tz, locale)}${place}`;
229
257
  return { title: clip(ev.title, 120), body: clip(body, 240), url: eventUrl(ev.slug) };
230
258
  };
@@ -234,7 +262,159 @@ export function eventNotice(e: CalEvent, settings: Settings, calendars: Map<stri
234
262
  localize: (l) => words(localizeDoc(e, l), l) };
235
263
  }
236
264
 
237
- /** Sends (or skips) queued first-publish notices. Returns how many were sent. */
265
+ // ---- event changes ---------------------------------------------------------------
266
+
267
+ /** Edits wait this long after the last one, so a quick fix sends one notice (or none)… */
268
+ export const CHANGE_QUIET = 10 * MINUTE;
269
+ /** …but only this long when the date they touch starts within CHANGE_SOON. */
270
+ export const CHANGE_QUIET_SOON = MINUTE;
271
+ export const CHANGE_SOON = 3 * HOUR;
272
+
273
+ /** The public fields a change notice compares (staged edits live in `draft` and don't count until applied). */
274
+ export function eventSnapshot(e: CalEvent): EventSnapshot {
275
+ return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
276
+ cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() } };
277
+ }
278
+ function readSnapshot(raw: string | null): EventSnapshot | null {
279
+ try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
280
+ }
281
+
282
+ interface Slot { start: number; allDay: boolean }
283
+ export interface EventChange {
284
+ /** the next date (or its time) moved */
285
+ moved?: { from: Slot; to: Slot };
286
+ /** a new place name or address */
287
+ place?: { from: EventSnapshot['location']; to: EventSnapshot['location'] };
288
+ /** the whole event was cancelled: its next date */
289
+ cancelled?: Slot;
290
+ /** single dates of a repeating event newly cancelled, soonest first */
291
+ cancelledDates?: Slot[];
292
+ /** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
293
+ at: number;
294
+ }
295
+
296
+ // the next occurrence that hasn't ended and the next that hasn't started
297
+ // (cancelled ones count: cancelling a date isn't a move)
298
+ function slots(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): { next: Slot | null; upcoming: Slot | null } {
299
+ const all = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY });
300
+ const slot = (o: Occurrence | undefined) => (o ? { start: o.start, allDay: o.allDay } : null);
301
+ return { next: slot(all[0]), upcoming: slot(all.find((o) => o.start > now)) };
302
+ }
303
+
304
+ /**
305
+ * What people following an event should hear, from what they last knew (`was`)
306
+ * to the event now: its next date or time moved, a new place, the whole event
307
+ * cancelled, or single dates cancelled. Null when nothing counts: other fields,
308
+ * series edits that leave the next date alone, restored dates, un-cancelling,
309
+ * or it's too late (what it touches has started, or nothing is left to come).
310
+ */
311
+ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number): EventChange | null {
312
+ if (was.cancelled) return null;
313
+ const { upcoming } = slots(e, settings, now);
314
+ if (!upcoming) return null;
315
+ if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
316
+ const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
317
+ // a date taken out of the series (dragged to its own event) or put back isn't a move
318
+ const exdates = [...new Set([...was.exdates, ...e.exdates])];
319
+ const from = slots({ ...then, exdates }, settings, now).next;
320
+ if (!from) return null;
321
+ const to = slots({ ...e, exdates }, settings, now).next;
322
+ const out: Omit<EventChange, 'at'> = {};
323
+ const starts: number[] = [];
324
+ // a move matters until the new time starts, even when the old one has passed
325
+ if (to && to.start > now && (to.start !== from.start || to.allDay !== from.allDay)) { out.moved = { from, to }; starts.push(from.start, to.start); }
326
+ const place = { name: e.location.name.trim(), address: e.location.address.trim() };
327
+ if ((place.name || place.address) && (place.name !== was.location.name || place.address !== was.location.address)) {
328
+ out.place = { from: was.location, to: place };
329
+ starts.push(upcoming.start);
330
+ }
331
+ // only dates people knew as on: one that was already cancelled and moved with its series isn't news
332
+ const dates = e.cancelledDates.filter((k) => !was.cancelledDates.includes(k) && findOccurrence(then, k, settings)).map((k) => findOccurrence(e, k, settings))
333
+ .filter((o): o is Occurrence => o !== null && o.start > now).sort((a, b) => a.start - b.start);
334
+ if (dates.length) {
335
+ out.cancelledDates = dates.map((o) => ({ start: o.start, allDay: o.allDay }));
336
+ starts.push(dates[0].start);
337
+ }
338
+ return starts.length ? { ...out, at: Math.min(...starts) } : null;
339
+ }
340
+
341
+ /** Called by eventChanged on every path: queues (or postpones) a change notice when a published event's schedule or place differs. */
342
+ export async function queueEventChange(repo: ContentRepository, before: CalEvent, after: CalEvent, now = Date.now()): Promise<boolean> {
343
+ // publishing is the "new event" notice; unpublishing says nothing
344
+ if (!PUSH.enabled || !CALENDAR.enabled || before.status !== 'published' || after.status !== 'published') return false;
345
+ const was = eventSnapshot(before);
346
+ if (JSON.stringify(was) === JSON.stringify(eventSnapshot(after))) return false;
347
+ const [settings, all] = await Promise.all([repo.getSettings(), repo.listCalendars()]);
348
+ if (!inChangeRule(settings.push.auto.eventChanges, after) || !eventIsListed(after, listedCalendars(all))) return false;
349
+ const key = `change:${after.id}`;
350
+ // a change to a date that starts soon waits only a minute (judged from what people last knew)
351
+ const row = await repo.push.queuedChange(key);
352
+ const change = eventChange((row?.status === 'pending' && readSnapshot(row.payload)) || was, after, settings, now);
353
+ const quiet = change && change.at - now < CHANGE_SOON ? CHANGE_QUIET_SOON : CHANGE_QUIET;
354
+ await repo.push.queueChange(key, after.id, JSON.stringify(was), now, now + quiet);
355
+ return true;
356
+ }
357
+
358
+ /** The site path a link opens, without the origin, a non-default locale prefix, query or hash ('' = another site). */
359
+ export function sitePath(link: string, origin: string): string {
360
+ try {
361
+ const u = new URL(link, origin || 'https://site.invalid');
362
+ if (origin && u.origin !== new URL(origin).origin) return '';
363
+ return stripLocale(u.pathname.replace(/(.)\/+$/, '$1'));
364
+ } catch { return ''; }
365
+ }
366
+
367
+ // an announcement pushed in the last hour whose button opens this event already told people
368
+ async function announcedLately(repo: ContentRepository, e: CalEvent, origin: string, now: number): Promise<boolean> {
369
+ if (!ANNOUNCEMENTS.enabled) return false;
370
+ const page = eventUrl(e.slug);
371
+ return (await repo.announcements.pushedSince(now - HOUR)).some((a) => a.link !== '' && sitePath(a.link, origin) === page);
372
+ }
373
+
374
+ /** `topics`: the rule's own audience; [] = the event's topics. */
375
+ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Settings, calendars: Map<string, Calendar>, topics: string[] = []): SendInput {
376
+ const tz = eventTimeZone(e, settings);
377
+ const siteTz = settings.timezone || 'UTC';
378
+ // all-day dates start at midnight in the site's zone (how they were expanded)
379
+ const zone = (x: Slot) => (x.allDay ? siteTz : tz);
380
+ const dates = change.cancelledDates ?? [];
381
+ const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : 'dateCancelled';
382
+ // one cancelled date opens that date (?on= is its day in the site's zone, like calendar links)
383
+ let on = '';
384
+ if (kind === 'dateCancelled' && dates.length === 1) {
385
+ const p = tzParts(dates[0].start, siteTz);
386
+ on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
387
+ }
388
+ const words = (ev: CalEvent, locale: string): Composed => {
389
+ const s = pushStrings(locale);
390
+ const at = (x: Slot) => when(x.start, x.allDay, zone(x), locale);
391
+ const day = (x: Slot) => when(x.start, true, zone(x), locale);
392
+ const parts: string[] = [];
393
+ if (change.cancelled) parts.push(ev.rrule ? s.cancelledFrom(day(change.cancelled)) : s.cancelled(at(change.cancelled)));
394
+ else {
395
+ const loc = { name: ev.location.name.trim(), address: ev.location.address.trim() };
396
+ // a new name is enough; the same name at a new address needs both
397
+ const place = !change.place ? '' : change.place.to.name !== change.place.from.name ? loc.name || loc.address : [loc.name, loc.address].filter(Boolean).join(', ');
398
+ if (change.moved) {
399
+ const { from, to } = change.moved;
400
+ const sameDay = !from.allDay && !to.allDay && day(from) === day(to);
401
+ parts.push(place ? s.movedTo(at(to), place) : s.moved(at(to), sameDay ? when(from.start, false, zone(from), locale, 'time') : at(from)));
402
+ } else if (place) parts.push(s.place(place));
403
+ if (dates.length) {
404
+ const list = dates.length === 1 ? [day(dates[0])] : dates.slice(0, 3).map((x) => when(x.start, true, zone(x), locale, 'monthDay'));
405
+ if (dates.length > 3) list.push(s.more(dates.length - 3));
406
+ let joined: string;
407
+ try { joined = new Intl.ListFormat(locale, { type: 'conjunction' }).format(list); } catch { joined = list.join(', '); }
408
+ parts.push(s.cancelledOn(joined));
409
+ }
410
+ }
411
+ return { title: clip(ev.title, 120), body: clip(parts.join(' · '), 240), url: `${eventUrl(ev.slug)}${on}` };
412
+ };
413
+ return { ...words(e, displayLocale(settings)), audience: topics.length ? topics : eventTopics(e, calendars), source: 'change', sourceKey: `change:${e.id}`,
414
+ data: { kind: 'event', id: e.id, slug: e.slug, change: kind }, localize: (l) => words(localizeDoc(e, l), l) };
415
+ }
416
+
417
+ /** Sends (or skips) queued first-publish and event-change notices. Returns how many were sent. */
238
418
  export async function processPushQueue(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
239
419
  const now = opts.now ?? Date.now();
240
420
  if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
@@ -254,6 +434,13 @@ export async function processPushQueue(repo: ContentRepository, opts: PushRunOpt
254
434
  } else if (row.kind === 'event' && CALENDAR.enabled) {
255
435
  const e = await repo.getEvent(row.docId);
256
436
  if (e && !e.cancelled && eventIsListed(e, calendars) && inRule(auto.newEvent.calendars, e)) notice = eventNotice(e, settings, calendars, now);
437
+ } else if (row.kind === 'change' && CALENDAR.enabled) {
438
+ // compared at send time: edits that cancel out, or come too late, send nothing
439
+ const e = await repo.getEvent(row.docId);
440
+ const was = readSnapshot(row.payload);
441
+ const rule = auto.eventChanges;
442
+ const change = e && was && eventIsListed(e, calendars) && inChangeRule(rule, e) ? eventChange(was, e, settings, now) : null;
443
+ if (e && change && !(await announcedLately(repo, e, run.origin, now))) notice = eventChangeNotice(e, change, settings, calendars, rule.topics);
257
444
  }
258
445
  if (!notice) { await repo.push.markQueue(row.key, 'skipped', null, now); continue; }
259
446
  const n = await sendNotification(repo, notice, run);
@@ -96,6 +96,10 @@ export class PushStore {
96
96
  async receiptFailed(counts: Map<string, { failed: number; removed: number }>) {
97
97
  for (const [id, c] of counts) await this.run('UPDATE push_notifications SET ok=MAX(ok-?,0), failed=failed+?, removed=removed+? WHERE id=?', [c.failed, c.failed, c.removed, id]);
98
98
  }
99
+ /** Notifications from one source that went out since `since`, newest first. */
100
+ async sentSince(source: PushNotification['source'], since: number) {
101
+ return (await this.all(`SELECT * FROM push_notifications WHERE source=? AND created_at>=? AND status!='failed' ORDER BY created_at DESC LIMIT 100`, [source, since])).map(notification);
102
+ }
99
103
  async listNotifications(limit = 50) { return (await this.all('SELECT * FROM push_notifications ORDER BY created_at DESC LIMIT ?', [limit])).map(notification); }
100
104
  async getNotification(id: string) { const r = await this.all('SELECT * FROM push_notifications WHERE id=?', [id]); return r[0] ? notification(r[0]) : null; }
101
105
 
@@ -120,17 +124,39 @@ export class PushStore {
120
124
  }
121
125
 
122
126
  // ---- automatic notification queue ----
123
- /** Claims up to `limit` pending rows (status → sending); rows queued before `staleBefore` are skipped instead. */
127
+ /**
128
+ * Claims up to `limit` due pending rows (status → sending); rows queued before
129
+ * `staleBefore` are skipped instead. A row with `due_at` waits until then and
130
+ * goes stale from then, so an event still being edited never counts as late.
131
+ */
124
132
  async claimPending(now: number, staleBefore: number, limit: number): Promise<PushQueueRow[]> {
125
- await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND created_at<?`, [now, staleBefore]);
126
- const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' ORDER BY created_at LIMIT ?`, [limit]);
133
+ await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending' AND COALESCE(due_at,created_at)<?`, [now, staleBefore]);
134
+ const due = await this.all(`SELECT key FROM push_queue WHERE status='pending' AND (due_at IS NULL OR due_at<=?) ORDER BY created_at LIMIT ?`, [now, limit]);
127
135
  const out: PushQueueRow[] = [];
128
136
  for (const { key } of due) {
129
- const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' RETURNING *`, [now, key]);
130
- if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence), status: 'sending', createdAt: Number(r[0].created_at) });
137
+ const r = await this.all(`UPDATE push_queue SET status='sending', updated_at=? WHERE key=? AND status='pending' AND (due_at IS NULL OR due_at<=?) RETURNING *`, [now, key, now]);
138
+ if (r[0]) out.push({ key: String(r[0].key), kind: r[0].kind as PushQueueRow['kind'], docId: String(r[0].doc_id), occurrence: r[0].occurrence == null ? null : Number(r[0].occurrence),
139
+ status: 'sending', createdAt: Number(r[0].created_at), payload: r[0].payload == null ? null : String(r[0].payload), dueAt: r[0].due_at == null ? null : Number(r[0].due_at) });
131
140
  }
132
141
  return out;
133
142
  }
143
+ /**
144
+ * Debounces an event's change notice (key change:<id>) in one statement, so
145
+ * concurrent saves can't lose an edit: a pending row keeps its snapshot (what
146
+ * people last knew) and waits until `dueAt` again; any other row (sent,
147
+ * skipped, failed, or being sent) starts over with this snapshot.
148
+ */
149
+ async queueChange(key: string, docId: string, payload: string, now: number, dueAt: number) {
150
+ await this.run(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at,payload,due_at)
151
+ VALUES (?,'change',?,NULL,'pending',NULL,?,?,?,?) ON CONFLICT(key) DO UPDATE SET
152
+ payload=CASE WHEN push_queue.status='pending' THEN push_queue.payload ELSE excluded.payload END,
153
+ created_at=CASE WHEN push_queue.status='pending' THEN push_queue.created_at ELSE excluded.created_at END,
154
+ status='pending', notification_id=NULL, due_at=excluded.due_at, updated_at=excluded.updated_at`, [key, docId, now, now, payload, dueAt]);
155
+ }
156
+ async queuedChange(key: string): Promise<{ status: string; payload: string | null } | null> {
157
+ const r = (await this.all('SELECT status, payload FROM push_queue WHERE key=?', [key]))[0];
158
+ return r ? { status: String(r.status), payload: r.payload == null ? null : String(r.payload) } : null;
159
+ }
134
160
  /** Inserts a new key as `sending`; false when it already exists (dedupe for "starting soon"). */
135
161
  async claimKey(key: string, kind: PushQueueRow['kind'], docId: string, occurrence: number | null, now: number): Promise<boolean> {
136
162
  const r = await this.all(`INSERT INTO push_queue (key,kind,doc_id,occurrence,status,notification_id,created_at,updated_at)
@@ -138,7 +164,8 @@ export class PushStore {
138
164
  return r.length > 0;
139
165
  }
140
166
  async markQueue(key: string, status: 'sent' | 'skipped' | 'failed', notificationId: string | null, now: number) {
141
- await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
167
+ // a change row an edit queued again while it was sending stays pending
168
+ await this.run(`UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=? AND (kind!='change' OR status='sending')`, [status, notificationId, now, key]);
142
169
  }
143
170
  async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
144
171
  /** When a notice of this kind last went out (0 = never). */
@@ -148,13 +175,13 @@ export class PushStore {
148
175
  async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
149
176
 
150
177
  // ---- retention ----
151
- /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence and broadcast keys. */
178
+ /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence, broadcast and change keys. */
152
179
  async purge(now: number, devicesBefore: number): Promise<number> {
153
180
  const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
154
181
  await (await this.driver()).batch([
155
182
  { sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
156
183
  { sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
157
- { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live') AND updated_at<?`, args: [now - 30 * 86400000] },
184
+ { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live','change') AND updated_at<?`, args: [now - 30 * 86400000] },
158
185
  ]);
159
186
  return gone.length;
160
187
  }
@@ -11,11 +11,26 @@ export const PushAuto = z.object({
11
11
  startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
12
  /** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
13
13
  live: z.object({ enabled: z.boolean().default(false) }).default({}),
14
+ /** a published event's new date, time or place, or a cancellation, once its edits settle */
15
+ eventChanges: z.object({
16
+ calendars: keys.default([]),
17
+ /** only events with one of these tags (any case); [] = every event in the calendars */
18
+ tags: z.array(z.string().trim().min(1).max(100)).max(20).default([]),
19
+ /** sent to these topics instead of the event's own (`events` and its calendars) */
20
+ topics: z.array(z.string().min(1).max(200)).max(10).default([]),
21
+ }).default({}),
14
22
  });
15
23
  export type PushAuto = z.infer<typeof PushAuto>;
16
24
  export const PushSettings = z.object({ auto: PushAuto.default({}) });
17
25
  export type PushSettings = z.infer<typeof PushSettings>;
18
26
 
27
+ /** An event's public schedule and place, as people last knew it (the payload of a queued change notice) */
28
+ export const EventSnapshot = z.object({
29
+ start: z.string(), end: z.string(), allDay: z.boolean(), timezone: z.string(), rrule: z.string(), exdates: z.array(z.string()),
30
+ cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
31
+ });
32
+ export type EventSnapshot = z.infer<typeof EventSnapshot>;
33
+
19
34
  export type PushPlatform = 'ios' | 'android';
20
35
  export interface PushDevice {
21
36
  id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
@@ -23,7 +38,7 @@ export interface PushDevice {
23
38
  channels: string[];
24
39
  createdAt: number; updatedAt: number; lastSeenAt: number;
25
40
  }
26
- export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement';
41
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change';
27
42
  export interface PushNotification {
28
43
  id: string; title: string; body: string; url: string;
29
44
  /** ['*'] = everyone */
@@ -35,7 +50,13 @@ export interface PushNotification {
35
50
  removed: number;
36
51
  error: string; author: string; createdAt: number; finishedAt: number | null;
37
52
  }
38
- export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement'; docId: string; occurrence: number | null; status: string; createdAt: number }
53
+ export interface PushQueueRow {
54
+ key: string; kind: 'post' | 'event' | 'soon' | 'live' | 'announcement' | 'change'; docId: string; occurrence: number | null; status: string; createdAt: number;
55
+ /** change rows: the EventSnapshot JSON from before the first edit */
56
+ payload: string | null;
57
+ /** change rows: not sent before this (null = as soon as possible) */
58
+ dueAt: number | null;
59
+ }
39
60
  export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' | 'site'; description?: string }
40
61
  export interface PushStats {
41
62
  devices: number;
@@ -679,6 +679,12 @@ export const MIGRATIONS: Migration[] = [
679
679
  ] },
680
680
  // announcements show on public pages, the banner and the app API
681
681
  { version: 58, statements: [...ANNOUNCEMENT_TABLE, ...contentVersionTriggers('announcements', ANNOUNCEMENT_VISIBLE_COLUMNS)] },
682
+ // event-change notices (PushAuto.eventChanges) wait in push_queue until the
683
+ // edits settle: payload = the schedule people last knew, due_at = send time
684
+ { version: 59, statements: [
685
+ `ALTER TABLE push_queue ADD COLUMN payload TEXT`,
686
+ `ALTER TABLE push_queue ADD COLUMN due_at INTEGER`,
687
+ ] },
682
688
  ];
683
689
 
684
690
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
package/src/lib/schema.ts CHANGED
@@ -672,7 +672,8 @@ export const Booking = z.object({
672
672
  formResponse: FormResponse.optional(),
673
673
  notes: z.string().default(''), // staff-only
674
674
  manageToken: z.string().default(''),
675
- source: z.enum(['web', 'admin', 'api', 'agent']).default('web'),
675
+ // web: the website; app: the public app API (/api/v1/bookings)
676
+ source: z.enum(['web', 'app', 'admin', 'api', 'agent']).default('web'),
676
677
  reminderSentAt: z.number().nullable().default(null),
677
678
  notificationVersion: z.number().default(0),
678
679
  createdAt: z.number(),
@@ -1,7 +1,5 @@
1
- // UI strings for the search modal and /search page. There is no site-wide
2
- // translation catalog yet — search is the first public surface with
3
- // per-locale chrome, so the dict lives here until one exists. Locales fall
4
- // back to English.
1
+ // UI strings for the search modal and /search page, in the same languages as
2
+ // the rest of the layout (site-words.ts). Locales fall back to English.
5
3
  export interface SearchStrings {
6
4
  label: string;
7
5
  placeholder: string;
@@ -43,6 +41,45 @@ const STRINGS: Record<string, SearchStrings> = {
43
41
  results: 'resultados',
44
42
  button: 'Buscar',
45
43
  },
44
+ fr: {
45
+ label: 'Rechercher',
46
+ placeholder: 'Rechercher des articles, pages, étiquettes et auteurs',
47
+ posts: 'Articles',
48
+ pages: 'Pages',
49
+ events: 'Événements',
50
+ tags: 'Étiquettes',
51
+ authors: 'Auteurs',
52
+ empty: 'Aucun résultat pour',
53
+ result: 'résultat',
54
+ results: 'résultats',
55
+ button: 'Rechercher',
56
+ },
57
+ de: {
58
+ label: 'Suchen',
59
+ placeholder: 'Beiträge, Seiten, Schlagwörter und Autoren durchsuchen',
60
+ posts: 'Beiträge',
61
+ pages: 'Seiten',
62
+ events: 'Veranstaltungen',
63
+ tags: 'Schlagwörter',
64
+ authors: 'Autoren',
65
+ empty: 'Nichts gefunden für',
66
+ result: 'Ergebnis',
67
+ results: 'Ergebnisse',
68
+ button: 'Suchen',
69
+ },
70
+ pt: {
71
+ label: 'Buscar',
72
+ placeholder: 'Busque posts, páginas, tags e autores',
73
+ posts: 'Posts',
74
+ pages: 'Páginas',
75
+ events: 'Eventos',
76
+ tags: 'Tags',
77
+ authors: 'Autores',
78
+ empty: 'Nada encontrado para',
79
+ result: 'resultado',
80
+ results: 'resultados',
81
+ button: 'Buscar',
82
+ },
46
83
  };
47
84
 
48
85
  export const searchStrings = (locale: string): SearchStrings => STRINGS[locale] ?? STRINGS.en;
@@ -8,6 +8,12 @@
8
8
  // [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
9
9
  // [data-menu-open] opens the phone menu sheet (built from site.links)
10
10
  // [data-live-header] (hidden) becomes the "Live" link during a stream
11
+ //
12
+ // `words` are core's own labels in the page's language (skip link, menu and
13
+ // dark-mode buttons, landmark and footer labels), so a custom header labels
14
+ // its controls the same way the built-in one does.
15
+ import type { SiteStrings } from './site-words';
16
+
11
17
  export interface ChromeLink {
12
18
  label: string;
13
19
  url: string;
@@ -32,4 +38,6 @@ export interface SiteChrome {
32
38
  liveHeader: boolean;
33
39
  headerStyle: string; // theme token `header`
34
40
  footerStyle: string; // theme token `footer`
41
+ lang: string; // the page's language, as on <html lang>
42
+ words: SiteStrings;
35
43
  }
@@ -0,0 +1,62 @@
1
+ // Words the public layout (Site.astro) shows around every page: the skip
2
+ // link, header and phone-menu controls, the footer's built-in columns and
3
+ // links. Owner-written labels (menus, collections) keep their own words;
4
+ // search, announcements and live video have their own lists. Other languages
5
+ // fall back to English.
6
+ export interface SiteStrings {
7
+ skip: string;
8
+ /** landmark labels */
9
+ main: string;
10
+ menu: string;
11
+ language: string;
12
+ social: string;
13
+ openMenu: string;
14
+ closeMenu: string;
15
+ /** the light/dark button */
16
+ scheme: string;
17
+ /** footer column headings (columns footer) */
18
+ read: string;
19
+ site: string;
20
+ /** built-in legal links, added when the site has no menu item for them */
21
+ privacy: string;
22
+ terms: string;
23
+ /** the header's automatic list, when there's no header menu */
24
+ home: string;
25
+ links: string;
26
+ /** code block copy button */
27
+ copy: string;
28
+ copied: string;
29
+ /** gallery lightbox */
30
+ close: string;
31
+ }
32
+
33
+ const STRINGS: Record<string, SiteStrings> = {
34
+ en: {
35
+ skip: 'Skip to content', main: 'Main', menu: 'Menu', language: 'Language', social: 'Social', openMenu: 'Open menu', closeMenu: 'Close menu',
36
+ scheme: 'Toggle dark mode', read: 'Read', site: 'Site', privacy: 'Privacy', terms: 'Terms', home: 'Home', links: 'Links',
37
+ copy: 'Copy', copied: 'Copied', close: 'Close',
38
+ },
39
+ es: {
40
+ skip: 'Saltar al contenido', main: 'Principal', menu: 'Menú', language: 'Idioma', social: 'Redes sociales', openMenu: 'Abrir menú', closeMenu: 'Cerrar menú',
41
+ scheme: 'Cambiar entre modo claro y oscuro', read: 'Leer', site: 'Sitio', privacy: 'Privacidad', terms: 'Términos', home: 'Inicio', links: 'Enlaces',
42
+ copy: 'Copiar', copied: 'Copiado', close: 'Cerrar',
43
+ },
44
+ fr: {
45
+ skip: 'Aller au contenu', main: 'Principal', menu: 'Menu', language: 'Langue', social: 'Réseaux sociaux', openMenu: 'Ouvrir le menu', closeMenu: 'Fermer le menu',
46
+ scheme: 'Basculer entre mode clair et sombre', read: 'Lire', site: 'Site', privacy: 'Confidentialité', terms: 'Conditions', home: 'Accueil', links: 'Liens',
47
+ copy: 'Copier', copied: 'Copié', close: 'Fermer',
48
+ },
49
+ de: {
50
+ skip: 'Zum Inhalt springen', main: 'Hauptmenü', menu: 'Menü', language: 'Sprache', social: 'Soziale Medien', openMenu: 'Menü öffnen', closeMenu: 'Menü schließen',
51
+ scheme: 'Hell- und Dunkelmodus umschalten', read: 'Lesen', site: 'Website', privacy: 'Datenschutz', terms: 'Nutzungsbedingungen', home: 'Startseite', links: 'Links',
52
+ copy: 'Kopieren', copied: 'Kopiert', close: 'Schließen',
53
+ },
54
+ pt: {
55
+ skip: 'Pular para o conteúdo', main: 'Principal', menu: 'Menu', language: 'Idioma', social: 'Redes sociais', openMenu: 'Abrir menu', closeMenu: 'Fechar menu',
56
+ scheme: 'Alternar entre modo claro e escuro', read: 'Ler', site: 'Site', privacy: 'Privacidade', terms: 'Termos', home: 'Início', links: 'Links',
57
+ copy: 'Copiar', copied: 'Copiado', close: 'Fechar',
58
+ },
59
+ };
60
+
61
+ export const siteStrings = (locale: string | null | undefined): SiteStrings =>
62
+ STRINGS[(locale || 'en').toLowerCase().split('-')[0]] ?? STRINGS.en;