diff --git a/Cargo.lock b/Cargo.lock index c70aabf..0b50800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2446,6 +2446,7 @@ dependencies = [ "serde", "serde_json", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index d628966..334e93e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ leptos = "0.8" leptos_router = "0.8" gloo-net = { version = "0.6", features = ["json"] } wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" web-sys = "0.3" js-sys = "0.3" console_error_panic_hook = "0.1" diff --git a/crates/runway-web/Cargo.toml b/crates/runway-web/Cargo.toml index 1fe5f21..22cda34 100644 --- a/crates/runway-web/Cargo.toml +++ b/crates/runway-web/Cargo.toml @@ -17,8 +17,9 @@ serde = { workspace = true } serde_json = { workspace = true } gloo-net = { workspace = true } wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } console_error_panic_hook = { workspace = true } -web-sys = { workspace = true, features = ["Document", "DomRect", "Element", "HtmlElement", "Location", "Window"] } +web-sys = { workspace = true, features = ["Document", "DomRect", "Element", "HtmlElement", "Location", "Notification", "NotificationOptions", "NotificationPermission", "Window"] } js-sys = { workspace = true } [lints] diff --git a/crates/runway-web/src/alarms.rs b/crates/runway-web/src/alarms.rs new file mode 100644 index 0000000..47d90ab --- /dev/null +++ b/crates/runway-web/src/alarms.rs @@ -0,0 +1,291 @@ +//! Which reminders are due. +//! +//! The alarms that matter are the ones on the server: a phone reads them long +//! after this tab is closed, and that is the whole point of writing them. This +//! is the lesser half — telling somebody while they are actually looking at +//! the page — and it is deliberately small. +//! +//! v1 kept "which alarms have fired" in `localStorage`, polled every thirty +//! seconds from a 2,008-line component, and shipped a service worker that +//! could not read `localStorage` and so did nothing but ping the tab that was +//! already polling. Here the arithmetic is a pure function and the remembering +//! is one set in memory. + +use crate::placed::Placed; +use chrono::{NaiveDateTime, TimeDelta}; +use runway_core::model::{AlarmAction, AlarmTrigger, TriggerRelation}; + +/// How late a reminder may be and still be worth saying. +/// +/// A page opened at noon should not announce every reminder of the morning, +/// and one that was in a background tab for four minutes should not lose the +/// one that came due while it was there. Five minutes is the width of that +/// gap. +pub const GRACE: TimeDelta = TimeDelta::minutes(5); + +/// A reminder that has come due. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Due { + /// What names this reminder, so the same one is never said twice. The + /// series, the occurrence within it, and which of the event's alarms. + pub key: String, + pub title: String, + /// When the event itself is, said the way a person would. + pub body: String, + /// When the reminder was for. + pub at: NaiveDateTime, +} + +/// Every reminder that has come due by `now` and is not yet stale. +/// +/// Only `DISPLAY` alarms. An `EMAIL` or `AUDIO` alarm is a real thing to carry +/// and store, and acting on one is not a browser tab's business — v1 defined +/// them in the model and quietly treated every alarm as a notification. +pub fn due(events: &[Placed], now: NaiveDateTime) -> Vec { + let mut found: Vec = events + .iter() + .flat_map(|event| { + event + .occurrence + .event + .alarms + .iter() + .enumerate() + .filter(|(_, alarm)| alarm.action == AlarmAction::Display) + .filter_map(move |(index, alarm)| { + let at = fires_at(event, &alarm.trigger)?; + (at <= now && now - at <= GRACE).then(|| Due { + key: key_for(event, index), + title: event.title().to_owned(), + body: body_for(event), + at, + }) + }) + }) + .collect(); + + // Soonest first, so a burst of them reads in the order they came due. + found.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.key.cmp(&b.key))); + found.dedup_by(|a, b| a.key == b.key); + found +} + +/// When a trigger goes off, in the zone the calendar is drawn in. +fn fires_at(event: &Placed, trigger: &AlarmTrigger) -> Option { + match trigger { + AlarmTrigger::Relative { offset, related } => { + // Which end it hangs off is a parameter v1 ignored, so alarms + // written elsewhere as "ten minutes before the end" moved when it + // touched them. + let anchor = match related { + TriggerRelation::Start => event.start, + TriggerRelation::End => event.end, + }; + Some(anchor + offset.as_time_delta()) + } + AlarmTrigger::Absolute { at } => { + // An absolute trigger is a UTC instant and everything here is + // wall-clock. The occurrence carries both spellings of its own + // start, and the difference between them is the offset that + // applies — no zone database needed for it. + let offset = event.occurrence.start_utc.naive_utc() - event.start; + Some(at.naive_utc() - offset) + } + } +} + +/// What names one reminder. +fn key_for(event: &Placed, index: usize) -> String { + format!( + "{}@{}#{index}", + event.occurrence.uid(), + event.occurrence.start_utc.to_rfc3339(), + ) +} + +fn body_for(event: &Placed) -> String { + let when = if event.is_all_day() { + "Today".to_owned() + } else { + format!("At {}", event.start.format("%-H:%M")) + }; + match event.occurrence.event.location.as_deref() { + Some(place) if !place.trim().is_empty() => format!("{when} · {place}"), + _ => when, + } +} + +/// Whether an event has anything set to remind anybody about it. +/// +/// What the little mark on a chip means. Any alarm counts, not only the ones +/// this browser would act on: the mark says "somebody will be told", and the +/// phone reading the same calendar is somebody. +pub fn has_reminder(event: &Placed) -> bool { + !event.occurrence.event.alarms.is_empty() +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use crate::placed::fixtures::{all_day, at, date, timed}; + use runway_core::model::{IcalDuration, VAlarm}; + + fn now() -> NaiveDateTime { + at(date(2026, 8, 27), 13, 45) + } + + fn with_alarms(mut event: Placed, alarms: Vec) -> Placed { + event.occurrence.event.alarms = alarms; + event + } + + fn before_start(minutes: i64) -> VAlarm { + VAlarm::display_before( + IcalDuration::minutes(-minutes).expect("a duration"), + "Reminder", + ) + } + + fn relative(minutes: i64, related: TriggerRelation) -> VAlarm { + let mut alarm = before_start(0); + alarm.trigger = AlarmTrigger::Relative { + offset: IcalDuration::minutes(minutes).expect("a duration"), + related, + }; + alarm + } + + /// A two o'clock dentist appointment, an hour long. + fn dentist(alarms: Vec) -> Placed { + with_alarms(timed(date(2026, 8, 27), 14, "Dentist"), alarms) + } + + #[test] + fn a_reminder_comes_due_at_its_offset() { + let events = vec![dentist(vec![before_start(15)])]; + + let found = due(&events, now()); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].title, "Dentist"); + assert_eq!(found[0].at, at(date(2026, 8, 27), 13, 45)); + assert_eq!(found[0].body, "At 14:00"); + } + + #[test] + fn a_reminder_that_is_not_due_yet_says_nothing() { + let events = vec![dentist(vec![before_start(5)])]; + + assert!(due(&events, now()).is_empty()); + } + + #[test] + fn a_reminder_long_past_is_not_announced() { + // Opening the page at noon should not recite the whole morning. + let events = vec![dentist(vec![before_start(15)])]; + + assert!(due(&events, at(date(2026, 8, 27), 14, 30)).is_empty()); + } + + #[test] + fn one_that_came_due_while_the_tab_was_away_is_still_said() { + let events = vec![dentist(vec![before_start(15)])]; + + assert_eq!(due(&events, at(date(2026, 8, 27), 13, 48)).len(), 1); + } + + #[test] + fn a_reminder_can_hang_off_the_end() { + // The parameter v1 ignored. Ten minutes before this ends is 14:50, + // and reading it as ten minutes before the start would be 13:50. + let events = vec![dentist(vec![relative(-10, TriggerRelation::End)])]; + + assert!(due(&events, now()).is_empty()); + assert_eq!( + due(&events, at(date(2026, 8, 27), 14, 50))[0].at, + at(date(2026, 8, 27), 14, 50), + ); + } + + #[test] + fn a_reminder_after_the_event_is_a_reminder_too() { + let events = vec![dentist(vec![relative(30, TriggerRelation::End)])]; + + assert_eq!( + due(&events, at(date(2026, 8, 27), 15, 30))[0].at, + at(date(2026, 8, 27), 15, 30), + ); + } + + #[test] + fn an_absolute_trigger_is_read_in_the_calendars_own_zone() { + // The fixtures put the occurrence's UTC and local starts at the same + // wall clock, so an absolute trigger at 13:45 UTC is 13:45 here. + let mut alarm = before_start(0); + alarm.trigger = AlarmTrigger::Absolute { + at: chrono::DateTime::from_naive_utc_and_offset(now(), chrono::Utc), + }; + + let found = due(&[dentist(vec![alarm])], now()); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].at, now()); + } + + #[test] + fn only_the_ones_a_browser_should_act_on() { + let mut email = before_start(15); + email.action = AlarmAction::Email; + + assert!(due(&[dentist(vec![email])], now()).is_empty()); + } + + #[test] + fn several_reminders_on_one_event_are_told_apart() { + let events = vec![dentist(vec![before_start(15), before_start(15)])]; + + let found = due(&events, now()); + + assert_eq!(found.len(), 2, "two alarms, two reminders"); + assert_ne!(found[0].key, found[1].key); + } + + #[test] + fn a_reminder_names_where_it_is_when_it_knows() { + let mut event = dentist(vec![before_start(15)]); + event.occurrence.event.location = Some("Meeting room 3".to_owned()); + + assert_eq!(due(&[event], now())[0].body, "At 14:00 · Meeting room 3"); + } + + #[test] + fn an_all_day_event_has_no_hour_to_give() { + let day = date(2026, 8, 27); + let events = vec![with_alarms( + all_day(day, day, "Vacuum"), + vec![relative(0, TriggerRelation::Start)], + )]; + + let found = due(&events, at(day, 0, 2)); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].body, "Today"); + } + + #[test] + fn an_event_with_nothing_set_has_no_mark() { + assert!(!has_reminder(&dentist(Vec::new()))); + assert!(has_reminder(&dentist(vec![before_start(15)]))); + } + + #[test] + fn the_mark_counts_alarms_this_browser_would_not_act_on() { + // It means "somebody will be told", and the phone reading the same + // calendar is somebody. + let mut email = before_start(15); + email.action = AlarmAction::Email; + + assert!(has_reminder(&dentist(vec![email]))); + } +} diff --git a/crates/runway-web/src/components/agenda_view.rs b/crates/runway-web/src/components/agenda_view.rs index 6045039..490bb10 100644 --- a/crates/runway-web/src/components/agenda_view.rs +++ b/crates/runway-web/src/components/agenda_view.rs @@ -153,6 +153,8 @@ fn Row(entry: Entry, event: Placed, day: NaiveDate) -> impl IntoView { {title} + {crate::alarms::has_reminder(&event) + .then(|| view! { })} } diff --git a/crates/runway-web/src/components/event_form.rs b/crates/runway-web/src/components/event_form.rs index 78e81db..3171da4 100644 --- a/crates/runway-web/src/components/event_form.rs +++ b/crates/runway-web/src/components/event_form.rs @@ -1005,9 +1005,11 @@ fn Reminders(event: RwSignal) -> impl IntoView { } /// The offsets worth offering, in minutes before the start. -const BEFORE: [(i64, &str); 7] = [ - (0, "When it starts"), +const BEFORE: [(i64, &str); 9] = [ + (-15, "15 minutes after"), + (0, "When"), (5, "5 minutes before"), + (10, "10 minutes before"), (15, "15 minutes before"), (30, "30 minutes before"), (60, "1 hour before"), @@ -1017,15 +1019,33 @@ const BEFORE: [(i64, &str); 7] = [ #[component] fn AlarmRow(index: usize, alarm: VAlarm, event: RwSignal) -> impl IntoView { - let minutes = match &alarm.trigger { - AlarmTrigger::Relative { offset, .. } => -offset.as_time_delta().num_minutes(), + // Which end it hangs off is carried, not assumed. v1 ignored `RELATED` + // entirely, so an alarm written elsewhere as "ten minutes before the end" + // moved to ten minutes before the start the moment it was round-tripped. + let (minutes, related) = match &alarm.trigger { + AlarmTrigger::Relative { offset, related } => { + (-offset.as_time_delta().num_minutes(), *related) + } // An absolute trigger is a real thing a server may hold, and this row - // does not offer to change it into an offset. It is shown as it is. - AlarmTrigger::Absolute { .. } => -1, + // does not offer to turn it into an offset. It is shown as it is. + AlarmTrigger::Absolute { .. } => (-1, TriggerRelation::Start), }; let absolute = matches!(alarm.trigger, AlarmTrigger::Absolute { .. }); let action = alarm.action; + // Changing one part of a trigger keeps the other, which is what changing + // one part of a trigger means. + let retrigger = move |before: i64, related: TriggerRelation| { + event.update(|draft| { + if let Some(row) = draft.alarms.get_mut(index) { + row.trigger = AlarmTrigger::Relative { + offset: IcalDuration::from(TimeDelta::minutes(-before)), + related, + }; + } + }); + }; + view! {
  • {if absolute { @@ -1048,19 +1068,9 @@ fn AlarmRow(index: usize, alarm: VAlarm, event: RwSignal) -> impl IntoVi let Ok(before) = picked.target().value().parse::() else { return; }; - event - .update(|draft| { - if let Some(row) = draft.alarms.get_mut(index) { - row.trigger = AlarmTrigger::Relative { - offset: IcalDuration::from( - TimeDelta::minutes(-before), - ), - related: TriggerRelation::Start, - }; - } - }); + retrigger(before, related); } - class="flex-1 cursor-pointer border" + class="min-w-0 flex-1 cursor-pointer border" style=FIELD > {BEFORE @@ -1070,6 +1080,28 @@ fn AlarmRow(index: usize, alarm: VAlarm, event: RwSignal) -> impl IntoVi }) .collect_view()} + } .into_any() }} diff --git a/crates/runway-web/src/components/header.rs b/crates/runway-web/src/components/header.rs index 8bd986e..978e17e 100644 --- a/crates/runway-web/src/components/header.rs +++ b/crates/runway-web/src/components/header.rs @@ -8,6 +8,7 @@ use crate::api::User; use crate::dates; +use crate::notify::{self, Permission}; use crate::state::PreferencesStore; use chrono::NaiveDate; use leptos::prelude::*; @@ -287,6 +288,8 @@ fn Settings() -> impl IntoView { }) /> + + {move || { if let Some(failed) = preferences.error() { view! { @@ -320,6 +323,82 @@ fn Settings() -> impl IntoView { } } +/// Whether reminders may leave the page. +/// +/// Asking here rather than on load is the whole reason this is a control at +/// all: a browser only shows the prompt for something the reader clicked, and +/// a prompt fired at a page they have not looked at yet is one they dismiss +/// without reading. What it says when there is nothing to ask matters too — +/// "blocked" and "this browser cannot" are different problems with different +/// fixes, and reminders still arrive on the page either way. +#[component] +fn Notifications( + /// Whether the panel is showing. Re-read every time it opens: permission + /// is the browser's to change, from a control this page does not own, and + /// a panel that answers with what was true at load is worse than one that + /// says nothing. + open: RwSignal, +) -> impl IntoView { + let state = RwSignal::new(Permission::Unsupported); + Effect::new(move |_| { + if open.get() { + state.set(notify::permission()); + } + }); + + let note = "margin: var(--space-1) 0 0; color: var(--text-subtle); \ + font-size: var(--font-size-xs)"; + + view! { +
    + "Reminders" + {move || match state.get() { + Permission::Granted => { + view! { +

    + "Notifications are on." +

    + } + .into_any() + } + Permission::Denied => { + view! { +

    + "Notifications are blocked. Reminders appear on the page." +

    + } + .into_any() + } + Permission::Unsupported => { + view! { +

    + "This browser has none. Reminders appear on the page." +

    + } + .into_any() + } + Permission::Ask => { + view! { + + } + .into_any() + } + }} +
    + } +} + #[component] fn Picker( label: &'static str, diff --git a/crates/runway-web/src/components/mod.rs b/crates/runway-web/src/components/mod.rs index 6862589..5f33ac8 100644 --- a/crates/runway-web/src/components/mod.rs +++ b/crates/runway-web/src/components/mod.rs @@ -9,6 +9,7 @@ mod event_form; mod header; mod login; mod month_view; +mod reminders; mod scope_chooser; mod shell; mod sidebar; @@ -24,8 +25,9 @@ pub use event_form::EventForm; pub use header::Header; pub use login::Login; pub use month_view::MonthView; +pub use reminders::Reminders; pub use scope_chooser::ScopeChooser; pub use shell::Shell; pub use sidebar::Sidebar; -pub use time_grid::TimeGrid; +pub use time_grid::{Bell, TimeGrid}; pub use year_view::YearView; diff --git a/crates/runway-web/src/components/month_view.rs b/crates/runway-web/src/components/month_view.rs index 255f7ca..f97d339 100644 --- a/crates/runway-web/src/components/month_view.rs +++ b/crates/runway-web/src/components/month_view.rs @@ -371,6 +371,8 @@ fn DayItems( {at} {title} + {crate::alarms::has_reminder(&event) + .then(|| view! { })} } }) diff --git a/crates/runway-web/src/components/reminders.rs b/crates/runway-web/src/components/reminders.rs new file mode 100644 index 0000000..44de7b8 --- /dev/null +++ b/crates/runway-web/src/components/reminders.rs @@ -0,0 +1,108 @@ +//! Saying a reminder while somebody is looking at the page. +//! +//! The alarms that matter went to the server, where a phone will read them at +//! six in the morning with this tab long closed. This is the part that only +//! works while the tab is open, and it is deliberately one effect and one +//! list: v1 spent a 2,008-line component and a service worker on it. + +use crate::alarms::{self, Due, GRACE}; +use crate::notify; +use crate::placed::Placed; +use chrono::NaiveDateTime; +use leptos::prelude::*; +use std::collections::HashSet; + +/// Announces reminders as they come due. +#[component] +pub fn Reminders( + /// Everything currently on screen, already resolved into the reader's + /// zone. Reminders are only for what is fetched, which is the range being + /// looked at — a reminder for November is November's problem, and the + /// phone's. + events: Signal>, + /// The clock, ticking every half minute. + now: Signal, +) -> impl IntoView { + // What has already been said, for this tab and no longer. A reload says + // anything still inside the grace window over again, which is the right + // way round: a reminder repeated is a smaller failure than one lost. v1 + // kept this ledger in `localStorage`, where it grew without bound and was + // unreadable from the worker that was supposed to consult it. + let announced = StoredValue::new(HashSet::::new()); + let showing = RwSignal::new(Vec::::new()); + + Effect::new(move |_| { + let at = now.get(); + let fresh: Vec = alarms::due(&events.get(), at) + .into_iter() + .filter(|due| announced.with_value(|said| !said.contains(&due.key))) + .collect(); + announced.update_value(|said| said.extend(fresh.iter().map(|due| due.key.clone()))); + + // One channel or the other, never both. A granted permission is the + // better one — it arrives when the tab is behind something else, which + // is when a reminder is worth having — so the page only speaks for + // itself when the browser will not. + if notify::permission().allows() { + for due in &fresh { + notify::show(&due.title, &due.body, &due.key); + } + } + showing.update(|list| { + if !notify::permission().allows() { + list.extend(fresh); + } + // A note nobody dismissed stops being worth reading. The same + // window that decides a reminder is too late to announce decides + // when one on screen has had its moment. + list.retain(|due| at - due.at <= GRACE); + }); + }); + + let dismiss = move |key: String| showing.update(|list| list.retain(|due| due.key != key)); + + view! { +
    + + { + let key = due.key.clone(); + view! { +
    +
    +

    {due.title.clone()}

    +

    {due.body.clone()}

    +
    + +
    + } + } +
    +
    + } +} diff --git a/crates/runway-web/src/components/shell.rs b/crates/runway-web/src/components/shell.rs index daea7c0..754101a 100644 --- a/crates/runway-web/src/components/shell.rs +++ b/crates/runway-web/src/components/shell.rs @@ -8,7 +8,7 @@ use crate::api::{self, User}; use crate::clock; use crate::components::{ AgendaView, ContextMenu, DragScope, Dragging, EventDetails, EventForm, Header, MonthView, - Sidebar, TimeGrid, YearView, + Reminders, Sidebar, TimeGrid, YearView, }; use crate::dates::{self, DateRange}; use crate::placed::Placed; @@ -353,6 +353,7 @@ fn ViewArea( }) }} + diff --git a/crates/runway-web/src/components/time_grid.rs b/crates/runway-web/src/components/time_grid.rs index 96b24e3..1e7a026 100644 --- a/crates/runway-web/src/components/time_grid.rs +++ b/crates/runway-web/src/components/time_grid.rs @@ -449,6 +449,7 @@ fn StripBar(bar: Bar, day: NaiveDate, event: Option) -> impl IntoView { ) > {title} + {crate::alarms::has_reminder(&event).then(|| view! { })} } .into_any() @@ -578,6 +579,25 @@ fn DayColumn( } } +/// The mark on something somebody will be reminded about. +/// +/// v1 had this and it was worth having: the alarms are the part that leaves +/// the browser, and being able to see at a glance which events carry one is +/// how you notice the one that does not. +#[component] +pub fn Bell() -> impl IntoView { + view! { + + "\u{1f514}" + + } +} + /// A grab handle at one end of a chip. #[component] fn Edge(top: bool) -> impl IntoView { @@ -832,6 +852,7 @@ fn Chip( {at} " " {title} + {crate::alarms::has_reminder(&event).then(|| view! { })} } diff --git a/crates/runway-web/src/lib.rs b/crates/runway-web/src/lib.rs index b822291..6b4731f 100644 --- a/crates/runway-web/src/lib.rs +++ b/crates/runway-web/src/lib.rs @@ -6,6 +6,7 @@ //! tested from without a browser. pub mod agenda; +pub mod alarms; pub mod api; pub mod bars; pub mod clock; @@ -16,6 +17,7 @@ pub mod drag; pub mod form; pub mod menu; pub mod month; +pub mod notify; pub mod placed; pub mod rrule; pub mod state; diff --git a/crates/runway-web/src/notify.rs b/crates/runway-web/src/notify.rs new file mode 100644 index 0000000..da877e0 --- /dev/null +++ b/crates/runway-web/src/notify.rs @@ -0,0 +1,92 @@ +//! The browser's own notifications. +//! +//! A thin cover over one Web API, kept in one place so the rest of the app +//! talks about permission rather than about `JsValue`. Nothing here decides +//! *what* to say — that is `alarms` — and nothing here is worth testing +//! natively, which is exactly why it is this small. + +use leptos::task::spawn_local; +use wasm_bindgen::JsValue; +use web_sys::{Notification, NotificationOptions, NotificationPermission}; + +/// Whether this browser will show a notification, and whether it has been +/// asked yet. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Permission { + /// No notifications here at all. A browser without them is not a browser + /// with them switched off, and the page says different things about the + /// two. + Unsupported, + /// Nobody has been asked. The only state in which asking is allowed to + /// happen, and only from something the reader clicked. + Ask, + Granted, + Denied, +} + +impl Permission { + /// Whether a notification sent now would arrive. + pub fn allows(self) -> bool { + self == Permission::Granted + } +} + +/// Whether the global exists. +/// +/// Reading `Notification.permission` where there is no `Notification` throws, +/// and a thrown `ReferenceError` in wasm is an unreachable with no message. +fn supported() -> bool { + web_sys::window() + .and_then(|window| js_sys::Reflect::get(&window, &JsValue::from_str("Notification")).ok()) + .is_some_and(|found| !found.is_undefined() && !found.is_null()) +} + +/// Where this browser stands right now. +pub fn permission() -> Permission { + if !supported() { + return Permission::Unsupported; + } + match Notification::permission() { + NotificationPermission::Granted => Permission::Granted, + NotificationPermission::Denied => Permission::Denied, + _ => Permission::Ask, + } +} + +/// Ask, and say where it landed. +/// +/// Call this from a click. Browsers refuse the prompt otherwise, and a refused +/// prompt looks from here exactly like a reader who said no — which is why +/// there is no "ask on load" anywhere in this app. +pub fn ask(settled: impl Fn(Permission) + 'static) { + if !supported() { + settled(Permission::Unsupported); + return; + } + let Ok(asked) = Notification::request_permission() else { + settled(permission()); + return; + }; + spawn_local(async move { + // The answer is read back from the API rather than from the promise: + // older browsers resolve it with nothing at all. + let _ = wasm_bindgen_futures::JsFuture::from(asked).await; + settled(permission()); + }); +} + +/// Show one, if we are allowed to. +/// +/// `tag` replaces rather than stacks: the same reminder arriving twice — two +/// tabs, or one reopened — should leave one notification on the desk. +pub fn show(title: &str, body: &str, tag: &str) { + if !permission().allows() { + return; + } + let options = NotificationOptions::new(); + options.set_body(body); + options.set_tag(tag); + // Nothing to do if the browser refuses it. There is no second channel to + // fall back to that the reader has not already declined. + let _ = Notification::new_with_options(title, &options); +} diff --git a/e2e/shoot.mjs b/e2e/shoot.mjs index 8b89c89..3fa1c76 100644 --- a/e2e/shoot.mjs +++ b/e2e/shoot.mjs @@ -1725,6 +1725,216 @@ for (const [key, want] of Object.entries(expected)) { } console.log(` preferences after reload: ${JSON.stringify(survived)}`); +// Reminders. The ones that matter went to the server and a phone will read +// them with this tab closed -- that half is checked by the round trip above. +// This is the half that only works while somebody is looking, and it has two +// channels: the browser's own notification when it is allowed, and a note on +// the page when it is not. +const stamp = await page.evaluate(() => { + const pad = (n) => String(n).padStart(2, '0'); + const parts = (d) => ({ + date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`, + time: `${pad(d.getHours())}:${pad(d.getMinutes())}`, + }); + const now = Date.now(); + // Read from the page's own clock, in the page's own zone: the app resolves + // "now" the same way, and a reminder is entirely about the two agreeing. + return { + today: parts(new Date(now)), + soon: parts(new Date(now + 12 * 60_000)), + later: parts(new Date(now + 42 * 60_000)), + }; +}); + +await page.goto(`${baseUrl}/week/${stamp.today.date}`, { waitUntil: 'networkidle' }); +await page.waitForSelector('[data-testid="time-grid"]', { timeout: 20000 }); +await page.waitForTimeout(1200); + +const openNewEvent = async () => { + const slot = await page.evaluate(() => { + const grid = document.querySelector('[data-testid="time-grid"]').getBoundingClientRect(); + for (const column of document.querySelectorAll('[data-testid^="column-"]')) { + const box = column.getBoundingClientRect(); + const x = Math.round(box.left + box.width / 2); + for (let y = Math.round(grid.top) + 40; y < grid.bottom - 20; y += 12) { + if (document.elementFromPoint(x, y) === column) return { x, y }; + } + } + return null; + }); + if (!slot) { + problems.push('no empty slot to write a reminder check into'); + return false; + } + await page.mouse.click(slot.x, slot.y, { button: 'right' }); + await page.waitForSelector('[data-testid="menu-new"]', { timeout: 10000 }); + await page.click('[data-testid="menu-new"]'); + await page.waitForSelector('[data-testid="event-form"]', { timeout: 10000 }); + return true; +}; + +// Twelve minutes out with a fifteen-minute reminder: due three minutes ago, +// which is inside the grace window and outside the rounding. +const writeSoonWithReminder = async (title) => { + await page.fill('[data-testid="field-title"]', title); + await page.fill('[data-testid="field-start-date"]', stamp.soon.date); + await page.fill('[data-testid="field-start-time"]', stamp.soon.time); + await page.fill('[data-testid="field-end-date"]', stamp.later.date); + await page.fill('[data-testid="field-end-time"]', stamp.later.time); + await page.click('[data-testid="tab-reminders"]'); + await page.click('[data-testid="add-alarm"]'); + await page.selectOption('[data-testid="alarm-offset"]', '15'); + await page.click('[data-testid="form-save"]'); + await page.waitForSelector('[data-testid="event-form"]', { state: 'detached', timeout: 15000 }); + await page.waitForTimeout(2000); +}; + +const chipNamed = (title) => page + .locator('[data-testid="time-grid"] [data-testid="event"]') + .filter({ hasText: title }) + .first(); +const noteFor = (title) => page.locator('[data-testid="reminder"]').filter({ hasText: title }); + +const onPage = 'Reminder on the page'; +if (await openNewEvent()) { + await writeSoonWithReminder(onPage); + + // Nothing has been granted yet, so it lands on the page. + await noteFor(onPage).first().waitFor({ timeout: 15000 }); + const said = await noteFor(onPage).first().innerText(); + console.log(` reminder on the page: ${said.replace(/\s+/g, ' ').trim()}`); + if (!said.includes(stamp.soon.time)) { + problems.push(`the reminder did not say when the event is: "${said}"`); + } + + // The mark on the chip means "somebody will be told about this". + if (await chipNamed(onPage).locator('[data-testid="has-alarm"]').count() === 0) { + problems.push('a chip with a reminder carries no mark'); + } + + await noteFor(onPage).locator('[data-testid="dismiss-reminder"]').click(); + await page.waitForTimeout(300); + if (await noteFor(onPage).count() !== 0) { + problems.push('dismissing left the reminder on screen'); + } + + // The same reminder is not said twice: the tick is every thirty seconds and + // the window it is due in is five minutes wide. + await page.waitForTimeout(31_000); + if (await noteFor(onPage).count() !== 0) { + problems.push('a dismissed reminder came back on the next tick'); + } + + // Which end the trigger hangs off has to survive the trip. v1 dropped + // `RELATED` on the floor, so "before it ends" quietly became "before it + // starts" the first time anything touched the event. + await chipNamed(onPage).click(corner); + await page.waitForSelector('[data-testid="details-edit"]', { timeout: 10000 }); + await page.click('[data-testid="details-edit"]'); + await page.waitForSelector('[data-testid="event-form"]', { timeout: 10000 }); + await page.click('[data-testid="tab-reminders"]'); + await page.selectOption('[data-testid="alarm-offset"]', '30'); + await page.selectOption('[data-testid="alarm-related"]', 'end'); + await page.click('[data-testid="form-save"]'); + await page.waitForSelector('[data-testid="event-form"]', { state: 'detached', timeout: 15000 }); + await page.waitForTimeout(2000); + + await chipNamed(onPage).click(corner); + await page.waitForSelector('[data-testid="event-details"]', { timeout: 10000 }); + const carried = await page.locator('[data-testid="details-alarms"] li').first().innerText(); + console.log(` reminder after a round trip: ${carried.trim()}`); + if (!carried.includes('30 minutes before it ends')) { + problems.push(`a reminder hung off the end came back as "${carried.trim()}"`); + } + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); +} + +// With permission, the browser says it instead — which is the point, because +// that one arrives when the tab is behind something else. Standing in for the +// real thing so the run can see what was sent. +// Headless Chromium answers the real one with "denied", which is a state +// with nothing to check: a browser that has said no is never asked again. +await page.evaluate(() => { + window.__notified = []; + class Stub { + constructor(title, options) { + window.__notified.push({ title, body: options?.body ?? null, tag: options?.tag ?? null }); + } + close() {} + } + Stub.permission = 'default'; + Stub.requestPermission = () => { + Stub.permission = 'granted'; + return Promise.resolve('granted'); + }; + window.Notification = Stub; +}); + +// Asking is a control, not something that happens on load: a prompt fired at a +// page nobody has looked at yet is a prompt nobody reads. +await page.click('[data-testid="settings-toggle"]'); +await page.waitForTimeout(200); +if (await page.locator('[data-testid="ask-notifications"]').count() === 0) { + problems.push('settings offered no way to allow notifications'); +} else { + await page.click('[data-testid="ask-notifications"]'); + await page.waitForTimeout(400); + const state = await page.locator('[data-testid="notification-state"]').innerText(); + console.log(` after allowing: ${state.trim()}`); + if (!state.includes('on')) problems.push(`allowing notifications left settings saying "${state}"`); +} +// The toggle rather than Escape: answering the question replaces the button +// that was clicked, so by now the focus that Escape is read from is on the +// page body and not inside the panel. +await page.click('[data-testid="settings-toggle"]'); +await page.waitForFunction( + () => getComputedStyle(document.querySelector('[data-testid="settings-panel"]')).display === 'none', + null, + { timeout: 5000 }, +); + +const offPage = 'Reminder for the desktop'; +if (await openNewEvent()) { + await writeSoonWithReminder(offPage); + await page.waitForFunction( + (title) => (window.__notified ?? []).some((sent) => sent.title === title), + offPage, + { timeout: 15000 }, + ); + const sent = await page.evaluate(() => window.__notified); + console.log(` notified: ${JSON.stringify(sent)}`); + const posted = sent.find((one) => one.title === offPage); + if (!posted.body || !posted.body.includes(stamp.soon.time)) { + problems.push(`the notification did not say when: ${JSON.stringify(posted)}`); + } + // A tag, so the same reminder from a second tab replaces this one rather + // than stacking beside it. + if (!posted.tag) problems.push('the notification carried no tag'); + // One channel or the other, never both. + if (await noteFor(offPage).count() !== 0) { + problems.push('a reminder was both notified and put on the page'); + } +} + +// Both of them off the calendar again, so a second run starts where this one +// did. +for (const title of [onPage, offPage]) { + const chip = chipNamed(title); + if (await chip.count() === 0) continue; + await chip.click({ button: 'right', ...corner }); + await page.waitForSelector('[data-testid="menu-edit"]', { timeout: 10000 }); + await page.click('[data-testid="menu-edit"]'); + await page.waitForSelector('[data-testid="form-delete"]', { timeout: 10000 }); + await page.click('[data-testid="form-delete"]'); + await page.click('[data-testid="form-delete-confirm"]'); + await page.waitForSelector('[data-testid="event-form"]', { state: 'detached', timeout: 15000 }); + await page.waitForTimeout(1500); + if (await chipNamed(title).count() !== 0) { + problems.push(`"${title}" was still on the grid after being deleted`); + } +} + await browser.close(); if (problems.length) {