connor c4e8ede28c Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel
tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating
the non-series handlers, dispatching on string literals in 53 places where
a typo was a runtime fallthrough.

What a scoped edit means to the stored .ics lives in runway-core::series,
pure and tested without a server, because that is the subtle part and v1
shipped it with no coverage at all. Editing one occurrence writes an
override and no EXDATE: an EXDATE says the occurrence does not happen, an
override says it happens differently, and writing both is contradictory.
Deleting one writes the EXDATE and removes any override that named it.

Splitting a series divides its bound rather than dropping it. Six weekly
occurrences split at the third become two plus four, not two plus
forever -- the count is what the person asked for and it should survive
being cut. Overrides after the split move to the new series; moving a
whole series shifts its overrides' RECURRENCE-IDs by the same amount
instead of leaving them pointing at occurrences that no longer exist.

Every write states a precondition. There is no unconditional path: an
update without an ETag is refused, and a stale one is a conflict rather
than a silent overwrite. UIDs are minted server-side, because a
client-supplied one could collide with and replace an unrelated event.

Reads use time-range and fan out across calendars concurrently. Zones that
cannot be resolved are reported in the response instead of being rendered
as though they were fine.

Two tests found real bugs: sub-second timestamps cannot survive
iCalendar's one-second resolution, and splitting at the first occurrence
was dropping the recurrence rule and quietly turning a series into a
single event.
2026-08-26 17:07:38 -04:00
2026-08-26 17:07:38 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 17:07:38 -04:00
2026-08-26 17:07:38 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 12:07:44 -04:00
2026-08-26 16:27:59 -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

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%