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