@proveanything/smartlinks 2.0.70 → 2.1.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.
@@ -0,0 +1,206 @@
1
+ // src/utils/freeTimes.ts
2
+ // Free-time maths for booking apps — pure, no I/O (docs/bookings.md, "Schedules belong to the app").
3
+ //
4
+ // Your app owns its schedule and its booking policy; this helper just does the fiddly part:
5
+ // turning local wall-clock opening hours into UTC intervals (daylight-saving safe), subtracting busy
6
+ // time (from bookings.freebusy), and stepping out bookable start times with durations, buffers and
7
+ // notice. Use it or ignore it — the platform never reads your schedule, except a schedule in this
8
+ // standard shape when your app allows public free-form booking with `rules.schedule: true`.
9
+ const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
10
+ const HHMM = /^([01]\d|2[0-4]):([0-5]\d)$/;
11
+ const YMD = /^\d{4}-\d{2}-\d{2}$/;
12
+ function minutesOf(hhmm) {
13
+ const m = HHMM.exec(hhmm || '');
14
+ if (!m)
15
+ throw new Error(`Invalid time "${hhmm}" (use HH:MM)`);
16
+ const mins = Number(m[1]) * 60 + Number(m[2]);
17
+ if (mins > 24 * 60)
18
+ throw new Error(`Invalid time "${hhmm}"`);
19
+ return mins;
20
+ }
21
+ /** Throws a readable Error when `schedule` isn't the standard shape. */
22
+ export function validateSchedule(schedule) {
23
+ if (!schedule || typeof schedule !== 'object')
24
+ throw new Error('Schedule must be an object');
25
+ try {
26
+ new Intl.DateTimeFormat('en-US', { timeZone: schedule.timezone });
27
+ }
28
+ catch (_a) {
29
+ throw new Error('Schedule needs a valid IANA timezone');
30
+ }
31
+ if (!schedule.timezone)
32
+ throw new Error('Schedule needs a valid IANA timezone');
33
+ const check = (hours, where) => {
34
+ if (!Array.isArray(hours))
35
+ throw new Error(`${where}: hours must be an array`);
36
+ for (const h of hours)
37
+ if (minutesOf(h.end) <= minutesOf(h.start))
38
+ throw new Error(`${where}: end must be after start (split hours that cross midnight)`);
39
+ };
40
+ for (const [day, hours] of Object.entries(schedule.weekly || {})) {
41
+ if (!DAY_KEYS.includes(day))
42
+ throw new Error(`Unknown day "${day}" (use ${DAY_KEYS.join(', ')})`);
43
+ check(hours, day);
44
+ }
45
+ for (const o of schedule.overrides || []) {
46
+ const ok = (o.date && YMD.test(o.date)) || (o.from && o.to && YMD.test(o.from) && YMD.test(o.to));
47
+ if (!ok)
48
+ throw new Error('Each override needs `date` or `from` + `to` (YYYY-MM-DD)');
49
+ check(o.hours || [], `override ${o.date || o.from}`);
50
+ }
51
+ return true;
52
+ }
53
+ function tzOffsetMs(ts, tz) {
54
+ const parts = new Intl.DateTimeFormat('en-US', {
55
+ timeZone: tz, hourCycle: 'h23',
56
+ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
57
+ }).formatToParts(new Date(ts));
58
+ const v = {};
59
+ for (const p of parts)
60
+ if (p.type !== 'literal')
61
+ v[p.type] = Number(p.value);
62
+ const asUtc = Date.UTC(v.year, v.month - 1, v.day, v.hour === 24 ? 0 : v.hour, v.minute, v.second);
63
+ return asUtc - (ts - (ts % 1000));
64
+ }
65
+ /** The UTC instant (ms) of local date `ymd` + `minutes` past midnight in `tz`. */
66
+ export function zonedToUtc(ymd, minutes, tz) {
67
+ const [y, mo, d] = ymd.split('-').map(Number);
68
+ const guess = Date.UTC(y, mo - 1, d) + minutes * 60000;
69
+ let ts = guess - tzOffsetMs(guess, tz);
70
+ const off2 = tzOffsetMs(ts, tz);
71
+ if (guess - off2 !== ts)
72
+ ts = guess - off2;
73
+ return ts;
74
+ }
75
+ function localYmd(ts, tz) {
76
+ return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(ts));
77
+ }
78
+ function addDaysYmd(ymd, n) {
79
+ const [y, m, d] = ymd.split('-').map(Number);
80
+ return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
81
+ }
82
+ function dayKey(ymd) {
83
+ const [y, m, d] = ymd.split('-').map(Number);
84
+ return DAY_KEYS[new Date(Date.UTC(y, m - 1, d)).getUTCDay()];
85
+ }
86
+ function hoursFor(schedule, ymd) {
87
+ let hours = (schedule.weekly || {})[dayKey(ymd)] || [];
88
+ for (const o of schedule.overrides || []) {
89
+ if ((o.date && o.date === ymd) || (o.from && o.to && o.from <= ymd && ymd <= o.to))
90
+ hours = o.hours || [];
91
+ }
92
+ return hours;
93
+ }
94
+ function toMs(v) {
95
+ return v instanceof Date ? v.getTime() : typeof v === 'number' ? v : new Date(v).getTime();
96
+ }
97
+ function merge(list) {
98
+ const sorted = [...list].sort((a, b) => a.start - b.start);
99
+ const out = [];
100
+ for (const i of sorted) {
101
+ const last = out[out.length - 1];
102
+ if (last && i.start <= last.end)
103
+ last.end = Math.max(last.end, i.end);
104
+ else
105
+ out.push(Object.assign({}, i));
106
+ }
107
+ return out;
108
+ }
109
+ /** The schedule's open hours overlapping [from, to), as merged UTC intervals. */
110
+ export function openIntervals(schedule, from, to) {
111
+ const fromMs = toMs(from);
112
+ const toMsV = toMs(to);
113
+ const tz = schedule.timezone;
114
+ const out = [];
115
+ let ymd = addDaysYmd(localYmd(fromMs, tz), -1);
116
+ const last = addDaysYmd(localYmd(toMsV, tz), 1);
117
+ while (ymd <= last) {
118
+ for (const h of hoursFor(schedule, ymd)) {
119
+ const start = zonedToUtc(ymd, minutesOf(h.start), tz);
120
+ const end = zonedToUtc(ymd, minutesOf(h.end), tz);
121
+ if (end > fromMs && start < toMsV)
122
+ out.push({ start, end });
123
+ }
124
+ ymd = addDaysYmd(ymd, 1);
125
+ }
126
+ return merge(out);
127
+ }
128
+ /** True when [start, end) lies entirely inside the schedule's open hours. */
129
+ export function withinSchedule(schedule, start, end) {
130
+ const s = toMs(start);
131
+ const e = toMs(end);
132
+ return openIntervals(schedule, s, e).some((i) => i.start <= s && e <= i.end);
133
+ }
134
+ /** `open` minus `busy` (both lists of intervals). */
135
+ export function subtractIntervals(open, busy) {
136
+ const b = merge(busy);
137
+ const out = [];
138
+ for (const o of merge(open)) {
139
+ let cursor = o.start;
140
+ for (const x of b) {
141
+ if (x.end <= cursor || x.start >= o.end)
142
+ continue;
143
+ if (x.start > cursor)
144
+ out.push({ start: cursor, end: x.start });
145
+ cursor = Math.max(cursor, x.end);
146
+ if (cursor >= o.end)
147
+ break;
148
+ }
149
+ if (cursor < o.end)
150
+ out.push({ start: cursor, end: o.end });
151
+ }
152
+ return out;
153
+ }
154
+ /**
155
+ * Bookable start times: open hours minus busy time, stepped at `step`, each long enough for
156
+ * `duration` (+ buffers against busy time), not sooner than `minNotice`.
157
+ *
158
+ * const fb = await bookings.freebusy(collectionId, appId, { resources: ['record:sam'], from, to }, true)
159
+ * const times = utils.freeTimes({ hours: samsSchedule, busy: fb.resources[0].busy, from, to, duration: 30 })
160
+ *
161
+ * Start times are aligned to `step` from local midnight in the schedule's timezone (so 30-minute
162
+ * steps land on :00 and :30), or from each open interval's start when you pass intervals.
163
+ */
164
+ export function freeTimes(opts) {
165
+ var _a;
166
+ const from = toMs(opts.from);
167
+ const to = toMs(opts.to);
168
+ const duration = opts.duration * 60000;
169
+ const step = (opts.step || opts.duration) * 60000;
170
+ const before = (opts.bufferBefore || 0) * 60000;
171
+ const after = (opts.bufferAfter || 0) * 60000;
172
+ const earliest = ((_a = opts.now) !== null && _a !== void 0 ? _a : Date.now()) + (opts.minNotice || 0) * 60000;
173
+ if (!(duration > 0) || !(step > 0) || !(to > from))
174
+ return [];
175
+ const isSchedule = !Array.isArray(opts.hours);
176
+ const schedule = isSchedule ? opts.hours : null;
177
+ const open = isSchedule ? openIntervals(schedule, from - before, to + after) : merge(opts.hours);
178
+ const busy = merge((opts.busy || []).map((b) => ({ start: toMs(b.start), end: toMs(b.end) })));
179
+ const freeForBuffers = subtractIntervals([{ start: from - before - 86400000, end: to + after + 86400000 }], busy);
180
+ const out = [];
181
+ for (const o of open) {
182
+ // Alignment origin: local midnight of the interval's day (schedules), or the interval start.
183
+ let t;
184
+ if (schedule) {
185
+ const ymd = localYmd(o.start, schedule.timezone);
186
+ const midnight = zonedToUtc(ymd, 0, schedule.timezone);
187
+ t = midnight + Math.ceil((o.start - midnight) / step) * step;
188
+ }
189
+ else {
190
+ t = o.start;
191
+ }
192
+ for (; t + duration <= o.end; t += step) {
193
+ if (t < from || t + duration > to || t < earliest)
194
+ continue;
195
+ const blockStart = t - before;
196
+ const blockEnd = t + duration + after;
197
+ if (opts.buffersWithinHours && (blockStart < o.start || blockEnd > o.end))
198
+ continue;
199
+ // The whole blocked span (appointment + buffers) must be clear of busy time.
200
+ if (!freeForBuffers.some((f) => f.start <= blockStart && blockEnd <= f.end))
201
+ continue;
202
+ out.push({ start: new Date(t).toISOString(), end: new Date(t + duration).toISOString() });
203
+ }
204
+ }
205
+ return out;
206
+ }
@@ -5,3 +5,4 @@
5
5
  export * from './paths.js';
6
6
  export * from './conditions.js';
7
7
  export * from './anonId.js';
8
+ export * from './freeTimes.js';
@@ -6,3 +6,4 @@
6
6
  export * from './paths.js';
7
7
  export * from './conditions.js';
8
8
  export * from './anonId.js';
9
+ export * from './freeTimes.js';