Merge remote-tracking branch 'origin/loops/events' into arch-events-remerge
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 46s

This commit is contained in:
2026-07-03 22:03:48 +00:00
10 changed files with 1151 additions and 14 deletions

View File

@@ -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))))))

View File

@@ -19,6 +19,7 @@ PRELOADS=(
lib/datalog/magic.sx
lib/events/calendar.sx
lib/events/timezone.sx
lib/events/ical.sx
lib/events/availability.sx
lib/persist/event.sx
lib/persist/backend.sx
@@ -49,6 +50,7 @@ PRELOADS=(
SUITES=(
"calendar:lib/events/tests/calendar.sx:(ev-calendar-tests-run!)"
"timezone:lib/events/tests/timezone.sx:(ev-timezone-tests-run!)"
"ical:lib/events/tests/ical.sx:(ev-ical-tests-run!)"
"availability:lib/events/tests/availability.sx:(ev-availability-tests-run!)"
"api:lib/events/tests/api.sx:(ev-api-tests-run!)"
"booking:lib/events/tests/booking.sx:(ev-booking-tests-run!)"

482
lib/events/ical.sx Normal file
View File

@@ -0,0 +1,482 @@
;; lib/events/ical.sx — iCalendar (RFC 5545) export.
;;
;; Serializes events to VEVENT / VCALENDAR text so a rose-ash calendar can be
;; imported by any standard client (Google/Apple/Outlook). Datetimes are UTC
;; epoch-minutes, emitted as basic-format UTC stamps (YYYYMMDDTHHMM00Z). The
;; full RRULE / EXDATE / RDATE model maps directly to the standard properties.
;;
;; Export is line-oriented: `ev/event->ical-lines` returns the VEVENT as a list
;; of content lines (no folding/CRLF — easy to assert on); `ev/ical-render`
;; joins lines with CRLF, the on-the-wire format. Requires calendar.sx.
;; ---- formatting helpers ----
(define ev-ical-pad2 (fn (n) (if (< n 10) (str "0" n) (str n))))
(define
ev-ical-pad4
(fn
(n)
(cond
((< n 10) (str "000" n))
((< n 100) (str "00" n))
((< n 1000) (str "0" n))
(else (str n)))))
(define
ev-ical-nth
(fn
(xs i)
(if
(= i 0)
(first xs)
(ev-ical-nth (rest xs) (- i 1)))))
(define
ev-ical-join
(fn
(parts sep)
(if
(empty? parts)
""
(reduce (fn (acc p) (str acc sep p)) (first parts) (rest parts)))))
;; An epoch-minute as an iCal basic-format stamp (no zone suffix).
(define
ev-ical-dt-stamp
(fn
(t)
(let
((civ (ev-dt->civil t)) (tod (ev-dt-tod t)))
(str
(ev-ical-pad4 (ev-civ-y civ))
(ev-ical-pad2 (ev-civ-m civ))
(ev-ical-pad2 (ev-civ-d civ))
"T"
(ev-ical-pad2 (quotient tod 60))
(ev-ical-pad2 (modulo tod 60))
"00"))))
;; A UTC epoch-minute as a UTC stamp (trailing Z).
(define ev-ical-dt (fn (t) (str (ev-ical-dt-stamp t) "Z")))
;; A local epoch-minute as a floating/local stamp (no Z) — used with TZID.
(define ev-ical-dt-local ev-ical-dt-stamp)
;; A UTC offset in minutes as "+HHMM" / "-HHMM".
(define
ev-ical-offset
(fn
(mins)
(let
((a (abs mins)))
(str
(if (< mins 0) "-" "+")
(ev-ical-pad2 (quotient a 60))
(ev-ical-pad2 (modulo a 60))))))
;; A duration in minutes as an iCal DURATION value (PT#H#M).
(define
ev-ical-duration
(fn
(mins)
(let
((h (quotient mins 60)) (m (modulo mins 60)))
(cond
((and (> h 0) (> m 0)) (str "PT" h "H" m "M"))
((> h 0) (str "PT" h "H"))
(else (str "PT" m "M"))))))
(define
ev-ical-wd
(fn (w) (ev-ical-nth (list "MO" "TU" "WE" "TH" "FR" "SA" "SU") w)))
(define
ev-ical-freq
(fn
(f)
(cond
((= f :daily) "DAILY")
((= f :weekly) "WEEKLY")
((= f :monthly) "MONTHLY")
(else "DAILY"))))
;; One BYDAY token: a weekly weekday number -> "MO"; a monthly ordinal weekday
;; {:ord :wd} -> "2TU" / "-1FR".
(define
ev-ical-byday-token
(fn
(e)
(if
(dict? e)
(str (get e :ord) (ev-ical-wd (get e :wd)))
(ev-ical-wd e))))
;; UNTIL converter: per RFC 5545, even a TZID DTSTART requires UNTIL in UTC, so
;; a tz event converts its (local) UNTIL to UTC; a non-tz event passes through.
(define
ev-ical-conv
(fn
(event)
(let
((tz (get event :tz)))
(if (nil? tz) (fn (t) t) (fn (t) (ev-tz-local->utc tz t))))))
;; ---- VTIMEZONE ----
;; A tz event exports DTSTART;TZID=<name>:<local time> and the VCALENDAR carries
;; a VTIMEZONE block defining the zone's DST rules, so a client recurs at a
;; fixed WALL-CLOCK time (DST-correct) rather than fixed UTC.
;; A DST transition rule -> "FREQ=YEARLY;BYMONTH=<m>;BYDAY=<ord><WD>".
(define
ev-ical-vtz-rrule
(fn
(rule)
(str
"FREQ=YEARLY;BYMONTH="
(get rule :month)
";BYDAY="
(get rule :ord)
(ev-ical-wd (get rule :wd)))))
;; The transition's DTSTART (local time of the FROM offset) in a reference year.
(define
ev-ical-vtz-dtstart
(fn
(rule from-offset)
(let
((day (ev-resolve-nth-weekday 1970 (get rule :month) (get rule :ord) (get rule :wd))))
(ev-ical-dt-local
(+ (* (ev-days-from-civil 1970 (get rule :month) day) 1440)
(get rule :time)
from-offset)))))
;; The VTIMEZONE content lines for a zone (DAYLIGHT + STANDARD for :dst; a
;; single STANDARD for :fixed).
(define
ev-ical-vtimezone
(fn
(tz)
(if
(= (get tz :kind) :dst)
(let
((std (get tz :std-offset))
(dst (get tz :dst-offset))
(sr (get tz :dst-start))
(er (get tz :dst-end)))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:DAYLIGHT"
(str "DTSTART:" (ev-ical-vtz-dtstart sr std))
(str "TZOFFSETFROM:" (ev-ical-offset std))
(str "TZOFFSETTO:" (ev-ical-offset dst))
(str "RRULE:" (ev-ical-vtz-rrule sr))
"END:DAYLIGHT"
"BEGIN:STANDARD"
(str "DTSTART:" (ev-ical-vtz-dtstart er dst))
(str "TZOFFSETFROM:" (ev-ical-offset dst))
(str "TZOFFSETTO:" (ev-ical-offset std))
(str "RRULE:" (ev-ical-vtz-rrule er))
"END:STANDARD"
"END:VTIMEZONE"))
(list
"BEGIN:VTIMEZONE"
(str "TZID:" (get tz :name))
"BEGIN:STANDARD"
"DTSTART:19700101T000000"
(str "TZOFFSETFROM:" (ev-ical-offset (get tz :offset)))
(str "TZOFFSETTO:" (ev-ical-offset (get tz :offset)))
"END:STANDARD"
"END:VTIMEZONE"))))
;; ---- RRULE ----
(define
ev-ical-rrule
(fn
(rrule conv)
(let
((parts (list (str "FREQ=" (ev-ical-freq (get rrule :freq))))))
(begin
(when
(and
(not (nil? (get rrule :interval)))
(> (get rrule :interval) 1))
(append! parts (str "INTERVAL=" (get rrule :interval))))
(when
(not (nil? (get rrule :count)))
(append! parts (str "COUNT=" (get rrule :count))))
(when
(not (nil? (get rrule :until)))
(append! parts (str "UNTIL=" (ev-ical-dt (conv (get rrule :until))))))
(when
(not (nil? (get rrule :byday)))
(append!
parts
(str
"BYDAY="
(ev-ical-join (map ev-ical-byday-token (get rrule :byday)) ","))))
(when
(not (nil? (get rrule :bymonthday)))
(append!
parts
(str
"BYMONTHDAY="
(ev-ical-join
(map (fn (d) (str d)) (get rrule :bymonthday))
","))))
(str "RRULE:" (ev-ical-join parts ";"))))))
;; ---- VEVENT / VCALENDAR ----
;; The VEVENT content lines for an event (list of strings). A tz event uses
;; DTSTART;TZID=<name>:<local> (matched by a VTIMEZONE at the VCALENDAR level)
;; with EXDATE/RDATE in the same TZID-local form; UNTIL is always UTC. A non-tz
;; event uses UTC `Z` stamps throughout.
(define
ev/event->ical-lines
(fn
(event)
(let
((lines (list "BEGIN:VEVENT"))
(conv (ev-ical-conv event))
(tz (get event :tz)))
(let
((dtparam (if (nil? tz) "" (str ";TZID=" (get tz :name))))
(fmt (if (nil? tz) ev-ical-dt ev-ical-dt-local)))
(begin
(append! lines (str "UID:" (get event :id)))
(append! lines (str "SUMMARY:" (get event :id)))
(append! lines (str "DTSTART" dtparam ":" (fmt (get event :dtstart))))
(append!
lines
(str "DURATION:" (ev-ical-duration (get event :duration))))
(when
(not (nil? (get event :rrule)))
(append! lines (ev-ical-rrule (get event :rrule) conv)))
(when
(and
(not (nil? (get event :exdate)))
(> (len (get event :exdate)) 0))
(append!
lines
(str
"EXDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :exdate)) ","))))
(when
(and
(not (nil? (get event :rdate)))
(> (len (get event :rdate)) 0))
(append!
lines
(str
"RDATE"
dtparam
":"
(ev-ical-join (map fmt (get event :rdate)) ","))))
(append! lines "END:VEVENT")
lines)))))
;; Collect the distinct timezones used by a list of events (by :name).
(define
ev-ical-distinct-tzs
(fn
(events)
(reduce
(fn
(acc ev)
(let
((tz (get ev :tz)))
(if
(or (nil? tz) (ev-ical-tz-seen? acc (get tz :name)))
acc
(append acc (list tz)))))
(list)
events)))
(define
ev-ical-tz-seen?
(fn
(tzs name)
(cond
((empty? tzs) false)
((= (get (first tzs) :name) name) true)
(else (ev-ical-tz-seen? (rest tzs) name)))))
;; A full VCALENDAR (list of content lines): a VTIMEZONE block for each distinct
;; zone the events reference, then every VEVENT.
(define
ev/events->ical-lines
(fn
(events)
(let
((lines (list "BEGIN:VCALENDAR" "VERSION:2.0" "PRODID:-//rose-ash//events-on-sx//EN")))
(begin
(for-each
(fn
(tz)
(for-each (fn (l) (append! lines l)) (ev-ical-vtimezone tz)))
(ev-ical-distinct-tzs events))
(for-each
(fn
(ev)
(for-each (fn (l) (append! lines l)) (ev/event->ical-lines ev)))
events)
(append! lines "END:VCALENDAR")
lines))))
;; Render content lines to the on-the-wire iCalendar text (CRLF-separated).
(define ev/ical-render (fn (lines) (ev-ical-join lines "\r\n")))
;; ---- import (parse VEVENT/VCALENDAR back into events) ----
;; Inverse of the export above: parse iCalendar content lines into event dicts
;; (ev-event-full shape). Capacity is not an iCal property, so imported events
;; default to capacity 0 — set it after import if needed.
;; "20260601T180000Z" -> UTC epoch-minutes.
(define
ev-ical-parse-dt
(fn
(s)
(ev-dt
(string->number (substring s 0 4))
(string->number (substring s 4 6))
(string->number (substring s 6 8))
(string->number (substring s 9 11))
(string->number (substring s 11 13)))))
;; "30M" / "" -> minutes.
(define
ev-ical-parse-min
(fn
(s)
(if (= (string-length s) 0) 0 (string->number (first (split s "M"))))))
;; "PT1H30M" / "PT1H" / "PT30M" -> minutes.
(define
ev-ical-parse-duration
(fn
(s)
(let
((body (substring s 2 (string-length s))))
(let
((hparts (split body "H")))
(if
(> (len hparts) 1)
(+ (* 60 (string->number (first hparts))) (ev-ical-parse-min (first (rest hparts))))
(ev-ical-parse-min body))))))
(define
ev-ical-wd->num
(fn
(tok)
(cond
((= tok "MO") 0)
((= tok "TU") 1)
((= tok "WE") 2)
((= tok "TH") 3)
((= tok "FR") 4)
((= tok "SA") 5)
((= tok "SU") 6)
(else 0))))
;; "MO" -> 0 ; "2TU" -> {:ord 2 :wd 1} ; "-1FR" -> {:ord -1 :wd 4}
(define
ev-ical-parse-byday-token
(fn
(tok)
(let
((n (string-length tok)))
(if
(= n 2)
(ev-ical-wd->num tok)
{:ord (string->number (substring tok 0 (- n 2)))
:wd (ev-ical-wd->num (substring tok (- n 2) n))}))))
(define
ev-ical-parse-freq
(fn
(v)
(cond
((= v "DAILY") :daily)
((= v "WEEKLY") :weekly)
((= v "MONTHLY") :monthly)
(else :daily))))
;; "FREQ=WEEKLY;INTERVAL=2;UNTIL=...;BYDAY=MO,WE" -> rrule dict.
(define
ev-ical-parse-rrule
(fn
(val)
(let
((rr {}))
(begin
(for-each
(fn
(p)
(let
((kv (split p "=")))
(let
((k (first kv)) (v (first (rest kv))))
(cond
((= k "FREQ") (dict-set! rr :freq (ev-ical-parse-freq v)))
((= k "INTERVAL") (dict-set! rr :interval (string->number v)))
((= k "COUNT") (dict-set! rr :count (string->number v)))
((= k "UNTIL") (dict-set! rr :until (ev-ical-parse-dt v)))
((= k "BYDAY") (dict-set! rr :byday (map ev-ical-parse-byday-token (split v ","))))
((= k "BYMONTHDAY") (dict-set! rr :bymonthday (map string->number (split v ","))))
(else nil)))))
(split val ";"))
rr))))
;; Parse a VEVENT's content lines into an event dict.
(define
ev/ical-lines->event
(fn
(lines)
(let
((ev {:capacity 0 :rrule nil}) (exd (list)) (rd (list)))
(begin
(for-each
(fn
(line)
(let
((kv (split line ":")))
(when
(> (len kv) 1)
(let
;; strip any property parameters (e.g. ";TZID=...") from the key
((k (first (split (first kv) ";"))) (v (first (rest kv))))
(cond
((= k "UID") (dict-set! ev :id (string->symbol v)))
((= k "DTSTART") (dict-set! ev :dtstart (ev-ical-parse-dt v)))
((= k "DURATION") (dict-set! ev :duration (ev-ical-parse-duration v)))
((= k "RRULE") (dict-set! ev :rrule (ev-ical-parse-rrule v)))
((= k "EXDATE") (set! exd (map ev-ical-parse-dt (split v ","))))
((= k "RDATE") (set! rd (map ev-ical-parse-dt (split v ","))))
(else nil))))))
lines)
(dict-set! ev :exdate exd)
(dict-set! ev :rdate rd)
ev))))
;; Split a VCALENDAR line list into per-VEVENT line groups.
(define
ev-ical-group-vevents
(fn
(lines cur in acc)
(cond
((empty? lines) acc)
((= (first lines) "BEGIN:VEVENT") (ev-ical-group-vevents (rest lines) (list) true acc))
((= (first lines) "END:VEVENT") (ev-ical-group-vevents (rest lines) (list) false (append acc (list cur))))
(in (ev-ical-group-vevents (rest lines) (append cur (list (first lines))) true acc))
(else (ev-ical-group-vevents (rest lines) cur false acc)))))
;; Parse a VCALENDAR line list into a list of events.
(define
ev/parse-vcalendar
(fn
(lines)
(map ev/ical-lines->event (ev-ical-group-vevents lines (list) false (list)))))

View File

@@ -1,13 +1,14 @@
{
"lang": "events",
"total_passed": 311,
"total_passed": 391,
"total_failed": 0,
"total": 311,
"total": 391,
"suites": [
{"name":"calendar","passed":51,"failed":0,"total":51},
{"name":"timezone","passed":17,"failed":0,"total":17},
{"name":"timezone","passed":25,"failed":0,"total":25},
{"name":"ical","passed":63,"failed":0,"total":63},
{"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},
@@ -16,5 +17,5 @@
{"name":"federation","passed":29,"failed":0,"total":29},
{"name":"integration","passed":8,"failed":0,"total":8}
],
"generated": "2026-06-07T13:59:09+00:00"
"generated": "2026-06-10T22:03:34+00:00"
}

View File

@@ -1,13 +1,14 @@
# events scoreboard
**311 / 311 passing** (0 failure(s)).
**391 / 391 passing** (0 failure(s)).
| Suite | Passed | Total | Status |
|-------|--------|-------|--------|
| calendar | 51 | 51 | ok |
| timezone | 17 | 17 | ok |
| timezone | 25 | 25 | ok |
| ical | 63 | 63 | 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 |

View File

@@ -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})))

404
lib/events/tests/ical.sx Normal file
View File

@@ -0,0 +1,404 @@
;; lib/events/tests/ical.sx — iCalendar (RFC 5545) export.
(define ev-ic-pass 0)
(define ev-ic-fail 0)
(define ev-ic-failures (list))
(define
ev-ic-check!
(fn
(name got expected)
(if
(= got expected)
(set! ev-ic-pass (+ ev-ic-pass 1))
(do
(set! ev-ic-fail (+ ev-ic-fail 1))
(append!
ev-ic-failures
(str name "\n expected: " expected "\n got: " got))))))
;; Find the value of a "KEY:value" line in a VEVENT line list (or nil).
(define
ev-ic-line
(fn
(lines key)
(cond
((empty? lines) nil)
((ev-ic-prefix? (first lines) (str key ":")) (first lines))
(else (ev-ic-line (rest lines) key)))))
(define
ev-ic-prefix?
(fn
(s p)
(and (>= (len s) (len p)) (= (substring s 0 (len p)) p))))
(define
ev-ic-run-all!
(fn
()
(do
(let
((lines (ev/event->ical-lines (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1))))
(do
(ev-ic-check! "VEVENT opens" (first lines) "BEGIN:VEVENT")
(ev-ic-check! "VEVENT closes" (ev-ic-line lines "END") "END:VEVENT")
(ev-ic-check!
"UID is the event id"
(ev-ic-line lines "UID")
"UID:one")
(ev-ic-check!
"DTSTART is a UTC basic-format stamp"
(ev-ic-line lines "DTSTART")
"DTSTART:20260610T140000Z")
(ev-ic-check!
"DURATION of 60m is PT1H"
(ev-ic-line lines "DURATION")
"DURATION:PT1H")
(ev-ic-check!
"a one-off event has no RRULE"
(ev-ic-line lines "RRULE")
nil)))
(ev-ic-check!
"30m duration is PT30M"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote e)
(ev-dt 2026 1 1 9 0)
30
nil
1))
"DURATION")
"DURATION:PT30M")
(ev-ic-check!
"90m duration is PT1H30M"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote e)
(ev-dt 2026 1 1 9 0)
90
nil
1))
"DURATION")
"DURATION:PT1H30M")
(let
((lines (ev/event->ical-lines (ev-event-full (quote yoga) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :until (ev-dt 2026 6 30 23 0) :byday (list 0 2)} 20 (list (ev-dt 2026 6 8 18 0)) (list (ev-dt 2026 6 20 18 0))))))
(do
(ev-ic-check!
"weekly RRULE serializes interval/until/byday in order"
(ev-ic-line lines "RRULE")
"RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=20260630T230000Z;BYDAY=MO,WE")
(ev-ic-check!
"EXDATE line"
(ev-ic-line lines "EXDATE")
"EXDATE:20260608T180000Z")
(ev-ic-check!
"RDATE line"
(ev-ic-line lines "RDATE")
"RDATE:20260620T180000Z")))
(ev-ic-check!
"daily COUNT RRULE"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote d)
(ev-dt 2026 6 1 9 0)
30
{:freq :daily :count 5}
1))
"RRULE")
"RRULE:FREQ=DAILY;COUNT=5")
(ev-ic-check!
"monthly nth-weekday BYDAY (2nd Tuesday)"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 13 9 0)
60
{:freq :monthly :byday (list {:ord 2 :wd 1})}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYDAY=2TU")
(ev-ic-check!
"monthly last-Friday BYDAY"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 30 9 0)
60
{:freq :monthly :byday (list {:ord -1 :wd 4})}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYDAY=-1FR")
(ev-ic-check!
"monthly BYMONTHDAY (incl. negative)"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote b)
(ev-dt 2026 1 15 9 0)
60
{:bymonthday (list 15 -1) :freq :monthly}
5))
"RRULE")
"RRULE:FREQ=MONTHLY;BYMONTHDAY=15,-1")
(ev-ic-check!
"all seven weekday tokens map correctly"
(ev-ic-line
(ev/event->ical-lines
(ev-event
(quote w)
(ev-dt 2026 6 1 9 0)
30
{:freq :weekly :byday (list 0 1 2 3 4 5 6)}
1))
"RRULE")
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR,SA,SU")
(let
((cal (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 nil 1) (ev-event (quote b) (ev-dt 2026 6 2 9 0) 30 nil 1)))))
(do
(ev-ic-check! "VCALENDAR opens" (first cal) "BEGIN:VCALENDAR")
(ev-ic-check!
"VCALENDAR declares VERSION"
(ev-ic-line cal "VERSION")
"VERSION:2.0")
(ev-ic-check!
"two events -> two VEVENT blocks"
(len (filter (fn (l) (= l "BEGIN:VEVENT")) cal))
2)
(ev-ic-check!
"VCALENDAR has exactly one closing line"
(len (filter (fn (l) (= l "END:VCALENDAR")) cal))
1)))
(ev-ic-check!
"render joins lines with CRLF"
(ev/ical-render
(list "BEGIN:VCALENDAR" "VERSION:2.0" "END:VCALENDAR"))
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR"))))
;; ---- import + round-trip ----
;; The occurrence starts an event expands to over a fixed window.
(define
ev-ic-starts
(fn
(ev)
(map (fn (o) (get o :start)) (ev-expand ev (ev-date 2026 1 1) (ev-date 2027 1 1)))))
;; Round-trip an event through export then import; true if both expand alike.
(define
ev-ic-roundtrips?
(fn
(ev)
(= (ev-ic-starts ev) (ev-ic-starts (ev/ical-lines->event (ev/event->ical-lines ev))))))
(define
ev-ic-rt-run-all!
(fn
()
(do
;; ---- field parsers ----
(ev-ic-check! "parse DTSTART" (ev-ical-parse-dt "20260601T180000Z") (ev-dt 2026 6 1 18 0))
(ev-ic-check! "parse DURATION PT1H30M" (ev-ical-parse-duration "PT1H30M") 90)
(ev-ic-check! "parse DURATION PT1H" (ev-ical-parse-duration "PT1H") 60)
(ev-ic-check! "parse DURATION PT30M" (ev-ical-parse-duration "PT30M") 30)
(ev-ic-check! "parse plain BYDAY token" (ev-ical-parse-byday-token "MO") 0)
(ev-ic-check! "parse ordinal BYDAY token" (ev-ical-parse-byday-token "2TU") {:ord 2 :wd 1})
(ev-ic-check! "parse last-weekday BYDAY token" (ev-ical-parse-byday-token "-1FR") {:ord -1 :wd 4})
;; ---- imported event basic fields ----
(let
((ev (ev/ical-lines->event (ev/event->ical-lines (ev-event (quote yoga) (ev-dt 2026 6 1 18 0) 90 nil 1)))))
(do
(ev-ic-check! "imported id is a symbol" (get ev :id) (quote yoga))
(ev-ic-check! "imported dtstart" (get ev :dtstart) (ev-dt 2026 6 1 18 0))
(ev-ic-check! "imported duration" (get ev :duration) 90)))
;; ---- round-trips preserve the occurrence set ----
(ev-ic-check!
"round-trip: one-off event"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 10 14 0) 60 nil 1))
true)
(ev-ic-check!
"round-trip: daily COUNT"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1))
true)
(ev-ic-check!
"round-trip: weekly interval/until/byday + exdate + rdate"
(ev-ic-roundtrips?
(ev-event-full
(quote a)
(ev-dt 2026 6 1 18 0)
90
{:freq :weekly :interval 2 :byday (list 0 2) :until (ev-dt 2026 6 30 23 0)}
20
(list (ev-dt 2026 6 8 18 0))
(list (ev-dt 2026 6 20 18 0))))
true)
(ev-ic-check!
"round-trip: monthly nth-weekday"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 13 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1))
true)
(ev-ic-check!
"round-trip: monthly bymonthday"
(ev-ic-roundtrips? (ev-event (quote a) (ev-dt 2026 1 15 9 0) 60 {:freq :monthly :bymonthday (list 15 -1)} 1))
true)
;; ---- parse a VCALENDAR with several events ----
(let
((cal
(ev/events->ical-lines
(list
(ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)
(ev-event (quote b) (ev-dt 2026 6 2 10 0) 60 nil 1)))))
(let
((events (ev/parse-vcalendar cal)))
(do
(ev-ic-check! "VCALENDAR parses both events" (len events) 2)
(ev-ic-check! "first event id" (get (first events) :id) (quote a))
(ev-ic-check! "second event id" (get (first (rest events)) :id) (quote b))
(ev-ic-check!
"parsed events expand correctly"
(ev-ic-starts (first events))
(ev-ic-starts (ev-event (quote a) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1)))))))))
;; ---- timezone-aware export (TZID + VTIMEZONE) ----
(define
ev-ic-find
(fn
(lines pfx)
(cond
((empty? lines) nil)
((ev-ic-prefix? (first lines) pfx) (first lines))
(else (ev-ic-find (rest lines) pfx)))))
(define ev-ic-count (fn (lines x) (len (filter (fn (l) (= l x)) lines))))
(define
ev-ic-index
(fn
(lines x)
(cond
((empty? lines) -1)
((= (first lines) x) 0)
(else
(let ((r (ev-ic-index (rest lines) x))) (if (< r 0) -1 (+ 1 r)))))))
(define
ev-ic-tz-run-all!
(fn
()
(do
;; a tz event's DTSTART is local wall-clock with a TZID parameter
(ev-ic-check!
"tz event DTSTART uses TZID + local wall-clock (not UTC)"
(ev-ic-find (ev/event->ical-lines (ev-event-tz (quote w) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)) "DTSTART")
"DTSTART;TZID=Europe/London:20260715T180000")
(ev-ic-check!
"a non-tz event still uses a UTC Z stamp"
(ev-ic-find (ev/event->ical-lines (ev-event (quote n) (ev-dt 2026 7 15 18 0) 60 nil 1)) "DTSTART")
"DTSTART:20260715T180000Z")
;; UNTIL stays UTC even for a TZID event (RFC 5545)
(ev-ic-check!
"tz event RRULE UNTIL is still UTC"
(ev-ic-find
(ev/event->ical-lines
(ev-event-tz (quote s) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :byday (list 0) :until (ev-dt 2026 6 30 23 0)} 1 ev-tz-london))
"RRULE")
"RRULE:FREQ=WEEKLY;UNTIL=20260630T220000Z;BYDAY=MO")
;; EXDATE matches the DTSTART form (TZID + local)
(ev-ic-check!
"tz event EXDATE uses TZID + local"
(ev-ic-find
(ev/event->ical-lines
(assoc
(ev-event-tz (quote s) (ev-dt 2026 7 1 18 0) 60 {:freq :daily :count 3} 1 ev-tz-london)
:exdate
(list (ev-dt 2026 7 2 18 0))))
"EXDATE")
"EXDATE;TZID=Europe/London:20260702T180000")
;; ---- VTIMEZONE block ----
(let
((vtz (ev-ical-vtimezone ev-tz-london)))
(do
(ev-ic-check! "VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Europe/London")
(ev-ic-check! "DAYLIGHT transitions GMT->BST" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")
(ev-ic-check! "DAYLIGHT rule is last Sunday of March" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=3") "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU")
(ev-ic-check! "STANDARD rule is last Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU")))
(let
((vtz (ev-ical-vtimezone ev-tz-paris)))
(do
(ev-ic-check! "Paris DAYLIGHT goes to +0200 (CEST)" (ev-ic-find vtz "TZOFFSETTO:+0200") "TZOFFSETTO:+0200")
(ev-ic-check! "Paris STANDARD goes to +0100 (CET)" (ev-ic-find vtz "TZOFFSETTO:+0100") "TZOFFSETTO:+0100")))
;; southern hemisphere exports a valid VTIMEZONE too: reversed offsets,
;; first-Sunday rules, and the -480 rule time folds back to local 02:00/03:00
(let
((vtz (ev-ical-vtimezone ev-tz-sydney)))
(do
(ev-ic-check! "Sydney VTIMEZONE names the zone" (ev-ic-find vtz "TZID") "TZID:Australia/Sydney")
(ev-ic-check! "Sydney DAYLIGHT goes to +1100 (AEDT)" (ev-ic-find vtz "TZOFFSETTO:+1100") "TZOFFSETTO:+1100")
(ev-ic-check! "Sydney STANDARD goes to +1000 (AEST)" (ev-ic-find vtz "TZOFFSETTO:+1000") "TZOFFSETTO:+1000")
(ev-ic-check! "Sydney DAYLIGHT rule is first Sunday of October" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=10") "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU")
(ev-ic-check! "Sydney STANDARD rule is first Sunday of April" (ev-ic-find vtz "RRULE:FREQ=YEARLY;BYMONTH=4") "RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU")
(ev-ic-check! "Sydney DAYLIGHT begins 02:00 local (AEST std, -480 folded)" (ev-ic-find vtz "DTSTART") "DTSTART:19701004T020000")))
;; ---- VCALENDAR carries one VTIMEZONE per distinct zone ----
(let
((cal (ev/events->ical-lines (list (ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)))))
(do
(ev-ic-check! "VCALENDAR includes the referenced VTIMEZONE" (ev-ic-count cal "BEGIN:VTIMEZONE") 1)
(ev-ic-check! "VTIMEZONE precedes the VEVENT" (< (ev-ic-index cal "BEGIN:VTIMEZONE") (ev-ic-index cal "BEGIN:VEVENT")) true)))
(ev-ic-check!
"two events in the same zone share one VTIMEZONE"
(ev-ic-count
(ev/events->ical-lines
(list
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-london)))
"BEGIN:VTIMEZONE")
1)
(ev-ic-check!
"events in two zones get two VTIMEZONEs"
(ev-ic-count
(ev/events->ical-lines
(list
(ev-event-tz (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1 ev-tz-london)
(ev-event-tz (quote b) (ev-dt 2026 6 2 9 0) 60 nil 1 ev-tz-paris)))
"BEGIN:VTIMEZONE")
2)
(ev-ic-check!
"a non-tz-only calendar has no VTIMEZONE"
(ev-ic-count (ev/events->ical-lines (list (ev-event (quote a) (ev-dt 2026 6 1 9 0) 60 nil 1))) "BEGIN:VTIMEZONE")
0)
;; ---- import tolerates the TZID parameter ----
(ev-ic-check!
"import parses DTSTART;TZID local time"
(get
(ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 7 15 18 0) 60 nil 1 ev-tz-london)))
:dtstart)
(ev-dt 2026 7 15 18 0))
(ev-ic-check!
"import parses a southern-zone DTSTART;TZID local time"
(get
(ev/ical-lines->event (ev/event->ical-lines (ev-event-tz (quote a) (ev-dt 2026 1 15 18 0) 60 nil 1 ev-tz-sydney)))
:dtstart)
(ev-dt 2026 1 15 18 0)))))
(define
ev-ical-tests-run!
(fn
()
(do
(set! ev-ic-pass 0)
(set! ev-ic-fail 0)
(set! ev-ic-failures (list))
(ev-ic-run-all!)
(ev-ic-rt-run-all!)
(ev-ic-tz-run-all!)
{:failures ev-ic-failures :total (+ ev-ic-pass ev-ic-fail) :passed ev-ic-pass :failed ev-ic-fail})))

View File

@@ -76,6 +76,58 @@
ev-tz-paris
(ev-dt 2026 7 15 12 0))
120)
;; ---- southern hemisphere (reversed seasons) ----
(ev-tz-check!
"Sydney January offset is 660 (AEDT, summer DST)"
(ev-tz-offset
ev-tz-sydney
(ev-dt 2026 1 15 12 0))
660)
(ev-tz-check!
"Sydney July offset is 600 (AEST, winter std)"
(ev-tz-offset
ev-tz-sydney
(ev-dt 2026 7 15 12 0))
600)
(ev-tz-check!
"Sydney DST starts first Sunday of October"
(ev-dt->civil
(+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-start)) 480))
(list 2026 10 4))
(ev-tz-check!
"Sydney DST ends first Sunday of April"
(ev-dt->civil
(+ (ev-tz-transition 2026 (get ev-tz-sydney :dst-end)) 480))
(list 2026 4 5))
(ev-tz-check!
"09:00 Sydney in summer (AEDT) is previous-day 22:00 UTC"
(ev-tz-local->utc
ev-tz-sydney
(ev-dt 2026 1 15 9 0))
(ev-dt 2026 1 14 22 0))
(ev-tz-check!
"09:00 Sydney in winter (AEST) is previous-day 23:00 UTC"
(ev-tz-local->utc
ev-tz-sydney
(ev-dt 2026 7 15 9 0))
(ev-dt 2026 7 14 23 0))
(let
((au (ev-event-tz (quote au) (ev-dt 2026 4 3 9 0) 60 {:freq :daily :count 5} 8 ev-tz-sydney)))
(let
((occs (ev-expand au (ev-date 2026 3 25) (ev-date 2026 4 12))))
(do
(ev-tz-check!
"Sydney daily occurrences shift in UTC across the autumn DST end"
(map (fn (o) (ev-dt-tod (get o :start))) occs)
(list 1320 1320 1380 1380 1380))
(ev-tz-check!
"but every Sydney occurrence stays 09:00 local wall-clock"
(map
(fn
(o)
(first (rest (ev-tz-local-of ev-tz-sydney (get o :start)))))
occs)
(list 540 540 540 540 540)))))
(ev-tz-check!
"DST starts last Sunday of March"
(ev-dt->civil

View File

@@ -13,8 +13,11 @@
;; :fixed — a constant offset.
;; :dst — std/dst offsets + two transition rules. Transitions are given in
;; UTC (EU zones all switch at 01:00 UTC), so the offset at any UTC
;; instant is a direct range check; no recursion. Northern-hemisphere
;; ordering (dst-start < dst-end within a year) is assumed.
;; instant is a direct range check; no recursion. Both hemispheres
;; are supported: northern zones have dst-start < dst-end (DST is the
;; interval [start, end)); southern zones have dst-start > dst-end
;; (DST wraps the year boundary), detected by comparing the two
;; transitions — see ev-tz-offset.
;;
;; Requires calendar.sx (ev-dt, ev-days-from-civil, ev-civil-from-days,
;; ev-civ-y, ev-floor-div, ev-resolve-nth-weekday).
@@ -58,10 +61,20 @@
(let
((start (ev-tz-transition year (get tz :dst-start)))
(end (ev-tz-transition year (get tz :dst-end))))
;; Northern hemisphere: dst-start < dst-end, DST is the closed-open
;; interval [start, end). Southern hemisphere: dst-start > dst-end
;; (DST begins in spring ~Oct and ends ~Apr), so within a calendar
;; year DST wraps the boundary — active OUTSIDE [end, start).
(if
(and (>= utc-dt start) (< utc-dt end))
(get tz :dst-offset)
(get tz :std-offset)))))
(< start end)
(if
(and (>= utc-dt start) (< utc-dt end))
(get tz :dst-offset)
(get tz :std-offset))
(if
(or (>= utc-dt start) (< utc-dt end))
(get tz :dst-offset)
(get tz :std-offset))))))
(else 0))))
;; UTC instant -> local wall-clock.
@@ -98,6 +111,19 @@
120
(ev-tz-rule 3 -1 6 60)
(ev-tz-rule 10 -1 6 60)))
;; Southern hemisphere: AEST +600 (std, winter), AEDT +660 (dst, summer). DST
;; begins 02:00 AEST first Sunday October and ends 03:00 AEDT first Sunday April
;; — both 16:00 UTC the preceding Saturday, i.e. -480 minutes from the Sunday in
;; the rule (the model adds rule :time to the resolved weekday's UTC midnight).
;; dst-start (Oct) > dst-end (Apr), so ev-tz-offset takes the wrap-the-year path.
(define
ev-tz-sydney
(ev-tz-dst
"Australia/Sydney"
600
660
(ev-tz-rule 10 1 6 -480)
(ev-tz-rule 4 1 6 -480)))
;; ---- tz-aware event expansion ----