@acms-subir/core 0.3.56 → 0.3.58

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 (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/cli/migrations.generated.mjs +24 -5
  3. package/docs/calendar.md +130 -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/booking/service.ts +4 -2
  11. package/src/lib/calendar/api.ts +29 -8
  12. package/src/lib/calendar/board.ts +3 -2
  13. package/src/lib/calendar/cards.ts +4 -4
  14. package/src/lib/calendar/change-migration.ts +17 -3
  15. package/src/lib/calendar/data.ts +16 -7
  16. package/src/lib/calendar/event-words.ts +8 -0
  17. package/src/lib/calendar/events.ts +128 -15
  18. package/src/lib/calendar/google/map.ts +1 -1
  19. package/src/lib/calendar/host-requests.ts +65 -10
  20. package/src/lib/calendar/ics-write.ts +65 -15
  21. package/src/lib/calendar/member-words.ts +14 -1
  22. package/src/lib/calendar/moves.ts +15 -9
  23. package/src/lib/calendar/request-admin.ts +2 -1
  24. package/src/lib/calendar/request-mail.ts +2 -1
  25. package/src/lib/calendar/request-page.ts +2 -1
  26. package/src/lib/calendar/request-service.ts +7 -7
  27. package/src/lib/calendar/schedule.ts +42 -8
  28. package/src/lib/calendar/segments.ts +522 -0
  29. package/src/lib/display/board.ts +6 -5
  30. package/src/lib/editor/schedule.ts +4 -2
  31. package/src/lib/mobile/calendar.ts +6 -5
  32. package/src/lib/mobile/schemas.ts +10 -3
  33. package/src/lib/mobile/search.ts +2 -1
  34. package/src/lib/push/service.ts +42 -10
  35. package/src/lib/push/types.ts +2 -0
  36. package/src/lib/repo/migrations.ts +13 -3
  37. package/src/lib/repo/sql.ts +34 -13
  38. package/src/lib/repo/types.ts +3 -1
  39. package/src/lib/schema.ts +34 -0
  40. package/src/lib/sections.ts +3 -2
  41. package/src/lib/sync/catalog.ts +1 -0
  42. package/src/pages/admin/calendar/events/index.astro +9 -8
  43. package/src/pages/admin/calendar/requests.astro +5 -5
  44. package/src/pages/admin/calendar/resources/[id].astro +11 -0
  45. package/src/pages/api/calendar/events/index.ts +2 -1
  46. package/src/pages/members/events/index.astro +87 -7
  47. package/src/server/index.ts +46 -3
  48. package/src/styles/editor.css +2 -0
@@ -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';
@@ -15,6 +17,7 @@ import { mergeEventDraft } from '../../../lib/drafts';
15
17
  import { fill } from '../../../lib/live/words';
16
18
  import { localizeDoc } from '../../../lib/translations';
17
19
  import type { CalEvent, RequestRecord } from '../../../lib/schema';
20
+ import { roomName as nameIn } from '../../../lib/schema';
18
21
  import type { MoveInput } from '../../../lib/calendar/moves';
19
22
  import { failure, keepPrivate, memberLanguage, notFound, redirectTo, signInRedirect } from '../_http';
20
23
 
@@ -57,6 +60,25 @@ function nextDay(day: string): string {
57
60
  return d.toISOString().slice(0, 10);
58
61
  }
59
62
 
63
+ /**
64
+ * The "Change from this date on" form → ChangeFromInput: the first date and
65
+ * times as for a move, and a repeat choice worded around that first date
66
+ * ('same' keeps the rule in effect, moved to its weekday; the others end as
67
+ * that rule does).
68
+ */
69
+ async function fromDateInput(form: FormData, e: CalEvent | null, key: string): Promise<ChangeFromInput> {
70
+ const base = moveInput(form, e);
71
+ const choice = String(form.get('repeat') ?? 'same');
72
+ if (choice === 'same' || !e) return base;
73
+ const date = String(form.get('date') ?? '').trim();
74
+ const wd = weekdayOf(date);
75
+ const code = WEEKDAY_CODES[wd.dow];
76
+ const kept = rruleMap((await changeFromDefaults(repo, e.id, key))?.rrule ?? '');
77
+ const ends = kept.COUNT ? `;COUNT=${kept.COUNT}` : kept.UNTIL ? `;UNTIL=${kept.UNTIL}` : '';
78
+ 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];
79
+ return { ...base, rrule: choice === 'once' ? '' : rule ? rule + ends : undefined };
80
+ }
81
+
60
82
  let error = '';
61
83
  // a move refused because a room is taken: shown under its date, with "Send as a request"
62
84
  let clash: { id: string; key: string; back: boolean; message: string; input: MoveInput | null } | null = null;
@@ -108,6 +130,16 @@ if (Astro.request.method === 'POST') {
108
130
  clash = { id, key, back: true, message: failure(err, w, lang).message, input: null };
109
131
  Astro.response.status = 409;
110
132
  }
133
+ } else if (op === 'from-date') {
134
+ // staff who host apply it; other hosts send it to them
135
+ posted = { id, key, form };
136
+ const input = await fromDateInput(form, await repo.getEvent(id), key);
137
+ if (user.can('content.edit')) {
138
+ await changeFromDate(repo, user, id, key, input, hooks);
139
+ return redirectTo(`${home}?fromDateDone=1`);
140
+ }
141
+ await requestFromDate(repo, user, id, key, input, String(form.get('message') ?? ''), hooks);
142
+ return redirectTo(`${home}?fromDateSent=1`);
111
143
  } else if (op === 'withdraw-request') {
112
144
  await withdrawDateRequest(repo, user, String(form.get('request') ?? ''));
113
145
  return redirectTo(`${home}?withdrawn=1`);
@@ -122,7 +154,7 @@ if (Astro.request.method === 'POST') {
122
154
  }
123
155
  }
124
156
 
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]]);
157
+ 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
158
  const flash = error || clash ? '' : FLASH.find(([k]) => Astro.url.searchParams.has(k))?.[1] ?? '';
127
159
 
128
160
  const DAY = 86_400_000;
@@ -147,14 +179,16 @@ const when = (o: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>
147
179
  };
148
180
  const day = (ms: number) => format({ month: 'short', day: 'numeric' }, siteTz).format(ms);
149
181
  const meta = (...parts: string[]) => parts.filter(Boolean).join(' · ');
150
- const repeats = (e: CalEvent) => describeRepeat(e.rrule, e.start, undefined, lang);
182
+ // the rule in effect, and a change from a date on still to come
183
+ const repeats = (e: CalEvent) => describeSeries(e, settings, now, lang, undefined, dateLocale);
151
184
  const pad = (n: number) => String(n).padStart(2, '0');
152
185
  /** an instant as the move form's date and time, in the event's zone */
153
186
  const wallParts = (e: CalEvent, ms: number) => {
154
187
  const p = tzParts(ms, e.allDay ? siteTz : eventTimeZone(e, settings));
155
188
  return { date: `${p.y}-${pad(p.m)}-${pad(p.d)}`, time: `${pad(p.hh)}:${pad(p.mm)}` };
156
189
  };
157
- const roomName = new Map(resources.map((r) => [r.id, r.name]));
190
+ // the rooms in the page's language (their main names without one)
191
+ const roomName = new Map(resources.map((r) => [r.id, nameIn(r, lang)]));
158
192
  const requestable = resources.filter((r) => r.requestable).map((r) => r.id);
159
193
  /** the rooms a host may pick for a date: the event's own (and its dates'), and the requestable ones */
160
194
  const roomChoices = (e: CalEvent) => [...new Set([...eventRoomIds(e), ...requestable])].filter((id) => roomName.has(id)).map((id) => ({ id, name: roomName.get(id)! }));
@@ -169,12 +203,21 @@ const requestTarget = (e: CalEvent, r: RequestRecord) => {
169
203
  // …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
204
  const requestWhen = (r: RequestRecord) => {
171
205
  const e = byId.get(r.eventId);
206
+ if (e && r.kind === 'from-date') {
207
+ // the date it changes from (while it's still there), then the new first date and its repeat
208
+ const f = r.data as FromDateRequestData;
209
+ const first = findOccurrence({ ...e, start: f.start, end: f.end, rrule: f.rrule, segments: [], movedDates: {}, cancelledDates: [], exdates: [] }, f.start, settings);
210
+ const from = r.state === 'pending' ? findOccurrence({ ...e, movedDates: {} }, r.key, settings) : null;
211
+ const to = [first ? when(first) : '', describeRepeat(f.rrule, f.start, undefined, lang)].filter(Boolean).join(' · ');
212
+ return from ? `${when(from)} → ${to}` : to;
213
+ }
172
214
  const at = e && findOccurrence(e, r.key, settings);
173
215
  if (!e || !at) return '';
174
216
  const to = r.state === 'pending' ? requestTarget(e, r) : null;
175
217
  return to ? `${when(at)} → ${when(to)}` : when(at);
176
218
  };
177
219
  const waitingMove = new Map(dateRequests.filter((r) => r.kind === 'move' && r.state === 'pending').map((r) => [`${r.eventId}|${r.key}`, r]));
220
+ const waitingFrom = new Set(dateRequests.filter((r) => r.kind === 'from-date' && r.state === 'pending').map((r) => `${r.eventId}|${r.key}`));
178
221
 
179
222
  // requests: waiting ones first, newest first; shown as they'd be once approved
180
223
  const requests = [
@@ -294,13 +337,16 @@ function moveValues(e: CalEvent, o: Occurrence) {
294
337
  <ul class="pc-mem-dates">
295
338
  {dates.map((o) => {
296
339
  const off = e.cancelledDates.includes(o.key);
297
- const open = e.rrule && !e.cancelled && o.start > now;
340
+ // a date cancelled with every date after it (a cancelled part) is the team's to restore
341
+ const open = e.rrule && !e.cancelled && o.start > now && (!o.cancelled || off);
298
342
  const waiting = waitingMove.get(`${e.id}|${o.key}`);
299
343
  const ownMove = o.moved && e.movedDates[o.key]?.by === user.id;
300
344
  const clashHere = clash && clash.id === e.id && clash.key === o.key ? clash : null;
301
345
  const target = waiting && requestTarget(e, waiting);
302
346
  const v = moveValues(e, o);
303
- const reopen = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
347
+ const failed = Boolean(posted && posted.id === e.id && posted.key === o.key && error);
348
+ const reopen = failed && posted!.form.get('op') !== 'from-date';
349
+ const reopenFrom = failed && posted!.form.get('op') === 'from-date';
304
350
  return (
305
351
  <li class={o.cancelled ? 'is-cancelled' : undefined}>
306
352
  <span>
@@ -308,6 +354,7 @@ function moveValues(e: CalEvent, o: Occurrence) {
308
354
  {o.cancelled && <em>{w.cancelledDate}</em>}
309
355
  {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
356
  {waiting && <em class="is-pending">{(waiting.data as MoveRequestData).back ? w.moveBackRequested : fill(w.moveRequested, { when: target ? when(target) : '' })}</em>}
357
+ {waitingFrom.has(`${e.id}|${o.key}`) && <em class="is-pending">{fill(w.changeFromRequested, { when: day(o.start) })}</em>}
311
358
  </span>
312
359
  {open && (
313
360
  <span class="pc-mem-date-acts">
@@ -377,6 +424,39 @@ function moveValues(e: CalEvent, o: Occurrence) {
377
424
  </form>
378
425
  </details>
379
426
  )}
427
+ {open && !off && !clashHere && (
428
+ <details class="pc-mem-move" open={reopenFrom}>
429
+ <summary class="pc-mem-btn is-small" aria-label={fill(w.changeFromLabel, { title: ev.title, when: when(o) })}>{w.changeFrom}</summary>
430
+ <form method="post">
431
+ <input type="hidden" name="op" value="from-date" />
432
+ <input type="hidden" name="id" value={e.id} />
433
+ <input type="hidden" name="key" value={o.key} />
434
+ <p class="pc-mem-hint">{w.changeFromHint}</p>
435
+ <div class="pc-mem-row">
436
+ <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>
437
+ {!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>}
438
+ {!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>}
439
+ </div>
440
+ <label class="pc-bk-field"><span>{w.form.repeats}</span>
441
+ <select name="repeat">
442
+ {([['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)
443
+ .map(([value, label]) => <option value={value} selected={reopenFrom && posted!.form.get('repeat') === value}>{label}</option>)}
444
+ </select>
445
+ </label>
446
+ {rooms.length > 0 && (
447
+ <fieldset>
448
+ <legend>{w.rooms}</legend>
449
+ <input type="hidden" name="rooms" value="1" />
450
+ {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>)}
451
+ <p class="pc-mem-hint" style="flex-basis:100%">{w.roomsHint}</p>
452
+ </fieldset>
453
+ )}
454
+ <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>
455
+ {!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>}
456
+ <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>
457
+ </form>
458
+ </details>
459
+ )}
380
460
  </li>
381
461
  );
382
462
  })}
@@ -256,6 +256,39 @@ export async function syncGoogleCalendar(repo: Repo, context: Pick<WriteContext,
256
256
  return syncGoogle(internal(repo), context.locals);
257
257
  }
258
258
 
259
+ // ---- from this date on -------------------------------------------------------
260
+ // A repeating event changed from one of its dates on (new times, weekday,
261
+ // rule, rooms or place), the dates before it kept as they were. It stays one
262
+ // event; the change is a segment on its row (CalEvent.segments). RSVPs,
263
+ // cancelled and moved dates follow to the new date nearest them within half
264
+ // a week (`npx acms docs calendar`). Staff and GOOGLE_SYNC only (room clashes
265
+ // warn); hosts send requestFromDate. All are idempotent (`changed: false`);
266
+ // `quiet` skips the member push, and RSVP emails still go.
267
+
268
+ const segments = () => import('../lib/calendar/segments');
269
+ export type { ChangeFromInput, ChangeFromResult } from '../lib/calendar/segments';
270
+ export type { SeriesSegment } from '../lib/schema';
271
+
272
+ /** Change the series from `fromKey` (an occurrence key) on: `input.start` is the new first date (wall-clock in the event's zone), `rrule` omitted = the rule in effect on the new weekday. */
273
+ export async function changeFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, input: import('../lib/calendar/segments').ChangeFromInput, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
274
+ return (await segments()).changeFromDate(internal(repo), who, eventId, fromKey, input, { url: context.url, locals: context.locals }, opts);
275
+ }
276
+
277
+ /** Cancel every date from `fromKey` on: listed, struck through, holding no room; their RSVPs are cancelled (emailed). */
278
+ export async function cancelFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
279
+ return (await segments()).cancelFromDate(internal(repo), who, eventId, fromKey, { url: context.url, locals: context.locals }, opts);
280
+ }
281
+
282
+ /** Undo cancelFromDate (from any date it covers). The dates are back; their RSVPs stay cancelled. */
283
+ export async function restoreFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, fromKey: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
284
+ return (await segments()).restoreFromDate(internal(repo), who, eventId, fromKey, { url: context.url, locals: context.locals }, opts);
285
+ }
286
+
287
+ /** Remove the change that took over at `from`: the rule before it carries on, RSVPs and dates following back. */
288
+ export async function revertFromDate(repo: Repo, who: Who | typeof GOOGLE_SYNC, eventId: string, from: string, context: WriteContext, opts: { quiet?: boolean } = {}): Promise<import('../lib/calendar/segments').ChangeFromResult> {
289
+ return (await segments()).revertFromDate(internal(repo), who, eventId, from, { url: context.url, locals: context.locals }, opts);
290
+ }
291
+
259
292
  // ---- rooms -----------------------------------------------------------------
260
293
  // Events hold rooms (CalEvent.roomHold) while they happen. These find where
261
294
  // two things want the same room. They don't check permissions, and their
@@ -361,7 +394,7 @@ export async function setHostedOccurrence(repo: Repo, who: Who, eventId: string,
361
394
  // that staff who can publish approve or decline; both sides are emailed.
362
395
 
363
396
  const hostRequests = () => import('../lib/calendar/host-requests');
364
- export type { HostMoveResult, MoveRequestData } from '../lib/calendar/host-requests';
397
+ export type { HostMoveResult, MoveRequestData, FromDateRequestData } from '../lib/calendar/host-requests';
365
398
 
366
399
  /** A host moves one date: `{ moved }` at once, or `{ request }` when moves need approval. Errors as moveOccurrence's, plus `code` room-not-allowed (a room the event doesn't use and nobody may request). */
367
400
  export async function moveHostedDate(repo: Repo, who: Who, eventId: string, occurrenceKey: string, input: import('../lib/calendar/moves').MoveInput, context: WriteContext): Promise<import('../lib/calendar/host-requests').HostMoveResult> {
@@ -383,6 +416,11 @@ export async function requestDateMove(repo: Repo, who: Who, eventId: string, occ
383
416
  return (await hostRequests()).requestDateMove(internal(repo), who, eventId, occurrenceKey, input, message, context);
384
417
  }
385
418
 
419
+ /** A host asks staff to change their event from one date on (checked like changeFromDate; rooms among the event's and the requestable ones). Staff are emailed. */
420
+ export async function requestFromDate(repo: Repo, who: Who, eventId: string, fromKey: string, input: Omit<import('../lib/calendar/segments').ChangeFromInput, 'location'>, message: string, context: WriteContext): Promise<import('../lib/schema').RequestRecord> {
421
+ return (await hostRequests()).requestFromDate(internal(repo), who, eventId, fromKey, input, message, context);
422
+ }
423
+
386
424
  /** A host's own date requests (waiting, approved, declined and withdrawn), newest first. */
387
425
  export async function listMyDateRequests(repo: Repo, who: Who, eventId?: string): Promise<import('../lib/schema').RequestRecord[]> {
388
426
  if (!who) return [];
@@ -394,8 +432,13 @@ export async function withdrawDateRequest(repo: Repo, who: Who, requestId: strin
394
432
  return (await hostRequests()).withdrawDateRequest(internal(repo), who, requestId);
395
433
  }
396
434
 
397
- /** Staff ("Publish content"): approve a host's date move. It runs as them (room clashes come back as warnings) and is recorded as the host's. Pass `seen`, the request's `updatedAt` you showed. */
398
- export async function approveDateRequest(repo: Repo, who: Who, requestId: string, context: WriteContext, seen?: number): Promise<{ request: import('../lib/schema').RequestRecord; moved: import('../lib/calendar/moves').MoveResult }> {
435
+ /**
436
+ * Staff ("Publish content"): approve a host's date request. A move runs as
437
+ * them (room clashes come back as warnings) and is recorded as the host's
438
+ * (`moved`); a change from a date on runs changeFromDate (`changed`). Pass
439
+ * `seen`, the request's `updatedAt` you showed.
440
+ */
441
+ export async function approveDateRequest(repo: Repo, who: Who, requestId: string, context: WriteContext, seen?: number): Promise<{ request: import('../lib/schema').RequestRecord; moved?: import('../lib/calendar/moves').MoveResult; changed?: import('../lib/calendar/segments').ChangeFromResult }> {
399
442
  return (await hostRequests()).approveDateRequest(internal(repo), who, requestId, context, seen);
400
443
  }
401
444
 
@@ -1346,4 +1346,6 @@ html.pe-evr-lock { overflow: hidden; }
1346
1346
  .pe-evd-rooms legend { font-size: 12px; color: var(--text-2); margin-bottom: 4px; padding: 0; }
1347
1347
  .pe-evd-room { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
1348
1348
  .pe-evd-formacts { display: flex; gap: 8px; }
1349
+ .pe-evd-segs { margin-bottom: 8px; padding-bottom: 4px; border-bottom: 1px solid var(--line); }
1350
+ .pe-evd-hint { margin: 0; font-size: 12.5px; color: var(--text-2); }
1349
1351
  .pe-ev-note.is-error { color: var(--red); }