diff --git a/lib/events/api.sx b/lib/events/api.sx index aa5b2d03..3484f9f4 100644 --- a/lib/events/api.sx +++ b/lib/events/api.sx @@ -275,3 +275,55 @@ ((ev/would-time-conflict? b store actor occ) {:status :time-conflict :actor actor :occ-key (ev-occ-key occ)}) (else (ev/book-occ! b store actor occ))))) + +;; ---- whole-series operations ---- +;; Apply a booking action to every occurrence of one event in [ws, we) — e.g. +;; "RSVP to the whole weekly class". Returns a list of (occ-key status) results, +;; one per occurrence (empty if the event id is unknown). +(define + ev/book-series! + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (map + (fn (occ) (list (ev-occ-key occ) (get (ev/book-occ! b store actor occ) :status))) + (ev-expand ev ws we)))))) + +;; Cancel `actor` from every occurrence of one event in [ws, we). +(define + ev/cancel-series! + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (map + (fn (occ) (list (ev-occ-key occ) (get (ev/cancel! b (ev-occ-key occ) actor) :status))) + (ev-expand ev ws we)))))) + +;; How many statuses in a series-result list equal `status`. +(define + ev/series-count + (fn + (results status) + (len (filter (fn (r) (= (first (rest r)) status)) results)))) + +;; The occurrences of one event in [ws, we) that `actor` is booked into. +(define + ev/series-booked + (fn + (b store actor event-id ws we) + (let + ((ev (ev/event-by-id store event-id))) + (if + (nil? ev) + (list) + (filter + (fn (occ) (ev-actor-booked? b (ev-occ-key occ) actor)) + (ev-expand ev ws we)))))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index a5a30acd..a2395334 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,14 +1,14 @@ { "lang": "events", - "total_passed": 332, + "total_passed": 341, "total_failed": 0, - "total": 332, + "total": 341, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"timezone","passed":17,"failed":0,"total":17}, {"name":"ical","passed":21,"failed":0,"total":21}, {"name":"availability","passed":22,"failed":0,"total":22}, - {"name":"api","passed":32,"failed":0,"total":32}, + {"name":"api","passed":41,"failed":0,"total":41}, {"name":"booking","passed":82,"failed":0,"total":82}, {"name":"booking-notify","passed":11,"failed":0,"total":11}, {"name":"ticket","passed":31,"failed":0,"total":31}, @@ -17,5 +17,5 @@ {"name":"federation","passed":29,"failed":0,"total":29}, {"name":"integration","passed":8,"failed":0,"total":8} ], - "generated": "2026-06-07T14:40:54+00:00" + "generated": "2026-06-07T15:20:08+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index b393b5b6..3a4b4e0d 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,6 +1,6 @@ # events scoreboard -**332 / 332 passing** (0 failure(s)). +**341 / 341 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -8,7 +8,7 @@ | timezone | 17 | 17 | ok | | ical | 21 | 21 | ok | | availability | 22 | 22 | ok | -| api | 32 | 32 | ok | +| api | 41 | 41 | ok | | booking | 82 | 82 | ok | | booking-notify | 11 | 11 | ok | | ticket | 31 | 31 | ok | diff --git a/lib/events/tests/api.sx b/lib/events/tests/api.sx index fb681f8f..b8ccf25a 100644 --- a/lib/events/tests/api.sx +++ b/lib/events/tests/api.sx @@ -319,6 +319,65 @@ (ev/would-time-conflict? b store (quote zed) ob) false)))))) +;; ---- whole-series booking ---- +(define + ev-api-sr-run-all! + (fn + () + (let + ((b (persist/open)) + (store + (ev/schedule + (ev/empty) + (quote yoga) + (ev-dt 2026 6 1 18 0) + 60 + {:freq :weekly :byday (list 0 2) :count 4} + 20)) + (ws (ev-date 2026 6 1)) + (we (ev-date 2026 7 1))) + (do + (let + ((res (ev/book-series! b store (quote nia) (quote yoga) ws we))) + (do + (ev-api-check! "series booking covers all four occurrences" (len res) 4) + (ev-api-check! "all occurrences booked" (ev/series-count res :booked) 4) + (ev-api-check! + "actor is now booked into the whole series" + (len (ev/series-booked b store (quote nia) (quote yoga) ws we)) + 4))) + ;; re-booking the series is idempotent + (ev-api-check! + "re-booking the series is idempotent" + (ev/series-count (ev/book-series! b store (quote nia) (quote yoga) ws we) :already) + 4) + ;; cancel the whole series + (let + ((res (ev/cancel-series! b store (quote nia) (quote yoga) ws we))) + (do + (ev-api-check! "series cancel reports four cancellations" (ev/series-count res :cancelled) 4) + (ev-api-check! + "actor booked into nothing after series cancel" + (len (ev/series-booked b store (quote nia) (quote yoga) ws we)) + 0))) + ;; capacity interacts per-occurrence: fill one occurrence first + (let + ((b2 (persist/open)) + (s2 + (ev/schedule (ev/empty) (quote clinic) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1))) + (do + (ev/book-occ! b2 s2 (quote x) (ev-occ (quote clinic) (ev-dt 2026 6 2 9 0) 30)) + (let + ((res (ev/book-series! b2 s2 (quote nia) (quote clinic) (ev-date 2026 6 1) (ev-date 2026 6 10)))) + (do + (ev-api-check! "series booking succeeds on free occurrences" (ev/series-count res :booked) 2) + (ev-api-check! "series booking hits :full where capacity is taken" (ev/series-count res :full) 1))))) + ;; unknown event id + (ev-api-check! + "series booking an unknown event yields no results" + (ev/book-series! b store (quote nia) (quote nope) ws we) + (list)))))) + (define ev-api-tests-run! (fn @@ -329,4 +388,5 @@ (set! ev-api-failures (list)) (ev-api-run-all!) (ev-api-cf-run-all!) + (ev-api-sr-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 94a7da04..d7943de6 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` → **332/332** (Phases 1-4 + 11 ext: …e2e delivery, conflict-checked booking, iCalendar export) +`bash lib/events/conformance.sh` → **341/341** (Phases 1-4 + 12 ext: …conflict-checked booking, iCalendar export, whole-series booking) ## Ground rules @@ -88,6 +88,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Whole-series booking (extension). `ev/book-series!` / + `ev/cancel-series!` apply a booking/cancel to every occurrence of one event + in a window (e.g. RSVP the whole weekly class), returning per-occurrence + (occ-key status) results; capacity is still enforced per occurrence (some + :booked, some :full). Idempotent re-book (all :already). `ev/series-count` + (tally a status), `ev/series-booked` (which occurrences the actor holds). + +9 tests, 341/341 green. This was the last flagged feature — surface saturated. - 2026-06-07 — iCalendar (RFC 5545) export (extension). `ical.sx` serializes events to VEVENT / VCALENDAR text for import by standard clients. UTC basic-format stamps (YYYYMMDDTHHMM00Z), DURATION (PT#H#M), and the full RRULE