slotlock 0.8.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 (53) hide show
  1. package/CHANGELOG.md +237 -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 +34 -8
  11. package/assistant.js +81 -26
  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 +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
package/hours.js ADDED
@@ -0,0 +1,224 @@
1
+ // Bookable hours with their exceptions (RFC 0004 B1, B2): the one evaluator search and enforced
2
+ // writes share. A resource's hours are its own, else its location's (nearest up the chain), else its
3
+ // tenant's, else the server's. Exceptions then replace whole local days: a closure empties them, an
4
+ // opening gives them hours of their own. Each applies in the resource's time zone, the tenant's
5
+ // first, then each location's from the outermost in, then the resource's own, so the nearer wins.
6
+ import { mergeIntervals } from './engine.js';
7
+ import { expandRules } from './rules.js';
8
+ import { createTimeZoneConverter } from './timezone.js';
9
+ const DAY_MS = 86_400_000;
10
+ const MINUTE_MS = 60_000;
11
+ const DAY_MINUTES = 1_440;
12
+ /** Exceptions a tenant may keep, at all its scopes together. */
13
+ export const SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS = 5_000;
14
+ /** The longest one exception may last, in days (both ends counted). */
15
+ export const SLOTLOCK_MAX_EXCEPTION_DAYS = 367;
16
+ /** Windows one open exception may give each day. */
17
+ export const SLOTLOCK_MAX_EXCEPTION_WINDOWS = 4;
18
+ export function invalidException(message) {
19
+ return Object.assign(new Error(message), { code: 'invalid_availability_exception' });
20
+ }
21
+ const DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
22
+ /** Days since 1970-01-01 for a real `YYYY-MM-DD`, or null. */
23
+ export function epochDay(date) {
24
+ const match = DATE.exec(date);
25
+ if (!match)
26
+ return null;
27
+ const [, year, month, day] = match.map(Number);
28
+ const ms = Date.UTC(year, month - 1, day);
29
+ const back = new Date(ms);
30
+ if (year < 1 || back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1 || back.getUTCDate() !== day) {
31
+ return null;
32
+ }
33
+ return Math.round(ms / DAY_MS);
34
+ }
35
+ /** `YYYY-MM-DD` for a day since 1970-01-01. */
36
+ export function dayToDate(day) {
37
+ return new Date(day * DAY_MS).toISOString().slice(0, 10);
38
+ }
39
+ /** An exception's windows from untrusted input, or `invalid_availability_exception`. */
40
+ export function parseExceptionWindows(value) {
41
+ if (!Array.isArray(value) || value.length < 1 || value.length > SLOTLOCK_MAX_EXCEPTION_WINDOWS) {
42
+ throw invalidException(`Open hours must be 1 to ${SLOTLOCK_MAX_EXCEPTION_WINDOWS} windows`);
43
+ }
44
+ return value.map((window, index) => {
45
+ const candidate = window;
46
+ if (typeof candidate !== 'object' ||
47
+ candidate === null ||
48
+ Array.isArray(candidate) ||
49
+ Object.keys(candidate).some((key) => key !== 'startMinutes' && key !== 'durationMinutes') ||
50
+ !Number.isInteger(candidate.startMinutes) ||
51
+ candidate.startMinutes < 0 ||
52
+ candidate.startMinutes >= DAY_MINUTES ||
53
+ !Number.isInteger(candidate.durationMinutes) ||
54
+ candidate.durationMinutes < 1 ||
55
+ candidate.durationMinutes > DAY_MINUTES) {
56
+ throw invalidException(`Window ${index} needs startMinutes (0-1439) and durationMinutes (1-1440)`);
57
+ }
58
+ return { startMinutes: candidate.startMinutes, durationMinutes: candidate.durationMinutes };
59
+ });
60
+ }
61
+ /**
62
+ * An exception's dates and outcome from untrusted input: real dates, the end not before the start,
63
+ * at most SLOTLOCK_MAX_EXCEPTION_DAYS, and hours exactly when it opens.
64
+ */
65
+ export function parseExceptionDays(input) {
66
+ const startsOn = typeof input.startsOn === 'string' ? input.startsOn : '';
67
+ const endsOn = input.endsOn === undefined || input.endsOn === null ? startsOn : String(input.endsOn);
68
+ const first = epochDay(startsOn);
69
+ const last = epochDay(endsOn);
70
+ if (first === null || last === null)
71
+ throw invalidException('Dates must be real days, YYYY-MM-DD');
72
+ if (last < first)
73
+ throw invalidException('The last day must not be before the first');
74
+ if (last - first + 1 > SLOTLOCK_MAX_EXCEPTION_DAYS) {
75
+ throw invalidException(`An exception lasts at most ${SLOTLOCK_MAX_EXCEPTION_DAYS} days`);
76
+ }
77
+ if (input.outcome === 'closed') {
78
+ if (input.hours !== undefined && input.hours !== null)
79
+ throw invalidException('A closure has no hours');
80
+ return { startsOn, endsOn, outcome: 'closed' };
81
+ }
82
+ if (input.outcome === 'open') {
83
+ return { startsOn, endsOn, outcome: 'open', hours: parseExceptionWindows(input.hours) };
84
+ }
85
+ throw invalidException('The outcome must be closed or open');
86
+ }
87
+ /** Stored hours that do not read as windows read as none: the day stays closed. */
88
+ export function storedExceptionWindows(value) {
89
+ try {
90
+ return parseExceptionWindows(value);
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }
96
+ /**
97
+ * The open windows of a resource within `window`: its weekly `hours`, with `exceptions` (in rank
98
+ * order) replacing the local days they name in `timezone`. Merged and sorted.
99
+ */
100
+ export function openWindows(params) {
101
+ const { window, timezone } = params;
102
+ const weekly = expandRules([...params.hours], window, timezone);
103
+ const exceptions = [...(params.exceptions ?? [])].sort((a, b) => a.rank - b.rank);
104
+ if (exceptions.length === 0)
105
+ return weekly;
106
+ const converter = createTimeZoneConverter(timezone);
107
+ const at = (day, minutes) => converter.wallFrameToUtc(day * DAY_MS + minutes * MINUTE_MS, { gap: 'end' }).getTime();
108
+ // The local days the window can touch: a zone is at most fourteen hours from UTC.
109
+ const firstDay = Math.floor(window.start.getTime() / DAY_MS) - 1;
110
+ const lastDay = Math.floor(window.end.getTime() / DAY_MS) + 1;
111
+ let open = weekly;
112
+ for (const exception of exceptions) {
113
+ const from = Math.max(epochDay(exception.startsOn) ?? Number.POSITIVE_INFINITY, firstDay);
114
+ const to = Math.min(epochDay(exception.endsOn) ?? Number.NEGATIVE_INFINITY, lastDay);
115
+ if (from > to)
116
+ continue;
117
+ const removed = { start: at(from, 0), end: at(to + 1, 0) };
118
+ open = open.flatMap((part) => {
119
+ const start = part.start.getTime();
120
+ const end = part.end.getTime();
121
+ if (end <= removed.start || start >= removed.end)
122
+ return [part];
123
+ const kept = [];
124
+ if (start < removed.start)
125
+ kept.push({ start: part.start, end: new Date(removed.start) });
126
+ if (end > removed.end)
127
+ kept.push({ start: new Date(removed.end), end: part.end });
128
+ return kept;
129
+ });
130
+ if (exception.outcome === 'open') {
131
+ for (let day = from; day <= to; day++) {
132
+ for (const hours of exception.hours ?? []) {
133
+ const start = Math.max(at(day, hours.startMinutes), window.start.getTime());
134
+ const end = Math.min(at(day, hours.startMinutes + hours.durationMinutes), window.end.getTime());
135
+ if (end > start)
136
+ open.push({ start: new Date(start), end: new Date(end) });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ return mergeIntervals(open.filter((part) => part.end.getTime() > part.start.getTime()));
142
+ }
143
+ /**
144
+ * The weekly hours that apply to a resource and where they come from: its own, else what it
145
+ * inherits, else the server's `fallback`.
146
+ */
147
+ export function applicableHours(resource, fallback) {
148
+ if (resource.availabilityRules !== undefined)
149
+ return { rules: resource.availabilityRules, source: 'resource' };
150
+ const inherited = resource.inheritedAvailability;
151
+ if (inherited !== undefined) {
152
+ return inherited.location !== undefined
153
+ ? { rules: inherited.rules, source: inherited.source, location: inherited.location }
154
+ : { rules: inherited.rules, source: inherited.source };
155
+ }
156
+ return { rules: fallback, source: 'server' };
157
+ }
158
+ /** Whether `interval` lies wholly inside the open windows. */
159
+ export function coveredByOpenWindows(open, interval) {
160
+ let covered = interval.start.getTime();
161
+ for (const part of [...open].sort((a, b) => a.start.getTime() - b.start.getTime())) {
162
+ if (part.start.getTime() > covered)
163
+ break;
164
+ covered = Math.max(covered, part.end.getTime());
165
+ if (covered >= interval.end.getTime())
166
+ return true;
167
+ }
168
+ return covered >= interval.end.getTime();
169
+ }
170
+ /** The first and last UTC day an exception query for `window` must reach (zones span ±14 hours). */
171
+ export function exceptionQueryDays(window) {
172
+ return {
173
+ from: dayToDate(Math.floor(window.start.getTime() / DAY_MS) - 1),
174
+ to: dayToDate(Math.floor(window.end.getTime() / DAY_MS) + 1),
175
+ };
176
+ }
177
+ /**
178
+ * The days from `from` to `to` (`YYYY-MM-DD`, inclusive) that exceptions replace, each as the
179
+ * nearest exception leaves it, by date.
180
+ */
181
+ export function exceptionDays(exceptions, from, to) {
182
+ const first = epochDay(from);
183
+ const last = epochDay(to);
184
+ if (first === null || last === null)
185
+ return [];
186
+ const days = new Map();
187
+ for (const exception of [...exceptions].sort((a, b) => a.rank - b.rank)) {
188
+ const start = Math.max(epochDay(exception.startsOn) ?? Number.POSITIVE_INFINITY, first);
189
+ const end = Math.min(epochDay(exception.endsOn) ?? Number.NEGATIVE_INFINITY, last);
190
+ for (let day = start; day <= end; day++) {
191
+ days.set(day, exception.outcome === 'open' ? [...(exception.hours ?? [])] : []);
192
+ }
193
+ }
194
+ return [...days.entries()]
195
+ .sort(([a], [b]) => a - b)
196
+ .map(([day, windows]) => ({ date: dayToDate(day), windows }));
197
+ }
198
+ /**
199
+ * Consecutive days that read alike as exception ranges: no windows a closure, else hours of their
200
+ * own. The inverse of `exceptionDays` for one scope.
201
+ */
202
+ export function exceptionRanges(days) {
203
+ const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
204
+ const ranges = [];
205
+ const key = (windows) => JSON.stringify([...windows].sort((a, b) => a.startMinutes - b.startMinutes || a.durationMinutes - b.durationMinutes));
206
+ let previous = null;
207
+ for (const day of sorted) {
208
+ const at = epochDay(day.date);
209
+ if (at === null)
210
+ continue;
211
+ const same = key(day.windows);
212
+ const last = ranges.at(-1);
213
+ if (last && previous && previous.day === at - 1 && previous.key === same) {
214
+ last.endsOn = day.date;
215
+ }
216
+ else {
217
+ ranges.push(day.windows.length === 0
218
+ ? { startsOn: day.date, endsOn: day.date, outcome: 'closed' }
219
+ : { startsOn: day.date, endsOn: day.date, outcome: 'open', hours: JSON.parse(same) });
220
+ }
221
+ previous = { day: at, key: same };
222
+ }
223
+ return ranges;
224
+ }
package/index.d.ts CHANGED
@@ -8,7 +8,18 @@ export { type SlotlockMetrics, createSlotlockMetrics } from './metrics.js';
8
8
  export { expandRules } from './rules.js';
9
9
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
10
10
  export type { HoursWindow, Weekday, WeeklyHours } from './weekly-hours.js';
11
- export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
11
+ export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, SLOTLOCK_MAX_VAVAILABILITY_DAYS, toJSCalendarEvent, } from './interop.js';
12
+ export { applicableHours, coveredByOpenWindows, exceptionDays, exceptionRanges, openWindows, parseExceptionDays, SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS, SLOTLOCK_MAX_EXCEPTION_DAYS, SLOTLOCK_MAX_EXCEPTION_WINDOWS, } from './hours.js';
13
+ export type { SlotlockAvailabilityException, SlotlockExceptionDay, SlotlockExceptionOutcome, SlotlockExceptionSource, SlotlockExceptionWindow, SlotlockHoursSource, SlotlockRankedException, } from './hours.js';
14
+ export type { SlotlockAvailabilityDefaults, SlotlockAvailabilityDefaultsResult, SlotlockAvailabilityExceptionInput, SlotlockOverrideResources, SlotlockHolidayCalendar, SlotlockHolidayImport, } from './availability-store.js';
15
+ export { SLOTLOCK_OVERRIDE_RESOURCES, SLOTLOCK_HOLIDAY_REIMPORT_DAYS, SLOTLOCK_MAX_HOLIDAY_CALENDARS, } from './availability-store.js';
16
+ export { type SlotlockHolidayCalendarRead, type SlotlockHolidaySource, readSlotlockHolidayCalendar, } from './holiday-import.js';
17
+ export { fetchHolidayCalendar, parseHolidayCalendar, SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES, SLOTLOCK_MAX_HOLIDAY_DAYS, } from './holidays.js';
18
+ export type { ParsedHolidayCalendar, SlotlockHoliday } from './holidays.js';
19
+ export { SLOTLOCK_BOOKING_ACTIONS, SLOTLOCK_BOOKING_ENDS, SLOTLOCK_BOOKING_STATUSES, SLOTLOCK_BOOKING_TRANSITIONS, slotlockBookingNext, slotlockBookingRefusal, } from './lifecycle.js';
20
+ export type { SlotlockBookingAction, SlotlockBookingStatus, SlotlockEndReason } from './lifecycle.js';
21
+ export { findEarliestRange, sameTimeDaysLater } from './ranges.js';
22
+ export type { SlotlockRangeSearch } from './ranges.js';
12
23
  export type { EmitVAvailabilityOptions, JSCalendarAlert, JSCalendarEvent, JSCalendarParticipant, JSCalendarRecurrenceRule, ParsedVAvailability, } from './interop.js';
13
24
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
14
25
  export type { LocalDateTime, TimeZoneConverter, ZonedCalendarDate, ZonedWallTime, } from './timezone.js';
@@ -44,5 +55,5 @@ export type { ICalendarWorkerPool, ICalendarWorkerPoolOptions } from './ical-wor
44
55
  export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
45
56
  export type { ExternalCalendarChange, ExternalCalendarChangeInput, ExternalCalendarStatus, ICalendarEvent, ICalendarExpansionWindow, ICalendarFeedRead, ICalendarSeriesRefusal, ITipMethod, } from './sync.js';
46
57
  export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
47
- export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
48
- export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, SlotlockCalendarEvent, SlotlockBookingDescription, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, WeeklyAvailabilityRule, } from './types.js';
58
+ export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_MAX_CONTACTS_PER_TENANT, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS, SLOTLOCK_TENANT_WRITE_METHODS, type SlotlockHoldTenantOptions, type SlotlockMigrationOptions, type SlotlockSql, type SlotlockStore, type SlotlockStoreOptions, type SlotlockTenantLimits, type SlotlockTenantStore, } from './store.js';
59
+ export type { AcquireHoldResult, BookingRetentionResult, CalendarAttendee, CalendarAttendeeRole, CalendarCoverageAssessment, CalendarCoverageCommand, CalendarCoverageResult, CalendarEventContent, CalendarEventOccurrence, CalendarEventRetentionResult, CalendarEventStatus, CalendarFreeBusy, CalendarHorizonRollCursor, CalendarHorizonRollResult, CalendarOrganizer, CalendarParticipationStatus, CalendarRecurrence, CalendarRecurrenceException, CalendarReminder, CalendarTransparency, ConfirmHoldResult, CancelCalendarEventResult, CancelExternalReservationResult, CreateReservationResult, DeleteResourceResult, ExternalReservationCommand, ExternalReservationResult, Interval, SlotlockAgendaEntry, SlotlockHold, MarkHoldPaidResult, SlotlockContact, SlotlockContactConsent, PutContactResult, SourceBusyCommand, SourceBusyResult, SlotlockCalendarEvent, SlotlockBookingDescription, SlotlockReservation, SlotlockResource, SlotlockTenantErasure, SlotlockBookingRuleDefaults, PutCalendarEventCommand, PutCalendarEventResult, ReleaseHoldResult, TrustedICalendarEvent, SlotlockInheritedAvailability, WeeklyAvailabilityRule, } from './types.js';
package/index.js CHANGED
@@ -11,7 +11,15 @@ export { createSlotlockGuardedFetch, createSlotlockWebhookSender, deliverSlotloc
11
11
  export { createSlotlockMetrics } from './metrics.js';
12
12
  export { expandRules } from './rules.js';
13
13
  export { availabilityRuleProblem, parseAvailabilityRules, rulesToWeeklyHours, SLOTLOCK_MAX_AVAILABILITY_RULES, weeklyHoursToRules, } from './weekly-hours.js';
14
- export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, toJSCalendarEvent, } from './interop.js';
14
+ export { emitVAvailability, parseVAvailability, recurrenceRuleFromRRule, SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS, SLOTLOCK_MAX_VAVAILABILITY_DAYS, toJSCalendarEvent, } from './interop.js';
15
+ // Stored hours that inherit, and closures and special days (RFC 0004 B1, B2).
16
+ export { applicableHours, coveredByOpenWindows, exceptionDays, exceptionRanges, openWindows, parseExceptionDays, SLOTLOCK_MAX_AVAILABILITY_EXCEPTIONS, SLOTLOCK_MAX_EXCEPTION_DAYS, SLOTLOCK_MAX_EXCEPTION_WINDOWS, } from './hours.js';
17
+ export { SLOTLOCK_OVERRIDE_RESOURCES, SLOTLOCK_HOLIDAY_REIMPORT_DAYS, SLOTLOCK_MAX_HOLIDAY_CALENDARS, } from './availability-store.js';
18
+ export { readSlotlockHolidayCalendar, } from './holiday-import.js';
19
+ export { fetchHolidayCalendar, parseHolidayCalendar, SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES, SLOTLOCK_MAX_HOLIDAY_DAYS, } from './holidays.js';
20
+ // The booking lifecycle, written down (RFC 0004 B4).
21
+ export { SLOTLOCK_BOOKING_ACTIONS, SLOTLOCK_BOOKING_ENDS, SLOTLOCK_BOOKING_STATUSES, SLOTLOCK_BOOKING_TRANSITIONS, slotlockBookingNext, slotlockBookingRefusal, } from './lifecycle.js';
22
+ export { findEarliestRange, sameTimeDaysLater } from './ranges.js';
15
23
  export { createTimeZoneConverter, zonedCalendarDate, zonedDateTimeToUtc, zonedWallTime, } from './timezone.js';
16
24
  export { createSlotlockAgentServer, clientSupportsSlotlockMcpApp, invokeSlotlockAgentOperation, isSlotlockAgentOperation, slotlockAgentTools, slotlockApplicationOperations, slotlockOpenApi, slotlockCalendarResourceUri, slotlockMcpToolResult, parseSlotlockA2ATimestamp, resolveSlotlockAgentOperation, slotlockAgentOperationScope, SLOTLOCK_A2A_PROTOCOL_VERSION, SLOTLOCK_A2A_TASK_PAGE_SIZE, SLOTLOCK_A2A_WATCH_SKILL, SLOTLOCK_A2A_TASK_STATES, SLOTLOCK_AGENT_OPERATION_LEGACY_NAMES, SLOTLOCK_AGENT_SERVER_VERSION, SLOTLOCK_MAX_ALTERNATIVES, SLOTLOCK_MCP_LEGACY_PROTOCOL_VERSION, SLOTLOCK_MCP_PROTOCOL_VERSION, SLOTLOCK_MCP_RATE_LIMITED_ERROR_CODE, SLOTLOCK_MCP_RESOURCE_URI_TEMPLATE, SLOTLOCK_MCP_SUPPORTED_PROTOCOL_VERSIONS, SLOTLOCK_MCP_APP_HTML, SLOTLOCK_MCP_APP_MIME_TYPE, SLOTLOCK_MCP_APP_RESOURCE, SLOTLOCK_MCP_APP_RESOURCE_URI, SLOTLOCK_MCP_APPS_EXTENSION, SlotlockAgentOperationError, } from './agent-server.js';
17
25
  export { createSlotlockNodeServer } from './node-server.js';
@@ -30,4 +38,4 @@ export { closeSlotlockPartnerMonth, priceSlotlockPartnerMonth, slotlockClientLab
30
38
  export { ICalendarWorkerBusyError, ICalendarWorkerPoolClosedError, createICalendarWorkerPool, } from './ical-worker.js';
31
39
  export { CalendarContractError, CalendarExpansionLimitError, emitICalendar, emitITipCalendar, expandCalendarEventOccurrences, normalizeCalendarEventContent, normalizeExternalCalendarChange, parseICalendarChanges, parseICalendarFeed, parseTrustedICalendarEvents, recurrenceRuleProblem, storedCalendarEventProblem, } from './sync.js';
32
40
  export { createSlotlockApplicationRoleGrantsDdl, createSlotlockTenantRlsDdl, SLOTLOCK_API_KEY_FUNCTIONS, SLOTLOCK_API_KEY_TABLES, SLOTLOCK_CORE_DDL, SLOTLOCK_DASHBOARD_FUNCTIONS, SLOTLOCK_DASHBOARD_TABLES, SLOTLOCK_DEFINER_FUNCTIONS, SLOTLOCK_DEFINER_TABLES, SLOTLOCK_OPERATOR_FUNCTIONS, SLOTLOCK_SCHEMA_FUNCTIONS, SLOTLOCK_SCHEMA_TABLES, SLOTLOCK_SCHEMA_VERSION, SLOTLOCK_TENANT_ADMIN_FUNCTIONS, SLOTLOCK_TENANT_ADMIN_TABLES, SLOTLOCK_TENANT_CONTEXT_SETTING, SLOTLOCK_TENANT_RLS_DDL, SLOTLOCK_TENANT_TABLES, } from './ddl.js';
33
- export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_TENANT_WRITE_METHODS, } from './store.js';
41
+ export { assertSlotlockSchemaCurrent, calendarEventRollingHorizon, createSlotlockStore, readSlotlockSchemaVersion, SLOTLOCK_AGENT_OWNER_COMMAND_QUOTA, SLOTLOCK_AGENT_OWNER_EVENT_QUOTA, SLOTLOCK_CALENDAR_HORIZON_DAYS, SLOTLOCK_SOURCE_BUSY_MAX, SLOTLOCK_EVENT_COMMAND_RETENTION_DAYS, SLOTLOCK_INTERNAL_EVENT_OWNER_REF, SLOTLOCK_LOCAL_RECURRENCE_COVERAGE_SOURCE, SLOTLOCK_MATERIALIZATION_WINDOW_DAYS, SLOTLOCK_MAX_EVENT_DURATION_DAYS, SLOTLOCK_MAX_CONTACTS_PER_TENANT, SLOTLOCK_PAYMENT_HOLD_MAX_TTL_MS, SLOTLOCK_TENANT_WRITE_METHODS, } from './store.js';
package/interop.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type SlotlockExceptionDay } from './hours.js';
1
2
  import type { SlotlockCalendarEvent, WeeklyAvailabilityRule } from './types.js';
2
3
  /** The longest VAVAILABILITY text `parseVAvailability` reads. */
3
4
  export declare const SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS = 65536;
@@ -8,26 +9,43 @@ export interface EmitVAvailabilityOptions {
8
9
  timezone: string;
9
10
  /** The VAVAILABILITY's UID; each AVAILABLE's is this with its index. */
10
11
  uid: string;
12
+ /**
13
+ * Days exceptions replace (RFC 0004 B1): each weekly AVAILABLE gets an EXDATE for its window on
14
+ * them, and the hours an open day has instead are AVAILABLEs of their own, one per window with an
15
+ * RDATE for each further day with the same window.
16
+ */
17
+ days?: readonly SlotlockExceptionDay[];
11
18
  }
19
+ /** The most days one VAVAILABILITY's EXDATEs, RDATEs and single AVAILABLEs may name. */
20
+ export declare const SLOTLOCK_MAX_VAVAILABILITY_DAYS = 1000;
12
21
  /**
13
22
  * A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
14
23
  * in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
15
24
  * two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
16
25
  * AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
17
26
  */
18
- export declare function emitVAvailability({ rules, timezone, uid }: EmitVAvailabilityOptions): string;
27
+ export declare function emitVAvailability({ rules, timezone, uid, days }: EmitVAvailabilityOptions): string;
19
28
  export interface ParsedVAvailability {
20
29
  /** The TZID every AVAILABLE named, `UTC` for UTC times, or null when they are all floating. */
21
30
  timezone: string | null;
22
31
  rules: WeeklyAvailabilityRule[];
32
+ /**
33
+ * The days EXDATEs, RDATEs and single AVAILABLEs change, each with the windows it has: none is
34
+ * closed. Empty when the hours are only weekly.
35
+ */
36
+ days: SlotlockExceptionDay[];
23
37
  }
24
38
  /**
25
- * Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one VAVAILABILITY, each AVAILABLE a
26
- * weekly RRULE with a time of day and a length. Refuses (`invalid_availability`) what weekly
27
- * rules cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, an AVAILABLE
28
- * without RRULE, with RDATE, EXDATE or RECURRENCE-ID, a whole-day DTSTART, seconds, AVAILABLEs in
29
- * different zones, and any rule `parseAvailabilityRules` refuses. An RRULE without BYDAY takes
30
- * DTSTART's day, as RFC 5545 reads it.
39
+ * Weekly rules and the days that differ from them, from an iCalendar VAVAILABILITY (RFC 7953): one
40
+ * VAVAILABILITY, each AVAILABLE either a weekly RRULE with a time of day and a length, or a single
41
+ * window. EXDATE takes a weekly window off its day, RDATE adds a window (of the AVAILABLE's length,
42
+ * or a PERIOD's), and a single AVAILABLE adds its own: each day they name becomes a day with hours
43
+ * of its own (`days`), as exceptions keep them (RFC 0004 B1). Refuses (`invalid_availability`) what
44
+ * these cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, RECURRENCE-ID, a
45
+ * whole-day DTSTART, seconds, AVAILABLEs in different zones, an EXDATE at another time than its
46
+ * window, a day with more than four windows or one over a day, more than
47
+ * SLOTLOCK_MAX_VAVAILABILITY_DAYS such days, and any rule `parseAvailabilityRules` refuses. An
48
+ * RRULE without BYDAY takes DTSTART's day, as RFC 5545 reads it.
31
49
  */
32
50
  export declare function parseVAvailability(text: string): ParsedVAvailability;
33
51
  /** A JSCalendar Event object (RFC 8984 §5.1): the properties Slotlock fills. */
package/interop.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // (RFC 8984). Pure functions: the application API serves them, and an application may call them
4
4
  // directly.
5
5
  import ICAL from 'ical.js';
6
+ import { epochDay } from './hours.js';
6
7
  import { zonedDateTimeFields } from './timezone.js';
7
8
  import { invalidAvailability, parseAvailabilityRules } from './weekly-hours.js';
8
9
  /** The longest VAVAILABILITY text `parseVAvailability` reads. */
@@ -40,13 +41,21 @@ function referenceDay(rrule) {
40
41
  const index = DAYS.findIndex((day) => named.includes(day));
41
42
  return new Date(REFERENCE_SUNDAY + Math.max(index, 0) * 86_400_000);
42
43
  }
44
+ /** The most days one VAVAILABILITY's EXDATEs, RDATEs and single AVAILABLEs may name. */
45
+ export const SLOTLOCK_MAX_VAVAILABILITY_DAYS = 1_000;
46
+ const compactDate = (date) => date.replaceAll('-', '');
47
+ const wallStamp = (date, minutes) => `${compactDate(date)}T${pad(Math.floor(minutes / 60))}${pad(minutes % 60)}00`;
48
+ /** The weekday codes an RRULE's BYDAY names. */
49
+ const byDays = (rrule) => new Set((/(?:^|;)BYDAY=([^;]*)/.exec(rrule)?.[1] ?? '').split(',').map((day) => day.slice(-2)));
50
+ /** The weekday code of `YYYY-MM-DD`. */
51
+ const weekdayOf = (date) => DAYS[new Date(`${date}T00:00:00Z`).getUTCDay()];
43
52
  /**
44
53
  * A VCALENDAR with one VAVAILABILITY holding an AVAILABLE per rule: DTSTART on the rule's first day
45
54
  * in a reference week of January 2024, with the IANA zone as TZID (no VTIMEZONE: RFC 7809 lets the
46
55
  * two sides agree on IANA names), DURATION and the rule's RRULE. `[]` is a VAVAILABILITY with no
47
56
  * AVAILABLE: never bookable. CRLF line endings, folded at 75 octets.
48
57
  */
49
- export function emitVAvailability({ rules, timezone, uid }) {
58
+ export function emitVAvailability({ rules, timezone, uid, days }) {
50
59
  const lines = [
51
60
  'BEGIN:VCALENDAR',
52
61
  'VERSION:2.0',
@@ -59,7 +68,31 @@ export function emitVAvailability({ rules, timezone, uid }) {
59
68
  const day = referenceDay(rule.rrule);
60
69
  const hour = Math.floor(rule.startMinutes / 60);
61
70
  const minute = rule.startMinutes % 60;
62
- lines.push('BEGIN:AVAILABLE', `UID:${uid}-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${day.getUTCFullYear()}${pad(day.getUTCMonth() + 1)}${pad(day.getUTCDate())}T${pad(hour)}${pad(minute)}00`, `DURATION:PT${rule.durationMinutes}M`, `RRULE:${rule.rrule}`, 'END:AVAILABLE');
71
+ lines.push('BEGIN:AVAILABLE', `UID:${uid}-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${day.getUTCFullYear()}${pad(day.getUTCMonth() + 1)}${pad(day.getUTCDate())}T${pad(hour)}${pad(minute)}00`, `DURATION:PT${rule.durationMinutes}M`, `RRULE:${rule.rrule}`);
72
+ const weekdays = byDays(rule.rrule);
73
+ const excluded = (days ?? [])
74
+ .filter((day) => weekdays.has(weekdayOf(day.date)))
75
+ .map((day) => wallStamp(day.date, rule.startMinutes));
76
+ if (excluded.length > 0)
77
+ lines.push(`EXDATE;TZID=${timezone}:${excluded.join(',')}`);
78
+ lines.push('END:AVAILABLE');
79
+ });
80
+ // An open day's hours: one AVAILABLE per window, repeated by RDATE on each further day with it.
81
+ const byWindow = new Map();
82
+ for (const day of days ?? []) {
83
+ for (const window of day.windows) {
84
+ const key = `${window.startMinutes}:${window.durationMinutes}`;
85
+ const entry = byWindow.get(key) ?? { window, dates: [] };
86
+ entry.dates.push(day.date);
87
+ byWindow.set(key, entry);
88
+ }
89
+ }
90
+ [...byWindow.values()].forEach(({ window, dates }, index) => {
91
+ const [first, ...rest] = [...dates].sort();
92
+ lines.push('BEGIN:AVAILABLE', `UID:${uid}-day-${index}`, `DTSTAMP:${VAVAILABILITY_DTSTAMP}`, `DTSTART;TZID=${timezone}:${wallStamp(first, window.startMinutes)}`, `DURATION:PT${window.durationMinutes}M`);
93
+ if (rest.length > 0)
94
+ lines.push(`RDATE;TZID=${timezone}:${rest.map((date) => wallStamp(date, window.startMinutes)).join(',')}`);
95
+ lines.push('END:AVAILABLE');
63
96
  });
64
97
  lines.push('END:VAVAILABILITY', 'END:VCALENDAR');
65
98
  return `${lines.map(fold).join(CRLF)}${CRLF}`;
@@ -72,12 +105,16 @@ function single(component, name) {
72
105
  return properties[0] ?? null;
73
106
  }
74
107
  /**
75
- * Weekly rules from an iCalendar VAVAILABILITY (RFC 7953): one VAVAILABILITY, each AVAILABLE a
76
- * weekly RRULE with a time of day and a length. Refuses (`invalid_availability`) what weekly
77
- * rules cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, an AVAILABLE
78
- * without RRULE, with RDATE, EXDATE or RECURRENCE-ID, a whole-day DTSTART, seconds, AVAILABLEs in
79
- * different zones, and any rule `parseAvailabilityRules` refuses. An RRULE without BYDAY takes
80
- * DTSTART's day, as RFC 5545 reads it.
108
+ * Weekly rules and the days that differ from them, from an iCalendar VAVAILABILITY (RFC 7953): one
109
+ * VAVAILABILITY, each AVAILABLE either a weekly RRULE with a time of day and a length, or a single
110
+ * window. EXDATE takes a weekly window off its day, RDATE adds a window (of the AVAILABLE's length,
111
+ * or a PERIOD's), and a single AVAILABLE adds its own: each day they name becomes a day with hours
112
+ * of its own (`days`), as exceptions keep them (RFC 0004 B1). Refuses (`invalid_availability`) what
113
+ * these cannot say: a VAVAILABILITY bounded by DTSTART or DTEND, more than one, RECURRENCE-ID, a
114
+ * whole-day DTSTART, seconds, AVAILABLEs in different zones, an EXDATE at another time than its
115
+ * window, a day with more than four windows or one over a day, more than
116
+ * SLOTLOCK_MAX_VAVAILABILITY_DAYS such days, and any rule `parseAvailabilityRules` refuses. An
117
+ * RRULE without BYDAY takes DTSTART's day, as RFC 5545 reads it.
81
118
  */
82
119
  export function parseVAvailability(text) {
83
120
  if (text.length > SLOTLOCK_MAX_VAVAILABILITY_CHARACTERS) {
@@ -99,16 +136,26 @@ export function parseVAvailability(text) {
99
136
  throw invalidAvailability('A VAVAILABILITY bounded by DTSTART or DTEND cannot be weekly hours');
100
137
  }
101
138
  const zones = new Set();
102
- const rules = availability.getAllSubcomponents('available').map((available, index) => {
103
- for (const name of ['rdate', 'exdate', 'recurrence-id']) {
104
- if (available.hasProperty(name)) {
105
- throw invalidAvailability(`AVAILABLE ${index} has ${name.toUpperCase()}: weekly hours cannot say it`);
106
- }
139
+ // What EXDATE, RDATE and single AVAILABLEs say, day by day: weekly windows taken away, and
140
+ // windows added.
141
+ const excluded = new Map();
142
+ const added = new Map();
143
+ const named = new Set();
144
+ const nameDay = (date, index) => {
145
+ named.add(date);
146
+ if (named.size > SLOTLOCK_MAX_VAVAILABILITY_DAYS) {
147
+ throw invalidAvailability(`AVAILABLE ${index}: at most ${SLOTLOCK_MAX_VAVAILABILITY_DAYS} days may differ from the week`);
148
+ }
149
+ };
150
+ const weekly = [];
151
+ availability.getAllSubcomponents('available').forEach((available, index) => {
152
+ if (available.hasProperty('recurrence-id')) {
153
+ throw invalidAvailability(`AVAILABLE ${index} has RECURRENCE-ID: weekly hours cannot say it`);
107
154
  }
108
155
  const dtstart = single(available, 'dtstart');
109
156
  const rrule = single(available, 'rrule');
110
- if (!dtstart || !rrule)
111
- throw invalidAvailability(`AVAILABLE ${index} needs DTSTART and RRULE`);
157
+ if (!dtstart)
158
+ throw invalidAvailability(`AVAILABLE ${index} needs DTSTART`);
112
159
  const start = dtstart.getFirstValue();
113
160
  if (start.isDate)
114
161
  throw invalidAvailability(`AVAILABLE ${index} starts on a date, not a time`);
@@ -139,16 +186,97 @@ export function parseVAvailability(text) {
139
186
  if (!Number.isInteger(durationMinutes) || durationMinutes < 1) {
140
187
  throw invalidAvailability(`AVAILABLE ${index} does not last a whole number of minutes`);
141
188
  }
189
+ const startMinutes = start.hour * 60 + start.minute;
190
+ const dateOf = (time) => `${pad(time.year, 4)}-${pad(time.month)}-${pad(time.day)}`;
191
+ // EXDATE and RDATE are read in DTSTART's zone: another would move them.
192
+ const sameZone = (property, name) => {
193
+ if (property.getParameter('tzid') !== dtstart.getParameter('tzid')) {
194
+ throw invalidAvailability(`AVAILABLE ${index} has ${name} in another zone than DTSTART`);
195
+ }
196
+ };
197
+ const addWindow = (date, minutes, length) => {
198
+ if (length > 1_440) {
199
+ throw invalidAvailability(`AVAILABLE ${index} adds a window longer than a day on ${date}`);
200
+ }
201
+ nameDay(date, index);
202
+ added.set(date, [...(added.get(date) ?? []), { startMinutes: minutes, durationMinutes: length }]);
203
+ };
204
+ for (const property of available.getAllProperties('rdate')) {
205
+ sameZone(property, 'RDATE');
206
+ for (const value of property.getValues()) {
207
+ if (value instanceof ICAL.Period) {
208
+ const from = value.start;
209
+ const to = value.getEnd();
210
+ const length = (wallSeconds(to) - wallSeconds(from)) / 60;
211
+ if (from.isDate || from.second !== 0 || !Number.isInteger(length) || length < 1) {
212
+ throw invalidAvailability(`AVAILABLE ${index} has an RDATE period that is not whole minutes`);
213
+ }
214
+ addWindow(dateOf(from), from.hour * 60 + from.minute, length);
215
+ }
216
+ else {
217
+ const time = value;
218
+ if (time.isDate || time.second !== 0) {
219
+ throw invalidAvailability(`AVAILABLE ${index} has an RDATE that is not a time to the minute`);
220
+ }
221
+ addWindow(dateOf(time), time.hour * 60 + time.minute, durationMinutes);
222
+ }
223
+ }
224
+ }
225
+ if (!rrule) {
226
+ // A single window on its day.
227
+ addWindow(dateOf(start), startMinutes, durationMinutes);
228
+ if (available.hasProperty('exdate')) {
229
+ throw invalidAvailability(`AVAILABLE ${index} has EXDATE but no RRULE`);
230
+ }
231
+ return;
232
+ }
142
233
  let value = String(rrule.getFirstValue());
143
234
  if (!/(?:^|;)BYDAY=/.test(value)) {
144
235
  value = `${value};BYDAY=${DAYS[start.dayOfWeek() - 1]}`;
145
236
  }
146
- return { rrule: value, startMinutes: start.hour * 60 + start.minute, durationMinutes };
237
+ const position = weekly.length;
238
+ weekly.push({ rule: { rrule: value, startMinutes, durationMinutes }, index });
239
+ for (const property of available.getAllProperties('exdate')) {
240
+ sameZone(property, 'EXDATE');
241
+ for (const time of property.getValues()) {
242
+ if (!time.isDate && time.hour * 60 + time.minute !== startMinutes) {
243
+ throw invalidAvailability(`AVAILABLE ${index} has an EXDATE at another time than its window`);
244
+ }
245
+ const date = dateOf(time);
246
+ nameDay(date, index);
247
+ excluded.set(date, (excluded.get(date) ?? new Set()).add(position));
248
+ }
249
+ }
147
250
  });
148
251
  if (zones.size > 1)
149
252
  throw invalidAvailability('The AVAILABLEs are in different time zones');
150
253
  const [timezone = null] = zones;
151
- return { timezone, rules: parseAvailabilityRules(rules) };
254
+ const rules = parseAvailabilityRules(weekly.map(({ rule }) => rule));
255
+ // Each named day as it reads: the weekly windows that fall on it and are not excluded, and those
256
+ // added.
257
+ const days = [...named].sort().map((date) => {
258
+ const weekday = weekdayOf(date);
259
+ const untilOk = (rrule) => {
260
+ const until = /(?:^|;)UNTIL=(\d{8})/.exec(rrule)?.[1];
261
+ return until === undefined || compactDate(date) <= until;
262
+ };
263
+ const windows = [];
264
+ rules.forEach((rule, position) => {
265
+ if (!byDays(rule.rrule).has(weekday) || !untilOk(rule.rrule) || excluded.get(date)?.has(position))
266
+ return;
267
+ if (rule.durationMinutes > 1_440) {
268
+ throw invalidAvailability(`A window longer than a day falls on ${date}, which differs from the week`);
269
+ }
270
+ windows.push({ startMinutes: rule.startMinutes, durationMinutes: rule.durationMinutes });
271
+ });
272
+ windows.push(...(added.get(date) ?? []));
273
+ if (windows.length > 4)
274
+ throw invalidAvailability(`${date} has more than 4 windows`);
275
+ if (epochDay(date) === null)
276
+ throw invalidAvailability(`${date} is not a day`);
277
+ return { date, windows };
278
+ });
279
+ return { timezone, rules, days };
152
280
  }
153
281
  const wallSeconds = (time) => Date.UTC(time.year, time.month - 1, time.day, time.hour, time.minute, time.second) / 1000;
154
282
  /** An instant as a LocalDateTime (RFC 8984 §1.4.4) on the wall clock of `timezone`. */
package/lifecycle.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { SlotlockChangeType } from './event-feed.js';
2
+ export declare const SLOTLOCK_BOOKING_STATUSES: readonly ["held", "confirmed", "checked_in", "completed", "cancelled", "no_show", "released", "expired", "erased"];
3
+ export type SlotlockBookingStatus = (typeof SLOTLOCK_BOOKING_STATUSES)[number];
4
+ /** What moves a hold or booking, and who does it. */
5
+ export declare const SLOTLOCK_BOOKING_ACTIONS: readonly ["confirm", "release", "expire", "reschedule", "check_in", "cancel", "no_show", "complete", "erase"];
6
+ export type SlotlockBookingAction = (typeof SLOTLOCK_BOOKING_ACTIONS)[number];
7
+ /** Where an end leaves a hold or booking: nothing moves it again, but erasure. */
8
+ export declare const SLOTLOCK_BOOKING_ENDS: ReadonlySet<SlotlockBookingStatus>;
9
+ /** Why a tombstoned reference ended, as `reservation_tombstones.end_reason` keeps it. */
10
+ export type SlotlockEndReason = 'cancelled' | 'no_show' | 'released' | 'expired' | 'erased';
11
+ type Move = {
12
+ to: SlotlockBookingStatus;
13
+ change: SlotlockChangeType | readonly SlotlockChangeType[] | null;
14
+ };
15
+ /**
16
+ * Each allowed move: the status it leads to and the feed change it writes (null: none; a list: all
17
+ * of them, in order). Anything not here is refused with `SLOTLOCK_BOOKING_REFUSALS`.
18
+ */
19
+ export declare const SLOTLOCK_BOOKING_TRANSITIONS: Readonly<Record<SlotlockBookingStatus, Readonly<Partial<Record<SlotlockBookingAction, Move>>>>>;
20
+ /** The refusal a move gets from a status it is not allowed from, as the store names it. */
21
+ export declare function slotlockBookingRefusal(from: SlotlockBookingStatus, action: SlotlockBookingAction): string;
22
+ /** What `action` does from `from`: the status it leads to and the change it writes, or its refusal. */
23
+ export declare function slotlockBookingNext(from: SlotlockBookingStatus, action: SlotlockBookingAction): {
24
+ ok: true;
25
+ to: SlotlockBookingStatus;
26
+ changes: readonly SlotlockChangeType[];
27
+ } | {
28
+ ok: false;
29
+ code: string;
30
+ };
31
+ export {};