@acms-subir/core 0.3.47 → 0.3.49
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 +56 -0
- package/cli/migrations.generated.mjs +42 -23
- package/docs/calendar.md +80 -0
- package/docs/server.md +28 -3
- package/docs/theme-authoring.md +1 -1
- package/integration.mjs +37 -0
- package/package.json +1 -1
- package/src/actions/index.ts +76 -11
- package/src/components/EventPage.astro +18 -12
- package/src/components/calendar/CalendarBoard.tsx +43 -19
- package/src/components/editor/event/EventSheet.tsx +2 -0
- package/src/components/editor/event/SingleDates.tsx +123 -0
- package/src/lib/booking/holds.ts +7 -7
- package/src/lib/calendar/api.ts +11 -1
- package/src/lib/calendar/board.ts +7 -3
- package/src/lib/calendar/cards.ts +5 -5
- package/src/lib/calendar/change-migration.ts +40 -15
- package/src/lib/calendar/data.ts +9 -3
- package/src/lib/calendar/event-words.ts +9 -0
- package/src/lib/calendar/events.ts +94 -9
- package/src/lib/calendar/ics-write.ts +25 -2
- package/src/lib/calendar/moves.ts +291 -0
- package/src/lib/calendar/render.ts +10 -6
- package/src/lib/calendar/schedule.ts +73 -61
- package/src/lib/email/booking-words.ts +7 -1
- package/src/lib/email/service.ts +6 -1
- package/src/lib/email/templates.ts +6 -2
- package/src/lib/mobile/calendar.ts +5 -2
- package/src/lib/mobile/schemas.ts +7 -3
- package/src/lib/origin-check.ts +54 -0
- package/src/lib/push/service.ts +44 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +28 -10
- package/src/lib/repo/types.ts +6 -3
- package/src/lib/schema.ts +21 -0
- package/src/lib/sections.ts +5 -2
- package/src/lib/sync/catalog.ts +1 -0
- package/src/middleware.ts +5 -0
- package/src/pages/api/calendar/events/index.ts +6 -0
- package/src/pages/members/_Layout.astro +1 -0
- package/src/pages/members/events/index.astro +1 -1
- package/src/server/index.ts +33 -0
- package/src/styles/editor.css +19 -0
- package/src/styles/schedule.css +2 -0
- package/src/styles/site.css +6 -0
- package/src/theme-sdk/calendar.ts +3 -2
- package/src/virtual.d.ts +2 -0
|
@@ -35,6 +35,8 @@ interface Props {
|
|
|
35
35
|
const { event: e, occurrence: o, settings, calendars, html, upcoming, origin, preview = false, rsvp = null } = Astro.props;
|
|
36
36
|
// the whole event, or just this date of a repeating one
|
|
37
37
|
const cancelled = e.cancelled || Boolean(o.cancelled);
|
|
38
|
+
// a moved date may be at its own place
|
|
39
|
+
const where = o.moved?.location ?? e.location;
|
|
38
40
|
const image = eventImage(e, settings);
|
|
39
41
|
const tz = eventTimeZone(e, settings);
|
|
40
42
|
const locale = siteLocale(settings);
|
|
@@ -52,13 +54,13 @@ const repeats = describeRepeat(e.rrule, e.start, (ymd) => {
|
|
|
52
54
|
return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' });
|
|
53
55
|
}, lang);
|
|
54
56
|
const ctaHref = safeHref(e.cta.href);
|
|
55
|
-
const mapHref =
|
|
56
|
-
? safeHref(
|
|
57
|
-
:
|
|
58
|
-
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([
|
|
57
|
+
const mapHref = where.url
|
|
58
|
+
? safeHref(where.url)
|
|
59
|
+
: where.address
|
|
60
|
+
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent([where.name, where.address].filter(Boolean).join(', '))}`
|
|
59
61
|
: '';
|
|
60
62
|
// static map (made when the event was saved; see lib/maps/service.ts)
|
|
61
|
-
const loc =
|
|
63
|
+
const loc = where;
|
|
62
64
|
const showMap = loc.showMap !== false && Boolean(loc.mapImage) && typeof loc.lat === 'number' && typeof loc.lng === 'number';
|
|
63
65
|
const directions = showMap ? safeHref(directionsUrl(loc)) : '';
|
|
64
66
|
const mapCredit = showMap ? mapAttribution(loc.mapImage) : '';
|
|
@@ -76,7 +78,7 @@ const I = {
|
|
|
76
78
|
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 11v5M12 8h.01',
|
|
77
79
|
video: 'M3 7h12v10H3zM15 10l6-3v10l-6-3',
|
|
78
80
|
};
|
|
79
|
-
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(
|
|
81
|
+
const online = /zoom\.us|meet\.google|teams\.microsoft|webex|whereby|jit\.si/i.test(where.url);
|
|
80
82
|
// the RSVP block speaks the page's language too
|
|
81
83
|
const w = rsvpStrings(lang);
|
|
82
84
|
const going = rsvp ? aroundCount(rsvp.going, w.personGoing, w.peopleGoing) : ['', ''];
|
|
@@ -91,6 +93,9 @@ const deadline = typeof rsvp?.closesAt === 'number' ? deadlineParts(rsvp.closesA
|
|
|
91
93
|
const closesLine = deadline && taking ? fill(w.closesOn, deadline) : '';
|
|
92
94
|
const closedLine = deadline && !rsvp!.open && !cancelled && rsvp!.closesAt! <= Date.now() ? fill(w.closedOn, deadline) : '';
|
|
93
95
|
const dateCancelled = fill(ew.dateCancelled, { date: f.date });
|
|
96
|
+
// "Moved from Thu, Jan 10 · 6:00 PM." (all-day: the date)
|
|
97
|
+
const movedFrom = o.moved ? formatOccurrence({ start: o.moved.from.start, end: o.moved.from.end, allDay: o.allDay, days: o.days }, tz, locale) : null;
|
|
98
|
+
const dateMoved = movedFrom ? fill(ew.dateMoved, { from: o.allDay ? movedFrom.date : movedFrom.range }) : '';
|
|
94
99
|
const showing = fill(ew.showing, { date: f.date });
|
|
95
100
|
const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: true, href: here(eventUrl(u.event.slug)) })).join('');
|
|
96
101
|
---
|
|
@@ -113,6 +118,7 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
113
118
|
<h1 class="pc-ev-title">{e.title}</h1>
|
|
114
119
|
{e.cancelled && <p class="pc-ev-cancelled"><b>{ew.eventCancelled}</b> {ew.eventCancelledNote}</p>}
|
|
115
120
|
{!e.cancelled && cancelled && <p class="pc-ev-cancelled"><b>{dateCancelled}</b> {ew.dateCancelledNote}</p>}
|
|
121
|
+
{!cancelled && dateMoved && <p class="pc-ev-moved"><b>{dateMoved}</b> {o.moved!.note || ew.dateMovedNote}</p>}
|
|
116
122
|
{e.excerpt && <p class="pc-ev-lede">{e.excerpt}</p>}
|
|
117
123
|
<div class="pc-ev-body post-content" set:html={html} />
|
|
118
124
|
</div>
|
|
@@ -125,15 +131,15 @@ const moreCards = upcoming.map((u) => eventCardHtml(u, settings, { showImage: tr
|
|
|
125
131
|
<span>{f.time}</span>
|
|
126
132
|
</div>
|
|
127
133
|
</div>
|
|
128
|
-
{(
|
|
134
|
+
{(where.name || where.address || where.url) && (
|
|
129
135
|
<div class="pc-ev-fact">
|
|
130
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !
|
|
136
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={online && !where.address ? I.video : I.pin} /></svg>
|
|
131
137
|
<div>
|
|
132
|
-
{
|
|
133
|
-
{(
|
|
138
|
+
{where.name && <b>{where.name}</b>}
|
|
139
|
+
{(where.address || mapHref) && (
|
|
134
140
|
<span>
|
|
135
|
-
{
|
|
136
|
-
{mapHref && <>{
|
|
141
|
+
{where.address}
|
|
142
|
+
{mapHref && <>{where.address && ' · '}<a href={mapHref} target="_blank" rel="noopener">{online ? ew.joinOnline : ew.map}</a></>}
|
|
137
143
|
</span>
|
|
138
144
|
)}
|
|
139
145
|
</div>
|
|
@@ -10,11 +10,15 @@ import '../../styles/schedule.css';
|
|
|
10
10
|
|
|
11
11
|
// The admin Schedule: a month (or week) grid of everything on the calendar —
|
|
12
12
|
// events, drafts, subscribed calendars, bookings. Click a day to add, drag
|
|
13
|
-
// an event to move it
|
|
14
|
-
//
|
|
13
|
+
// an event to move it (in the week view, to another time too; one date of a
|
|
14
|
+
// series becomes a moved date that keeps its sign-ups), click a day number
|
|
15
|
+
// for that day's list. Data comes from actions.calendar.range; this file only
|
|
16
|
+
// does UI and layout.
|
|
15
17
|
|
|
16
18
|
type View = 'month' | 'week';
|
|
17
|
-
type MoveUndo = { kind: 'series'; id: string; days: number
|
|
19
|
+
type MoveUndo = { kind: 'series'; id: string; days: number; minutes?: number }
|
|
20
|
+
| { kind: 'date'; id: string; key: string; previous: { start: string; end: string; roomIds?: string[]; note: string } | null }
|
|
21
|
+
| { kind: 'split'; id: string; seriesId: string; occurrence: string };
|
|
18
22
|
|
|
19
23
|
interface Props {
|
|
20
24
|
initial: BoardData;
|
|
@@ -61,8 +65,10 @@ export default function CalendarBoard(props: Props) {
|
|
|
61
65
|
const [peek, setPeek] = useState<{ item: BoardItem; rect: DOMRect } | null>(null);
|
|
62
66
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
|
63
67
|
const [dropDay, setDropDay] = useState<Iso | null>(null);
|
|
64
|
-
const [ask, setAsk] = useState<{ item: BoardItem; days: number; to: Iso } | null>(null);
|
|
65
|
-
const [toast, setToast] = useState<{ text: string; undo?: MoveUndo; error?: boolean } | null>(null);
|
|
68
|
+
const [ask, setAsk] = useState<{ item: BoardItem; days: number; minutes: number; to: Iso } | null>(null);
|
|
69
|
+
const [toast, setToast] = useState<{ text: string; undo?: MoveUndo; error?: boolean; warning?: string } | null>(null);
|
|
70
|
+
// week view: where on the event the pointer grabbed it (minutes below its start)
|
|
71
|
+
const grab = useRef(0);
|
|
66
72
|
const [menu, setMenu] = useState(false);
|
|
67
73
|
const [compact, setCompact] = useState(false);
|
|
68
74
|
const [selected, setSelected] = useState<Iso>(props.anchor);
|
|
@@ -182,18 +188,26 @@ export default function CalendarBoard(props: Props) {
|
|
|
182
188
|
setDraft({
|
|
183
189
|
key: '__draft', kind: 'event', id: '', title: d.title, startDay: d.day, endDay: d.endDay, allDay: d.allDay || d.endDay > d.day,
|
|
184
190
|
startMin: d.startMin, endMin: d.endMin, time: d.allDay ? '' : shortTime(d.startMin), range: '', color, calendarId: d.calendarId,
|
|
185
|
-
calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, location: '', edited: false, url: '',
|
|
191
|
+
calendarName: '', status: 'draft', recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, movedFrom: '', location: '', edited: false, url: '',
|
|
186
192
|
});
|
|
187
193
|
};
|
|
188
194
|
|
|
189
195
|
// ---- moving ----------------------------------------------------------------
|
|
190
196
|
const dayLabel = (d: Iso, o: Intl.DateTimeFormatOptions = { weekday: 'short', month: 'short', day: 'numeric' }) =>
|
|
191
197
|
new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(utc(d));
|
|
192
|
-
async function move(item: BoardItem, days: number, mode: 'series' | 'one', to: Iso) {
|
|
198
|
+
async function move(item: BoardItem, days: number, minutes: number, mode: 'series' | 'one', to: Iso) {
|
|
193
199
|
setAsk(null);
|
|
194
|
-
const r = await actions.events.move({ id: item.id, days, mode, occurrence: item.occurrence });
|
|
200
|
+
const r = await actions.events.move({ id: item.id, days, minutes, mode, occurrence: item.occurrence });
|
|
195
201
|
if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move the event.', error: true }); return; }
|
|
196
|
-
|
|
202
|
+
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 });
|
|
204
|
+
await refresh();
|
|
205
|
+
}
|
|
206
|
+
async function moveBack(it: BoardItem) {
|
|
207
|
+
const r = await actions.events.moveDateBack({ id: it.id, key: it.occurrence });
|
|
208
|
+
setPeek(null);
|
|
209
|
+
if (r.error || !r.data) { setToast({ text: r.error?.message ?? 'Couldn’t move it back.', error: true }); return; }
|
|
210
|
+
setToast({ text: `Moved “${it.title}” back to ${it.movedFrom}`, warning: r.data.warning || undefined });
|
|
197
211
|
await refresh();
|
|
198
212
|
}
|
|
199
213
|
async function undo(u: MoveUndo) {
|
|
@@ -205,7 +219,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
205
219
|
}
|
|
206
220
|
useEffect(() => {
|
|
207
221
|
if (!toast) return;
|
|
208
|
-
const t = setTimeout(() => setToast(null), toast.undo ? 8000 : 4000);
|
|
222
|
+
const t = setTimeout(() => setToast(null), toast.warning ? 12000 : toast.undo ? 8000 : 4000);
|
|
209
223
|
return () => clearTimeout(t);
|
|
210
224
|
}, [toast]);
|
|
211
225
|
|
|
@@ -213,22 +227,29 @@ export default function CalendarBoard(props: Props) {
|
|
|
213
227
|
if (!editable(it)) { e.preventDefault(); return; }
|
|
214
228
|
e.dataTransfer.setData('text/plain', it.key);
|
|
215
229
|
e.dataTransfer.effectAllowed = 'move';
|
|
230
|
+
// in the week's hour grid, keep the grabbed spot under the pointer
|
|
231
|
+
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
232
|
+
grab.current = Math.max(0, (e.clientY - box.top) / HOUR * 60);
|
|
216
233
|
setPeek(null);
|
|
217
234
|
setDragKey(it.key);
|
|
218
235
|
};
|
|
219
|
-
|
|
236
|
+
// `atMin`: the minute of the day the pointer dropped at (week view hour grid only)
|
|
237
|
+
const onDrop = (e: DragEvent, day: Iso, atMin?: number) => {
|
|
220
238
|
e.preventDefault();
|
|
221
239
|
const it = data.items.find((x) => x.key === (dragKey ?? e.dataTransfer.getData('text/plain')));
|
|
222
240
|
setDragKey(null); setDropDay(null);
|
|
223
241
|
if (!it) return;
|
|
224
242
|
const days = diffDays(it.startDay, day);
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
243
|
+
// to the quarter hour, keeping the event within the day
|
|
244
|
+
const minutes = atMin === undefined || it.allDay ? 0
|
|
245
|
+
: Math.max(-it.startMin, Math.min(1440 - 15 - it.startMin, Math.round((atMin - grab.current) / 15) * 15 - it.startMin));
|
|
246
|
+
if (!days && !minutes) return;
|
|
247
|
+
if (it.recurring) setAsk({ item: it, days, minutes, to: day });
|
|
248
|
+
else void move(it, days, minutes, 'series', day);
|
|
228
249
|
};
|
|
229
|
-
const dropProps = (day: Iso) => ({
|
|
250
|
+
const dropProps = (day: Iso, timed = false) => ({
|
|
230
251
|
onDragOver: (e: DragEvent) => { if (dragKey) { e.preventDefault(); if (dropDay !== day) setDropDay(day); } },
|
|
231
|
-
onDrop: (e: DragEvent) => onDrop(e, day),
|
|
252
|
+
onDrop: (e: DragEvent) => onDrop(e, day, timed ? (e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top) / HOUR * 60 : undefined),
|
|
232
253
|
});
|
|
233
254
|
|
|
234
255
|
// ---- actions on one item ---------------------------------------------------
|
|
@@ -402,7 +423,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
402
423
|
</div>
|
|
403
424
|
<div className="sch-wk-cols" style={{ height: 24 * HOUR }}>
|
|
404
425
|
{wdays.map((d) => (
|
|
405
|
-
<div key={d} className={`sch-wk-col ${d === today ? 'today' : ''} ${d === dropDay ? 'drop' : ''}`} {...dropProps(d)}
|
|
426
|
+
<div key={d} className={`sch-wk-col ${d === today ? 'today' : ''} ${d === dropDay ? 'drop' : ''}`} {...dropProps(d, true)}
|
|
406
427
|
onClick={(e) => {
|
|
407
428
|
if ((e.target as HTMLElement).closest('.sch-ev')) return;
|
|
408
429
|
const y = e.clientY - (e.currentTarget as HTMLElement).getBoundingClientRect().top;
|
|
@@ -480,12 +501,14 @@ export default function CalendarBoard(props: Props) {
|
|
|
480
501
|
{statusWord(it) && <li>{statusWord(it)}{it.edited ? ' · has unpublished changes' : ''}</li>}
|
|
481
502
|
{!statusWord(it) && it.edited && <li>Has unpublished changes</li>}
|
|
482
503
|
{it.cancelled && <li>{it.dateCancelled ? 'This date is cancelled' : 'Cancelled'}</li>}
|
|
504
|
+
{it.movedFrom && <li>Moved here from {it.movedFrom}</li>}
|
|
483
505
|
</ul>
|
|
484
506
|
<div className="sch-peek-actions">
|
|
485
507
|
{it.kind === 'event' && <>
|
|
486
508
|
<a className="btn sm primary" href={`/admin/calendar/events/${it.id}`}>Edit</a>
|
|
487
509
|
<button type="button" className="btn sm" onClick={() => duplicate(it)}>Duplicate</button>
|
|
488
510
|
{it.recurring && it.dateCancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, true)}>Restore this date</button>}
|
|
511
|
+
{it.recurring && it.movedFrom && !it.cancelled && <button type="button" className="btn sm" onClick={() => moveBack(it)}>Move back</button>}
|
|
489
512
|
{it.recurring && !it.cancelled && <button type="button" className="btn sm" onClick={() => cancelDate(it, false)}>Cancel this date</button>}
|
|
490
513
|
{it.url && <a className="btn sm ghost" href={it.url} target="_blank" rel="noopener">View ↗</a>}
|
|
491
514
|
<button type="button" className="btn sm ghost danger" onClick={() => remove(it)}>Delete</button>
|
|
@@ -591,8 +614,8 @@ export default function CalendarBoard(props: Props) {
|
|
|
591
614
|
<b>Move “{ask.item.title}”?</b>
|
|
592
615
|
<p>This event repeats. Move only {dayLabel(ask.item.startDay, { weekday: 'long', month: 'short', day: 'numeric' })}, or every date in the series?</p>
|
|
593
616
|
<div className="sch-ask-actions">
|
|
594
|
-
<button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, 'one', ask.to)}>Only this date</button>
|
|
595
|
-
<button type="button" className="btn" onClick={() => move(ask.item, ask.days, 'series', ask.to)}>Every date</button>
|
|
617
|
+
<button type="button" className="btn primary" onClick={() => move(ask.item, ask.days, ask.minutes, 'one', ask.to)}>Only this date</button>
|
|
618
|
+
<button type="button" className="btn" onClick={() => move(ask.item, ask.days, ask.minutes, 'series', ask.to)}>Every date</button>
|
|
596
619
|
<button type="button" className="btn ghost" onClick={() => setAsk(null)}>Cancel</button>
|
|
597
620
|
</div>
|
|
598
621
|
</div>
|
|
@@ -601,6 +624,7 @@ export default function CalendarBoard(props: Props) {
|
|
|
601
624
|
{toast && (
|
|
602
625
|
<div className={`sch-toast ${toast.error ? 'err' : ''}`} role="status">
|
|
603
626
|
{toast.text}
|
|
627
|
+
{toast.warning && <small className="sch-toast-warn">{toast.warning}</small>}
|
|
604
628
|
{toast.undo && <button type="button" onClick={() => undo(toast.undo!)}>Undo</button>}
|
|
605
629
|
</div>
|
|
606
630
|
)}
|
|
@@ -11,6 +11,7 @@ import { LocationField } from './LocationField';
|
|
|
11
11
|
import { OrganizeCard, StatusSection } from './OrganizeCard';
|
|
12
12
|
import { RegistrationCard } from './RegistrationCard';
|
|
13
13
|
import { WhenCard } from './WhenCard';
|
|
14
|
+
import { SingleDates } from './SingleDates';
|
|
14
15
|
import { registrationMode } from './registration';
|
|
15
16
|
|
|
16
17
|
// Every event setting, on the page above the event's own content, as one
|
|
@@ -106,6 +107,7 @@ export function EventSheet({ meta, onChange, calendars, fields, siteTimezone, ma
|
|
|
106
107
|
<span>{meta.cancelled && <em>Cancelled · </em>}{summary.join(' · ')}</span>
|
|
107
108
|
</p>
|
|
108
109
|
<WhenCard meta={meta} onChange={onChange} siteTimezone={siteTimezone} eventId={eventId} />
|
|
110
|
+
<SingleDates eventId={eventId} repeats={Boolean(meta.rrule)} />
|
|
109
111
|
<Section
|
|
110
112
|
id="ev-where"
|
|
111
113
|
title="Where"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { actions } from 'astro:actions';
|
|
3
|
+
import { Section } from './Section';
|
|
4
|
+
|
|
5
|
+
// 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.
|
|
10
|
+
|
|
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
|
+
|
|
15
|
+
const datePart = (w: string) => w.slice(0, 10);
|
|
16
|
+
const timePart = (w: string) => w.slice(11, 16);
|
|
17
|
+
|
|
18
|
+
export function SingleDates({ eventId, repeats }: { eventId: string; repeats: boolean }) {
|
|
19
|
+
const [data, setData] = useState<Data | null>(null);
|
|
20
|
+
const [limit, setLimit] = useState(8);
|
|
21
|
+
const [form, setForm] = useState<Form | null>(null);
|
|
22
|
+
const [busy, setBusy] = useState('');
|
|
23
|
+
const [note, setNote] = useState<{ text: string; error?: boolean } | null>(null);
|
|
24
|
+
|
|
25
|
+
const load = async (n = limit) => {
|
|
26
|
+
const r = await actions.events.dates({ id: eventId, limit: n });
|
|
27
|
+
if (r.data) setData(r.data);
|
|
28
|
+
};
|
|
29
|
+
useEffect(() => { if (eventId && repeats) void load(); }, [eventId, repeats, limit]);
|
|
30
|
+
|
|
31
|
+
if (!eventId || !repeats || !data || !data.dates.length) return null;
|
|
32
|
+
|
|
33
|
+
const open = (d: DateRow) => {
|
|
34
|
+
setNote(null);
|
|
35
|
+
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
|
+
};
|
|
37
|
+
const run = async (key: string, work: () => Promise<{ error?: { message: string }; data?: object }>, done: string) => {
|
|
38
|
+
setBusy(key);
|
|
39
|
+
const r = await work();
|
|
40
|
+
setBusy('');
|
|
41
|
+
if (r.error) { setNote({ text: r.error.message, error: true }); return; }
|
|
42
|
+
setForm(null);
|
|
43
|
+
const warning = r.data && 'warning' in r.data && typeof r.data.warning === 'string' ? r.data.warning : '';
|
|
44
|
+
setNote({ text: warning ? `${done} ${warning}` : done });
|
|
45
|
+
await load();
|
|
46
|
+
};
|
|
47
|
+
const save = (f: Form) => {
|
|
48
|
+
const start = data.allDay ? f.day : `${f.day}T${f.from}`;
|
|
49
|
+
// a time that ends before it starts runs past midnight
|
|
50
|
+
const end = data.allDay ? (f.endDay >= f.day ? f.endDay : f.day) : `${f.to <= f.from ? nextDay(f.day) : f.day}T${f.to}`;
|
|
51
|
+
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
|
+
};
|
|
53
|
+
|
|
54
|
+
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.">
|
|
56
|
+
<ul className="pe-evd">
|
|
57
|
+
{data.dates.map((d) => (
|
|
58
|
+
<li key={d.key} className={`pe-evd-row${d.cancelled ? ' is-cancelled' : d.moved ? ' is-moved' : ''}`}>
|
|
59
|
+
<div className="pe-evd-main">
|
|
60
|
+
<span className="pe-evd-when">{d.cancelled ? <s>{d.when}</s> : d.when}</span>
|
|
61
|
+
{d.cancelled && <small className="pe-evd-state">Cancelled</small>}
|
|
62
|
+
{d.moved && <small className="pe-evd-state">Moved from {d.moved.from}{d.moved.note ? ` · “${d.moved.note}”` : ''}</small>}
|
|
63
|
+
</div>
|
|
64
|
+
<div className="pe-evd-acts">
|
|
65
|
+
{d.cancelled ? (
|
|
66
|
+
<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
|
+
) : (
|
|
68
|
+
<>
|
|
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>
|
|
70
|
+
{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
|
+
<button type="button" className="pe-btn ghost sm" disabled={busy === d.key}
|
|
72
|
+
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>
|
|
73
|
+
</>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
{form?.key === d.key && (
|
|
77
|
+
<form className="pe-evd-form" onSubmit={(e) => { e.preventDefault(); void save(form); }}>
|
|
78
|
+
<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>
|
|
80
|
+
{data.allDay ? (
|
|
81
|
+
<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
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
<label className="pe-field"><span>Starts</span><input type="time" required step={300} value={form.from} onChange={(e) => setForm({ ...form, from: e.target.value })} /></label>
|
|
85
|
+
<label className="pe-field"><span>Ends</span><input type="time" required step={300} value={form.to} onChange={(e) => setForm({ ...form, to: e.target.value })} /></label>
|
|
86
|
+
</>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
{data.rooms.length > 0 && (
|
|
90
|
+
<fieldset className="pe-evd-rooms">
|
|
91
|
+
<legend>Rooms for this date</legend>
|
|
92
|
+
{data.rooms.map((r) => (
|
|
93
|
+
<label key={r.id} className="pe-evd-room">
|
|
94
|
+
<input type="checkbox" checked={form.roomIds.includes(r.id)}
|
|
95
|
+
onChange={(e) => setForm({ ...form, roomIds: e.target.checked ? [...form.roomIds, r.id] : form.roomIds.filter((x) => x !== r.id) })} />
|
|
96
|
+
{r.name}
|
|
97
|
+
</label>
|
|
98
|
+
))}
|
|
99
|
+
</fieldset>
|
|
100
|
+
)}
|
|
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 })} />
|
|
103
|
+
</label>
|
|
104
|
+
<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>
|
|
106
|
+
<button type="button" className="pe-btn ghost sm" onClick={() => setForm(null)}>Never mind</button>
|
|
107
|
+
</div>
|
|
108
|
+
</form>
|
|
109
|
+
)}
|
|
110
|
+
</li>
|
|
111
|
+
))}
|
|
112
|
+
</ul>
|
|
113
|
+
{note && <p className={`pe-ev-note${note.error ? ' is-error' : ''}`} role="status">{note.text}</p>}
|
|
114
|
+
{data.dates.length >= limit && limit < 52 && <button type="button" className="pe-evw-link" onClick={() => setLimit(Math.min(52, limit + 12))}>Show more dates</button>}
|
|
115
|
+
</Section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function nextDay(day: string): string {
|
|
120
|
+
const d = new Date(`${day}T00:00:00Z`);
|
|
121
|
+
d.setUTCDate(d.getUTCDate() + 1);
|
|
122
|
+
return d.toISOString().slice(0, 10);
|
|
123
|
+
}
|
package/src/lib/booking/holds.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// staff, and roomBusy() answers "is this room taken then?" for one span.
|
|
9
9
|
import type { ContentRepository } from '../repo/types';
|
|
10
10
|
import type { Booking, CalEvent, Resource, Settings } from '../schema';
|
|
11
|
-
import { expandEvents } from '../calendar/events';
|
|
11
|
+
import { eventRoomIds, expandEvents } from '../calendar/events';
|
|
12
12
|
import { tzParts, zonedToUtc } from '../calendar/ics';
|
|
13
13
|
import { resourceZone } from './availability';
|
|
14
14
|
|
|
@@ -27,8 +27,8 @@ export interface Hold {
|
|
|
27
27
|
const DAY = 86_400_000;
|
|
28
28
|
|
|
29
29
|
/** Does this event hold its rooms right now? */
|
|
30
|
-
export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
|
|
31
|
-
if (e.cancelled || !e.
|
|
30
|
+
export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold' | 'movedDates'>): boolean {
|
|
31
|
+
if (e.cancelled || !eventRoomIds(e).length) return false;
|
|
32
32
|
return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -52,8 +52,8 @@ export function holdsFromEvents(
|
|
|
52
52
|
opts: { force?: boolean; resourceId?: string } = {}
|
|
53
53
|
): Hold[] {
|
|
54
54
|
const out: Hold[] = [];
|
|
55
|
-
const holding = events.filter((e) => (opts.force ? !e.cancelled && e.
|
|
56
|
-
&& (!opts.resourceId || e.
|
|
55
|
+
const holding = events.filter((e) => (opts.force ? !e.cancelled && eventRoomIds(e).length > 0 : holdsRooms(e))
|
|
56
|
+
&& (!opts.resourceId || eventRoomIds(e).includes(opts.resourceId)));
|
|
57
57
|
if (!holding.length) return out;
|
|
58
58
|
// widen the window so setup/cleanup and whole-day holds at the edges count
|
|
59
59
|
const pad = 2 * DAY;
|
|
@@ -155,7 +155,7 @@ export async function eventClashes(
|
|
|
155
155
|
const holds = holdsFromEvents([event], byId, settings, now, to, { force: true });
|
|
156
156
|
if (!holds.length) return { clashes: [], holds };
|
|
157
157
|
const rooms = new Set(holds.map((h) => h.resourceId));
|
|
158
|
-
const others = events.filter((e) => e.id !== event.id && e.
|
|
158
|
+
const others = events.filter((e) => e.id !== event.id && eventRoomIds(e).some((id) => rooms.has(id)));
|
|
159
159
|
const from = Math.min(...holds.map((h) => h.start)), until = Math.max(...holds.map((h) => h.end));
|
|
160
160
|
const theirs = holdsFromEvents(others, byId, settings, from, until);
|
|
161
161
|
const clashes: EventClash[] = [];
|
|
@@ -199,7 +199,7 @@ export async function roomBusy(
|
|
|
199
199
|
const rooms = resources.filter((r) => ids.has(r.id));
|
|
200
200
|
const byId = new Map(rooms.map((r) => [r.id, r]));
|
|
201
201
|
const out: BusySlot[] = [];
|
|
202
|
-
const holding = events.filter((e) => e.
|
|
202
|
+
const holding = events.filter((e) => eventRoomIds(e).some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
|
|
203
203
|
for (const h of holdsFromEvents(holding, byId, settings, q.from, q.to)) {
|
|
204
204
|
if (q.ignore?.key && h.eventId === q.ignore.eventId && h.key === q.ignore.key) continue;
|
|
205
205
|
out.push({ resourceId: h.resourceId, start: h.start, end: h.end, kind: 'event', title: h.title, eventId: h.eventId, key: h.key });
|
package/src/lib/calendar/api.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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 Resource } from '../schema';
|
|
6
|
+
import { roomColor, 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
|
|
|
@@ -25,6 +25,9 @@ export interface EventJson {
|
|
|
25
25
|
repeats: string;
|
|
26
26
|
exdates: string[];
|
|
27
27
|
cancelledDates: string[];
|
|
28
|
+
// single dates at another time, by original occurrence key: the new wall
|
|
29
|
+
// times and the public note (staff callers also get rooms, place and who moved it)
|
|
30
|
+
movedDates: Record<string, PublicMovedDate>;
|
|
28
31
|
location: CalEvent['location'];
|
|
29
32
|
cta: CalEvent['cta'];
|
|
30
33
|
// notifyStaff (whether the hosts get RSVP emails) only for staff callers
|
|
@@ -40,6 +43,12 @@ export interface EventJson {
|
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export interface RoomJson { slug: string; name: string; color: string | null }
|
|
46
|
+
export type PublicMovedDate = Pick<MovedDate, 'start' | 'end' | 'note'> & Partial<MovedDate>;
|
|
47
|
+
|
|
48
|
+
/** moved dates as public data: where and why, not who */
|
|
49
|
+
export function movedDatesToJson(e: Pick<CalEvent, 'movedDates'>, includePrivate = false): Record<string, PublicMovedDate> {
|
|
50
|
+
return Object.fromEntries(Object.entries(e.movedDates ?? {}).map(([k, m]) => [k, includePrivate ? m : { start: m.start, end: m.end, note: m.note }]));
|
|
51
|
+
}
|
|
43
52
|
|
|
44
53
|
/** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
|
|
45
54
|
export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
|
|
@@ -70,6 +79,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
|
|
|
70
79
|
repeats: describeRepeat(e.rrule, e.start),
|
|
71
80
|
exdates: e.exdates,
|
|
72
81
|
cancelledDates: e.cancelledDates ?? [],
|
|
82
|
+
movedDates: movedDatesToJson(e, opts.includePrivate),
|
|
73
83
|
location: e.location,
|
|
74
84
|
cta: e.cta,
|
|
75
85
|
rsvp: opts.includePrivate ? e.rsvp : (({ notifyStaff: _, ...rsvp }) => rsvp)(e.rsvp),
|
|
@@ -34,6 +34,8 @@ export interface BoardItem extends GridItem {
|
|
|
34
34
|
cancelled: boolean;
|
|
35
35
|
// just this date is cancelled (Restore brings it back)
|
|
36
36
|
dateCancelled: boolean;
|
|
37
|
+
// this date was moved here: "Thu, Jan 10 · 6:00 PM" (where it was), '' when not moved
|
|
38
|
+
movedFrom: string;
|
|
37
39
|
location: string;
|
|
38
40
|
edited: boolean; // live event with unpublished changes
|
|
39
41
|
url: string; // public URL ('' when none)
|
|
@@ -99,6 +101,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
99
101
|
const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'admin', includeFeeds: true, includeBookings: false });
|
|
100
102
|
const locale = siteLocale(settings);
|
|
101
103
|
const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
104
|
+
const dayFmt = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
102
105
|
const rangeOf = (s: number, e: number, allDay: boolean) => {
|
|
103
106
|
if (allDay) return 'All day';
|
|
104
107
|
try { return timeFmt.formatRange(s, e); } catch { return `${timeFmt.format(s)} – ${timeFmt.format(e)}`; }
|
|
@@ -115,7 +118,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
115
118
|
const startDay = s.day;
|
|
116
119
|
const endDay = allDay ? addDays(startDay, Math.max(1, v.days) - 1) : en.day < s.day ? s.day : en.day;
|
|
117
120
|
const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((c) => c.name === v.calendarName && c.kind === 'feed');
|
|
118
|
-
const occurrence = e ? occurrenceKey(e, v.start, settings, tz) : '';
|
|
121
|
+
const occurrence = e ? v.key ?? occurrenceKey(e, v.start, settings, tz) : '';
|
|
119
122
|
items.push({
|
|
120
123
|
key: e ? `e:${e.id}:${v.start}` : `f:${cal?.id ?? ''}:${v.start}:${v.title}`,
|
|
121
124
|
kind: e ? 'event' : 'feed',
|
|
@@ -132,11 +135,12 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
132
135
|
status: e ? eventBoardStatus(e) : 'feed',
|
|
133
136
|
recurring: Boolean(e?.rrule),
|
|
134
137
|
repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
|
|
135
|
-
rooms: e ? e.roomHold.resourceIds.map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
|
|
138
|
+
rooms: e ? (v.rooms?.map((r) => r.id) ?? e.roomHold.resourceIds).map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
|
|
136
139
|
roomsHeld: e ? holdsRooms(e) : false,
|
|
137
140
|
occurrence,
|
|
138
141
|
cancelled: Boolean(v.cancelled),
|
|
139
142
|
dateCancelled: Boolean(e && !e.cancelled && (e.cancelledDates ?? []).includes(occurrence)),
|
|
143
|
+
movedFrom: v.moved ? `${dayFmt.format(v.moved.from.start)} · ${allDay ? 'All day' : timeFmt.format(v.moved.from.start)}` : '',
|
|
140
144
|
location: v.location,
|
|
141
145
|
edited: Boolean(e?.draft),
|
|
142
146
|
url: e?.status === 'published' ? v.url ?? '' : '',
|
|
@@ -167,7 +171,7 @@ export async function loadBoard(repo: ContentRepository, settings: Settings, fro
|
|
|
167
171
|
calendarId: `res:${b.resourceId}`,
|
|
168
172
|
calendarName: r?.name ?? '',
|
|
169
173
|
status: b.status as BoardStatus,
|
|
170
|
-
recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false,
|
|
174
|
+
recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false, dateCancelled: false, movedFrom: '',
|
|
171
175
|
location: r?.name ?? '', edited: false, url: '',
|
|
172
176
|
});
|
|
173
177
|
}
|
|
@@ -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 },
|
|
31
|
+
o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'> & { cancelled?: boolean; moved?: Occurrence['moved'] },
|
|
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(e.location);
|
|
51
|
+
const loc = locationLine(o.moved?.location ?? e.location);
|
|
52
52
|
const ctaHref = safeHref(e.cta.href);
|
|
53
53
|
const cta =
|
|
54
54
|
opts.showCta && e.cta.label && ctaHref && !cancelled
|
|
@@ -56,7 +56,7 @@ export function eventCardHtml(
|
|
|
56
56
|
: '';
|
|
57
57
|
return (
|
|
58
58
|
`<article class="pc-ev-card${cancelled ? ' is-cancelled' : ''}">${media}<div class="pc-ev-card-body">` +
|
|
59
|
-
`<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : ''}${esc(when)}</div>` +
|
|
59
|
+
`<div class="pc-ev-card-when">${cancelled ? `<span class="pc-ev-flag">${esc(eventStrings(siteLang(settings)).cancelled)}</span> ` : o.moved ? `<span class="pc-ev-flag is-moved">${esc(eventStrings(siteLang(settings)).moved)}</span> ` : ''}${esc(when)}</div>` +
|
|
60
60
|
`<${h} class="pc-ev-card-title"><a href="${esc(href)}">${esc(e.title || eventStrings(siteLang(settings)).calendar.untitled)}</a></${h}>` +
|
|
61
61
|
(e.excerpt ? `<p class="pc-ev-card-excerpt">${esc(e.excerpt)}</p>` : '') +
|
|
62
62
|
(loc ? `<div class="pc-ev-card-loc">${esc(loc)}</div>` : '') +
|
|
@@ -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'>[] = [];
|
|
83
|
+
const out: (Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>)[] = [];
|
|
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) });
|
|
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 } : {}) });
|
|
88
88
|
if (out.length >= opts.limit) break;
|
|
89
89
|
}
|
|
90
90
|
return out;
|