@acms-subir/core 0.3.56 → 0.3.58

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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +24 -5
  3. package/docs/calendar.md +130 -2
  4. package/docs/server.md +33 -1
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +52 -9
  7. package/src/components/EventPage.astro +6 -5
  8. package/src/components/calendar/CalendarBoard.tsx +6 -3
  9. package/src/components/editor/event/SingleDates.tsx +75 -15
  10. package/src/lib/booking/service.ts +4 -2
  11. package/src/lib/calendar/api.ts +29 -8
  12. package/src/lib/calendar/board.ts +3 -2
  13. package/src/lib/calendar/cards.ts +4 -4
  14. package/src/lib/calendar/change-migration.ts +17 -3
  15. package/src/lib/calendar/data.ts +16 -7
  16. package/src/lib/calendar/event-words.ts +8 -0
  17. package/src/lib/calendar/events.ts +128 -15
  18. package/src/lib/calendar/google/map.ts +1 -1
  19. package/src/lib/calendar/host-requests.ts +65 -10
  20. package/src/lib/calendar/ics-write.ts +65 -15
  21. package/src/lib/calendar/member-words.ts +14 -1
  22. package/src/lib/calendar/moves.ts +15 -9
  23. package/src/lib/calendar/request-admin.ts +2 -1
  24. package/src/lib/calendar/request-mail.ts +2 -1
  25. package/src/lib/calendar/request-page.ts +2 -1
  26. package/src/lib/calendar/request-service.ts +7 -7
  27. package/src/lib/calendar/schedule.ts +42 -8
  28. package/src/lib/calendar/segments.ts +522 -0
  29. package/src/lib/display/board.ts +6 -5
  30. package/src/lib/editor/schedule.ts +4 -2
  31. package/src/lib/mobile/calendar.ts +6 -5
  32. package/src/lib/mobile/schemas.ts +10 -3
  33. package/src/lib/mobile/search.ts +2 -1
  34. package/src/lib/push/service.ts +42 -10
  35. package/src/lib/push/types.ts +2 -0
  36. package/src/lib/repo/migrations.ts +13 -3
  37. package/src/lib/repo/sql.ts +34 -13
  38. package/src/lib/repo/types.ts +3 -1
  39. package/src/lib/schema.ts +34 -0
  40. package/src/lib/sections.ts +3 -2
  41. package/src/lib/sync/catalog.ts +1 -0
  42. package/src/pages/admin/calendar/events/index.astro +9 -8
  43. package/src/pages/admin/calendar/requests.astro +5 -5
  44. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  45. package/src/pages/api/calendar/events/index.ts +2 -1
  46. package/src/pages/members/events/index.astro +87 -7
  47. package/src/server/index.ts +46 -3
  48. package/src/styles/editor.css +2 -0
@@ -1,16 +1,32 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { actions } from 'astro:actions';
3
3
  import { Section } from './Section';
4
+ import { describeRepeat, rruleMap, WEEKDAY_CODES, weekdayOf } from '../../../lib/calendar/rrule';
4
5
 
5
6
  // Event settings → Single dates: the next dates of a saved repeating event,
6
- // each with Move… and Cancel (or Move back / Restore). These apply at once,
7
- // like on the Schedule, without touching unpublished edits: a moved date keeps
8
- // its sign-ups (they're emailed the new time) and its hosts
9
- // (lib/calendar/moves.ts). The list shows the event as saved.
7
+ // each with Move…, From here on… and Cancel (or Move back / Restore). These
8
+ // apply at once, like on the Schedule, without touching unpublished edits: a
9
+ // moved date keeps its sign-ups (they're emailed the new time) and its hosts
10
+ // (lib/calendar/moves.ts). From here on… changes the series from that date
11
+ // on, and the dates before it stay (lib/calendar/segments.ts); the changes
12
+ // are listed above the dates with Undo. The list shows the event as saved.
10
13
 
11
- interface DateRow { key: string; start: string; end: string; when: string; cancelled: boolean; moved: { from: string; note: string } | null; roomIds: string[] }
12
- interface Data { allDay: boolean; roomIds: string[]; dates: DateRow[]; rooms: { id: string; name: string }[] }
13
- interface Form { key: string; day: string; endDay: string; from: string; to: string; roomIds: string[]; note: string }
14
+ interface DateRow { key: string; start: string; end: string; when: string; cancelled: boolean; partCancelled: boolean; moved: { from: string; note: string } | null; roomIds: string[] }
15
+ interface SegmentRow { from: string; when: string; repeats: string; rooms: string[]; cancelled: boolean; note: string; current: boolean }
16
+ interface Data { allDay: boolean; roomIds: string[]; dates: DateRow[]; rooms: { id: string; name: string }[]; segments: SegmentRow[] }
17
+ interface Form { key: string; day: string; endDay: string; from: string; to: string; roomIds: string[]; note: string; following?: { rrule: string; keep: string } }
18
+
19
+ // the repeats a change from a date on can take, for its first day: the rule
20
+ // in effect moved there (`keep`), or a common one, ending as `keep` ends
21
+ function repeatOptions(day: string, keep: string): { value: string; label: string }[] {
22
+ const wd = weekdayOf(day);
23
+ const code = WEEKDAY_CODES[wd.dow];
24
+ const p = rruleMap(keep);
25
+ const ends = p.COUNT ? `;COUNT=${p.COUNT}` : p.UNTIL ? `;UNTIL=${p.UNTIL}` : '';
26
+ const rules = [`FREQ=WEEKLY;BYDAY=${code}`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=${code}`, `FREQ=MONTHLY;BYDAY=${wd.nth >= 5 ? -1 : wd.nth}${code}`, `FREQ=MONTHLY;BYMONTHDAY=${wd.day}`, 'FREQ=DAILY']
27
+ .map((r) => r + ends).filter((r) => r !== keep);
28
+ return [{ value: keep, label: `${describeRepeat(keep, day) || 'Same as now'} (as now)` }, ...rules.map((r) => ({ value: r, label: describeRepeat(r, day) })), { value: '', label: 'Just this date (no more after it)' }];
29
+ }
14
30
 
15
31
  const datePart = (w: string) => w.slice(0, 10);
16
32
  const timePart = (w: string) => w.slice(11, 16);
@@ -54,15 +70,50 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
54
70
  setNote({ text: warning ? `${done} ${warning}` : done });
55
71
  await load();
56
72
  };
73
+ // From here on…: the date's times, the rule in effect there and its rooms to start with
74
+ const openFollowing = async (d: DateRow) => {
75
+ setNote(null);
76
+ const r = await actions.events.fromDateDefaults({ id: eventId, key: d.key });
77
+ if (r.error || !r.data) { setNote({ text: r.error?.message ?? 'Couldn’t open that date.', error: true }); return; }
78
+ const x = r.data;
79
+ setForm({ key: d.key, day: datePart(x.start), endDay: datePart(x.end), from: timePart(x.start), to: timePart(x.end), roomIds: x.roomIds, note: '', following: { rrule: x.rrule, keep: x.rrule } });
80
+ };
57
81
  const save = (f: Form) => {
58
82
  const start = data.allDay ? f.day : `${f.day}T${f.from}`;
59
83
  // a time that ends before it starts runs past midnight
60
84
  const end = data.allDay ? (f.endDay >= f.day ? f.endDay : f.day) : `${f.to <= f.from ? nextDay(f.day) : f.day}T${f.to}`;
85
+ if (f.following) {
86
+ // the kept rule follows the new first day on the server; a chosen one is sent as it is
87
+ const rrule = f.following.rrule === f.following.keep ? undefined : f.following.rrule;
88
+ return run(f.key, async () => {
89
+ const r = await actions.events.changeFromDate({ id: eventId, key: f.key, start, end, rrule, roomIds: f.roomIds, note: f.note });
90
+ if (r.data && !r.data.changed) return { data: { warning: '' } };
91
+ return r;
92
+ }, 'Changed from this date on. Dates before it stay as they were, and people who signed up were emailed their new dates.');
93
+ }
61
94
  return run(f.key, () => actions.events.moveDate({ id: eventId, key: f.key, start, end, roomIds: f.roomIds, note: f.note }), 'Moved. People who signed up were emailed the new time.');
62
95
  };
63
96
 
64
97
  return (
65
- <Section id="ev-dates" title="Single dates" help="Move or cancel one date. It applies at once, even on a published event, and people who signed up for it are emailed.">
98
+ <Section id="ev-dates" title="Single dates" help="Move or cancel one date, or change every date from one on. It applies at once, even on a published event, and people who signed up are emailed.">
99
+ {data.segments.length > 0 && (
100
+ <ul className="pe-evd pe-evd-segs">
101
+ {data.segments.map((g) => (
102
+ <li key={g.from} className={`pe-evd-row${g.cancelled ? ' is-cancelled' : ' is-moved'}`}>
103
+ <div className="pe-evd-main">
104
+ <span className="pe-evd-when">{g.cancelled ? `Cancelled from ${g.when}` : `From ${g.when}: ${g.repeats}`}</span>
105
+ {(g.rooms.length > 0 || g.note) && <small className="pe-evd-state">{[g.rooms.join(', '), g.note && `“${g.note}”`].filter(Boolean).join(' · ')}</small>}
106
+ </div>
107
+ {!g.current && (
108
+ <div className="pe-evd-acts">
109
+ <button type="button" className="pe-btn sm" disabled={busy === g.from}
110
+ onClick={() => { if (confirm(`Undo the change from ${g.when}? Those dates go back to the pattern before it, and people who signed up are emailed.`)) void run(g.from, () => actions.events.revertFromDate({ id: eventId, from: g.from }), 'Change undone.'); }}>Undo</button>
111
+ </div>
112
+ )}
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ )}
66
117
  <ul className="pe-evd">
67
118
  {data.dates.map((d) => (
68
119
  <li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
@@ -72,11 +123,12 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
72
123
  {d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
73
124
  </div>
74
125
  <div className="pe-evd-acts">
75
- {d.cancelled ? (
126
+ {d.partCancelled ? null : d.cancelled ? (
76
127
  <button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: true }), 'Restored.')}>Restore</button>
77
128
  ) : (
78
129
  <>
79
- <button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key ? setForm(null) : open(d))} aria-expanded={form?.key === d.key}>Move…</button>
130
+ <button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key && !form.following ? setForm(null) : open(d))} aria-expanded={form?.key === d.key && !form.following}>Move…</button>
131
+ <button type="button" className="pe-btn sm" onClick={() => (form?.key === d.key && form.following ? setForm(null) : void openFollowing(d))} aria-expanded={Boolean(form?.key === d.key && form.following)}>From here on…</button>
80
132
  {d.moved && <button type="button" className="pe-btn sm" disabled={busy === d.key} onClick={() => run(d.key, () => actions.events.moveDateBack({ id: eventId, key: d.key }), 'Moved back. People who signed up were emailed.')}>Move back</button>}
81
133
  <button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
82
134
  onClick={() => { if (confirm(`Cancel ${d.when}? It shows as cancelled straight away, and people who signed up are emailed.`)) void run(d.key, () => actions.events.cancelDate({ id: eventId, occurrence: d.key, restore: false }), 'Cancelled.'); }}>Cancel date</button>
@@ -86,7 +138,7 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
86
138
  {form?.key === d.key && (
87
139
  <form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
88
140
  <div className="pe-evd-fields">
89
- <label className="pe-field"><span>{data.allDay ? 'From' : 'Date'}</span><input type="date" required value={form.day} onChange={(e) => setForm({ ...form, day: e.target.value })} /></label>
141
+ <label className="pe-field"><span>{form.following ? 'First date' : data.allDay ? 'From' : 'Date'}</span><input type="date" required value={form.day} onChange={(e) => setForm({ ...form, day: e.target.value })} /></label>
90
142
  {data.allDay ? (
91
143
  <label className="pe-field"><span>To</span><input type="date" required value={form.endDay} min={form.day} onChange={(e) => setForm({ ...form, endDay: e.target.value })} /></label>
92
144
  ) : (
@@ -96,9 +148,17 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
96
148
  </>
97
149
  )}
98
150
  </div>
151
+ {form.following && (
152
+ <label className="pe-field"><span>Repeats</span>
153
+ <select value={form.following.rrule} onChange={(e) => setForm({ ...form, following: { ...form.following!, rrule: e.target.value } })}>
154
+ {repeatOptions(form.day, form.following.keep).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
155
+ </select>
156
+ </label>
157
+ )}
158
+ {form.following && <p className="pe-evd-hint">The new first date can be in the same week as this one. Dates before it stay as they are. Sign-ups, cancelled and moved dates go to the nearest new date within 3½ days; sign-ups with none are cancelled. People are emailed.</p>}
99
159
  {data.rooms.length > 0 && (
100
160
  <fieldset className="pe-evd-rooms">
101
- <legend>Rooms for this date</legend>
161
+ <legend>{form.following ? 'Rooms from then on' : 'Rooms for this date'}</legend>
102
162
  {data.rooms.map((r) => (
103
163
  <label key={r.id} className="pe-evd-room">
104
164
  <input type="checkbox" checked={form.roomIds.includes(r.id)}
@@ -108,11 +168,11 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
108
168
  ))}
109
169
  </fieldset>
110
170
  )}
111
- <label className="pe-field"><span>Note <em>shown with the date, optional</em></span>
112
- <input type="text" maxLength={500} value={form.note} placeholder="Moved for the youth retreat" onChange={(e) => setForm({ ...form, note: e.target.value })} />
171
+ <label className="pe-field"><span>Note <em>{form.following ? 'in the email to people who signed up, optional' : 'shown with the date, optional'}</em></span>
172
+ <input type="text" maxLength={500} value={form.note} placeholder={form.following ? 'We meet on Wednesdays from now on' : 'Moved for the youth retreat'} onChange={(e) => setForm({ ...form, note: e.target.value })} />
113
173
  </label>
114
174
  <div className="pe-evd-formacts">
115
- <button type="submit" className="pe-btn primary sm" disabled={busy === form.key}>{busy === form.key ? 'Moving…' : 'Move this date'}</button>
175
+ <button type="submit" className="pe-btn primary sm" disabled={busy === form.key}>{busy === form.key ? (form.following ? 'Changing…' : 'Moving…') : form.following ? 'Change from this date on' : 'Move this date'}</button>
116
176
  <button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
117
177
  </div>
118
178
  </form>
@@ -5,6 +5,7 @@ import { collectResponse, displayAnswers } from '../forms/service';
5
5
  // calendar immediately); the rest wait as "pending" for staff.
6
6
  import type { ContentRepository } from '../repo/types';
7
7
  import type { Booking, BookingLink, BookingStatus, CalEvent, Resource, Settings } from '../schema';
8
+ import { roomName } from '../schema';
8
9
  import { loadFeed, expandVEvents } from '../calendar/ics';
9
10
  import { emitWebhook } from '../webhooks';
10
11
  import { parseFrom } from '../email/config';
@@ -108,8 +109,9 @@ async function describe(repo: ContentRepository, b: Booking, settings: Settings,
108
109
  const tz = resource ? resourceZone(resource, settings.timezone) : settings.timezone || 'UTC';
109
110
  return {
110
111
  resource, link, event: null as CalEvent | null, tz,
111
- title: [resource?.name, link?.name].filter(Boolean).join(' · ') || 'Booking',
112
- location: resource?.name ?? '',
112
+ // the room in the booking's language (its main name without one)
113
+ title: [resource && roomName(resource, locale), link?.name].filter(Boolean).join(' · ') || 'Booking',
114
+ location: resource ? roomName(resource, locale) : '',
113
115
  when: whenText(b, tz, settings, link?.mode === 'day'),
114
116
  };
115
117
  }
@@ -3,10 +3,11 @@
3
3
  // everywhere. Drafts, internal ids of private calendars, staging data and
4
4
  // private custom fields (unless a staff caller asks for them) never appear
5
5
  // here. Rooms are named only when set to show on public events (or for staff).
6
- import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
6
+ import { roomColor, roomName, type CalEvent, type Calendar, type MovedDate, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
  import { localizeForm } from '../forms/localize';
10
+ import { currentPart, seriesParts } from './events';
10
11
 
11
12
  export interface EventJson {
12
13
  id: string;
@@ -23,7 +24,10 @@ export interface EventJson {
23
24
  allDay: boolean;
24
25
  timezone: string;
25
26
  rrule: string;
27
+ // the repeat in words for the part of the series in effect now (a segment's, once it took over)
26
28
  repeats: string;
29
+ // the series changed from a date on (CalEvent.segments), in order; [] = never
30
+ segments: SegmentJson[];
27
31
  exdates: string[];
28
32
  cancelledDates: string[];
29
33
  // single dates at another time, by original occurrence key: the new wall
@@ -44,6 +48,18 @@ export interface EventJson {
44
48
  }
45
49
 
46
50
  export interface RoomJson { slug: string; name: string; color: string | null }
51
+ /** One segment of a series: from which date (an occurrence key), its first date, rule, words, rooms and place (null = the event's) */
52
+ export interface SegmentJson {
53
+ from: string;
54
+ start: string;
55
+ end: string;
56
+ rrule: string;
57
+ repeats: string;
58
+ cancelled: boolean;
59
+ note: string;
60
+ location: Pick<CalEvent['location'], 'name' | 'address' | 'url'> | null;
61
+ rooms: RoomJson[];
62
+ }
47
63
  export type PublicMovedDate = Pick<MovedDate, 'start' | 'end' | 'note'> & Partial<MovedDate>;
48
64
 
49
65
  /** moved dates as public data: where and why, not who */
@@ -51,11 +67,11 @@ export function movedDatesToJson(e: Pick<CalEvent, 'movedDates'>, includePrivate
51
67
  return Object.fromEntries(Object.entries(e.movedDates ?? {}).map(([k, m]) => [k, includePrivate ? m : { start: m.start, end: m.end, note: m.note }]));
52
68
  }
53
69
 
54
- /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
55
- export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
70
+ /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff); named in `locale` when they have a name in it. Unknown ids are skipped. */
71
+ export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false, locale?: string | null): RoomJson[] {
56
72
  if (!resources) return [];
57
73
  return ids.map((id) => resources.get(id)).filter((r): r is Resource => Boolean(r) && (includePrivate || r!.showOnEvents))
58
- .map((r) => ({ slug: r.slug, name: r.name, color: roomColor(r) || null }));
74
+ .map((r) => ({ slug: r.slug, name: roomName(r, locale), color: roomColor(r) || null }));
59
75
  }
60
76
 
61
77
  const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
@@ -68,7 +84,7 @@ const untilDate = (locale: string) => (ymd: string) => {
68
84
  };
69
85
 
70
86
  // resources: every room by id (rooms stay [] without it); locale: the
71
- // language of the repeat line ("Cada semana el sábado"; English without it)
87
+ // language of the repeat line and the room names ("Cada semana el sábado"; English without it)
72
88
  export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource>; locale?: string } = {}): EventJson {
73
89
  return {
74
90
  id: e.id,
@@ -85,8 +101,13 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
85
101
  allDay: e.allDay,
86
102
  timezone: e.timezone || siteTz || 'UTC',
87
103
  rrule: e.rrule,
88
- repeats: describeRepeat(e.rrule, e.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale),
89
- exdates: e.exdates,
104
+ // the part of the series in effect now (a change from a date on, once it took over), in the request's language
105
+ repeats: ((p) => describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale))(currentPart(e, { timezone: siteTz })),
106
+ segments: seriesParts(e).slice(1).map((p) => ({
107
+ from: p.from, start: p.start, end: p.end, rrule: p.rrule, repeats: describeRepeat(p.rrule, p.start, opts.locale ? untilDate(opts.locale) : undefined, opts.locale), cancelled: p.cancelled, note: p.note,
108
+ location: p.location ? { name: p.location.name, address: p.location.address, url: p.location.url } : null,
109
+ rooms: roomsToJson(p.roomIds, opts.resources, opts.includePrivate, opts.locale),
110
+ })), exdates: e.exdates,
90
111
  cancelledDates: e.cancelledDates ?? [],
91
112
  movedDates: movedDatesToJson(e, opts.includePrivate),
92
113
  location: e.location,
@@ -100,7 +121,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
100
121
  .map((id) => calendars.get(id))
101
122
  .filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
102
123
  .map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
103
- rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate),
124
+ rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate, opts.locale),
104
125
  fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
105
126
  updatedAt: new Date(e.updatedAt).toISOString(),
106
127
  };
@@ -5,7 +5,7 @@
5
5
  import type { ContentRepository } from '../repo/types';
6
6
  import type { CalEvent, Settings } from '../schema';
7
7
  import { collectViewEvents } from './data';
8
- import { occurrenceKey, safeColor } from './events';
8
+ import { occurrenceKey, partOf, safeColor } from './events';
9
9
  import { tzParts, zonedToUtc } from './ics';
10
10
  import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
11
11
  import { bookingsEnabled } from './config';
@@ -134,7 +134,8 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
134
134
  calendarName: cal?.name ?? '',
135
135
  status: e ? eventBoardStatus(e) : 'feed',
136
136
  recurring: Boolean(e?.rrule),
137
- repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
137
+ // the rule of the part of the series this date is in (CalEvent.segments)
138
+ repeat: e?.rrule ? ((p) => describeRepeat(p.rrule, p.start))(partOf(e, { segment: v.segment })) : '',
138
139
  rooms: e ? (v.rooms?.map((r) => r.id) ?? e.roomHold.resourceIds).map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
139
140
  roomsHeld: e ? holdsRooms(e) : false,
140
141
  occurrence,
@@ -28,7 +28,7 @@ const esc = (s: string) =>
28
28
  export const safeHref = (h: string) => (/^(https?:\/\/|\/(?!\/)|mailto:|tel:|#)/i.test(h.trim()) ? h.trim() : '');
29
29
 
30
30
  export function eventCardHtml(
31
- o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved'] },
31
+ o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved']; location?: Occurrence['location'] },
32
32
  settings: Settings,
33
33
  opts: { showImage: boolean; showCta?: boolean; href?: string; heading?: 'h2' | 'h3' }
34
34
  ): string {
@@ -48,7 +48,7 @@ export function eventCardHtml(
48
48
  ? `<a class="pc-ev-card-media" href="${esc(href)}" tabindex="-1" aria-hidden="true"><img src="${esc(image.src)}" alt="${esc(image.alt)}" loading="lazy" /></a>`
49
49
  : `<a class="pc-ev-card-media is-tile" href="${esc(href)}" tabindex="-1" aria-hidden="true"><span class="pc-ev-tile"><b>${esc(f.month)}</b><span>${esc(f.day)}</span></span></a>`
50
50
  : '';
51
- const loc = locationLine(o.moved?.location ?? e.location);
51
+ const loc = locationLine(o.location ?? e.location);
52
52
  const ctaHref = safeHref(e.cta.href);
53
53
  const cta =
54
54
  opts.showCta && e.cta.label && ctaHref && !cancelled
@@ -80,11 +80,11 @@ export async function upcomingEvents(
80
80
  displayTz: tz, now, windowStartMs: now, windowEndMs: now + (opts.horizonDays ?? 400) * 86_400_000,
81
81
  }, { audience: 'public', calendarIds: opts.calendarIds, includeFeeds: false, filter: opts.filter, locale: opts.locale });
82
82
  const seen = new Set<string>();
83
- const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'> & Pick<ViewEvent, 'rooms'>)[] = [];
83
+ const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved' | 'location'> & Pick<ViewEvent, 'rooms'>)[] = [];
84
84
  for (const v of events) {
85
85
  if (!v.event || v.end <= now || seen.has(v.event.id) || !tagged(v.event.tags)) continue;
86
86
  seen.add(v.event.id);
87
- out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}), rooms: v.rooms ?? [] });
87
+ out.push({ event: v.event, start: v.start, end: v.end, allDay: v.allDay, days: v.days, cancelled: Boolean(v.cancelled), ...(v.moved ? { moved: v.moved } : {}), ...(v.place ? { location: v.place } : {}), rooms: v.rooms ?? [] });
88
88
  if (out.length >= opts.limit) break;
89
89
  }
90
90
  return out;
@@ -15,7 +15,8 @@
15
15
  //
16
16
  // Each migration's column lists are frozen: a later events column joins by a
17
17
  // new migration that recreates these triggers through eventChangeTriggers()
18
- // (the test that sorts every column fails until then). v66 added moved_dates.
18
+ // (the test that sorts every column fails until then). v66 added moved_dates,
19
+ // v70 segments.
19
20
  const now = "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
20
21
 
21
22
  // v65's lists
@@ -27,9 +28,9 @@ const V65_COLUMNS = ['slug', 'title', 'body_json', 'body_html', 'excerpt', 'cove
27
28
  'location', 'cta', 'calendar_ids', 'fields', 'rsvp', 'translations', 'room_hold', 'start_at', 'end_at'];
28
29
 
29
30
  /** the columns copied into each snapshot (what the webhook payload and push rules read) */
30
- export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates'];
31
+ export const EVENT_CHANGE_SNAPSHOT = [...V65_SNAPSHOT, 'moved_dates', 'segments'];
31
32
  /** the columns whose change counts (published_at and scheduled_at follow status; room_hold names the rooms public data shows) */
32
- export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates'];
33
+ export const EVENT_CHANGE_COLUMNS = [...V65_COLUMNS, 'moved_dates', 'segments'];
33
34
  /** columns no change is recorded for */
34
35
  export const EVENT_CHANGE_IGNORED = ['id', 'draft', 'host_ids', 'request', 'source_uid', 'published_at', 'scheduled_at', 'created_at', 'updated_at'];
35
36
 
@@ -88,3 +89,16 @@ export const MOVED_DATES_MIGRATION = (contentVersionUpdate: string) => ({ versio
88
89
  `DROP TRIGGER event_changes_delete`,
89
90
  ...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates'], [...V65_COLUMNS, 'moved_dates']),
90
91
  ] });
92
+
93
+ // v70: a series changed from one date on (CalEvent.segments: its rule, times,
94
+ // rooms and place from then), a column on the row like moved_dates: public,
95
+ // so it bumps the content version and its changes are recorded
96
+ export const SEGMENTS_MIGRATION = (contentVersionUpdate: string) => ({ version: 70, statements: [
97
+ `ALTER TABLE events ADD COLUMN segments TEXT NOT NULL DEFAULT '[]'`,
98
+ `DROP TRIGGER content_version_events_update`,
99
+ contentVersionUpdate,
100
+ `DROP TRIGGER event_changes_insert`,
101
+ `DROP TRIGGER event_changes_update`,
102
+ `DROP TRIGGER event_changes_delete`,
103
+ ...eventChangeTriggers([...V65_SNAPSHOT, 'moved_dates', 'segments'], [...V65_COLUMNS, 'moved_dates', 'segments']),
104
+ ] });
@@ -7,7 +7,7 @@
7
7
  // details, lib/display: published events only, private calendars when it
8
8
  // names them, bookings with the booker's name, never emails, phones or notes).
9
9
  import type { ContentRepository } from '../repo/types';
10
- import { roomColor, type BookingLink, type CalEvent, type Calendar, type Resource, type Settings } from '../schema';
10
+ import { roomColor, roomName, type BookingLink, type CalEvent, type Calendar, type EventLocation, type Resource, type Settings } from '../schema';
11
11
  import { eventIsListed, eventIsPublic, eventRoomIds, expandEvents, locationLine, safeColor, type OccurrenceMove } from './events';
12
12
  import { expandVEvents, loadFeed, tzParts, type CalendarEvent, type CalendarWindow } from './ics';
13
13
  import { CALENDAR, eventUrl, resourceUrl, withoutPrivateFields } from './config';
@@ -31,6 +31,10 @@ export interface ViewEvent extends CalendarEvent {
31
31
  key?: string;
32
32
  // this date was moved here: where it was (UTC ms) and the public note
33
33
  moved?: OccurrenceMove;
34
+ // native events changed from a date on: the segment this date belongs to (its `from` key)
35
+ segment?: string;
36
+ // native events: the date's own place (moved there, or its segment's), when not the event's
37
+ place?: EventLocation;
34
38
  }
35
39
 
36
40
  // color: '#rrggbb', or '' when the room has none
@@ -103,7 +107,7 @@ export async function collectViewEvents(
103
107
  const rooms = (ids: string[]) => ids.map((id) => resources.get(id))
104
108
  // staff views and screens with private details name every room
105
109
  .filter((r): r is Resource => Boolean(r) && (scope.audience !== 'public' || r!.showOnEvents))
106
- .map((r) => ({ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }));
110
+ .map((r) => ({ id: r.id, slug: r.slug, name: roomName(r, scope.locale), color: roomColor(r) }));
107
111
  const words = eventStrings(scope.locale).calendar;
108
112
  for (const o of expandEvents(natives, settings, win)) {
109
113
  // rooms are per date (a moved date can change them)
@@ -115,7 +119,8 @@ export async function collectViewEvents(
115
119
  events.push({
116
120
  start: o.start, end: o.end, allDay: o.allDay, days: o.days,
117
121
  title: e.title || words.untitled,
118
- location: locationLine(o.moved?.location ?? e.location),
122
+ // the date's own place (moved there, or its segment's), else the event's
123
+ location: locationLine(o.location ?? e.location),
119
124
  description: e.excerpt,
120
125
  cal: cal ? idx.get(cal.id) ?? 0 : 0,
121
126
  ref: e.id,
@@ -129,6 +134,8 @@ export async function collectViewEvents(
129
134
  rooms: rooms(o.roomIds),
130
135
  key: o.key,
131
136
  ...(o.moved ? { moved: o.moved } : {}),
137
+ ...(o.segment ? { segment: o.segment } : {}),
138
+ ...(o.location ? { place: o.location } : {}),
132
139
  });
133
140
  }
134
141
 
@@ -169,14 +176,16 @@ export async function collectViewEvents(
169
176
  const r = byId.get(b.resourceId);
170
177
  if (!r) continue;
171
178
  const allDay = b.end - b.start >= 86_400_000 - 3_600_000;
179
+ // the room in the view's language (its main name without one)
180
+ const room = roomName(r, scope.locale);
172
181
  const title = scope.audience === 'admin' ? `${r.name} · ${b.name}`
173
- : scope.audience === 'screen' ? [linkName.get(b.linkId) || r.name, b.name].filter(Boolean).join(' · ')
174
- : `${words.reserved} · ${r.name}`;
182
+ : scope.audience === 'screen' ? [linkName.get(b.linkId) || room, b.name].filter(Boolean).join(' · ')
183
+ : `${words.reserved} · ${room}`;
175
184
  events.push({
176
185
  start: b.start, end: b.end, allDay, days: allDay ? Math.max(1, Math.round((b.end - b.start) / 86_400_000)) : 1,
177
- title, location: r.name, description: '', cal: inScope.length, color: '#8e919a', calendarName: r.name,
186
+ title, location: room, description: '', cal: inScope.length, color: '#8e919a', calendarName: room,
178
187
  url: scope.audience === 'public' ? resourceUrl(r.slug) : undefined,
179
- rooms: [{ id: r.id, slug: r.slug, name: r.name, color: roomColor(r) }],
188
+ rooms: [{ id: r.id, slug: r.slug, name: room, color: roomColor(r) }],
180
189
  });
181
190
  }
182
191
  }
@@ -22,6 +22,9 @@ export interface EventStrings {
22
22
  moved: string;
23
23
  dateMoved: string;
24
24
  dateMovedNote: string;
25
+ /** the series changed from a date on (describeSeries): "From {date}: {repeat}", and every date cancelled from one on */
26
+ fromDate: string;
27
+ cancelledFrom: string;
25
28
  /** the side column's landmark label */
26
29
  details: string;
27
30
  joinOnline: string;
@@ -135,6 +138,7 @@ const STRINGS: Record<string, EventStrings> = {
135
138
  cancelled: 'Cancelled', eventCancelled: 'This event is cancelled.', eventCancelledNote: 'It stays listed so nobody is left wondering.',
136
139
  dateCancelled: 'Cancelled on {date}.', dateCancelledNote: 'Other dates go ahead as usual.',
137
140
  moved: 'Moved', dateMoved: 'Moved from {from}.', dateMovedNote: 'Other dates stay as usual.',
141
+ fromDate: 'From {date}: {repeat}', cancelledFrom: 'Cancelled from {date}',
138
142
  details: 'Event details', joinOnline: 'Join online', map: 'Map', directionsTo: 'Directions to {place}', mapOf: 'Map of {place}',
139
143
  directionsHere: 'Directions to the location', mapHere: 'Map of the location', showing: 'Showing {date}',
140
144
  addToCalendar: 'Add to calendar', downloadIcs: 'Download .ics', share: 'Share', linkCopied: 'Link copied',
@@ -168,6 +172,7 @@ const STRINGS: Record<string, EventStrings> = {
168
172
  cancelled: 'Cancelado', eventCancelled: 'Este evento está cancelado.', eventCancelledNote: 'Lo dejamos en la lista para que nadie se quede con la duda.',
169
173
  dateCancelled: 'Cancelado el {date}.', dateCancelledNote: 'Las demás fechas siguen como siempre.',
170
174
  moved: 'Cambio de horario', dateMoved: 'Cambió de horario: antes era el {from}.', dateMovedNote: 'Las demás fechas siguen como siempre.',
175
+ fromDate: 'Desde el {date}: {repeat}', cancelledFrom: 'Cancelado desde el {date}',
171
176
  details: 'Detalles del evento', joinOnline: 'Unirse en línea', map: 'Mapa', directionsTo: 'Cómo llegar a {place}', mapOf: 'Mapa de {place}',
172
177
  directionsHere: 'Cómo llegar al lugar', mapHere: 'Mapa del lugar', showing: 'Mostrando el {date}',
173
178
  addToCalendar: 'Agregar al calendario', downloadIcs: 'Descargar .ics', share: 'Compartir', linkCopied: 'Enlace copiado',
@@ -201,6 +206,7 @@ const STRINGS: Record<string, EventStrings> = {
201
206
  cancelled: 'Annulé', eventCancelled: 'Cet événement est annulé.', eventCancelledNote: 'Il reste affiché pour que personne ne se pose de questions.',
202
207
  dateCancelled: 'Annulé le {date}.', dateCancelledNote: 'Les autres dates sont maintenues.',
203
208
  moved: 'Déplacé', dateMoved: 'Déplacé : prévu initialement le {from}.', dateMovedNote: 'Les autres dates sont maintenues.',
209
+ fromDate: 'À partir du {date} : {repeat}', cancelledFrom: 'Annulé à partir du {date}',
204
210
  details: 'Détails de l’événement', joinOnline: 'Rejoindre en ligne', map: 'Carte', directionsTo: 'Itinéraire vers {place}', mapOf: 'Carte de {place}',
205
211
  directionsHere: 'Itinéraire vers le lieu', mapHere: 'Carte du lieu', showing: 'Date affichée : {date}',
206
212
  addToCalendar: 'Ajouter au calendrier', downloadIcs: 'Télécharger le .ics', share: 'Partager', linkCopied: 'Lien copié',
@@ -234,6 +240,7 @@ const STRINGS: Record<string, EventStrings> = {
234
240
  cancelled: 'Abgesagt', eventCancelled: 'Diese Veranstaltung ist abgesagt.', eventCancelledNote: 'Sie bleibt sichtbar, damit sich niemand wundert.',
235
241
  dateCancelled: 'Abgesagt am {date}.', dateCancelledNote: 'Die anderen Termine finden wie gewohnt statt.',
236
242
  moved: 'Verschoben', dateMoved: 'Verschoben: ursprünglich am {from}.', dateMovedNote: 'Die anderen Termine finden wie gewohnt statt.',
243
+ fromDate: 'Ab {date}: {repeat}', cancelledFrom: 'Abgesagt ab {date}',
237
244
  details: 'Veranstaltungsdetails', joinOnline: 'Online teilnehmen', map: 'Karte', directionsTo: 'Route zu {place}', mapOf: 'Karte von {place}',
238
245
  directionsHere: 'Route zum Ort', mapHere: 'Karte des Ortes', showing: 'Angezeigt: {date}',
239
246
  addToCalendar: 'Zum Kalender hinzufügen', downloadIcs: '.ics herunterladen', share: 'Teilen', linkCopied: 'Link kopiert',
@@ -267,6 +274,7 @@ const STRINGS: Record<string, EventStrings> = {
267
274
  cancelled: 'Cancelado', eventCancelled: 'Este evento foi cancelado.', eventCancelledNote: 'Ele continua na lista para que ninguém fique na dúvida.',
268
275
  dateCancelled: 'Cancelado em {date}.', dateCancelledNote: 'As outras datas continuam normalmente.',
269
276
  moved: 'Remarcado', dateMoved: 'Remarcado: antes era em {from}.', dateMovedNote: 'As outras datas continuam normalmente.',
277
+ fromDate: 'A partir de {date}: {repeat}', cancelledFrom: 'Cancelado a partir de {date}',
270
278
  details: 'Detalhes do evento', joinOnline: 'Participar on-line', map: 'Mapa', directionsTo: 'Como chegar a {place}', mapOf: 'Mapa de {place}',
271
279
  directionsHere: 'Como chegar ao local', mapHere: 'Mapa do local', showing: 'Exibindo {date}',
272
280
  addToCalendar: 'Adicionar ao calendário', downloadIcs: 'Baixar .ics', share: 'Compartilhar', linkCopied: 'Link copiado',