Publish the resolved calendar as a subscribable iCal feed (#16) #120

Merged
claude-bot merged 1 commit from feat/16-ical-feed into main 2026-09-04 22:01:42 +00:00
Contributor

Closes #16.

GET /api/v1/export/calendar.ics publishes 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 /settings deliberately: 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_matches deliberately 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_session mirrors get_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 icalendar as 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:

  • Exclusive 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.
  • 75-octet line folding — folding counts bytes, not characters, so a naive split can cut a UTF-8 sequence in half and produce a document that won't decode at all. Tested with 🎄 × 60 and a long accented name.
  • Escaping — an unescaped ; or , in an event name is read as a property separator and corrupts the document. Tested with Bonfire Night; fireworks, backslashes and newlines.

Sample output, showing the escaped \; and Hanukkah's exclusive DTEND:20261212 (i.e. through the 11th):

BEGIN:VEVENT
UID:hanukkah_2026-mid-2026-12-04@iris
DTSTART;VALUE=DATE:20261204
DTEND;VALUE=DATE:20261212
SUMMARY:Hanukkah (mid)
DESCRIPTION:Phase: mid\nScheme: hanukkah_2026_mid\nApproved scheme
END:VEVENT
...
SUMMARY:Christmas Day\; lights (peak)

Verification

  • 450 backend tests pass (35 new), ruff, mypy clean
  • Frontend in node:22: tsc -b, eslint, vitest (76), npm run build all clean
  • uvx pre-commit run --all-files passes

Nothing here touches the controller.

🤖 Generated with Claude Code

Closes #16. `GET /api/v1/export/calendar.ics` publishes 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 /settings` deliberately: 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_matches` deliberately 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_session` mirrors `get_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 `icalendar` as 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: - **Exclusive `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. - **75-*octet* line folding** — folding counts bytes, not characters, so a naive split can cut a UTF-8 sequence in half and produce a document that won't decode at all. Tested with `🎄 × 60` and a long accented name. - **Escaping** — an unescaped `;` or `,` in an event name is read as a property separator and corrupts the document. Tested with `Bonfire Night; fireworks`, backslashes and newlines. Sample output, showing the escaped `\;` and Hanukkah's exclusive `DTEND:20261212` (i.e. through the 11th): ``` BEGIN:VEVENT UID:hanukkah_2026-mid-2026-12-04@iris DTSTART;VALUE=DATE:20261204 DTEND;VALUE=DATE:20261212 SUMMARY:Hanukkah (mid) DESCRIPTION:Phase: mid\nScheme: hanukkah_2026_mid\nApproved scheme END:VEVENT ... SUMMARY:Christmas Day\; lights (peak) ``` ### Verification - 450 backend tests pass (35 new), `ruff`, `mypy` clean - Frontend in `node:22`: `tsc -b`, `eslint`, `vitest` (76), `npm run build` all clean - `uvx pre-commit run --all-files` passes Nothing here touches the controller. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Publish the resolved calendar as a subscribable iCal feed (#16)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 10s
CI / Pre-commit hooks (pull_request) Successful in 46s
CI / Alembic migration check (pull_request) Successful in 52s
CI / Python lint & type-check (pull_request) Successful in 1m0s
CI / Frontend lint, test & build (pull_request) Successful in 1m37s
CI / Python tests (pull_request) Successful in 3m24s
CI / Docker build, health smoke & E2E (pull_request) Successful in 3m7s
b211e8b9f4
GET /api/v1/export/calendar.ics, so a phone can answer "what will the
lights do on the 20th" without opening Iris.

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. Days whose scheme is not approved are included
and flagged rather than omitted; a day that will run nothing is worth
seeing in a calendar whose job is to say what the lights will do.

The two acceptance criteria pull against each other: a standard calendar
client polls a URL and cannot complete an OIDC flow, so "subscribable"
and "access-controlled" cannot both be satisfied by the session cookie.
Resolved the way Nextcloud and Radicale do -- an install-wide feed token
in the URL, revealed on request from Settings and regenerable, and kept
out of GET /settings because it is a bearer credential rather than a
preference. Rotating it revokes every subscriber at once. The endpoint
accepts a session too, so the UI can link to it.

Validity is checked by parsing the output back with a real iCal library
(a dev-only dependency) rather than matching our own strings, which would
only prove self-consistency. That covers the parts of RFC 5545 that are
easy to get quietly wrong: escaping, 75-OCTET line folding that must not
split a multi-byte character, and the exclusive DTEND that makes every
event render a day short if treated as inclusive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/16-ical-feed 2026-09-04 22:01:43 +00:00
Sign in to join this conversation.
No description provided.