@acms-subir/core 0.3.56 → 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.
- package/CHANGELOG.md +13 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +116 -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/calendar/api.ts +23 -2
- 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 +9 -2
- 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-service.ts +5 -5
- package/src/lib/calendar/schedule.ts +42 -8
- package/src/lib/calendar/segments.ts +522 -0
- package/src/lib/editor/schedule.ts +2 -2
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/schemas.ts +10 -3
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +2 -0
- package/src/lib/repo/migrations.ts +5 -2
- package/src/lib/repo/sql.ts +24 -8
- package/src/lib/repo/types.ts +3 -1
- package/src/lib/schema.ts +24 -0
- package/src/lib/sections.ts +1 -1
- 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/api/calendar/events/index.ts +2 -1
- package/src/pages/members/events/index.astro +84 -6
- package/src/server/index.ts +46 -3
- package/src/styles/editor.css +2 -0
package/src/lib/calendar/api.ts
CHANGED
|
@@ -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
|
-
|
|
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),
|
|
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
|
-
|
|
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, 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
|
-
|
|
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',
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Native events: wall-clock time math, recurrence expansion (reusing the ICS
|
|
2
2
|
// expander, so feed events and native events follow identical rules),
|
|
3
3
|
// visibility, and "add to calendar" links. Pure — no repo, no fetch.
|
|
4
|
-
import type { CalEvent, Calendar, EventLocation, MovedDate, Settings } from '../schema';
|
|
4
|
+
import type { CalEvent, Calendar, EventLocation, MovedDate, SeriesSegment, Settings } from '../schema';
|
|
5
5
|
import { expandVEvents, tzParts, zonedToUtc, type CalendarWindow, type IcsTime, type VEvent } from './ics';
|
|
6
|
-
import { cleanRrule } from './rrule';
|
|
6
|
+
import { cleanRrule, describeRepeat } from './rrule';
|
|
7
7
|
import { eventStrings } from './event-words';
|
|
8
8
|
|
|
9
9
|
const DAY = 86_400_000;
|
|
@@ -130,6 +130,11 @@ export interface Occurrence {
|
|
|
130
130
|
roomIds: string[];
|
|
131
131
|
// this date was moved here (CalEvent.movedDates): where it was, and why
|
|
132
132
|
moved?: OccurrenceMove;
|
|
133
|
+
// the part of the series it belongs to, when the series changed from a
|
|
134
|
+
// date on (CalEvent.segments): that segment's `from` key
|
|
135
|
+
segment?: string;
|
|
136
|
+
// the date's own place (a moved date's, or its segment's), when not the event's
|
|
137
|
+
location?: EventLocation;
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
export interface OccurrenceMove {
|
|
@@ -141,13 +146,105 @@ export interface OccurrenceMove {
|
|
|
141
146
|
location?: EventLocation;
|
|
142
147
|
}
|
|
143
148
|
|
|
144
|
-
/** Every room an event may hold: its own and any a moved date holds instead. */
|
|
145
|
-
export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'>): string[] {
|
|
149
|
+
/** Every room an event may hold: its own and any a moved date or a segment holds instead. */
|
|
150
|
+
export function eventRoomIds(e: Pick<CalEvent, 'roomHold' | 'movedDates'> & { segments?: CalEvent['segments'] }): string[] {
|
|
146
151
|
const ids = new Set(e.roomHold?.resourceIds ?? []);
|
|
147
152
|
for (const m of Object.values(e.movedDates ?? {})) for (const id of m.roomIds ?? []) ids.add(id);
|
|
153
|
+
for (const seg of e.segments ?? []) for (const id of seg.roomIds ?? []) ids.add(id);
|
|
148
154
|
return [...ids];
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// ---- segments ------------------------------------------------------------
|
|
158
|
+
// A series changed from a date on (CalEvent.segments) is one event made of
|
|
159
|
+
// parts: its own rule up to the first segment's `from`, then each segment's
|
|
160
|
+
// rule (from its own first date) up to the next one's `from`. A part's dates
|
|
161
|
+
// stop at the day of the next part's `from` (wall-clock in the event's
|
|
162
|
+
// zone), so retiming the earlier rule never leaves a second date on that
|
|
163
|
+
// day. A date of any part is like any other: cancelled, moved and skipped by
|
|
164
|
+
// its own key.
|
|
165
|
+
|
|
166
|
+
/** One part of a series: the event's own rule (`from` '') or a segment. */
|
|
167
|
+
export interface SeriesPart {
|
|
168
|
+
/** the segment's `from` key; '' for the event's own rule */
|
|
169
|
+
from: string;
|
|
170
|
+
/** the next part's `from` ('' = none): this part's dates from that day on are gone (cutsAt) */
|
|
171
|
+
until: string;
|
|
172
|
+
start: string;
|
|
173
|
+
end: string;
|
|
174
|
+
rrule: string;
|
|
175
|
+
roomIds: string[];
|
|
176
|
+
location?: EventLocation;
|
|
177
|
+
cancelled: boolean;
|
|
178
|
+
note: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Whether a part that stops at `until` (a key; '' = never) has no date at `key`: from that day on, it's the next part's. */
|
|
182
|
+
export const cutsAt = (until: string, key: string) => Boolean(until) && key.slice(0, 10) >= until.slice(0, 10);
|
|
183
|
+
|
|
184
|
+
/** The parts of an event's series, in order (just its own rule when it has no segments or doesn't repeat). */
|
|
185
|
+
export function seriesParts(e: Pick<CalEvent, 'start' | 'end' | 'rrule' | 'allDay' | 'roomHold'> & { segments?: SeriesSegment[] }): SeriesPart[] {
|
|
186
|
+
const own: SeriesPart = { from: '', until: '', start: e.start, end: e.end, rrule: e.rrule, roomIds: e.roomHold?.resourceIds ?? [], cancelled: false, note: '' };
|
|
187
|
+
if (!e.rrule || !e.segments?.length) return [own];
|
|
188
|
+
// usable segments only: keys and times in the event's own form (all-day or timed)
|
|
189
|
+
const usable = e.segments.filter((g) => { const f = parseWall(g.from), st = parseWall(g.start); return f && st && f.dateOnly === e.allDay && st.dateOnly === e.allDay; })
|
|
190
|
+
.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
191
|
+
const parts = [own, ...usable.map((g): SeriesPart => ({
|
|
192
|
+
from: g.from, until: '', start: g.start, end: g.end || g.start, rrule: g.rrule, roomIds: g.roomIds ?? e.roomHold?.resourceIds ?? [],
|
|
193
|
+
...(g.location ? { location: g.location } : {}), cancelled: g.cancelled, note: g.note,
|
|
194
|
+
}))];
|
|
195
|
+
for (let i = 0; i < parts.length - 1; i++) parts[i].until = parts[i + 1].from;
|
|
196
|
+
return parts;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** A part's dates' length in wall-clock minutes (all-day: whole days × 1440). */
|
|
200
|
+
export function partMinutes(p: Pick<SeriesPart, 'start' | 'end'>): number {
|
|
201
|
+
const s = parseWall(p.start), en = parseWall(p.end) ?? s;
|
|
202
|
+
return s && en ? wallMinutesBetween(s, en) : 60;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The part of the series in effect at `now`: the one its next date (or, once all are over, its last) belongs to. */
|
|
206
|
+
export function currentPart(e: CalEvent, settings: Pick<Settings, 'timezone'>, now = Date.now()): SeriesPart {
|
|
207
|
+
const parts = seriesParts(e);
|
|
208
|
+
if (parts.length === 1) return parts[0];
|
|
209
|
+
const o = featuredOccurrence(e, settings, now);
|
|
210
|
+
return o ? partOf(e, o) : parts[0];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The repeat line of a series in `locale`: the rule in effect now, then the
|
|
215
|
+
* next change still to come ("Weekly on Thursday · From Wed, Jan 16: Weekly
|
|
216
|
+
* on Wednesday"), or "Cancelled from …". `formatDate` writes UNTIL dates as
|
|
217
|
+
* describeRepeat's does; `dateLocale` the change's day (default: `locale`).
|
|
218
|
+
*/
|
|
219
|
+
export function describeSeries(e: CalEvent, settings: Pick<Settings, 'timezone'>, now: number, locale?: string, formatDate?: (ymd: string) => string, dateLocale?: string): string {
|
|
220
|
+
if (!e.rrule) return '';
|
|
221
|
+
const w = eventStrings(locale);
|
|
222
|
+
const parts = seriesParts(e);
|
|
223
|
+
const current = currentPart(e, settings, now);
|
|
224
|
+
const text = describeRepeat(current.rrule, current.start, formatDate, locale);
|
|
225
|
+
if (parts.length === 1) return text;
|
|
226
|
+
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
227
|
+
const day = (ms: number) => {
|
|
228
|
+
try { return new Intl.DateTimeFormat(dateLocale || locale || 'en', { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz }).format(ms); }
|
|
229
|
+
catch { return new Date(ms).toISOString().slice(0, 10); }
|
|
230
|
+
};
|
|
231
|
+
const dates = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now - 400 * DAY, windowEndMs: now + 400 * DAY });
|
|
232
|
+
const firstOf = (p: SeriesPart) => dates.find((o) => (o.segment ?? '') === p.from);
|
|
233
|
+
const out = [current.cancelled ? fillWords(w.cancelledFrom, { date: firstOf(current) ? day(firstOf(current)!.start) : '' }) : text];
|
|
234
|
+
const next = parts[parts.findIndex((p) => p.from === current.from) + 1];
|
|
235
|
+
const first = next && firstOf(next);
|
|
236
|
+
if (next && first && first.start > now) {
|
|
237
|
+
out.push(next.cancelled ? fillWords(w.cancelledFrom, { date: day(first.start) })
|
|
238
|
+
: fillWords(w.fromDate, { date: day(first.start), repeat: describeRepeat(next.rrule, next.start, formatDate, locale) }));
|
|
239
|
+
}
|
|
240
|
+
return out.filter(Boolean).join(' · ');
|
|
241
|
+
}
|
|
242
|
+
const fillWords = (t: string, v: Record<string, string>) => t.replace(/\{(\w+)\}/g, (m, k: string) => v[k] ?? m);
|
|
243
|
+
|
|
244
|
+
/** The part of the series a date belongs to (its occurrence's `segment`, '' for the event's own rule). */
|
|
245
|
+
export const partOf = (e: Parameters<typeof seriesParts>[0], o: Pick<Occurrence, 'segment'>): SeriesPart =>
|
|
246
|
+
seriesParts(e).find((p) => p.from === (o.segment ?? '')) ?? seriesParts(e)[0];
|
|
247
|
+
|
|
151
248
|
/**
|
|
152
249
|
* The occurrence key for an occurrence start. Timed events use the event's
|
|
153
250
|
* own zone; all-day occurrences start at midnight in the display zone (how
|
|
@@ -166,16 +263,21 @@ export function expandEvents(events: CalEvent[], settings: Pick<Settings, 'timez
|
|
|
166
263
|
const out: Occurrence[] = [];
|
|
167
264
|
for (const e of events) {
|
|
168
265
|
const tz = eventTimeZone(e, settings);
|
|
169
|
-
const v = toVEvent(e, tz);
|
|
170
|
-
if (!v) continue;
|
|
171
266
|
// ?? []: editor previews pass unsaved events that may lack the field
|
|
172
267
|
const cancelledDates = new Set(e.cancelledDates ?? []);
|
|
173
268
|
const moved = movedEntries(e);
|
|
174
|
-
for (const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
269
|
+
for (const part of seriesParts(e)) {
|
|
270
|
+
const v = toVEvent(part.from ? { ...e, start: part.start, end: part.end, rrule: part.rrule } : e, tz);
|
|
271
|
+
if (!v) continue;
|
|
272
|
+
for (const o of expandVEvents([v], tz, win, 0)) {
|
|
273
|
+
const key = occurrenceKey(e, o.start, settings, win.displayTz);
|
|
274
|
+
// the next part has the dates from its `from` on; a moved date comes out at its new time below
|
|
275
|
+
if (cutsAt(part.until, key) || moved.has(key)) continue;
|
|
276
|
+
out.push({
|
|
277
|
+
event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || part.cancelled || cancelledDates.has(key), roomIds: part.roomIds,
|
|
278
|
+
...(part.from ? { segment: part.from } : {}), ...(part.location ? { location: part.location } : {}),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
179
281
|
}
|
|
180
282
|
for (const [key, m] of moved) {
|
|
181
283
|
const at = movedInstants(e, m, settings, win.displayTz);
|
|
@@ -230,10 +332,13 @@ function movedOccurrence(e: CalEvent, key: string, m: MovedDate, settings: Pick<
|
|
|
230
332
|
const at = movedInstants(e, m, settings, displayTz);
|
|
231
333
|
const was = at && seriesOccurrence(e, key, settings, displayTz);
|
|
232
334
|
if (!at || !was) return null;
|
|
335
|
+
// its own rooms and place, else its part's (a segment's, or the event's)
|
|
336
|
+
const location = m.location ?? was.location;
|
|
233
337
|
return {
|
|
234
|
-
event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled:
|
|
235
|
-
roomIds: m.roomIds ??
|
|
338
|
+
event: e, start: at.start, end: at.end, allDay: e.allDay, days: at.days, key, cancelled: was.cancelled,
|
|
339
|
+
roomIds: m.roomIds ?? was.roomIds,
|
|
236
340
|
moved: { from: { start: was.start, end: was.end }, note: m.note ?? '', ...(m.location ? { location: m.location } : {}) },
|
|
341
|
+
...(was.segment ? { segment: was.segment } : {}), ...(location ? { location } : {}),
|
|
237
342
|
};
|
|
238
343
|
}
|
|
239
344
|
|
|
@@ -358,7 +463,7 @@ const dateStamp = (ms: number, tz: string) => {
|
|
|
358
463
|
|
|
359
464
|
export function addToCalendarLinks(
|
|
360
465
|
e: CalEvent,
|
|
361
|
-
o: Pick<Occurrence, 'start' | 'end' | 'allDay'>,
|
|
466
|
+
o: Pick<Occurrence, 'start' | 'end' | 'allDay'> & Pick<Partial<Occurrence>, 'segment'>,
|
|
362
467
|
opts: { pageUrl: string; icsUrl: string; tz: string }
|
|
363
468
|
): { google: string; outlook: string; ics: string } {
|
|
364
469
|
const details = [e.excerpt, opts.pageUrl].filter(Boolean).join('\n\n');
|
|
@@ -371,7 +476,15 @@ export function addToCalendarLinks(
|
|
|
371
476
|
location: loc,
|
|
372
477
|
});
|
|
373
478
|
if (!o.allDay) g.set('ctz', opts.tz);
|
|
374
|
-
|
|
479
|
+
// the rule of the part of the series this date is in, stopped where the next part takes over
|
|
480
|
+
const part = partOf(e, o);
|
|
481
|
+
let rule = cleanRrule(part.rrule);
|
|
482
|
+
const cut = parseWall(part.until);
|
|
483
|
+
if (rule && cut) {
|
|
484
|
+
const ends = rule.split(';').filter((x) => !/^(COUNT|UNTIL)=/.test(x));
|
|
485
|
+
const last = zonedToUtc(cut.y, cut.m, cut.d, 0, 0, 0, opts.tz) - 1000;
|
|
486
|
+
rule = [...ends, o.allDay ? `UNTIL=${dateStamp(last, opts.tz)}` : `UNTIL=${utcStamp(last)}`].join(';');
|
|
487
|
+
}
|
|
375
488
|
if (rule) g.set('recur', `RRULE:${rule}`);
|
|
376
489
|
const iso = (ms: number) => new Date(ms).toISOString();
|
|
377
490
|
const ol = new URLSearchParams({
|
|
@@ -396,7 +396,7 @@ export function asSiteEvent(f: Fields, settings: Pick<Settings, 'timezone'>, id
|
|
|
396
396
|
return {
|
|
397
397
|
id, slug: '', title: f.title, bodyJson: '', bodyHtml: '', excerpt: '', cover: '', coverAlt: '', seoTitle: '', seoDesc: '', status: 'published', featured: false,
|
|
398
398
|
tags: [], template: '', start: f.when.start, end: f.when.end, allDay: f.when.allDay, timezone, rrule: f.repeat.rrule, exdates: f.repeat.exdates, cancelled: false,
|
|
399
|
-
cancelledDates: [], movedDates: {}, location: EventLocation.parse({ name: f.place }), cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
399
|
+
cancelledDates: [], movedDates: {}, segments: [], location: EventLocation.parse({ name: f.place }), cta: { label: '', href: '', style: 'primary' }, calendarIds: [], fields: {},
|
|
400
400
|
sourceUid: '', rsvp: { enabled: false, capacity: 0, autoConfirm: true, maxSeats: 4, closesAt: null, closeBefore: 0, notifyStaff: true, waitlist: false },
|
|
401
401
|
roomHold: { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] }, hostIds: [], request: null, startAt, endAt,
|
|
402
402
|
translations: {}, draft: null, publishedAt: null, scheduledAt: null, createdAt: 0, updatedAt: 0,
|
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// - 'approve': every host move, and every move back, is a request.
|
|
7
7
|
// Requests are rows in event_requests (repo.requests, RequestRecord kind
|
|
8
8
|
// 'move', data: MoveRequestData) that staff who can publish approve (the move
|
|
9
|
-
// runs as them, recorded as the host's) or decline with a note.
|
|
9
|
+
// runs as them, recorded as the host's) or decline with a note. A host's
|
|
10
|
+
// "Change from this date on" (C4) is always a request (kind 'from-date', data:
|
|
11
|
+
// FromDateRequestData), which staff apply with changeFromDate (segments.ts).
|
|
10
12
|
//
|
|
11
13
|
// Like requests.ts, everything checks who is acting, so the built-in My
|
|
12
14
|
// events page and a site's own pages share the rules. Hosts need no
|
|
@@ -17,7 +19,9 @@ import type { CalEvent, RequestRecord } from '../schema';
|
|
|
17
19
|
import { ApiError, coded } from '../api';
|
|
18
20
|
import type { SaveHooks } from '../content-service';
|
|
19
21
|
import { GOOGLE_SYNC, checkMove, moveOccurrence, moveOccurrenceBack, type MoveInput, type MoveResult } from './moves';
|
|
20
|
-
import {
|
|
22
|
+
import { changeFromDate, checkChangeFromDate, type ChangeFromInput, type ChangeFromResult } from './segments';
|
|
23
|
+
import { eventRoomIds, eventTimeZone, findOccurrence, type Occurrence } from './events';
|
|
24
|
+
import { describeRepeat } from './rrule';
|
|
21
25
|
import { MEMBERS } from './members-config';
|
|
22
26
|
import { siteLocale } from '../dates';
|
|
23
27
|
import { kickEmail } from '../email/service';
|
|
@@ -38,6 +42,16 @@ export interface MoveRequestData {
|
|
|
38
42
|
back?: boolean;
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
/** RequestRecord.data of a 'from-date' request: the series from its `key` on (ChangeFromInput, checked); `rrule` '' = just that date */
|
|
46
|
+
export interface FromDateRequestData {
|
|
47
|
+
start: string;
|
|
48
|
+
end: string;
|
|
49
|
+
rrule: string;
|
|
50
|
+
roomIds?: string[];
|
|
51
|
+
/** the note in the email to people who signed up */
|
|
52
|
+
note: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
/** What a host's move did: moved at once (`moved`), or became a request (`request`). */
|
|
42
56
|
export type HostMoveResult = { moved: MoveResult; request?: undefined } | { request: RequestRecord; moved?: undefined };
|
|
43
57
|
|
|
@@ -100,15 +114,40 @@ export async function requestDateMove(repo: ContentRepository, who: HostActor |
|
|
|
100
114
|
const data: MoveRequestData = input && !check.back
|
|
101
115
|
? { start: check.start!, end: check.end!, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: check.note ?? '' }
|
|
102
116
|
: { start: '', end: '', note: '', back: true };
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
return saveRequest(repo, who!, id, 'move', key, data, text(message, LIMITS.note), hooks);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A host asks staff to change their event from one date on (new first date,
|
|
122
|
+
* times, repeat, rooms): checked like the change itself, then saved as a
|
|
123
|
+
* pending 'from-date' request. Asking again from the same date replaces the
|
|
124
|
+
* waiting one. Rooms: the event's own and the requestable ones.
|
|
125
|
+
*/
|
|
126
|
+
export async function requestFromDate(repo: ContentRepository, who: HostActor | null, id: string, key: string, input: ChangeFromInput, message: string, hooks: SaveHooks): Promise<RequestRecord> {
|
|
127
|
+
const e = await hosted(repo, who, id);
|
|
128
|
+
if (!e.hostIds.includes(who!.id)) throw coded(404, 'not-found', 'Event not found.');
|
|
129
|
+
if (input.roomIds !== undefined) {
|
|
130
|
+
const allowed = new Set([...eventRoomIds(e), ...(await repo.listResources()).filter((r) => r.requestable).map((r) => r.id)]);
|
|
131
|
+
if (input.roomIds.some((r) => !allowed.has(r))) throw coded(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
|
|
132
|
+
}
|
|
133
|
+
const check = await checkChangeFromDate(repo, who!, id, key, { ...input, location: undefined });
|
|
134
|
+
if (!check.changed) throw coded(400, 'unchanged', 'The dates from then on are already like that.');
|
|
135
|
+
if (!await repo.email.rateLimit(`event-request:${who!.id}`, 20, 3_600_000)) throw coded(429, 'rate-limit', 'That’s a lot of requests in an hour. Try again later.', 3600);
|
|
136
|
+
const g = check.segment;
|
|
137
|
+
const data: FromDateRequestData = { start: g.start, end: g.end, rrule: g.rrule, ...(input.roomIds !== undefined ? { roomIds: [...new Set(input.roomIds)] } : {}), note: g.note };
|
|
138
|
+
return saveRequest(repo, who!, e.id, 'from-date', key, data, text(message, LIMITS.note), hooks);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// a host's waiting request about this date (and kind) is replaced; else a new one
|
|
142
|
+
async function saveRequest(repo: ContentRepository, who: HostActor, eventId: string, kind: 'move' | 'from-date', key: string, data: object, note: string, hooks: SaveHooks) {
|
|
143
|
+
const waiting = (await repo.requests.list({ eventId, userId: who.id, kinds: [kind], states: ['pending'], limit: -1 })).find((r) => r.key === key);
|
|
105
144
|
let saved: RequestRecord | null;
|
|
106
145
|
if (waiting) saved = await repo.requests.update(waiting.id, { data: { ...data }, note }, { state: 'pending' });
|
|
107
146
|
else {
|
|
108
|
-
if (await repo.requests.count({ userId: who
|
|
147
|
+
if (await repo.requests.count({ userId: who.id, states: ['pending'] }) >= LIMITS.pending) throw coded(429, 'too-many-waiting', 'You have many requests waiting already. Wait for the team to answer them first.');
|
|
109
148
|
const now = Date.now();
|
|
110
|
-
saved = await repo.requests.create({ id: crypto.randomUUID(), kind
|
|
111
|
-
eventId
|
|
149
|
+
saved = await repo.requests.create({ id: crypto.randomUUID(), kind, state: 'pending', source: 'member', userId: who.id, requester: null,
|
|
150
|
+
eventId, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
|
|
112
151
|
}
|
|
113
152
|
if (!saved) throw coded(409, 'answered', 'That request was just answered. Look at My events again.');
|
|
114
153
|
await alertStaff(repo, saved, hooks);
|
|
@@ -162,10 +201,19 @@ async function answerable(repo: ContentRepository, id: string, seen?: number): P
|
|
|
162
201
|
}
|
|
163
202
|
|
|
164
203
|
/** Staff approve a host's move request: the move runs as them (room clashes warn), recorded as the host's, and the host is emailed. Pass `seen` (the request's updatedAt they reviewed). */
|
|
165
|
-
export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved
|
|
204
|
+
export async function approveDateRequest(repo: ContentRepository, who: HostActor | null, id: string, hooks: SaveHooks, seen?: number): Promise<{ request: RequestRecord; moved?: MoveResult; changed?: ChangeFromResult }> {
|
|
166
205
|
requireApprover(who);
|
|
167
206
|
const r = await answerable(repo, id, seen);
|
|
168
|
-
if (r.kind
|
|
207
|
+
if (r.kind === 'from-date') {
|
|
208
|
+
const f = r.data as FromDateRequestData;
|
|
209
|
+
const when = await dateRequestWhen(repo, r);
|
|
210
|
+
const request = await claim(repo, r, who);
|
|
211
|
+
let changed: ChangeFromResult;
|
|
212
|
+
try { changed = await changeFromDate(repo, who, r.eventId, r.key, { start: f.start, end: f.end, rrule: f.rrule, roomIds: f.roomIds, note: f.note }, hooks); }
|
|
213
|
+
catch (err) { await release(repo, id); throw err; }
|
|
214
|
+
await tellHost(repo, request, 'from-date-approved', '', when, hooks);
|
|
215
|
+
return { request, changed };
|
|
216
|
+
}
|
|
169
217
|
const d = r.data as MoveRequestData;
|
|
170
218
|
// worded before it moves: "where it was → where it goes"
|
|
171
219
|
const when = await dateRequestWhen(repo, r);
|
|
@@ -210,11 +258,18 @@ export async function dateRequestTimes(repo: ContentRepository, r: RequestRecord
|
|
|
210
258
|
const settings = await repo.getSettings();
|
|
211
259
|
const d = r.data as MoveRequestData;
|
|
212
260
|
const tz = e.allDay ? settings.timezone || 'UTC' : eventTimeZone(e, settings);
|
|
213
|
-
const fmt = (o: Occurrence | null) => {
|
|
261
|
+
const fmt = (o: Pick<Occurrence, 'start'> | null) => {
|
|
214
262
|
if (!o) return '';
|
|
215
263
|
try { return new Intl.DateTimeFormat(siteLocale(settings), e.allDay ? { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz } : { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZone: tz }).format(o.start); }
|
|
216
264
|
catch { return new Date(o.start).toISOString(); }
|
|
217
265
|
};
|
|
266
|
+
if (r.kind === 'from-date') {
|
|
267
|
+
// "Thu, Jan 17, 6:00 PM" → "Wed, Jan 16, 5:00 PM · Weekly on Wednesday": the new first date and its repeat
|
|
268
|
+
const f = r.data as FromDateRequestData;
|
|
269
|
+
const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
|
|
270
|
+
const at = findOccurrence({ ...e, movedDates: {} }, r.key, settings);
|
|
271
|
+
return { from: fmt(at), to: [fmt(first), describeRepeat(f.rrule, f.start)].filter(Boolean).join(' · ') };
|
|
272
|
+
}
|
|
218
273
|
const to = d.back ? findOccurrence({ ...e, movedDates: {} }, r.key, settings)
|
|
219
274
|
: findOccurrence({ ...e, movedDates: { ...e.movedDates, [r.key]: { start: d.start, end: d.end, note: d.note, by: '', at: 0, ...(d.roomIds ? { roomIds: d.roomIds } : {}) } } }, r.key, settings);
|
|
220
275
|
return { from: fmt(findOccurrence(e, r.key, settings)), to: fmt(to) };
|