connor c36d52ce25 Add the detail panel and the context menus
Clicking an event shows everything it holds -- which is a lot more than v1
ever showed, because v1's wire format flattened attendees and categories to
comma-separated strings and dropped the rest of the RFC on the way out. Here
the whole VEvent arrives intact, so the panel shows all of it, and only the
parts that are there: a panel of empty rows is a panel nobody reads.

That meant saying RFC 5545 out loud. FREQ=MONTHLY;BYDAY=1MO and
TRIGGER;RELATED=END:-PT1H are exact and unreadable, so there is a module that
turns them into English -- and it says only what it can say. A rule with a
part it does not understand comes back as the rule itself rather than as a
confident sentence with the deciding half missing; paraphrasing BYSETPOS into
silence would misdescribe when somebody's meeting actually happens, which is
the same move as v1's title-matching deduplicator.

Right-clicking an event or a day opens a menu, positioned by arithmetic on
sizes the stylesheet guarantees rather than by rendering it and measuring --
measuring means a frame in the wrong place and imperative DOM surgery inside
a declarative framework, which is how v1's print preview reached 617 lines.
The tokens it reads are registered with @property so their computed values
come back as lengths; an ordinary custom property reads as the string "13rem",
which is not a number.

The menus offer what exists: open the details, go to that day, that week,
that month. Editing, duplicating and deleting arrive with the milestones that
build them. An item that greys itself out is a promise the interface cannot
keep, and v1 shipped a recurrence form full of fields that reached no API.

What is open lives in one place, offered through context rather than passed
down: the thing that opens a menu is a chip several components deep, and the
ones in between have no business carrying a callback they never call.
2026-08-27 11:19:37 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 12:07:44 -04:00

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, 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

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.
  • Test thoroughly. Real servers over mocks; the router under test is the router that ships.
S
Description
No description provided
Readme
832 KiB
Languages
Rust 85.6%
JavaScript 9.1%
CSS 2.9%
Shell 1%
Python 0.9%
Other 0.5%