Skip to content

Commit da80ab5

Browse files
docs(booking): add ADR 0011 slot rules + booking
1 parent 1b236e6 commit da80ab5

14 files changed

Lines changed: 1008 additions & 504 deletions

File tree

‎CONTEXT.md‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,51 @@ rules are checked against.
135135
_Avoid_: asset, entity, calendar (overloaded)
136136

137137
**Availability**:
138-
The windows during which a Resource can hold events (weekday + start/end time), plus capacity
139-
and buffer rules. Drives both advisory conflict checks and the Scheduler's free-slot
140-
projection.
141-
_Avoid_: schedule, hours, working hours
138+
Whether a given span can hold a given event — the *veto* side of scheduling. Resolves a Resource's
139+
Working Calendar chain (ADR 0008) to working minutes, then layers capacity, buffers and existing
140+
events on top. Answers one proposal at a time; the enumerating inverse is Slot generation.
141+
_Avoid_: schedule, hours, working hours; Resource-owned availability windows (ADR 0008 replaced
142+
those with Working Calendar references)
143+
144+
**Slot**:
145+
A discrete bookable unit generated from a Slot Rule — a start/end pair whose length is the rule's
146+
duration. Slots are ephemeral (derived, like an Occurrence), never stored; what is stored is the
147+
Slot Rule that generates them and the events that consume them. One Slot exists per (rule, start)
148+
regardless of resource count, and carries the resources currently free for it plus the remaining
149+
capacity. A Slot is *free* by construction: generation subtracts existing events, holds and
150+
buffers, so an offered Slot is a bookable one — advisorily, on the client.
151+
_Avoid_: time slot (that names the display grid `getTimeAxisLabels` produces), appointment, booking
152+
153+
**Slot Rule**:
154+
The stored definition that generates Slots: a Working Calendar reference supplying the window, its
155+
weekly repeat and its exceptions, plus `duration`, `step`, buffers, and the now-relative limits
156+
(`minNotice`, `maxHorizon`). `step` defaults to `duration` (back-to-back slots); a smaller `step`
157+
yields rolling, overlapping candidates. The grid anchors to the window start and a Slot that would
158+
overflow the window is not generated. A Slot Rule adds no recurrence of its own — repetition is the
159+
Working Calendar's, so holidays and closures resolve through the ADR 0008 hierarchy exactly as they
160+
do for Availability and the solver.
161+
_Avoid_: slot group (informal; a "group" is just one rule), availability rule, template
162+
163+
**Slot Rule Set**:
164+
The several Slot Rules that together describe one bookable thing — e.g. hour-long mornings and
165+
half-hour afternoons. Each rule needs its own Working Calendar, because
166+
`resolveLayeredDayMinutes` merges adjacent intervals and would fuse two windows of different
167+
granularity into one.
168+
_Avoid_: schedule (overloaded — the solver's pipeline stage and ADR 0010's `compileSchedule`
169+
both own that word)
170+
171+
**Hold**:
172+
A Slot claimed but not yet confirmed — an event carrying an expiry, so Slot generation subtracts it
173+
like any other event and stops subtracting once it lapses. Not a separate entity: confirming a Hold
174+
is an edit, not a new write path. Expiry is evaluated against the `now` passed into generation,
175+
never against the clock.
176+
_Avoid_: reservation, lock, pending booking
177+
178+
**Booking**:
179+
The confirmed event a Slot became. Written through the kernel's write pipeline, so it passes the
180+
same Availability, constraint and duration validate stages as any other event and participates in
181+
undo/redo. The client's decision is Advisory; only the server's is Authoritative.
182+
_Avoid_: appointment, reservation, order
142183

143184
**Calendar** / **Scheduler** / **Timeline**:
144185
Products. Calendar = kernel + recurrence/drag-resize/undo over a day/week/month view.

‎docs/ROADMAP.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ The Bryntum moat. Requires Phase 0 purity + Phase 1 calendar hierarchy.
103103
- [ ] **Write-sync protocol** (bidirectional; optimistic + server reconcile) beyond read-only
104104
`fetchEvents`.
105105
- [ ] **Virtualization contract** for large datasets (windowed projection; no px in core).
106-
- [ ] **`generateSlots`** — free/available-slot generation (booking / Calendly use case).
106+
- [ ] **`generateSlots` + booking** (ADR 0011) — `SlotRule` as its own type (window from a
107+
`WorkingCalendar`, rule adds `duration`/`step`/buffers/limits), a pure `src/slots/` core that
108+
subtracts events, holds and capacity, and `bookingFeature` with `book()`. Adds `timeZone` to
109+
`WorkingCalendar`; renames `getTimeSlots` → `getTimeAxisLabels`. Slice plan in
110+
`docs/plans/phase-3-booking.md`. Depends on Phase 1's hierarchy and ADR 0010's block compiler.
111+
- [ ] **Schedule rule blocks + lead time** (ADR 0010): `weekday`/`date`/`between` blocks compiling
112+
to `WorkingInterval` data, `resolveScheduleDay` with `skip`/`shift` exception policies, and
113+
`addWorkingDays` / `resolveCutoffStart` for cutoff-based estimates. Slice plan in
114+
`docs/plans/phase-3-schedule-rules.md`. Depends only on Phase 1's calendar hierarchy.
107115

108116
## Phase 4 — Scheduler product & richer scheduling
109117

@@ -148,3 +156,5 @@ The Bryntum moat. Requires Phase 0 purity + Phase 1 calendar hierarchy.
148156
| 0006 | Client/server Data Strategy (computation push-down) |
149157
| 0007 | Scheduling solver: fixpoint stage in write pipeline, client-authoritative |
150158
| 0008 | Working-time calendars as id-referenced shared entities with resolution hierarchy |
159+
| 0009 | Feature composition as the public API |
160+
| 0010 | Schedule rule blocks compile to `WorkingCalendar` data; no new predicate engine |
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Schedule rule blocks: compose `WorkingCalendar` data, no new predicate engine
2+
3+
## Status
4+
5+
accepted
6+
7+
## Context
8+
9+
Recurring, exception-aware scheduling rules keep coming up in a shape that isn't domain-specific:
10+
"deliver on Tuesdays, except public holidays, shifted to the next working day", "respond within 3
11+
business days", "if ordered before 16:00 the lead time starts today, otherwise tomorrow". None of
12+
this is particular to delivery — appointments, callbacks, SLAs, and shipment ETAs all want the
13+
same "recurring day-or-time predicate, plus exceptions, plus a resolution policy when the
14+
predicate lands on a non-working day" pattern.
15+
16+
The library already has the primitives that answer the hard part of this — `workingTime/`
17+
(Phase 2 of `docs/plans/phase-2-solver.md`) resolves layered `WorkingCalendar` data into working
18+
minutes and can walk forward/backward to the nearest working instant
19+
(`nextWorkingInstant`/`previousWorkingInstant`, `addWorkingMinutes`/`subtractWorkingMinutes`).
20+
What's missing is an ergonomic way to *build* a `WorkingCalendar` from small composable rule
21+
blocks (`weekday(...)`, `date(...)`, `between(...)`, `after(...)`, `before(...)`) instead of
22+
hand-writing `WorkingInterval` objects, plus a day-granularity lead-time primitive for the
23+
cutoff-based case.
24+
25+
Two shapes were on the table: compile the blocks into the existing `WorkingCalendar`/
26+
`WorkingInterval` data model (pure sugar, no new engine), or build a new tagged-data rule AST
27+
with its own evaluator that treats `WorkingCalendar` as just one possible input. The purity
28+
constraint (`src/workingTime` is inside `PURE_DIRS` — no closures, no non-serializable state,
29+
enforced by `validation/tests/purity.test.ts`) rules out plain JS-closure predicates either way;
30+
both options have to represent rules as data.
31+
32+
## Decision
33+
34+
- **Blocks compile to `WorkingInterval` fragments, not a new predicate type.** `weekday('tuesday')`,
35+
`date('2026-12-24')`, `dates([...])`, `dateRange(a, b)`, `between('09:00','17:00')`, and
36+
`after`/`before` (open-ended `between`) each produce the recurrent-or-dated `WorkingInterval`
37+
shape ADR 0008 already defines. `merge(a, b)` combines fragments that constrain different axes —
38+
`merge(weekday('tuesday'), between('08:00','16:00'))` is one interval, not two. `businessDays(n)`
39+
is not a block: it's a lead-time span, and doesn't participate in `on`/`except`.
40+
- **`compileSchedule(spec)` assembles a `WorkingCalendar`, and that's the entire new surface.**
41+
`spec.on` sets the base recurring intervals (`isWorking: true`); `spec.except` layers in
42+
`isWorking: false` overrides (holidays, closures) on top, using the same most-specific-wins
43+
layering `resolveLayeredDayMinutes` already implements. The output is a plain `WorkingCalendar` —
44+
nothing bespoke. A fluent `schedule(id).on(...).except(...).build()` may ship as sugar over it,
45+
but the plain-data function is canonical: it is what serializes, what a server builds, and what
46+
the purity boundary wants.
47+
- **`except` takes caller-supplied calendar/date data. No built-in per-country holiday
48+
tables.** Holiday data is jurisdiction-specific and goes stale; shipping it would be scope creep
49+
the library doesn't otherwise take on (mirrors the existing headless/data-only stance — see
50+
ADR 0005/0007 on arrows, editors, and histograms being the consumer's job, not the library's).
51+
A consumer (or a separate, explicitly-versioned holiday-data package) supplies the input.
52+
- **One new resolver combinator — `resolveScheduleDay` — because "skip" and "shift" are different
53+
questions.** See *Exception policy* below. Everything else stays: a range query is
54+
`getLayeredWorkingTime`, and both policy branches are implemented in terms of
55+
`nextWorkingInstant`/`previousWorkingInstant` rather than a parallel walker.
56+
- **The cutoff/lead-time case composes separately, via a new `addWorkingDays` primitive.**
57+
`resolveCutoffStart(instant, cutoffTime, layers, calendars)` decides whether the lead-time count
58+
starts today or tomorrow; `addWorkingDays(from, n, layers, calendars)` (a day-granularity sibling
59+
of `addWorkingMinutes`, not yet built) walks `n` working days forward. Neither needs the block
60+
compiler — they consume a `WorkingCalendar` directly, same as everything else in `workingTime/`.
61+
62+
### Exception policy: `skip` is not `shift`
63+
64+
An earlier draft of this ADR claimed `nextWorkingInstant(from, layers, calendars)` already answers
65+
"next matching day, skipping exceptions" and therefore no new resolver entry point was needed.
66+
That is only half true, and the missing half is the case consumers ask for most.
67+
68+
When Tuesday's delivery lands on a public holiday there are two defensible answers, and a single
69+
`WorkingCalendar` walk can only give one of them:
70+
71+
- **`skip`** — the next occurrence of the rule itself, i.e. *next Tuesday*. This is
72+
`nextWorkingInstant` over the compiled calendar, exceptions and all, exactly as claimed.
73+
- **`shift-forward` / `shift-backward`** — the nearest working day either side of the blocked
74+
occurrence, i.e. *Wednesday*. The compiled calendar cannot produce this, because Wednesday is
75+
non-working in it by construction — the rule says Tuesdays.
76+
77+
`shift-*` therefore needs two calendars: the **rule** calendar (when the thing nominally happens)
78+
and a **working-day** calendar (which days the business operates at all). The rule calendar
79+
supplies the candidate, and the shift is a walk over the working-day calendar from that candidate.
80+
Both are `WorkingCalendar`s and both resolve through the existing hierarchy, so this is a
81+
composition question, not a new engine:
82+
83+
```ts
84+
resolveScheduleDay(from, ruleLayers, policy, workingLayers, calendars) -> string | null
85+
```
86+
87+
For `skip`, `workingLayers` is unused and the call collapses to today's `nextWorkingInstant`. For
88+
`shift-*`, the rule's exceptions must live in a *separate* layer from its base recurrence — the
89+
candidate is resolved against the base, then tested and moved against the working-day calendar.
90+
ADR 0008's `parentId` chain is how that separation is expressed: a `store-42-delivery` calendar
91+
whose parent holds the recurrence, with the holiday overrides on the child.
92+
93+
### Day granularity is a convention, not a new type
94+
95+
`WorkingCalendar` resolves minutes. "Delivery on Tuesday" has no time-of-day, so a block with no
96+
time constraint compiles to the full civil day, `00:00`-`24:00`. Consequences worth stating
97+
because callers would otherwise guess: `resolveScheduleDay` returns a timestamp at `T00:00` for
98+
such a rule; `addWorkingDays` counts a day as working if it has *any* working minute, which is
99+
deliberately not `addWorkingMinutes`-with-`n * 1440`; and a rule that does carry a window
100+
(`merge(weekday('tuesday'), between('08:00','16:00'))`) answers the same queries at the finer
101+
granularity with no branch anywhere in the resolver.
102+
103+
## Considered Options
104+
105+
- **`every(n, unit)` as a block.** Dropped from the surface, not merely deferred: it is not
106+
expressible in the target model. `RecurrentWorkingInterval` is `{ weekdays, startTime, endTime }`
107+
— there is nowhere to record a period or an anchor, so "every 2 weeks" has no representation.
108+
The three ways out are all worse than declining: materialize to dated intervals over a finite
109+
horizon (a compiler that silently bounds the future, and a calendar whose size scales with the
110+
horizon), add `interval`/`anchorDate` to `RecurrentWorkingInterval` (a breaking change to ADR
111+
0008's model, in service of one block), or accept a second recurrence model alongside the one
112+
`recurrence/` already owns. Interval and set-position recurrence is RRULE's job; the roadmap
113+
already carries `BYSETPOS` / "2nd Tuesday" in Phase 4, and that is where an `every`-shaped block
114+
belongs, expressed over the recurrence engine rather than over `WorkingInterval`.
115+
- **New tagged-data rule AST with its own interpreter**, `WorkingCalendar` becoming one possible
116+
input among several. Rejected for now: real scope (new pure module, new test suite, a decision
117+
about whether it replaces or layers under ADR 0008's calendar hierarchy) for expressiveness
118+
nothing currently on the table needs. `WorkingInterval` already covers recurring-with-exceptions,
119+
which is every example gathered so far. Revisit only when a concrete rule can't be expressed as
120+
`WorkingInterval` data — the `every(n, unit)` case above is the first candidate, and it has a
121+
cheaper home.
122+
- **Built-in per-country holiday tables** (e.g. `holidays('PL')` shipping real dates). Rejected:
123+
holiday calendars change yearly, are jurisdiction-specific, and the library has already drawn
124+
the headless/data-only line elsewhere (ADR 0005/0007) — this would be the one exception.
125+
- **A single `nextOccurrence` with no policy argument.** Rejected once the skip/shift split above
126+
was clear: whichever behaviour the default picked would be silently wrong for the other half of
127+
the use cases, and the wrongness is a week-long date error, not a rounding difference.
128+
129+
## Consequences
130+
131+
- New surface is small: a block-to-`WorkingInterval` compiler and `compileSchedule`, both pure data
132+
transforms living in `workingTime/`. No new purity-boundary risk — data in, data out, same as
133+
every existing module there.
134+
- `resolveScheduleDay` adds one resolver entry point rather than none, and with it a documented
135+
requirement that `shift-*` callers keep base recurrence and exceptions in separate layers.
136+
Callers that only need `skip` can put everything in one calendar.
137+
- `addWorkingDays` and `resolveCutoffStart` are new work, independent of the block compiler, needed
138+
for the cutoff-based case specifically.
139+
- Interval recurrence ("every 2 weeks", "2nd Tuesday of the month") is explicitly *not* served by
140+
this ADR and is not a gap in it — it is Phase 4 recurrence work.
141+
- Slice plan in `docs/plans/phase-3-schedule-rules.md`.

0 commit comments

Comments
 (0)