|
| 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