From ba90a30e41d40a210d0617ee847652b5fae0ce25 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Wed, 26 Aug 2026 20:48:56 -0400 Subject: [PATCH] Add the app shell: sidebar, header, and routing The view and the focused date live in the URL. That makes the back button work and a week shareable, and gives "which week am I looking at" exactly one answer -- v1 kept the selected date in localStorage *and* in the database with no defined source of truth between them. Date arithmetic is a pure module with its own tests, run natively rather than in a browser. v1 did this inline in a 1,431-line week view where the only way to check it was to build, deploy and look. Thirteen tests, including that a month covers whole weeks and needs six rows when the month does -- v1 shipped "Fix print preview to display all 6 rows for 6-week months" -- and that stepping a month from the 31st lands on the last day of a shorter one instead of skipping February. The sidebar shows each calendar in its own colour, read from the CalDAV server rather than hashed from its path, and hiding one narrows the time-range query rather than filtering in the browser. Two things the browser loop found. chrono::Local traps on wasm without the wasmbind feature, which surfaces as a bare "unreachable" with no message in a release build. And toggling a calendar's visibility silently reordered the sidebar, because the insert wrote position 0 -- position is nullable now, for the same reason colour is: "never arranged" is a different statement from "first". e2e/dev.sh brings the whole stack up seeded with a week of events. It exists partly to record that trunk serve owns dist/: running trunk build against it concurrently rewrites the files without rewriting the integrity hashes in the served index.html, and the browser then blocks its own scripts, which looks like a blank page and is nothing of the sort. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 13 +- .../runway-server/migrations/0001_initial.sql | 6 +- crates/runway-server/src/db/calendars.rs | 22 +- crates/runway-server/src/routes/calendars.rs | 11 +- crates/runway-server/tests/calendars.rs | 34 ++ crates/runway-server/tests/db.rs | 64 ++- crates/runway-web/Cargo.toml | 8 +- crates/runway-web/src/api/calendars.rs | 4 +- crates/runway-web/src/components/app.rs | 113 ++---- crates/runway-web/src/components/header.rs | 312 ++++++++++++++ crates/runway-web/src/components/mod.rs | 8 +- crates/runway-web/src/components/shell.rs | 383 ++++++++++++++++++ crates/runway-web/src/components/sidebar.rs | 209 ++++++++++ crates/runway-web/src/dates.rs | 320 +++++++++++++++ crates/runway-web/src/lib.rs | 1 + crates/runway-web/src/state.rs | 211 +++++++++- e2e/dev.sh | 97 +++++ e2e/seed.py | 61 +++ e2e/shoot.mjs | 64 ++- 21 files changed, 1817 insertions(+), 126 deletions(-) create mode 100644 crates/runway-web/src/components/header.rs create mode 100644 crates/runway-web/src/components/shell.rs create mode 100644 crates/runway-web/src/components/sidebar.rs create mode 100644 crates/runway-web/src/dates.rs create mode 100755 e2e/dev.sh create mode 100644 e2e/seed.py diff --git a/Cargo.lock b/Cargo.lock index dc3e538..c70aabf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2439,6 +2439,7 @@ dependencies = [ "chrono", "console_error_panic_hook", "gloo-net", + "js-sys", "leptos", "leptos_router", "runway-core", diff --git a/Cargo.toml b/Cargo.toml index 8fc1ccc..d628966 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ leptos_router = "0.8" gloo-net = { version = "0.6", features = ["json"] } wasm-bindgen = "0.2" web-sys = "0.3" +js-sys = "0.3" console_error_panic_hook = "0.1" # CLI diff --git a/README.md b/README.md index 3ebda04..2973774 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,21 @@ 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. -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: +The whole stack, for looking at — a throwaway Baikal seeded with a week of +events, the backend, and the frontend: ```sh npx playwright install chromium # once -node e2e/shoot.mjs # screenshots + anything the console said +e2e/dev.sh up # → http://127.0.0.1:8080, testuser/testpassword +e2e/dev.sh shoot # screenshots + anything the console said +e2e/dev.sh down ``` +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: diff --git a/crates/runway-server/migrations/0001_initial.sql b/crates/runway-server/migrations/0001_initial.sql index b920853..081994c 100644 --- a/crates/runway-server/migrations/0001_initial.sql +++ b/crates/runway-server/migrations/0001_initial.sql @@ -101,12 +101,16 @@ CREATE TABLE preferences ( -- is a different statement from any particular colour -- v1 could not express -- it, so it hashed the calendar's path to invent one and disagreed with every -- other client about what colour a calendar was. +-- `position` is nullable for the same reason as `color`: NULL means "never +-- arranged", which is a different statement from "first". Without that +-- distinction, showing or hiding a calendar has to write *some* position, and +-- toggling a checkbox silently reorders the sidebar. CREATE TABLE calendar_settings ( user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, calendar_href TEXT NOT NULL, color TEXT, visible INTEGER NOT NULL DEFAULT 1, - position INTEGER NOT NULL DEFAULT 0, + position INTEGER, updated_at TEXT NOT NULL, PRIMARY KEY (user_id, calendar_href), diff --git a/crates/runway-server/src/db/calendars.rs b/crates/runway-server/src/db/calendars.rs index 728cc50..3b57b61 100644 --- a/crates/runway-server/src/db/calendars.rs +++ b/crates/runway-server/src/db/calendars.rs @@ -22,7 +22,12 @@ pub struct CalendarSetting { /// disagreed with every other client about what colour a calendar was. pub color: Option, pub visible: bool, - pub position: i64, + /// Where this calendar sits in the sidebar, or `None` if nobody has said. + /// + /// Nullable so that showing or hiding a calendar does not have to invent an + /// order for it — writing 0 there means every toggle quietly moves the + /// calendar to the top. + pub position: Option, pub updated_at: DateTime, } @@ -31,7 +36,7 @@ struct SettingRow { calendar_href: String, color: Option, visible: i64, - position: i64, + position: Option, updated_at: String, } @@ -67,7 +72,8 @@ impl<'a> CalendarSettings<'a> { let rows: Vec = sqlx::query_as( "SELECT calendar_href, color, visible, position, updated_at FROM calendar_settings WHERE user_id = ? - ORDER BY position, calendar_href", + -- Arranged calendars first, in the chosen order; the rest after. + ORDER BY position IS NULL, position, calendar_href", ) .bind(user_id.as_str()) .fetch_all(self.db.pool()) @@ -98,7 +104,7 @@ impl<'a> CalendarSettings<'a> { calendar_href: &str, color: Option<&str>, visible: bool, - position: i64, + position: Option, ) -> Result<(), DbError> { sqlx::query( "INSERT INTO calendar_settings @@ -134,8 +140,8 @@ impl<'a> CalendarSettings<'a> { ) -> Result<(), DbError> { sqlx::query( "INSERT INTO calendar_settings - (user_id, calendar_href, visible, position, updated_at) - VALUES (?, ?, ?, 0, ?) + (user_id, calendar_href, visible, updated_at) + VALUES (?, ?, ?, ?) ON CONFLICT (user_id, calendar_href) DO UPDATE SET visible = excluded.visible, updated_at = excluded.updated_at", @@ -161,8 +167,8 @@ impl<'a> CalendarSettings<'a> { ) -> Result<(), DbError> { sqlx::query( "INSERT INTO calendar_settings - (user_id, calendar_href, color, visible, position, updated_at) - VALUES (?, ?, ?, 1, 0, ?) + (user_id, calendar_href, color, visible, updated_at) + VALUES (?, ?, ?, 1, ?) ON CONFLICT (user_id, calendar_href) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at", diff --git a/crates/runway-server/src/routes/calendars.rs b/crates/runway-server/src/routes/calendars.rs index ce75d26..bee7f8e 100644 --- a/crates/runway-server/src/routes/calendars.rs +++ b/crates/runway-server/src/routes/calendars.rs @@ -39,7 +39,9 @@ pub struct CalendarView { /// rather than making a local override indistinguishable from the real one. pub color_source: ColorSource, pub visible: bool, - pub position: i64, + /// `None` when nobody has arranged this calendar. + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, #[serde(skip_serializing_if = "Option::is_none")] pub ctag: Option, pub supported_components: Vec, @@ -104,7 +106,7 @@ fn merge(discovered: Vec, settings: &[CalendarSetting]) -> Vec, settings: &[CalendarSetting]) -> Vec = before + .iter() + .map(|c| c["name"].as_str().unwrap_or_default().to_owned()) + .collect(); + let href = before[0]["href"].clone(); + + api.send( + Method::PATCH, + "/api/calendars", + Some(json!({ "href": href, "visible": false })), + ) + .await; + + let after: Vec = api + .calendars() + .await + .iter() + .map(|c| c["name"].as_str().unwrap_or_default().to_owned()) + .collect(); + assert_eq!(names, after, "the order must not have changed"); + + api.send( + Method::PATCH, + "/api/calendars", + Some(json!({ "href": href, "visible": true })), + ) + .await; +}); + caldav_test!(reordering_leaves_colour_and_visibility_alone, |api| { let href = api .send( diff --git a/crates/runway-server/tests/db.rs b/crates/runway-server/tests/db.rs index bf55ad3..c2100e3 100644 --- a/crates/runway-server/tests/db.rs +++ b/crates/runway-server/tests/db.rs @@ -78,7 +78,7 @@ async fn deleting_a_user_takes_everything_with_it() { .await .unwrap(); db.calendar_settings() - .set(&user.id, "/cal/personal/", Some("#123456"), true, 0) + .set(&user.id, "/cal/personal/", Some("#123456"), true, Some(0)) .await .unwrap(); let feed = db @@ -452,11 +452,11 @@ async fn calendar_settings_are_rows_not_a_json_blob() { let user = a_user(&db).await; db.calendar_settings() - .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) + .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, Some(0)) .await .unwrap(); db.calendar_settings() - .set(&user.id, "/cal/work/", Some("#DC2626"), false, 1) + .set(&user.id, "/cal/work/", Some("#DC2626"), false, Some(1)) .await .unwrap(); @@ -475,11 +475,11 @@ async fn hiding_one_calendar_leaves_the_others_alone() { let db = database().await; let user = a_user(&db).await; db.calendar_settings() - .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, 0) + .set(&user.id, "/cal/personal/", Some("#0CCE6B"), true, Some(0)) .await .unwrap(); db.calendar_settings() - .set(&user.id, "/cal/work/", Some("#DC2626"), true, 1) + .set(&user.id, "/cal/work/", Some("#DC2626"), true, Some(1)) .await .unwrap(); @@ -508,7 +508,7 @@ async fn hiding_one_calendar_leaves_the_others_alone() { Some("#DC2626"), "and toggling visibility must not discard the colour", ); - assert_eq!(work.position, 1, "or the ordering"); + assert_eq!(work.position, Some(1), "or the ordering"); } #[tokio::test] @@ -517,11 +517,11 @@ async fn a_calendar_with_no_stored_colour_is_distinguishable_from_one_set_to_bla let user = a_user(&db).await; db.calendar_settings() - .set(&user.id, "/cal/server-says/", None, true, 0) + .set(&user.id, "/cal/server-says/", None, true, Some(0)) .await .unwrap(); db.calendar_settings() - .set(&user.id, "/cal/black/", Some("#000000"), true, 1) + .set(&user.id, "/cal/black/", Some("#000000"), true, Some(1)) .await .unwrap(); @@ -554,11 +554,11 @@ async fn two_users_keep_separate_settings_for_the_same_calendar() { .unwrap(); db.calendar_settings() - .set(&alex.id, "/cal/shared/", Some("#111111"), true, 0) + .set(&alex.id, "/cal/shared/", Some("#111111"), true, Some(0)) .await .unwrap(); db.calendar_settings() - .set(&sam.id, "/cal/shared/", Some("#222222"), false, 0) + .set(&sam.id, "/cal/shared/", Some("#222222"), false, Some(0)) .await .unwrap(); @@ -586,6 +586,50 @@ async fn two_users_keep_separate_settings_for_the_same_calendar() { // --------------------------------------------------------------------- feeds -- +#[tokio::test] +async fn toggling_visibility_does_not_arrange_a_calendar() { + // Writing a position on a visibility toggle would mean every checkbox + // click silently moved the calendar to the top of the sidebar. + let db = database().await; + let user = a_user(&db).await; + + db.calendar_settings() + .set_visible(&user.id, "/cal/personal/", false) + .await + .unwrap(); + + let stored = db + .calendar_settings() + .get(&user.id, "/cal/personal/") + .await + .unwrap() + .unwrap(); + assert!(!stored.visible); + assert_eq!( + stored.position, None, + "\"never arranged\" is a real state, distinct from \"first\"", + ); +} + +#[tokio::test] +async fn arranged_calendars_come_before_unarranged_ones() { + let db = database().await; + let user = a_user(&db).await; + + db.calendar_settings() + .set_visible(&user.id, "/cal/untouched/", true) + .await + .unwrap(); + db.calendar_settings() + .set_position(&user.id, "/cal/chosen/", 0) + .await + .unwrap(); + + let all = db.calendar_settings().list(&user.id).await.unwrap(); + assert_eq!(all[0].calendar_href, "/cal/chosen/"); + assert_eq!(all[1].position, None); +} + #[tokio::test] async fn feeds_are_created_in_order_and_listed_that_way() { let db = database().await; diff --git a/crates/runway-web/Cargo.toml b/crates/runway-web/Cargo.toml index 26f342b..9c50fb3 100644 --- a/crates/runway-web/Cargo.toml +++ b/crates/runway-web/Cargo.toml @@ -7,7 +7,10 @@ description = "Leptos CSR frontend." [dependencies] runway-core = { workspace = true } -chrono = { workspace = true } +# `wasmbind` is what makes chrono::Local work in a browser. Without it +# `Local::now()` has no clock to read and traps -- which shows up as a bare +# "unreachable" with no message in a release build. +chrono = { workspace = true, features = ["wasmbind"] } leptos = { workspace = true, features = ["csr"] } leptos_router = { workspace = true } serde = { workspace = true } @@ -15,7 +18,8 @@ serde_json = { workspace = true } gloo-net = { workspace = true } wasm-bindgen = { workspace = true } console_error_panic_hook = { workspace = true } -web-sys = { workspace = true, features = ["Document", "Element", "HtmlElement", "Window"] } +web-sys = { workspace = true, features = ["Document", "Element", "HtmlElement", "Location", "Window"] } +js-sys = { workspace = true } [lints] workspace = true diff --git a/crates/runway-web/src/api/calendars.rs b/crates/runway-web/src/api/calendars.rs index 61c6485..c4e4dd6 100644 --- a/crates/runway-web/src/api/calendars.rs +++ b/crates/runway-web/src/api/calendars.rs @@ -17,7 +17,9 @@ pub struct Calendar { pub color: Option, pub color_source: ColorSource, pub visible: bool, - pub position: i64, + /// `None` when nobody has arranged this calendar. + #[serde(default)] + pub position: Option, #[serde(default)] pub ctag: Option, #[serde(default)] diff --git a/crates/runway-web/src/components/app.rs b/crates/runway-web/src/components/app.rs index 8ee3f9c..0818d6f 100644 --- a/crates/runway-web/src/components/app.rs +++ b/crates/runway-web/src/components/app.rs @@ -1,17 +1,19 @@ //! The root component: decides whether anyone is signed in, and shows one of //! two things. //! -//! It owns the decision and nothing else. The session and the preferences live -//! in stores offered through context, so a component that needs them asks, and -//! the ones in between are not made to carry them. v1's equivalent was 2,008 -//! lines holding 35 `use_state` hooks and 265 `.clone()` calls to prop-drill -//! them down. +//! It owns that decision and nothing else. The session, the preferences, the +//! calendars and the feeds live in stores offered through context, so a +//! component that needs them asks, and the ones in between are not made to +//! carry them. v1's equivalent was 2,008 lines holding 35 `use_state` hooks and +//! 265 `.clone()` calls to prop-drill them down. use crate::api::{self, User}; -use crate::components::{Login, Toolbar}; -use crate::state::{Auth, PreferencesStore, provide_stores}; +use crate::components::{Login, Shell}; +use crate::state::provide_stores; use crate::theme::apply; use leptos::prelude::*; +use leptos_router::components::{Route, Router, Routes}; +use leptos_router::path; /// What is known about the session so far. #[derive(Clone, PartialEq)] @@ -24,12 +26,12 @@ enum Status { #[component] pub fn App() -> impl IntoView { - let (auth, preferences) = provide_stores(); + let stores = provide_stores(); let status = RwSignal::new(Status::Checking); // The single place either token axis reaches the document. Runs whenever // the stored preferences change, including the moment they arrive. - Effect::new(move |_| apply(preferences.theme(), preferences.style())); + Effect::new(move |_| apply(stores.preferences.theme(), stores.preferences.style())); // Ask once, on load, whether the cookie the browser is holding is still // good. The token is HttpOnly, so this is the only way to find out. @@ -37,19 +39,15 @@ pub fn App() -> impl IntoView { leptos::task::spawn_local(async move { match api::session().await { Ok(Some(response)) => { - auth.signed_in(response.user.clone()); - preferences.load(); + stores.auth.signed_in(response.user.clone()); + stores.load_for_session(); status.set(Status::SignedIn(response.user)); } - Ok(None) => { - preferences.use_defaults(); - status.set(Status::SignedOut); - } // A server that cannot be reached is not the same as being // signed out, but there is nothing useful to show either way // except the login screen. - Err(_) => { - preferences.use_defaults(); + Ok(None) | Err(_) => { + stores.preferences.use_defaults(); status.set(Status::SignedOut); } } @@ -57,8 +55,8 @@ pub fn App() -> impl IntoView { }); let on_signed_in = Callback::new(move |account: User| { - auth.signed_in(account.clone()); - preferences.load(); + stores.auth.signed_in(account.clone()); + stores.load_for_session(); status.set(Status::SignedIn(account)); }); @@ -67,7 +65,21 @@ pub fn App() -> impl IntoView { {move || match status.get() { Status::Checking => view! { }.into_any(), Status::SignedOut => view! { }.into_any(), - Status::SignedIn(account) => view! { }.into_any(), + // The view and the focused date live in the URL, so the router + // only appears once somebody is signed in and there is + // something for those to mean. + Status::SignedIn(_) => { + view! { + + + + + + + + } + .into_any() + } }} } @@ -85,64 +97,3 @@ fn Splash() -> impl IntoView { } } - -/// A placeholder for the calendar, which arrives with the app shell. -#[component] -fn SignedIn(account: User) -> impl IntoView { - let auth = expect_context::(); - let preferences = expect_context::(); - let signing_out = RwSignal::new(false); - - let sign_out = move |_| { - signing_out.set(true); - leptos::task::spawn_local(async move { - let _ = api::logout().await; - auth.signed_out(); - // The session is gone server-side; reloading is the simplest way - // back to a clean state without inventing a second path through - // the same decision. - if let Some(window) = web_sys::window() { - let _ = window.location().reload(); - } - }); - }; - - view! { -
- - -
-
-

- "Signed in as " {account.username.clone()} -

-

- "Connected to " {account.server_url.clone()} -

-

- {move || { - let current = preferences.get(); - format!( - "Saved on the server: {} theme, {} density, opens on {}, \ - {}-minute grid.", - current.theme, - current.style, - current.view.label().to_lowercase(), - current.time_increment, - ) - }} -

-

- "These survive a reload because they live on the server, not in \ - localStorage." -

-
-
-
- } -} diff --git a/crates/runway-web/src/components/header.rs b/crates/runway-web/src/components/header.rs new file mode 100644 index 0000000..cccebcb --- /dev/null +++ b/crates/runway-web/src/components/header.rs @@ -0,0 +1,312 @@ +//! The header: where you are, and how to go somewhere else. +//! +//! The view and the focused date are in the URL, not in a signal and not in +//! `localStorage`. That makes the back button work, makes a week shareable, +//! and means "which week am I looking at" has exactly one answer — v1 kept the +//! selected date in `localStorage` *and* in the database with no defined source +//! of truth between them. + +use crate::api::User; +use crate::dates; +use crate::state::PreferencesStore; +use chrono::NaiveDate; +use leptos::prelude::*; +use runway_core::model::{Preferences, View}; + +#[component] +pub fn Header( + view: Signal, + focus: Signal, + today: NaiveDate, + account: User, + on_navigate: Callback<(View, NaiveDate)>, + signing_out: RwSignal, + sign_out: impl Fn(leptos::ev::MouseEvent) + 'static, +) -> impl IntoView { + let preferences = expect_context::(); + + let title = move || dates::title(view.get(), focus.get(), preferences.get().week_starts_on); + let step = move |forward: bool| { + let next = dates::step(view.get(), focus.get(), forward); + on_navigate.run((view.get(), next)); + }; + + view! { +
+
+ "Runway" + +
+ + +
+ + + +

+ {title} +

+
+ +
+ + + + {account.name().to_owned()} + + +
+
+ } +} + +#[component] +fn Step(label: &'static str, hint: &'static str) -> impl IntoView { + view! { + + } +} + +/// Which view is showing. A row of buttons rather than a select: there are five +/// and switching between them is the most common thing anyone does here. +#[component] +fn ViewSwitcher( + view: Signal, + focus: Signal, + on_navigate: Callback<(View, NaiveDate)>, +) -> impl IntoView { + view! { +
+ {View::ALL + .into_iter() + .map(|option| { + let selected = move || view.get() == option; + view! { + + } + }) + .collect_view()} +
+ } +} + +/// The token axes and the settings that go with them, folded away. +/// +/// They belong to the person rather than to the moment, so they do not need to +/// be in the way. Native `
` rather than a hand-built popover: it +/// closes on Escape and is reachable by keyboard without any of that being +/// written here. +#[component] +fn Settings() -> impl IntoView { + let preferences = expect_context::(); + + view! { +
+ + "Settings" + +
+ + + + () { + preferences.update(|p| p.week_starts_on = day); + } + }) + /> + () { + preferences.update(|p| p.time_increment = minutes); + } + }) + /> + + {move || { + if let Some(failed) = preferences.error() { + view! { +

+ {format!("Not saved: {}", failed.message)} +

+ } + .into_any() + } else if preferences.is_saving() { + view! { +

+ "Saving…" +

+ } + .into_any() + } else { + ().into_any() + } + }} +
+
+ } +} + +#[component] +fn Picker( + label: &'static str, + testid: &'static str, + current: Signal, + options: Vec<(String, String)>, + on_pick: Callback, +) -> impl IntoView { + view! { + + } +} diff --git a/crates/runway-web/src/components/mod.rs b/crates/runway-web/src/components/mod.rs index 9abcd3f..350ab42 100644 --- a/crates/runway-web/src/components/mod.rs +++ b/crates/runway-web/src/components/mod.rs @@ -1,9 +1,13 @@ //! Components. mod app; +mod header; mod login; -mod toolbar; +mod shell; +mod sidebar; pub use app::App; +pub use header::Header; pub use login::Login; -pub use toolbar::Toolbar; +pub use shell::Shell; +pub use sidebar::Sidebar; diff --git a/crates/runway-web/src/components/shell.rs b/crates/runway-web/src/components/shell.rs new file mode 100644 index 0000000..3ac6781 --- /dev/null +++ b/crates/runway-web/src/components/shell.rs @@ -0,0 +1,383 @@ +//! The signed-in page: header, sidebar, and whatever the current view is. +//! +//! The view and the focused date come from the URL. Everything else that +//! matters — which calendars are shown, what the theme is — comes from a store. +//! Nothing is passed down through components that do not use it. + +use crate::api::{self, User}; +use crate::components::{Header, Sidebar}; +use crate::dates::{self, DateRange}; +use crate::state::{Auth, CalendarsStore, FeedsStore, PreferencesStore}; +use chrono::{Local, NaiveDate}; +use leptos::prelude::*; +use leptos_router::hooks::{use_navigate, use_params_map}; +use runway_core::model::View; + +/// Today, where the person is. +pub fn today() -> NaiveDate { + Local::now().date_naive() +} + +/// The browser's own time zone. +/// +/// Asked of the browser rather than assumed, and overridden by the stored +/// preference when there is one. A zone belongs to the reader, and readers +/// move. +fn browser_timezone() -> String { + let options = js_sys::Intl::DateTimeFormat::new(&js_sys::Array::new(), &js_sys::Object::new()) + .resolved_options(); + js_sys::Reflect::get(&options, &"timeZone".into()) + .ok() + .and_then(|value| value.as_string()) + // An IANA identifier has an area and a location. Anything else is not + // one, and guessing would be worse than saying UTC out loud. + .filter(|zone| zone.contains('/')) + .unwrap_or_else(|| "UTC".to_owned()) +} + +#[component] +pub fn Shell() -> impl IntoView { + let auth = expect_context::(); + let preferences = expect_context::(); + let calendars = expect_context::(); + let feeds = expect_context::(); + let params = use_params_map(); + let navigate = use_navigate(); + let today = today(); + + // The URL is the source of truth for both. A missing or unreadable segment + // falls back to the preference and to today rather than erroring: a + // mistyped link should still show a calendar. + let view = Signal::derive(move || { + params + .read() + .get("view") + .and_then(|value| View::parse(&value).ok()) + .unwrap_or_else(|| preferences.get().view) + }); + let focus = Signal::derive(move || { + params + .read() + .get("date") + .and_then(|value| NaiveDate::parse_from_str(&value, "%Y-%m-%d").ok()) + .unwrap_or(today) + }); + + let on_navigate = Callback::new({ + let navigate = navigate.clone(); + move |(view, date): (View, NaiveDate)| { + navigate(&format!("/{}/{date}", view.as_str()), Default::default()); + } + }); + + let signing_out = RwSignal::new(false); + let sign_out = move |_| { + signing_out.set(true); + leptos::task::spawn_local(async move { + let _ = api::logout().await; + auth.signed_out(); + if let Some(window) = web_sys::window() { + let _ = window.location().assign("/").ok(); + } + }); + }; + + let account = auth.user().unwrap_or_else(|| User { + id: String::new(), + username: String::new(), + server_url: String::new(), + display_name: None, + }); + + view! { +
+
+
+ + +
+
+ } +} + +/// What the current view shows. +/// +/// A plain list for now: the grids arrive with their own milestones. It is +/// wired to the real endpoints, so it is what proves the whole path works — +/// CalDAV fetch, server-side expansion, visible-calendar filter and all. +#[component] +fn ViewArea( + view: Signal, + focus: Signal, + calendars: CalendarsStore, + feeds: FeedsStore, +) -> impl IntoView { + let preferences = expect_context::(); + let occurrences = RwSignal::new(Vec::::new()); + let from_feeds = RwSignal::new(Vec::::new()); + let warnings = RwSignal::new(Vec::::new()); + let loading = RwSignal::new(false); + let error = RwSignal::new(None::); + + let range = Signal::derive(move || { + dates::range_for(view.get(), focus.get(), preferences.get().week_starts_on) + }); + + // Refetches when the range changes, when a calendar is shown or hidden, or + // when the zone does. Asking the server for the range rather than + // everything is the whole point of the time-range filter v1 never used. + Effect::new(move |_| { + let DateRange { start, end } = range.get(); + let visible = calendars.visible_hrefs(); + let zone = preferences + .get() + .display_timezone + .unwrap_or_else(browser_timezone); + let has_feeds = feeds.all().iter().any(|feed| feed.visible); + + loading.set(true); + leptos::task::spawn_local(async move { + match api::events(start, end, &zone, &visible).await { + Ok(response) => { + occurrences.set(response.occurrences); + warnings.set(response.warnings); + error.set(None); + } + Err(failed) => error.set(Some(failed)), + } + + if has_feeds { + match api::feed_events(start, end, &zone, false).await { + Ok(response) => { + from_feeds.set(response.occurrences); + // A feed that could not be read is said out loud: one + // quietly missing from the week looks exactly like one + // with nothing in it. + warnings.update(|list| { + list.extend( + response + .failures + .into_iter() + .map(|f| format!("{}: {}", f.feed_name, f.message)), + ); + }); + } + Err(failed) => error.set(Some(failed)), + } + } else { + from_feeds.set(Vec::new()); + } + loading.set(false); + }); + }); + + view! { +
+
+

+ {move || view.get().label()} " view" +

+ + {move || { + let range = range.get(); + format!( + "{} → {} ({} days)", + range.start, + range.end.pred_opt().unwrap_or(range.end), + range.days(), + ) + }} + + {move || { + loading + .get() + .then(|| { + view! { + "Loading…" + } + }) + }} +
+ + {move || { + error + .get() + .map(|failed| { + view! { + + } + }) + }} + + {move || { + let list = warnings.get(); + (!list.is_empty()) + .then(|| { + view! { +
    + {list + .into_iter() + .map(|text| view! {
  • {text}
  • }) + .collect_view()} +
+ } + }) + }} + + +
+ } +} + +/// Everything in the range, in order. +/// +/// Deliberately unstyled beyond the tokens: it is a placeholder that proves the +/// data is right before any pixel arithmetic depends on it. +#[component] +fn Agenda( + occurrences: RwSignal>, + from_feeds: RwSignal>, +) -> impl IntoView { + view! { + {move || { + let mut rows: Vec<(chrono::DateTime, String, String, Option)> = + occurrences + .get() + .into_iter() + .map(|placed| { + ( + placed.occurrence.start_utc, + when(&placed.occurrence), + placed.occurrence.title().unwrap_or("(untitled)").to_owned(), + None, + ) + }) + .collect(); + rows.extend( + from_feeds + .get() + .into_iter() + .map(|placed| { + ( + placed.occurrence.start_utc, + when(&placed.occurrence), + placed.occurrence.title().unwrap_or("(untitled)").to_owned(), + Some(placed.feed_name), + ) + }), + ); + rows.sort_by_key(|(instant, ..)| *instant); + + if rows.is_empty() { + return view! { +

+ "Nothing in this range." +

+ } + .into_any(); + } + + let count = rows.len(); + view! { +
+

+ {format!("{count} occurrence(s)")} +

+
    + {rows + .into_iter() + .map(|(_, at, title, feed)| { + view! { +
  • + + {at} + + {title} + {feed + .map(|name| { + view! { + + {name} + + } + })} +
  • + } + }) + .collect_view()} +
+
+ } + .into_any() + }} + } +} + +/// How an occurrence's time reads. +/// +/// All-day events have no clock time, and saying "00:00" for one is a small lie +/// that the grids would then have to work around. +fn when(occurrence: &runway_core::model::Occurrence) -> String { + if occurrence.is_all_day() { + format!("{} · all day", occurrence.start.date()) + } else { + format!( + "{} {}", + occurrence.start.date(), + occurrence.start.naive_local().format("%H:%M"), + ) + } +} diff --git a/crates/runway-web/src/components/sidebar.rs b/crates/runway-web/src/components/sidebar.rs new file mode 100644 index 0000000..7e84976 --- /dev/null +++ b/crates/runway-web/src/components/sidebar.rs @@ -0,0 +1,209 @@ +//! The sidebar: what can be shown, and whether it is. +//! +//! Calendars and feeds are one list of sources with one kind of control, +//! because that is what they are to somebody reading a calendar. The colour +//! shown is the calendar's own — read from the CalDAV server, not invented. +//! v1 hashed the calendar's path to pick one and disagreed with every other +//! client about what colour a calendar was. + +use crate::api::{Calendar, ColorSource, Feed}; +use crate::state::{CalendarsStore, FeedsStore}; +use leptos::prelude::*; + +#[component] +pub fn Sidebar() -> impl IntoView { + let calendars = expect_context::(); + let feeds = expect_context::(); + + view! { + + } +} + +#[component] +fn Section(title: &'static str, children: Children) -> impl IntoView { + view! { +
+

+ {title} +

+ {children()} +
+ } +} + +#[component] +fn Note(text: &'static str) -> impl IntoView { + view! { +

{text}

+ } +} + +#[component] +fn CalendarRow(calendar: Calendar) -> impl IntoView { + let store = expect_context::(); + let href = calendar.href.clone(); + let visible = calendar.visible; + + view! { + + } +} + +#[component] +fn FeedRow(feed: Feed) -> impl IntoView { + let store = expect_context::(); + let id = feed.id.clone(); + let visible = feed.visible; + + view! { + + } +} + +/// One source: a swatch, a name, and whether it is shown. +#[component] +fn Row( + label: String, + color: Option, + unset: bool, + visible: bool, + testid: String, + on_toggle: Callback, +) -> impl IntoView { + let shown = RwSignal::new(visible); + // A calendar the server has no colour for gets the accent rather than an + // invented one, and the interface can say so later. + let swatch = color.unwrap_or_else(|| "var(--accent)".to_owned()); + let title = if unset { + format!("{label} — no colour set on the server") + } else { + label.clone() + }; + + view! { + + } +} + +/// A stable test handle from a display name. +fn slug(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_owned() +} diff --git a/crates/runway-web/src/dates.rs b/crates/runway-web/src/dates.rs new file mode 100644 index 0000000..32cd8b3 --- /dev/null +++ b/crates/runway-web/src/dates.rs @@ -0,0 +1,320 @@ +//! Which days a view covers, and how to move between them. +//! +//! Pure arithmetic, kept away from the components so it can be tested without a +//! browser. The previous iteration did this sort of calculation inline in a +//! 1,431-line week view, where the only way to check it was to build, deploy +//! and look. + +use chrono::{Datelike, Duration, Months, NaiveDate, Weekday}; +use runway_core::model::View; + +/// The half-open span of days a view shows: `[start, end)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DateRange { + pub start: NaiveDate, + /// Exclusive, matching the API's `to` and the recurrence window. + pub end: NaiveDate, +} + +impl DateRange { + pub fn days(&self) -> i64 { + (self.end - self.start).num_days() + } + + pub fn contains(&self, date: NaiveDate) -> bool { + date >= self.start && date < self.end + } + + /// Every day in the range. + pub fn iter(&self) -> impl Iterator { + let (start, end) = (self.start, self.end); + std::iter::successors(Some(start), move |date| { + let next = date.succ_opt()?; + (next < end).then_some(next) + }) + } +} + +/// The first day of the week, from the stored preference. +/// +/// 0 is Sunday, matching `Weekday::num_days_from_sunday` — and matching the +/// column, so there is no second convention to remember. +pub fn week_start(week_starts_on: u8) -> Weekday { + match week_starts_on % 7 { + 1 => Weekday::Mon, + 2 => Weekday::Tue, + 3 => Weekday::Wed, + 4 => Weekday::Thu, + 5 => Weekday::Fri, + 6 => Weekday::Sat, + _ => Weekday::Sun, + } +} + +/// The days a view covers around a focused date. +/// +/// A month view shows whole weeks, not just the month: the grid has to start on +/// the week's first day and end on its last, or the first row is ragged. That +/// is why this returns a range rather than the month's own bounds. +pub fn range_for(view: View, focus: NaiveDate, week_starts_on: u8) -> DateRange { + let first_day = week_start(week_starts_on); + match view { + View::Day => DateRange { + start: focus, + end: focus.succ_opt().unwrap_or(focus), + }, + View::Week => { + let start = start_of_week(focus, first_day); + DateRange { + start, + end: start + Duration::days(7), + } + } + View::Month => { + let first = focus.with_day(1).unwrap_or(focus); + let start = start_of_week(first, first_day); + let next_month = first.checked_add_months(Months::new(1)).unwrap_or(first); + // Whole weeks, so the last row is complete even when the month ends + // mid-week. Six rows for a month that needs them, five otherwise — + // v1 had a bug fixed by "Fix print preview to display all 6 rows + // for 6-week months". + let end = start_of_week(next_month.pred_opt().unwrap_or(next_month), first_day) + + Duration::days(7); + DateRange { start, end } + } + View::Agenda => DateRange { + start: focus, + end: focus + Duration::days(30), + }, + View::Year => { + let start = NaiveDate::from_ymd_opt(focus.year(), 1, 1).unwrap_or(focus); + let end = NaiveDate::from_ymd_opt(focus.year() + 1, 1, 1).unwrap_or(focus); + DateRange { start, end } + } + } +} + +fn start_of_week(date: NaiveDate, first_day: Weekday) -> NaiveDate { + let offset = i64::from( + date.weekday().num_days_from_sunday() as i64 as u32 + 7 - first_day.num_days_from_sunday(), + ) % 7; + date - Duration::days(offset) +} + +/// The date a view moves to when stepping forward or back. +/// +/// Stepping a month from the 31st lands on the last day of a shorter month +/// rather than skipping it — `checked_add_months` clamps, which is what a +/// person means by "next month". +pub fn step(view: View, focus: NaiveDate, forward: bool) -> NaiveDate { + let direction = if forward { 1 } else { -1 }; + match view { + View::Day => focus + Duration::days(direction), + View::Week => focus + Duration::days(7 * direction), + View::Agenda => focus + Duration::days(30 * direction), + View::Month => shift_months(focus, direction), + View::Year => shift_months(focus, 12 * direction), + } +} + +fn shift_months(date: NaiveDate, months: i64) -> NaiveDate { + let count = Months::new(months.unsigned_abs() as u32); + let shifted = if months >= 0 { + date.checked_add_months(count) + } else { + date.checked_sub_months(count) + }; + shifted.unwrap_or(date) +} + +/// What the header calls the current period. +pub fn title(view: View, focus: NaiveDate, week_starts_on: u8) -> String { + let range = range_for(view, focus, week_starts_on); + match view { + View::Day => focus.format("%A %-d %B %Y").to_string(), + View::Week => { + let last = range.end.pred_opt().unwrap_or(range.end); + // "25 – 31 August 2026" rather than repeating the month and year + // when they do not change, and "30 August – 5 September 2026" when + // they do. + if range.start.year() != last.year() { + format!( + "{} – {}", + range.start.format("%-d %b %Y"), + last.format("%-d %b %Y"), + ) + } else if range.start.month() != last.month() { + format!( + "{} – {}", + range.start.format("%-d %B"), + last.format("%-d %B %Y"), + ) + } else { + format!( + "{} – {}", + range.start.format("%-d"), + last.format("%-d %B %Y"), + ) + } + } + View::Month => focus.format("%B %Y").to_string(), + View::Agenda => format!( + "{} – {}", + range.start.format("%-d %b"), + range + .end + .pred_opt() + .unwrap_or(range.end) + .format("%-d %b %Y"), + ), + View::Year => focus.format("%Y").to_string(), + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).expect("a real date") + } + + #[test] + fn a_week_starts_on_the_chosen_day() { + // 2026-08-26 is a Wednesday. + let wednesday = date(2026, 8, 26); + + assert_eq!( + range_for(View::Week, wednesday, 0).start, + date(2026, 8, 23), + "Sunday", + ); + assert_eq!( + range_for(View::Week, wednesday, 1).start, + date(2026, 8, 24), + "Monday", + ); + assert_eq!(range_for(View::Week, wednesday, 0).days(), 7); + } + + #[test] + fn a_week_already_starting_on_the_first_day_does_not_move() { + let sunday = date(2026, 8, 23); + + assert_eq!(range_for(View::Week, sunday, 0).start, sunday); + } + + #[test] + fn a_month_covers_whole_weeks() { + // August 2026 starts on a Saturday and ends on a Monday, so a + // Sunday-first grid needs six rows. + let range = range_for(View::Month, date(2026, 8, 15), 0); + + assert_eq!(range.start, date(2026, 7, 26)); + assert_eq!(range.days() % 7, 0, "the grid must be whole weeks"); + assert_eq!( + range.days(), + 42, + "six rows -- v1 shipped a bug where a six-week month lost its last \ + row in print", + ); + assert!(range.contains(date(2026, 8, 31))); + } + + #[test] + fn a_month_that_fits_in_five_rows_uses_five() { + // February 2027 starts on a Monday and has 28 days. + let range = range_for(View::Month, date(2027, 2, 10), 1); + + assert_eq!(range.days(), 28, "four rows, exactly"); + assert_eq!(range.start, date(2027, 2, 1)); + } + + #[test] + fn a_day_is_one_day() { + let range = range_for(View::Day, date(2026, 8, 26), 0); + + assert_eq!(range.days(), 1); + assert!(range.contains(date(2026, 8, 26))); + assert!(!range.contains(date(2026, 8, 27))); + } + + #[test] + fn a_year_is_the_calendar_year() { + let range = range_for(View::Year, date(2026, 8, 26), 0); + + assert_eq!(range.start, date(2026, 1, 1)); + assert_eq!(range.end, date(2027, 1, 1)); + assert_eq!(range.days(), 365); + } + + #[test] + fn a_leap_year_has_a_leap_day() { + assert_eq!(range_for(View::Year, date(2028, 5, 1), 0).days(), 366); + } + + #[test] + fn stepping_a_month_from_the_31st_clamps_rather_than_skipping() { + // The trap: adding 31 days to 31 January lands in March and February + // never appears. + assert_eq!( + step(View::Month, date(2026, 1, 31), true), + date(2026, 2, 28) + ); + assert_eq!( + step(View::Month, date(2026, 3, 31), false), + date(2026, 2, 28) + ); + } + + #[test] + fn stepping_forward_and_back_returns_to_the_same_week() { + let start = date(2026, 8, 26); + + assert_eq!( + step(View::Week, step(View::Week, start, true), false), + start + ); + assert_eq!(step(View::Day, step(View::Day, start, true), false), start); + } + + #[test] + fn stepping_a_year_moves_a_year() { + assert_eq!(step(View::Year, date(2026, 8, 26), true), date(2027, 8, 26)); + } + + #[test] + fn a_range_lists_every_day_in_it() { + let range = range_for(View::Week, date(2026, 8, 26), 0); + let days: Vec = range.iter().collect(); + + assert_eq!(days.len(), 7); + assert_eq!(days[0], range.start); + assert_eq!(days[6], date(2026, 8, 29)); + } + + #[test] + fn titles_do_not_repeat_what_has_not_changed() { + assert_eq!(title(View::Month, date(2026, 8, 15), 0), "August 2026"); + assert_eq!( + title(View::Week, date(2026, 8, 26), 0), + "23 – 29 August 2026", + "one month, so it is named once", + ); + assert_eq!( + title(View::Week, date(2026, 9, 2), 0), + "30 August – 5 September 2026", + "two months, so both are named", + ); + assert_eq!(title(View::Year, date(2026, 8, 26), 0), "2026"); + } + + #[test] + fn a_week_spanning_new_year_names_both_years() { + assert_eq!( + title(View::Week, date(2026, 12, 31), 0), + "27 Dec 2026 – 2 Jan 2027", + ); + } +} diff --git a/crates/runway-web/src/lib.rs b/crates/runway-web/src/lib.rs index 0a8e8cb..bd2e8d3 100644 --- a/crates/runway-web/src/lib.rs +++ b/crates/runway-web/src/lib.rs @@ -7,5 +7,6 @@ pub mod api; pub mod components; +pub mod dates; pub mod state; pub mod theme; diff --git a/crates/runway-web/src/state.rs b/crates/runway-web/src/state.rs index aa2ed63..4fa5343 100644 --- a/crates/runway-web/src/state.rs +++ b/crates/runway-web/src/state.rs @@ -210,13 +210,37 @@ impl Default for PreferencesStore { } } -/// Puts both stores in scope. -pub fn provide_stores() -> (Auth, PreferencesStore) { - let auth = Auth::new(); - let preferences = PreferencesStore::new(); - provide_context(auth); - provide_context(preferences); - (auth, preferences) +/// Everything a signed-in page can ask for. +#[derive(Clone, Copy)] +pub struct Stores { + pub auth: Auth, + pub preferences: PreferencesStore, + pub calendars: CalendarsStore, + pub feeds: FeedsStore, +} + +impl Stores { + /// Loads everything that belongs to a session. + pub fn load_for_session(&self) { + self.preferences.load(); + self.calendars.load(); + self.feeds.load(); + } +} + +/// Puts every store in scope. +pub fn provide_stores() -> Stores { + let stores = Stores { + auth: Auth::new(), + preferences: PreferencesStore::new(), + calendars: CalendarsStore::new(), + feeds: FeedsStore::new(), + }; + provide_context(stores.auth); + provide_context(stores.preferences); + provide_context(stores.calendars); + provide_context(stores.feeds); + stores } pub fn auth() -> Auth { @@ -226,3 +250,176 @@ pub fn auth() -> Auth { pub fn preferences() -> PreferencesStore { expect_context::() } + +/// The calendars on the server, with this person's view of them. +/// +/// One writer, and toggles apply locally before the round trip: a checkbox that +/// waits for a server to visibly change feels broken. A refused change goes +/// back and says why rather than leaving the interface showing something that +/// was not stored. +#[derive(Clone, Copy)] +pub struct CalendarsStore { + items: RwSignal>, + loading: RwSignal, + error: RwSignal>, +} + +impl CalendarsStore { + pub fn new() -> Self { + Self { + items: RwSignal::new(Vec::new()), + loading: RwSignal::new(false), + error: RwSignal::new(None), + } + } + + pub fn all(&self) -> Vec { + self.items.get() + } + + /// The hrefs to ask the events API for. + /// + /// Filtering here rather than fetching everything and hiding it in the + /// browser: v1 fetched every calendar on every view change and filtered + /// client-side. + pub fn visible_hrefs(&self) -> Vec { + self.items + .get() + .into_iter() + .filter(|calendar| calendar.visible) + .map(|calendar| calendar.href) + .collect() + } + + pub fn is_loading(&self) -> bool { + self.loading.get() + } + + pub fn error(&self) -> Option { + self.error.get() + } + + pub fn load(self) { + self.loading.set(true); + leptos::task::spawn_local(async move { + match api::calendars().await { + Ok(found) => { + self.items.set(found); + self.error.set(None); + } + Err(failed) => self.error.set(Some(failed)), + } + self.loading.set(false); + }); + } + + /// Shows or hides one calendar, for this person only. + pub fn set_visible(self, href: &str, visible: bool) { + let previous = self.items.get(); + self.items.update(|items| { + if let Some(found) = items.iter_mut().find(|c| c.href == href) { + found.visible = visible; + } + }); + + let request = api::UpdateCalendar { + visible: Some(visible), + ..api::UpdateCalendar::to(href) + }; + leptos::task::spawn_local(async move { + match api::update_calendar(&request).await { + Ok(updated) => { + self.items.set(updated); + self.error.set(None); + } + Err(failed) => { + self.items.set(previous); + self.error.set(Some(failed)); + } + } + }); + } +} + +impl Default for CalendarsStore { + fn default() -> Self { + Self::new() + } +} + +/// Subscribed feeds, alongside the calendars in the sidebar. +#[derive(Clone, Copy)] +pub struct FeedsStore { + items: RwSignal>, + error: RwSignal>, +} + +impl FeedsStore { + pub fn new() -> Self { + Self { + items: RwSignal::new(Vec::new()), + error: RwSignal::new(None), + } + } + + pub fn all(&self) -> Vec { + self.items.get() + } + + pub fn error(&self) -> Option { + self.error.get() + } + + pub fn load(self) { + leptos::task::spawn_local(async move { + match api::feeds().await { + Ok(found) => { + self.items.set(found); + self.error.set(None); + } + Err(failed) => self.error.set(Some(failed)), + } + }); + } + + pub fn set_visible(self, id: &str, visible: bool) { + let previous = self.items.get(); + self.items.update(|items| { + if let Some(found) = items.iter_mut().find(|f| f.id == id) { + found.visible = visible; + } + }); + + let request = api::UpdateFeed { + id: id.to_owned(), + visible: Some(visible), + ..api::UpdateFeed::default() + }; + leptos::task::spawn_local(async move { + match api::update_feed(&request).await { + Ok(updated) => { + self.items.set(updated); + self.error.set(None); + } + Err(failed) => { + self.items.set(previous); + self.error.set(Some(failed)); + } + } + }); + } +} + +impl Default for FeedsStore { + fn default() -> Self { + Self::new() + } +} + +pub fn calendars() -> CalendarsStore { + expect_context::() +} + +pub fn feeds() -> FeedsStore { + expect_context::() +} diff --git a/e2e/dev.sh b/e2e/dev.sh new file mode 100755 index 0000000..c45380c --- /dev/null +++ b/e2e/dev.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Bring the whole stack up for looking at, or take it down again. +# +# e2e/dev.sh up # Baikal, backend, frontend, seeded with a week of events +# e2e/dev.sh down +# e2e/dev.sh shoot # screenshots of whatever is running +# +# `trunk serve` owns crates/runway-web/dist. Running `trunk build` against the +# same directory while it is serving rewrites the files without rewriting the +# integrity hashes in the served index.html, and the browser then blocks its own +# scripts -- which looks like a blank page and is nothing of the sort. +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +RUN="${RUNWAY_DEV_DIR:-${TMPDIR:-/tmp}/runway-dev}" +CALDAV_PORT=8800 +API_PORT=3000 +WEB_PORT=8080 + +runtime() { + if command -v podman >/dev/null 2>&1; then echo podman + elif command -v docker >/dev/null 2>&1; then echo docker + else echo "need podman or docker" >&2; exit 1 + fi +} + +wait_for() { + local url="$1" name="$2" + for _ in $(seq 1 90); do + if [ "$(curl -sS -o /dev/null -w '%{http_code}' -L "$url" 2>/dev/null)" = "200" ]; then + return 0 + fi + sleep 2 + done + echo "$name never came up at $url" >&2 + return 1 +} + +up() { + mkdir -p "$RUN" + local rt; rt="$(runtime)" + + "$rt" rm -f runway-dev-caldav >/dev/null 2>&1 || true + "$rt" run -d --rm --name runway-dev-caldav -p "$CALDAV_PORT:80" \ + docker.io/ckulka/baikal:nginx >/dev/null + wait_for "http://localhost:$CALDAV_PORT/" "Baikal" + python3 "$ROOT/crates/runway-caldav/tests/baikal/setup.py" \ + "http://localhost:$CALDAV_PORT" testuser testpassword + python3 "$HERE/seed.py" + + if [ ! -f "$RUN/secret.key" ]; then + (cd "$ROOT" && cargo run -q -p runway-server -- genkey) > "$RUN/secret.key" + fi + + pkill -f "target/.*/runway-server" >/dev/null 2>&1 || true + rm -f "$RUN"/runway.db* + ( + cd "$ROOT" + RUNWAY_SECRET_KEY="$(cat "$RUN/secret.key")" \ + RUNWAY_DATABASE_URL="sqlite:$RUN/runway.db" \ + RUNWAY_INSECURE_COOKIES=1 \ + RUNWAY_BIND="127.0.0.1:$API_PORT" \ + setsid cargo run -q -p runway-server > "$RUN/server.log" 2>&1 < /dev/null & + disown + ) + wait_for "http://127.0.0.1:$API_PORT/api/health" "the backend" + + pkill -f "trunk serve" >/dev/null 2>&1 || true + rm -rf "$ROOT/crates/runway-web/dist" + ( + cd "$ROOT/crates/runway-web" + setsid trunk serve "${TRUNK_ARGS:---release}" > "$RUN/trunk.log" 2>&1 < /dev/null & + disown + ) + wait_for "http://127.0.0.1:$WEB_PORT/" "the frontend" + + echo + echo " calendar server http://localhost:$CALDAV_PORT testuser / testpassword" + echo " backend http://127.0.0.1:$API_PORT" + echo " app http://127.0.0.1:$WEB_PORT" + echo " logs $RUN" +} + +down() { + pkill -f "trunk serve" >/dev/null 2>&1 || true + pkill -f "target/.*/runway-server" >/dev/null 2>&1 || true + "$(runtime)" rm -f runway-dev-caldav >/dev/null 2>&1 || true + echo "stopped" +} + +case "${1:-up}" in + up) up ;; + down) down ;; + shoot) node "$HERE/shoot.mjs" "http://127.0.0.1:$WEB_PORT" "$ROOT/e2e/screenshots" ;; + *) echo "usage: dev.sh [up|down|shoot]" >&2; exit 1 ;; +esac diff --git a/e2e/seed.py b/e2e/seed.py new file mode 100644 index 0000000..311f140 --- /dev/null +++ b/e2e/seed.py @@ -0,0 +1,61 @@ +"""Puts a plausible week of events on the development Baikal. + +Enough shape to tell whether a view is right by looking at it: overlapping +meetings, an all-day series, a recurring weekday standup, and an event that +crosses midnight. Not a fixture -- the tests use their own data. +""" +import base64, urllib.request, uuid, datetime + +BASE = "http://localhost:8800/dav.php/calendars/testuser" +AUTH = "Basic " + base64.b64encode(b"testuser:testpassword").decode() +TZ = "America/New_York" + +def dav(method, path, body=None, ctype="text/calendar; charset=utf-8"): + req = urllib.request.Request(f"{BASE}{path}", data=body.encode() if body else None, method=method) + req.add_header("Authorization", AUTH) + if body: + req.add_header("Content-Type", ctype) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + +def mkcalendar(slug, name, color): + body = f''' + +{name} +{color} + +''' + return dav("MKCALENDAR", f"/{slug}/", body, "application/xml") + +def event(cal, summary, start, end, rrule=None, allday=False): + uid = str(uuid.uuid4()) + if allday: + dt = f"DTSTART;VALUE=DATE:{start}\r\nDTEND;VALUE=DATE:{end}" + else: + dt = f"DTSTART;TZID={TZ}:{start}\r\nDTEND;TZID={TZ}:{end}" + rule = f"\r\nRRULE:{rrule}" if rrule else "" + ics = (f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Runway//seed//EN\r\n" + f"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20260101T000000Z\r\n{dt}\r\n" + f"SUMMARY:{summary}{rule}\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n") + return dav("PUT", f"/{cal}/{uid}.ics", ics) + +# A week either side of 2026-08-26 (a Wednesday). +for slug, name, color in [ + ("personal", "Personal", "#0CCE6B"), + ("work", "Work", "#3B82F6"), + ("household", "Household", "#DC2626"), +]: + mkcalendar(slug, name, color) + +event("personal", "Spanish", "20260825T083000", "20260825T093000", "FREQ=WEEKLY;BYDAY=TU") +event("personal", "Liv and Jordan Wedding", "20260829T193000", "20260830T000000") +event("personal", "Dentist", "20260827T140000", "20260827T150000") +event("work", "Standup", "20260824T091500", "20260824T093000", "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR") +event("work", "Design review", "20260826T130000", "20260826T140000") +event("work", "Quarterly planning", "20260828T100000", "20260828T120000") +event("household", "Garbage day", "20260824", "20260825", "FREQ=WEEKLY;BYDAY=MO", allday=True) +event("household", "Vacuum", "20260826", "20260827", "FREQ=WEEKLY;BYDAY=WE", allday=True) +print("seeded") diff --git a/e2e/shoot.mjs b/e2e/shoot.mjs index b75fdc0..f68eb6f 100644 --- a/e2e/shoot.mjs +++ b/e2e/shoot.mjs @@ -101,15 +101,63 @@ await shoot('login-error'); await page.fill('#password', CREDENTIALS.password); await page.click('[data-testid="sign-in"]'); -await page.waitForSelector('[data-testid="signed-in"]', { timeout: 20000 }); -await shootAxes('signed-in', [ - ['light', 'default'], - ['dark', 'default'], - ['nord', 'compact'], -]); +await page.waitForSelector('[data-testid="sidebar"]', { timeout: 20000 }); + +// The shell: sidebar, header navigation, and the current view's range. +await page.waitForSelector('[data-testid="sidebar"]', { timeout: 15000 }); +await page.waitForSelector('[data-testid="agenda"], [data-testid="empty"]', { timeout: 20000 }); + +// Start from a known state. Preferences persist on the server, so without this +// each run inherits whatever the last one left and the shots drift. +await page.click('[data-testid="settings"] summary'); +await page.selectOption('[data-testid="theme"]', 'light'); +await page.selectOption('[data-testid="density"]', 'default'); +await page.selectOption('[data-testid="view"]', 'week'); +await page.selectOption('[data-testid="increment"]', '30'); +await page.waitForFunction( + () => !document.querySelector('[data-testid="preference-saving"]'), + null, + { timeout: 10000 }, +); +await page.click('[data-testid="settings"] summary'); +await page.click('[data-testid="view-week"]'); +await page.waitForTimeout(900); +await shoot('shell-week'); + +// Navigating changes the URL, so the back button and a shared link both work. +await page.click('[data-testid="view-month"]'); +await page.waitForFunction(() => location.pathname.startsWith('/month/'), null, { timeout: 5000 }); +await page.waitForTimeout(400); +await shoot('shell-month'); +console.log(` url after switching view: ${new URL(page.url()).pathname}`); + +await page.click('[data-testid="next"]'); +await page.waitForTimeout(400); +await shoot('shell-month-next'); + +await page.click('[data-testid="today"]'); +await page.click('[data-testid="view-week"]'); +await page.waitForTimeout(400); + +// Hiding a calendar has to remove its events, not just grey out a label. +// Named explicitly: Baikal gives every account an empty default calendar, and +// hiding that one proves nothing. +const before = await page.locator('[data-testid="agenda"] li').count(); +const work = page.locator('[data-testid="calendar-work"]'); +await work.uncheck(); +await page.waitForTimeout(1200); +const after = await page.locator('[data-testid="agenda"] li').count(); +console.log(` occurrences with Work hidden: ${before} -> ${after}`); +if (!(after < before)) { + problems.push(`hiding a calendar with events in it changed nothing: ${before} -> ${after}`); +} +await shoot('shell-calendar-hidden'); +await work.check(); +await page.waitForTimeout(1200); // Preferences live on the server, not in localStorage, so they have to survive // a reload. Set every one of them, reload, and shoot what comes back. +await page.click('[data-testid="settings"] summary'); await page.selectOption('[data-testid="theme"]', 'nord'); await page.selectOption('[data-testid="density"]', 'comfortable'); await page.selectOption('[data-testid="view"]', 'month'); @@ -122,9 +170,11 @@ await page.waitForFunction( await shoot('preferences-set'); await page.reload({ waitUntil: 'networkidle' }); -await page.waitForSelector('[data-testid="signed-in"]', { timeout: 20000 }); +await page.waitForSelector('[data-testid="sidebar"]', { timeout: 20000 }); +await page.waitForTimeout(600); await shoot('preferences-after-reload'); +await page.click('[data-testid="settings"] summary'); const survived = await page.evaluate(() => ({ theme: document.documentElement.getAttribute('data-theme'), style: document.documentElement.getAttribute('data-style'),