@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.
- package/CHANGELOG.md +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- 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
|
|
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
|
+
] };
|