Skip to content

ADR 007: Atomic Per-Day Exception Model for Calendar

Date: 2026-03-12 Status: Accepted

Note: Retrospective ADR written 2026-04-26. The refactor was scoped, executed, and documented under plan 016 (created 2026-03-12, completed 2026-03-18, 102 unit tests).

Context

ADR-001 introduced calendar exceptions as the highest-priority layer on top of templates and service bindings. The original schema modeled an exception as a datetime range: from_datetime + until_datetime, plus a reason. A single row could represent anything from a 1-hour partial closure to a 2-week vacation.

This shape produced four concrete problems in practice:

  1. Semantic ambiguity. A row with from = 2026-12-23T08:00 and until = 2026-12-27T18:00 could mean "closed for those five days" or "closed every day from 08:00 to 18:00 across that range" — depending on which engine code touched it.
  2. Frontend timezone drift. Template-level forms used dateToUtcMidnight(); service-level forms used new Date().toISOString(). Same field, two encodings, off-by-one bugs at locale boundaries.
  3. Engine complexity. The availability engine had to subtract a range interval from a slotted day, handle DST transitions inside the range, and reason about partial-day overlap on both endpoints. The math was correct but the test surface kept growing.
  4. No standard alignment. iCalendar RFC 5545 (which we needed to interop with for the ICS work — ADR-006) uses per-date EXDATE / RDATE entries, not range exceptions. Every export translation was a fan-out from one row to N VEVENTs, and import had to fold N back into one row heuristically.

Decision

Refactor exceptions to an atomic per-day model. Each row represents exactly one date, with optional time bounds. Multi-day scenarios are composed at the UX layer.

Schema

Column Type Meaning
exception_date DATE The single date this exception affects
is_full_day BOOLEAN If true, the whole day; if false, use start_time/end_time
start_time TIME Window start (when partial)
end_time TIME Window end (when partial)
availability_type ENUM UNAVAILABLE (subtract slots) or AVAILABLE (inject slots)
batch_id UUID NULL Groups exceptions created in one multi-day operation
scope ENUM PARTNER (all services) or SERVICE (one service)
template_id UUID NULL Further scopes the exception to services using a template

Constraint preventing duplicate full-day blocks for the same scope/target/date:

UNIQUE (scope, partner_id, service_id, exception_date, template_id)
WHERE is_full_day = TRUE

Time-range exceptions on the same date are allowed to coexist (different windows are valid).

Multi-day handled at the UX layer

The "but what about a holiday week?" objection is solved by:

  1. UI lets the user pick a date range.
  2. Backend creates N atomic rows (one per day).
  3. All N share a batch_id.
  4. Batch endpoints (POST /exceptions/batch, DELETE /exceptions/batch/:batchId) treat them as a unit.

Engine integration

Availability calculation in availability-layering.service.ts:

  1. Fetch exceptions filtered by date range (not all historical entries).
  2. toInterval() converts each atomic exception to a single start/end ISO interval for its date.
  3. UNAVAILABLE → subtract that interval from existing slots; AVAILABLE → inject as a new slot.
  4. Exceptions remain the highest-priority layer.

Migration

Existing range-based rows were expanded into N atomic rows during a one-shot migration. Each expansion group received a shared batch_id. Time-of-day fields were preserved from the legacy from_datetime / until_datetime. Old columns dropped after data migration.

Consequences

Positive

  • RFC 5545-shaped. EXDATE / RDATE export is now a 1:1 mapping; ICS import (plan 015) lands rows in the native shape; ICS sync worker (plan 017, ADR-006) creates one row per external VEVENT instance.
  • Engine simplicity. Single-date lookup, no range intersection math, no DST edge cases inside an exception.
  • Trivial conflict detection. Two exceptions on the same date with the same scope are an immediate UNIQUE-constraint or query hit, not a date-range overlap analysis.
  • Industry alignment. Stripe, Square, and Airbnb all model availability overrides per-date. The platform now matches that mental model.
  • Front-end consistency. One date primitive (exception_date as DATE, no timestamp) means one parsing path on every form.
  • Composable with ICS sync. Plan 017's SyncWorkerService writes one row per imported external event — the bus and the table speak the same shape.

Negative

  • Row-count multiplier. A two-week summer-vacation block is now 14 rows instead of 1. At MVP scale (a few thousand exceptions per partner over the platform's lifetime) this is comfortably absorbed by the existing indexes; it is not free if a single partner ever needs to block a continuous several-month window.
  • Batch operations are now first-class. Any UX that presents a "vacation" as a single editable entity must hold the batch_id and operate on the group. Editing one day in the middle of a batch is straightforward (it just becomes a non-batched row); editing the whole batch atomically is more code.
  • Migration was destructive. Pre-existing range rows had to be expanded; the original from_datetime / until_datetime columns were dropped. Worth flagging that any external scripts reading the old shape broke at refactor time.
  • No native "weekly closed Mondays for a year" expression. That use case belongs in the template layer, not exceptions — but partners occasionally try to model it as exceptions and the atomic model amplifies the row count if they do. UI guard rails steer them to templates instead.

References

  • services/calendar-service/src/modules/exceptions/ — atomic-model service + controller
  • services/calendar-service/src/modules/availability/availability-layering.service.ts — engine integration (toInterval, AVAILABLE vs UNAVAILABLE handling)
  • docs/implementation-plans/016-exception-refactor/human/explanation.md — full design rationale
  • docs/implementation-plans/016-exception-refactor/artifacts/calendar-exception-analysis.md — approach comparison
  • ADR-001 — the layered availability model exceptions belong to