@acms-subir/core 0.3.43 → 0.3.45

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.45
4
+
5
+ ### Patch Changes
6
+
7
+ - 318e6a1: Rooms: clashes between events, a public free/busy check, and room colours and names in public event data.
8
+
9
+ **Clashes.** Room bookings already couldn't take a room an event holds, but two events could hold the same room at once with no warning. The event editor's Rooms box now warns when another event holds a ticked room at an overlapping time, setup and cleanup included. It shows that event's title (linking to it) and date, for example "Hall is also held by another event · Practice · Thu, Jan 10 · 7:00 – 8:00 PM". Both events keep the room, so the warning says to change a time or pick another room if they can't share it. The room list says "Another event" or "Busy on 3 of 12 dates", and Quick add counts these as busy too. Other events count while they hold rooms: published or scheduled, drafts with Hold now, and not on a cancelled date. New in `@acms-subir/core/server`:
10
+
11
+ - `eventRoomClashes(repo, eventOrId, { horizonDays?, now? })`: the other events holding the same rooms at overlapping times, as `{ hold, other }` pairs.
12
+ - `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in a span (event holds, bookings, staff blocks and busy calendars), with names for staff. `ignore: { eventId, key }` leaves out the date being moved.
13
+
14
+ Neither function checks permissions. Holds now carry the date's occurrence `key`, and occurrences carry `roomIds`, the rooms that date holds.
15
+
16
+ **Public free/busy.** `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers `{ room, start, end, free, busy: [{ start, end }] }`. The busy times are merged and clipped to the span and never say what or who. Only rooms with the new **Anyone can check if it’s free** switch answer; any other slug is a 404. Spans are at most 31 days, answers are never cached, and each address gets 30 checks a minute.
17
+
18
+ **Room colour and names.** Each room (Admin → Calendar → Rooms) gets:
19
+
20
+ - a **Colour**, for events in it on calendars and screens;
21
+ - **Name it on public events**, off by default, so existing rooms stay unnamed until staff turn it on.
22
+
23
+ Public event data now has `rooms: [{ slug, name, color }]`, listing only rooms with that switch on, with `color` `null` when a room has none. It appears in `/api/v1/events` (on each event and on each occurrence, the rooms that date holds), `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events` and webhook payloads. An API key with `events:read` and the agent tools see every room. Calendar views (`collectViewEvents`: `/calendar`, cards, embeds) give each native event `rooms: [{ id, slug, name, color }]` under the same rule. The editor API's resources take `color`, `showOnEvents` and `requestable`, and environment sync carries them.
24
+
25
+ Migration 64 adds `resources.color`, `show_on_events` and `requestable`; changing the colour or the naming switch refreshes cached pages and API answers. New docs: `npx acms docs calendar` covers repeats, cancelled dates, rooms and clashes, and `npx acms docs server` has the two new functions.
26
+
27
+ ## 0.3.44
28
+
29
+ ### Patch Changes
30
+
31
+ - b011cee: Moving one date of a repeating event on the admin Schedule now keeps what belongs to that date. Dragging a single date still splits it off into a one-off copy, but the copy now keeps the series' hosts, room hold, translations and SEO title and description. Before, all of these were left behind, so the room looked free at the new time, the date dropped off the hosts' My events, and the Spanish title was gone. That date's RSVPs, including places in line, move to the copy at the new time. Everyone with a seat gets the "new time" email with the updated calendar file, while places in line move quietly. **Undo** moves the RSVPs back to the series' date (with the email again) before the copy is deleted, so undoing never cancels anyone. Undo now refuses with "This event has unpublished changes" before anything changes, instead of deleting the copy first.
32
+
33
+ Dates moved before this release keep their copies as they are: staff can still add the hosts and room on those by hand. `repo.createEvent` now stores `translations` (it ignored them before), and the repository has a new `moveOccurrenceRsvps(eventId, start, { eventId?, start, end })`.
34
+
3
35
  ## 0.3.43
4
36
 
5
37
  ### Patch Changes
package/cli/main.mjs CHANGED
@@ -10,7 +10,7 @@ import { addMobile, mobileCommand, listApps } from './mobile.mjs';
10
10
  import { mobileDoctor, renderDoctor } from './mobile-doctor.mjs';
11
11
  import { monitorCommand, renderMonitor } from './monitor.mjs';
12
12
  const HELP=`acms — ACMS project, theme and agent tools
13
- Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server
13
+ Discovery: inspect | schema theme | docs theme | docs sections | docs mobile | docs sync | docs server | docs calendar
14
14
  agent init [--mobile] write .agents/skills (theme; mobile when an app exists)
15
15
  Check: check [--build] validate every project theme, refresh section types,
16
16
  report sections without a native app version
@@ -140,6 +140,7 @@ export async function main() {
140
140
  else if(cmd==='docs'&&sub==='mobile')data={markdown:await readFile(resolve(CORE,'docs/mobile.md'),'utf8')};
141
141
  else if(cmd==='docs'&&sub==='sync')data={markdown:await readFile(resolve(CORE,'docs/environment-sync.md'),'utf8')};
142
142
  else if(cmd==='docs'&&sub==='server')data={markdown:await readFile(resolve(CORE,'docs/server.md'),'utf8')};
143
+ else if(cmd==='docs'&&sub==='calendar')data={markdown:await readFile(resolve(CORE,'docs/calendar.md'),'utf8')};
143
144
  else if(cmd==='section')data=await sectionCommand(sub,rest,flags);
144
145
  else if(cmd==='check') {
145
146
  const p=await project(),themes=(await listLocalThemes(p)).filter(t=>t.origin==='project');
@@ -148,7 +149,7 @@ export async function main() {
148
149
  }
149
150
  else if(cmd==='agent'&&sub==='init')data=await agentInit(flags);
150
151
  else if(cmd==='inspect') {
151
- const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
152
+ const p=await project();data={coreVersion:p.version,themeApiVersion:1,project:p.pkg.name,targets:{cloudflare:await exists('wrangler.jsonc')||await exists('wrangler.toml'),docker:await exists('Dockerfile')},themes:await listLocalThemes(p),capabilities:['theme-schema','theme-create','theme-check','theme-dev','theme-pack','scoped-design-api','editable-sections','section-library','section-types','page-presets','mobile-apps','mobile-editor-apps','mobile-recipes','mobile-docs','mobile-doctor','monitor-kuma','migrate','server-api','calendar-docs'],mobileApps:await mobileApps(),configuration:await readJson(resolve(p.cwd,'.astro/acms-project.json')).catch(()=>({source:'Run astro sync or dev to discover configured collections.',collections:null}))};
152
153
  } else if(cmd==='theme')data=await themeCommand(sub,rest,flags);
153
154
  else if(['site','design','content','media'].includes(cmd))data=await remoteCommand(cmd,sub,rest,flags);
154
155
  else fail('USAGE','Unknown command.',[],['Run acms --help']);
@@ -1136,12 +1136,23 @@ var MIGRATIONS = [
1136
1136
  // with or without the answers); '{}' = off. Not public, so no cache trigger.
1137
1137
  { version: 63, statements: [
1138
1138
  `ALTER TABLE forms ADD COLUMN alerts TEXT NOT NULL DEFAULT '{}'`
1139
+ ] },
1140
+ // rooms: a colour and whether public event data names them
1141
+ // (Resource.color / .showOnEvents, both visible), and whether anyone may ask
1142
+ // if they're free (Resource.requestable, never shown, so no cache bump)
1143
+ { version: 64, statements: [
1144
+ `ALTER TABLE resources ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
1145
+ `ALTER TABLE resources ADD COLUMN show_on_events INTEGER NOT NULL DEFAULT 0`,
1146
+ `ALTER TABLE resources ADD COLUMN requestable INTEGER NOT NULL DEFAULT 0`,
1147
+ `DROP TRIGGER content_version_resources_update`,
1148
+ contentVersionTriggers("resources", [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"])[2]
1139
1149
  ] }
1140
1150
  ];
1141
1151
  var CURRENT_CONTENT_VERSION_COLUMNS = {
1142
1152
  ...CONTENT_VERSION_COLUMNS,
1143
1153
  settings: [...CONTENT_VERSION_COLUMNS.settings, "links_verified", "links_avatar_shape", "links_avatar_fit", "links_avatar_scale", "legal"],
1144
1154
  events: [...CONTENT_VERSION_COLUMNS.events, "cancelled_dates"],
1155
+ resources: [...CONTENT_VERSION_COLUMNS.resources, "color", "show_on_events"],
1145
1156
  announcements: ANNOUNCEMENT_VISIBLE_COLUMNS
1146
1157
  };
1147
1158
  var LATEST_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
@@ -0,0 +1,112 @@
1
+ # Calendar: repeats, single dates, rooms and clashes
2
+
3
+ How events repeat, how one date of a series is changed, and how events hold rooms.
4
+ Read it with `npx acms docs calendar`. The functions named here come from
5
+ `@acms-subir/core/server` (`npx acms docs server` has their signatures), and the app
6
+ API is in `docs/mobile-api-v1.md` in the framework repository.
7
+
8
+ ## Times and repeats
9
+
10
+ An event stores **wall-clock** times in its own timezone (the site's when it has
11
+ none): `start` and `end` are `YYYY-MM-DDTHH:mm`, or `YYYY-MM-DD` for all-day events,
12
+ whose `end` is the last day, inclusive. A 6 PM class stays at 6 PM across daylight
13
+ saving changes.
14
+
15
+ A repeating event has an `rrule`, the RFC 5545 rule without the `RRULE:` prefix, such
16
+ as `FREQ=WEEKLY;BYDAY=TH`. The editor offers daily, weekly on chosen days, monthly by
17
+ date or by weekday ("second Tuesday"), and yearly, each ending never, on a date or
18
+ after a number of times. Other rules round-trip as custom rules. Every repeat is the
19
+ same event: one page, one slug, the same hosts and rooms. The calendar, the app API,
20
+ the ICS feeds and room holds expand it into **occurrences** (dates) as needed.
21
+
22
+ `exdates` skips dates entirely: they vanish from the calendar and feeds. Dragging a
23
+ whole series on the admin Schedule moves the rule with it ("weekly on Thursday" moved a
24
+ day becomes "weekly on Friday").
25
+
26
+ ## Occurrence keys
27
+
28
+ Each date of a series has a **key**: its start as wall-clock time in the event's
29
+ timezone, in the same format as `start` (`2030-01-10T18:00`, or `2030-01-10` for
30
+ all-day events). Changes to single dates are stored on the event under that key. Take
31
+ keys from `listOccurrences` (`o.key`) rather than building them.
32
+
33
+ When the series moves to another day, stored keys move with it. When its time or rule
34
+ is edited instead, keys that no longer match a date are ignored, not an error.
35
+
36
+ ## Cancelled dates
37
+
38
+ Cancelling one date (**Cancel this date** in the editor, the Schedule, a host's My
39
+ events, or `cancelOccurrence`) applies at once, even on a published event. The date
40
+ stays listed, struck through as "Cancelled", so nobody wonders where it went:
41
+
42
+ - the event page, `/calendar`, `/api/v1/events` and `/api/calendar/events` mark it
43
+ `cancelled`;
44
+ - the ICS feeds add an override for that date with `STATUS:CANCELLED`;
45
+ - its room holds are released for that slot;
46
+ - its RSVPs are cancelled, and each person gets the "cancelled" email. Restoring the
47
+ date doesn't bring them back, so people sign up again;
48
+ - the "When an event changes" push rule can announce it.
49
+
50
+ The keys are stored in `cancelledDates`. `restoreOccurrence` undoes a cancellation.
51
+
52
+ ## Rooms
53
+
54
+ Rooms, people and equipment are **Resources** (Admin → Calendar → Rooms; they need
55
+ `calendar.bookings`, which is on by default). An event **holds** rooms
56
+ (`roomHold.resourceIds`, the Rooms box in the editor) while it happens, plus any setup
57
+ and cleanup minutes, or all day:
58
+
59
+ - Published and scheduled events hold their rooms. A draft holds them only with **Hold
60
+ now** on. A cancelled event, or a cancelled date, holds nothing.
61
+ - Holds are worked out from the events every time and never stored, so moving,
62
+ repeating, skipping or cancelling a date frees or moves the room automatically.
63
+ - Room bookings (`/book`, embeds, the app, the API) can't take a held room.
64
+
65
+ Each room also has:
66
+
67
+ - **Colour** (`color`, `#rrggbb` or `''`): for events in that room on calendars and
68
+ screens.
69
+ - **Name it on public events** (`showOnEvents`, off by default): public event data
70
+ names the room. When it's off, only staff see that an event uses the room.
71
+ - **Anyone can check if it’s free** (`requestable`, off by default): the room answers
72
+ the public free/busy check below.
73
+
74
+ ### Rooms in event data
75
+
76
+ - `/api/v1/events`, `/api/v1/events/:slug`, `/api/v1/search`, `/api/calendar/events`
77
+ and webhook payloads carry `rooms: [{ slug, name, color }]` on each event (`color`
78
+ is `null` when the room has none). Each occurrence also has `rooms`, the rooms that
79
+ date holds. Only rooms with **Name it on public events** appear. Staff callers (an
80
+ API key with `events:read`, the agent tools) see every room.
81
+ - Calendar views built on `collectViewEvents` (the `/calendar` page, calendar cards,
82
+ embeds) get `rooms: [{ id, slug, name, color }]` on each native event's
83
+ occurrence, with the same rule for public views. Admin views get every room.
84
+ - Site code reads `event.roomHold.resourceIds`, or `o.roomIds` on an occurrence.
85
+
86
+ ### Clashes
87
+
88
+ Bookings can't take a held room, but two **events** may hold the same room at once.
89
+ The event editor's Rooms box warns when another event holds a ticked room at an
90
+ overlapping time, with that event's title and date. Both events keep the room, so
91
+ change a time or pick another room if they can't share it. The same box still lists
92
+ bookings the event would overlap, with Keep or Cancel and email.
93
+
94
+ Site code and other framework features use the same checks:
95
+
96
+ - `eventRoomClashes(repo, eventOrId)`: the other events holding this event's rooms at
97
+ overlapping times over the next year, as pairs of `{ hold, other }` (one date of each).
98
+ - `roomBusy(repo, { roomIds, from, to, ignore? })`: everything that takes the rooms in
99
+ a span (event holds, bookings, staff blocks and the rooms' busy calendars), with
100
+ names. `ignore: { eventId, key }` leaves out the date you're about to move.
101
+
102
+ Both include names, so show them to staff only. Neither checks permissions.
103
+
104
+ ### Public free/busy
105
+
106
+ `GET /api/v1/rooms/free?room=<slug>&start=<day or date-time>&end=<…>` answers
107
+ whether a room is free, plus the busy times inside the span (merged), never what or
108
+ who. Only rooms with **Anyone can check if it’s free** answer, and every other slug is
109
+ a 404. Spans are at most 31 days, and a plain day means midnight in the site's
110
+ timezone. Answers are never cached, and each address gets 30 checks a minute. The
111
+ client has `roomFree({ room, start, end })`, and the native package has
112
+ `useRoomFree(query)`.
package/docs/mobile.md CHANGED
@@ -127,6 +127,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
127
127
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
128
128
  | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
129
129
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
130
+ | `useRoomFree({ room, start, end })` | bookings: is a requestable room free (no names) |
130
131
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
131
132
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNewPushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications); topic names come in the app's language |
132
133
  | `useAnnouncements({ kind, pinned })` / `useAnnouncement(id)` / `useAnnouncementState()` | announcements up right now; unread and closed banners kept on the phone (a dot, never a number); idle while `features.announcements` is false |
package/docs/server.md CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  getRepo, currentUser,
13
13
  listEvents, getEvent, listOccurrences,
14
14
  cancelOccurrence, restoreOccurrence,
15
+ eventRoomClashes, roomBusy,
15
16
  listMyEvents, listEventRequests, submitEventRequest, updateEventRequest,
16
17
  withdrawEventRequest, approveEventRequest, declineEventRequest, setHostedOccurrence,
17
18
  } from '@acms-subir/core/server';
@@ -32,6 +33,8 @@ getEvent(repo: Repo, id: string): Promise<CalEvent | null>
32
33
  listOccurrences(repo: Repo, w: { eventId?: string; from: Date | number; to: Date | number; tag?: string; excludeTag?: string }): Promise<Occurrence[]>
33
34
  cancelOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
34
35
  restoreOccurrence(repo: Repo, eventId: string, occurrenceKey: string, ctx: WriteContext): Promise<CalEvent>
36
+ eventRoomClashes(repo: Repo, event: string | CalEvent, opts?: { horizonDays?: number; now?: Date | number }): Promise<EventClash[]>
37
+ roomBusy(repo: Repo, q: { roomIds: string[]; from: Date | number; to: Date | number; ignore?: { eventId: string; key?: string } }): Promise<BusySlot[]>
35
38
 
36
39
  // event requests: `who` is currentUser()'s result (anything with `id` and `can()` works)
37
40
  listMyEvents(repo: Repo, who: ServerUser | null): Promise<CalEvent[]>
@@ -45,12 +48,16 @@ setHostedOccurrence(repo: Repo, who: ServerUser | null, eventId: string, occurre
45
48
 
46
49
  type Role = 'owner' | 'administrator' | 'editor' | 'author' | 'contributor' | (string & {})
47
50
  interface ServerUser { id: string; email: string; name: string; role: Role; staff: boolean; can(permission: string): boolean }
48
- interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean }
51
+ interface Occurrence { event: CalEvent; start: number; end: number; allDay: boolean; days: number; key: string; cancelled: boolean; roomIds: string[] }
52
+ interface Hold { resourceId: string; eventId: string; key: string; title: string; start: number; end: number; eventStart: number; eventEnd: number }
53
+ interface EventClash { hold: Hold; other: Hold }
54
+ interface BusySlot { resourceId: string; start: number; end: number; kind: 'event' | 'booking' | 'block' | 'calendar'; title: string; eventId?: string; key?: string; bookingId?: string }
49
55
  interface WriteContext { url: URL; locals: unknown }
50
56
  ```
51
57
 
52
58
  The types `Repo`, `Role`, `ServerUser`, `CalEvent`, `Occurrence`, `ListEventsOptions`,
53
- `OccurrenceWindow`, `WriteContext` and `EventRequestInput` are exported too.
59
+ `OccurrenceWindow`, `WriteContext`, `EventRequestInput`, `Hold`, `EventClash` and
60
+ `BusySlot` are exported too.
54
61
 
55
62
  - **`getRepo(locals)`**: the site's content store for this request. Pass `Astro.locals`
56
63
  (or the endpoint/action's `locals`). `Repo` is an opaque handle; pass it to the other
@@ -121,6 +128,44 @@ another day (the admin's drag-to-move), its cancelled dates move with it. If its
121
128
  or rule is edited instead, keys that no longer match an occurrence are ignored, not an
122
129
  error.
123
130
 
131
+ ## Rooms
132
+
133
+ Events hold rooms (`event.roomHold.resourceIds`, the Rooms box in the event editor)
134
+ while they happen, plus any setup and cleanup minutes, or all day. Room bookings
135
+ already can't take a held room. Two events, though, may hold the same room at once.
136
+ These two functions find where things want the same room. Both include names, so
137
+ show their results to staff only, and neither checks permissions.
138
+
139
+ - **`eventRoomClashes(repo, event, opts?)`**: other events holding the same rooms as
140
+ this one at overlapping times, soonest first. Pass an event id, or a `CalEvent` with
141
+ unsaved values, which is checked as if it were published. The other events count
142
+ while they hold rooms: when they're published or scheduled, or drafts with **Hold
143
+ now** on, and not on a cancelled date. Each clash pairs one of this event's dates
144
+ (`hold`) with the other event's date (`other`). `hold.start`/`hold.end` include
145
+ setup and cleanup, and `eventStart`/`eventEnd` are the date's own times. It looks a
146
+ year ahead (`horizonDays`). The event editor shows the same check as a warning; both
147
+ events keep the room.
148
+ - **`roomBusy(repo, { roomIds, from, to, ignore? })`**: everything that takes these
149
+ rooms in `[from, to)`, soonest first. That covers events holding them, pending and
150
+ confirmed bookings, staff blocks, and the rooms' busy calendars. `kind` says which,
151
+ and `title` is the event's title, the booker's name or the block's note (`''` for a
152
+ busy calendar). `ignore: { eventId, key }` leaves out one date of an event, such as
153
+ the date you're about to move, and `ignore: { eventId }` leaves out the whole event.
154
+ An empty list means every room is free. Windows are at most 400 days.
155
+
156
+ ```ts
157
+ // is the hall free next Wednesday 5–6 PM for this one class date?
158
+ const busy = await roomBusy(repo, {
159
+ roomIds: [hall.id], from: new Date('2030-01-09T22:00:00Z'), to: new Date('2030-01-09T23:00:00Z'),
160
+ ignore: { eventId: classEvent.id, key: '2030-01-10T18:00' },
161
+ });
162
+ if (busy.length) return `Taken by ${busy[0].title || 'something else'}`;
163
+ ```
164
+
165
+ For people outside the staff, `GET /api/v1/rooms/free?room=<slug>&start=&end=` answers
166
+ only free or busy (with the busy times), never what or who. It works only for rooms
167
+ marked **Anyone can check if it’s free**.
168
+
124
169
  ## Private event fields
125
170
 
126
171
  A custom field can be private:
package/integration.mjs CHANGED
@@ -874,6 +874,8 @@ export default function acms(options = {}) {
874
874
  ['/embed/book/[resource]/[link]', 'embed/book/[resource]/[link].astro'],
875
875
  ['/api/v1/resources', 'api/v1/resources.ts'],
876
876
  ['/api/v1/availability', 'api/v1/availability.ts'],
877
+ // free or busy for a requestable room (no names)
878
+ ['/api/v1/rooms/free', 'api/v1/rooms/free.ts'],
877
879
  ['/api/calendar/availability', 'api/calendar/availability.ts'],
878
880
  ['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
879
881
  ['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.43",
3
+ "version": "0.3.45",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -22,7 +22,7 @@ import { getCalendarEvents } from '../lib/calendar/ics';
22
22
  import { siteLocale } from '../lib/dates';
23
23
  import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
24
24
  import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
25
- import { holdConflicts } from '../lib/booking/holds';
25
+ import { eventClashes, holdConflicts } from '../lib/booking/holds';
26
26
  import { setBookingStatus } from '../lib/booking/service';
27
27
  import { eventTimeZone, featuredOccurrence, formatOccurrence, isValidTimeZone, normalizeEventTimes } from '../lib/calendar/events';
28
28
  import { eventsFromCsv, eventsFromIcs, eventsFromVEvents } from '../lib/calendar/import';
@@ -1034,8 +1034,9 @@ export const server = {
1034
1034
  },
1035
1035
  }),
1036
1036
  // Event editor → Rooms: which rooms/people are free at this event's time
1037
- // (for every room, so the picker can label them), and which existing
1038
- // bookings the selected rooms would overlap. Works on unsaved values.
1037
+ // (for every room, so the picker can label them), which existing bookings
1038
+ // the selected rooms would overlap, and which other events hold them then
1039
+ // (a warning: both keep the room). Works on unsaved values.
1039
1040
  roomStatus: defineAction({
1040
1041
  input: z.object({
1041
1042
  eventId: z.string().default(''),
@@ -1064,7 +1065,7 @@ export const server = {
1064
1065
  timezone: input.timezone, rrule: input.rrule, exdates: input.exdates, status: 'published', startAt: 0, endAt: 0, createdAt: 0, updatedAt: 0,
1065
1066
  roomHold: { ...input.roomHold, resourceIds: resources.map((r) => r.id), keep: input.roomHold.keep },
1066
1067
  });
1067
- const { conflicts, holds } = await holdConflicts(repo, settings, probe, { now });
1068
+ const [{ conflicts, holds }, { clashes }] = await Promise.all([holdConflicts(repo, settings, probe, { now }), eventClashes(repo, settings, probe, { now })]);
1068
1069
  const tz = settings.timezone || 'UTC';
1069
1070
  const locale = siteLocale(settings);
1070
1071
  const day = new Intl.DateTimeFormat(locale, { weekday: 'short', month: 'short', day: 'numeric', timeZone: tz });
@@ -1077,9 +1078,18 @@ export const server = {
1077
1078
  holds: holdsLabel,
1078
1079
  rooms: resources.map((r) => {
1079
1080
  const mine = conflicts.filter((c) => c.booking.resourceId === r.id);
1081
+ // one row per other event's date, however many of ours it overlaps
1082
+ const others = [...new Map(clashes.filter((c) => c.other.resourceId === r.id).map((c) => [`${c.other.eventId}:${c.other.eventStart}`, c])).values()];
1080
1083
  return {
1081
1084
  id: r.id, name: r.name, kind: r.kind, capacity: r.capacity,
1082
1085
  datesWithBookings: new Set(mine.map((c) => c.hold.eventStart)).size, dates: occurrences,
1086
+ datesWithEvents: new Set(others.map((c) => c.hold.eventStart)).size,
1087
+ datesBusy: new Set([...mine.map((c) => c.hold.eventStart), ...others.map((c) => c.hold.eventStart)]).size,
1088
+ events: others.slice(0, 20).map((c) => ({
1089
+ id: `${c.other.eventId}:${c.other.eventStart}`, eventId: c.other.eventId, title: c.other.title,
1090
+ when: `${day.format(c.other.eventStart)} · ${range(c.other.eventStart, c.other.eventEnd)}`,
1091
+ })),
1092
+ eventCount: others.length,
1083
1093
  conflicts: mine.slice(0, 20).map((c) => ({
1084
1094
  id: c.booking.id, name: c.booking.kind === 'block' ? `Blocked${c.booking.notes ? ` · ${c.booking.notes}` : ''}` : c.booking.name,
1085
1095
  block: c.booking.kind === 'block', status: c.booking.status,
@@ -98,7 +98,7 @@ export default function QuickAdd(props: Props) {
98
98
  const s = allDay ? day : `${day}T${from}`;
99
99
  const e = allDay ? (endDay < day ? day : endDay) : `${endDay < day ? day : endDay}T${to}`;
100
100
  fetchRoomStatus({ eventId: '', title, start: s, end: e, allDay, timezone: '', rrule: repeatRule(repeat, day), exdates: [] }, NO_HOLD)
101
- .then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length }))))
101
+ .then((d) => setRooms((d?.rooms ?? []).map((r) => ({ id: r.id, name: r.name, busy: r.conflicts.length + r.eventCount }))))
102
102
  .catch(() => {});
103
103
  }, 350);
104
104
  return () => clearTimeout(t);
@@ -5,7 +5,8 @@ import { actions } from 'astro:actions';
5
5
  // they can't be booked while it happens (± setup/cleanup) or all day. Each
6
6
  // room shows whether it's free at the event's time; overlapping bookings show
7
7
  // right here, with Keep (share the room) or Cancel and email. The same check
8
- // runs again when you publish (RoomConflictDialog).
8
+ // runs again when you publish (RoomConflictDialog). Other events holding the
9
+ // room at the same time show as a warning: both keep it.
9
10
 
10
11
  export interface RoomHoldValue {
11
12
  resourceIds: string[];
@@ -28,7 +29,12 @@ export interface RoomTimes {
28
29
  }
29
30
 
30
31
  interface Conflict { id: string; name: string; when: string; status: string; block: boolean }
31
- interface RoomRow { id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number }
32
+ interface EventClash { id: string; eventId: string; title: string; when: string }
33
+ interface RoomRow {
34
+ id: string; name: string; kind: string; capacity: number | null; conflicts: Conflict[]; datesWithBookings: number; dates: number;
35
+ // other events holding it at the same time (eventCount: all of them, events: the first 20)
36
+ events: EventClash[]; eventCount: number; datesWithEvents: number; datesBusy: number;
37
+ }
32
38
  interface Status { rooms: RoomRow[]; holds: string }
33
39
 
34
40
  export const NO_HOLD: RoomHoldValue = { resourceIds: [], span: 'event', before: 0, after: 0, whileDraft: false, keep: [] };
@@ -77,11 +83,13 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
77
83
  if (auto) onLocation(names(ids));
78
84
  };
79
85
  const label = (r: RoomRow) => {
80
- if (!r.conflicts.length) return { text: 'Free', cls: 'free' };
81
- if (r.dates > 1) return { text: `Busy on ${r.datesWithBookings} of ${r.dates} dates`, cls: 'busy' };
82
- return { text: r.conflicts.length === 1 ? 'Busy' : `Busy · ${r.conflicts.length} bookings`, cls: 'busy' };
86
+ if (!r.conflicts.length && !r.eventCount) return { text: 'Free', cls: 'free' };
87
+ if (r.dates > 1) return { text: `Busy on ${r.datesBusy} of ${r.dates} dates`, cls: 'busy' };
88
+ if (!r.conflicts.length) return { text: r.eventCount === 1 ? 'Another event' : `${r.eventCount} other events`, cls: 'busy' };
89
+ return { text: r.conflicts.length === 1 && !r.eventCount ? 'Busy' : `Busy · ${r.conflicts.length + r.eventCount} bookings and events`, cls: 'busy' };
83
90
  };
84
91
  const conflicted = data.rooms.filter((r) => selected.has(r.id) && r.conflicts.length);
92
+ const shared = data.rooms.filter((r) => selected.has(r.id) && r.eventCount);
85
93
  const draft = status === 'draft';
86
94
 
87
95
  async function cancelBookings(ids: string[]) {
@@ -157,6 +165,16 @@ export default function RoomHolds({ times, hold, status, locationName, onChange,
157
165
  )}
158
166
  </div>
159
167
  ))}
168
+ {shared.map((r) => (
169
+ <div key={r.id} className="rh-conflict rh-clash" role="status">
170
+ <b>{r.name} is also held by {r.eventCount === 1 ? 'another event' : `other events on ${r.eventCount} dates`}</b>
171
+ {r.events.slice(0, 5).map((c) => (
172
+ <div key={c.id} className="rh-bk"><a href={`/admin/calendar/events/${c.eventId}`} target="_blank" rel="noopener">{c.title}</a><small>{c.when}</small></div>
173
+ ))}
174
+ {r.eventCount > 5 && <small className="rh-more">+{r.eventCount - 5} more</small>}
175
+ <small className="rh-more">Both events keep the room. Change a time or pick another room if they can’t share it.</small>
176
+ </div>
177
+ ))}
160
178
  {note && <p className="pe-ev-note rh-done">{note}</p>}
161
179
  {data.holds && <p className="pe-ev-note">{data.holds}{draft && !hold.whileDraft ? ' once published.' : '.'}</p>}
162
180
  </>
@@ -4,6 +4,8 @@
4
4
  // moving, repeating, skipping a date, cancelling the event or one of its
5
5
  // dates, or deleting it frees or moves the room automatically. availabilityContext() adds these to its
6
6
  // busy list, so every booking path (pages, embeds, app, API, agent) obeys.
7
+ // Two events may hold the same room at once: eventClashes() finds those for
8
+ // staff, and roomBusy() answers "is this room taken then?" for one span.
7
9
  import type { ContentRepository } from '../repo/types';
8
10
  import type { Booking, CalEvent, Resource, Settings } from '../schema';
9
11
  import { expandEvents } from '../calendar/events';
@@ -13,6 +15,8 @@ import { resourceZone } from './availability';
13
15
  export interface Hold {
14
16
  resourceId: string;
15
17
  eventId: string;
18
+ // the occurrence key of the date that holds it (lib/calendar/events.ts)
19
+ key: string;
16
20
  title: string;
17
21
  start: number; // incl. setup
18
22
  end: number; // incl. cleanup
@@ -58,7 +62,8 @@ export function holdsFromEvents(
58
62
  // a cancelled date frees the room for that slot only
59
63
  if (o.cancelled) continue;
60
64
  const h = o.event.roomHold;
61
- for (const resourceId of h.resourceIds) {
65
+ // the rooms this date holds
66
+ for (const resourceId of o.roomIds) {
62
67
  if (opts.resourceId && resourceId !== opts.resourceId) continue;
63
68
  const r = resources.get(resourceId);
64
69
  if (!r) continue;
@@ -73,7 +78,7 @@ export function holdsFromEvents(
73
78
  end = o.end + h.after * 60_000;
74
79
  }
75
80
  if (end <= from || start >= to) continue;
76
- out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
81
+ out.push({ resourceId, eventId: o.event.id, key: o.key, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
77
82
  }
78
83
  }
79
84
  return out.sort((a, b) => a.start - b.start);
@@ -120,3 +125,94 @@ export async function holdConflicts(
120
125
  }
121
126
  return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
122
127
  }
128
+
129
+ // ---- events against events ------------------------------------------------
130
+
131
+ export interface EventClash {
132
+ /** one date of the event being checked, in one room */
133
+ hold: Hold;
134
+ /** another event's date holding that room at an overlapping time */
135
+ other: Hold;
136
+ }
137
+
138
+ /**
139
+ * Other events holding the same rooms at overlapping times (setup and cleanup
140
+ * included), soonest first. Like holdConflicts, `event` may be unsaved and is
141
+ * checked as if published; the others count only while they hold rooms
142
+ * (published or scheduled, drafts with "Hold now", not cancelled). These are
143
+ * warnings for staff: both events keep the room.
144
+ */
145
+ export async function eventClashes(
146
+ repo: ContentRepository,
147
+ settings: Pick<Settings, 'timezone'>,
148
+ event: CalEvent,
149
+ opts: { now?: number; horizonDays?: number } = {}
150
+ ): Promise<{ clashes: EventClash[]; holds: Hold[] }> {
151
+ const now = opts.now ?? Date.now();
152
+ const to = now + (opts.horizonDays ?? 366) * DAY;
153
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
154
+ const byId = new Map(resources.map((r) => [r.id, r]));
155
+ const holds = holdsFromEvents([event], byId, settings, now, to, { force: true });
156
+ if (!holds.length) return { clashes: [], holds };
157
+ const rooms = new Set(holds.map((h) => h.resourceId));
158
+ const others = events.filter((e) => e.id !== event.id && e.roomHold.resourceIds.some((id) => rooms.has(id)));
159
+ const from = Math.min(...holds.map((h) => h.start)), until = Math.max(...holds.map((h) => h.end));
160
+ const theirs = holdsFromEvents(others, byId, settings, from, until);
161
+ const clashes: EventClash[] = [];
162
+ for (const hold of holds) {
163
+ for (const other of theirs) {
164
+ if (other.resourceId === hold.resourceId && other.start < hold.end && other.end > hold.start) clashes.push({ hold, other });
165
+ }
166
+ }
167
+ return { clashes, holds };
168
+ }
169
+
170
+ export interface BusySlot {
171
+ resourceId: string;
172
+ start: number;
173
+ end: number;
174
+ /** an event holding it, a booking, a staff block, or one of its busy calendars */
175
+ kind: 'event' | 'booking' | 'block' | 'calendar';
176
+ /** the event's title, the booker's name or the block's note; '' for busy calendars. Staff only. */
177
+ title: string;
178
+ eventId?: string;
179
+ /** the event date's occurrence key */
180
+ key?: string;
181
+ bookingId?: string;
182
+ }
183
+
184
+ /**
185
+ * Everything that takes these rooms in [from, to): event holds (setup and
186
+ * cleanup included), pending and confirmed bookings and blocks, and the rooms'
187
+ * busy calendars. `ignore` leaves out one event, or one date of it (its key):
188
+ * the date being moved doesn't block its own new time. Names are for staff;
189
+ * the public free/busy check (GET /api/v1/rooms/free) only reports the times.
190
+ */
191
+ export async function roomBusy(
192
+ repo: ContentRepository,
193
+ settings: Pick<Settings, 'timezone'>,
194
+ q: { resourceIds: string[]; from: number; to: number; ignore?: { eventId: string; key?: string }; now?: number }
195
+ ): Promise<BusySlot[]> {
196
+ const ids = new Set(q.resourceIds);
197
+ if (!ids.size || q.to <= q.from) return [];
198
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
199
+ const rooms = resources.filter((r) => ids.has(r.id));
200
+ const byId = new Map(rooms.map((r) => [r.id, r]));
201
+ const out: BusySlot[] = [];
202
+ const holding = events.filter((e) => e.roomHold.resourceIds.some((id) => ids.has(id)) && !(q.ignore && e.id === q.ignore.eventId && !q.ignore.key));
203
+ for (const h of holdsFromEvents(holding, byId, settings, q.from, q.to)) {
204
+ if (q.ignore?.key && h.eventId === q.ignore.eventId && h.key === q.ignore.key) continue;
205
+ out.push({ resourceId: h.resourceId, start: h.start, end: h.end, kind: 'event', title: h.title, eventId: h.eventId, key: h.key });
206
+ }
207
+ // dynamic: service.ts imports this module
208
+ const { busyIntervals } = await import('./service');
209
+ await Promise.all(rooms.map(async (r) => {
210
+ const bookings = await repo.listBookings({ resourceId: r.id, from: q.from, to: q.to, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
211
+ // a booking's real time, as holdConflicts compares it
212
+ for (const b of bookings) out.push({ resourceId: r.id, start: b.start, end: b.end, kind: b.kind === 'block' ? 'block' : 'booking', title: b.kind === 'block' ? b.notes : b.name, bookingId: b.id });
213
+ for (const i of await busyIntervals(r, q.from, q.to, q.now ?? Date.now())) {
214
+ if (i.end > q.from && i.start < q.to) out.push({ resourceId: r.id, start: i.start, end: i.end, kind: 'calendar', title: '' });
215
+ }
216
+ }));
217
+ return out.sort((a, b) => a.start - b.start || a.end - b.end);
218
+ }
@@ -50,7 +50,8 @@ export const WAITLIST_HOLD = 24 * 3_600_000;
50
50
 
51
51
  const busyCache = new Map<string, { at: number; intervals: Interval[] }>();
52
52
 
53
- async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
53
+ /** a resource's busy calendars (Resource.busyFeeds) in [from, to), cached for 5 minutes */
54
+ export async function busyIntervals(resource: Resource, from: number, to: number, now: number): Promise<Interval[]> {
54
55
  if (!resource.busyFeeds.length) return [];
55
56
  const key = `${resource.id}:${resource.busyFeeds.join('|')}:${Math.floor(from / 86_400_000)}`;
56
57
  const hit = busyCache.get(key);
@@ -110,7 +110,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
110
110
  case 'get_event': {
111
111
  const e = await repo.getEvent(input.arguments.id);
112
112
  if (!e) throw new ApiError(404, 'Event not found.');
113
- return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
113
+ const rooms = new Map((await repo.listResources()).map((r) => [r.id, r]));
114
+ return { ...eventToJson(mergeEventDraft(e), ctx.origin, calMap, settings.timezone, { includePrivate: true, resources: rooms }), bodyHtml: mergeEventDraft(e).bodyHtml, hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id) };
114
115
  }
115
116
  case 'create_event': {
116
117
  const a = input.arguments;
@@ -2,8 +2,8 @@
2
2
  // webhook payloads and agent tools, so integrations see the same fields
3
3
  // everywhere. Drafts, internal ids of private calendars, staging data and
4
4
  // private custom fields (unless a staff caller asks for them) never appear
5
- // here.
6
- import type { CalEvent, Calendar } from '../schema';
5
+ // here. Rooms are named only when set to show on public events (or for staff).
6
+ import { roomColor, type CalEvent, type Calendar, type Resource } from '../schema';
7
7
  import { calendarFeedUrl, eventIcsUrl, eventUrl, publicEventFields } from './config';
8
8
  import { describeRepeat } from './rrule';
9
9
 
@@ -33,13 +33,25 @@ export interface EventJson {
33
33
  imageAlt: string;
34
34
  tags: string[];
35
35
  calendars: { id: string; slug: string; name: string; color: string }[];
36
+ // the rooms it holds (roomHold.resourceIds); color null = the room has none
37
+ rooms: RoomJson[];
36
38
  fields: Record<string, string>;
37
39
  updatedAt: string;
38
40
  }
39
41
 
42
+ export interface RoomJson { slug: string; name: string; color: string | null }
43
+
44
+ /** Rooms as public data: only those set to show on public events, unless `includePrivate` (staff). Unknown ids are skipped. */
45
+ export function roomsToJson(ids: string[], resources: Map<string, Resource> | undefined, includePrivate = false): RoomJson[] {
46
+ if (!resources) return [];
47
+ return ids.map((id) => resources.get(id)).filter((r): r is Resource => Boolean(r) && (includePrivate || r!.showOnEvents))
48
+ .map((r) => ({ slug: r.slug, name: r.name, color: roomColor(r) || null }));
49
+ }
50
+
40
51
  const abs = (origin: string, path: string) => (/^https?:\/\//.test(path) ? path : origin + path);
41
52
 
42
- export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean } = {}): EventJson {
53
+ // resources: every room by id (rooms stay [] without it)
54
+ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string, Calendar>, siteTz = '', opts: { includePrivate?: boolean; resources?: Map<string, Resource> } = {}): EventJson {
43
55
  return {
44
56
  id: e.id,
45
57
  slug: e.slug,
@@ -68,6 +80,7 @@ export function eventToJson(e: CalEvent, origin: string, calendars: Map<string,
68
80
  .map((id) => calendars.get(id))
69
81
  .filter((c): c is Calendar => Boolean(c) && c!.visibility !== 'private')
70
82
  .map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
83
+ rooms: roomsToJson(e.roomHold.resourceIds, opts.resources, opts.includePrivate),
71
84
  fields: opts.includePrivate ? e.fields : publicEventFields(e.fields),
72
85
  updatedAt: new Date(e.updatedAt).toISOString(),
73
86
  };