Files
connor fd298f4977
Check / guardrails (push) Successful in 1m27s
Check / bundle (push) Successful in 1m24s
Image / image (push) Successful in 2m39s
Check / check (push) Failing after 1m4s
Add and change what the sidebar lists
Every one of these calls has existed in the typed client since M9 and M10 and
had nothing wired to it: create, delete and update for calendars, subscribe,
update and unsubscribe for feeds. The only field the interface ever sent was
`visible`, from the checkbox. So a person could see the calendars on their
server and hide them, and could not make one, rename one, recolour one, reorder
them, or subscribe to anything at all -- the "Subscribed" section did not even
draw unless a feed was already in the database, which no interface could put
there. M14 was scoped as "calendar list, visibility toggles" and nothing after
it picked up the rest.

It goes next to the list rather than into Settings. The sidebar already is the
list of sources; Settings is about the person reading them. A calendar's name
and colour are neither -- they belong to the calendar, they are written to the
CalDAV server, and every other client sees them.

That distinction needed one thing from the API. `color_source` was already
reported so the interface could offer "use the calendar's own colour", but the
colour itself was only ever sent after the override was applied, so an editor
had no way to know what the calendar's own colour was. One control for both
would have written somebody's private override onto the shared calendar the
first time they saved anything. `CalendarView` now carries `own_color` beside
`color`, and the editor shows them as the two different things they are.

Reordering sends only the rows that actually move. Each one is a PATCH and a
round trip to the CalDAV server, and "no position at all" is a different state
from "position 0" -- so the first move on a fresh account writes every row and
every move after it writes two. The arithmetic for that is pure and tested, as
is turning a CalDAV colour into something a colour input will accept: Apple's
`calendar-color` is `#RRGGBB` or `#RRGGBBAA`, and a control with no notion of
alpha reads the eight-digit form as garbage and silently shows black.

Deleting a calendar destroys everything in it and there is no undo underneath,
so the menu item leads into the editor with its confirmation already showing
rather than doing it from the menu. The menu offers what exists: a calendar at
the top of the list is not offered "Move up".

The browser checks in `shoot.mjs` cover the whole round trip against a real
server -- made, coloured, renamed, deleted -- and that a link which cannot work
is refused before it costs a request. They cannot run yet: the file stops at
its all-day overflow assertion, because `seed.py` hard-codes the week of
2026-08-24 and that week has now passed. That is a separate problem and older
than this change.
2026-08-31 14:54:27 -04:00

5.4 KiB

Runway

Passive infrastructure for life's coordination.

A CalDAV web client in Rust — Leptos/WASM frontend, Axum backend, speaking to any RFC-compliant CalDAV server (developed against Baikal).

This is a ground-up rewrite. Its predecessor lives in ../calendar; the reasons it was replaced, and the feature-by-feature decisions that shaped this one, are recorded in docs/legacy-audit.md — that document is the spec.

Layout

Crate Role
runway-core RFC 5545 model, iCalendar round-trip, recurrence expansion. Pure, no I/O.
runway-caldav CalDAV client: discovery, time-ranged queries, ETag-aware CRUD.
runway-server Axum backend: CalDAV proxy, sessions, preferences, ICS feeds.
runway-web Leptos CSR frontend.
runway-cli Smoke tool for exercising a real CalDAV server from the terminal.

runway-core is feature-gated (model / ical / recurrence) so the frontend gets the shared types without pulling the parser and RRULE engine into the WASM bundle.

Development

The toolchain is pinned in rust-toolchain.toml and installs automatically on first use.

cargo check --workspace
cargo test --workspace
cargo clippy --workspace -- -D warnings

The CalDAV integration tests need a server and are skipped without one. This starts a throwaway Baikal in a container, installs it, runs them against it, and tears it down:

crates/runway-caldav/tests/baikal/run.sh

Running the backend needs an encryption key for stored CalDAV credentials. It must stay the same across restarts — a key invented at startup would silently make every saved credential unreadable — so the server refuses to start without one rather than generating a throwaway:

export RUNWAY_SECRET_KEY=$(cargo run -q -p runway-server -- genkey)
export RUNWAY_DATABASE_URL=sqlite:runway.db     # default
export RUNWAY_BIND=0.0.0.0:3000                 # default
export RUNWAY_INSECURE_COOKIES=1                # local HTTP only

cargo run -p runway-server

Frontend

npm install                       # Tailwind v4 and the browser tooling
cd crates/runway-web && trunk serve

Trunk serves on :8080 and proxies /api to the backend on :3000, so the session cookie is same-origin in development exactly as in production — nothing has to be weakened to make local work.

The whole stack, for looking at — a throwaway Baikal seeded with a week of events, the backend, and the frontend:

npx playwright install chromium   # once
e2e/dev.sh up                     # → http://127.0.0.1:8080, testuser/testpassword
HOST=0.0.0.0 e2e/dev.sh up        # ...and reachable from another machine
e2e/dev.sh shoot                  # screenshots + anything the console said
e2e/dev.sh down

Only the frontend binds to HOST; the backend stays on the loopback, because Trunk proxies /api to it from the server side. HOST=0.0.0.0 is for looking at the app from another machine on a network you trust — the dev stack runs with RUNWAY_INSECURE_COOKIES=1 and a throwaway calendar server.

Driving a real browser is the loop v1 never had, and the reason its week grid could only be checked by building, deploying and squinting. shoot.mjs also asserts: that hiding a calendar removes its events, that a calendar made from the sidebar reaches the CalDAV server and can be renamed and deleted there again, and that every preference survives a reload.

Some tests can be pointed at a whole real calendar rather than the committed fixtures:

RUNWAY_TEST_FEED_FILE=/path/to/calendar.ics cargo test -p runway-server --test feeds

The CLI drives the same stack the app does, which makes it the quickest way to tell a display bug from a data one:

export RUNWAY_CALDAV_URL=https://example.com/dav.php/
export RUNWAY_CALDAV_USER=you
export RUNWAY_CALDAV_PASSWORD=...        # from the environment, not a flag

cargo run -p runway-cli -- calendars
cargo run -p runway-cli -- list-events --from 2026-08-24 --to 2026-08-31

Deployment

One image holds both halves — the server binary and the built frontend — so they cannot be deployed out of step with each other. CI builds and pushes it on every merge to main; a timer on the server picks it up. Nothing goes up from a laptop.

deploy/runway-update          # what the timer runs, if you would rather not wait

The full arrangement, and the one-time setup it needs, is in deploy/README.md.

Principles

Carried over from the audit, and enforced by lints and CI rather than by good intentions:

  • One representation of an event. VEvent is the model and the wire format. No parallel DTOs.
  • Use the library. Recurrence is rrule, iCalendar is icalendar, XML is quick-xml.
  • Fix the parse, not the output. Never post-process data to paper over a bad parse.
  • State lives where it's owned. No localStorage-as-global, no prop-drilling megacomponents.
  • Four token axes, none of which can see the others. data-palette x data-mode x data-shape x data-density on <html>. A palette is seven numbers and every colour is derived from them down a ladder all palettes share, so contrast is a property of the system rather than of each palette's luck. A test rejects any rule selected on two axes at once -- that is the mechanism by which v1's two axes became 36 hand-written stylesheets.
  • Test thoroughly. Real servers over mocks; the router under test is the router that ships.