diff --git a/lib/events/api.sx b/lib/events/api.sx new file mode 100644 index 00000000..97fa2816 --- /dev/null +++ b/lib/events/api.sx @@ -0,0 +1,137 @@ +;; lib/events/api.sx — public events surface over calendar + availability. +;; +;; A `store` is an immutable value holding scheduled events and bookings: +;; +;; {:events (event ...) :bookings ((actor key) ...)} +;; +;; All queries are windowed: agenda/free/next-free expand recurring events into +;; concrete occurrences within an explicit (or derived) window before running +;; the Datalog availability rules. Phase 2 replaces `ev/book` with a capacity- +;; safe persist append; the rest of this facade stays put. + +(define ev/store (fn (events bookings) {:bookings bookings :events events})) + +(define ev/empty (fn () (ev/store (list) (list)))) + +(define ev/events (fn (store) (get store :events))) +(define ev/bookings (fn (store) (get store :bookings))) + +;; Add a (constructed) event to the store. +(define + ev/add-event + (fn + (store event) + (ev/store (cons event (ev/events store)) (ev/bookings store)))) + +;; Schedule a fresh event from parts, returning the updated store. rrule may be +;; nil for a one-off. (Booking is separate — see ev/book.) +(define + ev/schedule + (fn + (store id dtstart duration rrule capacity) + (ev/add-event store (ev-event id dtstart duration rrule capacity)))) + +;; Record that `actor` holds the occurrence with `key` (ev-occ-key of an +;; expanded occurrence). Phase 1: append-only, no capacity check. +(define + ev/book + (fn + (store actor key) + (ev/store + (ev/events store) + (cons (list actor key) (ev/bookings store))))) + +;; The maximum event duration in the store (0 when empty) — used to widen +;; expansion windows so any occurrence overlapping a query is captured. +(define + ev/store-max-duration + (fn + (store) + (reduce + (fn (m ev) (max m (get ev :duration))) + 0 + (ev/events store)))) + +;; All occurrences across all events within [ws, we), ascending by start. +(define + ev/agenda + (fn (store ws we) (ev-expand-all (ev/events store) ws we))) + +(define + ev-key-member? + (fn + (k keys) + (cond + ((empty? keys) false) + ((= k (first keys)) true) + (else (ev-key-member? k (rest keys)))))) + +;; Occurrence keys `actor` has booked. +(define + ev/actor-keys + (fn + (store actor) + (reduce + (fn + (acc b) + (if (= (first b) actor) (cons (first (rest b)) acc) acc)) + (list) + (ev/bookings store)))) + +;; The agenda restricted to occurrences `actor` is booked into, within window. +(define + ev/agenda-for + (fn + (store actor ws we) + (let + ((keys (ev/actor-keys store actor))) + (filter + (fn (o) (ev-key-member? (ev-occ-key o) keys)) + (ev/agenda store ws we))))) + +;; Build an availability db over occurrences expanded in [ws, we). +(define + ev/avail-window-db + (fn + (store ws we) + (ev-avail-db (ev/agenda store ws we) (ev/bookings store)))) + +;; Is `actor` free across [qs, qe)? Expands a window wide enough (back by the +;; longest event) to capture any occurrence that could overlap. +(define + ev/free? + (fn + (store actor qs qe) + (ev-free? + (ev/avail-window-db store (- qs (ev/store-max-duration store)) qe) + actor + qs + qe))) + +;; Earliest free slot of `duration` for `actor` in [after, horizon), or nil. +(define + ev/next-free + (fn + (store actor after duration horizon) + (ev-next-free + (ev/avail-window-db + store + (- after (ev/store-max-duration store)) + horizon) + actor + after + duration + horizon))) + +;; Overlapping double-bookings for `actor` among occurrences in [ws, we). +(define + ev/conflicts + (fn + (store actor ws we) + (ev-conflicts (ev/avail-window-db store ws we) actor))) + +(define + ev/has-conflict? + (fn + (store actor ws we) + (> (len (ev/conflicts store actor ws we)) 0))) diff --git a/lib/events/conformance.conf b/lib/events/conformance.conf index dc7857b6..addc4154 100644 --- a/lib/events/conformance.conf +++ b/lib/events/conformance.conf @@ -17,9 +17,11 @@ PRELOADS=( lib/datalog/magic.sx lib/events/calendar.sx lib/events/availability.sx + lib/events/api.sx ) SUITES=( "calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)" "availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)" + "api:lib/events/tests/api.sx:(ev-api-tests-run!)" ) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 3b817214..730a19ab 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,11 +1,12 @@ { "lang": "events", - "total_passed": 59, + "total_passed": 73, "total_failed": 0, - "total": 59, + "total": 73, "suites": [ {"name":"calendar","passed":37,"failed":0,"total":37}, - {"name":"availability","passed":22,"failed":0,"total":22} + {"name":"availability","passed":22,"failed":0,"total":22}, + {"name":"api","passed":14,"failed":0,"total":14} ], - "generated": "2026-06-07T00:49:23+00:00" + "generated": "2026-06-07T01:15:49+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index fff9a18a..264cca74 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,8 +1,9 @@ # events scoreboard -**59 / 59 passing** (0 failure(s)). +**73 / 73 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| | calendar | 37 | 37 | ok | | availability | 22 | 22 | ok | +| api | 14 | 14 | ok | diff --git a/lib/events/tests/api.sx b/lib/events/tests/api.sx new file mode 100644 index 00000000..6b1d2245 --- /dev/null +++ b/lib/events/tests/api.sx @@ -0,0 +1,176 @@ +;; lib/events/tests/api.sx — public events facade (schedule/agenda/free/book). + +(define ev-api-pass 0) +(define ev-api-fail 0) +(define ev-api-failures (list)) + +(define + ev-api-check! + (fn + (name got expected) + (if + (= got expected) + (set! ev-api-pass (+ ev-api-pass 1)) + (do + (set! ev-api-fail (+ ev-api-fail 1)) + (append! + ev-api-failures + (str name "\n expected: " expected "\n got: " got)))))) + +;; A store with a weekly yoga class (Mon+Wed 18:00, 60m, 4 occurrences). +(define + ev-api-store + (fn + () + (ev/schedule + (ev/empty) + (quote yoga) + (ev-dt 2026 6 1 18 0) + 60 + {:freq :weekly :count 4 :byday (list 0 2)} + 20))) + +(define + ev-api-run-all! + (fn + () + (let + ((s0 (ev-api-store))) + (let + ((occs (ev/agenda s0 (ev-date 2026 6 1) (ev-date 2026 7 1)))) + (let + ((s1 (ev/book (ev/book s0 (quote nia) (ev-occ-key (first occs))) (quote nia) (ev-occ-key (first (rest occs)))))) + (do + (ev-api-check! + "agenda expands weekly class to four occurrences" + (map (fn (o) (ev-dt->civil (get o :start))) occs) + (list + (list 2026 6 1) + (list 2026 6 3) + (list 2026 6 8) + (list 2026 6 10))) + (ev-api-check! + "empty store has empty agenda" + (ev/agenda + (ev/empty) + (ev-date 2026 6 1) + (ev-date 2026 7 1)) + (list)) + (ev-api-check! + "max duration reflects scheduled events" + (ev/store-max-duration s0) + 60) + (ev-api-check! + "max duration of empty store is zero" + (ev/store-max-duration (ev/empty)) + 0) + (ev-api-check! + "agenda-for lists only booked occurrences" + (map + (fn (o) (ev-dt->civil (get o :start))) + (ev/agenda-for + s1 + (quote nia) + (ev-date 2026 6 1) + (ev-date 2026 7 1))) + (list + (list 2026 6 1) + (list 2026 6 3))) + (ev-api-check! + "agenda-for empty for unbooked actor" + (ev/agenda-for + s1 + (quote zed) + (ev-date 2026 6 1) + (ev-date 2026 7 1)) + (list)) + (ev-api-check! + "free? false during a booked occurrence" + (ev/free? + s1 + (quote nia) + (ev-dt 2026 6 1 18 30) + (ev-dt 2026 6 1 19 0)) + false) + (ev-api-check! + "free? true in an open window" + (ev/free? + s1 + (quote nia) + (ev-dt 2026 6 1 9 0) + (ev-dt 2026 6 1 10 0)) + true) + (ev-api-check! + "free? half-open at occurrence end" + (ev/free? + s1 + (quote nia) + (ev-dt 2026 6 1 19 0) + (ev-dt 2026 6 1 20 0)) + true) + (ev-api-check! + "free? true for an actor who booked nothing" + (ev/free? + s1 + (quote zed) + (ev-dt 2026 6 1 18 0) + (ev-dt 2026 6 1 19 0)) + true) + (ev-api-check! + "next-free skips the booked slot to the hour after" + (ev-dt-tod + (ev/next-free + s1 + (quote nia) + (ev-dt + 2026 + 6 + 1 + 18 + 0) + 60 + (ev-dt + 2026 + 6 + 1 + 23 + 0))) + (* 19 60)) + (ev-api-check! + "next-free returns `after` when already open" + (ev/next-free + s1 + (quote nia) + (ev-dt 2026 6 1 9 0) + 60 + (ev-dt 2026 6 1 18 0)) + (ev-dt 2026 6 1 9 0)) + (ev-api-check! + "no conflict among disjoint bookings" + (ev/has-conflict? + s1 + (quote nia) + (ev-date 2026 6 1) + (ev-date 2026 7 1)) + false) + (let + ((sc (ev/book (ev/schedule s1 (quote talk) (ev-dt 2026 6 1 18 30) 60 nil 5) (quote nia) (ev-occ-key (ev-occ (quote talk) (ev-dt 2026 6 1 18 30) 60))))) + (ev-api-check! + "overlapping second booking creates a conflict" + (ev/has-conflict? + sc + (quote nia) + (ev-date 2026 6 1) + (ev-date 2026 7 1)) + true)))))))) + +(define + ev-api-tests-run! + (fn + () + (do + (set! ev-api-pass 0) + (set! ev-api-fail 0) + (set! ev-api-failures (list)) + (ev-api-run-all!) + {:failures ev-api-failures :total (+ ev-api-pass ev-api-fail) :passed ev-api-pass :failed ev-api-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index c47f9d67..2295951d 100644 --- a/plans/events-on-sx.md +++ b/plans/events-on-sx.md @@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher ## Status (rolling) -`bash lib/events/conformance.sh` → **59/59** (Phase 1: calendar + availability) +`bash lib/events/conformance.sh` → **73/73** (Phase 1 complete: calendar + availability + api) ## Ground rules @@ -58,8 +58,8 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── - [x] `calendar.sx` — MONTHLY (bymonthday + nth-weekday byday) - [x] `availability.sx` — free/busy rules (busy/conflict/busy_in on Datalog) - [x] `availability.sx` — next-free slot search (same rules, different bindings) -- [ ] `api.sx` — public entry points (schedule/agenda/free-check) -- [ ] tests + scoreboard + conformance.sh [done incrementally; api.sx pending] +- [x] `api.sx` — public entry points (schedule/agenda/book/free/next-free/conflicts) +- [x] tests + scoreboard + conformance.sh (73/73) ## Phase 2 — Ticketing + booking - [ ] capacity rules; transactional booking → `persist` (no overbooking) @@ -79,6 +79,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — **Phase 1 complete.** `api.sx`: immutable `store` + ({:events :bookings}) facade over calendar + availability. `ev/schedule`, + `ev/book`, `ev/agenda`, `ev/agenda-for`, `ev/free?`, `ev/next-free`, + `ev/conflicts`. Availability queries auto-widen the expansion window back by + the longest event so any overlapping occurrence is captured. 14 tests, + 73/73 green. Phase 2 (transactional booking on persist) is next — `ev/book` + becomes capacity-safe via a persist append at that point. - 2026-06-07 — `next-free` slot search: earliest start ≥ after where [s,s+duration) is free and ends ≤ horizon, else nil. Candidates are `after` plus each busy-interval end (interval-packing); each probe reuses the