@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
@@ -13,7 +13,7 @@ import { LiveConfig } from '../live/types';
13
13
  import { ApiError } from '../api';
14
14
  import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
15
15
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
16
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
16
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
17
17
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
18
18
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
19
19
  import { cleanRrule } from '../calendar/rrule';
@@ -148,6 +148,15 @@ function parseRequest(raw: unknown): EventRequest | null {
148
148
  if (raw === null || raw === undefined || raw === '') return null;
149
149
  try { const r = EventRequest.safeParse(JSON.parse(String(raw))); return r.success ? r.data : null; } catch { return null; }
150
150
  }
151
+ // a corrupt moved-dates map, or one bad entry, reads as "not moved" rather than breaking the event
152
+ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
153
+ let value: unknown;
154
+ try { value = JSON.parse(String(raw ?? '{}')); } catch { return {}; }
155
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
156
+ const out: CalEvent['movedDates'] = {};
157
+ for (const [key, entry] of Object.entries(value)) { const m = MovedDate.safeParse(entry); if (m.success) out[key] = m.data; }
158
+ return out;
159
+ }
151
160
  function rowToEvent(r: Row): CalEvent {
152
161
  return CalEvent.parse({
153
162
  id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
@@ -155,7 +164,7 @@ function rowToEvent(r: Row): CalEvent {
155
164
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
156
165
  featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
157
166
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
158
- rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []),
167
+ rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
159
168
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
160
169
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
161
170
  hostIds: json(r.host_ids, []), request: parseRequest(r.request),
@@ -848,8 +857,8 @@ export class SqlRepository implements ContentRepository {
848
857
  await this.driver.run(
849
858
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
850
859
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
851
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations)
852
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
860
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
861
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
853
862
  [
854
863
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
855
864
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -859,6 +868,7 @@ export class SqlRepository implements ContentRepository {
859
868
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
860
869
  JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
861
870
  JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
871
+ JSON.stringify(data.movedDates ?? {}),
862
872
  ]
863
873
  );
864
874
  await this.syncEventIndex(id);
@@ -883,7 +893,7 @@ export class SqlRepository implements ContentRepository {
883
893
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
884
894
  }
885
895
  const jsonCols: Record<string, string> = {
886
- tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
896
+ tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
887
897
  };
888
898
  for (const [k, col] of Object.entries(jsonCols)) {
889
899
  const v = (data as Record<string, unknown>)[k];
@@ -955,6 +965,14 @@ export class SqlRepository implements ContentRepository {
955
965
  await this.ready;
956
966
  await this.driver.run('UPDATE events SET cancelled_dates = ?, updated_at = ? WHERE id = ?', [JSON.stringify(dates), Date.now(), id]);
957
967
  }
968
+ async setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts: { quiet?: boolean } = {}) {
969
+ await this.ready;
970
+ const write = cancelledDates
971
+ ? { sql: 'UPDATE events SET moved_dates = ?, cancelled_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), JSON.stringify(cancelledDates), Date.now(), id] }
972
+ : { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
973
+ // quiet: the change it records sends its webhook but no member push (the flag shares this transaction)
974
+ await this.driver.batch(opts.quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write]);
975
+ }
958
976
  async cancelOccurrenceRsvps(id: string, start: number) {
959
977
  await this.ready;
960
978
  // the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
@@ -966,12 +984,12 @@ export class SqlRepository implements ContentRepository {
966
984
  ]);
967
985
  return Number((await this.first("SELECT COUNT(*) AS n FROM bookings WHERE event_id=? AND kind='rsvp' AND email_mutation=?", [id, mutation]))?.n ?? 0);
968
986
  }
969
- async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }) {
987
+ async moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message = '') {
970
988
  await this.ready;
971
989
  const target = to.eventId || id;
972
990
  // one mutation id marks every moved row; only the seated ones are emailed
973
991
  const mutation = crypto.randomUUID();
974
- const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
992
+ const notifications = bookingJobs(target,'rescheduled',undefined,false,mutation,message).map(s=>({...s,sql:s.sql.replace('b.id = ?',"b.event_id = ? AND b.kind='rsvp' AND b.status IN ('pending','confirmed')")}));
975
993
  await this.driver.batch([
976
994
  {sql:`UPDATE bookings SET event_id=?,start_at=?,end_at=?,hold_start=?,hold_end=?,notification_version=notification_version+1,email_mutation=?,reminder_sent_at=NULL,updated_at=?
977
995
  WHERE event_id=? AND kind='rsvp' AND start_at=? AND status IN ${RSVP_ACTIVE}`,args:[target,to.start,to.end,to.start,to.end,mutation,Date.now(),id,start]},
@@ -1906,15 +1924,15 @@ export class SqlRepository implements ContentRepository {
1906
1924
  ...(content.events ?? []).map((e) => ({
1907
1925
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1908
1926
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1909
- draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request)
1910
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1927
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
1928
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1911
1929
  args: [
1912
1930
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1913
1931
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1914
1932
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1915
1933
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1916
1934
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
1917
- JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null,
1935
+ JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
1918
1936
  ],
1919
1937
  })),
1920
1938
  // announcements (a backup made before them leaves the current ones). Restored
@@ -48,7 +48,7 @@ export type EventUpdate = Partial<
48
48
  CalEvent,
49
49
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
50
50
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
51
- | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
51
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
52
52
  | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
53
53
  | 'publishedAt' | 'scheduledAt'
54
54
  >
@@ -168,15 +168,18 @@ export interface ContentRepository {
168
168
  discardEventDraft(id: string): Promise<void>;
169
169
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
170
170
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
171
+ /** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
172
+ setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
171
173
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
172
174
  cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
173
175
  /**
174
176
  * Moves the RSVPs for one date of an event (those starting at `start`) to a
175
177
  * new time, and to another event when `to.eventId` is set. Pending and
176
- * confirmed ones get the "new time" email; places in line move quietly.
178
+ * confirmed ones get the "new time" email (with `message`, when given);
179
+ * places in line move quietly.
177
180
  * Returns how many moved.
178
181
  */
179
- moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
182
+ moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message?: string): Promise<number>;
180
183
  deleteEvent(id: string): Promise<void>;
181
184
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
182
185
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -357,6 +357,24 @@ export const RoomHold = z.object({
357
357
  });
358
358
  export type RoomHold = z.infer<typeof RoomHold>;
359
359
 
360
+ // One date of a repeating event at another time (and maybe in other rooms or
361
+ // another place): CalEvent.movedDates, keyed by the date's ORIGINAL occurrence
362
+ // key. start/end are wall-clock in the event's zone like the event's own
363
+ // (dates for all-day events, end inclusive). Unset roomIds/location = the
364
+ // event's. note is public ("Moved for the youth retreat"); by is who moved it
365
+ // (a user id, or 'google-sync'), at when. lib/calendar/moves.ts writes these.
366
+ export const MovedDate = z.object({
367
+ start: z.string(),
368
+ end: z.string(),
369
+ roomIds: z.array(z.string()).optional(),
370
+ location: EventLocation.optional(),
371
+ note: z.string().max(500).default(''),
372
+ by: z.string().default(''),
373
+ at: z.number().default(0),
374
+ });
375
+ export type MovedDate = z.infer<typeof MovedDate>;
376
+ export const MovedDates = z.record(z.string(), MovedDate).default({});
377
+
360
378
  // A member's event request (lib/calendar/requests.ts): a new event waiting
361
379
  // for approval (kind 'new', the event stays a draft), or changes to a live
362
380
  // event staged in its `draft` (kind 'changes'). Staff approve or decline;
@@ -407,6 +425,9 @@ export const CalEvent = z.object({
407
425
  // cancelled: shown struck through, but they don't hold rooms. Keys that no
408
426
  // longer match an occurrence (the series moved) are ignored.
409
427
  cancelledDates: z.array(z.string()).default([]),
428
+ // single dates at another time (MovedDate above); a date is moved or
429
+ // cancelled, never both. Keys that no longer match an occurrence are ignored.
430
+ movedDates: MovedDates,
410
431
  location: EventLocation.default({}),
411
432
  cta: EventCta.default({}),
412
433
  calendarIds: z.array(z.string()).default([]),
@@ -32,6 +32,8 @@ export interface LinkValue { label: string; href: string; external: boolean }
32
32
  export interface EventItem {
33
33
  title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
34
34
  start: string; end: string; allDay: boolean; cancelled: boolean;
35
+ /** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
36
+ moved: { from: string; note: string } | null;
35
37
  /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
36
38
  /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
37
39
  cta: { label: string; href: string } | null;
@@ -141,11 +143,12 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
141
143
  return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
142
144
  const e = lang ? localizeDoc(o.event, lang) : o.event;
143
145
  const f = formatOccurrence(o, tz, locale);
146
+ const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
144
147
  const ctaHref = safeUrl(e.cta.href);
145
148
  return {
146
149
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
147
- location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
148
- allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
150
+ location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
151
+ allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
149
152
  cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
150
153
  };
151
154
  });
@@ -130,6 +130,7 @@ const jsonFields = new Set([
130
130
  "roomHold",
131
131
  "exdates",
132
132
  "cancelledDates",
133
+ "movedDates",
133
134
  "amenities",
134
135
  "hours",
135
136
  "overrides",
@@ -14,16 +14,20 @@ import { readJson } from '../../../../lib/api';
14
14
  import { runAgentTool } from '../../../../lib/agent';
15
15
  import { waitUntilFrom } from '../../../../lib/webhooks';
16
16
  import { fail } from '../../../../lib/calendar/http';
17
- import { publicTags, tagFilter } from '../../../../lib/schema';
17
+ import { occurrenceMatches, readEventFilter, resolveEventFilter, tagsMatch } from '../../../../lib/calendar/filter';
18
18
 
19
19
  // Public read API for other sites, apps and lobby screens.
20
20
  // GET /api/calendar/events?from=2026-10-01&to=2026-10-31&calendar=youth&q=night
21
21
  // GET /api/calendar/events?tag=class (or excludeTag=class; a tag's name or slug)
22
+ // GET /api/calendar/events?calendar=youth,events&notag=Class&room=chapel
23
+ // (the /calendar page's filters: each takes several values, comma-separated
24
+ // or repeated; cal and notag are aliases of calendar and excludeTag)
22
25
  // → { timezone, from, to, occurrences: [{ id, start, end, allDay, cancelled, event }] }
23
26
  // GET /api/calendar/events?expand=0 → { events: [...] } (rules, not instances)
24
27
  // Anonymous callers see public calendars (and an unlisted one when asked by
25
28
  // slug). A key with events:read also sees private calendars and private
26
- // custom fields, and its tag filters also match internal "#" tags.
29
+ // custom fields, its tag filters also match internal "#" tags, and its room
30
+ // filter matches every room (anonymous callers: rooms shown on public events).
27
31
  const CORS = {
28
32
  'Access-Control-Allow-Origin': '*',
29
33
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
@@ -43,28 +47,28 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
43
47
  const principal = await authenticate(request, repo);
44
48
  if (request.headers.get('authorization') && !principal) throw new ApiError(401, 'Invalid API key.');
45
49
  const full = principal ? hasScope(principal, 'events:read') : false;
50
+ const filter = readEventFilter(url.searchParams);
46
51
  const [settings, calendars, published, rooms] = await Promise.all([repo.getSettings(), repo.listCalendars(), repo.listPublishedEvents(), repo.listResources()]);
47
52
  const tz = settings.timezone || 'UTC';
48
53
  const map = new Map(calendars.map((c) => [c.id, c]));
49
54
  const resources = new Map(rooms.map((r) => [r.id, r]));
50
55
  const origin = siteOrigin(settings, url);
51
56
 
52
- const slug = url.searchParams.get('calendar');
53
- const cal = slug ? calendars.find((c) => c.slug === slug) : null;
54
- if (slug && (!cal || (cal.visibility === 'private' && !full))) throw new ApiError(404, 'Unknown calendar.');
57
+ const f = resolveEventFilter(filter, { calendars, resources: rooms, allowPrivate: full });
58
+ if (f.calendarIds && f.calendarIds.length < filter.cal.length) throw new ApiError(404, 'Unknown calendar.');
59
+ if (f.roomIds && f.roomIds.length < filter.room.length) throw new ApiError(404, 'Unknown room.');
60
+ const picked = f.calendarIds;
55
61
  const q = (url.searchParams.get('q') ?? '').trim().toLowerCase();
56
62
  let events = published.filter((e) => {
57
- if (cal) return e.calendarIds.includes(cal.id) && (full || eventIsPublic(e, map));
63
+ if (picked) return e.calendarIds.some((id) => picked.includes(id)) && (full || eventIsPublic(e, map));
58
64
  return full ? true : eventIsListed(e, map);
59
65
  });
60
66
  if (q) events = events.filter((e) => `${e.title} ${e.excerpt} ${e.location.name}`.toLowerCase().includes(q));
61
- const tag = url.searchParams.get('tag')?.slice(0, 100), excludeTag = url.searchParams.get('excludeTag')?.slice(0, 100);
62
- if (tag || excludeTag) {
63
- const tagged = tagFilter(tag, excludeTag);
64
- events = events.filter((e) => tagged(full ? e.tags : publicTags(e.tags)));
65
- }
67
+ events = events.filter((e) => tagsMatch(f, e.tags, { internal: full }));
66
68
 
67
69
  if (url.searchParams.get('expand') === '0') {
70
+ // rules, not dates: a room filter matches the series' own rooms
71
+ if (f.roomIds) events = events.filter((e) => occurrenceMatches(f, { event: e }));
68
72
  return withCors(json({ timezone: tz, events: events.map((e) => eventToJson(e, origin, map, settings.timezone, { includePrivate: full, resources })) }));
69
73
  }
70
74
  const day = (param: string, fallback: number) => {
@@ -76,7 +80,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
76
80
  const to = Math.min(day('to', from + 90 * 86_400_000), from + 400 * 86_400_000);
77
81
  if (to <= from) throw new ApiError(400, '"to" must be after "from".');
78
82
  const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit')) || 200));
79
- const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to }).slice(0, limit);
83
+ const occ = expandEvents(events, settings, { displayTz: tz, now, windowStartMs: from, windowEndMs: to })
84
+ .filter((o) => !f.roomIds || occurrenceMatches(f, o))
85
+ .slice(0, limit);
80
86
  const cache = new Map<string, ReturnType<typeof eventToJson>>();
81
87
  const toJson = (id: string) => {
82
88
  if (!cache.has(id)) cache.set(id, eventToJson(events.find((e) => e.id === id)!, origin, map, settings.timezone, { includePrivate: full, resources }));
@@ -94,6 +100,12 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
94
100
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
95
101
  allDay: o.allDay,
96
102
  cancelled: o.cancelled,
103
+ // the date's key (a moved date keeps its original one), and where it moved from
104
+ key: o.key,
105
+ moved: o.moved ? {
106
+ from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
107
+ note: o.moved.note, location: o.moved.location ?? null,
108
+ } : null,
97
109
  // that date's rooms
98
110
  rooms: roomsToJson(o.roomIds, resources, full),
99
111
  event: toJson(o.event.id),
@@ -4,10 +4,13 @@ import { getRepo } from '../../lib/repo';
4
4
  import { siteOrigin } from '../../lib/seo';
5
5
  import { CALENDAR } from '../../lib/calendar/config';
6
6
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
7
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
7
8
 
8
9
  // One calendar's subscribe feed. Public and unlisted calendars are open;
9
10
  // private ones need the secret ?t= token from Calendars → Copy link (staff
10
11
  // subscriptions). A wrong token looks exactly like a missing calendar.
12
+ // ?tag=, ?notag= and ?room= narrow it like the /calendar page (a `cal` here
13
+ // is ignored: the address names the calendar).
11
14
  const sameToken = (a: string, b: string) =>
12
15
  a.length === b.length && a.length > 0 && timingSafeEqual(Buffer.from(a), Buffer.from(b));
13
16
 
@@ -18,8 +21,11 @@ export const GET: APIRoute = async ({ locals, url, params }) => {
18
21
  if (!calendar || calendar.kind !== 'native') return new Response(null, { status: 404 });
19
22
  const isPrivate = calendar.visibility === 'private';
20
23
  if (isPrivate && !sameToken(url.searchParams.get('t') ?? '', calendar.feedToken)) return new Response(null, { status: 404 });
21
- const settings = await repo.getSettings();
22
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate });
24
+ const filter = { ...readEventFilter(url.searchParams), cal: [] };
25
+ const [settings, resources] = await Promise.all([repo.getSettings(), filter.room.length ? repo.listResources() : []]);
26
+ const resolved = resolveEventFilter(filter, { calendars: [], resources, allowPrivate: isPrivate });
27
+ if (resolved.unknown) return new Response(null, { status: 404 });
28
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { calendar, allowPrivate: isPrivate, filter: resolved });
23
29
  // private feeds must never sit in shared caches
24
30
  const res = icsResponse(body, `${calendar.slug}.ics`, isPrivate ? 0 : 300);
25
31
  if (isPrivate) res.headers.set('Cache-Control', 'private, no-store');
@@ -3,13 +3,20 @@ import { getRepo } from '../../lib/repo';
3
3
  import { siteOrigin } from '../../lib/seo';
4
4
  import { CALENDAR } from '../../lib/calendar/config';
5
5
  import { feedFor, icsResponse } from '../../lib/calendar/feeds';
6
+ import { readEventFilter, resolveEventFilter } from '../../lib/calendar/filter';
6
7
 
7
8
  // Every public calendar in one subscribe feed (/calendar.ics). Feed
8
9
  // calendars are not re-published — subscribers can add those at the source.
10
+ // The /calendar page's filters narrow it: /calendar.ics?tag=Class,
11
+ // ?cal=youth,events&notag=Class, ?room=chapel. A calendar or room that isn't
12
+ // public answers 404, like one that doesn't exist.
9
13
  export const GET: APIRoute = async ({ locals, url }) => {
10
14
  if (!CALENDAR.enabled) return new Response(null, { status: 404 });
11
15
  const repo = await getRepo(locals);
12
- const settings = await repo.getSettings();
13
- const { body } = await feedFor(repo, settings, siteOrigin(settings, url), {});
16
+ const filter = readEventFilter(url.searchParams);
17
+ const [settings, calendars, resources] = await Promise.all([repo.getSettings(), repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
18
+ const resolved = resolveEventFilter(filter, { calendars, resources });
19
+ if (resolved.unknown) return new Response(null, { status: 404 });
20
+ const { body } = await feedFor(repo, settings, siteOrigin(settings, url), { filter: resolved });
14
21
  return icsResponse(body, 'calendar.ics');
15
22
  };
@@ -3,50 +3,69 @@ import Site from '../../layouts/Site.astro';
3
3
  import { getRepo } from '../../lib/repo';
4
4
  import { effectiveSettings } from '../../lib/designPreview';
5
5
  import { siteOrigin } from '../../lib/seo';
6
- import { siteLocale } from '../../lib/dates';
7
- import { CALENDAR, calendarFeedUrl, calendarPageUrl } from '../../lib/calendar/config';
6
+ import { siteLang, siteLocale } from '../../lib/dates';
7
+ import { I18N, localePath, requestLocale } from '../../lib/i18n';
8
+ import { isExtraLocale } from '../../lib/translations';
9
+ import { fill } from '../../lib/live/words';
10
+ import { eventStrings } from '../../lib/calendar/event-words';
11
+ import { CALENDAR, calendarFeedUrl, eventUrl } from '../../lib/calendar/config';
8
12
  import { calendarWindow, renderCalendarView } from '../../lib/calendar/render';
9
13
  import { collectViewEvents, type ViewEvent } from '../../lib/calendar/data';
10
14
  import { eventCardHtml } from '../../lib/calendar/cards';
11
15
  import { safeColor } from '../../lib/calendar/events';
12
16
  import { tzParts } from '../../lib/calendar/ics';
17
+ import { eventFilterQuery, hasEventFilter, readEventFilter, resolveEventFilter, type EventFilter } from '../../lib/calendar/filter';
13
18
 
14
19
  // The main calendar page: filter chips per public calendar, Month / Week /
15
20
  // List views (the same renderer as the calendar card), image cards for
16
21
  // what's coming up, and subscribe links for every calendar app. On phones the
17
22
  // month grid gives way to a two-week day strip over the agenda.
23
+ // The address carries the filters (filter.ts): ?cal=youth,events, ?tag=Class,
24
+ // ?notag=Class, ?room=chapel. Active ones show as removable chips, and the
25
+ // subscribe links carry them. /es/calendar speaks Spanish (the same languages
26
+ // as event pages) and keeps its links under /es.
18
27
  if (!CALENDAR.enabled) return new Response(null, { status: 404, statusText: 'Not found' });
19
28
 
20
29
  const repo = await getRepo(Astro.locals);
21
- const settings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
30
+ const reqLocale = I18N.enabled ? requestLocale(Astro.url, Astro.locals) : '';
31
+ const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
32
+ // dates and the renderer's words follow the page's language, like event pages
33
+ const settings = isExtraLocale(reqLocale) ? { ...baseSettings, locale: reqLocale } : baseSettings;
34
+ const lang = reqLocale || siteLang(settings);
35
+ const w = eventStrings(lang);
36
+ const cw = w.calendar;
37
+ const here = (path: string) => localePath(path, reqLocale);
22
38
  const now = Date.now();
23
39
  const tz = settings.timezone || 'UTC';
24
40
  const locale = siteLocale(settings);
25
41
  const origin = siteOrigin(settings, Astro.url);
26
42
 
27
- const all = await repo.listCalendars();
43
+ const filter = readEventFilter(Astro.url.searchParams);
44
+ const filtered = hasEventFilter(filter);
45
+ const [all, resources] = await Promise.all([repo.listCalendars(), filter.room.length ? repo.listResources() : []]);
46
+ const resolved = resolveEventFilter(filter, { calendars: all, resources });
28
47
  const listed = all.filter((c) => c.visibility === 'public');
29
- const calSlug = Astro.url.searchParams.get('cal') ?? '';
30
- // unlisted calendars work when linked directly, they just aren't offered
31
- const active = calSlug ? all.find((c) => c.slug === calSlug && c.visibility !== 'private') ?? null : null;
48
+ // exactly one calendar picked: its name, description and own feed
49
+ const active = filter.cal.length === 1 && resolved.calendars.length === 1 ? resolved.calendars[0] : null;
50
+ const picked = new Set(resolved.calendarIds ?? []);
32
51
  const viewParam = Astro.url.searchParams.get('view');
33
52
  const view = (['month', 'week', 'list'] as const).find((v) => v === viewParam) ?? CALENDAR.defaultView;
34
- const explicitView = Boolean(viewParam);
35
- const monthParam = /^(\d{4})-(\d{2})$/.exec(Astro.url.searchParams.get('m') ?? '');
53
+ const explicitView = viewParam === view;
54
+ const monthRaw = Astro.url.searchParams.get('m') ?? '';
55
+ const monthParam = /^(\d{4})-(\d{2})$/.exec(monthRaw);
36
56
 
37
57
  const win = calendarWindow(settings, now);
38
- const { events, calendars, errors } = await collectViewEvents(repo, settings, win, {
39
- audience: 'public',
40
- ...(active ? { calendarIds: [active.id] } : {}),
41
- });
58
+ const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'public', filter: resolved, locale: lang });
42
59
 
43
- const calendarHtml = renderCalendarView(events, settings, now, {
60
+ const gridHtml = renderCalendarView(events, settings, now, {
44
61
  view: view === 'list' ? 'upcoming' : view,
45
62
  limit: 50,
46
- multi: !active && calendars.length > 1,
47
- emptyText: 'Nothing on the calendar yet.',
63
+ multi: calendars.length > 1,
64
+ emptyText: filtered ? cw.noMatch : cw.empty,
48
65
  focus: monthParam ? { y: Number(monthParam[1]), m: Number(monthParam[2]) } : undefined,
49
66
  });
67
+ // an empty month or week grid says why (the list says it itself)
68
+ const calendarHtml = (filtered && !events.length && view !== 'list' ? '<p class="pc-cal-empty">' + cw.noMatch + '</p>' : '') + gridHtml;
50
69
 
51
70
  // coming up: the next occurrence of each distinct native event
52
71
  const seen = new Set<string>();
@@ -79,19 +98,44 @@ const stripDays = Array.from({ length: 14 }, (_, i) => {
79
98
  return { k, dow: fmt({ weekday: 'narrow' }).format(ms), d: tzParts(ms, tz).d, has: agenda.has(k), today: i === 0 };
80
99
  });
81
100
 
82
- const feed = origin + calendarFeedUrl(active?.slug);
101
+ // links keep the filters, the view, the month and the page's language
102
+ const pageHref = (f: EventFilter, opts: { view?: string | null; m?: string | null } = {}) => {
103
+ const v = opts.view === undefined ? (explicitView ? view : null) : opts.view;
104
+ const m = opts.m === undefined ? (monthParam ? monthRaw : null) : opts.m;
105
+ const q = [eventFilterQuery(f), v ? `view=${v}` : '', m ? `m=${m}` : ''].filter(Boolean).join('&');
106
+ return here(CALENDAR.base) + (q ? `?${q}` : '');
107
+ };
108
+ const viewHref = (v: string) => pageHref(filter, { view: v, m: null });
109
+ const without = (key: keyof EventFilter, value: string) => pageHref({ ...filter, [key]: filter[key].filter((x) => x !== value) });
110
+ // calendar chips switch a calendar on or off; "All" drops the calendar filter
111
+ const toggleCal = (slug: string) => {
112
+ const on = filter.cal.some((s) => s.toLowerCase() === slug);
113
+ return pageHref({ ...filter, cal: on ? filter.cal.filter((s) => s.toLowerCase() !== slug) : [...filter.cal, slug] });
114
+ };
115
+ const offered = [...listed, ...resolved.calendars.filter((c) => c.visibility !== 'public')];
116
+ const roomName = (slug: string) => resolved.rooms.find((r) => r.slug === slug.toLowerCase())?.name ?? slug;
117
+ // the removable chips: tags, left-out tags, rooms, and calendars that didn't resolve
118
+ const chips = [
119
+ ...filter.tag.map((t) => ({ label: t, href: without('tag', t) })),
120
+ ...filter.notag.map((t) => ({ label: fill(cw.without, { tag: t }), href: without('notag', t) })),
121
+ ...filter.room.map((r) => ({ label: fill(cw.room, { room: roomName(r) }), href: without('room', r) })),
122
+ ...filter.cal.filter((s) => !resolved.calendars.some((c) => c.slug === s.toLowerCase())).map((s) => ({ label: s, href: without('cal', s) })),
123
+ ];
124
+
125
+ // subscribe: one calendar alone keeps its own feed; anything else is the
126
+ // site feed with the same filters
127
+ const ownFeed = active && !filter.tag.length && !filter.notag.length && !filter.room.length;
128
+ const feedQuery = eventFilterQuery(filter);
129
+ const feed = origin + (ownFeed ? calendarFeedUrl(active.slug) : calendarFeedUrl() + (feedQuery ? `?${feedQuery}` : ''));
83
130
  const webcal = feed.replace(/^https?:\/\//, 'webcal://');
84
131
  const googleAdd = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(webcal)}`;
85
132
  const outlookAdd = `https://outlook.live.com/calendar/0/addfromweb?url=${encodeURIComponent(feed)}&name=${encodeURIComponent(active?.name ?? settings.siteTitle)}`;
86
- const viewHref = (v: string) => {
87
- const p = new URLSearchParams(Astro.url.search);
88
- p.set('view', v);
89
- p.delete('m');
90
- return `${CALENDAR.base}?${p}`;
91
- };
92
- const title = active ? active.name : 'Calendar';
93
- const lede = active?.description || `What’s happening at ${settings.siteTitle}. Subscribe to keep it in your own calendar.`;
133
+ const subscribeNote = ownFeed ? fill(cw.subscribeNamed, { name: active.name }) : filtered ? cw.subscribeFiltered : cw.subscribeAll;
134
+ const title = active ? active.name : cw.title;
135
+ const lede = active?.description || fill(cw.lede, { site: settings.siteTitle });
94
136
  const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.type === 'timeZoneName')?.value ?? tz;
137
+ const agendaEmpty = filtered && !events.length ? cw.noMatch : cw.emptySoon;
138
+ const cardHref = (v: ViewEvent) => here(eventUrl(v.event!.slug));
95
139
  ---
96
140
 
97
141
  <Site title={title} description={lede}>
@@ -102,40 +146,51 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
102
146
  </header>
103
147
 
104
148
  <div class="pc-calpage-bar">
105
- {(listed.length > 1 || active) && (
106
- <nav class="pc-calpage-chips" aria-label="Calendars">
107
- <a class={!active ? 'is-on' : ''} href={CALENDAR.base} aria-current={!active ? 'page' : undefined}>All</a>
108
- {listed.map((c) => (
109
- <a class={active?.id === c.id ? 'is-on' : ''} href={calendarPageUrl(c.slug)} aria-current={active?.id === c.id ? 'page' : undefined}>
149
+ {(offered.length > 1 || picked.size > 0) && (
150
+ <nav class="pc-calpage-chips" aria-label={cw.calendars}>
151
+ <a class={!filter.cal.length ? 'is-on' : ''} href={pageHref({ ...filter, cal: [] })} aria-current={!filter.cal.length ? 'page' : undefined}>{cw.all}</a>
152
+ {offered.map((c) => (
153
+ <a class={picked.has(c.id) ? 'is-on' : ''} href={toggleCal(c.slug)} aria-current={picked.has(c.id) ? 'true' : undefined}>
110
154
  <i style={`background:${safeColor(c.color)}`} aria-hidden="true" />{c.name}
111
155
  </a>
112
156
  ))}
113
- {active && active.visibility !== 'public' && <a class="is-on" href={calendarPageUrl(active.slug)}><i style={`background:${safeColor(active.color)}`} aria-hidden="true" />{active.name}</a>}
114
157
  </nav>
115
158
  )}
116
159
  <div class="pc-calpage-tools">
117
- <nav class="pc-calpage-views" aria-label="View">
118
- <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>Month</a>
119
- <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>Week</a>
120
- <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>List</a>
160
+ <nav class="pc-calpage-views" aria-label={cw.views}>
161
+ <a class={view === 'month' ? 'is-on' : ''} href={viewHref('month')}>{cw.month}</a>
162
+ <a class={view === 'week' ? 'is-on' : ''} href={viewHref('week')}>{cw.week}</a>
163
+ <a class={view === 'list' ? 'is-on' : ''} href={viewHref('list')}>{cw.list}</a>
121
164
  </nav>
122
165
  <details class="pc-calpage-sub">
123
166
  <summary>
124
167
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14" /></svg>
125
- Subscribe
168
+ {cw.subscribe}
126
169
  </summary>
127
170
  <div class="pc-calpage-sub-menu">
128
- <p>Add {active ? `“${active.name}”` : 'this calendar'} to your calendar app. New events show up on their own.</p>
171
+ <p>{subscribeNote}</p>
129
172
  <a href={googleAdd} target="_blank" rel="noopener">Google Calendar</a>
130
173
  <a href={webcal}>Apple Calendar</a>
131
174
  <a href={outlookAdd} target="_blank" rel="noopener">Outlook</a>
132
- <button type="button" data-copy={feed}>Copy link</button>
175
+ <button type="button" data-copy={feed} data-copied={w.linkCopied}>{cw.copyLink}</button>
133
176
  </div>
134
177
  </details>
135
178
  </div>
136
179
  </div>
137
180
 
138
- <div class="pc-calpage-strip" aria-label="Next two weeks">
181
+ {chips.length > 0 && (
182
+ <nav class="pc-calpage-filters" aria-label={cw.filters}>
183
+ {chips.map((c) => (
184
+ <a class="pc-calpage-filter" href={c.href} aria-label={fill(cw.remove, { filter: c.label })}>
185
+ <span>{c.label}</span>
186
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17" /></svg>
187
+ </a>
188
+ ))}
189
+ {chips.length > 1 && <a class="pc-calpage-clear" href={pageHref({ cal: [], tag: [], notag: [], room: [] })}>{cw.clear}</a>}
190
+ </nav>
191
+ )}
192
+
193
+ <div class="pc-calpage-strip" aria-label={cw.nextTwoWeeks}>
139
194
  {stripDays.map((d) => (
140
195
  <a class={`${d.has ? 'has' : ''}${d.today ? ' today' : ''}`} href={d.has ? `#d-${d.k}` : undefined} aria-label={d.k}>
141
196
  <small>{d.dow}</small><b>{d.d}</b><i aria-hidden="true" />
@@ -145,13 +200,13 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
145
200
 
146
201
  <div class="pc-calpage-view" set:html={calendarHtml} />
147
202
 
148
- <section class="pc-calpage-agenda" aria-label="Agenda">
149
- {agenda.size === 0 ? <p class="pc-cal-empty">Nothing in the next two months.</p> : [...agenda.entries()].map(([k, list]) => (
203
+ <section class="pc-calpage-agenda" aria-label={cw.agenda}>
204
+ {agenda.size === 0 ? <p class="pc-cal-empty">{agendaEmpty}</p> : [...agenda.entries()].map(([k, list]) => (
150
205
  <div class="pc-agenda-day" id={`d-${k}`}>
151
206
  <h3>{dayLabel.format(list[0].start < now ? now : list[0].start)}</h3>
152
207
  {list.map((v) => (
153
208
  <a class={`pc-agenda-row${v.cancelled ? ' is-cancelled' : ''}`} href={v.url ?? undefined}>
154
- <span class="t">{v.allDay ? 'All day' : timeLabel.format(v.start)}</span>
209
+ <span class="t">{v.allDay ? w.allDay : timeLabel.format(v.start)}</span>
155
210
  <span class="ti">
156
211
  {v.color && <i style={`background:${v.color}`} aria-hidden="true" />}
157
212
  <b>{v.title}</b>
@@ -165,14 +220,14 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
165
220
 
166
221
  {view !== 'list' && comingUp.length > 0 && (
167
222
  <section class="pc-calpage-up">
168
- <h2>Coming up</h2>
169
- <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true })).join('')} />
223
+ <h2>{cw.comingUp}</h2>
224
+ <div class="pc-ev-cards" data-layout="grid" set:html={comingUp.map((v) => eventCardHtml({ event: v.event!, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: v.cancelled }, settings, { showImage: true, href: cardHref(v) })).join('')} />
170
225
  </section>
171
226
  )}
172
227
 
173
228
  <footer class="pc-calpage-foot">
174
- <span>Times in {zoneName}</span>
175
- {errors.length > 0 && <span>Some linked calendars couldn’t be loaded right now.</span>}
229
+ <span>{fill(cw.timesIn, { zone: zoneName })}</span>
230
+ {errors.length > 0 && <span>{cw.feedErrors}</span>}
176
231
  </footer>
177
232
  </div>
178
233
  <script>
@@ -180,8 +235,9 @@ const zoneName = fmt({ timeZoneName: 'long' }).formatToParts(now).find((p) => p.
180
235
  const b = e.target instanceof Element ? e.target.closest<HTMLButtonElement>('[data-copy]') : null;
181
236
  if (b) {
182
237
  await navigator.clipboard.writeText(b.dataset.copy ?? '');
183
- b.textContent = 'Link copied';
184
- setTimeout(() => (b.textContent = 'Copy link'), 1400);
238
+ const label = (b.dataset.label ??= b.textContent ?? '');
239
+ b.textContent = b.dataset.copied ?? label;
240
+ setTimeout(() => (b.textContent = label), 1400);
185
241
  }
186
242
  document.querySelectorAll<HTMLDetailsElement>('details.pc-calpage-sub[open]').forEach((d) => {
187
243
  if (!(e.target instanceof Node && d.contains(e.target))) d.open = false;