domma-cms 0.102.1 → 0.103.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.
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/message-actions.js +56 -0
- package/plugins/_lib/admin/mail/reader-view.js +30 -1
- package/plugins/_lib/call/ice.js +58 -0
- package/plugins/_lib/call/rooms.js +232 -0
- package/plugins/_lib/call/routes.js +88 -0
- package/plugins/_lib/public/call/call.css +1 -0
- package/plugins/_lib/public/call/call.js +468 -0
- package/plugins/booking/CLAUDE.md +123 -0
- package/plugins/booking/admin/css/index.css +192 -0
- package/plugins/booking/admin/lib/details.js +58 -0
- package/plugins/booking/admin/lib/diary-layout.js +140 -0
- package/plugins/booking/admin/lib/extension-sections.js +160 -0
- package/plugins/booking/admin/lib/kit.js +249 -0
- package/plugins/booking/admin/templates/booking.html +1 -0
- package/plugins/booking/admin/views/booking.js +177 -0
- package/plugins/booking/admin/views/bookings.js +127 -0
- package/plugins/booking/admin/views/diary.js +374 -0
- package/plugins/booking/admin/views/panels.js +516 -0
- package/plugins/booking/admin/views/people.js +281 -0
- package/plugins/booking/admin/views/services.js +222 -0
- package/plugins/booking/admin/views/settings.js +203 -0
- package/plugins/booking/collections/booking-bookables.json +17 -0
- package/plugins/booking/collections/booking-bookings.json +18 -0
- package/plugins/booking/collections/booking-services.json +17 -0
- package/plugins/booking/config.js +36 -0
- package/plugins/booking/docs/guide.md +69 -0
- package/plugins/booking/inject/body.html +1 -0
- package/plugins/booking/lib/call.js +58 -0
- package/plugins/booking/lib/extensions.js +150 -0
- package/plugins/booking/lib/mail.js +113 -0
- package/plugins/booking/lib/store.js +384 -0
- package/plugins/booking/plugin.js +760 -0
- package/plugins/booking/plugin.json +80 -0
- package/plugins/booking/plugin.public.js +157 -0
- package/plugins/booking/public/booking.css +159 -0
- package/plugins/booking/public/booking.js +808 -0
- package/plugins/booking/public/lib/clean.js +141 -0
- package/plugins/booking/public/lib/page-format.js +121 -0
- package/plugins/booking/public/lib/slots.js +247 -0
- package/plugins/booking/public/lib/time.js +131 -0
- package/plugins/booking/templates/booking.html +5 -0
- package/plugins/booking/tests/admin-diary.test.js +73 -0
- package/plugins/booking/tests/api.test.js +356 -0
- package/plugins/booking/tests/public.test.js +71 -0
- package/plugins/booking/tests/pure.test.js +189 -0
- package/plugins/free-tier.lock.json +44 -1
- package/server/routes/api/related.js +20 -5
- package/server/server.js +3 -3
- 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;
|