@acms-subir/core 0.3.55 → 0.3.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/cli/migrations.generated.mjs +16 -4
- package/docs/calendar.md +189 -2
- package/docs/mobile.md +3 -1
- package/docs/server.md +84 -1
- package/integration.mjs +34 -0
- package/mobile-template/client/AGENTS.md +1 -0
- package/mobile-template/client/README.md +1 -1
- package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
- package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
- package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
- package/mobile-template/recipes/event-request/recipe.json +21 -0
- package/package.json +1 -1
- package/src/actions/index.ts +52 -9
- package/src/components/EventPage.astro +6 -5
- package/src/components/MobileApp.tsx +2 -2
- package/src/components/calendar/CalendarBoard.tsx +6 -3
- package/src/components/editor/event/SingleDates.tsx +87 -17
- package/src/layouts/Admin.astro +7 -0
- 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 +212 -0
- package/src/lib/calendar/request-config.ts +8 -0
- package/src/lib/calendar/request-mail.ts +158 -0
- package/src/lib/calendar/request-page.ts +52 -0
- package/src/lib/calendar/request-service.ts +535 -0
- package/src/lib/calendar/request-store.ts +2 -2
- package/src/lib/calendar/request-words.ts +263 -0
- 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/email/service.ts +6 -1
- package/src/lib/legal/app-privacy.ts +2 -2
- package/src/lib/legal/facts.ts +4 -0
- package/src/lib/legal/text-en.ts +3 -0
- package/src/lib/legal/text-es.ts +3 -0
- package/src/lib/mobile/calendar.ts +1 -0
- package/src/lib/mobile/http.ts +2 -1
- package/src/lib/mobile/openapi.ts +8 -0
- package/src/lib/mobile/requests.ts +27 -0
- package/src/lib/mobile/schemas.ts +24 -6
- package/src/lib/mobile/site.ts +3 -0
- package/src/lib/push/service.ts +42 -10
- package/src/lib/push/types.ts +3 -1
- 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/scheduler.ts +3 -0
- package/src/lib/schema.ts +67 -2
- package/src/lib/sections.ts +1 -1
- package/src/lib/sync/catalog.ts +1 -0
- package/src/pages/admin/calendar/events/index.astro +11 -8
- package/src/pages/admin/calendar/requests.astro +298 -0
- package/src/pages/api/calendar/events/index.ts +2 -1
- package/src/pages/api/v1/requests/index.ts +6 -0
- package/src/pages/api/v1/requests/options.ts +7 -0
- package/src/pages/members/events/index.astro +84 -6
- package/src/pages/request/index.astro +182 -0
- package/src/server/index.ts +102 -4
- package/src/styles/editor.css +2 -0
- package/src/styles/site.css +29 -0
- package/src/virtual.d.ts +1 -0
|
@@ -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) };
|
|
@@ -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',
|