@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +189 -2
  4. package/docs/mobile.md +3 -1
  5. package/docs/server.md +84 -1
  6. package/integration.mjs +34 -0
  7. package/mobile-template/client/AGENTS.md +1 -0
  8. package/mobile-template/client/README.md +1 -1
  9. package/mobile-template/recipes/event-request/files/app/request.tsx +305 -0
  10. package/mobile-template/recipes/event-request/files/components/DayPicker.tsx +66 -0
  11. package/mobile-template/recipes/event-request/files/components/RequestEventLink.tsx +19 -0
  12. package/mobile-template/recipes/event-request/files/components/RequestEventRow.tsx +20 -0
  13. package/mobile-template/recipes/event-request/recipe.json +21 -0
  14. package/package.json +1 -1
  15. package/src/actions/index.ts +52 -9
  16. package/src/components/EventPage.astro +6 -5
  17. package/src/components/MobileApp.tsx +2 -2
  18. package/src/components/calendar/CalendarBoard.tsx +6 -3
  19. package/src/components/editor/event/SingleDates.tsx +87 -17
  20. package/src/layouts/Admin.astro +7 -0
  21. package/src/lib/calendar/api.ts +23 -2
  22. package/src/lib/calendar/board.ts +3 -2
  23. package/src/lib/calendar/cards.ts +4 -4
  24. package/src/lib/calendar/change-migration.ts +17 -3
  25. package/src/lib/calendar/data.ts +9 -2
  26. package/src/lib/calendar/event-words.ts +8 -0
  27. package/src/lib/calendar/events.ts +128 -15
  28. package/src/lib/calendar/google/map.ts +1 -1
  29. package/src/lib/calendar/host-requests.ts +65 -10
  30. package/src/lib/calendar/ics-write.ts +65 -15
  31. package/src/lib/calendar/member-words.ts +14 -1
  32. package/src/lib/calendar/moves.ts +15 -9
  33. package/src/lib/calendar/request-admin.ts +212 -0
  34. package/src/lib/calendar/request-config.ts +8 -0
  35. package/src/lib/calendar/request-mail.ts +158 -0
  36. package/src/lib/calendar/request-page.ts +52 -0
  37. package/src/lib/calendar/request-service.ts +535 -0
  38. package/src/lib/calendar/request-store.ts +2 -2
  39. package/src/lib/calendar/request-words.ts +263 -0
  40. package/src/lib/calendar/schedule.ts +42 -8
  41. package/src/lib/calendar/segments.ts +522 -0
  42. package/src/lib/editor/schedule.ts +2 -2
  43. package/src/lib/email/service.ts +6 -1
  44. package/src/lib/legal/app-privacy.ts +2 -2
  45. package/src/lib/legal/facts.ts +4 -0
  46. package/src/lib/legal/text-en.ts +3 -0
  47. package/src/lib/legal/text-es.ts +3 -0
  48. package/src/lib/mobile/calendar.ts +1 -0
  49. package/src/lib/mobile/http.ts +2 -1
  50. package/src/lib/mobile/openapi.ts +8 -0
  51. package/src/lib/mobile/requests.ts +27 -0
  52. package/src/lib/mobile/schemas.ts +24 -6
  53. package/src/lib/mobile/site.ts +3 -0
  54. package/src/lib/push/service.ts +42 -10
  55. package/src/lib/push/types.ts +3 -1
  56. package/src/lib/repo/migrations.ts +5 -2
  57. package/src/lib/repo/sql.ts +24 -8
  58. package/src/lib/repo/types.ts +3 -1
  59. package/src/lib/scheduler.ts +3 -0
  60. package/src/lib/schema.ts +67 -2
  61. package/src/lib/sections.ts +1 -1
  62. package/src/lib/sync/catalog.ts +1 -0
  63. package/src/pages/admin/calendar/events/index.astro +11 -8
  64. package/src/pages/admin/calendar/requests.astro +298 -0
  65. package/src/pages/api/calendar/events/index.ts +2 -1
  66. package/src/pages/api/v1/requests/index.ts +6 -0
  67. package/src/pages/api/v1/requests/options.ts +7 -0
  68. package/src/pages/members/events/index.astro +84 -6
  69. package/src/pages/request/index.astro +182 -0
  70. package/src/server/index.ts +102 -4
  71. package/src/styles/editor.css +2 -0
  72. package/src/styles/site.css +29 -0
  73. 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 o of expandVEvents([v], tz, win, 0)) {
175
- const key = occurrenceKey(e, o.start, settings, win.displayTz);
176
- // a moved date comes out at its new time below
177
- if (moved.has(key)) continue;
178
- out.push({ event: e, start: o.start, end: o.end, allDay: o.allDay, days: o.days, key, cancelled: e.cancelled || cancelledDates.has(key), roomIds: e.roomHold?.resourceIds ?? [] });
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: e.cancelled,
235
- roomIds: m.roomIds ?? e.roomHold?.resourceIds ?? [],
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
- const rule = cleanRrule(e.rrule);
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 { eventTimeZone, findOccurrence, type Occurrence } from './events';
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
- const note = text(message, LIMITS.note);
104
- const waiting = (await repo.requests.list({ eventId: id, userId: who!.id, kinds: ['move'], states: ['pending'], limit: -1 })).find((r) => r.key === key);
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!.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.');
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: 'move', state: 'pending', source: 'member', userId: who!.id, requester: null,
111
- eventId: id, key, data: { ...data }, note, reply: '', reviewedBy: '', reviewedAt: 0, createdAt: now, updatedAt: now });
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: MoveResult }> {
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 !== 'move') throw coded(400, 'unavailable', 'Open the event to apply this change.');
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 s = parseWall(e.start);
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(e.end) ?? s;
127
+ const en = parseWall(part.end) ?? s;
78
128
  const tz = eventTimeZone(e, { timezone: siteTz });
79
- const rule = feedRrule(e.rrule, e.allDay, tz);
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(e, tz);
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(e.location);
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 e.location.lat === 'number' && typeof e.location.lng === 'number') lines.push(`GEO:${e.location.lat.toFixed(6)};${e.location.lng.toFixed(6)}`);
100
- const desc = [e.excerpt, e.location.url && e.location.url !== loc ? e.location.url : '', url].filter(Boolean).join('\n\n');
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 (e.cancelled) lines.push('STATUS:CANCELLED');
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 && !e.cancelled) lines.push(...cancelledOverrides(e, s, en, tz, siteTz, url, opts, stamps), ...movedOverrides(e, tz, siteTz, url, opts, stamps));
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',