Files
connor 0693ce0e43 Say a reminder while somebody is looking
The alarms that matter were already being written to the server, which is the
half that counts: a phone reads them at six in the morning with this tab long
closed. Two things were wrong with the writing, and the telling was missing.

`RELATED` was being dropped. The alarm row read the offset out of the trigger
and wrote a new one back with `related: Start` hard-coded, so an alarm made
elsewhere as "ten minutes before it ends" became ten minutes before the start
the first time this app touched the event -- the exact v1 bug the model was
given the parameter to prevent. It is a control now, beside the offset, and
changing either keeps the other. The offsets also cover "when it starts" and
"fifteen minutes after", which are things people set.

`alarms::due` is the arithmetic, and it is a pure function of the events on
screen and the time: only `DISPLAY` alarms, because acting on an `EMAIL` or an
`AUDIO` one is not a browser tab's business; relative triggers off whichever
end they name; absolute ones converted through the difference between the two
spellings of the occurrence's own start, so no zone database is needed for it.
A five-minute window decides what is late enough to skip -- a page opened at
noon should not recite the whole morning, and one that was behind another
window for four minutes should not lose what came due while it was there.

The telling is one effect on the clock that was already ticking for the
now-line, and one set in memory of what has been said. Either the browser says
it, tagged so a second tab replaces the notice rather than stacking beside it,
or the page does, in a note that can be dismissed -- never both, because the
granted one arrives when the tab is behind something else, which is when a
reminder is worth having. v1 spent a 2,008-line component polling every thirty
seconds, a `localStorage` ledger that grew without bound, and a service worker
that could not read that ledger and so did nothing but ping the tab that was
already polling.

Permission is asked from settings, on a click. A prompt fired at a page nobody
has looked at yet is one they dismiss without reading, and a dismissed prompt
is indistinguishable from here from a refusal. The panel re-reads where it
stands each time it opens, since that is the browser's to change from a
control this page does not own.

Chips that carry any alarm say so with a small mark -- any action, not only
the ones this browser would act on, because the mark means "somebody will be
told" and the phone reading the same calendar is somebody.

The browser checks cover both channels: a reminder due three minutes ago
arriving on the page and being dismissed and not coming back on the next tick,
a trigger hung off the end surviving a round trip through CalDAV, and, with
the notification API standing in for the real one, the same reminder going to
the browser instead and nothing being said twice.
2026-08-27 20:26:39 -04:00

86 lines
2.0 KiB
TOML

[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"
# Crypto
sha2 = "0.10"
chacha20poly1305 = "0.10"
rand = "0.9"
base64 = "0.22"
# Errors + logging
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Backend
axum = "0.8"
axum-extra = { version = "0.10", features = ["cookie", "query"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "request-id"] }
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"
gloo-net = { version = "0.6", features = ["json"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
js-sys = "0.3"
console_error_panic_hook = "0.1"
# CLI
clap = { version = "4", features = ["derive", "env"] }
iana-time-zone = "0.1"
# Dev
pretty_assertions = "1"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
strip = true