@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
@@ -0,0 +1,140 @@
1
+ // Which events a calendar view shows, read from its address. The /calendar
2
+ // page, embeds, /api/v1/events, /api/calendar/events, the subscribe feeds
3
+ // and the TV display all take the same parameters and apply the same rules:
4
+ // cal=youth,events only these calendars (slugs); `calendar` is an alias
5
+ // tag=Class only events with one of these tags
6
+ // notag=Class never events with any of these; `excludeTag` is an alias
7
+ // room=chapel only events held in one of these rooms (resource slugs)
8
+ // Each can repeat (tag=a&tag=b) or be comma-separated (tag=a,b). A list
9
+ // matches any of its values; different parameters must all match. Tags keep
10
+ // the hasTag rules (a name in any case, or its slug); internal "#" tags only
11
+ // match with allowPrivate. Publicly, private calendars and rooms not shown on
12
+ // public events resolve exactly like slugs that don't exist, so a link can't
13
+ // probe for them.
14
+ import { hasTag, publicTags, type CalEvent, type Calendar, type Resource } from '../schema';
15
+
16
+ export interface EventFilter {
17
+ cal: string[];
18
+ tag: string[];
19
+ notag: string[];
20
+ room: string[];
21
+ }
22
+
23
+ export const NO_FILTER: EventFilter = Object.freeze({ cal: [], tag: [], notag: [], room: [] }) as EventFilter;
24
+
25
+ const NAMES: Record<keyof EventFilter, string[]> = {
26
+ cal: ['cal', 'calendar'],
27
+ tag: ['tag'],
28
+ notag: ['notag', 'excludeTag'],
29
+ room: ['room'],
30
+ };
31
+ const MAX_VALUES = 20;
32
+ const MAX_LENGTH = 100;
33
+
34
+ /** the filter in an address's query (`Astro.url.searchParams`, `url.searchParams`) */
35
+ export function readEventFilter(params: URLSearchParams): EventFilter {
36
+ const read = (names: string[]) => {
37
+ const out: string[] = [];
38
+ for (const name of names) for (const raw of params.getAll(name)) for (const part of raw.split(',')) {
39
+ const v = part.trim().slice(0, MAX_LENGTH);
40
+ if (v && out.length < MAX_VALUES && !out.some((o) => o.toLowerCase() === v.toLowerCase())) out.push(v);
41
+ }
42
+ return out;
43
+ };
44
+ return { cal: read(NAMES.cal), tag: read(NAMES.tag), notag: read(NAMES.notag), room: read(NAMES.room) };
45
+ }
46
+
47
+ export const hasEventFilter = (f: EventFilter) => f.cal.length + f.tag.length + f.notag.length + f.room.length > 0;
48
+
49
+ /**
50
+ * The filter as a query string without the "?" ('' when empty), in the
51
+ * canonical names with comma-separated values: "cal=events&notag=Class".
52
+ * For links, subscribe feeds and API calls that should show the same events.
53
+ */
54
+ export function eventFilterQuery(f: EventFilter): string {
55
+ return (Object.keys(NAMES) as (keyof EventFilter)[])
56
+ .filter((k) => f[k].length)
57
+ .map((k) => `${k}=${f[k].map(encodeURIComponent).join(',')}`)
58
+ .join('&');
59
+ }
60
+
61
+ export interface ResolvedEventFilter {
62
+ /** null: no calendar filter; [] when `cal` was given but none resolved (the view is then empty) */
63
+ calendarIds: string[] | null;
64
+ /** null: no room filter; [] when `room` was given but none resolved */
65
+ roomIds: string[] | null;
66
+ /** the calendars and rooms that resolved, in the order the address gave them */
67
+ calendars: Calendar[];
68
+ rooms: Resource[];
69
+ tag: string[];
70
+ notag: string[];
71
+ /** internal "#" tags match (allowPrivate) */
72
+ internal: boolean;
73
+ /** some `cal` or `room` value didn't resolve (unknown, private or hidden) */
74
+ unknown: boolean;
75
+ }
76
+
77
+
78
+ /**
79
+ * Turns slugs into ids. Publicly, unlisted calendars resolve (as a direct
80
+ * `?cal=` link always has) but private ones don't, and only rooms shown on
81
+ * public events resolve. allowPrivate (API keys with events:read, saved
82
+ * screens) resolves every calendar and room and lets "#" tags match.
83
+ */
84
+ export function resolveEventFilter(
85
+ f: EventFilter,
86
+ ctx: { calendars: Calendar[]; resources?: Resource[]; allowPrivate?: boolean }
87
+ ): ResolvedEventFilter {
88
+ const allowPrivate = ctx.allowPrivate ?? false;
89
+ const pick = <T extends { id: string; slug: string }>(slugs: string[], items: T[], visible: (item: T) => boolean) => {
90
+ const found: T[] = [];
91
+ for (const s of slugs) {
92
+ const item = items.find((i) => i.slug === s.toLowerCase() && visible(i));
93
+ if (item && !found.includes(item)) found.push(item);
94
+ }
95
+ return found;
96
+ };
97
+ const calendars = pick(f.cal, ctx.calendars, (c) => allowPrivate || c.visibility !== 'private');
98
+ const rooms = pick(f.room, ctx.resources ?? [], (r) => allowPrivate || r.showOnEvents);
99
+ return {
100
+ calendarIds: f.cal.length ? calendars.map((c) => c.id) : null,
101
+ roomIds: f.room.length ? rooms.map((r) => r.id) : null,
102
+ calendars, rooms,
103
+ tag: f.tag, notag: f.notag,
104
+ internal: allowPrivate,
105
+ unknown: calendars.length < f.cal.length || rooms.length < f.room.length,
106
+ };
107
+ }
108
+
109
+ /** `tag` (any of them, when given) and no `notag`; "#" tags only count when internal */
110
+ export function tagsMatch(f: Pick<EventFilter, 'tag' | 'notag'>, names: string[], opts: { internal?: boolean } = {}): boolean {
111
+ const shown = opts.internal ? names : publicTags(names);
112
+ return (!f.tag.length || f.tag.some((t) => hasTag(shown, t))) && !f.notag.some((t) => hasTag(shown, t));
113
+ }
114
+
115
+ const overlaps = (wanted: string[] | null, have: string[]) => wanted === null || have.some((id) => wanted.includes(id));
116
+
117
+ /**
118
+ * Does one event pass? `roomIds` are the rooms the date holds: the event's
119
+ * room hold unless a moved date changed them. Visibility (drafts, private
120
+ * calendars) is the caller's job; this only applies the filter.
121
+ */
122
+ export function eventMatches(r: ResolvedEventFilter, e: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>, roomIds: string[] = e.roomHold.resourceIds): boolean {
123
+ return overlaps(r.calendarIds, e.calendarIds) && overlaps(r.roomIds, roomIds) && tagsMatch(r, e.tags, { internal: r.internal });
124
+ }
125
+
126
+ /** eventMatches for one occurrence, using the rooms that date holds */
127
+ export function occurrenceMatches(r: ResolvedEventFilter, o: { event: Pick<CalEvent, 'calendarIds' | 'tags' | 'roomHold'>; roomIds?: string[] }): boolean {
128
+ return eventMatches(r, o.event, o.roomIds ?? o.event.roomHold.resourceIds);
129
+ }
130
+
131
+ /**
132
+ * Entries without an event (feed calendars, Reserved blocks) have no tags:
133
+ * `tag` leaves them out and `notag` keeps them. A Reserved block passes a
134
+ * room filter for its own room; feed entries never do.
135
+ */
136
+ export function untaggedMatches(r: ResolvedEventFilter, opts: { calendarId?: string; roomId?: string } = {}): boolean {
137
+ if (r.tag.length) return false;
138
+ if (r.roomIds !== null && !(opts.roomId && r.roomIds.includes(opts.roomId))) return false;
139
+ return r.calendarIds === null || Boolean(opts.calendarId && r.calendarIds.includes(opts.calendarId));
140
+ }
@@ -5,7 +5,8 @@
5
5
  // across DST — Google, Apple and Outlook all resolve IANA zone ids. All-day
6
6
  // events are VALUE=DATE with the RFC's exclusive DTEND. A single cancelled
7
7
  // date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
8
- // STATUS:CANCELLED, so calendar apps strike that one date.
8
+ // STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
9
+ // override with its new DTSTART/DTEND (and its own place), so they show it there.
9
10
  import type { CalEvent, Calendar } from '../schema';
10
11
  import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
11
12
  import { zonedToUtc } from './ics';
@@ -103,10 +104,32 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
103
104
  if (e.cancelled) lines.push('STATUS:CANCELLED');
104
105
  const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
105
106
  lines.push(...stamps, 'END:VEVENT');
106
- if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps));
107
+ if (rule && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
107
108
  return lines;
108
109
  }
109
110
 
111
+ // one override per moved date that is still part of the series (and not
112
+ // cancelled): the same date (RECURRENCE-ID) at its new time
113
+ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
114
+ const out: string[] = [];
115
+ const cancelled = new Set(e.cancelledDates ?? []);
116
+ for (const key of Object.keys(e.movedDates ?? {}).sort()) {
117
+ const m = e.movedDates[key];
118
+ const w = parseWall(key), ms = parseWall(m.start), me = parseWall(m.end) ?? ms;
119
+ if (cancelled.has(key) || !w || !ms || !me || !findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')) continue;
120
+ const when = e.allDay
121
+ ? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(ms)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...me, dateOnly: true }, 1))}`]
122
+ : [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(ms)}`, `DTEND;TZID=${tz}:${localValue(me)}`];
123
+ const place = m.location ?? e.location;
124
+ const loc = locationLine(place);
125
+ const desc = [m.note, e.excerpt, place.url && place.url !== loc ? place.url : '', url].filter(Boolean).join('\n\n');
126
+ out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
127
+ `SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
128
+ `DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
129
+ }
130
+ return out;
131
+ }
132
+
110
133
  // one override per cancelled date that is still part of the series (keys
111
134
  // left behind by a moved series are ignored)
112
135
  function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
@@ -0,0 +1,291 @@
1
+ // Moved dates (CalEvent.movedDates): one date of a repeating event at another
2
+ // time, and maybe in other rooms or at another place, keeping what makes it
3
+ // that date: its key, its RSVPs (they move with it, and people get the "new
4
+ // time" email in their language with their cancel link), its hosts and its
5
+ // page. Moving and moving back apply at once, even on a published event, and
6
+ // leave staged edits alone, like cancelling a date. A date is moved or
7
+ // cancelled, never both: cancelling a moved date drops the move.
8
+ //
9
+ // These check who is acting, so the admin, the built-in member pages and a
10
+ // site's own code (@acms-subir/core/server) share the rules:
11
+ // - staff ("Create and edit content") and GOOGLE_SYNC (a calendar sync acting
12
+ // with staff rights) may move any date. A room taken at the new time is
13
+ // returned as a warning, since the change may already have happened
14
+ // elsewhere;
15
+ // - a host of the event may move its dates while the rooms are free; a taken
16
+ // room refuses the move (409), and staff are emailed about each host move.
17
+ // `quiet` skips the app notice and the staff email (a sync catching up); the
18
+ // RSVP emails always go, because those people's own booking changed.
19
+ import type { ContentRepository } from '../repo/types';
20
+ import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
+ import { ApiError } from '../api';
22
+ import { eventSideEffects, type SaveHooks } from '../content-service';
23
+ import { addWallDays, addWallMinutes, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
24
+ import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
+ import { kickEmail } from '../email/service';
26
+ import { waitUntilFrom } from '../webhooks';
27
+ import { MEMBERS } from './members-config';
28
+ import { siteLocale } from '../dates';
29
+
30
+ /** The actor for a calendar sync (e.g. Google): staff rights, room clashes only warn. */
31
+ export const GOOGLE_SYNC = 'google-sync';
32
+ /** Who is acting: a person (currentUser() from @acms-subir/core/server fits) or the sync. */
33
+ export type MoveActor = { id: string; can(permission: string): boolean } | typeof GOOGLE_SYNC;
34
+
35
+ export interface MoveInput {
36
+ /** the new start, wall-clock in the event's timezone: YYYY-MM-DDTHH:mm, or YYYY-MM-DD for an all-day event */
37
+ start: string;
38
+ /** the new end in the same form (all-day: the last day, inclusive); default: the date keeps its length */
39
+ end?: string;
40
+ /** the rooms that date holds instead (Resource ids); omit to keep its current rooms */
41
+ roomIds?: string[];
42
+ /** that date's place instead; null = the event's place again; omit to keep its current one */
43
+ location?: Partial<EventLocation> | null;
44
+ /** a public note shown with the date ("Moved for the youth retreat"); omit to keep the current one */
45
+ note?: string;
46
+ }
47
+ export interface MoveOptions {
48
+ /** no app notice and no staff email (a sync catching up); RSVP emails still go */
49
+ quiet?: boolean;
50
+ now?: number;
51
+ }
52
+ export interface MoveResult {
53
+ event: CalEvent;
54
+ /** what else takes the date's rooms at its new time (warnings for staff; hosts are refused instead) */
55
+ clashes: BusySlot[];
56
+ /** false: the date already was exactly so, and nothing was written, sent or emailed */
57
+ changed: boolean;
58
+ }
59
+
60
+ /**
61
+ * Why a move was refused (error.code, with error.status): one-off (not a
62
+ * repeating event), cancelled-event, not-a-date (the key isn't one of its
63
+ * dates), cancelled-date, started (the date has started or passed),
64
+ * original-passed (moving back to a time that has passed), past (the new time
65
+ * has passed), bad-time, room-taken (a host, 409).
66
+ */
67
+ export type MoveRefusal = 'one-off' | 'cancelled-event' | 'not-a-date' | 'cancelled-date' | 'started' | 'original-passed' | 'past' | 'bad-time' | 'room-taken';
68
+ const refuse = (status: number, code: MoveRefusal, message: string) => Object.assign(new ApiError(status, message), { code });
69
+
70
+ const DAY = 86_400_000;
71
+ const NOTE_MAX = 500;
72
+
73
+ type Role = 'staff' | 'host';
74
+ function roleOf(actor: MoveActor | null, e: CalEvent): Role {
75
+ if (actor === GOOGLE_SYNC) return 'staff';
76
+ if (!actor) throw new ApiError(401, 'Sign in to continue.');
77
+ if (actor.can('content.edit')) return 'staff';
78
+ if (e.hostIds.includes(actor.id)) return 'host';
79
+ throw new ApiError(404, 'Event not found.');
80
+ }
81
+ const actorId = (actor: MoveActor) => (actor === GOOGLE_SYNC ? GOOGLE_SYNC : actor.id);
82
+
83
+ async function load(repo: ContentRepository, id: string) {
84
+ const e = await repo.getEvent(id);
85
+ if (!e || e.status === 'archived') throw new ApiError(404, 'Event not found.');
86
+ return e;
87
+ }
88
+
89
+ // the date as it is now (moved or not), checked for moving (`started` waits: an unchanged move is a no-op even then)
90
+ function dateToChange(e: CalEvent, key: string, settings: { timezone: string }): Occurrence {
91
+ if (!e.rrule) throw refuse(400, 'one-off', 'Only a repeating event has single dates to move. Change the event’s time instead.');
92
+ if (e.cancelled) throw refuse(409, 'cancelled-event', 'This event is cancelled.');
93
+ if (typeof key !== 'string' || key.length > 20) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
94
+ if (e.cancelledDates.includes(key)) throw refuse(409, 'cancelled-date', 'That date is cancelled. Restore it before moving it.');
95
+ const o = findOccurrence(e, key, settings);
96
+ if (!o) throw refuse(400, 'not-a-date', 'That date isn’t part of this event.');
97
+ return o;
98
+ }
99
+ const started = () => refuse(400, 'started', 'That date has already started or passed.');
100
+
101
+ // The new wall times: `start` in the event's form, `end` defaulting to the
102
+ // date's own length (all-day: the same number of days)
103
+ function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
104
+ const s = parseWall(input.start);
105
+ if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
106
+ if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
107
+ let end = input.end;
108
+ if (!end) {
109
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
110
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
111
+ end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
112
+ }
113
+ try {
114
+ return normalizeEventTimes({ start: input.start, end, allDay: e.allDay });
115
+ } catch (err) {
116
+ throw refuse(400, 'bad-time', err instanceof Error ? err.message : 'Pick a valid time.');
117
+ }
118
+ }
119
+
120
+ // what else takes the rooms this date would hold at its new time (setup and
121
+ // cleanup, or the whole day, as the event holds them)
122
+ async function clashesFor(repo: ContentRepository, next: CalEvent, key: string, at: { start: number; end: number }, settings: { timezone: string }): Promise<BusySlot[]> {
123
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
124
+ const holds = holdsFromEvents([next], resources, settings, at.start - 2 * DAY, at.end + 2 * DAY, { force: true }).filter((h) => h.key === key);
125
+ const seen = new Set<string>();
126
+ const out: BusySlot[] = [];
127
+ for (const h of holds) {
128
+ for (const b of await roomBusy(repo, settings, { resourceIds: [h.resourceId], from: h.start, to: h.end, ignore: { eventId: next.id, key } })) {
129
+ const id = `${b.resourceId}:${b.kind}:${b.bookingId ?? `${b.eventId}:${b.key}`}:${b.start}`;
130
+ if (!seen.has(id)) { seen.add(id); out.push(b); }
131
+ }
132
+ }
133
+ return out.sort((a, b) => a.start - b.start);
134
+ }
135
+
136
+ async function refuseForHost(repo: ContentRepository, clashes: BusySlot[]) {
137
+ const names = new Map((await repo.listResources()).map((r) => [r.id, r.name]));
138
+ const rooms = [...new Set(clashes.map((c) => names.get(c.resourceId) ?? 'The room'))];
139
+ const list = rooms.length > 1 ? `${rooms.slice(0, -1).join(', ')} and ${rooms[rooms.length - 1]}` : rooms[0];
140
+ throw refuse(409, 'room-taken', `${list} ${rooms.length > 1 ? 'are' : 'is'} taken at that time. Pick another time${rooms.length ? ' or room' : ''}, or ask the team.`);
141
+ }
142
+
143
+ const sameRooms = (a: string[] | undefined, b: string[]) => a !== undefined && a.length === b.length && a.every((x, i) => x === b[i]);
144
+ // the same move, whoever made it and when
145
+ const sameMove = (a: MovedDate, b: MovedDate) => a.start === b.start && a.end === b.end && a.note === b.note
146
+ && JSON.stringify(a.roomIds ?? null) === JSON.stringify(b.roomIds ?? null) && JSON.stringify(a.location ?? null) === JSON.stringify(b.location ?? null);
147
+
148
+ /**
149
+ * Move one date of a repeating event to a new time (and maybe other rooms or
150
+ * another place). Moving an already moved date replaces its move; moving it
151
+ * back to exactly where it was is moveOccurrenceBack. Moving it to where it
152
+ * already is changes nothing (`changed: false`): retries never email twice.
153
+ */
154
+ export async function moveOccurrence(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, input: MoveInput, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
155
+ const now = opts.now ?? Date.now();
156
+ const e = await load(repo, id);
157
+ const role = roleOf(actor, e);
158
+ const settings = await repo.getSettings();
159
+ const current = dateToChange(e, key, settings);
160
+ const prior = e.movedDates[key];
161
+ const times = newTimes(e, input);
162
+
163
+ let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
164
+ if (roomIds !== undefined) {
165
+ const known = new Set((await repo.listResources()).map((r) => r.id));
166
+ roomIds = roomIds.filter((r) => known.has(r));
167
+ // the event's own rooms aren't an override
168
+ if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
169
+ }
170
+ let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
171
+ if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
172
+ if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
173
+ const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
174
+
175
+ const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: actorId(actor!), at: now };
176
+ if (prior && sameMove(prior, entry)) return { event: e, clashes: [], changed: false };
177
+ const at = movedInstants(e, entry, settings);
178
+ if (!at) throw refuse(400, 'bad-time', 'Pick a valid time.');
179
+
180
+ // back where the series puts it, with nothing else different: that's moving it back
181
+ const series = findOccurrence({ ...e, movedDates: {} }, key, settings)!;
182
+ if (at.start === series.start && at.end === series.end && !roomIds && !location) return moveOccurrenceBack(repo, actor, id, key, hooks, opts);
183
+ if (current.start <= now) throw started();
184
+ if (at.start <= now) throw refuse(400, 'past', 'Pick a time that hasn’t passed yet.');
185
+
186
+ const next: CalEvent = { ...e, movedDates: { ...e.movedDates, [key]: entry } };
187
+ const clashes = await clashesFor(repo, next, key, at, settings);
188
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
189
+
190
+ await repo.setEventMovedDates(id, next.movedDates, undefined, { quiet: opts.quiet });
191
+ const after = (await repo.getEvent(id))!;
192
+ const moved = findOccurrence(after, key, settings)!;
193
+ await afterChange(repo, after, current, moved, { role, actor: actor!, note, hooks, opts, settings });
194
+ return { event: after, clashes, changed: true };
195
+ }
196
+
197
+ /** Put a moved date back where the series has it (idempotent: a date that isn't moved is `changed: false`). Hosts are refused if its rooms are taken there now. */
198
+ export async function moveOccurrenceBack(repo: ContentRepository, actor: MoveActor | null, id: string, key: string, hooks: SaveHooks, opts: MoveOptions = {}): Promise<MoveResult> {
199
+ const now = opts.now ?? Date.now();
200
+ const e = await load(repo, id);
201
+ const role = roleOf(actor, e);
202
+ if (!Object.hasOwn(e.movedDates, key)) return { event: e, clashes: [], changed: false };
203
+ const settings = await repo.getSettings();
204
+ const { [key]: _gone, ...rest } = e.movedDates;
205
+ const next: CalEvent = { ...e, movedDates: rest };
206
+ const current = findOccurrence(e, key, settings);
207
+ const back = findOccurrence(next, key, settings);
208
+ // a key the series no longer has (it was retimed): nothing to put back, just forget it
209
+ if (current && back) {
210
+ if (current.start <= now) throw started();
211
+ if (back.start <= now) throw refuse(400, 'original-passed', 'Its original time has already passed.');
212
+ }
213
+ const clashes = back ? await clashesFor(repo, next, key, back, settings) : [];
214
+ if (clashes.length && role === 'host') await refuseForHost(repo, clashes);
215
+ await repo.setEventMovedDates(id, rest, undefined, { quiet: opts.quiet });
216
+ const after = (await repo.getEvent(id))!;
217
+ if (current && back) await afterChange(repo, after, current, back, { role, actor: actor!, note: '', hooks, opts, settings });
218
+ else await eventSideEffects(repo, hooks, after);
219
+ return { event: after, clashes, changed: true };
220
+ }
221
+
222
+ // RSVPs follow the date (emailed when its time changed), then the usual side
223
+ // effects (webhook; the app notice unless the write was quiet), then staff hear about a host's move
224
+ async function afterChange(repo: ContentRepository, after: CalEvent, from: Occurrence, to: Occurrence,
225
+ c: { role: Role; actor: MoveActor; note: string; hooks: SaveHooks; opts: MoveOptions; settings: Awaited<ReturnType<ContentRepository['getSettings']>> }) {
226
+ if ((from.start !== to.start || from.end !== to.end) && await repo.moveOccurrenceRsvps(after.id, from.start, { start: to.start, end: to.end }, c.note)) {
227
+ await kickEmail(repo, c.hooks.locals, waitUntilFrom(c.hooks.locals));
228
+ }
229
+ await eventSideEffects(repo, c.hooks, after);
230
+ if (c.role === 'host' && !c.opts.quiet && c.actor !== GOOGLE_SYNC) await notifyStaffOfMove(repo, after, from, to, c.actor.id, c.note, c.settings, c.hooks);
231
+ }
232
+
233
+ // "Date moved" to members.notify (else the owners), like members' requests
234
+ async function notifyStaffOfMove(repo: ContentRepository, e: CalEvent, from: Occurrence, to: Occurrence, by: string, note: string,
235
+ settings: Awaited<ReturnType<ContentRepository['getSettings']>>, hooks: SaveHooks) {
236
+ const recipients = MEMBERS.notify.length ? MEMBERS.notify : (await repo.listUsers()).filter((u) => u.role === 'owner' && u.email).map((u) => u.email);
237
+ if (!recipients.length) return;
238
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
239
+ const fmt = (o: Occurrence) => {
240
+ try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
241
+ catch { return new Date(o.start).toISOString(); }
242
+ };
243
+ const version = (await repo.email.config().catch(() => null))?.version ?? 0;
244
+ const at = Date.now();
245
+ for (const recipient of recipients) {
246
+ await repo.email.enqueue({ dedupe: `event-move:${e.id}:${from.key}:${at}:${recipient}`, kind: 'event-request', recipient,
247
+ payload: JSON.stringify({ eventId: e.id, kind: 'staff-moved', at, reply: '', by, note, when: `${fmt(from)} → ${fmt(to)}` }), configVersion: version, expiresAt: at + 7 * DAY });
248
+ }
249
+ await kickEmail(repo, hooks.locals, waitUntilFrom(hooks.locals)).catch(() => {});
250
+ }
251
+
252
+ /** One upcoming date for the editor's Dates panel: its key, wall times (for the move form), words, and state. */
253
+ export interface DateRow {
254
+ key: string;
255
+ /** where it is now, wall-clock in the event's zone (a moved date: its new time) */
256
+ start: string;
257
+ end: string;
258
+ /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
259
+ when: string;
260
+ cancelled: boolean;
261
+ /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
262
+ moved: { from: string; note: string } | null;
263
+ roomIds: string[];
264
+ }
265
+
266
+ /** The next `limit` dates of a series from `now`, moved and cancelled ones included. */
267
+ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentRepository['getSettings']>>, limit: number, now = Date.now()): DateRow[] {
268
+ if (!e.rrule) return [];
269
+ const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
270
+ const locale = siteLocale(settings);
271
+ const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
272
+ const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
273
+ const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
274
+ const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
275
+ const length = es && ee ? wallMinutesBetween(es, ee) : 60;
276
+ return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
277
+ .slice(0, limit)
278
+ .map((o) => {
279
+ const m = o.moved ? e.movedDates[o.key] : undefined;
280
+ const k = parseWall(o.key)!;
281
+ return {
282
+ key: o.key,
283
+ start: m?.start ?? o.key,
284
+ end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
285
+ when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
286
+ cancelled: o.cancelled,
287
+ moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
288
+ roomIds: o.roomIds,
289
+ };
290
+ });
291
+ }
@@ -11,7 +11,8 @@ import { siteLocale } from '../dates';
11
11
  import { getCalendarEvents, tzParts, zonedToUtc } from './ics';
12
12
  import { calendarPageUrl } from './src';
13
13
  import { collectViewEvents, type ViewEvent } from './data';
14
- import { eventStrings } from './event-words';
14
+ import { eventStrings, type EventStrings } from './event-words';
15
+ import { fill } from '../live/words';
15
16
 
16
17
  // Card sources are ICS feed URLs or native site calendars:
17
18
  // 'acms:all' every public calendar (+ uncategorized events)
@@ -58,6 +59,7 @@ interface DisplayEvent {
58
59
  u?: string; // native events: the event page
59
60
  k?: string; // calendar color (#rrggbb), overrides the indexed dot color
60
61
  x?: 1; // cancelled
62
+ mv?: string; // a moved date: "Moved from Thu, Jan 10 · 6:00 PM."
61
63
  }
62
64
 
63
65
  function compactTime(s: string): string {
@@ -65,6 +67,8 @@ function compactTime(s: string): string {
65
67
  }
66
68
 
67
69
  function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayEvent[] {
70
+ const ew = eventStrings(locale);
71
+ const allDay = ew.allDay;
68
72
  const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
69
73
  const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
70
74
  return events.map((e) => {
@@ -73,7 +77,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
73
77
  let tm = '';
74
78
  let rg: string;
75
79
  if (e.allDay) {
76
- rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · All day`;
80
+ rg = e.days > 1 ? `${when} – ${dayFmt.format(e.end - DAY)}` : `${when} · ${allDay}`;
77
81
  } else {
78
82
  tm = compactTime(timeFmt.format(e.start));
79
83
  rg = `${when} · ${timeFmt.format(e.start)} – ${timeFmt.format(e.end)}`;
@@ -84,6 +88,7 @@ function buildDisplay(events: ViewEvent[], tz: string, locale: string): DisplayE
84
88
  ...(e.url ? { u: e.url } : {}),
85
89
  ...(e.color && /^#[0-9a-f]{6}$/i.test(e.color) ? { k: e.color } : {}),
86
90
  ...(e.cancelled ? { x: 1 as const } : {}),
91
+ ...(e.moved && !e.cancelled ? { mv: fill(ew.dateMoved, { from: e.allDay ? dayFmt.format(e.moved.from.start) : `${dayFmt.format(e.moved.from.start)} · ${timeFmt.format(e.moved.from.start)}` }) } : {}),
87
92
  };
88
93
  });
89
94
  }
@@ -104,7 +109,7 @@ function indexByDay(events: DisplayEvent[]): Map<number, number[]> {
104
109
 
105
110
  const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
106
111
  const chipHtml = (e: DisplayEvent, i: number) =>
107
- `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
112
+ `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
108
113
 
109
114
  function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
110
115
  const first = dayNum(y, m, 1);
@@ -132,11 +137,11 @@ function weekHtml(weekStart: number, todayN: number, byDay: Map<number, number[]
132
137
  return `<div class="pc-cal-week">${h}</div>`;
133
138
  }
134
139
 
135
- function upcomingHtml(events: DisplayEvent[], order: number[], limit: number): string {
136
- if (!order.length) return `<p class="pc-cal-empty">No upcoming events.</p>`;
140
+ function upcomingHtml(events: DisplayEvent[], order: number[], limit: number, w: EventStrings): string {
141
+ if (!order.length) return `<p class="pc-cal-empty">${esc(w.calendar.noUpcoming)}</p>`;
137
142
  const items = order.slice(0, limit).map((i) => {
138
143
  const e = events[i];
139
- return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || 'All day'}</span></button></li>`;
144
+ return `<li><button type="button" class="pc-cal-row${e.x ? ' is-cancelled' : ''}${e.mv ? ' is-moved' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="d">${esc(e.rg.split(' · ')[0])}</span><span class="ti">${esc(e.t)}</span><span class="t">${esc(e.tm) || esc(w.allDay)}</span></button></li>`;
140
145
  });
141
146
  return `<ul class="pc-cal-agenda">${items.join('')}</ul>`;
142
147
  }
@@ -153,7 +158,7 @@ function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1
153
158
  function dow(n){return((n+4-(P.ws||0))%7+7)%7}
154
159
  var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
155
160
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
156
- function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
161
+ function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+(e.mv?' is-moved':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
157
162
  function chips(n){return(byDay[n]||[]).map(chip).join('')}
158
163
  var todayN=dnum(P.today),mY=P.month[0],mM=P.month[1],weekN=todayN-dow(todayN);
159
164
  function renderMonth(){
@@ -189,7 +194,7 @@ if(!hit||!root.contains(hit)){if(!t.closest('.pc-cal-pop'))closePop();return}
189
194
  var e=P.events[Number(hit.getAttribute('data-ev'))];if(!e)return;
190
195
  closePop();
191
196
  pop=document.createElement('div');pop.className='pc-cal-pop';
192
- pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="Close">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
197
+ pop.innerHTML='<button type="button" class="pc-cal-pop-x" aria-label="'+esc(P.close)+'">&times;</button>'+(e.x?'<span class="pc-cal-pop-flag">'+esc(P.cancelled)+'</span>':'')+(e.mv?'<span class="pc-cal-pop-flag is-moved">'+esc(P.moved)+'</span>':'')+'<strong class="pc-cal-pop-title">'+esc(e.t)+'</strong><div class="pc-cal-pop-when">'+esc(e.rg)+'</div>'+(e.mv?'<div class="pc-cal-pop-moved">'+esc(e.mv)+'</div>':'')+(e.l?'<div class="pc-cal-pop-loc">'+esc(e.l)+'</div>':'')+(e.ds?'<p class="pc-cal-pop-desc">'+esc(e.ds)+'</p>':'')+(e.u?'<a class="pc-cal-pop-link" href="'+esc(e.u)+'">'+esc(P.details)+' &rarr;</a>':'');
193
198
  frame.appendChild(pop);
194
199
  var fr=frame.getBoundingClientRect(),cr=hit.getBoundingClientRect();
195
200
  pop.style.left=Math.max(8,Math.min(cr.left-fr.left,fr.width-pop.offsetWidth-8))+'px';
@@ -234,7 +239,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
234
239
  (opts.title ? `<h3 class="pc-cal-heading">${esc(opts.title)}</h3>` : '') +
235
240
  (opts.subtitle ? `<p class="pc-cal-sub">${esc(opts.subtitle)}</p>` : '');
236
241
  const tz = settings.timezone || 'UTC';
242
+ // a page in another language passes its settings with that locale
237
243
  const locale = siteLocale(settings);
244
+ const w = eventStrings(locale);
238
245
  const today = tzParts(now, tz);
239
246
  const [minY, minM] = addMonths(today.y, today.m, -6);
240
247
  const [maxY, maxM] = addMonths(today.y, today.m, 6);
@@ -263,10 +270,10 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
263
270
  titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
264
271
  bodyHtml = weekHtml(weekStart, todayN, byDay, events, dows);
265
272
  } else if (opts.view === 'upcoming') {
266
- titleText = 'Upcoming';
273
+ titleText = w.calendar.upcoming;
267
274
  const startOfToday = zonedToUtc(today.y, today.m, today.d, 0, 0, 0, tz);
268
275
  const order = raw.map((e, i) => ({ e, i })).filter(({ e }) => e.end > startOfToday).map(({ i }) => i);
269
- bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)));
276
+ bodyHtml = upcomingHtml(events, order, Math.min(50, Math.max(1, opts.limit || 6)), w);
270
277
  if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
271
278
  } else {
272
279
  titleText = `${months[fm - 1]} ${fy}`;
@@ -276,9 +283,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
276
283
  const navButtons =
277
284
  opts.view === 'upcoming'
278
285
  ? ''
279
- : `<button type="button" class="pc-cal-btn" data-nav="today">Today</button>` +
280
- `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="Previous">‹</button>` +
281
- `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="Next">›</button>`;
286
+ : `<button type="button" class="pc-cal-btn" data-nav="today">${esc(w.calendar.today)}</button>` +
287
+ `<button type="button" class="pc-cal-btn" data-nav="prev" aria-label="${esc(w.calendar.previous)}">‹</button>` +
288
+ `<button type="button" class="pc-cal-btn" data-nav="next" aria-label="${esc(w.calendar.next)}">›</button>`;
282
289
 
283
290
  const footer = opts.footer
284
291
  ? `<div class="pc-cal-foot"><a class="pc-cal-open" href="${esc(opts.footer.href)}" target="_blank" rel="noopener">${esc(opts.footer.label)}</a></div>`
@@ -291,8 +298,8 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
291
298
  min: [minY, minM],
292
299
  max: [maxY, maxM],
293
300
  months, monthsShort, dows, events, ws,
294
- // the pop-up's flag in the dates' language, like event cards
295
- details: 'Details', cancelled: eventStrings(locale).cancelled,
301
+ // the pop-up's words in the dates' language, like event cards
302
+ details: w.calendar.details, cancelled: w.cancelled, close: w.calendar.close, moved: w.moved,
296
303
  };
297
304
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
298
305
 
@@ -319,11 +326,12 @@ export async function renderCalendarBlock(
319
326
  const heading =
320
327
  (a.title ? `<h3 class="pc-cal-heading">${esc(a.title)}</h3>` : '') +
321
328
  (a.subtitle ? `<p class="pc-cal-sub">${esc(a.subtitle)}</p>` : '');
329
+ const w = eventStrings(siteLocale(settings)).calendar;
322
330
 
323
331
  const feeds = a.sources.filter((s) => s.startsWith('http'));
324
332
  const natives = repo ? a.sources.filter(isNativeSource) : [];
325
333
  if (!feeds.length && !natives.length) {
326
- return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">No calendar connected.</p></div></section>`;
334
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.noSource)}</p></div></section>`;
327
335
  }
328
336
 
329
337
  const win = calendarWindow(settings, now);
@@ -346,7 +354,7 @@ export async function renderCalendarBlock(
346
354
  const raw: ViewEvent[] = [...feedEvents, ...native].sort((x, y) => x.start - y.start || x.title.localeCompare(y.title));
347
355
 
348
356
  if (!raw.length && feeds.length && errors.length === feeds.length && !natives.length) {
349
- return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">Calendar unavailable right now.</p></div></section>`;
357
+ return `<section class="pc-calendar is-empty">${heading}<div class="pc-cal-frame"><p class="pc-cal-empty">${esc(w.unavailable)}</p></div></section>`;
350
358
  }
351
359
 
352
360
  const pageUrl = a.showOpenLink ? feeds.map(calendarPageUrl).find(Boolean) ?? '' : '';
@@ -355,7 +363,7 @@ export async function renderCalendarBlock(
355
363
  title: a.title,
356
364
  subtitle: a.subtitle,
357
365
  limit: a.limit,
358
- footer: pageUrl ? { href: pageUrl, label: 'Open in Google Calendar ↗' } : undefined,
366
+ footer: pageUrl ? { href: pageUrl, label: w.openGoogle } : undefined,
359
367
  // dots only mean something with 2+ calendars
360
368
  multi: feeds.length + nativeCalendars > 1,
361
369
  });