@acms-subir/core 0.3.55 → 0.3.57

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 (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. package/src/virtual.d.ts +1 -0
@@ -17,6 +17,8 @@ import '../../styles/schedule.css';
17
17
 
18
18
  type View = 'month' | 'week';
19
19
  type MoveUndo = { kind: 'series'; id: string; days: number; minutes?: number }
20
+ // this and following dates: the event's segments as they were (lib/calendar/segments.ts)
21
+ | { kind: 'following'; id: string; from: string; previous: { from: string; start: string; end: string; rrule?: string }[] }
20
22
  | { kind: 'date'; id: string; key: string; previous: { start: string; end: string; roomIds?: string[]; note: string } | null }
21
23
  | { kind: 'split'; id: string; seriesId: string; occurrence: string };
22
24
 
@@ -195,12 +197,12 @@ export default function CalendarBoard(props: Props) {
195
197
  // ---- moving ----------------------------------------------------------------
196
198
  const dayLabel = (d: Iso, o: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }) =>
197
199
  new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(utc(d));
198
- async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one', to: Iso) {
200
+ async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one' | 'following', to: Iso) {
199
201
  setAsk(null);
200
202
  const r = await actions.events.move({ id: item.id, days, minutes, mode, occurrence: item.occurrence });
201
203
  if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move the event.', error: true }); return; }
202
204
  const when = !item.allDay && (minutes || view === 'week') ? `${dayLabel(to)}, ${shortTime(Math.max(0, Math.min(1439, item.startMin + minutes)))}` : dayLabel(to);
203
- setToast({ text: `Moved “${item.title}” to ${when}`, undo: r.data.undo as MoveUndo, warning: r.data.warning || undefined });
205
+ setToast({ text: mode === 'following' ? `Moved “${item.title}” to ${when} and every date after it` : `Moved “${item.title}” to ${when}`, undo: r.data.undo as MoveUndo, warning: r.data.warning || undefined });
204
206
  await refresh();
205
207
  }
206
208
  async function moveBack(it: BoardItem) {
@@ -612,9 +614,10 @@ export default function CalendarBoard(props: Props) {
612
614
  <div className="sch-peek-layer center" onMouseDown={(e) => { if (e.target === e.currentTarget) setAsk(null); }}>
613
615
  <div className="sch-ask" role="alertdialog" aria-label={`Move ${ask.item.title}`}>
614
616
  <b>Move “{ask.item.title}”?</b>
615
- <p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, or every date in the series?</p>
617
+ <p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, that date and the ones after it (the dates before stay), or every date in the series?</p>
616
618
  <div className="sch-ask-actions">
617
619
  <button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, ask.minutes, 'one', ask.to)}>Only this date</button>
620
+ <button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'following', ask.to)}>This and following dates</button>
618
621
  <button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'series', ask.to)}>Every date</button>
619
622
  <button type="button" className="btn ghost" onClick={() => setAsk(null)}>Cancel</button>
620
623
  </div>
@@ -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);
@@ -28,12 +44,22 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
28
44
  };
29
45
  useEffect(() => { if (eventId && repeats) void load(); }, [eventId, repeats, limit]);
30
46
 
31
- if (!eventId || !repeats || !data || !data.dates.length) return null;
32
-
33
47
  const open = (d: DateRow) => {
34
48
  setNote(null);
35
49
  setForm({ key: d.key, day: datePart(d.start), endDay: datePart(d.end), from: timePart(d.start), to: timePart(d.end), roomIds: d.roomIds, note: d.moved?.note ?? '' });
36
50
  };
51
+ // ?move=<key> (approving a request to change one date, Calendar → Requests)
52
+ // opens that date's Move…, looking further ahead when it isn't in the first few
53
+ const [wanted, setWanted] = useState(() => (typeof location === 'undefined' ? '' : new URLSearchParams(location.search).get('move') ?? ''));
54
+ useEffect(() => {
55
+ if (!wanted || !data) return;
56
+ const d = data.dates.find((x) => x.key === wanted);
57
+ if (d) { open(d); setWanted(''); requestAnimationFrame(() => document.getElementById('ev-dates')?.scrollIntoView({ block: 'start' })); }
58
+ else if (limit < 52) setLimit(52);
59
+ else setWanted('');
60
+ }, [data, wanted]);
61
+
62
+ if (!eventId || !repeats || !data || !data.dates.length) return null;
37
63
  const run = async (key: string, work: () => Promise<{ error?: { message: string }; data?: object }>, done: string) => {
38
64
  setBusy(key);
39
65
  const r = await work();
@@ -44,15 +70,50 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
44
70
  setNote({ text: warning ? `${done} ${warning}` : done });
45
71
  await load();
46
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
+ };
47
81
  const save = (f: Form) => {
48
82
  const start = data.allDay ? f.day : `${f.day}T${f.from}`;
49
83
  // a time that ends before it starts runs past midnight
50
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
+ }
51
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.');
52
95
  };
53
96
 
54
97
  return (
55
- <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
+ )}
56
117
  <ul className="pe-evd">
57
118
  {data.dates.map((d) => (
58
119
  <li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
@@ -62,11 +123,12 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
62
123
  {d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
63
124
  </div>
64
125
  <div className="pe-evd-acts">
65
- {d.cancelled ? (
126
+ {d.partCancelled ? null : d.cancelled ? (
66
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>
67
128
  ) : (
68
129
  <>
69
- <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>
70
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>}
71
133
  <button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
72
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>
@@ -76,7 +138,7 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
76
138
  {form?.key === d.key && (
77
139
  <form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
78
140
  <div className="pe-evd-fields">
79
- <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>
80
142
  {data.allDay ? (
81
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>
82
144
  ) : (
@@ -86,9 +148,17 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
86
148
  </>
87
149
  )}
88
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>}
89
159
  {data.rooms.length > 0 && (
90
160
  <fieldset className="pe-evd-rooms">
91
- <legend>Rooms for this date</legend>
161
+ <legend>{form.following ? 'Rooms from then on' : 'Rooms for this date'}</legend>
92
162
  {data.rooms.map((r) => (
93
163
  <label key={r.id} className="pe-evd-room">
94
164
  <input type="checkbox" checked={form.roomIds.includes(r.id)}
@@ -98,11 +168,11 @@ export function SingleDates({ eventId, repeats }: { eventId: string; repeats: bo
98
168
  ))}
99
169
  </fieldset>
100
170
  )}
101
- <label className="pe-field"><span>Note <em>shown with the date, optional</em></span>
102
- <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 })} />
103
173
  </label>
104
174
  <div className="pe-evd-formacts">
105
- <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>
106
176
  <button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
107
177
  </div>
108
178
  </form>
@@ -5,6 +5,7 @@ import { BRAND } from '../lib/brand';
5
5
  import { CONTENT } from '../lib/content';
6
6
  import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
7
7
  import { ANNOUNCEMENTS } from '../lib/announcements/config';
8
+ import { REQUESTS } from '../lib/calendar/request-config';
8
9
  import { OWNER_ENV } from '../lib/auth';
9
10
  import { validateRequestSession } from '../lib/auth/request';
10
11
  import { accessFor } from '../lib/permissions/access';
@@ -36,6 +37,8 @@ const [pages, posts, links, home, settings, sessionUser] = await Promise.all([
36
37
  ]);
37
38
  // pending booking requests, shown as a count on the Bookings nav item
38
39
  const pendingBookings = CALENDAR.enabled ? (await repo.listBookings({ from: Date.now(), statuses: ['pending'], ...(!bookingsEnabled() ? { kinds: ['rsvp'] as const as ['rsvp'] } : {}) })).length : 0;
40
+ // event requests waiting for staff (acms({ requests })): a count on Requests
41
+ const pendingAsks = REQUESTS.enabled ? await repo.requests.count({ states: ['pending'] }) : 0;
39
42
  const me = {
40
43
  name: sessionUser?.name || 'Admin',
41
44
  role: roleLabel(sessionUserId === OWNER_ENV ? 'owner' : sessionUser?.role ?? 'editor'),
@@ -74,6 +77,7 @@ const icons: Record<string, string> = {
74
77
  user: 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0',
75
78
  shield: 'M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4',
76
79
  calendar: 'M3 5.5h18v15H3zM3 10h18M8 3v4M16 3v4',
80
+ inbox: 'M3 13h5l1.5 3h5L16 13h5M5.5 5h13L21 13v6H3v-6z',
77
81
  calendars: 'M4 6h.01M4 12h.01M4 18h.01M8 6h12M8 12h12M8 18h12',
78
82
  bookings: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2',
79
83
  resources: 'M3 21V9l9-5 9 5v12M9 21v-6h6v6',
@@ -130,6 +134,9 @@ const groups: { id: string; label: string; items: Item[] }[] = [
130
134
  : []),
131
135
  // TV screens: saved display links (lib/display), with private details behind their key
132
136
  ...(can('schedule.manage') ? [{ key: 'calendar:screens', label: 'Screens', href: '/admin/calendar/screens', icon: icons.monitor, count: null }] : []),
137
+ ...(REQUESTS.enabled && can('content.edit')
138
+ ? [{ key: 'calendar:requests', label: 'Requests', href: '/admin/calendar/requests', icon: icons.inbox, count: pendingAsks || null, attention: pendingAsks > 0 }]
139
+ : []),
133
140
  ],
134
141
  }]
135
142
  : []),
@@ -7,6 +7,7 @@ import { roomColor, type CalEvent, type Calendar, type MovedDate, type Resource
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 */
@@ -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),
110
+ })), exdates: e.exdates,
90
111
  cancelledDates: e.cancelledDates ?? [],
91
112
  movedDates: movedDatesToJson(e, opts.includePrivate),
92
113
  location: e.location,
@@ -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, 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
@@ -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
 
@@ -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',