@acms-subir/core 0.3.56 → 0.3.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/cli/migrations.generated.mjs +16 -4
  3. package/docs/calendar.md +116 -2
  4. package/docs/server.md +33 -1
  5. package/package.json +1 -1
  6. package/src/actions/index.ts +52 -9
  7. package/src/components/EventPage.astro +6 -5
  8. package/src/components/calendar/CalendarBoard.tsx +6 -3
  9. package/src/components/editor/event/SingleDates.tsx +75 -15
  10. package/src/lib/calendar/api.ts +23 -2
  11. package/src/lib/calendar/board.ts +3 -2
  12. package/src/lib/calendar/cards.ts +4 -4
  13. package/src/lib/calendar/change-migration.ts +17 -3
  14. package/src/lib/calendar/data.ts +9 -2
  15. package/src/lib/calendar/event-words.ts +8 -0
  16. package/src/lib/calendar/events.ts +128 -15
  17. package/src/lib/calendar/google/map.ts +1 -1
  18. package/src/lib/calendar/host-requests.ts +65 -10
  19. package/src/lib/calendar/ics-write.ts +65 -15
  20. package/src/lib/calendar/member-words.ts +14 -1
  21. package/src/lib/calendar/moves.ts +15 -9
  22. package/src/lib/calendar/request-admin.ts +2 -1
  23. package/src/lib/calendar/request-service.ts +5 -5
  24. package/src/lib/calendar/schedule.ts +42 -8
  25. package/src/lib/calendar/segments.ts +522 -0
  26. package/src/lib/editor/schedule.ts +2 -2
  27. package/src/lib/mobile/calendar.ts +1 -0
  28. package/src/lib/mobile/schemas.ts +10 -3
  29. package/src/lib/push/service.ts +42 -10
  30. package/src/lib/push/types.ts +2 -0
  31. package/src/lib/repo/migrations.ts +5 -2
  32. package/src/lib/repo/sql.ts +24 -8
  33. package/src/lib/repo/types.ts +3 -1
  34. package/src/lib/schema.ts +24 -0
  35. package/src/lib/sections.ts +1 -1
  36. package/src/lib/sync/catalog.ts +1 -0
  37. package/src/pages/admin/calendar/events/index.astro +9 -8
  38. package/src/pages/admin/calendar/requests.astro +5 -5
  39. package/src/pages/api/calendar/events/index.ts +2 -1
  40. package/src/pages/members/events/index.astro +84 -6
  41. package/src/server/index.ts +46 -3
  42. package/src/styles/editor.css +2 -0
@@ -5,7 +5,8 @@ import type { ContentRepository } from '../repo/types';
5
5
  import type { CalEvent, Calendar, Post, Settings } from '../schema';
6
6
  import { CONTENT, collectionUrl } from '../content';
7
7
  import { CALENDAR, eventUrl } from '../calendar/config';
8
- import { eventIsListed, eventTimeZone, expandEvents, findOccurrence, type Occurrence } from '../calendar/events';
8
+ import { cutsAt, eventIsListed, eventTimeZone, expandEvents, findOccurrence, partOf, type Occurrence } from '../calendar/events';
9
+ import { describeRepeat } from '../calendar/rrule';
9
10
  import { tzParts } from '../calendar/ics';
10
11
  import { I18N, stripLocale } from '../i18n';
11
12
  import { localizeDoc } from '../translations';
@@ -98,28 +99,30 @@ interface Words {
98
99
  cancelled: (d: string) => string; cancelledFrom: (d: string) => string; cancelledOn: (d: string) => string; more: (n: number) => string;
99
100
  // one date of a series moved (CalEvent.movedDates), or several: their new days
100
101
  dateMoved: (to: string, was: string) => string; datesMoved: (d: string) => string;
102
+ // the series changed from a date on (CalEvent.segments): "From Wed, Jan 16: Weekly on Wednesday, 5:00 PM"
103
+ fromDate: (d: string, what: string) => string;
101
104
  }
102
105
  const STRINGS: Record<string, Words> = {
103
106
  en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live',
104
107
  moved: (to, was) => `Now ${to} (was ${was})`, movedTo: (to, p) => `Now ${to} · ${p}`, place: (p) => `New place: ${p}`,
105
108
  cancelled: (d) => `Cancelled: ${d}`, cancelledFrom: (d) => `Cancelled: every date from ${d}`, cancelledOn: (d) => `Cancelled on ${d}`, more: (n) => `${n} more`,
106
- dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}` },
109
+ dateMoved: (to, was) => `Moved to ${to} (was ${was})`, datesMoved: (d) => `New times on ${d}`, fromDate: (d, w) => `From ${d}: ${w}` },
107
110
  es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo',
108
111
  moved: (to, was) => `Ahora ${to} (antes ${was})`, movedTo: (to, p) => `Ahora ${to} · ${p}`, place: (p) => `Nuevo lugar: ${p}`,
109
112
  cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas las fechas desde el ${d}`, cancelledOn: (d) => `Cancelado el ${d}`, more: (n) => `${n} más`,
110
- dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}` },
113
+ dateMoved: (to, was) => `Se cambia al ${to} (antes ${was})`, datesMoved: (d) => `Nuevo horario el ${d}`, fromDate: (d, w) => `Desde el ${d}: ${w}` },
111
114
  fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct',
112
115
  moved: (to, was) => `Désormais ${to} (au lieu de ${was})`, movedTo: (to, p) => `Désormais ${to} · ${p}`, place: (p) => `Nouveau lieu : ${p}`,
113
116
  cancelled: (d) => `Annulé : ${d}`, cancelledFrom: (d) => `Annulé : toutes les dates à partir du ${d}`, cancelledOn: (d) => `Annulé le ${d}`, more: (n) => `${n} autres`,
114
- dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}` },
117
+ dateMoved: (to, was) => `Déplacé au ${to} (au lieu de ${was})`, datesMoved: (d) => `Nouveaux horaires le ${d}`, fromDate: (d, w) => `À partir du ${d} : ${w}` },
115
118
  de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live',
116
119
  moved: (to, was) => `Jetzt ${to} (statt ${was})`, movedTo: (to, p) => `Jetzt ${to} · ${p}`, place: (p) => `Neuer Ort: ${p}`,
117
120
  cancelled: (d) => `Abgesagt: ${d}`, cancelledFrom: (d) => `Abgesagt: alle Termine ab ${d}`, cancelledOn: (d) => `Abgesagt am ${d}`, more: (n) => `${n} weitere`,
118
- dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}` },
121
+ dateMoved: (to, was) => `Verschoben auf ${to} (statt ${was})`, datesMoved: (d) => `Neue Zeiten am ${d}`, fromDate: (d, w) => `Ab ${d}: ${w}` },
119
122
  pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo',
120
123
  moved: (to, was) => `Agora ${to} (antes ${was})`, movedTo: (to, p) => `Agora ${to} · ${p}`, place: (p) => `Novo local: ${p}`,
121
124
  cancelled: (d) => `Cancelado: ${d}`, cancelledFrom: (d) => `Cancelado: todas as datas a partir de ${d}`, cancelledOn: (d) => `Cancelado em ${d}`, more: (n) => `mais ${n}`,
122
- dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}` },
125
+ dateMoved: (to, was) => `Mudou para ${to} (antes ${was})`, datesMoved: (d) => `Novos horários em ${d}`, fromDate: (d, w) => `A partir de ${d}: ${w}` },
123
126
  };
124
127
  export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
125
128
 
@@ -299,7 +302,8 @@ export const CHANGE_SOON = 3 * HOUR;
299
302
  export function eventSnapshot(e: CalEvent): EventSnapshot {
300
303
  return { start: e.start, end: e.end, allDay: e.allDay, timezone: e.timezone, rrule: e.rrule, exdates: [...e.exdates].sort(),
301
304
  cancelled: e.cancelled, cancelledDates: [...e.cancelledDates].sort(), location: { name: e.location.name.trim(), address: e.location.address.trim() },
302
- movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }])) };
305
+ movedDates: Object.fromEntries(Object.keys(e.movedDates ?? {}).sort().map((k) => [k, { start: e.movedDates[k].start, end: e.movedDates[k].end }])),
306
+ segments: (e.segments ?? []).map(({ from, start, end, rrule, cancelled }) => ({ from, start, end, rrule, cancelled })).sort((a, b) => (a.from < b.from ? -1 : 1)) };
303
307
  }
304
308
  function readSnapshot(raw: string | null): EventSnapshot | null {
305
309
  try { const r = EventSnapshot.safeParse(JSON.parse(raw ?? '')); return r.success ? r.data : null; } catch { return null; }
@@ -317,6 +321,10 @@ export interface EventChange {
317
321
  cancelledDates?: Slot[];
318
322
  /** single dates of a repeating event at a new time (moved, moved again or moved back), by their new time */
319
323
  movedDates?: { from: Slot; to: Slot }[];
324
+ /** the series changed from a date on (or back): its first new date, and the rule it follows from there */
325
+ fromDate?: { at: Slot; rrule: string; start: string };
326
+ /** every date from this one on cancelled (a cancelled segment) */
327
+ cancelledFrom?: Slot;
320
328
  /** when the soonest date it touches starts (a move: the old or the new time, whichever is sooner) */
321
329
  at: number;
322
330
  }
@@ -342,7 +350,24 @@ export function eventChange(was: EventSnapshot, e: CalEvent, settings: Pick<Sett
342
350
  if (!upcoming) return null;
343
351
  if (e.cancelled) return { cancelled: upcoming, at: upcoming.start };
344
352
  // the series as people knew it, with today's moved dates (those are compared one by one below)
353
+ // (its segments are today's: a change to them is told on its own, below)
345
354
  const then: CalEvent = { ...e, start: was.start, end: was.end, allDay: was.allDay, timezone: was.timezone, rrule: was.rrule, exdates: was.exdates, cancelledDates: was.cancelledDates };
355
+ // changed from a date on (or back): one notice from the first date it touches; the dates it carried over aren't news on their own
356
+ const segs = (list: EventSnapshot['segments']) => new Map(list.map((g) => [g.from, JSON.stringify(g)]));
357
+ const before = segs(was.segments), after = segs(eventSnapshot(e).segments);
358
+ const touched = [...new Set([...before.keys(), ...after.keys()])].filter((k) => before.get(k) !== after.get(k)).sort()[0];
359
+ // (when the whole series moved, its segments moved with it: that's told as a move below)
360
+ const sameSeries = was.start === e.start && was.end === e.end && was.rrule === e.rrule && was.allDay === e.allDay && was.timezone === e.timezone;
361
+ if (touched !== undefined && sameSeries) {
362
+ const first = expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
363
+ .find((o) => o.start > now && (cutsAt(touched, o.key) || (o.segment ?? '') >= touched));
364
+ if (!first) return null;
365
+ const part = partOf(e, first);
366
+ const slot = { start: first.start, allDay: first.allDay };
367
+ const place = { name: e.location.name.trim(), address: e.location.address.trim() };
368
+ const placeChange = (place.name || place.address) && (place.name !== was.location.name || place.address !== was.location.address) ? { place: { from: was.location, to: place } } : {};
369
+ return part.cancelled ? { cancelledFrom: slot, at: slot.start, ...placeChange } : { fromDate: { at: slot, rrule: part.rrule, start: part.start }, at: slot.start, ...placeChange };
370
+ }
346
371
  // a date taken out of the series (dragged to its own event) or put back isn't a move
347
372
  const exdates = [...new Set([...was.exdates, ...e.exdates])];
348
373
  const from = slots({ ...then, exdates }, settings, now).next;
@@ -418,10 +443,11 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
418
443
  const zone = (x: Slot) => (x.allDay ? siteTz : tz);
419
444
  const dates = change.cancelledDates ?? [];
420
445
  const movedDates = change.movedDates ?? [];
421
- const kind = change.cancelled ? 'cancelled' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
446
+ const kind = change.cancelled ? 'cancelled' : change.cancelledFrom ? 'cancelledFrom' : change.fromDate ? 'fromDate' : change.moved ? 'moved' : change.place ? 'place' : movedDates.length ? 'dateMoved' : 'dateCancelled';
422
447
  // one cancelled or moved date opens that date (?on= is its day in the site's zone, like calendar links)
423
448
  let on = '';
424
- const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to : null;
449
+ const one = kind === 'dateCancelled' && dates.length === 1 ? dates[0] : kind === 'dateMoved' && movedDates.length === 1 && !dates.length ? movedDates[0].to
450
+ : change.fromDate?.at ?? change.cancelledFrom ?? null;
425
451
  if (one) {
426
452
  const p = tzParts(one.start, siteTz);
427
453
  on = `?on=${p.y}-${String(p.m).padStart(2, '0')}-${String(p.d).padStart(2, '0')}`;
@@ -432,7 +458,13 @@ export function eventChangeNotice(e: CalEvent, change: EventChange, settings: Se
432
458
  const day = (x: Slot) => when(x.start, true, zone(x), locale);
433
459
  const parts: string[] = [];
434
460
  if (change.cancelled) parts.push(ev.rrule ? s.cancelledFrom(day(change.cancelled)) : s.cancelled(at(change.cancelled)));
435
- else {
461
+ else if (change.cancelledFrom) parts.push(s.cancelledFrom(day(change.cancelledFrom)));
462
+ else if (change.fromDate) {
463
+ const { at: first, rrule, start } = change.fromDate;
464
+ const rule = describeRepeat(rrule, start, undefined, locale);
465
+ parts.push(s.fromDate(day(first), first.allDay || !rule ? rule || at(first) : `${rule}, ${when(first.start, false, zone(first), locale, 'time')}`));
466
+ if (change.place) parts.push(s.place(ev.location.name.trim() || ev.location.address.trim()));
467
+ } else {
436
468
  const loc = { name: ev.location.name.trim(), address: ev.location.address.trim() };
437
469
  // a new name is enough; the same name at a new address needs both
438
470
  const place = !change.place ? '' : change.place.to.name !== change.place.from.name ? loc.name || loc.address : [loc.name, loc.address].filter(Boolean).join(', ');
@@ -40,6 +40,8 @@ export const EventSnapshot = z.object({
40
40
  cancelled: z.boolean(), cancelledDates: z.array(z.string()), location: z.object({ name: z.string(), address: z.string() }),
41
41
  // single dates at another time (key → new wall times); notices queued before moved dates have none
42
42
  movedDates: z.record(z.object({ start: z.string(), end: z.string() })).default({}),
43
+ // the series from a date on (CalEvent.segments); notices queued before segments have none
44
+ segments: z.array(z.object({ from: z.string(), start: z.string(), end: z.string(), rrule: z.string(), cancelled: z.boolean() })).default([]),
43
45
  });
44
46
  export type EventSnapshot = z.infer<typeof EventSnapshot>;
45
47
 
@@ -5,7 +5,7 @@ import { WEBHOOK_MIGRATION } from '../webhook-sql';
5
5
  import { PUSH_MIGRATION } from '../push/migration';
6
6
  import { EDITOR_MIGRATION, EDITOR_GRACE_MIGRATION } from '../editor/migration';
7
7
  import { CALENDAR_SETTINGS_MIGRATION, ROOM_HOLD_MIGRATION } from '../calendar/migration';
8
- import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION } from '../calendar/change-migration';
8
+ import { EVENT_CHANGES_MIGRATION, MOVED_DATES_MIGRATION, SEGMENTS_MIGRATION } from '../calendar/change-migration';
9
9
  import { PLACES_MIGRATION } from '../maps/migration';
10
10
  import { ROLES_MIGRATION } from '../permissions/migration';
11
11
  import { SYNC_MIGRATION } from '../sync/migration';
@@ -738,13 +738,16 @@ export const MIGRATIONS: Migration[] = [
738
738
  // two-way Google Calendar sync: the account, links, pairs, outgoing queue
739
739
  // (triggers on events) and Recent changes. Not public, so no cache trigger.
740
740
  GCAL_MIGRATION,
741
+ // a series changed from one date on (CalEvent.segments): a column on the
742
+ // row, so the triggers on events see it, like moved_dates
743
+ SEGMENTS_MIGRATION(contentVersionTriggers('events', [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'])[2]),
741
744
  ];
742
745
 
743
746
  // Current cache coverage; keep earlier migration inputs frozen for upgrade compatibility.
744
747
  export const CURRENT_CONTENT_VERSION_COLUMNS = {
745
748
  ...CONTENT_VERSION_COLUMNS,
746
749
  settings: [...CONTENT_VERSION_COLUMNS.settings, 'links_verified', 'links_avatar_shape', 'links_avatar_fit', 'links_avatar_scale', 'legal'],
747
- events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates'],
750
+ events: [...CONTENT_VERSION_COLUMNS.events, 'cancelled_dates', 'moved_dates', 'segments'],
748
751
  resources: [...CONTENT_VERSION_COLUMNS.resources, 'color', 'show_on_events'],
749
752
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS,
750
753
  };
@@ -16,7 +16,7 @@ import { LiveConfig } from '../live/types';
16
16
  import { ApiError } from '../api';
17
17
  import { EmailStore, bookingJobs, rsvpStaffJobs } from '../email/store';
18
18
  import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
19
- import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
19
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, Place, EventLocation, EventRequest, MovedDate, SeriesSegment, isInternalTag, roomColor, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
20
20
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
21
21
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
22
22
  import { cleanRrule } from '../calendar/rrule';
@@ -164,6 +164,13 @@ function parseMovedDates(raw: unknown): CalEvent['movedDates'] {
164
164
  // (push_control.quiet shares the write's transaction)
165
165
  const quietly = (write: SqlStatement, quiet?: boolean): SqlStatement[] =>
166
166
  quiet ? [{ sql: 'UPDATE push_control SET quiet=1 WHERE id=1' }, write, { sql: 'UPDATE push_control SET quiet=0 WHERE id=1' }] : [write];
167
+ // a corrupt segments list, or one bad segment, reads as "not changed from a date"
168
+ function parseSegments(raw: unknown): CalEvent['segments'] {
169
+ let value: unknown;
170
+ try { value = JSON.parse(String(raw ?? '[]')); } catch { return []; }
171
+ if (!Array.isArray(value)) return [];
172
+ return value.flatMap((entry) => { const s = SeriesSegment.safeParse(entry); return s.success ? [s.data] : []; }).sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
173
+ }
167
174
  function rowToEvent(r: Row): CalEvent {
168
175
  return CalEvent.parse({
169
176
  id: r.id, slug: r.slug, title: r.title, bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
@@ -172,6 +179,7 @@ function rowToEvent(r: Row): CalEvent {
172
179
  featured: Boolean(r.featured), tags: json(r.tags, []), template: r.template ?? '',
173
180
  start: r.starts, end: r.ends ?? '', allDay: Boolean(r.all_day), timezone: r.timezone ?? '',
174
181
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled), cancelledDates: json(r.cancelled_dates, []), movedDates: parseMovedDates(r.moved_dates),
182
+ segments: parseSegments(r.segments),
175
183
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
176
184
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}), roomHold: json(r.room_hold, {}),
177
185
  hostIds: json(r.host_ids, []), request: parseRequest(r.request),
@@ -870,8 +878,8 @@ export class SqlRepository implements ContentRepository {
870
878
  await this.driver.run(
871
879
  `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
872
880
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
873
- published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates)
874
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
881
+ published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, translations, moved_dates, segments)
882
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
875
883
  [
876
884
  id, data.slug, data.title, data.bodyJson ?? '', data.bodyHtml ?? '', data.excerpt ?? '', data.cover ?? '', data.coverAlt ?? '',
877
885
  data.seoTitle ?? '', data.seoDesc ?? '', data.status ?? 'draft', data.featured ? 1 : 0, JSON.stringify(data.tags ?? []), data.template ?? '',
@@ -881,7 +889,7 @@ export class SqlRepository implements ContentRepository {
881
889
  data.publishedAt ?? (data.status === 'published' ? now : null), data.scheduledAt ?? null, now, now,
882
890
  JSON.stringify(data.rsvp ?? {}), JSON.stringify(data.roomHold ?? {}), JSON.stringify(data.cancelledDates ?? []),
883
891
  JSON.stringify(data.hostIds ?? []), data.request ? JSON.stringify(data.request) : null, JSON.stringify(data.translations ?? {}),
884
- JSON.stringify(data.movedDates ?? {}),
892
+ JSON.stringify(data.movedDates ?? {}), JSON.stringify(data.segments ?? []),
885
893
  ]
886
894
  );
887
895
  await this.syncEventIndex(id);
@@ -907,7 +915,7 @@ export class SqlRepository implements ContentRepository {
907
915
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
908
916
  }
909
917
  const jsonCols: Record<string, string> = {
910
- tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
918
+ tags: 'tags', exdates: 'exdates', cancelledDates: 'cancelled_dates', movedDates: 'moved_dates', segments: 'segments', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations', roomHold: 'room_hold',
911
919
  };
912
920
  for (const [k, col] of Object.entries(jsonCols)) {
913
921
  const v = (data as Record<string, unknown>)[k];
@@ -986,6 +994,14 @@ export class SqlRepository implements ContentRepository {
986
994
  : { sql: 'UPDATE events SET moved_dates = ?, updated_at = ? WHERE id = ?', args: [JSON.stringify(moved), Date.now(), id] };
987
995
  await this.driver.batch(quietly(write, opts.quiet));
988
996
  }
997
+ async setEventDates(id: string, dates: Partial<Pick<CalEvent, 'segments' | 'movedDates' | 'cancelledDates' | 'exdates'>>, opts: { quiet?: boolean } = {}) {
998
+ await this.ready;
999
+ const cols: [keyof typeof dates, string][] = [['segments', 'segments'], ['movedDates', 'moved_dates'], ['cancelledDates', 'cancelled_dates'], ['exdates', 'exdates']];
1000
+ const set = cols.filter(([k]) => dates[k] !== undefined);
1001
+ if (!set.length) return;
1002
+ const write = { sql: `UPDATE events SET ${set.map(([, c]) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`, args: [...set.map(([k]) => JSON.stringify(dates[k])), Date.now(), id] };
1003
+ await this.driver.batch(quietly(write, opts.quiet));
1004
+ }
989
1005
  async cancelOccurrenceRsvps(id: string, start: number) {
990
1006
  await this.ready;
991
1007
  // the mutation id limits the "cancelled" emails to exactly these RSVPs (as deleteEvent does)
@@ -1939,15 +1955,15 @@ export class SqlRepository implements ContentRepository {
1939
1955
  ...(content.events ?? []).map((e) => ({
1940
1956
  sql: `INSERT INTO events (id, slug, title, body_json, body_html, excerpt, cover, cover_alt, seo_title, seo_desc, status, featured, tags, template,
1941
1957
  starts, ends, all_day, timezone, rrule, exdates, cancelled, location, cta, calendar_ids, fields, source_uid, start_at, end_at,
1942
- draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates)
1943
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1958
+ draft, published_at, scheduled_at, created_at, updated_at, rsvp, room_hold, cancelled_dates, host_ids, request, moved_dates, segments)
1959
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1944
1960
  args: [
1945
1961
  e.id, e.slug, e.title, e.bodyJson, e.bodyHtml, e.excerpt, e.cover, e.coverAlt, e.seoTitle, e.seoDesc, e.status, e.featured ? 1 : 0,
1946
1962
  JSON.stringify(e.tags), e.template, e.start, e.end, e.allDay ? 1 : 0, e.timezone, e.rrule, JSON.stringify(e.exdates),
1947
1963
  e.cancelled ? 1 : 0, JSON.stringify(e.location), JSON.stringify(e.cta), JSON.stringify(e.calendarIds), JSON.stringify(e.fields),
1948
1964
  e.sourceUid, e.startAt, e.endAt, e.draft ? JSON.stringify(e.draft) : null, e.publishedAt, e.scheduledAt, e.createdAt, e.updatedAt,
1949
1965
  JSON.stringify(e.rsvp ?? {}), JSON.stringify(e.roomHold ?? {}), JSON.stringify(e.cancelledDates ?? []),
1950
- JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}),
1966
+ JSON.stringify(e.hostIds ?? []), e.request ? JSON.stringify(e.request) : null, JSON.stringify(e.movedDates ?? {}), JSON.stringify(e.segments ?? []),
1951
1967
  ],
1952
1968
  })),
1953
1969
  // announcements (a backup made before them leaves the current ones). Restored
@@ -51,7 +51,7 @@ export type EventUpdate = Partial<
51
51
  CalEvent,
52
52
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
53
53
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
54
- | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
54
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates' | 'segments'
55
55
  | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
56
56
  | 'publishedAt' | 'scheduledAt'
57
57
  >
@@ -179,6 +179,8 @@ export interface ContentRepository {
179
179
  setEventCancelledDates(id: string, dates: string[], opts?: { quiet?: boolean }): Promise<void>;
180
180
  /** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
181
181
  setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
182
+ /** replace an event's rule segments, moved, cancelled or skipped dates together in one write (lib/calendar/segments.ts), keeping any staged draft; quiet as above */
183
+ setEventDates(id: string, dates: Partial<Pick<CalEvent, 'segments' | 'movedDates' | 'cancelledDates' | 'exdates'>>, opts?: { quiet?: boolean }): Promise<void>;
182
184
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
183
185
  cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
184
186
  /**
package/src/lib/schema.ts CHANGED
@@ -375,6 +375,28 @@ export const MovedDate = z.object({
375
375
  export type MovedDate = z.infer<typeof MovedDate>;
376
376
  export const MovedDates = z.record(z.string(), MovedDate).default({});
377
377
 
378
+ // A repeating event changed from one date on (CalEvent.segments, C4): the
379
+ // rule before it stops at `from` (an occurrence key of that rule: its dates
380
+ // from there on are gone) and this one takes over with its own first date
381
+ // (`start`/`end`, wall-clock like the event's, and possibly before `from`),
382
+ // rule, rooms and place. It stays one event: same page, hosts and RSVPs.
383
+ // `cancelled`: every date from `from` on is cancelled (struck through,
384
+ // restorable). note is public; by/at as for MovedDate. lib/calendar/segments.ts
385
+ // writes these; they're sorted by `from`.
386
+ export const SeriesSegment = z.object({
387
+ from: z.string(),
388
+ start: z.string(),
389
+ end: z.string(),
390
+ rrule: z.string().default(''),
391
+ roomIds: z.array(z.string()).optional(),
392
+ location: EventLocation.optional(),
393
+ cancelled: z.boolean().default(false),
394
+ note: z.string().max(500).default(''),
395
+ by: z.string().default(''),
396
+ at: z.number().default(0),
397
+ });
398
+ export type SeriesSegment = z.infer<typeof SeriesSegment>;
399
+
378
400
  // A member's event request (lib/calendar/requests.ts): a new event waiting
379
401
  // for approval (kind 'new', the event stays a draft), or changes to a live
380
402
  // event staged in its `draft` (kind 'changes'). Staff approve or decline;
@@ -477,6 +499,8 @@ export const CalEvent = z.object({
477
499
  // single dates at another time (MovedDate above); a date is moved or
478
500
  // cancelled, never both. Keys that no longer match an occurrence are ignored.
479
501
  movedDates: MovedDates,
502
+ // the series changed from a date on (SeriesSegment above); only used while it repeats
503
+ segments: z.array(SeriesSegment).default([]),
480
504
  location: EventLocation.default({}),
481
505
  cta: EventCta.default({}),
482
506
  calendarIds: z.array(z.string()).default([]),
@@ -135,7 +135,7 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
135
135
  const ctaHref = safeUrl(e.cta.href);
136
136
  return {
137
137
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
138
- location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
138
+ location: locationLine(o.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
139
139
  allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null,
140
140
  rooms: (o.rooms ?? []).map((r) => ({ slug: r.slug, name: r.name, color: r.color || null })), when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
141
141
  cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
@@ -131,6 +131,7 @@ const jsonFields = new Set([
131
131
  "exdates",
132
132
  "cancelledDates",
133
133
  "movedDates",
134
+ "segments",
134
135
  "amenities",
135
136
  "hours",
136
137
  "overrides",
@@ -8,7 +8,7 @@ import ContentList, { type ContentItem } from '../../../../components/ContentLis
8
8
  import { getRepo } from '../../../../lib/repo';
9
9
  import { slugify, type Status } from '../../../../lib/schema';
10
10
  import { CALENDAR, eventUrl } from '../../../../lib/calendar/config';
11
- import { featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
11
+ import { describeSeries, featuredOccurrence, formatOccurrence, locationLine, safeColor } from '../../../../lib/calendar/events';
12
12
  import { defaultPlaceLocation } from '../../../../lib/maps/places';
13
13
  import { describeRepeat } from '../../../../lib/calendar/rrule';
14
14
  import { expandVEvents, loadFeed, tzParts } from '../../../../lib/calendar/ics';
@@ -97,10 +97,11 @@ if (Astro.request.method === 'POST') {
97
97
  try {
98
98
  // a host's date move (host-requests.ts): `id` is the request's
99
99
  if (op === 'approve-date') {
100
- const { moved } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
101
- const n = moved.clashes.length;
100
+ const { moved, changed } = await approveDateRequest(repo, who, id, hooks, Number(form.get('at')) || undefined);
101
+ const done = (moved ?? changed)!;
102
+ const n = done.clashes.length;
102
103
  const shared = n ? ', and it shares a room with ' + n + ' other booking' + (n === 1 ? '' : 's') + ' at that time' : '';
103
- return toRequests('Approved. “' + moved.event.title + '” has the date moved' + shared + '.');
104
+ return toRequests('Approved. “' + done.event.title + '” ' + (moved ? 'has the date moved' : 'is changed from that date on') + shared + '.');
104
105
  }
105
106
  if (op === 'decline-date') {
106
107
  await declineDateRequest(repo, who, id, String(form.get('reply') || ''), hooks, Number(form.get('at')) || undefined);
@@ -233,7 +234,7 @@ const selected = rows
233
234
  const items: ContentItem[] = selected.map(({ e, o }) => {
234
235
  const f = o ? formatOccurrence(o, tz, locale) : null;
235
236
  const cals = e.calendarIds.map((id) => calById.get(id)?.name).filter(Boolean);
236
- const repeats = describeRepeat(e.rrule, e.start);
237
+ const repeats = describeSeries(e, settings, now);
237
238
  return {
238
239
  id: e.id,
239
240
  href: `/admin/calendar/events/${e.id}`,
@@ -307,8 +308,8 @@ const dateRows = scope !== 'requests' ? [] : await Promise.all(waitingDates
307
308
  const clash = clashes.length ? `${[...new Set(clashes.map((c) => roomNames.get(c.resourceId) ?? 'A room'))].join(', ')} also held then by ${[...new Set(clashes.map((c) => c.title || 'something else'))].slice(0, 3).join(', ')}` : '';
308
309
  return {
309
310
  id: r.id, href: `/admin/calendar/events/${e.id}`, title: e.title || 'Untitled event', at: r.updatedAt, host,
310
- ...(await dateRequestTimes(repo, r)), back: Boolean(d.back),
311
- line: `${d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
311
+ ...(await dateRequestTimes(repo, r)), back: Boolean(d.back), label: r.kind === 'from-date' ? 'From this date on' : d.back ? 'Move back' : 'Date move',
312
+ line: `${r.kind === 'from-date' ? 'Change from this date on' : d.back ? 'Move back' : 'Date move'} from ${host} · ${ago(r.createdAt)}`, note: r.note, publicNote: d.note, clash,
312
313
  };
313
314
  }));
314
315
 
@@ -511,7 +512,7 @@ const subBits = [
511
512
  {r.publicNote && <small>Note on the date: {r.publicNote}</small>}
512
513
  {r.clash && <small class="ev-req-clash">{r.clash}</small>}
513
514
  </a>
514
- <span class="bk-st is-pending">{r.back ? 'Move back' : 'Date move'}</span>
515
+ <span class="bk-st is-pending">{r.label}</span>
515
516
  <div class="bk-acts">
516
517
  <a class="btn sm ghost" href={r.href}>Open</a>
517
518
  {canApprove && (
@@ -139,7 +139,7 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
139
139
  {tab === 'pending' && canAnswer && (
140
140
  <div class="rq-acts">
141
141
  {(r.kind === 'new' || r.kind === 'change') && <button class="btn sm primary" type="button" data-modal={'approve-' + r.dom}>Approve…</button>}
142
- {r.kind === 'move' && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
142
+ {(r.kind === 'move' || r.kind === 'from-date') && <form method="post"><input type="hidden" name="op" value="approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>}
143
143
  {(r.kind === 'move' || r.kind === 'from-date') && r.href && <a class="btn sm ghost" href={r.href}>Open the event</a>}
144
144
  {r.kind === 'member-new' || r.kind === 'member-changes' ? (
145
145
  <>
@@ -147,7 +147,7 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
147
147
  <form method="post"><input type="hidden" name="op" value="member-approve" /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} /><button class="btn sm primary" type="submit">Approve</button></form>
148
148
  </>
149
149
  ) : r.canReply && r.email && <button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button>}
150
- {r.kind !== 'from-date' && <button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>}
150
+ <button class="btn sm ghost" type="button" data-modal={'decline-' + r.dom}>Decline…</button>
151
151
  </div>
152
152
  )}
153
153
  {tab !== 'pending' && r.canReply && r.email && canAnswer && <div class="rq-acts"><button class="btn sm ghost" type="button" data-modal={'reply-' + r.dom}>Reply</button></div>}
@@ -189,12 +189,12 @@ const empty = { pending: 'Nothing waiting. Requests from the app, the website an
189
189
  </form>
190
190
  </Modal>
191
191
  )}
192
- {tab === 'pending' && r.kind !== 'from-date' && (
192
+ {tab === 'pending' && (
193
193
  <Modal id={'decline-' + r.dom} title="Decline this request">
194
194
  <form method="post">
195
195
  <input type="hidden" name="op" value={r.kind === 'member-new' || r.kind === 'member-changes' ? 'member-decline' : 'decline'} /><input type="hidden" name="id" value={r.id} /><input type="hidden" name="at" value={r.at} />
196
- <p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move'
197
- ? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : '.')
196
+ <p class="hint">{r.kind === 'member-new' || r.kind === 'member-changes' || r.kind === 'move' || r.kind === 'from-date'
197
+ ? 'They’re emailed that it can’t go ahead' + (r.kind === 'member-new' ? '; the draft stays for them to change and send again.' : r.kind === 'move' ? '; the date stays as it is.' : r.kind === 'from-date' ? '; the schedule stays as it is.' : '.')
198
198
  : 'It moves to Declined, and is deleted with the rest after the time you keep requests.'}</p>
199
199
  <label class="field"><span>Reason <em class="hint">optional</em></span><textarea name="reason" rows="3" maxlength="2000" placeholder="The Sanctuary is booked that weekend."></textarea></label>
200
200
  {(r.kind === 'new' || r.kind === 'change') && <label class="check-row"><input type="hidden" name="email-offered" value="1" /><input type="checkbox" name="email" value="1" checked /> Email them, in their language</label>}
@@ -106,8 +106,9 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
106
106
  from: { start: o.allDay ? isoWithOffset(o.moved.from.start, etz).slice(0, 10) : isoWithOffset(o.moved.from.start, etz), end: o.allDay ? isoWithOffset(o.moved.from.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.moved.from.end, etz) },
107
107
  note: o.moved.note, location: o.moved.location ?? null,
108
108
  } : null,
109
- // that date's rooms
109
+ // that date's rooms, and its own place (moved there, or from its segment; null = the event's)
110
110
  rooms: roomsToJson(o.roomIds, resources, full),
111
+ location: o.location ?? null,
111
112
  event: toJson(o.event.id),
112
113
  };
113
114
  }),
@@ -5,9 +5,11 @@ import { currentUser } from '../../../server';
5
5
  import { coded } from '../../../lib/api';
6
6
  import { readForm } from '../../../lib/forms/http';
7
7
  import { MEMBERS, memberEventsPath, hostedEvents, withdrawEventRequest, dismissEventRequest, setHostedOccurrence } from '../../../lib/calendar/requests';
8
- import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, withdrawDateRequest, type MoveRequestData } from '../../../lib/calendar/host-requests';
8
+ import { dismissDateRequest, moveHostedDate, moveHostedDateBack, myDateRequests, requestDateMove, requestFromDate, withdrawDateRequest, type FromDateRequestData, type MoveRequestData } from '../../../lib/calendar/host-requests';
9
+ import { changeFromDate, changeFromDefaults, type ChangeFromInput } from '../../../lib/calendar/segments';
10
+ import { rruleMap, WEEKDAY_CODES, weekdayOf } from '../../../lib/calendar/rrule';
9
11
  import { eventUrl } from '../../../lib/calendar/config';
10
- import { eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
12
+ import { describeSeries, eventIsPublic, eventRoomIds, eventTimeZone, expandEvents, featuredOccurrence, findOccurrence, formatOccurrence, type Occurrence } from '../../../lib/calendar/events';
11
13
  import { tzParts } from '../../../lib/calendar/ics';
12
14
  import { describeRepeat } from '../../../lib/calendar/rrule';
13
15
  import { requestLabel } from '../../../lib/calendar/member-form';
@@ -57,6 +59,25 @@ function nextDay(day: string): string {
57
59
  return d.toISOString().slice(0, 10);
58
60
  }
59
61
 
62
+ /**
63
+ * The "Change from this date on" form → ChangeFromInput: the first date and
64
+ * times as for a move, and a repeat choice worded around that first date
65
+ * ('same' keeps the rule in effect, moved to its weekday; the others end as
66
+ * that rule does).
67
+ */
68
+ async function fromDateInput(form: FormData, e: CalEvent | null, key: string): Promise<ChangeFromInput> {
69
+ const base = moveInput(form, e);
70
+ const choice = String(form.get('repeat') ?? 'same');
71
+ if (choice === 'same' || !e) return base;
72
+ const date = String(form.get('date') ?? '').trim();
73
+ const wd = weekdayOf(date);
74
+ const code = WEEKDAY_CODES[wd.dow];
75
+ const kept = rruleMap((await changeFromDefaults(repo, e.id, key))?.rrule ?? '');
76
+ const ends = kept.COUNT ? `;COUNT=${kept.COUNT}` : kept.UNTIL ? `;UNTIL=${kept.UNTIL}` : '';
77
+ const rule = { weekly: `FREQ=WEEKLY;BYDAY=${code}`, biweekly: `FREQ=WEEKLY;INTERVAL=2;BYDAY=${code}`, monthly: `FREQ=MONTHLY;BYDAY=${wd.nth >= 5 ? -1 : wd.nth}${code}`, daily: 'FREQ=DAILY' }[choice];
78
+ return { ...base, rrule: choice === 'once' ? '' : rule ? rule + ends : undefined };
79
+ }
80
+
60
81
  let error = '';
61
82
  // a move refused because a room is taken: shown under its date, with "Send as a request"
62
83
  let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
@@ -108,6 +129,16 @@ if (Astro.request.method === 'POST') {
108
129
  clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
109
130
  Astro.response.status = 409;
110
131
  }
132
+ } else if (op === 'from-date') {
133
+ // staff who host apply it; other hosts send it to them
134
+ posted = { id, key, form };
135
+ const input = await fromDateInput(form, await repo.getEvent(id), key);
136
+ if (user.can('content.edit')) {
137
+ await changeFromDate(repo, user, id, key, input, hooks);
138
+ return redirectTo(`${home}?fromDateDone=1`);
139
+ }
140
+ await requestFromDate(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
141
+ return redirectTo(`${home}?fromDateSent=1`);
111
142
  } else if (op === 'withdraw-request') {
112
143
  await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
113
144
  return redirectTo(`${home}?withdrawn=1`);
@@ -122,7 +153,7 @@ if (Astro.request.method === 'POST') {
122
153
  }
123
154
  }
124
155
 
125
- const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent'] as const).map((k) => [k, w.flash[k]]);
156
+ const FLASH: [keyof typeof w.flash, string][] = (['sent', 'updated', 'changes', 'withdrawn', 'cancelled', 'restored', 'moved', 'movedBack', 'moveSent', 'fromDateSent', 'fromDateDone'] as const).map((k) => [k, w.flash[k]]);
126
157
  const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
127
158
 
128
159
  const DAY = 86_400_000;
@@ -147,7 +178,8 @@ const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>
147
178
  };
148
179
  const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
149
180
  const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
150
- const repeats = (e: CalEvent) => describeRepeat(e.rrule, e.start, undefined, lang);
181
+ // the rule in effect, and a change from a date on still to come
182
+ const repeats = (e: CalEvent) => describeSeries(e, settings, now, lang, undefined, dateLocale);
151
183
  const pad = (n: number) => String(n).padStart(2, '0');
152
184
  /** an instant as the move form's date and time, in the event's zone */
153
185
  const wallParts = (e: CalEvent, ms: number) => {
@@ -169,12 +201,21 @@ const requestTarget = (e: CalEvent, r: RequestRecord) => {
169
201
  // …worded for its card: "Thu, Jan 10 · 6:00 – 7:00 PM → Wed, Jan 9 · 5:00 – 6:00 PM" while it waits, else the date as it is
170
202
  const requestWhen = (r: RequestRecord) => {
171
203
  const e = byId.get(r.eventId);
204
+ if (e && r.kind === 'from-date') {
205
+ // the date it changes from (while it's still there), then the new first date and its repeat
206
+ const f = r.data as FromDateRequestData;
207
+ const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
208
+ const from = r.state === 'pending' ? findOccurrence({ ...e, movedDates: {} }, r.key, settings) : null;
209
+ const to = [first ? when(first) : '', describeRepeat(f.rrule, f.start, undefined, lang)].filter(Boolean).join(' · ');
210
+ return from ? `${when(from)} → ${to}` : to;
211
+ }
172
212
  const at = e && findOccurrence(e, r.key, settings);
173
213
  if (!e || !at) return '';
174
214
  const to = r.state === 'pending' ? requestTarget(e, r) : null;
175
215
  return to ? `${when(at)} → ${when(to)}` : when(at);
176
216
  };
177
217
  const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
218
+ const waitingFrom = new Set(dateRequests.filter((r) => r.kind === 'from-date' && r.state === 'pending').map((r) => `${r.eventId}|${r.key}`));
178
219
 
179
220
  // requests: waiting ones first, newest first; shown as they'd be once approved
180
221
  const requests = [
@@ -294,13 +335,16 @@ function moveValues(e: CalEvent, o: Occurrence) {
294
335
  <ul class="pc-mem-dates">
295
336
  {dates.map((o) => {
296
337
  const off = e.cancelledDates.includes(o.key);
297
- const open = e.rrule && !e.cancelled && o.start > now;
338
+ // a date cancelled with every date after it (a cancelled part) is the team's to restore
339
+ const open = e.rrule && !e.cancelled && o.start > now && (!o.cancelled || off);
298
340
  const waiting = waitingMove.get(`${e.id}|${o.key}`);
299
341
  const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
300
342
  const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
301
343
  const target = waiting && requestTarget(e, waiting);
302
344
  const v = moveValues(e, o);
303
- const reopen = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
345
+ const failed = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
346
+ const reopen = failed && posted!.form.get('op') !== 'from-date';
347
+ const reopenFrom = failed && posted!.form.get('op') === 'from-date';
304
348
  return (
305
349
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
306
350
  <span>
@@ -308,6 +352,7 @@ function moveValues(e: CalEvent, o: Occurrence) {
308
352
  {o.cancelled && <em>{w.cancelledDate}</em>}
309
353
  {o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>{fill(w.movedFrom, { when: when({ ...o, start: o.moved.from.start, end: o.moved.from.end }) })}</em>}
310
354
  {waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
355
+ {waitingFrom.has(`${e.id}|${o.key}`) && <em class="is-pending">{fill(w.changeFromRequested, { when: day(o.start) })}</em>}
311
356
  </span>
312
357
  {open && (
313
358
  <span class="pc-mem-date-acts">
@@ -377,6 +422,39 @@ function moveValues(e: CalEvent, o: Occurrence) {
377
422
  </form>
378
423
  </details>
379
424
  )}
425
+ {open && !off && !clashHere && (
426
+ <details class="pc-mem-move" open={reopenFrom}>
427
+ <summary class="pc-mem-btn is-small" aria-label={fill(w.changeFromLabel, { title: ev.title, when: when(o) })}>{w.changeFrom}</summary>
428
+ <form method="post">
429
+ <input type="hidden" name="op" value="from-date" />
430
+ <input type="hidden" name="id" value={e.id} />
431
+ <input type="hidden" name="key" value={o.key} />
432
+ <p class="pc-mem-hint">{w.changeFromHint}</p>
433
+ <div class="pc-mem-row">
434
+ <label class="pc-bk-field"><span>{w.firstDate}</span><input type="date" name="date" required value={reopenFrom ? v.date : wallParts(e, o.start).date} /></label>
435
+ {!e.allDay && <label class="pc-bk-field"><span>{w.starts}</span><input type="time" name="startTime" required value={reopenFrom ? v.startTime : wallParts(e, o.start).time} /></label>}
436
+ {!e.allDay && <label class="pc-bk-field"><span>{w.ends}</span><input type="time" name="endTime" value={reopenFrom ? v.endTime : wallParts(e, o.end).time} /></label>}
437
+ </div>
438
+ <label class="pc-bk-field"><span>{w.form.repeats}</span>
439
+ <select name="repeat">
440
+ {([['same', w.sameRepeat], ['weekly', w.form.repeat.weekly], ['biweekly', w.form.repeat.biweekly], ['monthly', w.form.repeat.monthly], ['daily', w.form.repeat.daily], ['once', w.justThisDate]] as const)
441
+ .map(([value, label]) => <option value={value} selected={reopenFrom && posted!.form.get('repeat') === value}>{label}</option>)}
442
+ </select>
443
+ </label>
444
+ {rooms.length > 0 && (
445
+ <fieldset>
446
+ <legend>{w.rooms}</legend>
447
+ <input type="hidden" name="rooms" value="1" />
448
+ {rooms.map((r) => <label class="pc-bk-check"><input type="checkbox" name="roomIds" value={r.id} checked={(reopenFrom ? v.roomIds : o.roomIds).includes(r.id)} /> <span>{r.name}</span></label>)}
449
+ <p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
450
+ </fieldset>
451
+ )}
452
+ <label class="pc-bk-field"><span>{w.publicNote} <em>{w.form.optional}</em></span><input name="note" maxlength="500" value={reopenFrom ? v.note : ''} /><small>{w.publicNoteHint}</small></label>
453
+ {!user.can('content.edit') && <label class="pc-bk-field"><span>{w.messageForTeam} <em>{w.form.optional}</em></span><textarea name="message" rows="2" maxlength="1000">{reopenFrom ? v.message : ''}</textarea></label>}
454
+ <div class="pc-mem-actions"><button class="pc-mem-btn is-small is-primary" type="submit">{user.can('content.edit') ? w.changeFrom.replace(/…$/, '') : w.sendForApproval}</button></div>
455
+ </form>
456
+ </details>
457
+ )}
380
458
  </li>
381
459
  );
382
460
  })}