events: reminders + digests from the agenda + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
reminders.sx bridges calendar + durable rosters to notify: ev/occurrence- reminders (one per booked attendee, fires lead before start, idempotency key occ-key/recipient/lead), ev/agenda-reminders (sorted by fire-at), ev/due-reminders (fire-at <= now), ev/reminder->msg (notify wire shape), ev/agenda-digest + ev/agenda-for-p. 196/196 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,7 @@ PRELOADS=(
|
||||
lib/flow/api.sx
|
||||
lib/events/notify.sx
|
||||
lib/events/api.sx
|
||||
lib/events/reminders.sx
|
||||
)
|
||||
|
||||
SUITES=(
|
||||
@@ -49,4 +50,5 @@ SUITES=(
|
||||
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"
|
||||
"ticket:lib/events/tests/ticket.sx:(ev-ticket-tests-run!)"
|
||||
"notify:lib/events/tests/notify.sx:(ev-notify-tests-run!)"
|
||||
"reminders:lib/events/tests/reminders.sx:(ev-reminders-tests-run!)"
|
||||
)
|
||||
|
||||
96
lib/events/reminders.sx
Normal file
96
lib/events/reminders.sx
Normal file
@@ -0,0 +1,96 @@
|
||||
;; lib/events/reminders.sx — derive reminder + digest messages from the agenda.
|
||||
;;
|
||||
;; Bridges the schedule (calendar) and the durable roster (booking on persist)
|
||||
;; to the notification layer (notify.sx). For each booked attendee of each
|
||||
;; upcoming occurrence we derive a reminder message that fires `lead` minutes
|
||||
;; before the occurrence starts. Each message has a deterministic idempotency
|
||||
;; key — occ-key / recipient / lead — so re-deriving over an overlapping window
|
||||
;; never produces a duplicate ping (the notify transport dedups on this id).
|
||||
;;
|
||||
;; A reminder is a dict:
|
||||
;; {:id :recipient :event :start :fire-at}
|
||||
;; `ev/reminder->msg` projects it to notify's (id recipient body) wire shape.
|
||||
|
||||
;; Reminders for one occurrence: one per booked attendee (durable roster).
|
||||
(define
|
||||
ev/occurrence-reminders
|
||||
(fn
|
||||
(b occ lead)
|
||||
(let
|
||||
((occ-key (ev-occ-key occ))
|
||||
(start (get occ :start))
|
||||
(evid (get occ :id)))
|
||||
(map (fn (actor) {:id (str occ-key "/" actor "/" lead) :event evid :start start :fire-at (- start lead) :recipient actor}) (ev/roster-occ b occ)))))
|
||||
|
||||
;; Insertion sort of reminder dicts ascending by :fire-at (then :id for ties).
|
||||
(define
|
||||
ev-rem-before?
|
||||
(fn
|
||||
(a c)
|
||||
(cond
|
||||
((< (get a :fire-at) (get c :fire-at)) true)
|
||||
((> (get a :fire-at) (get c :fire-at)) false)
|
||||
(else (< (get a :id) (get c :id))))))
|
||||
|
||||
(define
|
||||
ev-rem-insert
|
||||
(fn
|
||||
(r sorted)
|
||||
(cond
|
||||
((empty? sorted) (list r))
|
||||
((ev-rem-before? r (first sorted)) (cons r sorted))
|
||||
(else (cons (first sorted) (ev-rem-insert r (rest sorted)))))))
|
||||
|
||||
(define
|
||||
ev-rem-sort
|
||||
(fn (rs) (reduce (fn (acc r) (ev-rem-insert r acc)) (list) rs)))
|
||||
|
||||
;; All reminders across the agenda in [ws, we), ascending by fire-at.
|
||||
(define
|
||||
ev/agenda-reminders
|
||||
(fn
|
||||
(b store ws we lead)
|
||||
(let
|
||||
((acc (list)))
|
||||
(begin
|
||||
(for-each
|
||||
(fn
|
||||
(occ)
|
||||
(for-each
|
||||
(fn (r) (append! acc r))
|
||||
(ev/occurrence-reminders b occ lead)))
|
||||
(ev/agenda store ws we))
|
||||
(ev-rem-sort acc)))))
|
||||
|
||||
;; Reminders whose fire-at has arrived (fire-at <= now) — what a scheduler
|
||||
;; should hand to the notify transport at time `now`.
|
||||
(define
|
||||
ev/due-reminders
|
||||
(fn
|
||||
(reminders now)
|
||||
(filter (fn (r) (<= (get r :fire-at) now)) reminders)))
|
||||
|
||||
;; Project a reminder to notify's (id recipient body) wire shape.
|
||||
(define
|
||||
ev/reminder->msg
|
||||
(fn
|
||||
(r)
|
||||
(list
|
||||
(get r :id)
|
||||
(get r :recipient)
|
||||
(list :reminder (get r :event) (get r :start)))))
|
||||
|
||||
;; ---- digests ----
|
||||
|
||||
;; The occurrences `actor` is booked into (durable roster), within window.
|
||||
(define
|
||||
ev/agenda-for-p
|
||||
(fn
|
||||
(b store actor ws we)
|
||||
(filter
|
||||
(fn (occ) (ev-bk-member? actor (ev/roster-occ b occ)))
|
||||
(ev/agenda store ws we))))
|
||||
|
||||
;; 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}))
|
||||
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"lang": "events",
|
||||
"total_passed": 182,
|
||||
"total_passed": 196,
|
||||
"total_failed": 0,
|
||||
"total": 182,
|
||||
"total": 196,
|
||||
"suites": [
|
||||
{"name":"calendar","passed":37,"failed":0,"total":37},
|
||||
{"name":"availability","passed":22,"failed":0,"total":22},
|
||||
{"name":"api","passed":24,"failed":0,"total":24},
|
||||
{"name":"booking","passed":61,"failed":0,"total":61},
|
||||
{"name":"ticket","passed":31,"failed":0,"total":31},
|
||||
{"name":"notify","passed":7,"failed":0,"total":7}
|
||||
{"name":"notify","passed":7,"failed":0,"total":7},
|
||||
{"name":"reminders","passed":14,"failed":0,"total":14}
|
||||
],
|
||||
"generated": "2026-06-07T04:02:26+00:00"
|
||||
"generated": "2026-06-07T04:34:36+00:00"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# events scoreboard
|
||||
|
||||
**182 / 182 passing** (0 failure(s)).
|
||||
**196 / 196 passing** (0 failure(s)).
|
||||
|
||||
| Suite | Passed | Total | Status |
|
||||
|-------|--------|-------|--------|
|
||||
@@ -10,3 +10,4 @@
|
||||
| booking | 61 | 61 | ok |
|
||||
| ticket | 31 | 31 | ok |
|
||||
| notify | 7 | 7 | ok |
|
||||
| reminders | 14 | 14 | ok |
|
||||
|
||||
220
lib/events/tests/reminders.sx
Normal file
220
lib/events/tests/reminders.sx
Normal file
@@ -0,0 +1,220 @@
|
||||
;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda.
|
||||
|
||||
(define ev-rm-pass 0)
|
||||
(define ev-rm-fail 0)
|
||||
(define ev-rm-failures (list))
|
||||
|
||||
(define
|
||||
ev-rm-check!
|
||||
(fn
|
||||
(name got expected)
|
||||
(if
|
||||
(= got expected)
|
||||
(set! ev-rm-pass (+ ev-rm-pass 1))
|
||||
(do
|
||||
(set! ev-rm-fail (+ ev-rm-fail 1))
|
||||
(append!
|
||||
ev-rm-failures
|
||||
(str name "\n expected: " expected "\n got: " got))))))
|
||||
|
||||
;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off
|
||||
;; talk; durable bookings on a persist backend.
|
||||
(define
|
||||
ev-rm-store
|
||||
(fn
|
||||
()
|
||||
(ev/schedule
|
||||
(ev/schedule
|
||||
(ev/empty)
|
||||
(quote yoga)
|
||||
(ev-dt 2026 6 1 18 0)
|
||||
60
|
||||
{:freq :weekly :count 4 :byday (list 0 2)}
|
||||
20)
|
||||
(quote talk)
|
||||
(ev-dt 2026 6 2 12 0)
|
||||
30
|
||||
nil
|
||||
50)))
|
||||
|
||||
(define
|
||||
ev-rm-run-all!
|
||||
(fn
|
||||
()
|
||||
(let
|
||||
((store (ev-rm-store)) (b (persist/open)))
|
||||
(let
|
||||
((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(do
|
||||
(ev/book-occ! b store (quote nia) (first occs))
|
||||
(ev/book-occ! b store (quote ola) (first occs))
|
||||
(ev/book-occ!
|
||||
b
|
||||
store
|
||||
(quote ola)
|
||||
(ev-occ
|
||||
(quote talk)
|
||||
(ev-dt 2026 6 2 12 0)
|
||||
30))
|
||||
(do
|
||||
(let
|
||||
((rs (ev/occurrence-reminders b (first occs) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"one reminder per booked attendee"
|
||||
(len rs)
|
||||
2)
|
||||
(ev-rm-check!
|
||||
"reminder fires lead minutes before start"
|
||||
(get (first rs) :fire-at)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60))
|
||||
(ev-rm-check!
|
||||
"reminder idempotency key encodes occ/recipient/lead"
|
||||
(get (first rs) :id)
|
||||
(str
|
||||
(ev-occ-key (first occs))
|
||||
"/"
|
||||
(quote nia)
|
||||
"/"
|
||||
60))
|
||||
(ev-rm-check!
|
||||
"reminder names the event"
|
||||
(get (first rs) :event)
|
||||
(quote yoga))))
|
||||
(ev-rm-check!
|
||||
"unbooked occurrence has no reminders"
|
||||
(len
|
||||
(ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60))
|
||||
0)
|
||||
(let
|
||||
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"agenda reminders cover all bookings"
|
||||
(len all)
|
||||
3)
|
||||
(ev-rm-check!
|
||||
"agenda reminders sorted by fire-at"
|
||||
(map (fn (r) (get r :fire-at)) all)
|
||||
(list
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)
|
||||
60)
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
12
|
||||
0)
|
||||
60)))))
|
||||
(let
|
||||
((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60)))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"nothing due before the first fire-at"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(-
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
17
|
||||
0)
|
||||
1)))
|
||||
0)
|
||||
(ev-rm-check!
|
||||
"the two yoga reminders are due at 17:00"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
17
|
||||
0)))
|
||||
2)
|
||||
(ev-rm-check!
|
||||
"all reminders due once past the last fire-at"
|
||||
(len
|
||||
(ev/due-reminders
|
||||
all
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
2
|
||||
12
|
||||
0)))
|
||||
3)))
|
||||
(let
|
||||
((r (first (ev/occurrence-reminders b (first occs) 60))))
|
||||
(ev-rm-check!
|
||||
"reminder projects to (id recipient body)"
|
||||
(ev/reminder->msg r)
|
||||
(list
|
||||
(str
|
||||
(ev-occ-key (first occs))
|
||||
"/"
|
||||
(quote nia)
|
||||
"/"
|
||||
60)
|
||||
(quote nia)
|
||||
(list
|
||||
:reminder (quote yoga)
|
||||
(ev-dt
|
||||
2026
|
||||
6
|
||||
1
|
||||
18
|
||||
0)))))
|
||||
(let
|
||||
((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(do
|
||||
(ev-rm-check!
|
||||
"digest is addressed to the actor"
|
||||
(get dig :recipient)
|
||||
(quote ola))
|
||||
(ev-rm-check!
|
||||
"digest lists the actor's booked occurrences"
|
||||
(map (fn (it) (get it :event)) (get dig :items))
|
||||
(list (quote yoga) (quote talk)))))
|
||||
(let
|
||||
((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1))))
|
||||
(ev-rm-check!
|
||||
"digest empty for an actor with no bookings"
|
||||
(get empty-dig :items)
|
||||
(list)))))))))
|
||||
|
||||
(define
|
||||
ev-reminders-tests-run!
|
||||
(fn
|
||||
()
|
||||
(do
|
||||
(set! ev-rm-pass 0)
|
||||
(set! ev-rm-fail 0)
|
||||
(set! ev-rm-failures (list))
|
||||
(ev-rm-run-all!)
|
||||
{:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))
|
||||
@@ -18,7 +18,7 @@ capacity rules, transactional booking, and a flow-driven notification dispatcher
|
||||
|
||||
## Status (rolling)
|
||||
|
||||
`bash lib/events/conformance.sh` → **182/182** (Phases 1-2 + Phase 3 notification delivery flows)
|
||||
`bash lib/events/conformance.sh` → **196/196** (Phases 1-2 + Phase 3: notification flows + reminders)
|
||||
|
||||
## Ground rules
|
||||
|
||||
@@ -73,7 +73,7 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
- [x] `notify.sx` — reminder/digest flows over injected transport
|
||||
- [x] retry/backoff on transport failure (flow suspend/resume)
|
||||
- [x] tests: delivery success, retry path, idempotent re-send
|
||||
- [ ] wire reminders to occurrences (schedule "starts in 1h" from agenda)
|
||||
- [x] wire reminders to occurrences (`reminders.sx` — derive from agenda + roster)
|
||||
- [ ] NOTE: shared with `feed/notify` — candidate for later extraction to a
|
||||
`delivery-on-sx` once a second consumer is real. **Delivery core
|
||||
(request→dispatch→resume, idempotent, bounded retry) is the extraction seam.**
|
||||
@@ -84,6 +84,14 @@ lib/events/api.sx ── (events/schedule) (events/book) (events/agenda) ──
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-06-07 — Reminders + digests from the agenda. `reminders.sx` bridges
|
||||
calendar + durable rosters to notify: `ev/occurrence-reminders` (one per
|
||||
booked attendee, fires `lead` before start, idempotency key
|
||||
occ-key/recipient/lead), `ev/agenda-reminders` (window-wide, sorted by
|
||||
fire-at), `ev/due-reminders` (fire-at ≤ now — the scheduler query),
|
||||
`ev/reminder->msg` (projects to notify's (id recipient body) shape),
|
||||
`ev/agenda-digest` + `ev/agenda-for-p` (an actor's upcoming booked
|
||||
occurrences). +14 tests, 196/196 green.
|
||||
- 2026-06-07 — **Phase 3 start: notification delivery flows.** `notify.sx`:
|
||||
reminders + digests as durable `flow`s over an INJECTED transport (the host
|
||||
`dispatch`). A flow `request`s delivery (suspend), the host sends and resumes
|
||||
|
||||
Reference in New Issue
Block a user