Set up workspace skeleton

Five crates: runway-core (pure domain), runway-caldav (protocol),
runway-server (axum), runway-web (leptos), runway-cli (smoke tool).

runway-core is feature-gated into model/ical/recurrence so the frontend
can depend on the shared types without pulling icalendar and rrule into
the WASM bundle. The previous iteration shipped reqwest, ical and regex
to the browser for a dead module; the feature split makes that mistake
structurally hard to repeat.

Guardrails are compiler- and CI-enforced rather than aspirational:
workspace lints deny unwrap_used/expect_used, dead_code and unsafe_code,
clippy.toml caps function length and arity, deny.toml pins licences.

Toolchain is pinned per-project so the machine-wide default is untouched.
Cargo.lock is committed this time.

docs/legacy-audit.md carries the marked-up feature decisions and is the
spec for the rewrite.
This commit is contained in:
2026-08-26 12:07:44 -04:00
commit bf63024711
20 changed files with 4272 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Build output
/target
/crates/runway-web/dist
# Local database + secrets
*.db
*.db-shm
*.db-wal
.env
.env.local
# Node (e2e only)
node_modules/
/e2e/test-results
/e2e/playwright-report
# Editor / OS
.DS_Store
.idea/
.vscode/
Generated
+3565
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
[workspace]
resolver = "3"
members = [
"crates/runway-core",
"crates/runway-caldav",
"crates/runway-server",
"crates/runway-web",
"crates/runway-cli",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
license = "MIT"
repository = "https://github.com/connorjohnstone/runway"
[workspace.lints.rust]
dead_code = "deny"
unused_must_use = "deny"
unsafe_code = "forbid"
[workspace.lints.clippy]
# v1 had 65 of these. Tests opt out locally.
unwrap_used = "deny"
expect_used = "deny"
[workspace.dependencies]
# Internal
runway-core = { path = "crates/runway-core" }
runway-caldav = { path = "crates/runway-caldav" }
# Core data
chrono = { version = "0.4", default-features = false, features = ["serde", "clock", "std"] }
chrono-tz = { version = "0.10", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
# Calendar domain
icalendar = "0.17"
rrule = "0.14"
# Errors + logging
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Backend
axum = "0.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "chrono", "uuid", "migrate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
quick-xml = "0.42"
# Frontend
leptos = "0.8"
leptos_router = "0.8"
# Dev
pretty_assertions = "1"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true
+43
View File
@@ -0,0 +1,43 @@
# 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`](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.
```sh
cargo check --workspace
cargo test --workspace
cargo clippy --workspace -- -D warnings
```
## 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.
+4
View File
@@ -0,0 +1,4 @@
# Keep functions small enough to hold in your head. v1's worst offenders were
# 240-line iCal serialisers and a 2,008-line component.
too-many-arguments-threshold = 6
too-many-lines-threshold = 120
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "runway-caldav"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "CalDAV client: discovery, time-ranged queries, ETag-aware CRUD."
[dependencies]
runway-core = { workspace = true, features = ["ical"] }
chrono = { workspace = true }
quick-xml = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
//! CalDAV client: discovery, time-ranged queries and ETag-aware CRUD.
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "runway-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Smoke tool for exercising a real CalDAV server from the terminal."
[dependencies]
runway-core = { workspace = true, features = ["ical", "recurrence"] }
runway-caldav = { workspace = true }
chrono = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("runway-cli: not yet implemented");
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "runway-core"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Pure calendar domain: RFC 5545 model, iCalendar round-trip, recurrence expansion."
[features]
default = ["model"]
# Types only. Wasm-safe, dependency-light: this is all the frontend needs.
model = []
# iCalendar parsing/serialisation. Server and CLI only.
ical = ["dep:icalendar"]
# RRULE expansion. Server and CLI only.
recurrence = ["dep:rrule", "dep:chrono-tz"]
[dependencies]
chrono = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono-tz = { workspace = true, optional = true }
icalendar = { workspace = true, optional = true }
rrule = { workspace = true, optional = true }
[dev-dependencies]
serde_json = { workspace = true }
pretty_assertions = { workspace = true }
[lints]
workspace = true
+8
View File
@@ -0,0 +1,8 @@
//! Pure calendar domain logic for Runway.
//!
//! This crate holds the RFC 5545 model and, behind feature flags, the iCalendar
//! round-trip and recurrence expansion. It performs no I/O and has no knowledge
//! of HTTP, CalDAV or the database, which keeps it fast to test and — with only
//! the default `model` feature — cheap enough to compile into the WASM bundle.
pub mod model;
+1
View File
@@ -0,0 +1 @@
pub struct Placeholder;
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "runway-server"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Axum backend: CalDAV proxy, sessions, preferences, ICS feeds."
[dependencies]
runway-core = { workspace = true, features = ["ical", "recurrence"] }
runway-caldav = { workspace = true }
axum = { workspace = true }
chrono = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tower = { workspace = true }
tower-http = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
reqwest = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
//! Runway backend: a CalDAV proxy with sessions, preferences and ICS feeds.
+3
View File
@@ -0,0 +1,3 @@
fn main() {
// Server startup lands in M7, once sessions and the CalDAV proxy exist.
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "runway-web"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Leptos CSR frontend."
[dependencies]
runway-core = { workspace = true }
chrono = { workspace = true }
leptos = { workspace = true, features = ["csr"] }
leptos_router = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("runway-web: not yet implemented");
}
+18
View File
@@ -0,0 +1,18 @@
[advisories]
yanked = "deny"
[licenses]
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Unicode-3.0",
"Zlib",
"MPL-2.0",
]
[bans]
multiple-versions = "warn"
+416
View File
@@ -0,0 +1,416 @@
# Runway v1 audit — what to carry forward, what to leave behind
Source: `/home/connor/docs/projects/calendar` (Rust/WASM CalDAV web client, "Runway").
221 commits · 62 Rust files · ~22,056 lines of Rust · ~8,300 lines of hand-written CSS · 2.5 MB WASM bundle.
Purpose of this doc: an inventory you can mark up. Tick what matters, strike what doesn't,
and we'll turn the survivors into a build plan for the rewrite.
> **How to mark up.** Nothing is being ported: every feature you pick gets written fresh, so this
> list is about *what the app should do*, not which code survives.
>
> In Part 1 the first three columns are:
> - **B** — this feature matters; build it. A vote for the **capability**, not an endorsement of
> how v1 did it. Assume I'll design the execution fresh, and that anything marked *"Works,
> fragile"* gets a better approach by default rather than a faithful reproduction.
> - **S** — skip it, don't implement it at all.
> - **D** — it matters **and** you already have a specific change in mind. Append it to the Notes
> cell; that's the instruction I'll design against. As loose as "simpler" or as specific as you like.
>
> So **B vs D is not "as-is" vs "changed"** — both get written from scratch. The difference is
> whether you're handing me a constraint or leaving the execution to me. The **v1 status** column is
> there as history and as a warning about where the hard parts were; it is never a spec.
>
> Part 2 uses **Y**/**N**/**D** — make the same architectural call again, make a different one, or
> keep the intent but change the execution (again, say how in Notes).
>
> Each cell is a single space at a fixed character position — columns 2, 4 and 6 of every row — so
> you can move down a column in vim and hit `rx`. Leave all three blank if you're undecided.
---
## Part 1 — Feature inventory
Notes flag what each feature actually cost in v1.
### 1.1 Authentication & session
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F1 | Log in with CalDAV server URL + username + password | Works | Auth = "can we PROPFIND your calendars?" Simple and correct. -- I'd like to add OpenID as well, to connect behind my Authelia instance |
|x| | | F2 | JWT token issued by backend, stored in localStorage | Works | |
| |x| | F3 | Second, parallel `session_token` (SQLite-backed) used *only* by the preferences API | Works | Two auth schemes for one app. Pick one. |
|x| | | F4 | CalDAV password re-sent on every request via `X-CalDAV-Password` header | Works | Password is kept in localStorage in cleartext and read from 8+ call sites. See §3.4. -- Keep, but if there's a better way I'm open to it |
|x| | | F5 | "Remember me" — persists server URL + username (not password) | Works | Cheap to build. |
|x| | | F6 | Token verification on app startup, auto-logout on invalid | Works | |
|x| | | F7 | Session expiry (24h) + expired-session cleanup | Works | |
|x| | | F8 | Multi-user support (users table keyed on username+server_url) | Works | Was this ever used, or is it a single-user app? Big simplification if single-user. -- Currently not used, but I want to keep |
### 1.2 Calendar & event viewing
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F9 | Month view | Works | 326 lines. The cheap one. |
|x| | | F10 | Week view with hour grid | Works | 1,431 lines — the single hardest file in the repo. |
|x| | | F11 | Day view | **Absent** | Never built. |
|x| | | F12 | Agenda / list view | **Absent** | Never built. |
|x| | | F13 | Year view | **Absent** | Never built. |
|x| | | F14 | Current-time indicator line in week view | Works | Re-renders every 5s. |
|x| | | F15 | Configurable time increment (grid granularity) | Works | Persisted per-user. |
|x| | | F16 | Overlapping-event column layout in week view | Works, fragile | Hand-rolled interval-packing inline in the view component. |
|x| | | F17 | All-day event lane at top of week view | Works | |
|x| | | F18 | Per-calendar colors | Works | |
|x| | | F19 | Calendar show/hide toggles in sidebar | Works | Filtering happens client-side *after* fetching everything. |
|x| | | F20 | "More events" overflow indicator in month cells | Works | |
|x| | | F21 | Reminder-bell icon on events that have alarms | Works | |
|x| | | F22 | Navigate prev/next period, jump to today | Works | |
|x| | | F23 | Selected-date persistence across reloads | Works | Stored in localStorage *and* the DB. |
### 1.3 Event editing
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F24 | Create event (tabbed modal: Basic / Advanced / People / Categories / Location / Reminders) | Works | 6 tabs, ~1,400 lines across `event_form/`. |
|x| | | F25 | Edit existing event | Works | |
|x| | | F26 | Delete event | Works | |
|x| | | F27 | View event detail modal | Works | |
|x| | | F28 | Drag to move an event (week view) | Works, fragile | Hand-rolled mouse-event math with pixel offsets and snapping. |
|x| | | F29 | Drag top/bottom edge to resize (week view) | Works, fragile | Same. |
|x| | | F30 | Drag on empty grid to create an event | Works | |
|x| | | F31 | Right-click context menu on an event | Works | |
|x| | | F32 | Right-click context menu on a day cell | Works | |
|x| | | F33 | Full RFC 5545 property support in the model (status, class, priority, organizer, attendees, categories, geo, url, resources, contact, related-to, sequence, transp, attachments) | Model complete, UI partial | The `VEvent` struct covers all of it; the form exposes maybe half; the wire format flattens most of it to comma-separated strings. |
|x| | | F34 | Attendees | Half-built | Backend has `// TODO: Parse attendees properly`. Round-trip is lossy. |
|x| | | F35 | Attachments | Model only | No UI, no CalDAV round-trip. |
### 1.4 Recurring events
This is where most of the complexity — and most of the bugs — lived.
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F36 | Create recurring event (daily / weekly / monthly / yearly + interval) | Works | |
|x| | | F37 | Weekly BYDAY (pick days of week) | Works | Sent as `Vec<bool>` of length 7, not as an RRULE. -- If RRULE is better, we should do that |
|x| | | F38 | COUNT limit / UNTIL date | Works | |
|x| | | F39 | Monthly BYMONTHDAY and BYDAY ("first Monday") | **Dead-ended** | Fields exist in the form model (`monthly_by_day`, `monthly_by_monthday`, `yearly_by_month`) but are dropped on the way to the API. |
|x| | | F40 | Client-side expansion of RRULE into visible occurrences | Works, ~650 lines hand-rolled | See §3.1 — the single biggest reinvention in the repo. -- To be clear, I want to see re-occurences visually, but we don't need to re-invent anything |
|x| | | F41 | Edit scope: "this event only" (EXDATE + RECURRENCE-ID exception) | Works | Correct RFC approach. Documented well in-code. |
|x| | | F42 | Edit scope: "this and future" (UNTIL split + new series) | Works | Correct RFC approach. |
|x| | | F43 | Edit scope: "entire series" | Works | |
|x| | | F44 | Delete scope: this / following / series | Works | |
|x| | | F45 | EXDATE handling | Works | |
|x| | | F46 | RECURRENCE-ID exception events | Works | |
| | | | F47 | Dedicated `/api/calendar/events/series/*` endpoints | Works | 1,165 lines of handler. Mostly duplicating the non-series handlers. -- I'm ambivalent on this unless it serves the UI in some useful way |
### 1.5 Calendar management
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F48 | Discover calendars on the CalDAV server (PROPFIND) | Works | |
|x| | | F49 | Create a new calendar collection (MKCALENDAR) | Works | |
|x| | | F50 | Delete a calendar collection | Works | |
|x| | | F51 | Calendar management modal (rename/recolor/reorder) | Works | 448 lines. |
|x| | | F52 | Auto-assign a color per calendar from a hash of its path | Works | Nice touch. |
|x| | | F53 | Editable 16-color event palette + custom palette editor | Works | Stored as a JSON blob inside the `calendar_colors` preferences column. |
### 1.6 External calendar subscriptions
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F54 | Subscribe to read-only ICS feed by URL | Works | |
|x| | | F55 | Server-side caching of fetched ICS with staleness check | Works | Good idea, worth building. |
|x| | | F56 | Per-feed name, color, visibility | Works | |
| |x| | F57 | Heuristic de-duplication / consolidation of feed events | **Actively harmful** | ~500 lines that fuzzy-match events by normalized title and *merge or discard* them. See §3.2. |
|x| | | F58 | ETag support for external feeds | **Not implemented** | `let etag = None; // TODO`. |
|x| | | F59 | Timezone parsing for external feeds | **Not implemented** | `dtstart_tzid: None, // TODO: Parse timezone from ICS`. |
### 1.7 Alarms & notifications
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F60 | VALARM support in the model, add/edit/remove alarms in the form | Works | |
|x| | | F61 | Browser notification when an alarm fires | Works | |
|x| | | F62 | Alarm scheduler polling every 30s from the main app | Works | State in localStorage. |
| |x| | F63 | Service worker for "background" alarm processing | **Ceremony, no value** | The SW can't read localStorage, so it just pings the main tab, which is already polling. Deletable today with zero behavior change. |
| |x| | F64 | IndexedDB for persistent alarm storage | **Never built** | `web-sys` IndexedDB features + `indexed_db_futures` dep are enabled and shipped in the WASM bundle; zero code uses them. |
| |x| | F65 | Email/audio alarm actions | Model only | Only `Display` is handled. |
### 1.8 Presentation & printing
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F66 | 12 named color themes (Ocean, Forest, Sunset, Purple, Dark, Rose, Mint, Midnight, Charcoal, Nord, Dracula, Default) | Works | -- I want the concept of "color themes", but it doesn't need to be these 12. Some are good, some bad |
|x| | | F67 | 3 layout "styles" (Default, Google, Apple), each a separate stylesheet | Works | 12 × 3 = 36 combinations to hand-maintain across ~8,300 lines of CSS. -- Again, I want the concept of "display styles", but it doesn't need to be implemented as it is currently, and we could do better than the ones we have |
|x| | | F68 | System dark-mode detection (`prefers-color-scheme`) | Works | |
|x| | | F69 | Print preview modal with live zoom, start/end hour clipping, per-element font-size and padding sliders | Works | 617 lines of Rust + 1,320 lines of print CSS, and it drives the *interactive* view components via extra `print_*` props. See §3.5. -- Implementation is bad here, I know, but this feature is important. I want to be able to print the calendars we're displaying |
|x| | | F70 | Actual printing | Works | Clones the DOM into a hidden node and measures it imperatively. |
|x| | | F71 | Mobile warning modal ("this app isn't for phones") | Works | Stands in for actually being responsive. |
| |x| | F72 | Responsive/mobile layout | **Not really** | F71 exists because F72 doesn't. -- We'll do it later, perhaps. CalDAV clients exist on Android already |
### 1.9 Infrastructure
|B|S|D| # | Feature | v1 status | Notes |
|-|-|-|---|---------|-----------|-------|
|x| | | F73 | Docker Compose stack (Axum backend + Caddy serving the WASM bundle) | Works | |
|x| | | F74 | SQLite + sqlx migrations, auto-run on container start | Works | |
|x| | | F75 | Gitea Actions workflow building & pushing a backend image | Works | |
| | |x| F76 | `deploy_frontend.sh` — rsync the `dist/` to the server over SSH | Works | Frontend deploy is manual and separate from the backend's CI. -- I'm ok with making this automated instead |
|x| | | F77 | Backend integration test suite (18 tests) | **Does not compile** | Stale: calls `AuthService::new(secret)` (now takes 2 args) and builds `AppState` without its `db` field. Also duplicates the whole router instead of importing it. |
|x| | | F78 | Playwright E2E suite | **Source is gone** | 7.2 MB of stale HTML reports and trace zips remain on disk; not a single `.spec.ts` survives. |
|x| | | F79 | Unit tests | 11 total | 4 in dead code, 3 in config, 4 in the CalDAV client. -- Broadly speaking, I want this code being tested very thoroughly. That's my hedge against AI writing bad code |
---
## Part 2 — Stack & architecture decisions to re-litigate
Each of these worked. The question is whether you'd choose it again — **Y**es, same call; **N**o,
drop the approach entirely; or **D** for same intent, different execution (several of these are
squarely in that third bucket — A3, A6 and A10 especially).
|Y|N|D| # | Decision | Verdict to consider |
|-|-|-|---|----------|---------------------|
|x| | | A1 | **Rust + Yew + WASM frontend** | The 2.5 MB bundle, the 869 `.clone()` calls, and the lack of a mature component/CSS ecosystem are all downstream of this. It's a legitimate choice if you want one language end-to-end — but it is the reason the calendar viz was a headache. Worth an explicit yes/no. -- I'm a big proponent of Rust/WASM/functional programming. I prefer to do it this way|
|x| | | A2 | **Axum backend as a CalDAV proxy** | Right call. Browsers can't do CalDAV directly (CORS, custom methods, credentials). Keep. |
|x| | | A3 | **Cargo workspace with a shared `calendar-models` crate** | Right instinct, under-used — see §3.3. Keep the crate; actually route everything through it. |
|x| | | A4 | **`VEvent` as an RFC 5545-faithful struct** | Genuinely good. The best asset in the repo. Carry it forward nearly as-is. |
|x| | | A5 | **SQLite for users/sessions/prefs/feed cache** | Fine. Keep. |
| | |x| A6 | **Password forwarded per-request rather than stored server-side** | Defensible (server never persists the secret), but it forced the password into localStorage. Consider an httpOnly cookie holding an encrypted credential blob instead. -- I'm a bit ambivalent. Open to whichever approach is best |
|x| | | A7 | **Trunk for the frontend build** | Fine if A1 stays. |
| |x| | A8 | **Client-side recurrence expansion** | Reconsider. Doing it server-side means one implementation, easier tests, smaller bundle, and the client just renders what it's given. -- Agree, just want to clarify that I do want the UI displaying recurring events as many events, like most calendars |
| | | | A9 | **Fetch the whole calendar, filter client-side** | The CalDAV `calendar-query` REPORT supports a `time-range` filter and v1 never used it. Every view change refetches everything. -- ambivalent. whatever is best for performance |
| | | | A10 | **Hand-written CSS with 553 custom properties** | The 116 `!important` declarations are the tell. Needs a real strategy (design tokens + a small utility layer, or a component library). -- open to whatever as long as we keep the "themes" and "styles" concepts |
---
## Part 3 — What actually went wrong
Concrete, with evidence. This is the part worth internalizing before starting over.
### 3.1 Reinventing libraries that exist
- **RRULE expansion, ~650 lines, hand-rolled.**
`frontend/src/services/calendar_service.rs:331-1006``expand_recurring_events`,
`generate_occurrences`, `generate_weekly_byday_occurrences`,
`generate_monthly_bymonthday_occurrences`, `generate_monthly_byday_occurrences`,
`generate_yearly_bymonth_occurrences`, `parse_byday`, `add_months`, `add_years`,
`days_in_month`, `is_leap_year`.
The [`rrule`](https://crates.io/crates/rrule) crate does all of this, RFC-compliant, with a test suite.
`days_in_month` and `is_leap_year` are also already in `chrono`.
- **CalDAV XML parsing by regex.**
`backend/src/calendar.rs:265``extract_xml_content` tries **six** regex patterns in sequence to
cope with namespace prefixes, recompiling each `Regex` on every call inside a loop.
`quick-xml` or `roxmltree` solves this properly and faster.
- **iCalendar serialization by string concatenation.**
`backend/src/calendar.rs:1303-1546` — 240 lines of `push_str(&format!(...))`. No 75-octet line
folding (an RFC violation that bites on long descriptions), no `VTIMEZONE` emission.
The [`icalendar`](https://crates.io/crates/icalendar) crate builds and folds correctly.
- **HTTP client, three times.**
17 raw `web_sys::RequestInit` call sites across `auth.rs`, `services/preferences.rs`, and
`services/calendar_service.rs`, each with its own header/error/JSON handling. `gloo-net` is one line per call.
- **Two near-identical datetime parsers** (`parse_datetime` and `parse_datetime_with_tz`,
`backend/src/calendar.rs:715` and `:807`), plus a **third** copy in
`backend/src/handlers/ics_fetcher.rs:333`. One of them loops over a `formats` array whose
loop variable most branches never use — the same work runs three times per call.
### 3.2 Solving symptoms instead of causes
`backend/src/handlers/ics_fetcher.rs:485-880` is ~500 lines named `deduplicate_events`,
`consolidate_same_title_events`, `deduplicate_exact_recurring_events`, `consolidate_weekly_patterns`,
`would_event_be_generated_by_rrule`, `normalize_title`, `event_completeness_score`.
It strips punctuation from event titles, lowercases them, groups by the result, guesses which of
the collisions is "most complete", and **throws the rest away**. It will also try to merge two
weekly RRULEs into one multi-day rule.
Almost certainly the real bug was upstream: the ICS parser doesn't handle `RECURRENCE-ID` overrides
or `VTIMEZONE` (both are open `TODO`s in the same file), so a correct feed *looked* like it had
duplicates. Rather than fix the parser, v1 added a lossy heuristic on top. Any two genuinely
distinct events with similar titles and the same start time get silently merged.
**Rule for v2: when output looks wrong, fix the parse, never post-process the output.**
### 3.3 Four parallel representations of "an event"
1. `calendar_models::VEvent` — the good, RFC-faithful one.
2. `backend::calendar::OldCalendarEvent` — a deprecated flattened copy, still compiled (`calendar.rs:18`).
3. `CreateEventRequest` / `UpdateEventRequest` / `CreateEventSeriesRequest` / `UpdateEventSeriesRequest`
(`backend/src/models.rs:100-200`) — four near-identical stringly-typed wire structs.
Dates and times as separate `"YYYY-MM-DD"` / `"HH:MM"` strings; attendees and categories as
comma-separated strings; recurrence decomposed into `recurrence: String` + `recurrence_days: Vec<bool>`
+ `interval` + `count` + `end_date` instead of just… the RRULE that `VEvent` already holds.
4. `EventCreationData` (`frontend/src/components/event_form/types.rs:64`) — the form model,
with *its own* `EventStatus` and `EventClass` enums duplicating the ones in `calendar-models`,
converted to the wire format via a **22-element tuple** (`to_create_event_params`) that
serializes enums with `format!("{:?}", …).to_uppercase()`.
Plus a fifth, `frontend::calendar::CalendarEvent`, in dead code (see §3.6).
Every conversion between these is a place to lose data, and several of them do — that's how
`monthly_by_day` (F39) ends up in the form but never reaches the server.
**Rule for v2: `VEvent` is the wire format. `POST /events` takes a `VEvent`. No parallel DTOs.**
### 3.4 localStorage as a global mutable variable
`caldav_credentials` (the plaintext CalDAV password) is read directly from localStorage in
**8 separate places** inside `app.rs` alone, plus `components/calendar.rs`. Preferences live in
localStorage *and* in the SQLite `user_preferences` table with no defined source of truth — some
keys are written to both, some to one. `calendar_view_mode`, `calendar_theme`, `calendar_style`,
`calendar_selected_date`, `calendar_time_increment`, `calendar_colors`, `last_used_calendar`,
`user_preferences` (a JSON blob of the others) all coexist.
This is the classic "one hidden global" pattern: any component can read or write app state without
declaring that it does, so nothing is traceable and cache-invalidation bugs are inevitable
(commit `933d7a8 Fix calendar visibility preservation during event updates` is one of these).
**Rule for v2: one auth context, one preferences store, one writer. Secrets never in localStorage.**
### 3.5 The 2,008-line `App` component
`frontend/src/app.rs` is a single `#[function_component]` holding **35 `use_state` hooks** and
**265 `.clone()` calls**. Every modal's open/closed flag, every context menu's x/y position, the
color palette, the alarm scheduler, the refresh interval — all in one function, all prop-drilled
down through components that only pass them along.
Symptoms this produced:
- `CalendarProps.on_event_update_request` is a callback taking a **7-element tuple**.
- A `timestamp` field on `UserInfo` whose only job is `// Add timestamp to force re-render`.
- `print_mode`, `print_pixels_per_hour`, `print_start_hour` props threaded into `WeekView` so the
print-preview modal can reuse it — print concerns leaking into the interactive view.
- The print modal doing imperative DOM surgery (`set_inner_html`, cloning nodes, measuring
`client_height`, mutating inline styles, `query_selector_all(".week-event")`) inside a
declarative framework — `print_preview_modal.rs:244-303`.
**Rule for v2: state lives at the level that owns it. Use a reducer + context for genuinely global
state. If a prop is a tuple of more than two things, it wants to be a struct.**
### 3.6 Dead code shipped to users
- `frontend/src/calendar.rs`**638 lines**: a complete second CalDAV client (its own
`CalendarEvent`, `CalDAVClient`, XML extraction, iCal parsing, 4 unit tests) that is
**not declared in `main.rs`** and has never been reachable.
- Because that file *looks* used, `reqwest`, `ical`, and `regex` stay in the frontend's
dependency list and get compiled into the WASM bundle.
- Also fully unused frontend deps: `chrono-tz`, `indexed_db_futures`, `base64`, `dotenvy`,
`serde-wasm-bindgen`, plus the 8 IndexedDB `web-sys` features.
- `OldCalendarEvent`, `EventReminder`, `ReminderAction` in `backend/src/calendar.rs` — superseded, still compiled.
- `backend/src/debug_caldav.rs` — a debug scratch module.
- Duplicate top-level `migrations/` directory shadowing the real `backend/migrations/`.
- Three orphaned `_recurring_edit_*` state hooks in `app.rs:140-142`.
A meaningful share of that 2.5 MB bundle is code nobody can execute.
### 3.7 Observability and error handling
- **157 `println!` calls** in the backend with emoji prefixes (`🔄`, `✅`, `🗑️`); **zero** uses of
`log` or `tracing`. No levels, no filtering, no structure — and a login handler that prints
`Password length: {}`.
- **74 `web_sys::console` calls** in the frontend, same story.
- 65 `.unwrap()` calls across both crates.
- Errors are `String` at nearly every frontend boundary (`Result<T, String>`), so nothing can be
matched on — a 401 and a JSON parse failure are indistinguishable to the caller.
- Handlers dispatch on stringly-typed actions (`"delete_this"`, `"this_and_future"`,
`"all_in_series"`, `"update_series"`) — 53 occurrences — instead of enums, so a typo is a runtime
fallthrough rather than a compile error.
### 3.8 Timezone modelling
The client computes its current UTC *offset* (`format!("{:+03}:{:02}", …)` from
`js_sys::Date::get_timezone_offset()`) and sends that as `timezone: String` on every request.
An offset is not a timezone: `-06:00` in January and `-06:00` in July are different zones, and a
recurring 9am meeting created in winter drifts an hour after the DST boundary. The `VEvent` model
already has proper `*_tzid` fields; the wire format throws them away in favour of an offset.
`chrono-tz` is in the frontend's dependency list and never used.
**Rule for v2: IANA TZID everywhere (`America/Denver`), never a raw offset. Store the zone, derive the offset.**
### 3.9 CSS at scale, without a system
- `styles.css`: 5,610 lines. `print-preview.css`: 1,320 lines. `styles/google.css`: 19 KB.
`styles/apple.css`: 15 KB.
- **553** CSS custom property definitions, **116** `!important` declarations.
- Selectors are redefined many times over — `.external-calendar-modal` appears 23 times,
`.login-form` 18, `.calendar-day` 13, `.week-event` 12.
- Font Awesome pulled from a CDN in `index.html` (an external dependency on every page load).
- The last ~15 commits in the repo are almost entirely CSS-tweak commits for the print preview.
The 36 theme × style combinations were never worth their maintenance cost on a personal app.
### 3.10 Testing and correctness feedback
Effectively no safety net. 11 unit tests total (4 of them in dead code). The integration suite
doesn't compile against current signatures. The Playwright specs were deleted but 7.2 MB of their
reports remain. Every regression was found by using the app.
The recurring-event edit semantics (§F41-F44) are genuinely subtle — EXDATE + RECURRENCE-ID +
UNTIL splitting — and they were shipped with **zero** automated coverage.
---
## Part 4 — What v2 should reach for instead
No columns here — these follow from the decisions above rather than being decisions themselves.
| v1 hand-rolled | v2 candidate |
|---|---|
| 650-line RRULE expander | [`rrule`](https://crates.io/crates/rrule) crate |
| Regex XML extraction | `quick-xml` or `roxmltree` |
| String-concat iCal writer | [`icalendar`](https://crates.io/crates/icalendar) crate (handles line folding) |
| 3 copies of a datetime parser | one function in `calendar-models`, tested |
| 17 raw `RequestInit` fetch sites | `gloo-net` (or a single typed API client module) |
| `println!` with emoji | `tracing` + `tracing-subscriber` |
| `Result<T, String>` | `thiserror` enums, both crates |
| Stringly-typed actions | `#[derive(Serialize, Deserialize)] enum EditScope { ThisOnly, ThisAndFuture, EntireSeries }` |
| Offset-as-timezone | `chrono-tz` with IANA TZIDs |
| 8,300 lines of bespoke CSS | a small token set + one utility layer; ship **one** theme with a light/dark pair |
| Manual visual verification | headless-browser screenshots in the loop (see below) |
### Verifying the calendar viz this time
The thing that made week view painful — pixel math you can't see until you build, deploy, and
squint — is now directly addressable. I can drive a real browser, take screenshots of the running
app, and compare them as I go. That means for v2 we can:
- screenshot the week grid at each step and check the layout by eye before moving on;
- build a small fixture set (overlapping events, all-day spans, DST-boundary days, midnight-crossing
events, a 6-week month) and snapshot each one;
- catch layout regressions the moment they appear rather than three commits later;
- read the browser console for panics and warnings without you having to reproduce them.
This is the single biggest change in what's possible between v1 and v2, and it should shape the
plan: build the view layer against real rendered output from day one.
---
## Part 5 — Open questions for you
Answer inline underneath each — free text, no columns.
1. **Rust/WASM again, or a JS/TS frontend?** (A1) Everything about the CSS pain, the bundle size,
and the viz difficulty traces back here. Keeping Rust is defensible; it should just be a decision
rather than an inheritance.
> Keep it as Rust/WASM. This is important to me. I'm a big fan.
2. **Single-user or multi-user?** (F8) If it's only ever you, the users table, JWT, and half the
session machinery evaporate.
> Multi-user. I'd like this to eventually be a usable tool by households where more than one person might want to use it.
3. **Which views do you actually use?** Month and week both? Would a day or agenda view earn its keep?
> The other views are also nice to have. But I mostly use week and month. I do want them all though, I suppose.
4. **Themes: how many?** One good light/dark pair, or is the 12-theme picker something you use?
> It doesn't have to be 12, but I like having themes. I'm sure we can come up with a clever way to make this not **too** complex.
5. **Print preview: keep?** It was ~2,000 lines and the last 15 commits. Is printing a real workflow
or a weekend detour?
> Yes, printing is important. I want to be able to print one of these calendars with the currently visible items in a way that is pretty, consistent with the current theme, takes up the whole page and nothing more, etc so I can laminate it and put it on my fridge.
6. **Alarms: keep?** Browser notifications only fire when the tab is open. Your phone already does
this from the CalDAV server. Is the web client the right place for it?
> The alarms are important to make it to the server (my phone client will use them and notify me). Does the browser need to notify you when they go off? I mean, it's not critical, but I don't think it's that difficult and it's nice to have.
7. **External ICS feeds: keep?** Worth it, but only if we do the parse correctly (§3.2) instead of
the dedup heuristics.
> Yes, these are important. In particular outlook, so I can put my work schedule on there. But you're right, we should handle them correctly.
8. **Drag-and-drop editing: how important?** It's the highest-risk UI feature per unit of value.
> It's important. I really want a snazzy UX for this. Drag and drop for simple moves AND the context menu for "full editing"
9. **Anything in v1 you actively miss or that never worked right** that isn't listed above?
>
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.98.0"
components = ["rustfmt", "clippy"]
targets = ["wasm32-unknown-unknown"]