@proveanything/smartlinks 2.0.70 → 2.0.71

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.
@@ -39,8 +39,26 @@ export interface Asset {
39
39
  proofId: string | null;
40
40
  /** App that owns this asset, e.g. 'homepage' */
41
41
  appId: string | null;
42
- /** CDN URL of the original file */
42
+ /**
43
+ * Address of the asset's current **file**. Versioned: it changes when the file is replaced (the old one moves
44
+ * to `versions[]`). For static content, use `assetUrl` instead.
45
+ */
43
46
  url: string;
47
+ /**
48
+ * The **asset's** own address: `https://cdn.smartlinks.app/sites/{collectionId}/a/{assetId}`.
49
+ *
50
+ * It's the address of the asset (the named thing, e.g. "Hero image"), not of one file. It always serves the
51
+ * asset's current file: replace the file and everything using `assetUrl` shows the new one, with no redeploy
52
+ * and no extra API call (the CDN copy is cleared; browsers re-check within a minute). Deleting the asset takes
53
+ * the address down; restoring it brings it back.
54
+ *
55
+ * Use it in static content: a built site's HTML and CSS, emails, banners. Product and collection images are
56
+ * different: they are references in the record (`AssetRef`), and a new image is a record change.
57
+ *
58
+ * `null` for an outside link, and for an asset made before asset addresses existed until it's given one
59
+ * ({@link asset.ensureAssetUrl}).
60
+ */
61
+ assetUrl: string | null;
44
62
  /**
45
63
  * CDN URL of the WebP thumbnail (max 512px longest edge, no crop).
46
64
  * Always .webp — null until thumbnail generation has run.
@@ -336,6 +354,12 @@ export interface CreateResumableUploadOptions {
336
354
  * uploads. When provided, the public resumable route is used.
337
355
  */
338
356
  token?: string;
357
+ /**
358
+ * Replace this asset's file instead of creating a new asset (admin). The id and `assetUrl` stay the same,
359
+ * so everything using `assetUrl` shows the new file; the previous file goes into `versions[]`. Any size —
360
+ * use this to replace a large video.
361
+ */
362
+ replaceAssetId?: string;
339
363
  }
340
364
  export interface ResumableStartOptions {
341
365
  /** Progress callback (0-100), driven by bytes confirmed by storage. */
@@ -0,0 +1,301 @@
1
+ import type { PaginatedResponse } from './appObjects.js';
2
+ export type BookingStatus = 'held' | 'pending' | 'confirmed' | 'completed' | 'cancelled' | 'no_show' | 'waitlisted';
3
+ export type BookingVisibility = 'public' | 'owner' | 'admin';
4
+ /** A claim on a resource's time: seats on a slot (`capacity`) or exclusive time (`exclusive`). */
5
+ export interface BookingAllocation {
6
+ id: string;
7
+ bookingId: string | null;
8
+ slotId: string | null;
9
+ resourceType: string;
10
+ resourceId: string;
11
+ /** The time actually blocked — buffers included. */
12
+ startsAt: string;
13
+ endsAt: string;
14
+ quantity: number;
15
+ mode: 'exclusive' | 'capacity';
16
+ active: boolean;
17
+ sourceSystem?: string | null;
18
+ data: Record<string, any>;
19
+ }
20
+ export interface Booking {
21
+ id: string;
22
+ collectionId: string;
23
+ appId: string;
24
+ visibility: BookingVisibility;
25
+ /** Admin / owner callers only. */
26
+ contactId?: string;
27
+ status: BookingStatus;
28
+ /** The customer-visible time (buffers excluded). */
29
+ startsAt: string;
30
+ endsAt: string;
31
+ timezone?: string | null;
32
+ quantity: number;
33
+ holdExpiresAt?: string | null;
34
+ /** Platform flags, e.g. 'hold_expired'. */
35
+ flags: string[];
36
+ /** 'admin' | 'owner' | 'hold_expired' | an app-defined reason. */
37
+ cancelReason?: string | null;
38
+ idempotencyKey?: string | null;
39
+ seriesId?: string | null;
40
+ ref?: string | null;
41
+ customId?: string | null;
42
+ sourceSystem?: string | null;
43
+ productId?: string | null;
44
+ proofId?: string;
45
+ parentType?: string | null;
46
+ parentId?: string | null;
47
+ createdAt: string;
48
+ updatedAt: string;
49
+ cancelledAt?: string | null;
50
+ data: Record<string, any>;
51
+ /** Owner + admin only. */
52
+ owner?: Record<string, any>;
53
+ /** Admin only. */
54
+ admin?: Record<string, any>;
55
+ allocations: BookingAllocation[];
56
+ /**
57
+ * Anonymous public creates only, when the app enables `publicCreate.bookings.anonymous.edit.editToken`.
58
+ * Returned ONCE — store it to let the guest view / cancel / move / confirm their booking.
59
+ */
60
+ editToken?: string;
61
+ }
62
+ export interface BookingSlot {
63
+ id: string;
64
+ collectionId: string;
65
+ appId: string;
66
+ visibility: BookingVisibility;
67
+ resourceType?: string | null;
68
+ resourceId?: string | null;
69
+ startsAt: string;
70
+ endsAt: string;
71
+ timezone?: string | null;
72
+ capacity: number;
73
+ booked: number;
74
+ /** capacity - booked */
75
+ remaining: number;
76
+ perContactLimit?: number | null;
77
+ status: 'open' | 'closed' | 'cancelled';
78
+ seriesId?: string | null;
79
+ ref?: string | null;
80
+ customId?: string | null;
81
+ sourceSystem?: string | null;
82
+ productId?: string | null;
83
+ createdAt: string;
84
+ updatedAt: string;
85
+ data: Record<string, any>;
86
+ owner?: Record<string, any>;
87
+ admin?: Record<string, any>;
88
+ }
89
+ /** One claim in a booking request: seats on a slot, or exclusive time on a resource. */
90
+ export type BookingClaimInput = {
91
+ slotId: string;
92
+ quantity?: number;
93
+ } | {
94
+ resourceType: string;
95
+ resourceId: string;
96
+ /** Defaults to the booking's startsAt / endsAt. */
97
+ startsAt?: string | Date;
98
+ endsAt?: string | Date;
99
+ /** Admin only (public bookings take buffers from the app's rules). Minutes. */
100
+ bufferBefore?: number;
101
+ bufferAfter?: number;
102
+ };
103
+ export interface CreateBookingInput {
104
+ /** Seats on one slot … */
105
+ slotId?: string;
106
+ quantity?: number;
107
+ /** … or exclusive time on one resource … */
108
+ resourceType?: string;
109
+ resourceId?: string;
110
+ resource?: {
111
+ type: string;
112
+ id: string;
113
+ };
114
+ startsAt?: string | Date;
115
+ endsAt?: string | Date;
116
+ /** … or several claims at once (all-or-nothing): a stylist and a chair, two tables. */
117
+ allocations?: BookingClaimInput[];
118
+ /** Admin only — public bookings get the status the app's rules enforce. Default 'confirmed'. */
119
+ status?: 'held' | 'pending' | 'confirmed' | 'waitlisted';
120
+ /** For status 'held' (admin: default 15, max 1440). */
121
+ holdMinutes?: number;
122
+ /** Admin only: blocked time around an exclusive claim, in minutes. */
123
+ bufferBefore?: number;
124
+ bufferAfter?: number;
125
+ /** A retried create with the same key returns the first booking instead of booking twice. */
126
+ idempotencyKey?: string;
127
+ /** Admin only — public bookings use the signed-in caller's contact. */
128
+ contactId?: string;
129
+ timezone?: string;
130
+ visibility?: BookingVisibility;
131
+ ref?: string;
132
+ customId?: string;
133
+ sourceSystem?: string;
134
+ productId?: string;
135
+ proofId?: string;
136
+ parentType?: string;
137
+ parentId?: string;
138
+ seriesId?: string;
139
+ data?: Record<string, any>;
140
+ owner?: Record<string, any>;
141
+ /** Admin only. */
142
+ admin?: Record<string, any>;
143
+ }
144
+ export interface UpdateBookingInput {
145
+ data?: Record<string, any>;
146
+ owner?: Record<string, any>;
147
+ admin?: Record<string, any>;
148
+ visibility?: BookingVisibility;
149
+ ref?: string;
150
+ timezone?: string;
151
+ productId?: string;
152
+ proofId?: string;
153
+ contactId?: string;
154
+ sourceSystem?: string;
155
+ customId?: string;
156
+ flags?: string[];
157
+ }
158
+ export interface MoveBookingInput {
159
+ slotId?: string;
160
+ resourceType?: string;
161
+ resourceId?: string;
162
+ resource?: {
163
+ type: string;
164
+ id: string;
165
+ };
166
+ startsAt?: string | Date;
167
+ endsAt?: string | Date;
168
+ allocations?: BookingClaimInput[];
169
+ bufferBefore?: number;
170
+ bufferAfter?: number;
171
+ }
172
+ export interface BookingListQueryParams {
173
+ status?: string;
174
+ contactId?: string;
175
+ slotId?: string;
176
+ resourceType?: string;
177
+ resourceId?: string;
178
+ /** Bookings overlapping [from, to). */
179
+ from?: string | Date;
180
+ to?: string | Date;
181
+ ref?: string;
182
+ customId?: string;
183
+ sourceSystem?: string;
184
+ productId?: string;
185
+ seriesId?: string;
186
+ /** Default 'starts_at:asc'. */
187
+ sort?: string;
188
+ limit?: number;
189
+ offset?: number;
190
+ }
191
+ export interface CreateSlotInput {
192
+ startsAt: string | Date;
193
+ endsAt: string | Date;
194
+ capacity: number;
195
+ perContactLimit?: number;
196
+ /** What the slot runs on (an instructor, a room) … */
197
+ resourceType?: string;
198
+ resourceId?: string;
199
+ /** … and whether the slot blocks that resource's time (no double-booking across slots). */
200
+ blockResource?: boolean;
201
+ status?: 'open' | 'closed' | 'cancelled';
202
+ timezone?: string;
203
+ visibility?: BookingVisibility;
204
+ seriesId?: string;
205
+ ref?: string;
206
+ customId?: string;
207
+ sourceSystem?: string;
208
+ productId?: string;
209
+ data?: Record<string, any>;
210
+ owner?: Record<string, any>;
211
+ admin?: Record<string, any>;
212
+ }
213
+ export type UpdateSlotInput = Partial<Omit<CreateSlotInput, 'blockResource' | 'seriesId' | 'resourceType' | 'resourceId'>>;
214
+ export interface SlotListQueryParams {
215
+ status?: string;
216
+ /** Only slots with places left. */
217
+ available?: boolean;
218
+ from?: string | Date;
219
+ to?: string | Date;
220
+ resourceType?: string;
221
+ resourceId?: string;
222
+ productId?: string;
223
+ seriesId?: string;
224
+ ref?: string;
225
+ customId?: string;
226
+ sort?: string;
227
+ limit?: number;
228
+ offset?: number;
229
+ }
230
+ export interface CreateBlockInput {
231
+ resourceType: string;
232
+ resourceId: string;
233
+ startsAt: string | Date;
234
+ endsAt: string | Date;
235
+ sourceSystem?: string;
236
+ data?: Record<string, any>;
237
+ }
238
+ export interface BlockListQueryParams {
239
+ resourceType?: string;
240
+ resourceId?: string;
241
+ from?: string | Date;
242
+ to?: string | Date;
243
+ limit?: number;
244
+ offset?: number;
245
+ }
246
+ /** A resource reference: `{ type, id }` or the string form `'record:staff-1'`. */
247
+ export type BookingResourceRef = {
248
+ type: string;
249
+ id: string;
250
+ } | string;
251
+ export interface FreeBusyQuery {
252
+ resources: BookingResourceRef[];
253
+ from: string | Date;
254
+ to: string | Date;
255
+ }
256
+ export interface FreeBusyInterval {
257
+ start: string;
258
+ end: string;
259
+ }
260
+ export interface FreeBusyResponse {
261
+ from: string;
262
+ to: string;
263
+ resources: Array<{
264
+ resourceType: string;
265
+ resourceId: string;
266
+ busy: FreeBusyInterval[];
267
+ }>;
268
+ }
269
+ /** Public calls on a booking made by an anonymous guest: pass the edit token returned at create. */
270
+ export interface BookingAccessOptions {
271
+ editToken?: string;
272
+ }
273
+ export type BookingList = PaginatedResponse<Booking>;
274
+ export type SlotList = PaginatedResponse<BookingSlot>;
275
+ export type BlockList = PaginatedResponse<BookingAllocation>;
276
+ /** Error codes a booking call can fail with (HTTP 409 unless noted). */
277
+ export type BookingErrorCode = 'SLOT_FULL' | 'SLOT_CLOSED' | 'RESOURCE_TAKEN' | 'PER_CONTACT_LIMIT' | 'QUANTITY_LIMIT' | 'HOLD_EXPIRED' | 'INVALID_STATE' | 'SLOT_OVERBOOKED' | 'SLOT_HAS_BOOKINGS' | 'TOO_SOON' | 'TOO_FAR_AHEAD' | 'IN_THE_PAST' | 'DURATION_NOT_ALLOWED' | 'OUTSIDE_SCHEDULE' | 'NO_SCHEDULE' | 'SCHEDULE_INVALID' | 'CUTOFF_PASSED' | 'FORBIDDEN' | 'NOT_FOUND' | 'BAD_REQUEST';
278
+ export interface ScheduleHours {
279
+ /** 'HH:MM' (24h) */
280
+ start: string;
281
+ /** 'HH:MM' (24h); '24:00' allowed. Hours never cross midnight — split them. */
282
+ end: string;
283
+ }
284
+ export type ScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
285
+ export interface ScheduleOverride {
286
+ /** One date (YYYY-MM-DD) … */
287
+ date?: string;
288
+ /** … or an inclusive range. */
289
+ from?: string;
290
+ to?: string;
291
+ /** [] = closed */
292
+ hours: ScheduleHours[];
293
+ }
294
+ export interface StandardSchedule {
295
+ /** IANA timezone, e.g. 'Europe/London'. */
296
+ timezone: string;
297
+ /** A missing day is closed. */
298
+ weekly?: Partial<Record<ScheduleDay, ScheduleHours[]>>;
299
+ /** Dated exceptions; the last matching one wins over `weekly`. */
300
+ overrides?: ScheduleOverride[];
301
+ }
@@ -0,0 +1,3 @@
1
+ // src/types/bookings.ts
2
+ // Generic booking objects (Slots, Bookings, Allocations) — see docs/bookings.md.
3
+ export {};
@@ -37,6 +37,7 @@ export * from "./iframeResponder.js";
37
37
  export * from "./ai.js";
38
38
  export * from "./appManifest.js";
39
39
  export * from "./appObjects.js";
40
+ export * from "./bookings.js";
40
41
  export * from "./loyalty.js";
41
42
  export * from "./translations.js";
42
43
  export * from "./integrations.js";
@@ -39,6 +39,7 @@ export * from "./iframeResponder.js";
39
39
  export * from "./ai.js";
40
40
  export * from "./appManifest.js";
41
41
  export * from "./appObjects.js";
42
+ export * from "./bookings.js";
42
43
  export * from "./loyalty.js";
43
44
  export * from "./translations.js";
44
45
  export * from "./integrations.js";
@@ -0,0 +1,57 @@
1
+ import type { StandardSchedule, FreeBusyInterval } from '../types/bookings.js';
2
+ export interface TimeInterval {
3
+ /** epoch ms (UTC) */
4
+ start: number;
5
+ /** epoch ms (UTC) */
6
+ end: number;
7
+ }
8
+ /** Throws a readable Error when `schedule` isn't the standard shape. */
9
+ export declare function validateSchedule(schedule: StandardSchedule): true;
10
+ /** The UTC instant (ms) of local date `ymd` + `minutes` past midnight in `tz`. */
11
+ export declare function zonedToUtc(ymd: string, minutes: number, tz: string): number;
12
+ /** The schedule's open hours overlapping [from, to), as merged UTC intervals. */
13
+ export declare function openIntervals(schedule: StandardSchedule, from: string | number | Date, to: string | number | Date): TimeInterval[];
14
+ /** True when [start, end) lies entirely inside the schedule's open hours. */
15
+ export declare function withinSchedule(schedule: StandardSchedule, start: string | number | Date, end: string | number | Date): boolean;
16
+ /** `open` minus `busy` (both lists of intervals). */
17
+ export declare function subtractIntervals(open: TimeInterval[], busy: TimeInterval[]): TimeInterval[];
18
+ export interface FreeTimesOptions {
19
+ /** Open hours: a standard schedule, or open intervals you've worked out yourself. */
20
+ hours: StandardSchedule | TimeInterval[];
21
+ /** Busy intervals, e.g. `resources[i].busy` from bookings.freebusy (ISO strings or ms). */
22
+ busy?: Array<FreeBusyInterval | TimeInterval>;
23
+ from: string | number | Date;
24
+ to: string | number | Date;
25
+ /** Appointment length, minutes. */
26
+ duration: number;
27
+ /** Minutes between candidate start times. Default: `duration`. */
28
+ step?: number;
29
+ /** Blocked time around each appointment, minutes (must also fit in a free gap). */
30
+ bufferBefore?: number;
31
+ bufferAfter?: number;
32
+ /** No start sooner than this many minutes from `now`. */
33
+ minNotice?: number;
34
+ /** "Now", for notice (default Date.now()). */
35
+ now?: number;
36
+ /**
37
+ * Whether buffers must also fall inside opening hours. Default false: a buffer may run past
38
+ * closing (cleaning up after the last appointment) but never into busy time.
39
+ */
40
+ buffersWithinHours?: boolean;
41
+ }
42
+ export interface FreeTimeSlot {
43
+ /** ISO start of the appointment (buffers excluded). */
44
+ start: string;
45
+ end: string;
46
+ }
47
+ /**
48
+ * Bookable start times: open hours minus busy time, stepped at `step`, each long enough for
49
+ * `duration` (+ buffers against busy time), not sooner than `minNotice`.
50
+ *
51
+ * const fb = await bookings.freebusy(collectionId, appId, { resources: ['record:sam'], from, to }, true)
52
+ * const times = utils.freeTimes({ hours: samsSchedule, busy: fb.resources[0].busy, from, to, duration: 30 })
53
+ *
54
+ * Start times are aligned to `step` from local midnight in the schedule's timezone (so 30-minute
55
+ * steps land on :00 and :30), or from each open interval's start when you pass intervals.
56
+ */
57
+ export declare function freeTimes(opts: FreeTimesOptions): FreeTimeSlot[];
@@ -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';