@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4

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 (289) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -0,0 +1,120 @@
1
+ // Rooms held by events. An event that lists rooms/people in roomHold makes
2
+ // them unbookable while it happens (± setup/cleanup) or for the whole day.
3
+ // Holds are computed from events every time — never stored as bookings — so
4
+ // moving, repeating, skipping a date, cancelling or deleting the event frees
5
+ // or moves the room automatically. availabilityContext() adds these to its
6
+ // busy list, so every booking path (pages, embeds, app, API, agent) obeys.
7
+ import type { ContentRepository } from '../repo/types';
8
+ import type { Booking, CalEvent, Resource, Settings } from '../schema';
9
+ import { expandEvents } from '../calendar/events';
10
+ import { tzParts, zonedToUtc } from '../calendar/ics';
11
+ import { resourceZone } from './availability';
12
+
13
+ export interface Hold {
14
+ resourceId: string;
15
+ eventId: string;
16
+ title: string;
17
+ start: number; // incl. setup
18
+ end: number; // incl. cleanup
19
+ eventStart: number; // the occurrence itself
20
+ eventEnd: number;
21
+ }
22
+
23
+ const DAY = 86_400_000;
24
+
25
+ /** Does this event hold its rooms right now? */
26
+ export function holdsRooms(e: Pick<CalEvent, 'status' | 'cancelled' | 'roomHold'>): boolean {
27
+ if (e.cancelled || !e.roomHold.resourceIds.length) return false;
28
+ return e.status === 'published' || e.status === 'scheduled' || (e.status === 'draft' && e.roomHold.whileDraft);
29
+ }
30
+
31
+ // midnight (in tz) of the day containing ms
32
+ function dayStart(ms: number, tz: string): number {
33
+ const p = tzParts(ms, tz);
34
+ return zonedToUtc(p.y, p.m, p.d, 0, 0, 0, tz);
35
+ }
36
+ function nextDayStart(ms: number, tz: string): number {
37
+ // +26h then back to that day's midnight survives DST changes
38
+ return dayStart(dayStart(ms, tz) + DAY + 2 * 3_600_000, tz);
39
+ }
40
+
41
+ /** Holds for the given events in [from, to). `force` counts drafts too (editor previews). */
42
+ export function holdsFromEvents(
43
+ events: CalEvent[],
44
+ resources: Map<string, Pick<Resource, 'id' | 'timezone'>>,
45
+ settings: Pick<Settings, 'timezone'>,
46
+ from: number,
47
+ to: number,
48
+ opts: { force?: boolean; resourceId?: string } = {}
49
+ ): Hold[] {
50
+ const out: Hold[] = [];
51
+ const holding = events.filter((e) => (opts.force ? !e.cancelled && e.roomHold.resourceIds.length > 0 : holdsRooms(e))
52
+ && (!opts.resourceId || e.roomHold.resourceIds.includes(opts.resourceId)));
53
+ if (!holding.length) return out;
54
+ // widen the window so setup/cleanup and whole-day holds at the edges count
55
+ const pad = 2 * DAY;
56
+ const occurrences = expandEvents(holding, settings, { displayTz: settings.timezone || 'UTC', now: from, windowStartMs: from - pad, windowEndMs: to + pad });
57
+ for (const o of occurrences) {
58
+ const h = o.event.roomHold;
59
+ for (const resourceId of h.resourceIds) {
60
+ if (opts.resourceId && resourceId !== opts.resourceId) continue;
61
+ const r = resources.get(resourceId);
62
+ if (!r) continue;
63
+ let start: number;
64
+ let end: number;
65
+ if (h.span === 'day') {
66
+ const tz = resourceZone(r, settings.timezone || 'UTC');
67
+ start = dayStart(o.start, tz);
68
+ end = nextDayStart(Math.max(o.start, o.end - 1), tz);
69
+ } else {
70
+ start = o.start - h.before * 60_000;
71
+ end = o.end + h.after * 60_000;
72
+ }
73
+ if (end <= from || start >= to) continue;
74
+ out.push({ resourceId, eventId: o.event.id, title: o.event.title || 'Event', start, end, eventStart: o.start, eventEnd: o.end });
75
+ }
76
+ }
77
+ return out.sort((a, b) => a.start - b.start);
78
+ }
79
+
80
+ /** Holds on the site's live events (what booking pages must respect). */
81
+ export async function eventHolds(repo: ContentRepository, settings: Pick<Settings, 'timezone'>, from: number, to: number, resourceId?: string): Promise<Hold[]> {
82
+ const [events, resources] = await Promise.all([repo.listEvents(), repo.listResources()]);
83
+ return holdsFromEvents(events, new Map(resources.map((r) => [r.id, r])), settings, from, to, { resourceId });
84
+ }
85
+
86
+ export interface HoldConflict {
87
+ booking: Pick<Booking, 'id' | 'name' | 'email' | 'start' | 'end' | 'status' | 'resourceId' | 'kind' | 'notes'>;
88
+ hold: Hold;
89
+ }
90
+
91
+ /**
92
+ * Bookings that overlap an event's holds. `event` may be unsaved (the
93
+ * editor's current values): holds are computed as if it were published.
94
+ * Repeating events are checked over the next `horizonDays` (default a year).
95
+ */
96
+ export async function holdConflicts(
97
+ repo: ContentRepository,
98
+ settings: Pick<Settings, 'timezone'>,
99
+ event: CalEvent,
100
+ opts: { now?: number; horizonDays?: number } = {}
101
+ ): Promise<{ conflicts: HoldConflict[]; holds: Hold[] }> {
102
+ const now = opts.now ?? Date.now();
103
+ const to = now + (opts.horizonDays ?? 366) * DAY;
104
+ const resources = new Map((await repo.listResources()).map((r) => [r.id, r]));
105
+ const holds = holdsFromEvents([event], resources, settings, now, to, { force: true });
106
+ if (!holds.length) return { conflicts: [], holds };
107
+ const keep = new Set(event.roomHold.keep);
108
+ const conflicts: HoldConflict[] = [];
109
+ for (const resourceId of new Set(holds.map((h) => h.resourceId))) {
110
+ const mine = holds.filter((h) => h.resourceId === resourceId);
111
+ const bookings = await repo.listBookings({ resourceId, from: mine[0].start, to: mine[mine.length - 1].end, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] });
112
+ for (const b of bookings) {
113
+ if (keep.has(b.id)) continue;
114
+ // a booking's own buffers (hold_start/end) don't count, only its real time
115
+ const hold = mine.find((h) => b.start < h.end && b.end > h.start);
116
+ if (hold) conflicts.push({ booking: { id: b.id, name: b.name, email: b.email, start: b.start, end: b.end, status: b.status, resourceId: b.resourceId, kind: b.kind, notes: b.notes }, hold });
117
+ }
118
+ }
119
+ return { conflicts: conflicts.sort((a, b) => a.booking.start - b.booking.start), holds };
120
+ }
@@ -12,6 +12,8 @@ import { manageUrl as manageUrlFor, eventUrl } from '../calendar/config';
12
12
  import { eventTimeZone, locationLine } from '../calendar/events';
13
13
  import { bookingEmail, whenText, type BookingEmail } from './emails';
14
14
  import { holdOf, isBookable, resourceZone, type AvailabilityContext, type Interval } from './availability';
15
+ import { decideStatus, linkApproval } from './approval';
16
+ import { eventHolds } from './holds';
15
17
 
16
18
  export interface FlowOptions {
17
19
  origin: string;
@@ -62,11 +64,13 @@ export async function availabilityContext(
62
64
  ignoreBookingId?: string
63
65
  ): Promise<AvailabilityContext> {
64
66
  const to = now + (link.maxAdvance + 2) * 86_400_000;
65
- const [bookings, busy] = await Promise.all([
67
+ const [bookings, busy, held] = await Promise.all([
66
68
  repo.listBookings({ resourceId: resource.id, from: now - 86_400_000, to, statuses: ['pending', 'confirmed'] }),
67
69
  busyIntervals(resource, now - 86_400_000, to, now),
70
+ // events that use this room/person (lib/booking/holds.ts)
71
+ eventHolds(repo, settings, now - 86_400_000, to, resource.id),
68
72
  ]);
69
- return { resource, link, bookings, busy, siteTimezone: settings.timezone, now, ignoreBookingId };
73
+ return { resource, link, bookings, busy: [...busy, ...held.map(({ start, end }) => ({ start, end }))], siteTimezone: settings.timezone, now, ignoreBookingId };
70
74
  }
71
75
 
72
76
  // ---- notifications ---------------------------------------------------------
@@ -171,7 +175,11 @@ export async function createResourceBooking(
171
175
  // staff adding a booking by hand may ignore notice windows, never overlaps
172
176
  const ctx = await availabilityContext(repo, resource, link, settings, opts.now ?? Date.now());
173
177
  if (args.source !== 'admin' && !isBookable(ctx, args.start, args.end)) throw new BookingError(409, 'That time was just taken or isn’t available. Please pick another.');
174
- const status: BookingStatus = args.status ?? (link.autoConfirm ? 'confirmed' : 'pending');
178
+ const approval = linkApproval(link, settings);
179
+ const returning = approval.mode === 'rules' && approval.rules.firstTime
180
+ ? (await repo.listBookings({ email: booker.email, statuses: ['pending', 'confirmed'], kinds: ['booking'] })).length > 0
181
+ : true;
182
+ const status: BookingStatus = args.status ?? decideStatus(approval, { start: args.start, end: args.end, seats: 1, returning });
175
183
  const b = await repo.createBooking({
176
184
  kind: 'booking', resourceId: resource.id, linkId: link.id, eventId: '', status,
177
185
  start: args.start, end: args.end, ...holdOf(link, args.start, args.end),
@@ -275,3 +283,29 @@ export async function sendDueReminders(repo: ContentRepository, opts: FlowOption
275
283
  await kickEmail(repo,opts.locals,opts.waitUntil);
276
284
  return due.length;
277
285
  }
286
+
287
+ // cron: pending bookings nobody answered within the link's deadline get the
288
+ // chosen decision (approve or decline). Bookings that already started are
289
+ // left alone — deciding them now would only send a confusing email.
290
+ export async function decideOverdue(repo: ContentRepository, opts: FlowOptions): Promise<number> {
291
+ const now = opts.now ?? Date.now();
292
+ const pending = await repo.listBookings({ from: now, statuses: ['pending'], kinds: ['booking'] });
293
+ if (!pending.length) return 0;
294
+ const settings = await repo.getSettings();
295
+ const links = new Map<string, BookingLink | null>();
296
+ let decided = 0;
297
+ for (const b of pending) {
298
+ if (b.start <= now) continue;
299
+ if (!links.has(b.linkId)) links.set(b.linkId, await repo.getBookingLink(b.linkId));
300
+ const link = links.get(b.linkId);
301
+ if (!link) continue;
302
+ const { rules } = linkApproval(link, settings);
303
+ if (!rules.deadlineHours || b.createdAt + rules.deadlineHours * 3_600_000 > now) continue;
304
+ try {
305
+ await setBookingStatus(repo, b, rules.deadlineAction === 'decline' ? 'declined' : 'confirmed', opts,
306
+ rules.deadlineAction === 'decline' ? 'We weren’t able to confirm this booking in time. Please pick another time.' : undefined);
307
+ decided++;
308
+ } catch { /* changed meanwhile — next sweep sees the new state */ }
309
+ }
310
+ return decided;
311
+ }
@@ -15,6 +15,14 @@ const eventFields = {
15
15
  location: z.object({ name: z.string().max(300).default(''), address: z.string().max(500).default(''), url: z.string().max(1000).default('') }).strict().optional(),
16
16
  cta: z.object({ label: z.string().max(120).default(''), href: z.string().max(1000).default(''), style: z.enum(['primary', 'outline']).default('primary') }).strict().optional(),
17
17
  calendars: z.array(z.string().max(100)).max(20).optional(),
18
+ // bookable rooms/people (slugs from the booking resources) this event uses:
19
+ // they can't be booked while it happens (± setup/cleanup minutes) or all day
20
+ rooms: z.object({
21
+ resources: z.array(z.string().max(100)).max(50),
22
+ span: z.enum(['event', 'day']).default('event'),
23
+ setup: z.number().int().min(0).max(1440).default(0),
24
+ cleanup: z.number().int().min(0).max(1440).default(0),
25
+ }).strict().optional(),
18
26
  tags: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
19
27
  cancelled: z.boolean().optional(),
20
28
  seoTitle: z.string().max(300).optional(),
@@ -43,6 +51,6 @@ export const EVENT_TOOL_DESCRIPTIONS: Record<(typeof EVENT_TOOL_NAMES)[number],
43
51
  get_event: 'Read one event including any pending draft. Times are wall-clock strings in the event timezone.',
44
52
  list_calendars: 'List calendars (slug, name, visibility, kind). Use slugs in create_event/update_event "calendars".',
45
53
  create_event: 'Create a DRAFT event. start/end are wall-clock "YYYY-MM-DDTHH:mm" (or "YYYY-MM-DD" with allDay) in the event timezone (site timezone when omitted). content is Markdown. rrule is an RFC 5545 rule like "FREQ=WEEKLY;BYDAY=TU". Never publishes.',
46
- update_event: 'Update supplied event fields. Published/scheduled events stage a pending draft that goes live on publish/Update. calendars replaces the list.',
54
+ update_event: 'Update supplied event fields. rooms (resource slugs) holds bookable rooms so they cannot be booked during the event; [] releases them. Published/scheduled events stage a pending draft that goes live on publish/Update. calendars replaces the list.',
47
55
  publish_event: 'Publish an event (applying any pending draft). Requires owner-enabled publishing permission.',
48
56
  };
@@ -35,6 +35,17 @@ async function resolveCalendars(repo: ContentRepository, slugs: string[] | undef
35
35
  });
36
36
  }
37
37
 
38
+ async function resolveRooms(repo: ContentRepository, rooms: { resources: string[]; span: 'event' | 'day'; setup: number; cleanup: number } | undefined, keep: string[] = []) {
39
+ if (rooms === undefined) return undefined;
40
+ const all = await repo.listResources();
41
+ const resourceIds = rooms.resources.map((s) => {
42
+ const r = all.find((x) => x.slug === s || x.id === s);
43
+ if (!r) throw new ApiError(400, `Unknown room or person "${s}". Call list_resources for valid slugs.`);
44
+ return r.id;
45
+ });
46
+ return { resourceIds, span: rooms.span, before: rooms.setup, after: rooms.cleanup, whileDraft: false, keep };
47
+ }
48
+
38
49
  function checkLinks(a: { cta?: { href: string }; location?: { url: string }; timezone?: string }) {
39
50
  if (a.cta?.href && !/^(https?:\/\/|\/(?!\/)|mailto:|tel:)/i.test(a.cta.href)) throw new ApiError(400, 'cta.href must start with https://, /, mailto: or tel:.');
40
51
  if (a.location?.url && !/^https?:\/\//i.test(a.location.url)) throw new ApiError(400, 'location.url must start with https://.');
@@ -54,6 +65,7 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
54
65
  start: e.start, end: e.end, allDay: e.allDay, rrule: e.rrule,
55
66
  next: o ? formatOccurrence(o, settings.timezone || 'UTC', siteLocale(settings)).range : '',
56
67
  calendars: e.calendarIds.map((id) => calMap.get(id)?.slug).filter(Boolean),
68
+ rooms: e.roomHold.resourceIds.length ? { resources: e.roomHold.resourceIds, span: e.roomHold.span, setup: e.roomHold.before, cleanup: e.roomHold.after } : null,
57
69
  hasPendingDraft: Boolean(e.draft), editUrl: editUrl(e.id),
58
70
  };
59
71
  };
@@ -91,13 +103,14 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
91
103
  rrule: a.rrule ?? '', excerpt: a.excerpt ?? '', location: a.location, cta: a.cta, tags: a.tags,
92
104
  cancelled: a.cancelled ?? false, seoTitle: a.seoTitle, seoDesc: a.seoDesc,
93
105
  calendarIds: (await resolveCalendars(repo, a.calendars)) ?? [],
106
+ ...(a.rooms ? { roomHold: await resolveRooms(repo, a.rooms) } : {}),
94
107
  ...(a.content !== undefined ? { bodyHtml: renderAgentMarkdown(a.content) } : {}),
95
108
  });
96
109
  if (a.tags?.length) await repo.ensureTags(a.tags);
97
110
  return { ...summary(e) };
98
111
  }
99
112
  case 'update_event': {
100
- const { id, content, calendars: slugs, ...rest } = input.arguments;
113
+ const { id, content, calendars: slugs, rooms, ...rest } = input.arguments;
101
114
  checkLinks(rest);
102
115
  const before = await repo.getEvent(id);
103
116
  if (!before) throw new ApiError(404, 'Event not found.');
@@ -108,6 +121,8 @@ export async function runEventTool(repo: ContentRepository, input: EventToolInpu
108
121
  }
109
122
  const calendarIds = await resolveCalendars(repo, slugs);
110
123
  if (calendarIds) data.calendarIds = calendarIds;
124
+ const roomHold = await resolveRooms(repo, rooms, before.roomHold.keep);
125
+ if (roomHold) data.roomHold = roomHold;
111
126
  if (data.start !== undefined || data.end !== undefined || data.allDay !== undefined) {
112
127
  const merged = mergeEventDraft(before);
113
128
  try {
@@ -0,0 +1,171 @@
1
+ // Data for the admin Schedule: every occurrence in a date range — events
2
+ // (drafts included), subscribed calendars, bookings and blocked time —
3
+ // flattened into site-local days so the browser only does layout
4
+ // (lib/calendar/grid.ts). Times are shown in the site timezone.
5
+ import type { ContentRepository } from '../repo/types';
6
+ import type { CalEvent, Settings } from '../schema';
7
+ import { collectViewEvents } from './data';
8
+ import { eventTimeZone, safeColor } from './events';
9
+ import { tzParts, zonedToUtc } from './ics';
10
+ import { addDays, isIsoDay, type GridItem, type Iso } from './grid';
11
+ import { bookingsEnabled } from './config';
12
+ import { siteLocale } from '../dates';
13
+ import { describeRepeat } from './rrule';
14
+ import { holdsRooms } from '../booking/holds';
15
+
16
+ export type BoardKind = 'event' | 'feed' | 'booking' | 'block';
17
+ export type BoardStatus = 'published' | 'draft' | 'scheduled' | 'archived' | 'feed' | 'pending' | 'confirmed';
18
+
19
+ export interface BoardItem extends GridItem {
20
+ kind: BoardKind;
21
+ id: string; // event id / booking id / '' for feeds
22
+ time: string; // short start label, e.g. "7p", "10:30a"
23
+ range: string; // "7:00 – 8:30 PM" or "All day"
24
+ color: string;
25
+ calendarId: string;
26
+ calendarName: string;
27
+ status: BoardStatus;
28
+ recurring: boolean;
29
+ repeat: string; // "Weekly on Wednesday" ('' when it doesn't repeat)
30
+ rooms: string[]; // bookable rooms this event holds (names)
31
+ roomsHeld: boolean; // false for drafts that don't hold yet
32
+ // this occurrence's start as wall time in the event's own zone (for exdates)
33
+ occurrence: string;
34
+ cancelled: boolean;
35
+ location: string;
36
+ edited: boolean; // live event with unpublished changes
37
+ url: string; // public URL ('' when none)
38
+ }
39
+
40
+ export interface BoardCalendar { id: string; name: string; color: string; kind: 'native' | 'feed'; visibility: string }
41
+
42
+ export interface BoardData {
43
+ from: Iso;
44
+ to: Iso;
45
+ today: Iso;
46
+ weekStart: 0 | 1;
47
+ items: BoardItem[];
48
+ calendars: BoardCalendar[];
49
+ errors: string[];
50
+ }
51
+
52
+ const pad = (n: number) => String(n).padStart(2, '0');
53
+ const isoOf = (p: { y: number; m: number; d: number }) => `${p.y}-${pad(p.m)}-${pad(p.d)}`;
54
+ export const NO_CALENDAR = '';
55
+ export const BOOKING_COLOR = '#8e919a';
56
+
57
+ function shortTime(hh: number, mm: number): string {
58
+ const h = hh % 12 || 12;
59
+ return `${h}${mm ? `:${pad(mm)}` : ''}${hh < 12 ? 'a' : 'p'}`;
60
+ }
61
+
62
+ export function todayIn(tz: string, now = Date.now()): Iso {
63
+ return isoOf(tzParts(now, tz || 'UTC'));
64
+ }
65
+
66
+ // local day + minutes for an instant; `endExclusive` maps midnight to the
67
+ // previous day's 24:00 so an event ending at midnight stays on its day
68
+ function local(ms: number, tz: string, endExclusive = false) {
69
+ const p = tzParts(endExclusive ? ms - 1 : ms, tz);
70
+ const day = isoOf(p);
71
+ const min = endExclusive ? p.hh * 60 + p.mm + 1 : p.hh * 60 + p.mm;
72
+ return { day, min: Math.min(min, 1440), hh: p.hh, mm: p.mm };
73
+ }
74
+
75
+ export async function loadBoard(repo: ContentRepository, settings: Settings, from: Iso, to: Iso, opts: { now?: number } = {}): Promise<BoardData> {
76
+ if (!isIsoDay(from) || !isIsoDay(to) || to < from) throw new Error('Pick a valid date range.');
77
+ // at most ~14 weeks per request — a month grid needs 6, prefetching adds the neighbours
78
+ if (addDays(from, 100) < to) throw new Error('That date range is too long.');
79
+ const tz = settings.timezone || 'UTC';
80
+ const now = opts.now ?? Date.now();
81
+ const startMs = zonedToUtc(+from.slice(0, 4), +from.slice(5, 7), +from.slice(8, 10), 0, 0, 0, tz);
82
+ const endIso = addDays(to, 1);
83
+ const endMs = zonedToUtc(+endIso.slice(0, 4), +endIso.slice(5, 7), +endIso.slice(8, 10), 0, 0, 0, tz);
84
+ const win = { displayTz: tz, now, windowStartMs: startMs, windowEndMs: endMs };
85
+
86
+ const { events, calendars, errors } = await collectViewEvents(repo, settings, win, { audience: 'admin', includeFeeds: true, includeBookings: false });
87
+ const locale = siteLocale(settings);
88
+ const timeFmt = new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
89
+ const rangeOf = (s: number, e: number, allDay: boolean) => {
90
+ if (allDay) return 'All day';
91
+ try { return timeFmt.formatRange(s, e); } catch { return `${timeFmt.format(s)} – ${timeFmt.format(e)}`; }
92
+ };
93
+ const calById = new Map(calendars.map((c) => [c.id, c]));
94
+ const roomName = new Map((bookingsEnabled() ? await repo.listResources() : []).map((r) => [r.id, r.name]));
95
+ const items: BoardItem[] = [];
96
+
97
+ for (const v of events) {
98
+ const e: CalEvent | undefined = v.event;
99
+ const allDay = v.allDay;
100
+ const s = local(v.start, tz);
101
+ const en = local(v.end, tz, true);
102
+ const startDay = s.day;
103
+ const endDay = allDay ? addDays(startDay, Math.max(1, v.days) - 1) : en.day < s.day ? s.day : en.day;
104
+ const cal = e ? e.calendarIds.map((id) => calById.get(id)).find(Boolean) : calendars.find((c) => c.name === v.calendarName && c.kind === 'feed');
105
+ let occurrence = '';
106
+ if (e) {
107
+ const p = tzParts(v.start, eventTimeZone(e, settings));
108
+ occurrence = allDay ? isoOf(p) : `${isoOf(p)}T${pad(p.hh)}:${pad(p.mm)}`;
109
+ }
110
+ items.push({
111
+ key: e ? `e:${e.id}:${v.start}` : `f:${cal?.id ?? ''}:${v.start}:${v.title}`,
112
+ kind: e ? 'event' : 'feed',
113
+ id: e?.id ?? '',
114
+ title: v.title,
115
+ startDay, endDay, allDay,
116
+ startMin: allDay ? 0 : s.min,
117
+ endMin: allDay ? 1440 : en.min,
118
+ time: allDay ? '' : shortTime(s.hh, s.mm),
119
+ range: rangeOf(v.start, v.end, allDay),
120
+ color: v.color ?? (cal ? safeColor(cal.color) : '#8a8378'),
121
+ calendarId: cal?.id ?? NO_CALENDAR,
122
+ calendarName: cal?.name ?? '',
123
+ status: e ? (e.status as BoardStatus) : 'feed',
124
+ recurring: Boolean(e?.rrule),
125
+ repeat: e?.rrule ? describeRepeat(e.rrule, e.start) : '',
126
+ rooms: e ? e.roomHold.resourceIds.map((id) => roomName.get(id)).filter((n): n is string => Boolean(n)) : [],
127
+ roomsHeld: e ? holdsRooms(e) : false,
128
+ occurrence,
129
+ cancelled: Boolean(v.cancelled),
130
+ location: v.location,
131
+ edited: Boolean(e?.draft),
132
+ url: e?.status === 'published' ? v.url ?? '' : '',
133
+ });
134
+ }
135
+
136
+ if (bookingsEnabled() && settings.calendar.showBookings) {
137
+ const [bookings, resources] = await Promise.all([
138
+ repo.listBookings({ from: startMs, to: endMs, statuses: ['pending', 'confirmed'], kinds: ['booking', 'block'] }),
139
+ repo.listResources(),
140
+ ]);
141
+ const rById = new Map(resources.map((r) => [r.id, r]));
142
+ for (const b of bookings) {
143
+ const r = rById.get(b.resourceId);
144
+ const s = local(b.start, tz);
145
+ const en = local(b.end, tz, true);
146
+ const allDay = s.min === 0 && en.min === 1440;
147
+ items.push({
148
+ key: `b:${b.id}`,
149
+ kind: b.kind === 'block' ? 'block' : 'booking',
150
+ id: b.id,
151
+ title: b.kind === 'block' ? `${r?.name ?? 'Blocked'} · ${b.notes || 'Blocked'}` : `${r?.name ?? 'Booking'} · ${b.name}`,
152
+ startDay: s.day, endDay: en.day < s.day ? s.day : en.day, allDay,
153
+ startMin: allDay ? 0 : s.min, endMin: allDay ? 1440 : en.min,
154
+ time: allDay ? '' : shortTime(s.hh, s.mm),
155
+ range: rangeOf(b.start, b.end, allDay),
156
+ color: BOOKING_COLOR,
157
+ calendarId: `res:${b.resourceId}`,
158
+ calendarName: r?.name ?? '',
159
+ status: b.status as BoardStatus,
160
+ recurring: false, repeat: '', rooms: [], roomsHeld: false, occurrence: '', cancelled: false,
161
+ location: r?.name ?? '', edited: false, url: '',
162
+ });
163
+ }
164
+ }
165
+
166
+ items.sort((a, b) => a.startDay.localeCompare(b.startDay) || a.startMin - b.startMin);
167
+ return {
168
+ from, to, items, errors, today: todayIn(tz, now), weekStart: settings.calendar.weekStart,
169
+ calendars: calendars.map((c) => ({ id: c.id, name: c.name, color: safeColor(c.color), kind: c.kind, visibility: c.visibility })),
170
+ };
171
+ }
@@ -21,6 +21,8 @@ export interface CalendarConfig {
21
21
  eventsBase: string;
22
22
  // bookings: rooms, people and equipment at bookBase (off = no /book routes)
23
23
  bookings: boolean;
24
+ // starting approval default for booking links (acms({ calendar: { bookings: { approval } } }))
25
+ bookingApproval: 'auto' | 'rules' | 'manual';
24
26
  bookBase: string;
25
27
  resourceKinds: string[];
26
28
  defaultView: 'month' | 'week' | 'list';
@@ -32,6 +34,7 @@ const FALLBACK: CalendarConfig = {
32
34
  base: '/calendar',
33
35
  eventsBase: '/events',
34
36
  bookings: true,
37
+ bookingApproval: 'auto',
35
38
  bookBase: '/book',
36
39
  resourceKinds: ['Room', 'Person', 'Vehicle', 'Equipment'],
37
40
  defaultView: 'month',
@@ -0,0 +1,169 @@
1
+ // Pure date + layout math for the admin Schedule (month and week grids).
2
+ // Runs on the server and in the browser, so no repo, no Intl time zones:
3
+ // every date here is a site-local calendar day as "YYYY-MM-DD", and every
4
+ // item already carries its local start/end day (lib/calendar/board.ts).
5
+
6
+ export type Iso = string;
7
+
8
+ export interface GridItem {
9
+ key: string;
10
+ startDay: Iso;
11
+ endDay: Iso; // inclusive
12
+ allDay: boolean;
13
+ startMin: number; // minutes after midnight on startDay (timed items)
14
+ endMin: number; // minutes after midnight on endDay
15
+ title: string;
16
+ }
17
+
18
+ const pad = (n: number) => String(n).padStart(2, '0');
19
+ const toUtc = (d: Iso) => Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10));
20
+ const fromUtc = (ms: number): Iso => {
21
+ const t = new Date(ms);
22
+ return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
23
+ };
24
+
25
+ export const isIsoDay = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && fromUtc(toUtc(s)) === s;
26
+ export const addDays = (d: Iso, n: number): Iso => fromUtc(toUtc(d) + n * 86_400_000);
27
+ export const diffDays = (a: Iso, b: Iso) => Math.round((toUtc(b) - toUtc(a)) / 86_400_000);
28
+ // 0 = Sunday … 6 = Saturday
29
+ export const dayOfWeek = (d: Iso) => new Date(toUtc(d)).getUTCDay();
30
+ export const monthOf = (d: Iso) => d.slice(0, 7);
31
+ export const isIsoMonth = (s: string) => /^\d{4}-(0[1-9]|1[0-2])$/.test(s);
32
+ export function addMonths(month: string, n: number): string {
33
+ const y = +month.slice(0, 4), m = +month.slice(5, 7) - 1 + n;
34
+ const t = new Date(Date.UTC(y, m, 1));
35
+ return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}`;
36
+ }
37
+
38
+ export function startOfWeek(d: Iso, weekStart: 0 | 1): Iso {
39
+ return addDays(d, -((dayOfWeek(d) - weekStart + 7) % 7));
40
+ }
41
+
42
+ // Weeks covering a month: always whole weeks, 4–6 rows depending on the month
43
+ export function monthWeeks(month: string, weekStart: 0 | 1): Iso[][] {
44
+ const first = `${month}-01`;
45
+ const last = addDays(`${addMonths(month, 1)}-01`, -1);
46
+ const weeks: Iso[][] = [];
47
+ for (let w = startOfWeek(first, weekStart); w <= last; w = addDays(w, 7)) {
48
+ weeks.push(Array.from({ length: 7 }, (_, i) => addDays(w, i)));
49
+ }
50
+ return weeks;
51
+ }
52
+
53
+ export function weekDays(day: Iso, weekStart: 0 | 1): Iso[] {
54
+ const s = startOfWeek(day, weekStart);
55
+ return Array.from({ length: 7 }, (_, i) => addDays(s, i));
56
+ }
57
+
58
+ // Items that need a bar across days: all-day items and anything that runs
59
+ // past midnight. Single-day timed items render as chips.
60
+ export const isSpanning = (it: GridItem) => it.allDay || it.endDay > it.startDay;
61
+
62
+ export interface Bar<T extends GridItem> {
63
+ item: T;
64
+ col: number; // 0-based column inside the week
65
+ span: number; // columns covered
66
+ lane: number;
67
+ continuesLeft: boolean;
68
+ continuesRight: boolean;
69
+ }
70
+
71
+ export interface WeekLayout<T extends GridItem> {
72
+ bars: Bar<T>[]; // visible bars; `lane` is the line they sit on
73
+ chips: { item: T; line: number }[][]; // per column, visible chips and their line
74
+ hidden: number[]; // per column, items not shown ("+N more")
75
+ all: T[][]; // per column, everything on that day (for the day panel)
76
+ }
77
+
78
+ const byStart = <T extends GridItem>(a: T, b: T) =>
79
+ a.startDay.localeCompare(b.startDay) || Number(b.allDay) - Number(a.allDay) || a.startMin - b.startMin || a.title.localeCompare(b.title);
80
+
81
+ // Lay out one week row as `lines` stacked slots per day. Bars (all-day and
82
+ // multi-day items) take the same line across every day they cover; timed
83
+ // chips fill each day's free lines. A day with more than fits keeps its last
84
+ // line for "+N more".
85
+ export function layoutWeek<T extends GridItem>(days: Iso[], items: T[], lines: number): WeekLayout<T> {
86
+ const first = days[0], last = days[days.length - 1];
87
+ const inWeek = items.filter((it) => it.startDay <= last && it.endDay >= first).sort(byStart);
88
+ const all: T[][] = days.map((d) => inWeek.filter((it) => it.startDay <= d && it.endDay >= d));
89
+
90
+ // greedy lane packing, longest bar first among those starting the same day
91
+ const clip = (d: Iso) => (d < first ? first : d);
92
+ const spanning = inWeek.filter(isSpanning).sort((a, b) =>
93
+ clip(a.startDay).localeCompare(clip(b.startDay)) || diffDays(b.startDay, b.endDay) - diffDays(a.startDay, a.endDay) || byStart(a, b));
94
+ const laneEnds: number[] = [];
95
+ const placed: Bar<T>[] = [];
96
+ for (const item of spanning) {
97
+ const col = Math.max(0, diffDays(first, item.startDay));
98
+ const end = Math.min(days.length - 1, diffDays(first, item.endDay));
99
+ let lane = laneEnds.findIndex((e) => e < col);
100
+ if (lane === -1) { lane = laneEnds.length; laneEnds.push(end); } else laneEnds[lane] = end;
101
+ placed.push({ item, col, span: end - col + 1, lane, continuesLeft: item.startDay < first, continuesRight: item.endDay > last });
102
+ }
103
+ const covers = (b: Bar<T>, c: number) => b.col <= c && b.col + b.span > c;
104
+ const timed = days.map((d) => inWeek.filter((it) => !isSpanning(it) && it.startDay === d));
105
+ const overflow = days.map((_, c) => {
106
+ const here = placed.filter((b) => covers(b, c));
107
+ return here.length + timed[c].length > lines || here.some((b) => b.lane >= lines);
108
+ });
109
+ const visible = (b: Bar<T>) => {
110
+ if (b.lane >= lines) return false;
111
+ if (b.lane < lines - 1) return true;
112
+ for (let c = b.col; c < b.col + b.span; c++) if (overflow[c]) return false;
113
+ return true;
114
+ };
115
+ // a hidden bar needs a "+N more" line in every day it covers, which can
116
+ // hide more bars on that line — settle until nothing changes
117
+ for (let changed = true; changed; ) {
118
+ changed = false;
119
+ for (const b of placed) {
120
+ if (visible(b)) continue;
121
+ for (let c = b.col; c < b.col + b.span; c++) if (!overflow[c]) { overflow[c] = true; changed = true; }
122
+ }
123
+ }
124
+ const bars = placed.filter(visible);
125
+ const chips: { item: T; line: number }[][] = [];
126
+ const hidden: number[] = [];
127
+ for (let c = 0; c < days.length; c++) {
128
+ const capacity = overflow[c] ? lines - 1 : lines;
129
+ const taken = new Set(bars.filter((b) => covers(b, c)).map((b) => b.lane));
130
+ const free: number[] = [];
131
+ for (let l = 0; l < capacity; l++) if (!taken.has(l)) free.push(l);
132
+ const shown = timed[c].slice(0, free.length).map((item, i) => ({ item, line: free[i] }));
133
+ chips.push(shown);
134
+ const hiddenBars = placed.filter((b) => covers(b, c) && !visible(b)).length;
135
+ hidden.push(hiddenBars + timed[c].length - shown.length);
136
+ }
137
+ return { bars, chips, hidden, all };
138
+ }
139
+
140
+ // Week view: side-by-side columns for overlapping timed items in one day
141
+ export interface Placed<T extends GridItem> { item: T; top: number; height: number; col: number; cols: number }
142
+ export function layoutDay<T extends GridItem>(day: Iso, items: T[]): Placed<T>[] {
143
+ const list = items
144
+ .filter((it) => !isSpanning(it) && it.startDay === day)
145
+ .sort((a, b) => a.startMin - b.startMin || b.endMin - a.endMin);
146
+ const out: Placed<T>[] = [];
147
+ let group: Placed<T>[] = [];
148
+ let groupEnd = -1;
149
+ const flush = () => {
150
+ const cols = group.reduce((n, p) => Math.max(n, p.col + 1), 1);
151
+ for (const p of group) p.cols = cols;
152
+ out.push(...group);
153
+ group = [];
154
+ };
155
+ for (const item of list) {
156
+ const end = Math.max(item.endMin, item.startMin + 20);
157
+ if (item.startMin >= groupEnd && group.length) flush();
158
+ const taken = new Set(group.filter((p) => p.top + p.height > item.startMin).map((p) => p.col));
159
+ let col = 0;
160
+ while (taken.has(col)) col++;
161
+ group.push({ item, top: item.startMin, height: end - item.startMin, col, cols: 1 });
162
+ groupEnd = Math.max(groupEnd, end);
163
+ }
164
+ if (group.length) flush();
165
+ return out;
166
+ }
167
+
168
+ // Move an item by whole days, keeping its length (drag to reschedule)
169
+ export const shiftDays = (from: Iso, to: Iso) => diffDays(from, to);
@@ -0,0 +1,16 @@
1
+ // Schedule + approval settings. Site-wide calendar settings (week start,
2
+ // default calendar, booking approval default) live in one JSON column; each
3
+ // booking link gains an approval mode that can follow that default. Existing
4
+ // links that confirmed automatically now follow the site default (which
5
+ // starts as automatic, so nothing changes); manual ones stay manual.
6
+ export const CALENDAR_SETTINGS_MIGRATION = { version: 40, statements: [
7
+ `ALTER TABLE settings ADD COLUMN calendar TEXT NOT NULL DEFAULT '{}'`,
8
+ `ALTER TABLE booking_links ADD COLUMN approval TEXT NOT NULL DEFAULT 'default'`,
9
+ `ALTER TABLE booking_links ADD COLUMN approval_rules TEXT NOT NULL DEFAULT '{}'`,
10
+ `UPDATE booking_links SET approval = CASE auto_confirm WHEN 1 THEN 'default' ELSE 'manual' END`,
11
+ ] };
12
+
13
+ // events that reserve bookable rooms/people (CalEvent.roomHold)
14
+ export const ROOM_HOLD_MIGRATION = { version: 41, statements: [
15
+ `ALTER TABLE events ADD COLUMN room_hold TEXT NOT NULL DEFAULT '{}'`,
16
+ ] };