From 07e4cb5f4a44ff34d288a7d5fcf4b94d6125113b Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 07:47:00 +0000 Subject: [PATCH] events: reschedule notifications + 7 tests ev/reschedule-notifications: when an event carries per-occurrence overrides, reads the roster at each overridden occurrence's original occ-key and emits a reschedule message per booked attendee (old-start/new-start/new-duration). Idempotency key = original-key/reschedule/new-start. 272/272 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/events/reminders.sx | 51 +++++++++++++++++++++++++++++++ lib/events/scoreboard.json | 8 ++--- lib/events/scoreboard.md | 4 +-- lib/events/tests/reminders.sx | 56 +++++++++++++++++++++++++++++++++++ plans/events-on-sx.md | 9 +++++- 5 files changed, 121 insertions(+), 7 deletions(-) diff --git a/lib/events/reminders.sx b/lib/events/reminders.sx index 465d638b..3ebf8a1b 100644 --- a/lib/events/reminders.sx +++ b/lib/events/reminders.sx @@ -94,3 +94,54 @@ ;; A single digest message summarising an actor's upcoming booked occurrences. ;; :items is ({:event :start} ...); empty when the actor has nothing booked. (define ev/agenda-digest (fn (b store actor ws we) {:items (map (fn (occ) {:event (get occ :id) :start (get occ :start)}) (ev/agenda-for-p b store actor ws we)) :id (str actor "/digest/" ws "-" we) :recipient actor})) + +;; ---- reschedule notifications ---- +;; When an event carries per-occurrence overrides (ev-with-override), every +;; attendee booked at the ORIGINAL start should be told the new time. Bookings +;; were made against the original occ-key (id@orig-start), so we read that +;; roster. Idempotency key encodes the original key and the new start, so +;; re-deriving the same reschedule never double-notifies. +(define + ev/reschedule-notifications + (fn + (b event) + (let + ((overrides (ev-or (get event :overrides) (list))) + (evid (get event :id)) + (dur (get event :duration))) + (reduce + (fn + (acc entry) + (let + ((orig-start (first entry)) + (ov (first (rest entry)))) + (let + ((occ (ev-occ evid orig-start dur)) + (new-start (get ov :start)) + (new-duration (get ov :duration))) + (let + ((key (ev-occ-key occ))) + (append + acc + (map + (fn + (actor) + {:id (str key "/reschedule/" new-start) + :recipient actor + :event evid + :old-start orig-start + :new-start new-start + :new-duration new-duration}) + (ev/roster-occ b occ))))))) + (list) + overrides)))) + +;; Project a reschedule notification to notify's (id recipient body) shape. +(define + ev/reschedule-notify->msg + (fn + (r) + (list + (get r :id) + (get r :recipient) + (list :rescheduled (get r :event) (get r :old-start) (get r :new-start))))) diff --git a/lib/events/scoreboard.json b/lib/events/scoreboard.json index 6979e992..cc185616 100644 --- a/lib/events/scoreboard.json +++ b/lib/events/scoreboard.json @@ -1,8 +1,8 @@ { "lang": "events", - "total_passed": 265, + "total_passed": 272, "total_failed": 0, - "total": 265, + "total": 272, "suites": [ {"name":"calendar","passed":51,"failed":0,"total":51}, {"name":"availability","passed":22,"failed":0,"total":22}, @@ -11,8 +11,8 @@ {"name":"booking-notify","passed":11,"failed":0,"total":11}, {"name":"ticket","passed":31,"failed":0,"total":31}, {"name":"notify","passed":7,"failed":0,"total":7}, - {"name":"reminders","passed":14,"failed":0,"total":14}, + {"name":"reminders","passed":21,"failed":0,"total":21}, {"name":"federation","passed":23,"failed":0,"total":23} ], - "generated": "2026-06-07T07:20:13+00:00" + "generated": "2026-06-07T07:46:42+00:00" } diff --git a/lib/events/scoreboard.md b/lib/events/scoreboard.md index 43e831d7..5bb66698 100644 --- a/lib/events/scoreboard.md +++ b/lib/events/scoreboard.md @@ -1,6 +1,6 @@ # events scoreboard -**265 / 265 passing** (0 failure(s)). +**272 / 272 passing** (0 failure(s)). | Suite | Passed | Total | Status | |-------|--------|-------|--------| @@ -11,5 +11,5 @@ | booking-notify | 11 | 11 | ok | | ticket | 31 | 31 | ok | | notify | 7 | 7 | ok | -| reminders | 14 | 14 | ok | +| reminders | 21 | 21 | ok | | federation | 23 | 23 | ok | diff --git a/lib/events/tests/reminders.sx b/lib/events/tests/reminders.sx index 8d0988d1..7e24bdad 100644 --- a/lib/events/tests/reminders.sx +++ b/lib/events/tests/reminders.sx @@ -208,6 +208,61 @@ (get empty-dig :items) (list))))))))) +;; ---- reschedule notifications ---- +(define + ev-rm-rs-run-all! + (fn + () + (let + ((b (persist/open)) + (ev (ev-event (quote yoga) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 20))) + (let + ((occ2 (ev-occ (quote yoga) (ev-dt 2026 6 2 9 0) 60))) + (do + (ev/book-occ! b (ev/add-event (ev/empty) ev) (quote nia) occ2) + (ev/book-occ! b (ev/add-event (ev/empty) ev) (quote ola) occ2) + ;; reschedule the Jun 2 occurrence to 14:00 / 90 min + (let + ((moved (ev-with-override ev (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 90))) + (let + ((ns (ev/reschedule-notifications b moved))) + (do + (ev-rm-check! + "every booked attendee is notified of the reschedule" + (map (fn (n) (get n :recipient)) ns) + (list (quote nia) (quote ola))) + (ev-rm-check! + "reschedule carries old and new start" + (list (get (first ns) :old-start) (get (first ns) :new-start)) + (list (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0))) + (ev-rm-check! + "reschedule carries the new duration" + (get (first ns) :new-duration) + 90) + (ev-rm-check! + "reschedule idempotency key encodes original key + new start" + (get (first ns) :id) + (str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0))) + (ev-rm-check! + "reschedule projects to notify wire shape" + (ev/reschedule-notify->msg (first ns)) + (list + (str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0)) + (quote nia) + (list :rescheduled (quote yoga) (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0))))))) + ;; an override on an occurrence nobody booked notifies no one + (let + ((moved2 (ev-with-override ev (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 3 10 0) 60))) + (ev-rm-check! + "rescheduling an unbooked occurrence notifies no one" + (len (ev/reschedule-notifications b moved2)) + 0)) + ;; an event with no overrides yields no reschedule notifications + (ev-rm-check! + "event without overrides has no reschedule notifications" + (len (ev/reschedule-notifications b ev)) + 0)))))) + (define ev-reminders-tests-run! (fn @@ -217,4 +272,5 @@ (set! ev-rm-fail 0) (set! ev-rm-failures (list)) (ev-rm-run-all!) + (ev-rm-rs-run-all!) {:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail}))) diff --git a/plans/events-on-sx.md b/plans/events-on-sx.md index 87881707..f1bd1f97 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` → **265/265** (Phases 1-4 + ext: fed free/busy, waitlist, EXDATE/RDATE, overrides, booking-notify) +`bash lib/events/conformance.sh` → **272/272** (Phases 1-4 + 6 ext: fed f/b, waitlist, EXDATE/RDATE, overrides, booking-notify, reschedule-notify) ## Ground rules @@ -86,6 +86,13 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ── ## Progress log +- 2026-06-07 — Reschedule notifications (extension). When an event carries + per-occurrence overrides, `ev/reschedule-notifications` reads the roster at + each overridden occurrence's ORIGINAL occ-key and produces a reschedule + message per booked attendee (old-start, new-start, new-duration). Idempotency + key = original-key/reschedule/new-start. `ev/reschedule-notify->msg` for the + notify wire shape. Combines overrides (calendar) + rosters (booking) + the + message-derivation pattern. +7 tests, 272/272 green. - 2026-06-07 — Booking lifecycle notifications (extension). `booking-notify.sx` walks the booking stream into ordered notifications classified by kind: :booked / :promoted / :held / :confirmed / :released / :cancelled /