@acms-subir/core 0.3.3 → 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 (66) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/cli/preview.mjs +8 -4
  3. package/cli/sections.mjs +4 -2
  4. package/docs/mobile.md +1 -1
  5. package/docs/theme-authoring.md +19 -3
  6. package/integration.mjs +21 -4
  7. package/package.json +14 -12
  8. package/src/actions/index.ts +186 -3
  9. package/src/components/booking/BookingFlow.astro +6 -3
  10. package/src/components/calendar/BookingsHead.astro +51 -0
  11. package/src/components/calendar/CalendarBoard.tsx +618 -0
  12. package/src/components/calendar/MiniMonth.tsx +54 -0
  13. package/src/components/calendar/NewEventDialog.tsx +44 -0
  14. package/src/components/calendar/QuickAdd.tsx +263 -0
  15. package/src/components/calendar/RoomHolds.tsx +203 -0
  16. package/src/components/calendar/WhereItLands.tsx +51 -0
  17. package/src/components/editor/EventPanel.tsx +23 -2
  18. package/src/components/editor/PostEditor.tsx +32 -4
  19. package/src/layouts/Admin.astro +5 -6
  20. package/src/layouts/Site.astro +29 -2
  21. package/src/lib/backup/content.ts +6 -1
  22. package/src/lib/booking/agent.ts +3 -2
  23. package/src/lib/booking/approval.ts +70 -0
  24. package/src/lib/booking/holds.ts +120 -0
  25. package/src/lib/booking/service.ts +37 -3
  26. package/src/lib/calendar/agent-schema.ts +9 -1
  27. package/src/lib/calendar/agent.ts +16 -1
  28. package/src/lib/calendar/board.ts +171 -0
  29. package/src/lib/calendar/config.ts +3 -0
  30. package/src/lib/calendar/grid.ts +169 -0
  31. package/src/lib/calendar/migration.ts +16 -0
  32. package/src/lib/calendar/render.ts +9 -7
  33. package/src/lib/calendar/schedule.ts +166 -0
  34. package/src/lib/content-service.ts +5 -0
  35. package/src/lib/drafts.ts +1 -1
  36. package/src/lib/mobile/calendar.ts +10 -6
  37. package/src/lib/mobile/http.ts +6 -0
  38. package/src/lib/repo/migrations.ts +3 -0
  39. package/src/lib/repo/sql.ts +28 -16
  40. package/src/lib/repo/types.ts +2 -2
  41. package/src/lib/scheduler.ts +3 -1
  42. package/src/lib/schema.ts +49 -0
  43. package/src/lib/sections.ts +33 -8
  44. package/src/lib/site-chrome.ts +34 -0
  45. package/src/lib/theme-assets.ts +16 -0
  46. package/src/lib/theme.ts +17 -3
  47. package/src/middleware.ts +6 -2
  48. package/src/pages/admin/calendar/bookings.astro +32 -26
  49. package/src/pages/admin/calendar/calendars.astro +4 -263
  50. package/src/pages/admin/calendar/events/index.astro +41 -83
  51. package/src/pages/admin/calendar/index.astro +43 -0
  52. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  53. package/src/pages/admin/calendar/resources/index.astro +16 -17
  54. package/src/pages/admin/calendar/resources/link.astro +52 -12
  55. package/src/pages/admin/calendar/settings.astro +458 -0
  56. package/src/pages/admin/integrations.astro +5 -11
  57. package/src/pages/book/[resource].ics.ts +7 -1
  58. package/src/styles/admin.css +48 -0
  59. package/src/styles/cards.css +5 -1
  60. package/src/styles/editor.css +36 -0
  61. package/src/styles/schedule.css +325 -0
  62. package/src/styles/sections.css +19 -16
  63. package/src/theme-sdk/fields.mjs +5 -1
  64. package/src/theme-sdk/index.ts +5 -1
  65. package/src/virtual.d.ts +3 -0
  66. package/src/pages/admin/calendar/index.ts +0 -4
@@ -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
+ ] };
@@ -105,9 +105,9 @@ const dotStyle = (e: DisplayEvent) => (e.k ? ` style="--cal-dot:${e.k}"` : '');
105
105
  const chipHtml = (e: DisplayEvent, i: number) =>
106
106
  `<button type="button" class="pc-cal-event${e.x ? ' is-cancelled' : ''}" data-ev="${i}" data-cal="${e.c}"${dotStyle(e)}><span class="t">${esc(e.tm)}</span><b>${esc(e.t)}</b></button>`;
107
107
 
108
- function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[]): string {
108
+ function monthGridHtml(y: number, m: number, todayN: number, byDay: Map<number, number[]>, events: DisplayEvent[], dows: string[], monthsShort: string[], ws: number): string {
109
109
  const first = dayNum(y, m, 1);
110
- const start = first - dowOfDay(first);
110
+ const start = first - ((dowOfDay(first) - ws + 7) % 7);
111
111
  let h = dows.map((d) => `<div class="pc-cal-dow">${esc(d)}</div>`).join('');
112
112
  for (let i = 0; i < 42; i++) {
113
113
  const n = start + i;
@@ -149,7 +149,7 @@ var body=root.querySelector('.pc-cal-body'),titleEl=root.querySelector('.pc-cal-
149
149
  var D=864e5;
150
150
  function dnum(a){return Math.round(Date.UTC(a[0],a[1]-1,a[2])/D)}
151
151
  function fday(n){var d=new Date(n*D);return[d.getUTCFullYear(),d.getUTCMonth()+1,d.getUTCDate()]}
152
- function dow(n){return((n+4)%7+7)%7}
152
+ function dow(n){return((n+4-(P.ws||0))%7+7)%7}
153
153
  var byDay={};P.events.forEach(function(e,i){var n0=dnum(e.d);for(var k=0;k<Math.min(e.n,60);k++){(byDay[n0+k]=byDay[n0+k]||[]).push(i)}});
154
154
  function esc(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
155
155
  function chip(i){var e=P.events[i];return '<button type="button" class="pc-cal-event'+(e.x?' is-cancelled':'')+'" data-ev="'+i+'" data-cal="'+e.c+'"'+(e.k?' style="--cal-dot:'+e.k+'"':'')+'><span class="t">'+esc(e.tm)+'</span><b>'+esc(e.t)+'</b></button>'}
@@ -246,7 +246,9 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
246
246
  new Intl.DateTimeFormat(locale, { ...o, timeZone: 'UTC' }).format(ms);
247
247
  const months = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'long' }, Date.UTC(2026, i, 1)));
248
248
  const monthsShort = Array.from({ length: 12 }, (_, i) => utcLabel({ month: 'short' }, Date.UTC(2026, i, 1)));
249
- const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + i))); // Aug 2 2026 = Sunday
249
+ // Calendar settings → Defaults → "Weeks start on" (0 Sunday, 1 Monday)
250
+ const ws = settings.calendar?.weekStart ?? 0;
251
+ const dows = Array.from({ length: 7 }, (_, i) => utcLabel({ weekday: 'short' }, Date.UTC(2026, 7, 2 + ws + i))); // Aug 2 2026 = Sunday
250
252
 
251
253
  const focusOk = opts.focus && (opts.focus.y * 12 + opts.focus.m) >= minY * 12 + minM && (opts.focus.y * 12 + opts.focus.m) <= maxY * 12 + maxM;
252
254
  const [fy, fm] = focusOk ? [opts.focus!.y, opts.focus!.m] : [today.y, today.m];
@@ -254,7 +256,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
254
256
  let bodyHtml: string;
255
257
  let titleText: string;
256
258
  if (opts.view === 'week') {
257
- const weekStart = todayN - dowOfDay(todayN);
259
+ const weekStart = todayN - ((dowOfDay(todayN) - ws + 7) % 7);
258
260
  const wa = fromDayNum(weekStart);
259
261
  const wb = fromDayNum(weekStart + 6);
260
262
  titleText = `${monthsShort[wa.m - 1]} ${wa.d} – ${wa.m === wb.m ? '' : `${monthsShort[wb.m - 1]} `}${wb.d}, ${wb.y}`;
@@ -267,7 +269,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
267
269
  if (!order.length && opts.emptyText) bodyHtml = `<p class="pc-cal-empty">${esc(opts.emptyText)}</p>`;
268
270
  } else {
269
271
  titleText = `${months[fm - 1]} ${fy}`;
270
- bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort);
272
+ bodyHtml = monthGridHtml(fy, fm, todayN, byDay, events, dows, monthsShort, ws);
271
273
  }
272
274
 
273
275
  const navButtons =
@@ -287,7 +289,7 @@ export function renderCalendarView(raw: ViewEvent[], settings: Settings, now: nu
287
289
  month: [fy, fm],
288
290
  min: [minY, minM],
289
291
  max: [maxY, maxM],
290
- months, monthsShort, dows, events,
292
+ months, monthsShort, dows, events, ws,
291
293
  details: 'Details', cancelled: 'Cancelled',
292
294
  };
293
295
  const json = JSON.stringify(payload).replace(/</g, '\\u003c');
@@ -0,0 +1,166 @@
1
+ // Writes behind the admin Schedule: quick add, drag to move (a whole series
2
+ // or one occurrence), undo, duplicate. Everything goes through the same
3
+ // save/apply path as the event editor, so hooks, feeds, search and webhooks
4
+ // see the same changes. A move applies at once, even on a published event —
5
+ // the editor's "stage until Update" flow is for content edits, not dates.
6
+ import type { ContentRepository } from '../repo/types';
7
+ import type { CalEvent } from '../schema';
8
+ import { slugify } from '../schema';
9
+ import { ApiError } from '../api';
10
+ import { applyEventUpdate, saveEvent, type SaveHooks } from '../content-service';
11
+ import { addWallDays, formatWall, parseWall } from './events';
12
+ import { rruleMap, WEEKDAY_CODES, weekdayOf } from './rrule';
13
+ import { eventDeleted } from './hooks';
14
+ import { waitUntilFrom } from '../webhooks';
15
+
16
+ async function uniqueSlug(repo: ContentRepository, title: string) {
17
+ const base = slugify(title) || 'event';
18
+ let slug = base;
19
+ for (let n = 2; await repo.getEventBySlug(slug); n++) slug = `${base}-${n}`;
20
+ return slug;
21
+ }
22
+
23
+ const live = (s: CalEvent['status']) => s === 'published' || s === 'scheduled';
24
+
25
+ // Save on a live event without staging: stage, then apply just this change.
26
+ // Refuses when the event already has unrelated unpublished edits, so a drag
27
+ // never publishes someone's half-finished copy.
28
+ async function saveNow(repo: ContentRepository, e: CalEvent, data: Parameters<typeof saveEvent>[2], hooks: SaveHooks) {
29
+ if (live(e.status) && e.draft) {
30
+ throw new ApiError(409, 'This event has unpublished changes. Open it and update or discard them first.');
31
+ }
32
+ const saved = await saveEvent(repo, e.id, data, hooks);
33
+ return live(e.status) ? applyEventUpdate(repo, e.id, hooks) : saved;
34
+ }
35
+
36
+ export interface QuickEvent {
37
+ title: string;
38
+ start: string; // YYYY-MM-DD or YYYY-MM-DDTHH:mm
39
+ end: string;
40
+ allDay: boolean;
41
+ calendarId: string;
42
+ rrule: string;
43
+ location: string;
44
+ publish: boolean;
45
+ // bookable rooms/people this event uses (held while it happens)
46
+ roomIds?: string[];
47
+ }
48
+
49
+ export async function quickCreate(repo: ContentRepository, q: QuickEvent, hooks: SaveHooks): Promise<CalEvent> {
50
+ const title = q.title.trim().slice(0, 300) || 'Untitled event';
51
+ const cal = q.calendarId ? await repo.getCalendar(q.calendarId) : null;
52
+ const created = await repo.createEvent({
53
+ title, slug: await uniqueSlug(repo, title), start: q.start, allDay: q.allDay, status: 'draft',
54
+ calendarIds: cal?.kind === 'native' ? [cal.id] : [],
55
+ location: { name: q.location.trim().slice(0, 300), address: '', url: '' },
56
+ });
57
+ if (q.roomIds?.length) await saveEvent(repo, created.id, { roomHold: { resourceIds: q.roomIds, span: 'event', before: 0, after: 0, whileDraft: false, keep: [] } }, hooks);
58
+ // times + repeat go through the editor's validation (end defaults, pairs)
59
+ await saveEvent(repo, created.id, { start: q.start, end: q.end || undefined, allDay: q.allDay, rrule: q.rrule }, hooks);
60
+ return q.publish ? saveEvent(repo, created.id, { status: 'published' }, hooks) : (await repo.getEvent(created.id))!;
61
+ }
62
+
63
+ // Shift a recurrence rule by whole days so "weekly on Wednesday" moved two
64
+ // days later becomes "weekly on Friday" (and nth-weekday / day-of-month
65
+ // rules follow the new first date). UNTIL moves with the series.
66
+ export function shiftRrule(rrule: string, days: number, newStart: string): string {
67
+ if (!rrule || !days) return rrule;
68
+ const p = rruleMap(rrule);
69
+ if (p.FREQ === 'WEEKLY' && p.BYDAY) {
70
+ p.BYDAY = p.BYDAY.split(',').map((c) => {
71
+ const i = (WEEKDAY_CODES as readonly string[]).indexOf(c);
72
+ return i < 0 ? c : WEEKDAY_CODES[(((i + days) % 7) + 7) % 7];
73
+ }).join(',');
74
+ }
75
+ if (p.FREQ === 'MONTHLY') {
76
+ const wd = weekdayOf(newStart);
77
+ if (p.BYDAY && /^-?\d(SU|MO|TU|WE|TH|FR|SA)$/.test(p.BYDAY)) p.BYDAY = `${wd.nth >= 5 || p.BYDAY.startsWith('-') ? -1 : wd.nth}${WEEKDAY_CODES[wd.dow]}`;
78
+ else if (p.BYMONTHDAY && !p.BYMONTHDAY.includes(',')) p.BYMONTHDAY = String(wd.day);
79
+ }
80
+ if (p.UNTIL && /^\d{8}$/.test(p.UNTIL)) {
81
+ const u = parseWall(`${p.UNTIL.slice(0, 4)}-${p.UNTIL.slice(4, 6)}-${p.UNTIL.slice(6, 8)}`);
82
+ if (u) p.UNTIL = formatWall(addWallDays(u, days)).replace(/-/g, '');
83
+ }
84
+ const order = ['FREQ', 'INTERVAL', 'BYDAY', 'BYMONTHDAY', 'BYMONTH', 'COUNT', 'UNTIL', 'WKST'];
85
+ return Object.entries(p).sort(([a], [b]) => order.indexOf(a) - order.indexOf(b)).map(([k, v]) => `${k}=${v}`).join(';');
86
+ }
87
+
88
+ const shift = (wall: string, days: number) => {
89
+ const w = parseWall(wall);
90
+ return w ? formatWall(addWallDays(w, days)) : wall;
91
+ };
92
+
93
+ export type MoveUndo =
94
+ | { kind: 'series'; id: string; days: number }
95
+ | { kind: 'split'; id: string; seriesId: string; occurrence: string };
96
+
97
+ // Move a whole event (and every repeat) by `days`
98
+ export async function moveSeries(repo: ContentRepository, id: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
99
+ const e = await repo.getEvent(id);
100
+ if (!e) throw new ApiError(404, 'Event not found.');
101
+ if (!Number.isInteger(days) || Math.abs(days) > 3660) throw new ApiError(400, 'Pick a day to move it to.');
102
+ const start = shift(e.start, days);
103
+ const event = await saveNow(repo, e, {
104
+ start, end: shift(e.end, days), allDay: e.allDay,
105
+ ...(e.rrule ? { rrule: shiftRrule(e.rrule, days, start), exdates: e.exdates.map((x) => shift(x, days)) } : {}),
106
+ }, hooks);
107
+ return { event, undo: { kind: 'series', id, days: -days } };
108
+ }
109
+
110
+ // Move one occurrence of a repeating event: skip that date in the series and
111
+ // add a one-off copy on the new date. No schema change, and the copy is a
112
+ // normal event the editor can open.
113
+ export async function moveOccurrence(repo: ContentRepository, id: string, occurrence: string, days: number, hooks: SaveHooks): Promise<{ event: CalEvent; undo: MoveUndo }> {
114
+ const e = await repo.getEvent(id);
115
+ if (!e) throw new ApiError(404, 'Event not found.');
116
+ if (!e.rrule) return moveSeries(repo, id, days, hooks);
117
+ const occ = parseWall(occurrence);
118
+ const first = parseWall(e.start);
119
+ if (!occ || !first) throw new ApiError(400, 'That date isn’t part of this event.');
120
+ if (e.exdates.includes(occurrence)) throw new ApiError(409, 'That date was already removed from the series.');
121
+ // keep the occurrence's own length (first occurrence's start → end)
122
+ const toMs = (w: { y: number; m: number; d: number; hh: number; mm: number }) => Date.UTC(w.y, w.m - 1, w.d, w.hh, w.mm);
123
+ const lenMs = toMs(parseWall(e.end) ?? first) - toMs(first);
124
+ const newStart = addWallDays(occ, days);
125
+ const t = new Date(toMs(newStart) + lenMs);
126
+ const pad = (n: number) => String(n).padStart(2, '0');
127
+ const endDay = `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`;
128
+ const endStr = e.allDay ? endDay : `${endDay}T${pad(t.getUTCHours())}:${pad(t.getUTCMinutes())}`;
129
+
130
+ await saveNow(repo, e, { exdates: [...e.exdates, occurrence] }, hooks);
131
+ const copy = await repo.createEvent({
132
+ title: e.title, slug: await uniqueSlug(repo, e.title), start: formatWall(newStart), end: endStr, allDay: e.allDay,
133
+ timezone: e.timezone, status: 'draft', excerpt: e.excerpt, bodyJson: e.bodyJson, bodyHtml: e.bodyHtml, cover: e.cover, coverAlt: e.coverAlt,
134
+ location: e.location, cta: e.cta, calendarIds: e.calendarIds, fields: e.fields, tags: e.tags, cancelled: e.cancelled, rsvp: e.rsvp,
135
+ template: e.template, featured: false,
136
+ });
137
+ const event = live(e.status) ? await saveEvent(repo, copy.id, { status: 'published' }, hooks) : copy;
138
+ return { event, undo: { kind: 'split', id: copy.id, seriesId: e.id, occurrence } };
139
+ }
140
+
141
+ export async function undoMove(repo: ContentRepository, undo: MoveUndo, hooks: SaveHooks): Promise<void> {
142
+ if (undo.kind === 'series') {
143
+ await moveSeries(repo, undo.id, undo.days, hooks);
144
+ return;
145
+ }
146
+ const series = await repo.getEvent(undo.seriesId);
147
+ const copy = await repo.getEvent(undo.id);
148
+ if (copy) {
149
+ await repo.deleteEvent(copy.id);
150
+ await eventDeleted(repo, copy, { waitUntil: waitUntilFrom(hooks.locals) });
151
+ }
152
+ if (series) await saveNow(repo, series, { exdates: series.exdates.filter((x) => x !== undo.occurrence) }, hooks);
153
+ }
154
+
155
+ export async function duplicateEvent(repo: ContentRepository, id: string): Promise<CalEvent> {
156
+ const e = await repo.getEvent(id);
157
+ if (!e) throw new ApiError(404, 'Event not found.');
158
+ const src = e.draft ? { ...e, ...(e.draft as Partial<CalEvent>) } : e;
159
+ const title = `${src.title} (copy)`;
160
+ return repo.createEvent({
161
+ title, slug: await uniqueSlug(repo, title), start: src.start, end: src.end, allDay: src.allDay, timezone: src.timezone,
162
+ rrule: src.rrule, exdates: src.exdates, status: 'draft', excerpt: src.excerpt, bodyJson: src.bodyJson, bodyHtml: src.bodyHtml,
163
+ cover: src.cover, coverAlt: src.coverAlt, location: src.location, cta: src.cta, calendarIds: src.calendarIds, fields: src.fields,
164
+ tags: src.tags, rsvp: src.rsvp, template: src.template,
165
+ });
166
+ }
@@ -132,6 +132,11 @@ export async function cleanEventInput(repo: ContentRepository, data: EventSave,
132
132
  throw new ApiError(400, err instanceof Error ? err.message : 'Invalid time.');
133
133
  }
134
134
  }
135
+ if (out.roomHold !== undefined) {
136
+ // only rooms/people that exist; order kept, duplicates dropped
137
+ const known = new Set((await repo.listResources()).map((r) => r.id));
138
+ out.roomHold = { ...out.roomHold, resourceIds: [...new Set(out.roomHold.resourceIds.filter((id) => known.has(id)))] };
139
+ }
135
140
  if (out.calendarIds !== undefined) {
136
141
  const known = new Set((await repo.listCalendars()).filter((c) => c.kind === 'native').map((c) => c.id));
137
142
  out.calendarIds = [...new Set(out.calendarIds.filter((c) => known.has(c)))];
package/src/lib/drafts.ts CHANGED
@@ -25,7 +25,7 @@ export const PAGE_DRAFT_KEYS = [
25
25
  export const EVENT_DRAFT_KEYS = [
26
26
  'title', 'slug', 'bodyJson', 'bodyHtml', 'excerpt', 'tags', 'cover', 'coverAlt',
27
27
  'seoTitle', 'seoDesc', 'featured', 'template', 'start', 'end', 'allDay',
28
- 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations',
28
+ 'timezone', 'rrule', 'exdates', 'cancelled', 'location', 'cta', 'calendarIds', 'fields', 'rsvp', 'translations', 'roomHold',
29
29
  ] as const;
30
30
 
31
31
  type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];