@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.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +24 -5
- package/docs/calendar.md +130 -2
- package/docs/server.md +33 -1
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +75 -15
- package/src/lib/booking/service.ts +4 -2
- package/src/lib/calendar/api.ts +29 -8
- package/src/lib/calendar/board.ts +3 -2
- package/src/lib/calendar/cards.ts +4 -4
- package/src/lib/calendar/change-migration.ts +17 -3
- package/src/lib/calendar/data.ts +16 -7
- package/src/lib/calendar/event-words.ts +8 -0
- package/src/lib/calendar/events.ts +128 -15
- package/src/lib/calendar/google/map.ts +1 -1
- package/src/lib/calendar/host-requests.ts +65 -10
- package/src/lib/calendar/ics-write.ts +65 -15
- package/src/lib/calendar/member-words.ts +14 -1
- package/src/lib/calendar/moves.ts +15 -9
- package/src/lib/calendar/request-admin.ts +2 -1
- package/src/lib/calendar/request-mail.ts +2 -1
- package/src/lib/calendar/request-page.ts +2 -1
- package/src/lib/calendar/request-service.ts +7 -7
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/display/board.ts +6 -5
- package/src/lib/editor/schedule.ts +4 -2
- package/src/lib/mobile/calendar.ts +6 -5
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/mobile/search.ts +2 -1
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +13 -3
- package/src/lib/repo/sql.ts +34 -13
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +34 -0
- package/src/lib/sections.ts +3 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +9 -8
- package/src/pages/admin/calendar/requests.astro +5 -5
- package/src/pages/admin/calendar/resources/[id].astro +11 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +87 -7
- package/src/server/index.ts +46 -3
- 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
|
|
7
|
-
// like on the Schedule, without touching unpublished edits: a
|
|
8
|
-
// its sign-ups (they're emailed the new time) and its hosts
|
|
9
|
-
// (lib/calendar/moves.ts).
|
|
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
|
|
13
|
-
interface
|
|
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
|
|
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=
|
|
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
|
-
|
|
112
|
-
|
|
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
|
}
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
+
] });
|
package/src/lib/calendar/data.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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) ||
|
|
174
|
-
: `${words.reserved} · ${
|
|
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:
|
|
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:
|
|
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',
|