domma-cms 0.102.1 → 0.104.0

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 (50) hide show
  1. package/package.json +1 -1
  2. package/plugins/_lib/admin/mail/message-actions.js +56 -0
  3. package/plugins/_lib/admin/mail/reader-view.js +30 -1
  4. package/plugins/_lib/call/ice.js +58 -0
  5. package/plugins/_lib/call/rooms.js +249 -0
  6. package/plugins/_lib/call/routes.js +89 -0
  7. package/plugins/_lib/public/call/call.css +1 -0
  8. package/plugins/_lib/public/call/call.js +477 -0
  9. package/plugins/booking/CLAUDE.md +137 -0
  10. package/plugins/booking/admin/css/index.css +192 -0
  11. package/plugins/booking/admin/lib/details.js +58 -0
  12. package/plugins/booking/admin/lib/diary-layout.js +140 -0
  13. package/plugins/booking/admin/lib/extension-sections.js +160 -0
  14. package/plugins/booking/admin/lib/kit.js +249 -0
  15. package/plugins/booking/admin/templates/booking.html +1 -0
  16. package/plugins/booking/admin/views/booking.js +207 -0
  17. package/plugins/booking/admin/views/bookings.js +127 -0
  18. package/plugins/booking/admin/views/diary.js +374 -0
  19. package/plugins/booking/admin/views/panels.js +516 -0
  20. package/plugins/booking/admin/views/people.js +281 -0
  21. package/plugins/booking/admin/views/services.js +222 -0
  22. package/plugins/booking/admin/views/settings.js +203 -0
  23. package/plugins/booking/collections/booking-bookables.json +17 -0
  24. package/plugins/booking/collections/booking-bookings.json +18 -0
  25. package/plugins/booking/collections/booking-services.json +17 -0
  26. package/plugins/booking/config.js +36 -0
  27. package/plugins/booking/docs/guide.md +70 -0
  28. package/plugins/booking/inject/body.html +1 -0
  29. package/plugins/booking/lib/call.js +72 -0
  30. package/plugins/booking/lib/extensions.js +169 -0
  31. package/plugins/booking/lib/mail.js +113 -0
  32. package/plugins/booking/lib/store.js +390 -0
  33. package/plugins/booking/plugin.js +806 -0
  34. package/plugins/booking/plugin.json +80 -0
  35. package/plugins/booking/plugin.public.js +157 -0
  36. package/plugins/booking/public/booking.css +159 -0
  37. package/plugins/booking/public/booking.js +808 -0
  38. package/plugins/booking/public/lib/clean.js +141 -0
  39. package/plugins/booking/public/lib/page-format.js +121 -0
  40. package/plugins/booking/public/lib/slots.js +247 -0
  41. package/plugins/booking/public/lib/time.js +131 -0
  42. package/plugins/booking/templates/booking.html +5 -0
  43. package/plugins/booking/tests/admin-diary.test.js +73 -0
  44. package/plugins/booking/tests/api.test.js +445 -0
  45. package/plugins/booking/tests/public.test.js +72 -0
  46. package/plugins/booking/tests/pure.test.js +189 -0
  47. package/plugins/free-tier.lock.json +44 -1
  48. package/server/routes/api/related.js +20 -5
  49. package/server/server.js +3 -3
  50. package/server/services/hooks.js +35 -8
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The shapes Booking stores, cleaned from whatever a form sent. PURE, shared
3
+ * by the server (which trusts only these) and the admin (which uses them to
4
+ * show the same errors before saving).
5
+ *
6
+ * @module booking/public/lib/clean
7
+ */
8
+
9
+ import {DAY_KEYS, isDate, toMinutes} from './time.js';
10
+
11
+ export const KINDS = ['person', 'resource'];
12
+ export const STATUSES = ['pending', 'confirmed', 'completed', 'no-show', 'cancelled'];
13
+ export const COLOURS = ['blue', 'green', 'teal', 'purple', 'pink', 'orange', 'red', 'grey'];
14
+
15
+ const objectOr = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
16
+ const str = (v, max = 200) => String(v ?? '').trim().slice(0, max);
17
+ const int = (v, min, max, fallback) => {
18
+ const n = Math.round(Number(v));
19
+ return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : fallback;
20
+ };
21
+ export const EMAIL_RE = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
22
+
23
+ /** URL-safe name: "Hair cut & finish" -> "hair-cut-finish". */
24
+ export function slugify(s) {
25
+ return String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '')
26
+ .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'service';
27
+ }
28
+
29
+ /** Weekly hours: {mon: [{from:'09:00', to:'17:00'}]} with nonsense dropped. */
30
+ export function cleanHours(input) {
31
+ const out = {};
32
+ for (const day of DAY_KEYS) {
33
+ const list = Array.isArray(input?.[day]) ? input[day] : [];
34
+ out[day] = list
35
+ .map(w => ({from: str(w?.from, 5), to: str(w?.to, 5)}))
36
+ .filter(w => Number.isFinite(toMinutes(w.from)) && Number.isFinite(toMinutes(w.to)) && toMinutes(w.to) > toMinutes(w.from))
37
+ .slice(0, 8);
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /** Days off / closures: [{from, to, label}], `to` defaulting to `from`. */
43
+ export function cleanDaysOff(input) {
44
+ return (Array.isArray(input) ? input : [])
45
+ .map(o => ({from: str(o?.from, 10), to: str(o?.to || o?.from, 10), label: str(o?.label, 80)}))
46
+ .filter(o => isDate(o.from) && isDate(o.to) && o.to >= o.from)
47
+ .sort((a, b) => a.from.localeCompare(b.from))
48
+ .slice(0, 200);
49
+ }
50
+
51
+ /** Office hours Monday to Friday, the starting point for a new bookable. */
52
+ export const WEEKDAYS_9_TO_5 = Object.fromEntries(DAY_KEYS.map(d => [d, ['mon', 'tue', 'wed', 'thu', 'fri'].includes(d) ? [{from: '09:00', to: '17:00'}] : []]));
53
+
54
+ /**
55
+ * A person or a resource.
56
+ *
57
+ * @returns {{data: object, errors: Object<string, string>}}
58
+ */
59
+ export function cleanBookable(input, current = {}) {
60
+ const v = {...current, ...input};
61
+ const errors = {};
62
+ const data = {
63
+ name: str(v.name, 80),
64
+ kind: KINDS.includes(v.kind) ? v.kind : 'person',
65
+ description: str(v.description, 500),
66
+ userId: str(v.userId, 64),
67
+ email: str(v.email, 200),
68
+ colour: COLOURS.includes(v.colour) ? v.colour : 'blue',
69
+ hours: cleanHours(v.hours ?? WEEKDAYS_9_TO_5),
70
+ daysOff: cleanDaysOff(v.daysOff),
71
+ calendarBusy: v.calendarBusy !== false,
72
+ active: v.active !== false,
73
+ order: int(v.order, 0, 100000, 0)
74
+ };
75
+ if (!data.name) errors.name = 'Give it a name.';
76
+ if (data.email && !EMAIL_RE.test(data.email)) errors.email = 'An email address, or leave it empty.';
77
+ return {data, errors};
78
+ }
79
+
80
+ /**
81
+ * A service.
82
+ *
83
+ * @returns {{data: object, errors: Object<string, string>}}
84
+ */
85
+ export function cleanService(input, current = {}) {
86
+ const v = {...current, ...input};
87
+ const errors = {};
88
+ const duration = int(v.duration, 5, 24 * 60, 60);
89
+ const data = {
90
+ name: str(v.name, 80),
91
+ slug: slugify(v.slug || v.name),
92
+ description: str(v.description, 1000),
93
+ duration,
94
+ bufferBefore: int(v.bufferBefore, 0, 480, 0),
95
+ bufferAfter: int(v.bufferAfter, 0, 480, 0),
96
+ step: int(v.step, 5, 24 * 60, duration >= 60 ? 30 : Math.max(5, duration)),
97
+ minNoticeMinutes: int(v.minNoticeMinutes, 0, 60 * 24 * 60, 120),
98
+ maxAdvanceDays: int(v.maxAdvanceDays, 1, 365, 60),
99
+ bookables: [...new Set((Array.isArray(v.bookables) ? v.bookables : []).map(x => str(x, 64)).filter(Boolean))].slice(0, 100),
100
+ chooseBookable: v.chooseBookable !== false,
101
+ colour: COLOURS.includes(v.colour) ? v.colour : 'blue',
102
+ public: v.public !== false,
103
+ active: v.active !== false,
104
+ order: int(v.order, 0, 100000, 0),
105
+ // Pro keeps its own fields here; the free Tool carries them untouched.
106
+ // Merged per extension, so saving one extension's section never drops another's.
107
+ ext: {...objectOr(current.ext), ...objectOr(input?.ext)}
108
+ };
109
+ if (!data.name) errors.name = 'Give the service a name.';
110
+ if (!data.bookables.length) errors.bookables = 'Pick at least one person or room who provides it.';
111
+ return {data, errors};
112
+ }
113
+
114
+ /**
115
+ * The customer's details on a booking.
116
+ *
117
+ * @param {object} input
118
+ * @param {{askPhone?: boolean, phoneRequired?: boolean}} [rules]
119
+ * @returns {{data: {name: string, email: string, phone: string, notes: string}, errors: Object<string, string>}}
120
+ */
121
+ export function cleanCustomer(input, {askPhone = true, phoneRequired = false} = {}) {
122
+ const errors = {};
123
+ const data = {
124
+ name: str(input?.name, 120),
125
+ email: str(input?.email, 200).toLowerCase(),
126
+ phone: askPhone ? str(input?.phone, 40) : '',
127
+ notes: str(input?.notes, 2000)
128
+ };
129
+ if (!data.name) errors.name = 'Your name, please.';
130
+ if (!EMAIL_RE.test(data.email)) errors.email = 'An email address we can confirm the booking to.';
131
+ if (askPhone && phoneRequired && !data.phone) errors.phone = 'A phone number, please.';
132
+ if (data.phone && !/^[+()\d\s.-]{5,40}$/.test(data.phone)) errors.phone = 'Digits, spaces and + ( ) - only.';
133
+ return {data, errors};
134
+ }
135
+
136
+ /** One-line label for a booking's time, in the business zone: "Tue 15/07/2031, 09:00". */
137
+ export function whenLabel(wall) {
138
+ const [y, m, d] = wall.date.split('-');
139
+ const day = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][wall.weekday];
140
+ return `${day} ${d}/${m}/${y}, ${wall.time}`;
141
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * What the booking page shows, worked out without a DOM: dates in words,
3
+ * lengths, the week strip, and whether to name the time zone. PURE, shared by
4
+ * the server-rendered page (plugin.public.js) and the browser (booking.js).
5
+ *
6
+ * @module booking/public/lib/page-format
7
+ */
8
+
9
+ import {addDays, weekdayOf} from './time.js';
10
+
11
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
12
+ const SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
13
+
14
+ /** '2031-07-15' -> '15/07/2031'. */
15
+ export function ddmmyyyy(date) {
16
+ const [y, m, d] = String(date || '').split('-');
17
+ return y && m && d ? `${d}/${m}/${y}` : '';
18
+ }
19
+
20
+ /** '2031-07-15' -> 'Tuesday 15/07/2031' (or 'Tue 15/07/2031' when short). */
21
+ export function dayLabel(date, {short = false} = {}) {
22
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ''))) return '';
23
+ return `${(short ? SHORT : DAYS)[weekdayOf(date)]} ${ddmmyyyy(date)}`;
24
+ }
25
+
26
+ /** The week strip's parts for one date: {weekday: 'Tue', day: '15', month: '07'}. */
27
+ export function dayParts(date) {
28
+ const [, m, d] = String(date).split('-');
29
+ return {weekday: SHORT[weekdayOf(date)], day: String(Number(d)), month: m};
30
+ }
31
+
32
+ /** 45 -> '45 minutes', 60 -> '1 hour', 90 -> '1 hour 30 minutes'. */
33
+ export function lengthLabel(minutes) {
34
+ const n = Math.max(0, Math.round(Number(minutes) || 0));
35
+ const h = Math.floor(n / 60);
36
+ const m = n % 60;
37
+ const parts = [];
38
+ if (h) parts.push(`${h} hour${h === 1 ? '' : 's'}`);
39
+ if (m || !h) parts.push(`${m} minute${m === 1 ? '' : 's'}`);
40
+ return parts.join(' ');
41
+ }
42
+
43
+ /** The seven dates of a strip starting at `start`. */
44
+ export function weekOf(start) {
45
+ return Array.from({length: 7}, (_, i) => addDays(start, i));
46
+ }
47
+
48
+ /**
49
+ * Where the strip may go from `start`, inside the bookable range.
50
+ *
51
+ * @param {string} start
52
+ * @param {{first: string, last: string}} range
53
+ * @returns {{prev: string, next: string}} '' when that way is out of range
54
+ */
55
+ export function weekMoves(start, range) {
56
+ const prev = addDays(start, -7);
57
+ const next = addDays(start, 7);
58
+ return {
59
+ prev: range?.first && start > range.first ? (prev < range.first ? range.first : prev) : '',
60
+ next: range?.last && next <= range.last ? next : ''
61
+ };
62
+ }
63
+
64
+ /**
65
+ * A note for customers whose clock is somewhere else: '' when the page's zone
66
+ * is theirs (or theirs cannot be told).
67
+ *
68
+ * @param {string} siteZone
69
+ * @param {string} [viewerZone]
70
+ */
71
+ export function zoneNote(siteZone, viewerZone) {
72
+ if (!siteZone || !viewerZone || siteZone === viewerZone) return '';
73
+ return `Times are ${siteZone.replace(/_/g, ' ')} time.`;
74
+ }
75
+
76
+ /** The token from a manage address ('/booking/manage/<id>?t=<token>'). */
77
+ export function tokenFrom(manage) {
78
+ const m = /[?&]t=([\w-]+)/.exec(String(manage || ''));
79
+ return m ? m[1] : '';
80
+ }
81
+
82
+ /** The .ics address for a booking. */
83
+ export const icsUrl = (id, t) => `/api/plugins/booking/public/booking/${encodeURIComponent(id)}/ics?t=${encodeURIComponent(t)}`;
84
+
85
+ /** "3 places left", "1 place left", "Full". */
86
+ export function placesText(left) {
87
+ const n = Math.max(0, Math.round(Number(left) || 0));
88
+ return n ? `${n} place${n === 1 ? '' : 's'} left` : 'Full';
89
+ }
90
+
91
+ /**
92
+ * The booking form's extra questions (Booking Pro): what the page sends, and
93
+ * what it can tell before sending. Errors are keyed `answers.<id>`, the same
94
+ * as the server's, so both show in the same place.
95
+ *
96
+ * @param {{id: string, label: string, kind: string, required?: boolean, options?: string[]}[]} fields
97
+ * @param {Object<string, *>} values - as the controls hold them (strings; true/false for yes/no)
98
+ * @returns {{answers: Object<string, *>, errors: Object<string, string>}}
99
+ */
100
+ export function answersFor(fields, values = {}) {
101
+ const answers = {};
102
+ const errors = {};
103
+ for (const f of fields || []) {
104
+ const key = `answers.${f.id}`;
105
+ const v = values[f.id];
106
+ if (f.kind === 'yesno') {
107
+ answers[f.id] = v === true;
108
+ if (f.required && v !== true) errors[key] = `${f.label}: please tick to confirm.`;
109
+ continue;
110
+ }
111
+ const text = String(v ?? '').trim();
112
+ if (!text) {
113
+ if (f.required) errors[key] = `${f.label}: please answer.`;
114
+ continue;
115
+ }
116
+ if (f.kind === 'number' && !Number.isFinite(Number(text))) { errors[key] = `${f.label}: a number, please.`; continue; }
117
+ if (f.kind === 'email' && !/^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(text)) { errors[key] = `${f.label}: an email address, please.`; continue; }
118
+ answers[f.id] = f.kind === 'number' ? Number(text) : text;
119
+ }
120
+ return {answers, errors};
121
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Which times can be booked - the one place that decides.
3
+ *
4
+ * The booking page asks it, the admin asks it, and the server asks it AGAIN
5
+ * inside the write lock before any booking is saved, so two people choosing
6
+ * the same slot in the same second cannot both have it. PURE: everything it
7
+ * needs is passed in, nothing is read.
8
+ *
9
+ * A booking blocks more than its own length: the service's buffer before and
10
+ * after (setting up, cleaning a room, travel). Each booking stores the span it
11
+ * blocks (`blockStart`, `blockEnd`), so changing a service's buffers later
12
+ * never moves what existing bookings hold.
13
+ *
14
+ * @module booking/public/lib/slots
15
+ */
16
+
17
+ import {DAY_KEYS, addDays, atWallClock, daysBetween, isDate, minutes, overlaps, toMinutes, weekdayOf, wallClock} from './time.js';
18
+
19
+ /** Statuses that hold their time. A cancelled booking frees it. */
20
+ export const HOLDING = new Set(['confirmed', 'pending', 'completed', 'no-show']);
21
+
22
+ /**
23
+ * A bookable's opening windows on one date, in minutes since midnight, after
24
+ * its days off and the business's closures. Windows are merged and sorted.
25
+ *
26
+ * @param {object} bookable - {hours: {mon: [{from, to}]}, daysOff: [{from, to}]}
27
+ * @param {string} date
28
+ * @param {{from: string, to: string}[]} [closures] - the whole business's
29
+ * @returns {{from: number, to: number}[]}
30
+ */
31
+ export function windowsOn(bookable, date, closures = []) {
32
+ const off = [...(bookable?.daysOff || []), ...(closures || [])]
33
+ .some(o => isDate(o?.from) && date >= o.from && date <= (isDate(o.to) ? o.to : o.from));
34
+ if (off) return [];
35
+ const list = (bookable?.hours?.[DAY_KEYS[weekdayOf(date)]] || [])
36
+ .map(w => ({from: toMinutes(w?.from), to: toMinutes(w?.to)}))
37
+ .filter(w => Number.isFinite(w.from) && Number.isFinite(w.to) && w.to > w.from)
38
+ .sort((a, b) => a.from - b.from);
39
+ const merged = [];
40
+ for (const w of list) {
41
+ const last = merged[merged.length - 1];
42
+ if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
43
+ else merged.push({...w});
44
+ }
45
+ return merged;
46
+ }
47
+
48
+ /**
49
+ * The span a booking at `start` for `service` blocks, as ms.
50
+ *
51
+ * @returns {{start: number, end: number, blockStart: number, blockEnd: number}}
52
+ */
53
+ export function spanOf(service, start) {
54
+ const s = typeof start === 'number' ? start : new Date(start).getTime();
55
+ const end = s + minutes(service.duration);
56
+ return {start: s, end, blockStart: s - minutes(service.bufferBefore || 0), blockEnd: end + minutes(service.bufferAfter || 0)};
57
+ }
58
+
59
+ /** The bookables a booking holds: its own, and any it took along (a room with a trainer - Booking Pro). */
60
+ export const holdsOf = (b) => (Array.isArray(b?.holds) && b.holds.length ? b.holds : [b?.bookableId]).filter(Boolean);
61
+
62
+ /**
63
+ * Bookings of the same session - same service, same start, same person or
64
+ * room - are one class that people join (Booking Pro's places). They never
65
+ * clash with each other; they count towards the class's places instead.
66
+ */
67
+ export const sameSession = (b, serviceId, startMs, bookableId) =>
68
+ b.serviceId === serviceId && b.bookableId === bookableId && new Date(b.startAt).getTime() === startMs;
69
+
70
+ /** The rules a service runs under when no extension adds any. */
71
+ export const PLAIN = Object.freeze({capacity: 1, companions: [], maxPerDay: 0});
72
+
73
+ /**
74
+ * How many places are left for this span on one bookable: 0 when it is taken.
75
+ * `capacity` 1 (the plain case) answers 1 or 0.
76
+ *
77
+ * @param {{start: number, end: number, blockStart: number, blockEnd: number}} span
78
+ * @param {string} bookableId
79
+ * @param {object[]} bookings - existing ones, any bookable
80
+ * @param {{startAt: string, endAt: string}[]} [busy] - this bookable's other commitments (its Calendar)
81
+ * @param {string} [ignoreId] - a booking being moved does not clash with itself
82
+ * @param {{serviceId?: string, capacity?: number, primary?: boolean, sessionOf?: string, maxPerDay?: number, dayOf?: Function}} [opts]
83
+ * primary: this bookable is the one booked (a companion never hosts a session);
84
+ * sessionOf: for a companion, the primary whose session may share it.
85
+ */
86
+ export function placesLeft(span, bookableId, bookings, busy = [], ignoreId = '', opts = {}) {
87
+ const capacity = Math.max(1, Number(opts.capacity) || 1);
88
+ const host = opts.primary === false ? opts.sessionOf : bookableId;
89
+ let joined = 0;
90
+ for (const b of bookings || []) {
91
+ if (b.id === ignoreId || !HOLDING.has(b.status) || !holdsOf(b).includes(bookableId)) continue;
92
+ const bs = new Date(b.blockStart || b.startAt).getTime();
93
+ const be = new Date(b.blockEnd || b.endAt).getTime();
94
+ if (!overlaps(span.blockStart, span.blockEnd, bs, be)) continue;
95
+ if (capacity > 1 && host && sameSession(b, opts.serviceId, span.start, host)) { joined++; continue; }
96
+ return 0;
97
+ }
98
+ for (const x of busy || []) {
99
+ // Busy time is the person's own; buffers are ours to keep, so compare the booking itself.
100
+ if (overlaps(span.start, span.end, new Date(x.startAt).getTime(), new Date(x.endAt).getTime())) return 0;
101
+ }
102
+ // A day's limit counts sessions, not people in them: joining a class that already runs is never over it.
103
+ if (opts.maxPerDay > 0 && !joined && typeof opts.dayOf === 'function') {
104
+ const day = opts.dayOf(span.start);
105
+ const sessions = new Set();
106
+ for (const b of bookings || []) {
107
+ if (b.id === ignoreId || !HOLDING.has(b.status) || b.bookableId !== bookableId || opts.dayOf(new Date(b.startAt).getTime()) !== day) continue;
108
+ sessions.add(`${b.serviceId}|${b.startAt}`);
109
+ }
110
+ if (sessions.size >= opts.maxPerDay) return 0;
111
+ }
112
+ return Math.max(0, capacity - joined);
113
+ }
114
+
115
+ /** Is a bookable free for this span? (The plain, one-place case of placesLeft.) */
116
+ export function isFree(span, bookableId, bookings, busy = [], ignoreId = '') {
117
+ return placesLeft(span, bookableId, bookings, busy, ignoreId) > 0;
118
+ }
119
+
120
+ /**
121
+ * Who can take a booking at this span: for each candidate with a place left,
122
+ * the companions it would take along (one from each group, preferring those
123
+ * its session already holds). Candidates a companion group cannot serve drop out.
124
+ *
125
+ * @param {object} p
126
+ * @param {object} p.service
127
+ * @param {{id: string}[]} p.candidates
128
+ * @param {{id: string}[][]} [p.companions] - groups of bookables; one of each is needed as well
129
+ * @param {object} p.span
130
+ * @param {object[]} p.bookings
131
+ * @param {Object<string, object[]>} [p.busy]
132
+ * @param {string} [p.ignoreId]
133
+ * @param {object} [p.rules]
134
+ * @param {Function} [p.dayOf]
135
+ * @returns {{bookableId: string, holds: string[], left: number}[]}
136
+ */
137
+ export function whoCanTake({service, candidates, companions = [], span, bookings, busy = {}, ignoreId = '', rules = PLAIN, dayOf}) {
138
+ const out = [];
139
+ for (const bk of candidates || []) {
140
+ const left = placesLeft(span, bk.id, bookings, busy[bk.id], ignoreId,
141
+ {serviceId: service.id, capacity: rules.capacity, primary: true, maxPerDay: rules.maxPerDay, dayOf});
142
+ if (!left) continue;
143
+ const session = (bookings || []).find(b => b.id !== ignoreId && HOLDING.has(b.status) && sameSession(b, service.id, span.start, bk.id));
144
+ const holds = [bk.id];
145
+ let ok = true;
146
+ for (const group of companions || []) {
147
+ const options = [...group].sort((a, b) => Number(holdsOf(session).includes(b.id)) - Number(holdsOf(session).includes(a.id)));
148
+ const pick = options.find(c => c.id !== bk.id && !holds.includes(c.id)
149
+ && placesLeft(span, c.id, bookings, busy[c.id], ignoreId, {serviceId: service.id, capacity: rules.capacity, primary: false, sessionOf: bk.id}) > 0);
150
+ if (!pick) { ok = false; break; }
151
+ holds.push(pick.id);
152
+ }
153
+ if (ok) out.push({bookableId: bk.id, holds, left});
154
+ }
155
+ return out;
156
+ }
157
+
158
+ /**
159
+ * Every bookable start for a service, day by day.
160
+ *
161
+ * @param {object} input
162
+ * @param {object} input.service - {id, duration, bufferBefore, bufferAfter, step, minNoticeMinutes, maxAdvanceDays, bookables}
163
+ * @param {object[]} input.bookables - the service's providers (active ones)
164
+ * @param {object[]} input.bookings
165
+ * @param {Object<string, object[]>} [input.busy] - bookableId -> busy spans
166
+ * @param {string} input.from - first date
167
+ * @param {string} input.to - last date
168
+ * @param {Date|number} input.now
169
+ * @param {string} input.tz
170
+ * @param {{from: string, to: string}[]} [input.closures]
171
+ * @param {string} [input.ignoreId]
172
+ * @param {object} [input.rules] - capacity, companions (groups of bookables), maxPerDay - from extensions
173
+ * @param {boolean} [input.includeFull] - also list starts whose class is full ({full: true}), for a waiting list
174
+ * @returns {{date: string, slots: {start: string, end: string, time: string, bookableIds: string[], left: number, full?: boolean}[]}[]}
175
+ */
176
+ export function slotsFor({service, bookables, bookings, busy = {}, from, to, now, tz, closures = [], ignoreId = '', rules = PLAIN, includeFull = false}) {
177
+ const nowMs = new Date(now).getTime();
178
+ const earliest = nowMs + minutes(Number(service.minNoticeMinutes) || 0);
179
+ const latest = Number(service.maxAdvanceDays) > 0 ? nowMs + Number(service.maxAdvanceDays) * 86400000 : Infinity;
180
+ const step = Math.max(5, Number(service.step) || Number(service.duration) || 30);
181
+ const dayOf = (ms) => wallClock(ms, tz).date;
182
+ const days = [];
183
+ if (!isDate(from) || !isDate(to) || !(Number(service.duration) > 0)) return days;
184
+ const companions = rules.companions || [];
185
+
186
+ for (const date of daysBetween(from, to, 92)) {
187
+ const byStart = new Map();
188
+ for (const bk of bookables || []) {
189
+ for (const w of windowsOn(bk, date, closures)) {
190
+ for (let m = w.from; m + Number(service.duration) <= w.to; m += step) {
191
+ const start = atWallClock(date, m, tz).getTime();
192
+ // A start the clocks skipped lands on another wall time - it is not this one.
193
+ if (wallClock(start, tz).minutes !== m || wallClock(start, tz).date !== date) continue;
194
+ if (start < earliest || start > latest) continue;
195
+ const span = spanOf(service, start);
196
+ const key = String(start);
197
+ const [can] = whoCanTake({service, candidates: [bk], companions, span, bookings, busy, ignoreId, rules, dayOf});
198
+ if (!can) {
199
+ const fullSession = includeFull && rules.capacity > 1
200
+ && (bookings || []).some(b => b.id !== ignoreId && HOLDING.has(b.status) && sameSession(b, service.id, start, bk.id));
201
+ if (fullSession && !byStart.has(key)) {
202
+ byStart.set(key, {start: new Date(start).toISOString(), end: new Date(span.end).toISOString(), time: wallClock(start, tz).time, bookableIds: [], left: 0, full: true, fullIds: [bk.id]});
203
+ } else if (fullSession && byStart.get(key).full) byStart.get(key).fullIds.push(bk.id);
204
+ continue;
205
+ }
206
+ const slot = byStart.get(key);
207
+ if (!slot || slot.full) {
208
+ byStart.set(key, {start: new Date(start).toISOString(), end: new Date(span.end).toISOString(), time: wallClock(start, tz).time, bookableIds: [bk.id], left: can.left});
209
+ } else {
210
+ slot.bookableIds.push(bk.id);
211
+ slot.left = Math.max(slot.left, can.left);
212
+ }
213
+ }
214
+ }
215
+ }
216
+ const slots = [...byStart.values()].sort((a, b) => a.start.localeCompare(b.start));
217
+ days.push({date, slots});
218
+ }
219
+ return days;
220
+ }
221
+
222
+ /**
223
+ * Who takes a booking made for "anyone": a class that already runs first (so
224
+ * people end up together, not in two half-empty sessions), then the provider
225
+ * with the fewest bookings that day, then the first listed.
226
+ *
227
+ * @param {string[]} ids - free at that time
228
+ * @param {object[]} bookings
229
+ * @param {string} date
230
+ * @param {string} tz
231
+ * @param {{serviceId?: string, start?: number}} [session]
232
+ * @returns {string}
233
+ */
234
+ export function pickBookable(ids, bookings, date, tz, session = {}) {
235
+ if (!ids.length) return '';
236
+ const running = session.serviceId ? ids.find(id => (bookings || []).some(b => HOLDING.has(b.status) && sameSession(b, session.serviceId, session.start, id))) : '';
237
+ if (running) return running;
238
+ const load = (id) => (bookings || []).filter(b => b.bookableId === id && HOLDING.has(b.status) && wallClock(b.startAt, tz).date === date).length;
239
+ return [...ids].map((id, i) => ({id, i, n: load(id)})).sort((a, b) => a.n - b.n || a.i - b.i)[0].id;
240
+ }
241
+
242
+ /** The first date that could have a slot, and the last, for a service - the booking page's range. */
243
+ export function bookingRange(service, now, tz) {
244
+ const first = wallClock(new Date(new Date(now).getTime() + minutes(Number(service.minNoticeMinutes) || 0)), tz).date;
245
+ const days = Math.max(1, Math.min(365, Number(service.maxAdvanceDays) || 60));
246
+ return {first, last: addDays(wallClock(now, tz).date, days)};
247
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Wall-clock time in a named zone, without a library.
3
+ *
4
+ * Bookings are stored as UTC instants. Opening hours are wall-clock times
5
+ * ("09:00 on Mondays") in the business's zone, which moves against UTC twice a
6
+ * year. Everything that turns one into the other is here, PURE and shared by
7
+ * the server, the admin and the booking page (public/ is served; lib/ is not).
8
+ *
9
+ * Dates are 'YYYY-MM-DD' strings and times 'HH:MM', never Date objects, until
10
+ * the moment they become an instant: a Date carries the host's zone, and the
11
+ * host is not where the business is.
12
+ *
13
+ * @module booking/public/lib/time
14
+ */
15
+
16
+ export const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
17
+ const MINUTE = 60000;
18
+
19
+ const formatters = new Map();
20
+ function partsFormatter(tz) {
21
+ if (!formatters.has(tz)) {
22
+ formatters.set(tz, new Intl.DateTimeFormat('en-GB', {
23
+ timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit',
24
+ hour: '2-digit', minute: '2-digit', second: '2-digit', weekday: 'short'
25
+ }));
26
+ }
27
+ return formatters.get(tz);
28
+ }
29
+
30
+ /** A zone name Intl accepts, else 'Europe/London'. */
31
+ export function cleanZone(tz) {
32
+ const name = String(tz || '').trim();
33
+ if (!name) return 'Europe/London';
34
+ try { partsFormatter(name); return name; } catch { return 'Europe/London'; }
35
+ }
36
+
37
+ /** The wall clock in `tz` at an instant: {date, time, weekday (0 = Sunday), minutes (since midnight)}. */
38
+ export function wallClock(instant, tz) {
39
+ const at = instant instanceof Date ? instant : new Date(instant);
40
+ const p = {};
41
+ for (const {type, value} of partsFormatter(cleanZone(tz)).formatToParts(at)) p[type] = value;
42
+ const hour = p.hour === '24' ? '00' : p.hour;
43
+ return {
44
+ date: `${p.year}-${p.month}-${p.day}`,
45
+ time: `${hour}:${p.minute}`,
46
+ weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(p.weekday),
47
+ minutes: Number(hour) * 60 + Number(p.minute)
48
+ };
49
+ }
50
+
51
+ /** How far `tz` is ahead of UTC at an instant, in milliseconds. */
52
+ export function zoneOffset(instant, tz) {
53
+ const at = instant instanceof Date ? instant : new Date(instant);
54
+ const p = {};
55
+ for (const {type, value} of partsFormatter(cleanZone(tz)).formatToParts(at)) p[type] = value;
56
+ const asUtc = Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour === '24' ? 0 : p.hour), Number(p.minute), Number(p.second));
57
+ return asUtc - Math.floor(at.getTime() / 1000) * 1000;
58
+ }
59
+
60
+ /**
61
+ * The instant a wall-clock time in `tz` happens.
62
+ *
63
+ * A time the clocks skip (01:30 on the spring-forward night in London) comes
64
+ * out an hour later, as a wall clock would read it; a time that happens twice
65
+ * (the autumn night) is the first of the two.
66
+ *
67
+ * @param {string} date - 'YYYY-MM-DD'
68
+ * @param {string|number} time - 'HH:MM', or minutes since midnight
69
+ * @param {string} tz
70
+ * @returns {Date}
71
+ */
72
+ export function atWallClock(date, time, tz) {
73
+ const [y, m, d] = String(date).split('-').map(Number);
74
+ const mins = typeof time === 'number' ? time : toMinutes(time);
75
+ const guess = Date.UTC(y, m - 1, d, 0, mins);
76
+ // The zone's offset a day either side: across a change they differ, and
77
+ // the wall time belongs to one of them, both (autumn), or neither (spring).
78
+ const before = zoneOffset(new Date(guess - 86400000), tz);
79
+ const after = zoneOffset(new Date(guess + 86400000), tz);
80
+ const want = ((mins % 1440) + 1440) % 1440;
81
+ const fits = [guess - before, guess - after].filter(t => wallClock(new Date(t), tz).minutes === want);
82
+ return new Date(fits.length ? Math.min(...fits) : guess - before);
83
+ }
84
+
85
+ /** 'HH:MM' -> minutes since midnight (NaN when it is not a time). '24:00' is 1440, the end of a day. */
86
+ export function toMinutes(time) {
87
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(time ?? '').trim());
88
+ if (!m) return NaN;
89
+ const h = Number(m[1]);
90
+ const min = Number(m[2]);
91
+ if (min > 59 || h > 24 || (h === 24 && min)) return NaN;
92
+ return h * 60 + min;
93
+ }
94
+
95
+ /** Minutes since midnight -> 'HH:MM'. */
96
+ export function fromMinutes(mins) {
97
+ const n = Math.max(0, Math.min(1440, Math.round(mins)));
98
+ return `${String(Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}`;
99
+ }
100
+
101
+ /** A calendar date `n` days on (UTC arithmetic on a date only - no zone involved). */
102
+ export function addDays(date, n) {
103
+ const [y, m, d] = String(date).split('-').map(Number);
104
+ return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
105
+ }
106
+
107
+ /** 0 (Sunday) to 6 for a calendar date. */
108
+ export function weekdayOf(date) {
109
+ const [y, m, d] = String(date).split('-').map(Number);
110
+ return new Date(Date.UTC(y, m - 1, d)).getUTCDay();
111
+ }
112
+
113
+ /** Is this a real 'YYYY-MM-DD'? */
114
+ export function isDate(date) {
115
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date ?? ''))) return false;
116
+ const [y, m, d] = date.split('-').map(Number);
117
+ const t = new Date(Date.UTC(y, m - 1, d));
118
+ return t.getUTCFullYear() === y && t.getUTCMonth() === m - 1 && t.getUTCDate() === d;
119
+ }
120
+
121
+ /** Days from `from` to `to`, both included. */
122
+ export function daysBetween(from, to, limit = 400) {
123
+ const out = [];
124
+ for (let d = from; d <= to && out.length < limit; d = addDays(d, 1)) out.push(d);
125
+ return out;
126
+ }
127
+
128
+ /** Do [aStart, aEnd) and [bStart, bEnd) overlap? Instants as ms. */
129
+ export const overlaps = (aStart, aEnd, bStart, bEnd) => aStart < bEnd && bStart < aEnd;
130
+
131
+ export const minutes = (n) => n * MINUTE;
@@ -0,0 +1,5 @@
1
+ <main class="container bk-page" id="dm-booking" data-booking-page="{{page}}">
2
+ {{body}}
3
+ </main>
4
+ <link rel="stylesheet" href="/plugins/booking/public/booking.css?v={{assetV}}">
5
+ <script type="module" src="/plugins/booking/public/booking.js?v={{assetV}}"></script>