@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
@@ -20,7 +20,7 @@ import type { ContentRepository } from '../repo/types';
20
20
  import { EventLocation, type CalEvent, type MovedDate } from '../schema';
21
21
  import { ApiError, coded } from '../api';
22
22
  import { eventSideEffects, type SaveHooks } from '../content-service';
23
- import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence } from './events';
23
+ import { addWallDays, addWallMinutes, eventRoomIds, eventTimeZone, expandEvents, findOccurrence, formatWall, movedInstants, normalizeEventTimes, parseWall, wallMinutesBetween, type Occurrence, partOf, partMinutes, type SeriesPart } from './events';
24
24
  import { holdsFromEvents, roomBusy, type BusySlot } from '../booking/holds';
25
25
  import { kickEmail } from '../email/service';
26
26
  import { waitUntilFrom } from '../webhooks';
@@ -103,14 +103,14 @@ const started = () => refuse(400, 'started', 'That date has already started or p
103
103
 
104
104
  // The new wall times: `start` in the event's form, `end` defaulting to the
105
105
  // date's own length (all-day: the same number of days)
106
- function newTimes(e: CalEvent, input: MoveInput): { start: string; end: string } {
106
+ function newTimes(e: CalEvent, input: MoveInput, part: SeriesPart): { start: string; end: string } {
107
107
  const s = parseWall(input.start);
108
108
  if (!s) throw refuse(400, 'bad-time', e.allDay ? 'Pick a date (YYYY-MM-DD).' : 'Pick a date and time (YYYY-MM-DDTHH:mm).');
109
109
  if (s.dateOnly !== e.allDay) throw refuse(400, 'bad-time', e.allDay ? 'This event lasts all day: give a date only (YYYY-MM-DD).' : 'Give a date and a time (YYYY-MM-DDTHH:mm).');
110
110
  let end = input.end;
111
111
  if (!end) {
112
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
113
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
112
+ // the length of its part's dates (a change from a date on may have its own)
113
+ const length = partMinutes(part);
114
114
  end = formatWall(e.allDay ? addWallDays(s, Math.round(length / 1440)) : addWallMinutes(s, Math.max(1, length)));
115
115
  }
116
116
  try {
@@ -162,7 +162,9 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
162
162
  const settings = await repo.getSettings();
163
163
  const current = dateToChange(e, key, settings);
164
164
  const prior = e.movedDates[key];
165
- const times = newTimes(e, input);
165
+ // its part of the series (CalEvent.segments): the length, rooms and place it has without a move
166
+ const part = partOf(e, current);
167
+ const times = newTimes(e, input, part);
166
168
 
167
169
  let roomIds = input.roomIds === undefined ? prior?.roomIds : [...new Set(input.roomIds)];
168
170
  if (roomIds !== undefined) {
@@ -175,11 +177,11 @@ async function planMove(repo: ContentRepository, actor: MoveActor | null, id: st
175
177
  if (roomIds.some((r) => !allowed.has(r))) throw refuse(403, 'room-not-allowed', 'Pick from the rooms this event uses, or ask the team for another room.');
176
178
  }
177
179
  // the event's own rooms aren't an override
178
- if (sameRooms(roomIds, e.roomHold.resourceIds)) roomIds = undefined;
180
+ if (sameRooms(roomIds, part.roomIds)) roomIds = undefined;
179
181
  }
180
182
  let location = input.location === undefined ? prior?.location : input.location === null ? undefined : EventLocation.parse({ ...e.location, ...input.location });
181
183
  if (location && !location.name.trim() && !location.address.trim()) throw new ApiError(400, 'Give the place a name or an address.');
182
- if (location && JSON.stringify(location) === JSON.stringify(e.location)) location = undefined;
184
+ if (location && JSON.stringify(location) === JSON.stringify(part.location ?? e.location)) location = undefined;
183
185
  const note = input.note === undefined ? prior?.note ?? '' : String(input.note).replace(/\s+/g, ' ').trim().slice(0, NOTE_MAX);
184
186
 
185
187
  const entry: MovedDate = { start: times.start, end: times.end, ...(roomIds ? { roomIds } : {}), ...(location ? { location } : {}), note, by: by || actorId(actor!), at: now };
@@ -306,6 +308,8 @@ export interface DateRow {
306
308
  /** "Thu, Jan 10 · 6:00 – 7:00 PM" */
307
309
  when: string;
308
310
  cancelled: boolean;
311
+ /** cancelled because every date from some date on is (a cancelled part, CalEvent.segments) */
312
+ partCancelled: boolean;
309
313
  /** moved: "Thu, Jan 10 · 6:00 PM" (where it was) and the note */
310
314
  moved: { from: string; note: string } | null;
311
315
  roomIds: string[];
@@ -319,19 +323,21 @@ export function upcomingDates(e: CalEvent, settings: Awaited<ReturnType<ContentR
319
323
  const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
320
324
  const time = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
321
325
  const range = (a: number, b: number) => { try { return time.formatRange(a, b); } catch { return `${time.format(a)} – ${time.format(b)}`; } };
322
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
323
- const length = es && ee ? wallMinutesBetween(es, ee) : 60;
324
326
  return expandEvents([e], settings, { displayTz: settings.timezone || 'UTC', now, windowStartMs: now, windowEndMs: now + 400 * DAY })
325
327
  .slice(0, limit)
326
328
  .map((o) => {
327
329
  const m = o.moved ? e.movedDates[o.key] : undefined;
328
330
  const k = parseWall(o.key)!;
331
+ const part = partOf(e, o);
332
+ const length = partMinutes(part);
329
333
  return {
330
334
  key: o.key,
331
335
  start: m?.start ?? o.key,
332
336
  end: m?.end ?? formatWall(e.allDay ? addWallDays(k, Math.round(length / 1440)) : addWallMinutes(k, length)),
333
337
  when: e.allDay ? day.format(o.start) : `${day.format(o.start)} · ${range(o.start, o.end)}`,
334
338
  cancelled: o.cancelled,
339
+ // cancelled with the rest of its part (cancelFromDate): restored from the change, not one by one
340
+ partCancelled: part.cancelled,
335
341
  moved: o.moved ? { from: e.allDay ? day.format(o.moved.from.start) : `${day.format(o.moved.from.start)} · ${time.format(o.moved.from.start)}`, note: o.moved.note } : null,
336
342
  roomIds: o.roomIds,
337
343
  };
@@ -110,7 +110,8 @@ export async function requestRows(repo: ContentRepository, settings: Settings, t
110
110
  ? { taken: true, text: `${found[0].room} on ${f.slot(found[0].start, found[0].end)}${found[0].title ? ` (${found[0].title})` : ''}${found.length > 1 ? ` and ${found.length - 1} more` : ''}` }
111
111
  : { taken: false, text: `${names} ${d.roomIds.length > 1 ? 'are' : 'is'} free then` };
112
112
  }
113
- } else if (q.kind === 'move') {
113
+ } else if (q.kind === 'move' || q.kind === 'from-date') {
114
+ // a host's date move, or change from a date on ("Thu, Jan 17, 6:00 PM → Wed, Jan 16, 5:00 PM · Weekly on Wednesday")
114
115
  title = e?.title ?? 'A deleted event';
115
116
  line = await dateRequestWhen(repo, q);
116
117
  if (tab === 'pending' && e) {
@@ -1,5 +1,6 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import type { RequestRecord, Settings } from '../schema';
3
+ import { roomName } from '../schema';
3
4
  import type { Mail } from '../email/types';
4
5
  import { siteLang, siteLocale } from '../dates';
5
6
  import { I18N, localeName } from '../i18n';
@@ -66,7 +67,7 @@ async function personSummary(repo: ContentRepository, q: RequestRecord, settings
66
67
  const parts = [
67
68
  requestWhen(d.start, d.end, settings, locale),
68
69
  ...(d.repeat === 'weekly' ? [w.weekly] : []),
69
- d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
70
+ d.roomIds.map((id) => rooms.find((r) => r.id === id)).filter((r) => r !== undefined).map((r) => roomName(r, lang)).join(', '),
70
71
  d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
71
72
  type ? (lang && type.translations[lang]) || type.label : '',
72
73
  ].filter(Boolean);
@@ -1,5 +1,6 @@
1
1
  import type { ContentRepository } from '../repo/types';
2
2
  import type { RequestRecord, Settings } from '../schema';
3
+ import { roomName } from '../schema';
3
4
  import { requestWhen } from './request-mail';
4
5
  import { requestSettings, type NewEventDetails, type RequestInput } from './request-service';
5
6
  import { requestStrings } from './request-words';
@@ -44,7 +45,7 @@ export async function sentSummary(repo: ContentRepository, q: RequestRecord, set
44
45
  const line = [
45
46
  requestWhen(d.start, d.end, settings, locale),
46
47
  d.repeat === 'weekly' ? w.weekly : d.repeat === 'other' ? d.repeatText : '',
47
- d.roomIds.map((id) => rooms.find((r) => r.id === id)?.name).filter(Boolean).join(', '),
48
+ d.roomIds.map((id) => rooms.find((r) => r.id === id)).filter((r) => r !== undefined).map((r) => roomName(r, lang)).join(', '),
48
49
  d.people ? fill(w.aboutPeople, { count: String(d.people) }) : '',
49
50
  type ? (lang && type.translations[lang]) || type.label : '',
50
51
  ].filter(Boolean).join(' · ');
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { RequestRecord, RoomHold, type CalEvent, type RequestSettings, type Resource, type Settings } from '../schema';
3
+ import { RequestRecord, RoomHold, roomName, type CalEvent, type RequestSettings, type Resource, type Settings } from '../schema';
4
4
  import { ApiError, coded } from '../api';
5
5
  import { sha256Hex } from '../api-keys';
6
6
  import { validAddress } from '../email/config';
@@ -139,7 +139,7 @@ export async function requestOptions(repo: ContentRepository, locale = '', now =
139
139
  return {
140
140
  open: rq.open,
141
141
  types: rq.types.map((t) => ({ id: t.id, label: typeLabel(t, lang) })),
142
- rooms: (await requestableRooms(repo)).map((r) => ({ slug: r.slug, name: r.name, color: /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '' })),
142
+ rooms: (await requestableRooms(repo)).map((r) => ({ slug: r.slug, name: roomName(r, lang), color: /^#[0-9a-f]{6}$/i.test(r.color) ? r.color.toLowerCase() : '' })),
143
143
  replyTime: words?.replyTime || rq.replyTime,
144
144
  notice: words?.notice || rq.notice,
145
145
  phone: rq.phone,
@@ -429,11 +429,11 @@ export async function approveRequest(repo: ContentRepository, who: Actor | null,
429
429
  if (!event) throw new ApiError(409, 'That event was deleted. Decline the request, or reply to the person.');
430
430
  done = await decide(repo, q, answer);
431
431
  next = `/admin/calendar/events/${event.id}${q.key ? `?move=${encodeURIComponent(q.key)}` : ''}`;
432
- } else if (q.kind === 'move') {
433
- // a host's date move (lib/calendar/host-requests.ts): it runs, and the host hears about it there
432
+ } else if (q.kind === 'move' || q.kind === 'from-date') {
433
+ // a host's date move or change from a date on (lib/calendar/host-requests.ts): it runs, and the host hears about it there
434
434
  const { approveDateRequest } = await import('./host-requests');
435
- const { request, moved } = await approveDateRequest(repo, who, id, hooks, seen);
436
- return { request, event: moved.event, next: `/admin/calendar/events/${request.eventId}` };
435
+ const { request, moved, changed } = await approveDateRequest(repo, who, id, hooks, seen);
436
+ return { request, event: (moved ?? changed)!.event, next: `/admin/calendar/events/${request.eventId}` };
437
437
  } else {
438
438
  throw new ApiError(400, 'Open the event to answer this request.');
439
439
  }
@@ -464,7 +464,7 @@ async function draftFrom(repo: ContentRepository, q: RequestRecord, hooks: SaveH
464
464
  export async function declineRequest(repo: ContentRepository, who: Actor | null, id: string, reply: string, hooks: SaveHooks, seen?: number, opts: { email?: boolean } = {}): Promise<RequestRecord> {
465
465
  requireAnswerer(who);
466
466
  const q = await pendingRequest(repo, id, seen);
467
- if (q.kind === 'move') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
467
+ if (q.kind === 'move' || q.kind === 'from-date') return (await import('./host-requests')).declineDateRequest(repo, who, id, reply, hooks, seen);
468
468
  if (q.kind !== 'new' && q.kind !== 'change') throw new ApiError(400, 'Open the event to answer this request.');
469
469
  const reason = text(reply, REQUEST_LIMITS.message);
470
470
  const done = await decide(repo, q, { state: 'declined', reviewedBy: who.id, reviewedAt: Date.now(), reply: reason });
@@ -6,12 +6,12 @@
6
6
  // Cancelling one date of a series applies at once too, without touching any
7
7
  // staged edits.
8
8
  import type { ContentRepository } from '../repo/types';
9
- import type { CalEvent, MovedDate } from '../schema';
9
+ import type { CalEvent, MovedDate, SeriesSegment } from '../schema';
10
10
  import { EventLocation, slugify } from '../schema';
11
11
  import { defaultPlaceLocation } from '../maps/places';
12
12
  import { ApiError } from '../api';
13
13
  import { applyEventUpdate, eventSideEffects, flushEventHooks, saveEvent, type SaveHooks } from '../content-service';
14
- import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween } from './events';
14
+ import { addWallDays, addWallMinutes, findOccurrence, formatWall, parseWall, wallMinutesBetween, partOf, partMinutes } from './events';
15
15
  import { moveOccurrence, moveOccurrenceBack, type MoveActor } from './moves';
16
16
  import type { BusySlot } from '../booking/holds';
17
17
  import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
@@ -105,6 +105,8 @@ const dayOf = (wall: string) => {
105
105
 
106
106
  export type MoveUndo =
107
107
  | { kind: 'series'; id: string; days: number; minutes?: number }
108
+ // this and following dates (lib/calendar/segments.ts): the segments as they were, and where the change took over
109
+ | { kind: 'following'; id: string; from: string; previous: SeriesSegment[] }
108
110
  // one date moved (previous: where it was moved before, null = not moved)
109
111
  | { kind: 'date'; id: string; key: string; previous: Pick<MovedDate, 'start' | 'end' | 'roomIds' | 'location' | 'note'> | null }
110
112
  // undo tokens from before moved dates: the date was split off into a copy
@@ -126,10 +128,16 @@ export async function moveSeries(repo: ContentRepository, id: string, days: numb
126
128
  ...(e.rrule ? { rrule: shiftRrule(e.rrule, dayShift, start), exdates: e.exdates.map((x) => shiftBy(x, total)) } : {}),
127
129
  }, hooks);
128
130
  // cancelled dates move with the series, like skipped ones; a moved date
129
- // keeps its new time and follows under its new key
130
- if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length)) {
131
+ // keeps its new time and follows under its new key; a change from a date on
132
+ // (segments) moves as a whole: its change point, first date and rule
133
+ if (e.rrule && (e.cancelledDates.length || Object.keys(e.movedDates).length || e.segments.length)) {
131
134
  const moved = Object.fromEntries(Object.entries(e.movedDates).map(([k, m]) => [shiftBy(k, total), m]));
132
- await repo.setEventMovedDates(id, moved, e.cancelledDates.map((x) => shiftBy(x, total)));
135
+ const segments = e.segments.map((g) => {
136
+ const gs = shiftBy(g.start, total);
137
+ const shift = ((a, b) => (a && b ? Math.round(wallMinutesBetween(a, b) / 1440) : days))(dayOf(g.start), dayOf(gs));
138
+ return { ...g, from: shiftBy(g.from, total), start: gs, end: shiftBy(g.end, total), rrule: shiftRrule(g.rrule, shift, gs) };
139
+ });
140
+ await repo.setEventDates(id, { movedDates: moved, cancelledDates: e.cancelledDates.map((x) => shiftBy(x, total)), segments });
133
141
  await flushEventHooks(repo, hooks);
134
142
  event = (await repo.getEvent(id))!;
135
143
  }
@@ -149,21 +157,47 @@ export async function moveDateBy(repo: ContentRepository, actor: MoveActor, id:
149
157
  }
150
158
  if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
151
159
  const prior = e.movedDates[key];
152
- const es = parseWall(e.start), ee = parseWall(e.end) ?? es;
153
160
  const k = parseWall(key);
154
- if (!k || !es || !ee) throw new ApiError(400, 'That date isn’t part of this event.');
161
+ const at = k && findOccurrence({ ...e, movedDates: {} }, key, await repo.getSettings());
162
+ if (!k || !at) throw new ApiError(400, 'That date isn’t part of this event.');
163
+ // its length is its part's (a change from a date on may have its own)
155
164
  const startWall = prior?.start ?? key;
156
- const endWall = prior?.end ?? formatWall(addWallMinutes(k, wallMinutesBetween(es, ee)));
165
+ const endWall = prior?.end ?? formatWall(addWallMinutes(k, partMinutes(partOf(e, at))));
157
166
  const r = await moveOccurrence(repo, actor, id, key, { start: shiftBy(startWall, minutes), end: shiftBy(endWall, minutes) }, hooks);
158
167
  const previous = prior ? { start: prior.start, end: prior.end, roomIds: prior.roomIds, location: prior.location, note: prior.note } : null;
159
168
  return { event: r.event, clashes: r.clashes, undo: { kind: 'date', id, key, previous } };
160
169
  }
161
170
 
171
+ // Move one date of a repeating event and every date after it by `minutes`
172
+ // (a drag on the Schedule, "This and following dates"): a change from that
173
+ // date on (lib/calendar/segments.ts) whose rule follows the new weekday or
174
+ // day, keeping each date's length. The dates before it stay where they are.
175
+ export async function moveFollowingBy(repo: ContentRepository, actor: MoveActor, id: string, key: string, minutes: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo; clashes: BusySlot[] }> {
176
+ const e = await repo.getEvent(id);
177
+ if (!e) throw new ApiError(404, 'Event not found.');
178
+ if (!Number.isInteger(minutes) || Math.abs(minutes) > 1440 * 3660) throw new ApiError(400, 'Pick a day to move it to.');
179
+ const { changeFromDate, changeFromDefaults } = await import('./segments');
180
+ const from = await changeFromDefaults(repo, id, key);
181
+ if (!from) throw new ApiError(400, 'That date isn’t part of this event.');
182
+ // a moved date was dragged from where it is now
183
+ const moved = e.movedDates[key];
184
+ const r = await changeFromDate(repo, actor, id, key, { start: shiftBy(moved?.start ?? from.start, minutes), end: shiftBy(moved?.end ?? from.end, minutes) }, hooks);
185
+ // undo puts the segments back, from the earliest one that changed
186
+ const same = (a: SeriesSegment, list: SeriesSegment[]) => list.some((x) => JSON.stringify(x) === JSON.stringify(a));
187
+ const changed = [...r.event.segments.filter((g) => !same(g, e.segments)), ...e.segments.filter((g) => !same(g, r.event.segments))].map((g) => g.from).sort();
188
+ return { event: r.event, clashes: r.clashes, undo: { kind: 'following', id, from: changed[0] ?? key, previous: e.segments } };
189
+ }
190
+
162
191
  export async function undoMove(repo: ContentRepository, actor: MoveActor, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
163
192
  if (undo.kind === 'series') {
164
193
  await moveSeries(repo, undo.id, undo.days, hooks, undo.minutes ?? 0);
165
194
  return;
166
195
  }
196
+ if (undo.kind === 'following') {
197
+ const { restoreSegments } = await import('./segments');
198
+ await restoreSegments(repo, actor, undo.id, undo.from, undo.previous, hooks);
199
+ return;
200
+ }
167
201
  if (undo.kind === 'date') {
168
202
  if (undo.previous) await moveOccurrence(repo, actor, undo.id, undo.key, { ...undo.previous, roomIds: undo.previous.roomIds ?? [], location: undo.previous.location ?? null }, hooks);
169
203
  else await moveOccurrenceBack(repo, actor, undo.id, undo.key, hooks);