@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
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
// date of a series is an override VEVENT (same UID, RECURRENCE-ID) with
|
|
8
8
|
// STATUS:CANCELLED, so calendar apps strike that one date; a moved date is an
|
|
9
9
|
// override with its new DTSTART/DTEND (and its own place), so they show it there.
|
|
10
|
+
// A series changed from a date on (CalEvent.segments) is one VEVENT per part,
|
|
11
|
+
// the way Google writes "this and following": the event's own rule keeps its
|
|
12
|
+
// UID and stops with an UNTIL just before the change, and each segment is a
|
|
13
|
+
// recurring event of its own (UID `<id>-<from>`), with its own overrides.
|
|
10
14
|
import type { CalEvent, Calendar } from '../schema';
|
|
11
|
-
import { addWallDays, eventInstants, eventTimeZone, findOccurrence, locationLine, parseWall, type Wall } from './events';
|
|
15
|
+
import { addWallDays, cutsAt, eventInstants, eventTimeZone, expandEvents, findOccurrence, locationLine, parseWall, seriesParts, type SeriesPart, type Wall } from './events';
|
|
12
16
|
import { zonedToUtc } from './ics';
|
|
13
17
|
import { cleanRrule, rruleMap } from './rrule';
|
|
14
18
|
|
|
@@ -72,18 +76,64 @@ export interface FeedOptions {
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): string[] {
|
|
75
|
-
const
|
|
79
|
+
const parts = seriesParts(e);
|
|
80
|
+
if (parts.length === 1) return partVevent(e, e, '', siteTz, opts);
|
|
81
|
+
return parts.flatMap((p) => {
|
|
82
|
+
const rule = ruleUntil(e, p, siteTz);
|
|
83
|
+
// a part whose dates all went to the next one has nothing to say
|
|
84
|
+
if (rule === null) return [];
|
|
85
|
+
// its own dates' overrides only: the dates the series puts in this part
|
|
86
|
+
const mine = (key: string) => (findOccurrence(e, key, { timezone: siteTz }, siteTz || 'UTC')?.segment ?? '') === p.from;
|
|
87
|
+
const part: CalEvent = { ...e, start: p.start, end: p.end, rrule: rule, cancelled: e.cancelled || p.cancelled, location: p.location ?? e.location, segments: [],
|
|
88
|
+
cancelledDates: e.cancelledDates.filter(mine), movedDates: Object.fromEntries(Object.entries(e.movedDates).filter(([k]) => mine(k))) };
|
|
89
|
+
return partVevent(e, part, p.from ? `-${p.from.replace(/[-:]/g, '')}` : '', siteTz, opts);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A part's rule for the feed: stopped just before the next part's `from`
|
|
94
|
+
// (RFC 5545 UNTIL is inclusive: the instant before it for timed events, the
|
|
95
|
+
// day before for all-day ones). COUNT becomes that UNTIL when the rule would
|
|
96
|
+
// still have dates there; null when the part has none left before it.
|
|
97
|
+
function ruleUntil(e: CalEvent, p: SeriesPart, siteTz: string): string | null {
|
|
98
|
+
const clean = cleanRrule(p.rrule);
|
|
99
|
+
if (!p.until || !clean) return clean;
|
|
100
|
+
const cut = parseWall(p.until);
|
|
101
|
+
if (!cut) return clean;
|
|
102
|
+
const tz = eventTimeZone(e, { timezone: siteTz });
|
|
103
|
+
const settings = { timezone: siteTz };
|
|
104
|
+
const solo: CalEvent = { ...e, start: p.start, end: p.end, rrule: clean, segments: [], movedDates: {}, cancelledDates: [] };
|
|
105
|
+
// parts stop at the day of the next one's `from` (events.ts cutsAt)
|
|
106
|
+
const cutMs = zonedToUtc(cut.y, cut.m, cut.d, 0, 0, 0, e.allDay ? siteTz || 'UTC' : tz);
|
|
107
|
+
const first = parseWall(p.start);
|
|
108
|
+
const startMs = first ? (e.allDay ? zonedToUtc(first.y, first.m, first.d, 0, 0, 0, siteTz || 'UTC') : zonedToUtc(first.y, first.m, first.d, first.hh, first.mm, 0, tz)) : cutMs;
|
|
109
|
+
if (startMs >= cutMs) return null;
|
|
110
|
+
// the rule would carry on past the cut (within the next two years): stop it there
|
|
111
|
+
const later = expandEvents([solo], settings, { displayTz: siteTz || 'UTC', now: cutMs, windowStartMs: cutMs, windowEndMs: cutMs + 800 * 86_400_000 }).some((o) => cutsAt(p.until, o.key));
|
|
112
|
+
if (!later) return clean;
|
|
113
|
+
const parts = rruleMap(clean);
|
|
114
|
+
delete parts.COUNT;
|
|
115
|
+
if (e.allDay) {
|
|
116
|
+
const before = addWallDays({ ...cut, dateOnly: true }, -1);
|
|
117
|
+
parts.UNTIL = `${before.y}${pad(before.m)}${pad(before.d)}`;
|
|
118
|
+
} else parts.UNTIL = utcStamp(cutMs - 1000);
|
|
119
|
+
return Object.entries(parts).map(([k, v]) => `${k}=${v}`).join(';');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// one VEVENT (and its date overrides) for an event, or for one part of it
|
|
123
|
+
// (`e` the event as stored, `part` the part as an event of its own)
|
|
124
|
+
function partVevent(e: CalEvent, part: CalEvent, uidSuffix: string, siteTz: string, opts: FeedOptions): string[] {
|
|
125
|
+
const s = parseWall(part.start);
|
|
76
126
|
if (!s) return [];
|
|
77
|
-
const en = parseWall(
|
|
127
|
+
const en = parseWall(part.end) ?? s;
|
|
78
128
|
const tz = eventTimeZone(e, { timezone: siteTz });
|
|
79
|
-
const rule = feedRrule(
|
|
80
|
-
const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
|
|
129
|
+
const rule = feedRrule(part.rrule, e.allDay, tz);
|
|
130
|
+
const lines: string[] = ['BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`];
|
|
81
131
|
if (e.allDay) {
|
|
82
132
|
lines.push(`DTSTART;VALUE=DATE:${dateValue(s)}`, `DTEND;VALUE=DATE:${dateValue(addWallDays({ ...en, dateOnly: true }, 1))}`);
|
|
83
133
|
} else if (rule) {
|
|
84
134
|
lines.push(`DTSTART;TZID=${tz}:${localValue(s)}`, `DTEND;TZID=${tz}:${localValue(en)}`);
|
|
85
135
|
} else {
|
|
86
|
-
const { startAt, endAt } = eventInstants(
|
|
136
|
+
const { startAt, endAt } = eventInstants(part, tz);
|
|
87
137
|
lines.push(`DTSTART:${utcStamp(startAt)}`, `DTEND:${utcStamp(endAt)}`);
|
|
88
138
|
}
|
|
89
139
|
if (rule) lines.push(`RRULE:${rule}`);
|
|
@@ -93,24 +143,24 @@ export function eventToVevent(e: CalEvent, siteTz: string, opts: FeedOptions): s
|
|
|
93
143
|
}
|
|
94
144
|
const url = opts.origin + opts.eventUrl(e);
|
|
95
145
|
lines.push(`SUMMARY:${escapeText(e.title || 'Untitled event')}`);
|
|
96
|
-
const loc = locationLine(
|
|
146
|
+
const loc = locationLine(part.location);
|
|
97
147
|
if (loc) lines.push(`LOCATION:${escapeText(loc)}`);
|
|
98
148
|
// RFC 5545 GEO: latitude;longitude
|
|
99
|
-
if (typeof
|
|
100
|
-
const desc = [e.excerpt,
|
|
149
|
+
if (typeof part.location.lat === 'number' && typeof part.location.lng === 'number') lines.push(`GEO:${part.location.lat.toFixed(6)};${part.location.lng.toFixed(6)}`);
|
|
150
|
+
const desc = [e.excerpt, part.location.url && part.location.url !== loc ? part.location.url : '', url].filter(Boolean).join('\n\n');
|
|
101
151
|
lines.push(`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`);
|
|
102
152
|
const cats = e.calendarIds.map((id) => opts.calendars?.get(id)?.name).filter((n): n is string => Boolean(n));
|
|
103
153
|
if (cats.length) lines.push(`CATEGORIES:${cats.map(escapeText).join(',')}`);
|
|
104
|
-
if (
|
|
154
|
+
if (part.cancelled) lines.push('STATUS:CANCELLED');
|
|
105
155
|
const stamps = [`LAST-MODIFIED:${utcStamp(e.updatedAt)}`, `SEQUENCE:${Math.max(0, Math.floor((e.updatedAt - e.createdAt) / 1000)) % 2147483647}`];
|
|
106
156
|
lines.push(...stamps, 'END:VEVENT');
|
|
107
|
-
if (rule && !
|
|
157
|
+
if (rule && !part.cancelled) lines.push(...cancelledOverrides(part, s, en, tz, siteTz, url, uidSuffix, opts, stamps), ...movedOverrides(part, tz, siteTz, url, uidSuffix, opts, stamps));
|
|
108
158
|
return lines;
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
// one override per moved date that is still part of the series (and not
|
|
112
162
|
// cancelled): the same date (RECURRENCE-ID) at its new time
|
|
113
|
-
function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
163
|
+
function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, uidSuffix: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
114
164
|
const out: string[] = [];
|
|
115
165
|
const cancelled = new Set(e.cancelledDates ?? []);
|
|
116
166
|
for (const key of Object.keys(e.movedDates ?? {}).sort()) {
|
|
@@ -123,7 +173,7 @@ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, op
|
|
|
123
173
|
const place = m.location ?? e.location;
|
|
124
174
|
const loc = locationLine(place);
|
|
125
175
|
const desc = [m.note, e.excerpt, place.url && place.url !== loc ? place.url : '', url].filter(Boolean).join('\n\n');
|
|
126
|
-
out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
176
|
+
out.push('BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
127
177
|
`SUMMARY:${escapeText(e.title || 'Untitled event')}`, ...(loc ? [`LOCATION:${escapeText(loc)}`] : []),
|
|
128
178
|
`DESCRIPTION:${escapeText(desc)}`, `URL:${url}`, ...stamps, 'END:VEVENT');
|
|
129
179
|
}
|
|
@@ -132,7 +182,7 @@ function movedOverrides(e: CalEvent, tz: string, siteTz: string, url: string, op
|
|
|
132
182
|
|
|
133
183
|
// one override per cancelled date that is still part of the series (keys
|
|
134
184
|
// left behind by a moved series are ignored)
|
|
135
|
-
function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
185
|
+
function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz: string, url: string, uidSuffix: string, opts: FeedOptions, stamps: string[]): string[] {
|
|
136
186
|
const out: string[] = [];
|
|
137
187
|
const toMin = (w: Wall) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm) / 60_000;
|
|
138
188
|
const lenMin = e.allDay ? (toMin({ ...en, hh: 0, mm: 0 }) - toMin({ ...s, hh: 0, mm: 0 })) + 1440 : Math.max(0, toMin(en) - toMin(s));
|
|
@@ -144,7 +194,7 @@ function cancelledOverrides(e: CalEvent, s: Wall, en: Wall, tz: string, siteTz:
|
|
|
144
194
|
const when = e.allDay
|
|
145
195
|
? [`RECURRENCE-ID;VALUE=DATE:${dateValue(w)}`, `DTSTART;VALUE=DATE:${dateValue(w)}`, `DTEND;VALUE=DATE:${dateValue(end)}`]
|
|
146
196
|
: [`RECURRENCE-ID;TZID=${tz}:${localValue(w)}`, `DTSTART;TZID=${tz}:${localValue(w)}`, `DTEND;TZID=${tz}:${localValue(end)}`];
|
|
147
|
-
out.push('BEGIN:VEVENT', `UID:${e.id}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
197
|
+
out.push('BEGIN:VEVENT', `UID:${e.id}${uidSuffix}@${opts.host}`, `DTSTAMP:${utcStamp(opts.now ?? Date.now())}`, ...when,
|
|
148
198
|
`SUMMARY:${escapeText(e.title || 'Untitled event')}`, `URL:${url}`, 'STATUS:CANCELLED', ...stamps, 'END:VEVENT');
|
|
149
199
|
}
|
|
150
200
|
return out;
|
|
@@ -16,7 +16,7 @@ export interface MemberStrings {
|
|
|
16
16
|
/** after an action (?sent=1 …) */
|
|
17
17
|
flash: {
|
|
18
18
|
sent: string; updated: string; changes: string; withdrawn: string; cancelled: string; restored: string;
|
|
19
|
-
moved: string; movedBack: string; moveSent: string; fromDateSent: string; dismissed: string;
|
|
19
|
+
moved: string; movedBack: string; moveSent: string; fromDateSent: string; fromDateDone: string; dismissed: string;
|
|
20
20
|
};
|
|
21
21
|
requests: string;
|
|
22
22
|
upcoming: string;
|
|
@@ -88,6 +88,9 @@ export interface MemberStrings {
|
|
|
88
88
|
changeFromRequested: string;
|
|
89
89
|
weekday: string;
|
|
90
90
|
everyWeek: string;
|
|
91
|
+
firstDate: string;
|
|
92
|
+
sameRepeat: string;
|
|
93
|
+
justThisDate: string;
|
|
91
94
|
/** the request form */
|
|
92
95
|
form: {
|
|
93
96
|
title: string; when: string; date: string; starts: string; ends: string; optional: string; allDay: string; allDayHint: string;
|
|
@@ -129,6 +132,7 @@ const EN: MemberStrings = {
|
|
|
129
132
|
movedBack: 'That date is back at its usual time. People who signed up were emailed.',
|
|
130
133
|
moveSent: 'Your move is with the team. The date stays as it is until they approve it.',
|
|
131
134
|
fromDateSent: 'Your schedule change is with the team. Nothing changes until they apply it.',
|
|
135
|
+
fromDateDone: 'Changed from that date on. People who signed up were emailed their new dates.',
|
|
132
136
|
dismissed: 'Cleared.',
|
|
133
137
|
},
|
|
134
138
|
requests: 'Requests', upcoming: 'Upcoming', pastEvents: 'Past events ({count})', showingRecent: 'Showing the {count} most recent.',
|
|
@@ -151,6 +155,7 @@ const EN: MemberStrings = {
|
|
|
151
155
|
changeFrom: 'Change from this date on…', changeFromLabel: 'Change {title} from {when} on',
|
|
152
156
|
changeFromHint: 'For a lasting change, like a new day or time. Dates before this one stay as they are. The team applies it, and people who signed up hear about their new dates.',
|
|
153
157
|
changeFromRequested: 'Change from {when} on waiting for approval', weekday: 'Day', everyWeek: 'Every week',
|
|
158
|
+
firstDate: 'First date', sameRepeat: 'Same as now, from the new day', justThisDate: 'Just that date',
|
|
154
159
|
form: {
|
|
155
160
|
title: 'Title', when: 'When', date: 'Date', starts: 'Starts', ends: 'Ends', optional: '(optional)', allDay: 'All day', allDayHint: 'No start or end time',
|
|
156
161
|
multiDay: 'This event runs over {days} days. Changing the date moves all of them.', repeats: 'Repeats', where: 'Where',
|
|
@@ -197,6 +202,7 @@ const ES: MemberStrings = {
|
|
|
197
202
|
movedBack: 'La fecha volvió a su horario de siempre. Avisamos por correo a quienes se inscribieron.',
|
|
198
203
|
moveSent: 'Tu cambio de fecha ya lo tiene el equipo. La fecha sigue igual hasta que lo aprueben.',
|
|
199
204
|
fromDateSent: 'Tu cambio de horario ya lo tiene el equipo. Nada cambia hasta que lo apliquen.',
|
|
205
|
+
fromDateDone: 'Se cambió desde esa fecha. Avisamos por correo a quienes se inscribieron.',
|
|
200
206
|
dismissed: 'Listo.',
|
|
201
207
|
},
|
|
202
208
|
requests: 'Solicitudes', upcoming: 'Próximos', pastEvents: 'Eventos pasados ({count})', showingRecent: 'Se muestran los {count} más recientes.',
|
|
@@ -219,6 +225,7 @@ const ES: MemberStrings = {
|
|
|
219
225
|
changeFrom: 'Cambiar desde esta fecha…', changeFromLabel: 'Cambiar {title} desde el {when}',
|
|
220
226
|
changeFromHint: 'Para un cambio duradero, como un nuevo día u horario. Las fechas anteriores quedan igual. El equipo lo aplica, y a quienes se inscribieron les avisamos sus nuevas fechas.',
|
|
221
227
|
changeFromRequested: 'Cambio desde el {when} en espera de aprobación', weekday: 'Día', everyWeek: 'Cada semana',
|
|
228
|
+
firstDate: 'Primera fecha', sameRepeat: 'Igual que ahora, desde el nuevo día', justThisDate: 'Solo esa fecha',
|
|
222
229
|
form: {
|
|
223
230
|
title: 'Título', when: 'Cuándo', date: 'Fecha', starts: 'Empieza', ends: 'Termina', optional: '(opcional)', allDay: 'Todo el día', allDayHint: 'Sin hora de inicio ni de fin',
|
|
224
231
|
multiDay: 'Este evento dura {days} días. Si cambias la fecha, se mueven todos.', repeats: 'Se repite', where: 'Dónde',
|
|
@@ -265,6 +272,7 @@ const FR: MemberStrings = {
|
|
|
265
272
|
movedBack: 'La date revient à son horaire habituel. Les inscrits ont été prévenus par e-mail.',
|
|
266
273
|
moveSent: 'L’équipe a reçu votre déplacement. La date reste telle quelle jusqu’à son approbation.',
|
|
267
274
|
fromDateSent: 'L’équipe a reçu votre changement d’horaire. Rien ne change avant qu’elle l’applique.',
|
|
275
|
+
fromDateDone: 'Changé à partir de cette date. Les inscrits ont été prévenus par e-mail.',
|
|
268
276
|
dismissed: 'C’est effacé.',
|
|
269
277
|
},
|
|
270
278
|
requests: 'Demandes', upcoming: 'À venir', pastEvents: 'Événements passés ({count})', showingRecent: 'Les {count} plus récents sont affichés.',
|
|
@@ -287,6 +295,7 @@ const FR: MemberStrings = {
|
|
|
287
295
|
changeFrom: 'Changer à partir de cette date…', changeFromLabel: 'Changer {title} à partir du {when}',
|
|
288
296
|
changeFromHint: 'Pour un changement durable, comme un nouveau jour ou horaire. Les dates précédentes restent telles quelles. L’équipe l’applique, et les inscrits sont prévenus de leurs nouvelles dates.',
|
|
289
297
|
changeFromRequested: 'Changement à partir du {when} en attente d’approbation', weekday: 'Jour', everyWeek: 'Chaque semaine',
|
|
298
|
+
firstDate: 'Première date', sameRepeat: 'Comme maintenant, à partir du nouveau jour', justThisDate: 'Seulement cette date',
|
|
290
299
|
form: {
|
|
291
300
|
title: 'Titre', when: 'Quand', date: 'Date', starts: 'Début', ends: 'Fin', optional: '(facultatif)', allDay: 'Toute la journée', allDayHint: 'Sans heure de début ni de fin',
|
|
292
301
|
multiDay: 'Cet événement dure {days} jours. Changer la date les déplace tous.', repeats: 'Répétition', where: 'Lieu',
|
|
@@ -333,6 +342,7 @@ const DE: MemberStrings = {
|
|
|
333
342
|
movedBack: 'Der Termin ist wieder zur gewohnten Zeit. Wer angemeldet ist, hat eine E-Mail bekommen.',
|
|
334
343
|
moveSent: 'Das Team hat deine Verlegung. Der Termin bleibt, wie er ist, bis sie freigegeben ist.',
|
|
335
344
|
fromDateSent: 'Das Team hat deine Planänderung. Es ändert sich nichts, bis sie übernommen ist.',
|
|
345
|
+
fromDateDone: 'Ab diesem Termin geändert. Angemeldete haben ihre neuen Termine per E-Mail bekommen.',
|
|
336
346
|
dismissed: 'Erledigt.',
|
|
337
347
|
},
|
|
338
348
|
requests: 'Anfragen', upcoming: 'Demnächst', pastEvents: 'Vergangene Termine ({count})', showingRecent: 'Die {count} neuesten werden gezeigt.',
|
|
@@ -355,6 +365,7 @@ const DE: MemberStrings = {
|
|
|
355
365
|
changeFrom: 'Ab diesem Termin ändern…', changeFromLabel: '{title} ab {when} ändern',
|
|
356
366
|
changeFromHint: 'Für eine dauerhafte Änderung, etwa ein neuer Tag oder eine neue Zeit. Frühere Termine bleiben, wie sie sind. Das Team übernimmt sie, und Angemeldete erfahren ihre neuen Termine.',
|
|
357
367
|
changeFromRequested: 'Änderung ab {when} wartet auf Freigabe', weekday: 'Tag', everyWeek: 'Jede Woche',
|
|
368
|
+
firstDate: 'Erster Termin', sameRepeat: 'Wie bisher, ab dem neuen Tag', justThisDate: 'Nur dieser Termin',
|
|
358
369
|
form: {
|
|
359
370
|
title: 'Titel', when: 'Wann', date: 'Datum', starts: 'Beginn', ends: 'Ende', optional: '(optional)', allDay: 'Ganztägig', allDayHint: 'Ohne Beginn- und Endzeit',
|
|
360
371
|
multiDay: 'Dieser Termin dauert {days} Tage. Ein neues Datum verschiebt alle.', repeats: 'Wiederholung', where: 'Wo',
|
|
@@ -401,6 +412,7 @@ const PT: MemberStrings = {
|
|
|
401
412
|
movedBack: 'A data voltou ao horário de sempre. Quem se inscreveu foi avisado por e-mail.',
|
|
402
413
|
moveSent: 'A equipe recebeu sua mudança de data. A data continua como está até ser aprovada.',
|
|
403
414
|
fromDateSent: 'A equipe recebeu sua mudança de horário. Nada muda até que ela seja aplicada.',
|
|
415
|
+
fromDateDone: 'Mudou a partir dessa data. Quem se inscreveu foi avisado por e-mail.',
|
|
404
416
|
dismissed: 'Pronto.',
|
|
405
417
|
},
|
|
406
418
|
requests: 'Pedidos', upcoming: 'Próximos', pastEvents: 'Eventos passados ({count})', showingRecent: 'Mostrando os {count} mais recentes.',
|
|
@@ -423,6 +435,7 @@ const PT: MemberStrings = {
|
|
|
423
435
|
changeFrom: 'Mudar a partir desta data…', changeFromLabel: 'Mudar {title} a partir de {when}',
|
|
424
436
|
changeFromHint: 'Para uma mudança duradoura, como um novo dia ou horário. As datas anteriores continuam como estão. A equipe aplica, e quem se inscreveu é avisado das novas datas.',
|
|
425
437
|
changeFromRequested: 'Mudança a partir de {when} aguardando aprovação', weekday: 'Dia', everyWeek: 'Toda semana',
|
|
438
|
+
firstDate: 'Primeira data', sameRepeat: 'Como agora, a partir do novo dia', justThisDate: 'Só essa data',
|
|
426
439
|
form: {
|
|
427
440
|
title: 'Título', when: 'Quando', date: 'Data', starts: 'Começa', ends: 'Termina', optional: '(opcional)', allDay: 'Dia inteiro', allDayHint: 'Sem hora de início ou fim',
|
|
428
441
|
multiDay: 'Este evento dura {days} dias. Mudar a data move todos eles.', repeats: 'Repete', where: 'Onde',
|
|
@@ -20,7 +20,7 @@ import type { ContentRepository } from '../repo/types';
|
|
|
20
20
|
import { EventLocation, type CalEvent, type MovedDate } from '../schema';
|
|
21
21
|
import { ApiError, coded } from '../api';
|
|
22
22
|
import { eventSideEffects, type SaveHooks } from '../content-service';
|
|
23
|
-
import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
|
|
23
|
+
import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence, partOf, partMinutes, type SeriesPart } from './events';
|
|
24
24
|
import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
|
|
25
25
|
import { kickEmail } from '../email/service';
|
|
26
26
|
import { waitUntilFrom } from '../webhooks';
|
|
@@ -103,14 +103,14 @@ const started = () => refuse(400, 'started', 'That date has already started or p
|
|
|
103
103
|
|
|
104
104
|
// The new wall times: `start` in the event's form, `end` defaulting to the
|
|
105
105
|
// date's own length (all-day: the same number of days)
|
|
106
|
-
function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
|
|
106
|
+
function newTimes(e: CalEvent, input: MoveInput, part: SeriesPart): { start: string; end: string } {
|
|
107
107
|
const s = parseWall(input.start);
|
|
108
108
|
if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
|
|
109
109
|
if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
|
|
110
110
|
let end = input.end;
|
|
111
111
|
if (!end) {
|
|
112
|
-
|
|
113
|
-
const length =
|
|
112
|
+
// the length of its part's dates (a change from a date on may have its own)
|
|
113
|
+
const length = partMinutes(part);
|
|
114
114
|
end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
|
|
115
115
|
}
|
|
116
116
|
try {
|
|
@@ -162,7 +162,9 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
|
|
|
162
162
|
const settings = await repo.getSettings();
|
|
163
163
|
const current = dateToChange(e, key, settings);
|
|
164
164
|
const prior = e.movedDates[key];
|
|
165
|
-
|
|
165
|
+
// its part of the series (CalEvent.segments): the length, rooms and place it has without a move
|
|
166
|
+
const part = partOf(e, current);
|
|
167
|
+
const times = newTimes(e, input, part);
|
|
166
168
|
|
|
167
169
|
let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
|
|
168
170
|
if (roomIds !== undefined) {
|
|
@@ -175,11 +177,11 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
|
|
|
175
177
|
if (roomIds.some((r) => !allowed.has(r))) throw refuse(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
|
|
176
178
|
}
|
|
177
179
|
// the event's own rooms aren't an override
|
|
178
|
-
if (sameRooms(roomIds,
|
|
180
|
+
if (sameRooms(roomIds, part.roomIds)) roomIds = undefined;
|
|
179
181
|
}
|
|
180
182
|
let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
|
|
181
183
|
if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
|
|
182
|
-
if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
|
|
184
|
+
if (location && JSON.stringify(location) === JSON.stringify(part.location ?? e.location)) location = undefined;
|
|
183
185
|
const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
|
|
184
186
|
|
|
185
187
|
const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
|
|
@@ -306,6 +308,8 @@ export interface DateRow {
|
|
|
306
308
|
/** "Thu, Jan 10 · 6:00 – 7:00 PM" */
|
|
307
309
|
when: string;
|
|
308
310
|
cancelled: boolean;
|
|
311
|
+
/** cancelled because every date from some date on is (a cancelled part, CalEvent.segments) */
|
|
312
|
+
partCancelled: boolean;
|
|
309
313
|
/** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
|
|
310
314
|
moved: { from: string; note: string } | null;
|
|
311
315
|
roomIds: string[];
|
|
@@ -319,19 +323,21 @@ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentR
|
|
|
319
323
|
const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
|
|
320
324
|
const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
321
325
|
const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
|
|
322
|
-
const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
|
|
323
|
-
const length = es && ee ? wallMinutesBetween(es, ee) : 60;
|
|
324
326
|
return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
|
|
325
327
|
.slice(0, limit)
|
|
326
328
|
.map((o) => {
|
|
327
329
|
const m = o.moved ? e.movedDates[o.key] : undefined;
|
|
328
330
|
const k = parseWall(o.key)!;
|
|
331
|
+
const part = partOf(e, o);
|
|
332
|
+
const length = partMinutes(part);
|
|
329
333
|
return {
|
|
330
334
|
key: o.key,
|
|
331
335
|
start: m?.start ?? o.key,
|
|
332
336
|
end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
|
|
333
337
|
when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
|
|
334
338
|
cancelled: o.cancelled,
|
|
339
|
+
// cancelled with the rest of its part (cancelFromDate): restored from the change, not one by one
|
|
340
|
+
partCancelled: part.cancelled,
|
|
335
341
|
moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
|
|
336
342
|
roomIds: o.roomIds,
|
|
337
343
|
};
|
|
@@ -110,7 +110,8 @@ export async function requestRows(repo: ContentRepository, settings: Settings, t
|
|
|
110
110
|
? { taken: true, text: `${found[0].room} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
|
|
111
111
|
: { taken: false, text: `${names} ${d.roomIds.length > 1 ? 'are' : 'is'} free then` };
|
|
112
112
|
}
|
|
113
|
-
} else if (q.kind === 'move') {
|
|
113
|
+
} else if (q.kind === 'move' || q.kind === 'from-date') {
|
|
114
|
+
// a host's date move, or change from a date on ("Thu, Jan 17, 6:00 PM → Wed, Jan 16, 5:00 PM · Weekly on Wednesday")
|
|
114
115
|
title = e?.title ?? 'A deleted event';
|
|
115
116
|
line = await dateRequestWhen(repo, q);
|
|
116
117
|
if (tab === 'pending' && e) {
|
|
@@ -429,11 +429,11 @@ export async function approveRequest(repo: ContentRepository, who: Actor | null,
|
|
|
429
429
|
if (!event) throw new ApiError(409, 'That event was deleted. Decline the request, or reply to the person.');
|
|
430
430
|
done = await decide(repo, q, answer);
|
|
431
431
|
next = `/admin/calendar/events/${event.id}${q.key ? `?move=${encodeURIComponent(q.key)}` : ''}`;
|
|
432
|
-
} else if (q.kind === 'move') {
|
|
433
|
-
// a host's date move (lib/calendar/host-requests.ts): it runs, and the host hears about it there
|
|
432
|
+
} else if (q.kind === 'move' || q.kind === 'from-date') {
|
|
433
|
+
// a host's date move or change from a date on (lib/calendar/host-requests.ts): it runs, and the host hears about it there
|
|
434
434
|
const { approveDateRequest } = await import('./host-requests');
|
|
435
|
-
const { request, moved } = await approveDateRequest(repo, who, id, hooks, seen);
|
|
436
|
-
return { request, event: moved
|
|
435
|
+
const { request, moved, changed } = await approveDateRequest(repo, who, id, hooks, seen);
|
|
436
|
+
return { request, event: (moved ?? changed)!.event, next: `/admin/calendar/events/${request.eventId}` };
|
|
437
437
|
} else {
|
|
438
438
|
throw new ApiError(400, 'Open the event to answer this request.');
|
|
439
439
|
}
|
|
@@ -464,7 +464,7 @@ async function draftFrom(repo: ContentRepository, q: RequestRecord, hooks: SaveH
|
|
|
464
464
|
export async function declineRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number, opts: { email?: boolean } = {}): Promise<RequestRecord> {
|
|
465
465
|
requireAnswerer(who);
|
|
466
466
|
const q = await pendingRequest(repo, id, seen);
|
|
467
|
-
if (q.kind === 'move') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
|
|
467
|
+
if (q.kind === 'move' || q.kind === 'from-date') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
|
|
468
468
|
if (q.kind !== 'new' && q.kind !== 'change') throw new ApiError(400, 'Open the event to answer this request.');
|
|
469
469
|
const reason = text(reply, REQUEST_LIMITS.message);
|
|
470
470
|
const done = await decide(repo, q, { state: 'declined', reviewedBy: who.id, reviewedAt: Date.now(), reply: reason });
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// Cancelling one date of a series applies at once too, without touching any
|
|
7
7
|
// staged edits.
|
|
8
8
|
import type { ContentRepository } from '../repo/types';
|
|
9
|
-
import type { CalEvent, MovedDate } from '../schema';
|
|
9
|
+
import type { CalEvent, MovedDate, SeriesSegment } from '../schema';
|
|
10
10
|
import { EventLocation, slugify } from '../schema';
|
|
11
11
|
import { defaultPlaceLocation } from '../maps/places';
|
|
12
12
|
import { ApiError } from '../api';
|
|
13
13
|
import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
|
|
14
|
-
import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween } from './events';
|
|
14
|
+
import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween, partOf, partMinutes } from './events';
|
|
15
15
|
import { moveOccurrence, moveOccurrenceBack, type MoveActor } from './moves';
|
|
16
16
|
import type { BusySlot } from '../booking/holds';
|
|
17
17
|
import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
|
|
@@ -105,6 +105,8 @@ const dayOf = (wall: string) => {
|
|
|
105
105
|
|
|
106
106
|
export type MoveUndo =
|
|
107
107
|
| { kind: 'series'; id: string; days: number; minutes?: number }
|
|
108
|
+
// this and following dates (lib/calendar/segments.ts): the segments as they were, and where the change took over
|
|
109
|
+
| { kind: 'following'; id: string; from: string; previous: SeriesSegment[] }
|
|
108
110
|
// one date moved (previous: where it was moved before, null = not moved)
|
|
109
111
|
| { kind: 'date'; id: string; key: string; previous: Pick<MovedDate, 'start' | 'end' | 'roomIds' | 'location' | 'note'> | null }
|
|
110
112
|
// undo tokens from before moved dates: the date was split off into a copy
|
|
@@ -126,10 +128,16 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
|
|
|
126
128
|
...(e.rrule ? { rrule: shiftRrule(e.rrule, dayShift, start), exdates: e.exdates.map((x) => shiftBy(x, total)) } : {}),
|
|
127
129
|
}, hooks);
|
|
128
130
|
// cancelled dates move with the series, like skipped ones; a moved date
|
|
129
|
-
// keeps its new time and follows under its new key
|
|
130
|
-
|
|
131
|
+
// keeps its new time and follows under its new key; a change from a date on
|
|
132
|
+
// (segments) moves as a whole: its change point, first date and rule
|
|
133
|
+
if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length || e.segments.length)) {
|
|
131
134
|
const moved = Object.fromEntries(Object.entries(e.movedDates).map(([k, m]) => [shiftBy(k, total), m]));
|
|
132
|
-
|
|
135
|
+
const segments = e.segments.map((g) => {
|
|
136
|
+
const gs = shiftBy(g.start, total);
|
|
137
|
+
const shift = ((a, b) => (a && b ? Math.round(wallMinutesBetween(a, b) / 1440) : days))(dayOf(g.start), dayOf(gs));
|
|
138
|
+
return { ...g, from: shiftBy(g.from, total), start: gs, end: shiftBy(g.end, total), rrule: shiftRrule(g.rrule, shift, gs) };
|
|
139
|
+
});
|
|
140
|
+
await repo.setEventDates(id, { movedDates: moved, cancelledDates: e.cancelledDates.map((x) => shiftBy(x, total)), segments });
|
|
133
141
|
await flushEventHooks(repo, hooks);
|
|
134
142
|
event = (await repo.getEvent(id))!;
|
|
135
143
|
}
|
|
@@ -149,21 +157,47 @@ export async function moveDateBy(repo: ContentRepository, actor: MoveActor, id:
|
|
|
149
157
|
}
|
|
150
158
|
if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
|
|
151
159
|
const prior = e.movedDates[key];
|
|
152
|
-
const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
|
|
153
160
|
const k = parseWall(key);
|
|
154
|
-
|
|
161
|
+
const at = k && findOccurrence({ ...e, movedDates: {} }, key, await repo.getSettings());
|
|
162
|
+
if (!k || !at) throw new ApiError(400, 'That date isn’t part of this event.');
|
|
163
|
+
// its length is its part's (a change from a date on may have its own)
|
|
155
164
|
const startWall = prior?.start ?? key;
|
|
156
|
-
const endWall = prior?.end ?? formatWall(addWallMinutes(k,
|
|
165
|
+
const endWall = prior?.end ?? formatWall(addWallMinutes(k, partMinutes(partOf(e, at))));
|
|
157
166
|
const r = await moveOccurrence(repo, actor, id, key, { start: shiftBy(startWall, minutes), end: shiftBy(endWall, minutes) }, hooks);
|
|
158
167
|
const previous = prior ? { start: prior.start, end: prior.end, roomIds: prior.roomIds, location: prior.location, note: prior.note } : null;
|
|
159
168
|
return { event: r.event, clashes: r.clashes, undo: { kind: 'date', id, key, previous } };
|
|
160
169
|
}
|
|
161
170
|
|
|
171
|
+
// Move one date of a repeating event and every date after it by `minutes`
|
|
172
|
+
// (a drag on the Schedule, "This and following dates"): a change from that
|
|
173
|
+
// date on (lib/calendar/segments.ts) whose rule follows the new weekday or
|
|
174
|
+
// day, keeping each date's length. The dates before it stay where they are.
|
|
175
|
+
export async function moveFollowingBy(repo: ContentRepository, actor: MoveActor, id: string, key: string, minutes: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo; clashes: BusySlot[] }> {
|
|
176
|
+
const e = await repo.getEvent(id);
|
|
177
|
+
if (!e) throw new ApiError(404, 'Event not found.');
|
|
178
|
+
if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
|
|
179
|
+
const { changeFromDate, changeFromDefaults } = await import('./segments');
|
|
180
|
+
const from = await changeFromDefaults(repo, id, key);
|
|
181
|
+
if (!from) throw new ApiError(400, 'That date isn’t part of this event.');
|
|
182
|
+
// a moved date was dragged from where it is now
|
|
183
|
+
const moved = e.movedDates[key];
|
|
184
|
+
const r = await changeFromDate(repo, actor, id, key, { start: shiftBy(moved?.start ?? from.start, minutes), end: shiftBy(moved?.end ?? from.end, minutes) }, hooks);
|
|
185
|
+
// undo puts the segments back, from the earliest one that changed
|
|
186
|
+
const same = (a: SeriesSegment, list: SeriesSegment[]) => list.some((x) => JSON.stringify(x) === JSON.stringify(a));
|
|
187
|
+
const changed = [...r.event.segments.filter((g) => !same(g, e.segments)), ...e.segments.filter((g) => !same(g, r.event.segments))].map((g) => g.from).sort();
|
|
188
|
+
return { event: r.event, clashes: r.clashes, undo: { kind: 'following', id, from: changed[0] ?? key, previous: e.segments } };
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
export async function undoMove(repo: ContentRepository, actor: MoveActor, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
|
|
163
192
|
if (undo.kind === 'series') {
|
|
164
193
|
await moveSeries(repo, undo.id, undo.days, hooks, undo.minutes ?? 0);
|
|
165
194
|
return;
|
|
166
195
|
}
|
|
196
|
+
if (undo.kind === 'following') {
|
|
197
|
+
const { restoreSegments } = await import('./segments');
|
|
198
|
+
await restoreSegments(repo, actor, undo.id, undo.from, undo.previous, hooks);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
167
201
|
if (undo.kind === 'date') {
|
|
168
202
|
if (undo.previous) await moveOccurrence(repo, actor, undo.id, undo.key, { ...undo.previous, roomIds: undo.previous.roomIds ?? [], location: undo.previous.location ?? null }, hooks);
|
|
169
203
|
else await moveOccurrenceBack(repo, actor, undo.id, undo.key, hooks);
|