Publish the resolved calendar as a subscribable iCal feed (#16) #120
No reviewers
Labels
No labels
area/ai
area/backend
area/frontend
area/infra
area/scheduler
area/wled
good-first-issue
priority/high
priority/low
priority/medium
type/bug
type/chore
type/ci-cd
type/docs
type/feature
type/qa
v1.0.0
v1.1.0
v1.2.0
v2.0.0
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
rbrooks/Iris-WLED!120
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/16-ical-feed"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #16.
GET /api/v1/export/calendar.icspublishes the resolved lighting calendar, so a phone can answer "what will the lights do on the 20th" without opening Iris.Shape
Days are resolved individually — priority, pins and shadowing can change which event wins from one day to the next — then coalesced, so consecutive days sharing an event and phase become one VEVENT rather than a wall of single-day rows. A multi-day event with an eve phase reads as two entries, which is what it actually does to the lights.
Days whose scheme isn't approved are included and flagged, not hidden. A day that will run nothing is exactly the thing worth spotting in a calendar whose job is to say what the lights will do.
The two acceptance criteria conflict, and how I resolved it
"Subscribable in a standard calendar client" and "access-controlled" can't both be satisfied by the session cookie: Google and Apple Calendar poll a URL and can't complete an OIDC flow.
Resolved the way Nextcloud and Radicale do — an install-wide feed token in the URL, revealed on request from Settings and regenerable. It's kept out of
GET /settingsdeliberately: it's a bearer credential, not a preference, and that payload is viewer-readable. Rotating revokes every subscriber at once. The endpoint also accepts a normal session so the UI can link to it.Two details worth noting in the auth path:
token_matchesdeliberately does not create a token, so an unauthenticated request can't bring a credential into existence as a side effect. There's a test for that._has_sessionmirrorsget_current_user's expiry check rather than just looking the row up. Without that a stale cookie would read the feed forever — a lower bar than every other endpoint applies.Validity is checked with a real parser
I added
icalendaras a dev-only dependency and parse the generated document back, rather than asserting against my own strings — which would only prove self-consistency, and "valid iCal feed" is the first acceptance criterion.That's what covers the parts of RFC 5545 that are easy to get quietly wrong:
DTEND— the classic hand-rolled-iCal off-by-one. A single-day event ends the following day; treat it as inclusive and every event renders a day short.🎄 × 60and a long accented name.;or,in an event name is read as a property separator and corrupts the document. Tested withBonfire Night; fireworks, backslashes and newlines.Sample output, showing the escaped
\;and Hanukkah's exclusiveDTEND:20261212(i.e. through the 11th):Verification
ruff,mypycleannode:22:tsc -b,eslint,vitest(76),npm run buildall cleanuvx pre-commit run --all-filespassesNothing here touches the controller.
🤖 Generated with Claude Code