slotlock 0.7.0 → 0.9.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 (57) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +64 -9
  11. package/assistant.js +450 -82
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +21 -0
  17. package/dashboard-assistant-ui.js +539 -0
  18. package/dashboard-markdown.d.ts +2 -0
  19. package/dashboard-markdown.js +157 -0
  20. package/dashboard-settings-ui.d.ts +1 -0
  21. package/dashboard-settings-ui.js +78 -0
  22. package/dashboard.d.ts +84 -11
  23. package/dashboard.js +1654 -384
  24. package/ddl.d.ts +5 -1
  25. package/ddl.js +760 -9
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +18 -0
  28. package/holiday-import.d.ts +38 -0
  29. package/holiday-import.js +57 -0
  30. package/holidays.d.ts +38 -0
  31. package/holidays.js +150 -0
  32. package/hours.d.ts +125 -0
  33. package/hours.js +224 -0
  34. package/index.d.ts +14 -3
  35. package/index.js +10 -2
  36. package/interop.d.ts +25 -7
  37. package/interop.js +145 -17
  38. package/lifecycle.d.ts +31 -0
  39. package/lifecycle.js +115 -0
  40. package/maintenance.d.ts +19 -0
  41. package/maintenance.js +55 -1
  42. package/metrics.js +3 -0
  43. package/openapi.json +16237 -7795
  44. package/package.json +1 -1
  45. package/ranges.d.ts +28 -0
  46. package/ranges.js +135 -0
  47. package/resource-search.d.ts +19 -0
  48. package/resource-search.js +153 -0
  49. package/rest-api.js +7 -0
  50. package/rules.d.ts +2 -0
  51. package/rules.js +2 -0
  52. package/self-host.d.ts +1 -1
  53. package/self-host.js +221 -21
  54. package/slotlock.manifest.json +1 -1
  55. package/store.d.ts +367 -24
  56. package/store.js +1567 -97
  57. package/types.d.ts +198 -0
@@ -1,3 +1,4 @@
1
+ import { type SlotlockRankedException } from './hours.js';
1
2
  import type { Interval, WeeklyAvailabilityRule } from './types.js';
2
3
  /**
3
4
  * A resource's booking rules: what a booking made through Slotlock (hold, reservation, agent event)
@@ -24,10 +25,46 @@ export interface SlotlockBookingRules {
24
25
  */
25
26
  bufferBeforeMinutes?: number;
26
27
  /**
27
- * Minutes kept free after each booking made under the rule, as its trailing buffer: 0-1,440.
28
- * Bookings made before the rule, or imported, keep what they have.
28
+ * Minutes kept free after each booking made under the rule, as its trailing buffer: 0-10,080 (a
29
+ * week, for a vehicle's service after a long hire). Bookings made before the rule, or imported,
30
+ * keep what they have.
29
31
  */
30
32
  turnaroundMinutes?: number;
33
+ /** A booking lasts at least this many minutes: 1-525,600 (a year). */
34
+ minDurationMinutes?: number;
35
+ /** A booking lasts at most this many minutes: 1-525,600. */
36
+ maxDurationMinutes?: number;
37
+ /** A booking lasts exactly one of these many minutes: 1-20 lengths, each 1-525,600. */
38
+ allowedDurations?: number[];
39
+ /** The length a search offers when it is asked for none: 1-525,600, and allowed by the others. */
40
+ defaultDurationMinutes?: number;
41
+ /**
42
+ * A booking from one local day to another (a hire) starts inside one of these windows of the day,
43
+ * in the resource's time zone: 1-4 windows. A booking within one day is not held to them.
44
+ */
45
+ pickupTimes?: SlotlockDayWindow[];
46
+ /** Such a booking ends inside one of these windows of the day, alike. */
47
+ returnTimes?: SlotlockDayWindow[];
48
+ /**
49
+ * How the bookable hours hold a booking from one local day to another (a hire): `whole`, every
50
+ * minute of it inside them; `ends`, only its start and end, the resource being out between. A
51
+ * booking within one day lies inside them either way. By default `ends` where `pickupTimes` or
52
+ * `returnTimes` are given, else `whole`. Writes are held to it under `enforceHours`; searches
53
+ * (slots and ranges) follow it always.
54
+ */
55
+ hoursApply?: SlotlockHoursApply;
56
+ /**
57
+ * A booking is cancelled at least this many minutes before it starts (RFC 0004 B5): 0-525,600.
58
+ * Later, cancelling it is refused (`cancel_cutoff`) unless an admin overrides with a reason.
59
+ */
60
+ cancelCutoffMinutes?: number;
61
+ /** A booking is moved at least this many minutes before it starts, alike (`reschedule_cutoff`). */
62
+ rescheduleCutoffMinutes?: number;
63
+ /**
64
+ * What a cancellation costs, recorded on it for the integrator to charge: Slotlock never moves
65
+ * money. With `withinMinutes`, only a cancellation that late before the start.
66
+ */
67
+ cancellationFee?: SlotlockCancellationFee;
31
68
  /** A booking lies inside the resource's bookable hours. */
32
69
  enforceHours?: boolean;
33
70
  /**
@@ -43,8 +80,20 @@ export interface SlotlockBookingRules {
43
80
  */
44
81
  requireCertain?: boolean;
45
82
  }
83
+ /** A cancellation's fee: an amount in the currency's minor unit (cents), ISO 4217 currency. */
84
+ export interface SlotlockCancellationFee {
85
+ amountMinor: number;
86
+ currency: string;
87
+ /** Only a cancellation this many minutes or fewer before the start: 0-525,600. */
88
+ withinMinutes?: number;
89
+ }
90
+ /** A window of a day: minutes after local midnight, and how long (it may run past midnight). */
91
+ export interface SlotlockDayWindow {
92
+ startMinutes: number;
93
+ durationMinutes: number;
94
+ }
46
95
  /** Which rule a write broke, as its refusal names it. */
47
- export type SlotlockBookingRule = 'min_notice' | 'max_advance' | 'slot_step' | 'outside_hours' | 'uncertain';
96
+ export type SlotlockBookingRule = 'min_notice' | 'max_advance' | 'slot_step' | 'outside_hours' | 'uncertain' | 'min_duration' | 'max_duration' | 'duration_not_allowed' | 'pickup_time' | 'return_time' | 'cancel_cutoff' | 'reschedule_cutoff';
48
97
  /** Each numeric rule's least and greatest value. */
49
98
  export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
50
99
  readonly minNoticeMinutes: readonly [0, 525600];
@@ -54,7 +103,29 @@ export declare const SLOTLOCK_BOOKING_RULE_LIMITS: Readonly<{
54
103
  readonly bufferBeforeMinutes: readonly [0, number];
55
104
  readonly turnaroundMinutes: readonly [0, number];
56
105
  readonly checkInMinutes: readonly [1, number];
106
+ readonly minDurationMinutes: readonly [1, 525600];
107
+ readonly maxDurationMinutes: readonly [1, 525600];
108
+ readonly defaultDurationMinutes: readonly [1, 525600];
109
+ readonly cancelCutoffMinutes: readonly [0, 525600];
110
+ readonly rescheduleCutoffMinutes: readonly [0, 525600];
57
111
  }>;
112
+ /** Lengths `allowedDurations` may list. */
113
+ export declare const SLOTLOCK_MAX_ALLOWED_DURATIONS = 20;
114
+ /** Windows `pickupTimes` and `returnTimes` may each hold. */
115
+ export declare const SLOTLOCK_MAX_DAY_WINDOWS = 4;
116
+ /** How the hours hold a hire (`hoursApply`). */
117
+ export type SlotlockHoursApply = 'whole' | 'ends';
118
+ /** The `hoursApply` a resource's rules mean: as given, else `ends` with pick-up or return times. */
119
+ export declare function hoursApplyOf(rules: SlotlockBookingRules | undefined): SlotlockHoursApply;
120
+ /**
121
+ * The cutoff rule a cancellation or move of a booking starting at `start`, made at `now`, breaks,
122
+ * or null (RFC 0004 B5).
123
+ */
124
+ export declare function cutoffRuleBroken(rules: SlotlockBookingRules | undefined, kind: 'cancel' | 'reschedule', start: Date, now: Date): 'cancel_cutoff' | 'reschedule_cutoff' | null;
125
+ /** The fee a cancellation of a booking starting at `start`, made at `now`, records, or null. */
126
+ export declare function cancellationFeeDue(rules: SlotlockBookingRules | undefined, start: Date, now: Date): SlotlockCancellationFee | null;
127
+ /** The duration rule a booking of `minutes` breaks, or null. */
128
+ export declare function durationRuleBroken(rules: SlotlockBookingRules | undefined, minutes: number): 'min_duration' | 'max_duration' | 'duration_not_allowed' | null;
58
129
  /** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
59
130
  export declare function parseBookingRules(value: unknown): SlotlockBookingRules;
60
131
  /**
@@ -83,13 +154,27 @@ export declare function bookingBufferBeforeMs(rules: SlotlockBookingRules | unde
83
154
  */
84
155
  export declare function nextStepStart(from: Date, stepMinutes: number, timezone: string, offsetMinutes?: number): Date;
85
156
  /**
86
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
87
- * the default) are read only when enforced.
157
+ * Whether a booking runs from one local day into another (a hire): its last moment falls on a later
158
+ * local day than its start. One that ends at midnight stays on its day.
159
+ */
160
+ export declare function isMultiDay(interval: Interval, timezone: string): boolean;
161
+ /**
162
+ * Whether `instant` lies in one of the windows of its local day: `[start, start + length)`, or with
163
+ * `closed` (a return) up to and including the window's end.
164
+ */
165
+ export declare function withinDayWindows(instant: Date, windows: readonly SlotlockDayWindow[], timezone: string, closed?: boolean): boolean;
166
+ /**
167
+ * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, else
168
+ * inherited, else the default) and `exceptions` are read only when enforced. A hire (a booking from
169
+ * one local day to another) under `pickupTimes` or `returnTimes` starts and ends in them, and under
170
+ * `enforceHours` lies inside the hours as `hoursApply` says: wholly, or only at its start and end
171
+ * (the resource being out between).
88
172
  */
89
173
  export declare function bookingRuleBroken(params: {
90
174
  rules: SlotlockBookingRules | undefined;
91
175
  timezone: string;
92
176
  hours: readonly WeeklyAvailabilityRule[];
177
+ exceptions?: readonly SlotlockRankedException[];
93
178
  interval: Interval;
94
179
  now: Date;
95
180
  }): SlotlockBookingRule | null;
@@ -114,6 +199,8 @@ export declare function bookingSearch(params: {
114
199
  window: Interval;
115
200
  now: Date;
116
201
  bufferAfterMs?: number;
202
+ /** The length searched for: a length the duration rules refuse finds nothing. */
203
+ durationMs?: number;
117
204
  }): SlotlockBookingSearch | null;
118
205
  /** The time a search must read as free or busy: its window widened by a slot's buffers. */
119
206
  export declare function bookingSearchReach(search: SlotlockBookingSearch): Interval;
package/booking-rules.js CHANGED
@@ -1,4 +1,4 @@
1
- import { expandRules } from './rules.js';
1
+ import { coveredByOpenWindows, openWindows } from './hours.js';
2
2
  import { zonedOffsetMinutes } from './timezone.js';
3
3
  const MINUTE_MS = 60_000;
4
4
  const DAY_MINUTES = 24 * 60;
@@ -9,10 +9,131 @@ export const SLOTLOCK_BOOKING_RULE_LIMITS = Object.freeze({
9
9
  slotStepMinutes: [1, DAY_MINUTES],
10
10
  stepOffsetMinutes: [0, DAY_MINUTES - 1],
11
11
  bufferBeforeMinutes: [0, DAY_MINUTES],
12
- turnaroundMinutes: [0, DAY_MINUTES],
12
+ turnaroundMinutes: [0, 7 * DAY_MINUTES],
13
13
  checkInMinutes: [1, DAY_MINUTES],
14
+ minDurationMinutes: [1, 525_600],
15
+ maxDurationMinutes: [1, 525_600],
16
+ defaultDurationMinutes: [1, 525_600],
17
+ cancelCutoffMinutes: [0, 525_600],
18
+ rescheduleCutoffMinutes: [0, 525_600],
14
19
  });
15
20
  const LIMITS = SLOTLOCK_BOOKING_RULE_LIMITS;
21
+ /** Lengths `allowedDurations` may list. */
22
+ export const SLOTLOCK_MAX_ALLOWED_DURATIONS = 20;
23
+ /** Windows `pickupTimes` and `returnTimes` may each hold. */
24
+ export const SLOTLOCK_MAX_DAY_WINDOWS = 4;
25
+ const LIST_RULES = new Set(['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']);
26
+ /** The `hoursApply` a resource's rules mean: as given, else `ends` with pick-up or return times. */
27
+ export function hoursApplyOf(rules) {
28
+ if (rules?.hoursApply !== undefined)
29
+ return rules.hoursApply;
30
+ return rules?.pickupTimes !== undefined || rules?.returnTimes !== undefined ? 'ends' : 'whole';
31
+ }
32
+ function parseHoursApply(value) {
33
+ if (value !== 'whole' && value !== 'ends')
34
+ throw new TypeError("Slotlock booking rule hoursApply must be 'whole' or 'ends'");
35
+ return value;
36
+ }
37
+ /** `cancellationFee` from untrusted input, or a TypeError. */
38
+ function parseCancellationFee(value) {
39
+ const fee = value;
40
+ if (typeof fee !== 'object' ||
41
+ fee === null ||
42
+ Array.isArray(fee) ||
43
+ Object.keys(fee).some((key) => !['amountMinor', 'currency', 'withinMinutes'].includes(key)) ||
44
+ !Number.isSafeInteger(fee.amountMinor) ||
45
+ fee.amountMinor < 0 ||
46
+ fee.amountMinor > 1_000_000_000_000 ||
47
+ typeof fee.currency !== 'string' ||
48
+ !/^[A-Z]{3}$/.test(fee.currency) ||
49
+ (fee.withinMinutes !== undefined &&
50
+ (!Number.isSafeInteger(fee.withinMinutes) || fee.withinMinutes < 0 || fee.withinMinutes > 525_600))) {
51
+ throw new TypeError('Slotlock booking rule cancellationFee needs amountMinor (a whole 0-1,000,000,000,000), currency (ISO 4217, like EUR) and optionally withinMinutes (0-525600)');
52
+ }
53
+ return {
54
+ amountMinor: fee.amountMinor,
55
+ currency: fee.currency,
56
+ ...(fee.withinMinutes !== undefined ? { withinMinutes: fee.withinMinutes } : {}),
57
+ };
58
+ }
59
+ /**
60
+ * The cutoff rule a cancellation or move of a booking starting at `start`, made at `now`, breaks,
61
+ * or null (RFC 0004 B5).
62
+ */
63
+ export function cutoffRuleBroken(rules, kind, start, now) {
64
+ const minutes = kind === 'cancel' ? rules?.cancelCutoffMinutes : rules?.rescheduleCutoffMinutes;
65
+ if (minutes === undefined)
66
+ return null;
67
+ return now.getTime() > start.getTime() - minutes * MINUTE_MS ? (kind === 'cancel' ? 'cancel_cutoff' : 'reschedule_cutoff') : null;
68
+ }
69
+ /** The fee a cancellation of a booking starting at `start`, made at `now`, records, or null. */
70
+ export function cancellationFeeDue(rules, start, now) {
71
+ const fee = rules?.cancellationFee;
72
+ if (!fee)
73
+ return null;
74
+ if (fee.withinMinutes !== undefined && now.getTime() < start.getTime() - fee.withinMinutes * MINUTE_MS)
75
+ return null;
76
+ return fee;
77
+ }
78
+ /** `allowedDurations` from untrusted input, or a TypeError. */
79
+ function parseAllowedDurations(value) {
80
+ if (!Array.isArray(value) ||
81
+ value.length < 1 ||
82
+ value.length > SLOTLOCK_MAX_ALLOWED_DURATIONS ||
83
+ !value.every((length) => Number.isSafeInteger(length) && length >= 1 && length <= 525_600) ||
84
+ new Set(value).size !== value.length) {
85
+ throw new TypeError(`Slotlock booking rule allowedDurations must be 1-${SLOTLOCK_MAX_ALLOWED_DURATIONS} different whole minutes, each 1-525600`);
86
+ }
87
+ return [...value].sort((a, b) => a - b);
88
+ }
89
+ /** `pickupTimes` or `returnTimes` from untrusted input, or a TypeError. */
90
+ function parseDayWindows(key, value) {
91
+ const valid = Array.isArray(value) &&
92
+ value.length >= 1 &&
93
+ value.length <= SLOTLOCK_MAX_DAY_WINDOWS &&
94
+ value.every((window) => typeof window === 'object' &&
95
+ window !== null &&
96
+ !Array.isArray(window) &&
97
+ Object.keys(window).every((name) => name === 'startMinutes' || name === 'durationMinutes') &&
98
+ Number.isSafeInteger(window.startMinutes) &&
99
+ window.startMinutes >= 0 &&
100
+ window.startMinutes < DAY_MINUTES &&
101
+ Number.isSafeInteger(window.durationMinutes) &&
102
+ window.durationMinutes >= 1 &&
103
+ window.durationMinutes <= DAY_MINUTES);
104
+ if (!valid) {
105
+ throw new TypeError(`Slotlock booking rule ${key} must be 1-${SLOTLOCK_MAX_DAY_WINDOWS} windows of startMinutes (0-1439) and durationMinutes (1-1440)`);
106
+ }
107
+ return value.map((window) => ({
108
+ startMinutes: window.startMinutes,
109
+ durationMinutes: window.durationMinutes,
110
+ }));
111
+ }
112
+ /** Refuse durations that leave no length a booking may have (a TypeError naming them). */
113
+ function checkDurations(rules) {
114
+ const min = rules.minDurationMinutes ?? 1;
115
+ const max = rules.maxDurationMinutes ?? 525_600;
116
+ if (min > max)
117
+ throw new TypeError('Slotlock booking rule minDurationMinutes must not exceed maxDurationMinutes');
118
+ if (rules.allowedDurations && !rules.allowedDurations.some((length) => length >= min && length <= max)) {
119
+ throw new TypeError('Slotlock booking rule allowedDurations has no length within the minimum and maximum');
120
+ }
121
+ if (rules.defaultDurationMinutes !== undefined && durationRuleBroken(rules, rules.defaultDurationMinutes) !== null) {
122
+ throw new TypeError('Slotlock booking rule defaultDurationMinutes must be a length the other rules allow');
123
+ }
124
+ }
125
+ /** The duration rule a booking of `minutes` breaks, or null. */
126
+ export function durationRuleBroken(rules, minutes) {
127
+ if (!rules)
128
+ return null;
129
+ if (rules.minDurationMinutes !== undefined && minutes < rules.minDurationMinutes)
130
+ return 'min_duration';
131
+ if (rules.maxDurationMinutes !== undefined && minutes > rules.maxDurationMinutes)
132
+ return 'max_duration';
133
+ if (rules.allowedDurations !== undefined && !rules.allowedDurations.includes(minutes))
134
+ return 'duration_not_allowed';
135
+ return null;
136
+ }
16
137
  /** Booking rules from untrusted input, or a TypeError naming the field that is wrong. */
17
138
  export function parseBookingRules(value) {
18
139
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -21,10 +142,20 @@ export function parseBookingRules(value) {
21
142
  const input = value;
22
143
  const rules = {};
23
144
  for (const key of Object.keys(input)) {
24
- if (!(key in LIMITS) && key !== 'enforceHours' && key !== 'requireCertain') {
145
+ if (!(key in LIMITS) && key !== 'enforceHours' && key !== 'requireCertain' && key !== 'hoursApply' && !LIST_RULES.has(key)) {
25
146
  throw new TypeError(`Slotlock booking rules have no ${key}`);
26
147
  }
27
148
  }
149
+ if (input.hoursApply !== undefined)
150
+ rules.hoursApply = parseHoursApply(input.hoursApply);
151
+ if (input.allowedDurations !== undefined)
152
+ rules.allowedDurations = parseAllowedDurations(input.allowedDurations);
153
+ if (input.cancellationFee !== undefined)
154
+ rules.cancellationFee = parseCancellationFee(input.cancellationFee);
155
+ if (input.pickupTimes !== undefined)
156
+ rules.pickupTimes = parseDayWindows('pickupTimes', input.pickupTimes);
157
+ if (input.returnTimes !== undefined)
158
+ rules.returnTimes = parseDayWindows('returnTimes', input.returnTimes);
28
159
  for (const [key, [min, max]] of Object.entries(LIMITS)) {
29
160
  const field = input[key];
30
161
  if (field === undefined)
@@ -51,6 +182,7 @@ export function parseBookingRules(value) {
51
182
  rules.stepOffsetMinutes >= rules.slotStepMinutes) {
52
183
  throw new TypeError('Slotlock booking rule stepOffsetMinutes must be less than slotStepMinutes');
53
184
  }
185
+ checkDurations(rules);
54
186
  return rules;
55
187
  }
56
188
  /**
@@ -73,6 +205,27 @@ export function parseEffectiveBookingRules(value) {
73
205
  dropped.push(key);
74
206
  continue;
75
207
  }
208
+ if (key === 'hoursApply') {
209
+ if (field === 'whole' || field === 'ends')
210
+ rules.hoursApply = field;
211
+ else
212
+ dropped.push(key);
213
+ continue;
214
+ }
215
+ if (LIST_RULES.has(key)) {
216
+ try {
217
+ if (key === 'allowedDurations')
218
+ rules.allowedDurations = parseAllowedDurations(field);
219
+ else if (key === 'cancellationFee')
220
+ rules.cancellationFee = parseCancellationFee(field);
221
+ else
222
+ rules[key] = parseDayWindows(key, field);
223
+ }
224
+ catch {
225
+ dropped.push(key);
226
+ }
227
+ continue;
228
+ }
76
229
  const limits = LIMITS[key];
77
230
  if (limits === undefined ||
78
231
  typeof field !== 'number' ||
@@ -84,6 +237,13 @@ export function parseEffectiveBookingRules(value) {
84
237
  }
85
238
  rules[key] = field;
86
239
  }
240
+ // Levels are validated alone: a combination that leaves no length refuses every booking.
241
+ try {
242
+ checkDurations(rules);
243
+ }
244
+ catch {
245
+ dropped.push('durations');
246
+ }
87
247
  return { rules, dropped };
88
248
  }
89
249
  /**
@@ -138,9 +298,35 @@ export function nextStepStart(from, stepMinutes, timezone, offsetMinutes = 0) {
138
298
  }
139
299
  return candidate;
140
300
  }
301
+ /** The local day an instant falls on in `timezone`, as days since 1970-01-01. */
302
+ function localDay(instant, timezone) {
303
+ const local = instant.getTime() + zonedOffsetMinutes(instant, timezone) * MINUTE_MS;
304
+ return Math.floor(local / (DAY_MINUTES * MINUTE_MS));
305
+ }
306
+ /**
307
+ * Whether a booking runs from one local day into another (a hire): its last moment falls on a later
308
+ * local day than its start. One that ends at midnight stays on its day.
309
+ */
310
+ export function isMultiDay(interval, timezone) {
311
+ return localDay(new Date(interval.end.getTime() - 1), timezone) > localDay(interval.start, timezone);
312
+ }
313
+ /**
314
+ * Whether `instant` lies in one of the windows of its local day: `[start, start + length)`, or with
315
+ * `closed` (a return) up to and including the window's end.
316
+ */
317
+ export function withinDayWindows(instant, windows, timezone, closed = false) {
318
+ const minutes = localMinutes(instant, timezone);
319
+ return windows.some((window) => {
320
+ const into = (((minutes - window.startMinutes) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
321
+ return closed ? into <= window.durationMinutes : into < window.durationMinutes;
322
+ });
323
+ }
141
324
  /**
142
- * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, or
143
- * the default) are read only when enforced.
325
+ * The rule a booking of `interval` made at `now` breaks, or null. `hours` (the resource's own, else
326
+ * inherited, else the default) and `exceptions` are read only when enforced. A hire (a booking from
327
+ * one local day to another) under `pickupTimes` or `returnTimes` starts and ends in them, and under
328
+ * `enforceHours` lies inside the hours as `hoursApply` says: wholly, or only at its start and end
329
+ * (the resource being out between).
144
330
  */
145
331
  export function bookingRuleBroken(params) {
146
332
  const { rules, interval, now } = params;
@@ -158,18 +344,37 @@ export function bookingRuleBroken(params) {
158
344
  !onStep(interval.start, rules.slotStepMinutes, params.timezone, stepOffset(rules))) {
159
345
  return 'slot_step';
160
346
  }
347
+ const duration = durationRuleBroken(rules, (interval.end.getTime() - start) / MINUTE_MS);
348
+ if (duration)
349
+ return duration;
350
+ const multiDay = isMultiDay(interval, params.timezone);
351
+ if (multiDay && rules.pickupTimes && !withinDayWindows(interval.start, rules.pickupTimes, params.timezone)) {
352
+ return 'pickup_time';
353
+ }
354
+ if (multiDay && rules.returnTimes && !withinDayWindows(interval.end, rules.returnTimes, params.timezone, true)) {
355
+ return 'return_time';
356
+ }
357
+ // A hire the hours hold only at its ends.
358
+ const hire = multiDay && hoursApplyOf(rules) === 'ends';
161
359
  if (rules.enforceHours === true) {
162
- const open = expandRules([...params.hours], interval, params.timezone);
163
- let covered = interval.start.getTime();
164
- for (const window of open.sort((a, b) => a.start.getTime() - b.start.getTime())) {
165
- if (window.start.getTime() > covered)
166
- break;
167
- covered = Math.max(covered, window.end.getTime());
168
- if (covered >= interval.end.getTime())
169
- break;
360
+ const open = (window) => openWindows({
361
+ hours: params.hours,
362
+ ...(params.exceptions !== undefined ? { exceptions: params.exceptions } : {}),
363
+ timezone: params.timezone,
364
+ window,
365
+ });
366
+ if (hire) {
367
+ // Open at the start, and open up to the end: the hours a hire is collected and returned in.
368
+ const minute = MINUTE_MS;
369
+ const atStart = { start: interval.start, end: new Date(start + minute) };
370
+ const atEnd = { start: new Date(interval.end.getTime() - minute), end: interval.end };
371
+ if (!coveredByOpenWindows(open(atStart), atStart) || !coveredByOpenWindows(open(atEnd), atEnd)) {
372
+ return 'outside_hours';
373
+ }
170
374
  }
171
- if (covered < interval.end.getTime())
375
+ else if (!coveredByOpenWindows(open(interval), interval)) {
172
376
  return 'outside_hours';
377
+ }
173
378
  }
174
379
  return null;
175
380
  }
@@ -179,6 +384,9 @@ export function bookingRuleBroken(params) {
179
384
  */
180
385
  export function bookingSearch(params) {
181
386
  const { rules, window, now } = params;
387
+ if (params.durationMs !== undefined && durationRuleBroken(rules, params.durationMs / MINUTE_MS) !== null) {
388
+ return null;
389
+ }
182
390
  let start = window.start.getTime();
183
391
  const end = window.end.getTime();
184
392
  if (rules?.minNoticeMinutes !== undefined) {
@@ -188,7 +396,27 @@ export function bookingSearch(params) {
188
396
  const bufferBeforeMs = bookingBufferBeforeMs(rules);
189
397
  const step = rules?.slotStepMinutes;
190
398
  const offset = stepOffset(rules);
191
- const toStep = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
399
+ const stepped = step === undefined ? undefined : (from) => nextStepStart(from, step, params.timezone, offset);
400
+ // A slot that runs from one local day into another starts and ends in the pick-up and return
401
+ // times: candidates move on a minute (or a step) at a time, for at most two days.
402
+ const durationMs = params.durationMs;
403
+ const hireRules = rules && (rules.pickupTimes !== undefined || rules.returnTimes !== undefined) ? rules : undefined;
404
+ const toStep = hireRules === undefined || durationMs === undefined
405
+ ? stepped
406
+ : (from) => {
407
+ let candidate = stepped ? stepped(from) : new Date(Math.ceil(from.getTime() / MINUTE_MS) * MINUTE_MS);
408
+ for (let tries = 0; tries <= 2 * DAY_MINUTES; tries++) {
409
+ const slot = { start: candidate, end: new Date(candidate.getTime() + durationMs) };
410
+ if (!isMultiDay(slot, params.timezone) ||
411
+ ((!hireRules.pickupTimes || withinDayWindows(slot.start, hireRules.pickupTimes, params.timezone)) &&
412
+ (!hireRules.returnTimes || withinDayWindows(slot.end, hireRules.returnTimes, params.timezone, true)))) {
413
+ return candidate;
414
+ }
415
+ const next = new Date(Math.floor(candidate.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS);
416
+ candidate = stepped ? stepped(next) : next;
417
+ }
418
+ return new Date(end + 1);
419
+ };
192
420
  const searched = { start: new Date(start), end: new Date(end) };
193
421
  if (rules?.maxAdvanceDays !== undefined) {
194
422
  const latestStart = now.getTime() + rules.maxAdvanceDays * DAY_MINUTES * MINUTE_MS;
@@ -0,0 +1,21 @@
1
+ /** How a step reads while it runs, once it has, and when it could not. */
2
+ export type SlotlockAssistantStepState = 'run' | 'ok' | 'bad';
3
+ /** A tool step in plain words: "Checking availability…", "Checked availability", "Couldn't check availability". */
4
+ export declare function slotlockAssistantStepLabel(name: string, title: string, state: SlotlockAssistantStepState, write?: boolean): string;
5
+ /** The verb a proposed change's card is approved with, and whether it removes something. */
6
+ export declare function slotlockAssistantActionVerb(operation: string): {
7
+ verb: string;
8
+ busy: string;
9
+ done: string;
10
+ danger: boolean;
11
+ };
12
+ /** The chat's styles: one centred column, the composer docked under it, quiet steps and cards. */
13
+ export declare const SLOTLOCK_ASSISTANT_CSS = "\n.assistant-page { display: grid; }\n.assistant { position: relative; display: flex; flex-direction: column; width: min(100%, 720px); margin: 0 auto; min-height: calc(100dvh - 180px); }\n.assistant .thread { flex: 1; }\n.assistant[data-empty] { justify-content: center; padding-bottom: 12vh; }\n.assistant[data-empty] .thread { display: none; }\n.assistant[data-empty] .dock { order: 1; position: static; padding: 0; background: none; }\n.assistant[data-empty] .assistant-start { order: 2; padding-top: var(--space-3); }\n.assistant:not([data-empty]) .assistant-start { display: none; }\n.chips { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-2); }\n.chips form { margin: 0; }\n.chip { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 var(--space-3); border-radius: var(--radius-full); border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); font: inherit; font-size: var(--text-md); cursor: pointer; transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease); }\n.chip:hover { background: var(--hover); border-color: var(--line-strong); color: var(--ink); }\n.thread { list-style: none; margin: 0; padding: var(--space-2) 0 var(--space-6); display: grid; gap: var(--space-6); align-content: start; }\n.turn { margin: 0; min-width: 0; }\n.turn.mine { justify-self: end; max-width: 80%; }\n.bubble { padding: 10px 14px; border-radius: 18px; background: var(--sunken); color: var(--ink); font-size: var(--text-lg); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }\n.turn.bot { justify-self: stretch; }\n.prose { max-width: 68ch; color: var(--ink); font-size: var(--text-lg); line-height: 1.6; overflow-wrap: anywhere; }\n.prose > :first-child { margin-top: 0; }\n.prose > :last-child { margin-bottom: 0; }\n.prose p { margin: 0 0 var(--space-3); color: inherit; }\n.prose strong { font-weight: 600; }\n.prose h4 { margin: var(--space-4) 0 var(--space-2); font-size: var(--text-lg); font-weight: 600; }\n.prose ul, .prose ol { margin: 0 0 var(--space-3); padding-left: 20px; }\n.prose li { margin: 4px 0; }\n.prose li > ul, .prose li > ol { margin: 4px 0 0; }\n.prose a { color: var(--accent); text-underline-offset: 2px; }\n.prose code { font: 400 12.5px/1.4 var(--mono); background: var(--sunken); border-radius: 4px; padding: 1px 5px; }\n.prose pre { margin: 0 0 var(--space-3); padding: var(--space-3); background: var(--sunken); border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; }\n.prose pre code { background: none; padding: 0; font-size: 12.5px; }\n.prose blockquote { margin: 0 0 var(--space-3); padding-left: var(--space-3); border-left: 2px solid var(--line-strong); color: var(--ink-2); }\n.prose hr { border: 0; border-top: 1px solid var(--line); margin: var(--space-4) 0; }\n.md-table { overflow-x: auto; margin: 0 0 var(--space-3); }\n.md-table table { border-collapse: collapse; font-size: var(--text-md); min-width: 50%; }\n.md-table table { width: auto; }\n.md-table th { font: 600 var(--text-md)/1.4 var(--font); text-transform: none; letter-spacing: 0; background: none; text-align: left; color: var(--ink-2); padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line); white-space: nowrap; }\n.md-table td { padding: 6px 16px 6px 0; border-bottom: 1px solid var(--line-soft); vertical-align: top; }\n.md-table tr:last-child td { border-bottom: 0; }\n.change details.more { margin: 0; }\n.change details.more > summary { cursor: pointer; color: var(--ink-3); font-size: var(--text-sm); list-style: none; }\n.change details.more > summary::-webkit-details-marker { display: none; }\n.change details.more > summary:hover { color: var(--ink-2); }\n.change details.more[open] > summary { margin-bottom: var(--space-2); }\n.turn.steps { justify-self: start; }\ndetails.steps > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; color: var(--ink-3); font-size: var(--text-md); padding: 2px 0; }\ndetails.steps > summary::-webkit-details-marker { display: none; }\ndetails.steps > summary::after { content: \"\"; width: 6px; height: 6px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); transition: transform var(--fast) var(--ease); margin-left: 2px; }\ndetails.steps[open] > summary::after { transform: rotate(45deg); }\ndetails.steps > summary:hover { color: var(--ink-2); }\ndetails.steps.has-bad > summary::before { content: \"\"; width: 6px; height: 6px; border-radius: 50%; background: var(--bad); }\n.step-list { list-style: none; margin: var(--space-2) 0 0; padding: 0 0 0 var(--space-3); border-left: 1px solid var(--line); display: grid; gap: 6px; }\n.step { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.step::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--good); transform: translateY(-1px); }\n.step.bad::before { background: var(--bad); }\n.step.run::before { background: var(--ink-3); }\n.step .why { color: var(--ink-3); font-size: var(--text-sm); }\n.live-steps { display: grid; gap: 6px; }\n.live-steps .step.run, .thinking { background: linear-gradient(90deg, var(--ink-3) 0%, var(--ink) 50%, var(--ink-3) 100%); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: assistant-shimmer 1.6s linear infinite; }\n.thinking { font-size: var(--text-md); justify-self: start; }\n@keyframes assistant-shimmer { from { background-position: 100% 0; } to { background-position: -100% 0; } }\n.prose.streaming > :last-child::after { content: \"\"; display: inline-block; width: 8px; height: 8px; margin-left: 4px; border-radius: 50%; background: var(--ink-3); vertical-align: baseline; animation: assistant-pulse 1s ease-in-out infinite; }\n@keyframes assistant-pulse { 50% { opacity: 0.25; } }\n.turn.note { color: var(--ink-3); font-size: var(--text-md); }\n.turn.error { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 10px 12px; border: 1px solid var(--bad-line); background: var(--bad-wash); border-radius: var(--radius); font-size: var(--text-md); color: var(--ink); }\n.turn.error::before { content: \"\"; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--bad); }\n.turn.error > span { flex: 1; }\n.turn.error form, .turn.note form { margin: 0; }\n.text-button { appearance: none; border: 0; background: none; padding: 0; font: inherit; font-size: var(--text-md); color: var(--accent); cursor: pointer; }\n.text-button:hover { text-decoration: underline; text-underline-offset: 2px; }\n.turn.action { justify-self: start; width: min(100%, 560px); }\n.change { padding: var(--space-4) var(--space-5); background: var(--surface); border: 1px solid var(--line-strong); border-radius: var(--radius-lg); display: grid; gap: var(--space-3); }\n.change-title { margin: 0; font-size: var(--text-lg); font-weight: 600; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px var(--space-4); margin: 0; font-size: var(--text-md); }\n.change .facts dt { color: var(--ink-3); font-size: var(--text-sm); padding-top: 1px; }\n.change .facts dd { margin: 0; color: var(--ink); overflow-wrap: anywhere; }\n.change .facts dd pre { margin: 0; white-space: pre-wrap; font: 400 12px/1.5 var(--mono); }\n.change .facts .was { color: var(--ink-3); text-decoration: line-through; }\n.change-buttons { display: flex; gap: var(--space-2); flex-wrap: wrap; }\n.change-buttons form { margin: 0; }\n.change-buttons button { height: 32px; }\nbutton.danger { background: var(--bad); color: var(--bad-ink); border-color: var(--bad); }\nbutton.danger:hover { filter: brightness(1.08); }\nbutton.ghost { background: transparent; border-color: transparent; color: var(--ink-2); }\nbutton.ghost:hover { background: var(--hover); color: var(--ink); }\n.receipt { margin: 0; display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }\n.receipt::before { content: \"\"; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); transform: translateY(-1px); }\n.receipt.ok { color: var(--ink); }\n.receipt.ok::before { background: var(--good); }\n.receipt.bad::before { background: var(--bad); }\n.receipt .why { color: var(--ink-3); }\n.dock { position: sticky; bottom: 0; padding: var(--space-6) 0 var(--space-6); background: linear-gradient(to bottom, transparent, var(--canvas) 24px); z-index: 2; }\n.composer { display: grid; margin: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; transition: border-color var(--fast) var(--ease), box-shadow var(--fast) var(--ease); }\n.composer:focus-within { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--ring); }\n.composer textarea { display: block; width: 100%; min-height: 26px; max-height: 200px; resize: none; border: 0; outline: 0; background: transparent; padding: 14px 16px 0; color: var(--ink); font: inherit; font-size: var(--text-lg); line-height: 1.5; box-shadow: none; }\n.composer textarea:focus { box-shadow: none; border: 0; }\n.composer-bar { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-2); padding: var(--space-2); }\n.composer-bar .count { margin-right: auto; padding-left: var(--space-2); font-size: var(--text-sm); color: var(--ink-3); }\n.composer-bar .count.over { color: var(--warn); }\n.send { display: inline-grid; place-items: center; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: var(--button); color: var(--button-ink); cursor: pointer; transition: opacity var(--fast) var(--ease), background var(--fast) var(--ease); }\n.send:hover { background: var(--button-hover); }\n.send:disabled { opacity: 0.4; cursor: default; }\n.send svg { width: 16px; height: 16px; }\n.send .stop { display: none; }\n.assistant.streaming .send .arrow { display: none; }\n.assistant.streaming .send .stop { display: block; }\n.jump { position: absolute; left: 50%; transform: translate(-50%, 0); top: -8px; width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: var(--surface); color: var(--ink-2); display: grid; place-items: center; cursor: pointer; box-shadow: var(--pop-shadow); }\n.jump[hidden] { display: none; }\n.new-chat svg, .chats-link svg { width: 14px; height: 14px; }\n.assistant-page[data-empty] .new-chat { display: none; }\n.assistant-actions { display: flex; gap: var(--space-2); }\n.chats-link { display: none; }\n/* Chats: the member's conversations beside the one shown, most recent first, grouped by day. */\n.assistant-shell { display: grid; grid-template-columns: 252px minmax(0, 1fr); gap: var(--space-6); align-items: start; }\n/* It scrolls with the page: a column that scrolled on its own would clip a row's menu. */\n.chats { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }\n.chats-head { display: flex; align-items: center; justify-content: space-between; min-height: 36px; }\n.chats-head h2 { margin: 0; font-size: var(--text-md); font-weight: 600; color: var(--ink-2); }\n.icon-button { display: grid; place-items: center; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--ink-2); transition: background var(--fast) var(--ease), color var(--fast) var(--ease); }\n.icon-button:hover { background: var(--hover); color: var(--ink); text-decoration: none; }\n.icon-button svg { width: 15px; height: 15px; }\n.chats-filter { height: 32px; font-size: var(--text-md); }\n.chats-filter[hidden] { display: none; }\n/* Sentence case, not the dashboard's uppercase h3. */\n.chat-group { margin: var(--space-3) 0 2px; padding: 0 var(--space-2); font-family: inherit; font-size: var(--text-xs); font-weight: 500; line-height: 1.5; text-transform: none; letter-spacing: 0; color: var(--ink-3); }\n.chat-group[hidden] { display: none; }\n.chat-list { min-width: 0; }\n/* minmax(0, 1fr): a long title is cut with an ellipsis, never widening the column. */\n.chat-rows { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 1px; }\n.chat-row { position: relative; display: flex; align-items: center; border-radius: var(--radius); transition: background var(--fast) var(--ease); }\n.chat-row[hidden] { display: none; }\n.chat-row:hover, .chat-row:focus-within, .chat-row.current { background: var(--hover); }\n.chat-link { flex: 1; min-width: 0; display: flex; align-items: baseline; gap: var(--space-2); padding: 7px 4px 7px var(--space-2); color: var(--ink-2); font-size: var(--text-md); }\n.chat-link:hover { color: var(--ink); text-decoration: none; }\n.chat-row.current .chat-link { color: var(--ink); font-weight: 500; }\n.chat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.chat-when { flex: none; font-size: var(--text-xs); color: var(--ink-3); font-variant-numeric: tabular-nums; }\n.chat-menu { position: relative; flex: none; }\n.chat-menu summary { list-style: none; display: grid; place-items: center; width: 28px; height: 28px; margin-right: 2px; border-radius: var(--radius-sm); color: var(--ink-3); cursor: pointer; opacity: 0; transition: opacity var(--fast) var(--ease), background var(--fast) var(--ease); }\n.chat-menu summary::-webkit-details-marker { display: none; }\n.chat-row:hover .chat-menu summary, .chat-row:focus-within .chat-menu summary, .chat-row.current .chat-menu summary, .chat-menu[open] summary { opacity: 1; }\n.chat-menu summary:hover, .chat-menu[open] summary { background: var(--line-soft); color: var(--ink); }\n.chat-menu summary svg { width: 14px; height: 14px; }\n.chat-menu .pop { z-index: 20; width: 260px; display: grid; gap: var(--space-2); }\n.chat-menu .pop form { display: grid; gap: var(--space-2); margin: 0; }\n.chat-menu .pop form + form { padding-top: var(--space-2); border-top: 1px solid var(--line-soft); }\n.chat-menu .pop button.danger { background: none; border-color: transparent; box-shadow: none; color: var(--bad); }\n.chat-menu .pop button.danger:hover { background: var(--bad-wash); }\n.chat-rename input { width: 100%; min-width: 0; height: 34px; }\n.chats-empty { padding: var(--space-2); font-size: var(--text-md); }\n.chats-page .chats { max-width: 560px; }\n.chats-page .chats-head { display: none; }\n.chats-page .chat-menu summary { opacity: 1; }\n@media (max-width: 1100px) {\n .assistant-shell { grid-template-columns: minmax(0, 1fr); }\n .assistant-shell > .chats { display: none; }\n .chats-link { display: inline-flex; }\n}\n@media (hover: none) {\n .chat-menu summary { opacity: 1; }\n}\n@media (prefers-reduced-motion: reduce) {\n .live-steps .step.run, .thinking { animation: none; color: var(--ink-3); background: none; }\n .prose.streaming > :last-child::after { animation: none; }\n}\n@media (max-width: 760px) {\n .assistant { min-height: calc(100dvh - 140px); }\n .assistant[data-empty] { justify-content: flex-end; padding-bottom: 0; }\n .assistant[data-empty] .assistant-start { order: 1; padding: 0 0 var(--space-3); }\n .assistant[data-empty] .dock { order: 2; position: sticky; padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .chips { flex-wrap: nowrap; justify-content: flex-start; overflow-x: auto; max-width: 100%; padding-bottom: 2px; scrollbar-width: none; }\n .chips::-webkit-scrollbar { display: none; }\n .chip { flex: none; }\n .turn.mine { max-width: 88%; }\n .md-table table { display: table; }\n .md-table thead { display: table-header-group; }\n .md-table tbody { display: table-row-group; }\n .md-table tr { display: table-row; padding: 0; border: 0; }\n .md-table th, .md-table td { display: table-cell; padding: 6px 12px 6px 0; }\n .md-table td { border-bottom: 1px solid var(--line-soft); }\n .composer textarea { font-size: 16px; }\n .dock { padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n .change { padding: var(--space-4); }\n .change .facts { grid-template-columns: minmax(0, 1fr); gap: 2px; }\n .change .facts dd { margin-bottom: var(--space-2); }\n .change-buttons form { flex: 1; }\n .change-buttons button { width: 100%; height: 40px; }\n}\n";
14
+ /**
15
+ * The browser side of the chat, appended to app.js: Enter sends, the box grows, a write chip fills
16
+ * the box instead of sending, and a reply streams in from `<root>/assistant/stream` (newline-
17
+ * delimited JSON: text deltas, steps, then the turn as the server renders it). Without it the
18
+ * page still works: every form posts and the page reloads at the latest message.
19
+ * `markdownSource` is renderSlotlockMarkdown's source, so a reply renders as it will once stored.
20
+ */
21
+ export declare function slotlockAssistantScript(markdownSource: string): string;