Say a reminder while somebody is looking
The alarms that matter were already being written to the server, which is the half that counts: a phone reads them at six in the morning with this tab long closed. Two things were wrong with the writing, and the telling was missing. `RELATED` was being dropped. The alarm row read the offset out of the trigger and wrote a new one back with `related: Start` hard-coded, so an alarm made elsewhere as "ten minutes before it ends" became ten minutes before the start the first time this app touched the event -- the exact v1 bug the model was given the parameter to prevent. It is a control now, beside the offset, and changing either keeps the other. The offsets also cover "when it starts" and "fifteen minutes after", which are things people set. `alarms::due` is the arithmetic, and it is a pure function of the events on screen and the time: only `DISPLAY` alarms, because acting on an `EMAIL` or an `AUDIO` one is not a browser tab's business; relative triggers off whichever end they name; absolute ones converted through the difference between the two spellings of the occurrence's own start, so no zone database is needed for it. A five-minute window decides what is late enough to skip -- a page opened at noon should not recite the whole morning, and one that was behind another window for four minutes should not lose what came due while it was there. The telling is one effect on the clock that was already ticking for the now-line, and one set in memory of what has been said. Either the browser says it, tagged so a second tab replaces the notice rather than stacking beside it, or the page does, in a note that can be dismissed -- never both, because the granted one arrives when the tab is behind something else, which is when a reminder is worth having. v1 spent a 2,008-line component polling every thirty seconds, a `localStorage` ledger that grew without bound, and a service worker that could not read that ledger and so did nothing but ping the tab that was already polling. Permission is asked from settings, on a click. A prompt fired at a page nobody has looked at yet is one they dismiss without reading, and a dismissed prompt is indistinguishable from here from a refusal. The panel re-reads where it stands each time it opens, since that is the browser's to change from a control this page does not own. Chips that carry any alarm say so with a small mark -- any action, not only the ones this browser would act on, because the mark means "somebody will be told" and the phone reading the same calendar is somebody. The browser checks cover both channels: a reminder due three minutes ago arriving on the page and being dismissed and not coming back on the next tick, a trigger hung off the end surviving a round trip through CalDAV, and, with the notification API standing in for the real one, the same reminder going to the browser instead and nothing being said twice.
This commit is contained in:
Generated
+1
@@ -2446,6 +2446,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Due> {
|
||||
let mut found: Vec<Due> = 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<NaiveDateTime> {
|
||||
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<VAlarm>) -> 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<VAlarm>) -> 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])));
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,8 @@ fn Row(entry: Entry, event: Placed, day: NaiveDate) -> impl IntoView {
|
||||
</span>
|
||||
<span class="truncate" style="font-size: var(--font-size-sm)">
|
||||
{title}
|
||||
{crate::alarms::has_reminder(&event)
|
||||
.then(|| view! { <crate::components::Bell /> })}
|
||||
</span>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -1005,9 +1005,11 @@ fn Reminders(event: RwSignal<VEvent>) -> 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<VEvent>) -> 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! {
|
||||
<li class="flex items-center" style="gap: var(--space-2); margin-bottom: var(--space-1)">
|
||||
{if absolute {
|
||||
@@ -1048,19 +1068,9 @@ fn AlarmRow(index: usize, alarm: VAlarm, event: RwSignal<VEvent>) -> impl IntoVi
|
||||
let Ok(before) = picked.target().value().parse::<i64>() 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<VEvent>) -> impl IntoVi
|
||||
})
|
||||
.collect_view()}
|
||||
</select>
|
||||
<select
|
||||
data-testid="alarm-related"
|
||||
prop:value=match related {
|
||||
TriggerRelation::Start => "start",
|
||||
TriggerRelation::End => "end",
|
||||
}
|
||||
on:change:target=move |picked| {
|
||||
retrigger(
|
||||
minutes,
|
||||
if picked.target().value() == "end" {
|
||||
TriggerRelation::End
|
||||
} else {
|
||||
TriggerRelation::Start
|
||||
},
|
||||
);
|
||||
}
|
||||
class="cursor-pointer border"
|
||||
style=FIELD
|
||||
>
|
||||
<option value="start">"it starts"</option>
|
||||
<option value="end">"it ends"</option>
|
||||
</select>
|
||||
}
|
||||
.into_any()
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
})
|
||||
/>
|
||||
|
||||
<Notifications open />
|
||||
|
||||
{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<bool>,
|
||||
) -> 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! {
|
||||
<div data-testid="notifications" style="margin-top: var(--space-3)">
|
||||
<span style="color: var(--text-muted); font-size: var(--font-size-sm)">"Reminders"</span>
|
||||
{move || match state.get() {
|
||||
Permission::Granted => {
|
||||
view! {
|
||||
<p data-testid="notification-state" style=note>
|
||||
"Notifications are on."
|
||||
</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
Permission::Denied => {
|
||||
view! {
|
||||
<p data-testid="notification-state" style=note>
|
||||
"Notifications are blocked. Reminders appear on the page."
|
||||
</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
Permission::Unsupported => {
|
||||
view! {
|
||||
<p data-testid="notification-state" style=note>
|
||||
"This browser has none. Reminders appear on the page."
|
||||
</p>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
Permission::Ask => {
|
||||
view! {
|
||||
<button
|
||||
data-testid="ask-notifications"
|
||||
on:click=move |_| notify::ask(move |settled| state.set(settled))
|
||||
class="cursor-pointer border"
|
||||
style="margin-top: var(--space-1); width: 100%; \
|
||||
padding: var(--space-1) var(--space-2); \
|
||||
border-radius: var(--radius-sm); border-color: var(--border); \
|
||||
background: var(--surface); color: var(--text); \
|
||||
font-family: inherit; font-size: var(--font-size-sm)"
|
||||
>
|
||||
"Allow notifications"
|
||||
</button>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Picker(
|
||||
label: &'static str,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -371,6 +371,8 @@ fn DayItems(
|
||||
<span style="color: var(--text-subtle); \
|
||||
font-variant-numeric: tabular-nums">{at}</span>
|
||||
<span class="truncate">{title}</span>
|
||||
{crate::alarms::has_reminder(&event)
|
||||
.then(|| view! { <crate::components::Bell /> })}
|
||||
</div>
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<Vec<Placed>>,
|
||||
/// The clock, ticking every half minute.
|
||||
now: Signal<NaiveDateTime>,
|
||||
) -> 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::<String>::new());
|
||||
let showing = RwSignal::new(Vec::<Due>::new());
|
||||
|
||||
Effect::new(move |_| {
|
||||
let at = now.get();
|
||||
let fresh: Vec<Due> = 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! {
|
||||
<div
|
||||
data-testid="reminders"
|
||||
class="flex flex-col"
|
||||
style="position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 80; \
|
||||
gap: var(--space-2); width: 17rem; pointer-events: none"
|
||||
>
|
||||
<For each=move || showing.get() key=|due| due.key.clone() let:due>
|
||||
{
|
||||
let key = due.key.clone();
|
||||
view! {
|
||||
<div
|
||||
role="status"
|
||||
data-testid="reminder"
|
||||
data-key=due.key.clone()
|
||||
class="flex items-start border"
|
||||
style="gap: var(--space-2); padding: var(--space-2) var(--space-3); \
|
||||
border-radius: var(--radius); border-color: var(--border); \
|
||||
background: var(--surface-overlay); \
|
||||
box-shadow: var(--shadow-lg); pointer-events: auto"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p style="margin: 0; font-size: var(--font-size-sm); \
|
||||
font-weight: 600">{due.title.clone()}</p>
|
||||
<p style="margin: var(--space-1) 0 0; color: var(--text-muted); \
|
||||
font-size: var(--font-size-xs)">{due.body.clone()}</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="dismiss-reminder"
|
||||
aria-label="Dismiss"
|
||||
on:click=move |_| dismiss(key.clone())
|
||||
class="cursor-pointer"
|
||||
style="border: 0; background: none; color: var(--text-subtle); \
|
||||
font-family: inherit; font-size: var(--font-size-sm); \
|
||||
line-height: 1"
|
||||
>
|
||||
"×"
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</For>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
})
|
||||
}}
|
||||
|
||||
<Reminders events=drawable now=now.into() />
|
||||
<EventDetails />
|
||||
<EventForm zone />
|
||||
<ContextMenu />
|
||||
|
||||
@@ -449,6 +449,7 @@ fn StripBar(bar: Bar, day: NaiveDate, event: Option<Placed>) -> impl IntoView {
|
||||
)
|
||||
>
|
||||
{title}
|
||||
{crate::alarms::has_reminder(&event).then(|| view! { <Bell /> })}
|
||||
</div>
|
||||
}
|
||||
.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! {
|
||||
<span
|
||||
data-testid="has-alarm"
|
||||
aria-label="has a reminder"
|
||||
title="Has a reminder"
|
||||
style="margin-left: 0.15em; opacity: 0.75"
|
||||
>
|
||||
"\u{1f514}"
|
||||
</span>
|
||||
}
|
||||
}
|
||||
|
||||
/// A grab handle at one end of a chip.
|
||||
#[component]
|
||||
fn Edge(top: bool) -> impl IntoView {
|
||||
@@ -832,6 +852,7 @@ fn Chip(
|
||||
<span style="font-weight: 600">{at}</span>
|
||||
" "
|
||||
{title}
|
||||
{crate::alarms::has_reminder(&event).then(|| view! { <Bell /> })}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+210
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user