@acms-subir/core 0.3.47 → 0.3.48

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 (44) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/cli/migrations.generated.mjs +42 -23
  3. package/docs/calendar.md +80 -0
  4. package/docs/server.md +28 -3
  5. package/docs/theme-authoring.md +1 -1
  6. package/package.json +1 -1
  7. package/src/actions/index.ts +76 -11
  8. package/src/components/EventPage.astro +18 -12
  9. package/src/components/calendar/CalendarBoard.tsx +43 -19
  10. package/src/components/editor/event/EventSheet.tsx +2 -0
  11. package/src/components/editor/event/SingleDates.tsx +123 -0
  12. package/src/lib/booking/holds.ts +7 -7
  13. package/src/lib/calendar/api.ts +11 -1
  14. package/src/lib/calendar/board.ts +7 -3
  15. package/src/lib/calendar/cards.ts +5 -5
  16. package/src/lib/calendar/change-migration.ts +40 -15
  17. package/src/lib/calendar/data.ts +9 -3
  18. package/src/lib/calendar/event-words.ts +9 -0
  19. package/src/lib/calendar/events.ts +94 -9
  20. package/src/lib/calendar/ics-write.ts +25 -2
  21. package/src/lib/calendar/moves.ts +291 -0
  22. package/src/lib/calendar/render.ts +10 -6
  23. package/src/lib/calendar/schedule.ts +73 -61
  24. package/src/lib/email/booking-words.ts +7 -1
  25. package/src/lib/email/service.ts +6 -1
  26. package/src/lib/email/templates.ts +6 -2
  27. package/src/lib/mobile/calendar.ts +5 -2
  28. package/src/lib/mobile/schemas.ts +7 -3
  29. package/src/lib/push/service.ts +44 -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 +28 -10
  33. package/src/lib/repo/types.ts +6 -3
  34. package/src/lib/schema.ts +21 -0
  35. package/src/lib/sections.ts +5 -2
  36. package/src/lib/sync/catalog.ts +1 -0
  37. package/src/pages/api/calendar/events/index.ts +6 -0
  38. package/src/pages/members/_Layout.astro +1 -0
  39. package/src/pages/members/events/index.astro +1 -1
  40. package/src/server/index.ts +33 -0
  41. package/src/styles/editor.css +19 -0
  42. package/src/styles/schedule.css +2 -0
  43. package/src/styles/site.css +6 -0
  44. package/src/theme-sdk/calendar.ts +3 -2
@@ -48,7 +48,7 @@ export type EventUpdate = Partial<
48
48
  CalEvent,
49
49
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
50
50
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
51
- | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates'
51
+ | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled' | 'cancelledDates' | 'movedDates'
52
52
  | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations' | 'roomHold'
53
53
  | 'publishedAt' | 'scheduledAt'
54
54
  >
@@ -168,15 +168,18 @@ export interface ContentRepository {
168
168
  discardEventDraft(id: string): Promise<void>;
169
169
  /** replace an event's cancelled occurrence keys at once, keeping any staged draft */
170
170
  setEventCancelledDates(id: string, dates: string[]): Promise<void>;
171
+ /** replace an event's moved dates (and cancelled dates, when given) at once, keeping any staged draft; quiet: no member push for it */
172
+ setEventMovedDates(id: string, moved: CalEvent['movedDates'], cancelledDates?: string[], opts?: { quiet?: boolean }): Promise<void>;
171
173
  /** Cancels the RSVPs for one date of an event (they get the "cancelled" email). Returns how many. */
172
174
  cancelOccurrenceRsvps(id: string, start: number): Promise<number>;
173
175
  /**
174
176
  * Moves the RSVPs for one date of an event (those starting at `start`) to a
175
177
  * new time, and to another event when `to.eventId` is set. Pending and
176
- * confirmed ones get the "new time" email; places in line move quietly.
178
+ * confirmed ones get the "new time" email (with `message`, when given);
179
+ * places in line move quietly.
177
180
  * Returns how many moved.
178
181
  */
179
- moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }): Promise<number>;
182
+ moveOccurrenceRsvps(id: string, start: number, to: { eventId?: string; start: number; end: number }, message?: string): Promise<number>;
180
183
  deleteEvent(id: string): Promise<void>;
181
184
  // saved-place sync (lib/maps/places.ts): rewrite just the location of the
182
185
  // live row and/or the staged draft, leaving every other field — and the
package/src/lib/schema.ts CHANGED
@@ -357,6 +357,24 @@ export const RoomHold = z.object({
357
357
  });
358
358
  export type RoomHold = z.infer<typeof RoomHold>;
359
359
 
360
+ // One date of a repeating event at another time (and maybe in other rooms or
361
+ // another place): CalEvent.movedDates, keyed by the date's ORIGINAL occurrence
362
+ // key. start/end are wall-clock in the event's zone like the event's own
363
+ // (dates for all-day events, end inclusive). Unset roomIds/location = the
364
+ // event's. note is public ("Moved for the youth retreat"); by is who moved it
365
+ // (a user id, or 'google-sync'), at when. lib/calendar/moves.ts writes these.
366
+ export const MovedDate = z.object({
367
+ start: z.string(),
368
+ end: z.string(),
369
+ roomIds: z.array(z.string()).optional(),
370
+ location: EventLocation.optional(),
371
+ note: z.string().max(500).default(''),
372
+ by: z.string().default(''),
373
+ at: z.number().default(0),
374
+ });
375
+ export type MovedDate = z.infer<typeof MovedDate>;
376
+ export const MovedDates = z.record(z.string(), MovedDate).default({});
377
+
360
378
  // A member's event request (lib/calendar/requests.ts): a new event waiting
361
379
  // for approval (kind 'new', the event stays a draft), or changes to a live
362
380
  // event staged in its `draft` (kind 'changes'). Staff approve or decline;
@@ -407,6 +425,9 @@ export const CalEvent = z.object({
407
425
  // cancelled: shown struck through, but they don't hold rooms. Keys that no
408
426
  // longer match an occurrence (the series moved) are ignored.
409
427
  cancelledDates: z.array(z.string()).default([]),
428
+ // single dates at another time (MovedDate above); a date is moved or
429
+ // cancelled, never both. Keys that no longer match an occurrence are ignored.
430
+ movedDates: MovedDates,
410
431
  location: EventLocation.default({}),
411
432
  cta: EventCta.default({}),
412
433
  calendarIds: z.array(z.string()).default([]),
@@ -32,6 +32,8 @@ export interface LinkValue { label: string; href: string; external: boolean }
32
32
  export interface EventItem {
33
33
  title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
34
34
  start: string; end: string; allDay: boolean; cancelled: boolean;
35
+ /** this date was moved here: from "Thu, Oct 1 · 6:00 PM" (where it was), with staff's public note; null when not moved */
36
+ moved: { from: string; note: string } | null;
35
37
  /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
36
38
  /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
37
39
  cta: { label: string; href: string } | null;
@@ -141,11 +143,12 @@ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value
141
143
  return (await upcomingEvents(repo, settings, now, { calendarIds, limit, tag: cfg.tag, excludeTag: cfg.excludeTag })).map((o): EventItem => {
142
144
  const e = lang ? localizeDoc(o.event, lang) : o.event;
143
145
  const f = formatOccurrence(o, tz, locale);
146
+ const was = o.moved ? formatOccurrence({ ...o, start: o.moved.from.start, end: o.moved.from.end }, tz, locale) : null;
144
147
  const ctaHref = safeUrl(e.cta.href);
145
148
  return {
146
149
  title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
147
- location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
148
- allDay: o.allDay, cancelled: o.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
150
+ location: locationLine(o.moved?.location ?? e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
151
+ allDay: o.allDay, cancelled: o.cancelled, moved: was && o.moved ? { from: o.allDay ? was.date : was.range, note: o.moved.note } : null, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
149
152
  cta: e.cta.label && ctaHref && !o.cancelled ? { label: e.cta.label, href: ctaHref } : null,
150
153
  };
151
154
  });
@@ -130,6 +130,7 @@ const jsonFields = new Set([
130
130
  "roomHold",
131
131
  "exdates",
132
132
  "cancelledDates",
133
+ "movedDates",
133
134
  "amenities",
134
135
  "hours",
135
136
  "overrides",
@@ -100,6 +100,12 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
100
100
  end: o.allDay ? isoWithOffset(o.end - 86_400_000, etz).slice(0, 10) : isoWithOffset(o.end, etz),
101
101
  allDay: o.allDay,
102
102
  cancelled: o.cancelled,
103
+ // the date's key (a moved date keeps its original one), and where it moved from
104
+ key: o.key,
105
+ moved: o.moved ? {
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
+ note: o.moved.note, location: o.moved.location ?? null,
108
+ } : null,
103
109
  // that date's rooms
104
110
  rooms: roomsToJson(o.roomIds, resources, full),
105
111
  event: toJson(o.event.id),
@@ -62,6 +62,7 @@ const { title, user } = Astro.props;
62
62
  .pc-mem-dates li { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 42px; padding: 5px 0; border-top: 1px solid var(--line); font-variant-numeric: tabular-nums; }
63
63
  .pc-mem-dates li.is-cancelled > span { color: var(--muted); }
64
64
  .pc-mem-dates em { font-style: normal; font-size: 12.5px; font-weight: 600; color: var(--danger); margin-left: 8px; }
65
+ .pc-mem-dates em.is-moved { color: var(--accent); font-weight: 500; }
65
66
  .pc-mem-past > summary { cursor: pointer; font-weight: 600; font-size: 15px; }
66
67
  .pc-mem-past .pc-mem-list { margin-top: 10px; }
67
68
  /* the request form */
@@ -175,7 +175,7 @@ const pastShown = past.slice(0, 10);
175
175
  const off = e.cancelledDates.includes(o.key);
176
176
  return (
177
177
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
178
- <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}</span>
178
+ <span>{o.cancelled ? <s>{when(o)}</s> : when(o)}{o.cancelled && <em>Cancelled</em>}{o.moved && !o.cancelled && <em class="is-moved" title={o.moved.note || undefined}>Moved from {when({ ...o, start: o.moved.from.start, end: o.moved.from.end })}</em>}</span>
179
179
  {e.rrule && !e.cancelled && o.start > now && (
180
180
  <form method="post" onsubmit={off ? undefined : "return confirm('Cancel this date? It shows as cancelled straight away.')"}>
181
181
  <input type="hidden" name="op" value={off ? 'restore-date' : 'cancel-date'} />
@@ -37,6 +37,7 @@ export interface ServerUser {
37
37
  export type { CalEvent, Occurrence };
38
38
  export type { EventRequestInput } from '../lib/calendar/requests';
39
39
  export type { EventClash, BusySlot, Hold } from '../lib/booking/holds';
40
+ export type { MoveInput, MoveResult } from '../lib/calendar/moves';
40
41
 
41
42
  export interface ListEventsOptions {
42
43
  /** only published events (default: every event except archived ones) */
@@ -172,6 +173,38 @@ export async function restoreOccurrence(repo: Repo, eventId: string, occurrenceK
172
173
  return restore(internal(repo), eventId, occurrenceKey, { url: context.url, locals: context.locals });
173
174
  }
174
175
 
176
+ // ---- moved dates -------------------------------------------------------------
177
+ // One date of a repeating event at another time (and maybe other rooms or
178
+ // another place), keeping its key, its RSVPs (moved with it; each person gets
179
+ // the "new time" email with their cancel link) and its hosts. These check who
180
+ // is acting: staff ("Create and edit content") and GOOGLE_SYNC may move any
181
+ // date, and a room taken at the new time comes back in `clashes` as a
182
+ // warning; a host of the event may move its dates only while the rooms are
183
+ // free (409 otherwise), and staff are emailed about it.
184
+
185
+ /** The actor for a calendar sync acting with staff rights (a change already made elsewhere): clashes only warn. */
186
+ export const GOOGLE_SYNC = 'google-sync';
187
+
188
+ /**
189
+ * Move one date (by its occurrence key) to `input.start` (and `end`, else it
190
+ * keeps its length; wall-clock in the event's timezone, like `start`).
191
+ * `roomIds`/`location`/`note` replace that date's own (omit to keep them).
192
+ * Applies at once, even on a published event. `quiet` skips the app notice
193
+ * and the staff email (a sync catching up); RSVP emails still go. Errors have
194
+ * a `status`: 400 (not a repeating event, not one of its dates, started or a
195
+ * past time), 401, 404, 409 (a cancelled date, or a taken room for a host).
196
+ */
197
+ export async function moveOccurrence(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
198
+ const { moveOccurrence: move } = await import('../lib/calendar/moves');
199
+ return move(internal(repo), who, eventId, occurrenceKey, input, { url: context.url, locals: context.locals }, opts);
200
+ }
201
+
202
+ /** Put a moved date back where the series has it. Same rules; idempotent. */
203
+ export async function moveOccurrenceBack(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, occurrenceKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/moves').MoveResult> {
204
+ const { moveOccurrenceBack: back } = await import('../lib/calendar/moves');
205
+ return back(internal(repo), who, eventId, occurrenceKey, { url: context.url, locals: context.locals }, opts);
206
+ }
207
+
175
208
  // ---- rooms -----------------------------------------------------------------
176
209
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
177
210
  // two things want the same room. They don't check permissions, and their
@@ -1328,3 +1328,22 @@ html.pe-evr-lock { overflow: hidden; }
1328
1328
  .pe-evs-wrap { --evs-pad: 14px; padding-top: 18px; }
1329
1329
  .pe-evsec input:where(:not([type='checkbox'], [type='radio'])), .pe-evsec select, .pe-evsec textarea, .pe-loc-search input, .pe-evsec input.mono { font-size: 16px; }
1330
1330
  }
1331
+
1332
+ /* Event settings → Single dates (SingleDates.tsx): move or cancel one date */
1333
+ .pe-evd { list-style: none; margin: 0; padding: 0; }
1334
+ .pe-evd-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; padding: 8px 0; border-top: 1px solid var(--line); }
1335
+ .pe-evd-row:first-child { border-top: 0; }
1336
+ .pe-evd-main { display: flex; flex-direction: column; min-width: 0; font-size: 13.5px; font-variant-numeric: tabular-nums; }
1337
+ .pe-evd-state { font-size: 12px; color: var(--text-2); }
1338
+ .pe-evd-row.is-cancelled .pe-evd-when { color: var(--muted); }
1339
+ .pe-evd-row.is-cancelled .pe-evd-state { color: var(--red); }
1340
+ .pe-evd-row.is-moved .pe-evd-state { color: var(--accent); }
1341
+ .pe-evd-acts { display: flex; flex-wrap: wrap; gap: 6px; }
1342
+ .pe-evd-form { flex-basis: 100%; display: flex; flex-direction: column; gap: 10px; margin: 4px 0 6px; padding: 12px; border-radius: 10px; background: var(--panel-2, var(--soft)); }
1343
+ .pe-evd-fields { display: flex; flex-wrap: wrap; gap: 10px; }
1344
+ .pe-evd-fields .pe-field { flex: 1 1 130px; margin: 0; }
1345
+ .pe-evd-rooms { border: 0; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; }
1346
+ .pe-evd-rooms legend { font-size: 12px; color: var(--text-2); margin-bottom: 4px; padding: 0; }
1347
+ .pe-evd-room { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
1348
+ .pe-evd-formacts { display: flex; gap: 8px; }
1349
+ .pe-ev-note.is-error { color: var(--red); }
@@ -205,6 +205,8 @@ kbd { font: 10.5px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--
205
205
  .sch-toast button { border: 0; background: none; color: var(--accent); font: inherit; font-weight: 650; cursor: pointer; padding: 0; }
206
206
  :root[data-theme='dark'] .sch-toast { background: #2e2b27; color: var(--text); border: 1px solid var(--line-strong); }
207
207
  .sch-toast.err { background: var(--red); color: #fff; }
208
+ /* a room clash after a move: both events keep the room */
209
+ .sch-toast-warn { display: block; max-width: 340px; font-size: 12px; color: var(--amber, #c98a16); }
208
210
 
209
211
  /* ---- quick add ---- */
210
212
  .qa-layer { position: fixed; inset: 0; z-index: 75; }
@@ -806,6 +806,10 @@ li::marker { color: var(--muted); }
806
806
  @layer base {
807
807
  .pc-cal-event.is-cancelled b, .pc-cal-row.is-cancelled .ti { text-decoration: line-through; color: var(--cal-muted); }
808
808
  .pc-cal-pop-flag { display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--danger); margin-bottom: 3px; }
809
+ /* a moved date: flagged, never struck through */
810
+ .pc-cal-pop-flag.is-moved, .pc-ev-flag.is-moved { color: var(--accent); }
811
+ .pc-cal-pop-moved { font-size: 12.5px; color: var(--cal-muted); margin-top: 2px; }
812
+ .pc-cal-event.is-moved b::before { content: '↷ '; }
809
813
  .pc-cal-pop-link { display: inline-block; margin-top: 9px; font-weight: 600; font-size: 12.5px; color: var(--cal-accent); text-decoration: none; }
810
814
  .pc-cal-pop-link:hover { text-decoration: underline; }
811
815
 
@@ -867,6 +871,8 @@ li::marker { color: var(--muted); }
867
871
  .pc-ev-page.is-cancelled .pc-ev-title { text-decoration: line-through; text-decoration-thickness: 2px; color: var(--muted); }
868
872
  .pc-ev-cancelled { background: color-mix(in srgb, var(--danger) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
869
873
  .pc-ev-cancelled b { color: var(--danger); }
874
+ .pc-ev-moved { background: color-mix(in srgb, var(--accent) 10%, var(--panel)); border-radius: var(--r-sm); padding: 10px 14px; font-size: 14.5px; margin: 0 0 16px; }
875
+ .pc-ev-moved b { color: var(--accent); }
870
876
  .pc-ev-lede { font-size: 19px; line-height: 1.55; color: var(--muted); margin: 0 0 22px; }
871
877
  .pc-ev-side {
872
878
  position: sticky; top: 96px; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
@@ -29,9 +29,10 @@ export interface UpcomingEventsOptions {
29
29
  /**
30
30
  * Occurrence start/end are Unix milliseconds; event retains its authored
31
31
  * fields except private custom fields. `cancelled` is true when the whole
32
- * event or just this date is cancelled.
32
+ * event or just this date is cancelled. `moved` is set when this date of a
33
+ * series was moved here: `from` (its old start and end) and staff's `note`.
33
34
  */
34
- export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'>;
35
+ export type UpcomingEvent = Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days' | 'cancelled'> & Pick<Partial<Occurrence>, 'moved'>;
35
36
 
36
37
  /** Server-side helper for Astro templates and endpoints. No repository access needed. */
37
38
  export async function upcomingEvents(