Skip to main content

max / goingson

Describe the calendar, the screen the flip programme did not have Thirteen per-screen flip tasks were filed on 2026-08-21 and none of them was events. `events.js` is 1,037 lines, the largest file in `frontend/js/`, and it had one Low task asking for its ten `esc()` sites and nothing describing the screen. A programme whose end state is retiring `index.html` cannot skip a tab, so this is the fourteenth screen. GET /events the document, all three sections GET /events/list the sections alone, what the filter swaps GET /events/{id} the detail pane POST /events/{id}/delete delete one occurrence Three lists rather than one, which is `events.js`'s own split: recurring templates first (the rule, not fifty occurrences of it), then upcoming, then past newest-first. `is_template` is derived here the way `EventResponse` derives it, since the model does not carry it. THE ONE REAL CORRECTION, and it is a layer mistake worth naming. `events.js` calls the `list_events` command, which excludes snoozed rows and offers `list_snoozed_events` to merge them back. The repository's `list_all` underneath is not that query: it filters only events converted into contexts. The first draft of this port read `list_all` and treated the two as equivalent, which would have put snoozed events on the calendar on every visit. A test now holds both halves of that apart. WHAT IT REFUSED rather than described badly, all four already filed from other screens: the form's conditional timezone block (`FieldKind` has no conditionally-present field, same gap as the recurrence rule on task edit), reminders (a repeating group of offsets), the recurring scope question (a write that pauses for an answer, so deleting a rule is refused rather than guessed), and bulk selection (a set gathered across rows with nothing to carry it to a write). The detail pane says so on a rule rather than offering a delete that would choose for the user. Fourteen tests.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:47 UTC
Signed with PGP, not checked
Commit: c649509ff7bfc41bc79ca15821f9199c0c9d9607
Parent: 711ffe2
3 files changed, +704 insertions, -1 deletion
@@ -221,6 +221,7 @@
221 221 pub mod data;
222 222 pub mod day_planning;
223 223 pub mod emails;
224 + pub mod events;
224 225 pub mod monthly_review;
225 226 pub mod problems;
226 227 pub mod projects;
@@ -432,7 +433,8 @@
432 433 let router = task_list::routes(router);
433 434 let router = data::routes(router);
434 435 let router = time_tracking::routes(router);
435 - emails::routes(router)
436 + let router = emails::routes(router);
437 + events::routes(router)
436 438 }
437 439
438 440 /// The custom protocol serving the screens inside the app, and the handle its
@@ -1,0 +1,376 @@
1 + //! The calendar, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The fourteenth screen, and the one the flip programme did not have. Thirteen
6 + //! per-screen flip tasks were filed on 2026-08-21 and none of them was the
7 + //! calendar: `events.js` is 1,037 lines, the largest single file in
8 + //! `frontend/js/`, and it had one Low task asking for its ten `esc()` sites and
9 + //! nothing describing the screen. A programme that retires `index.html` cannot
10 + //! skip a tab.
11 + //!
12 + //! # Three lists rather than one
13 + //!
14 + //! `events.js:load` splits what the API returns into three sections and the
15 + //! description keeps all three, because they answer different questions:
16 + //!
17 + //! - **Recurring** shows *templates* — the rule itself, not its occurrences. The
18 + //! JS comment is explicit about why, and it is the reason this is not just a
19 + //! sort: a weekly meeting would otherwise bury the rule under fifty instances.
20 + //! `is_template` is computed in Rust already (`recurrence != None &&
21 + //! !is_recurring_instance`), so this reads it rather than deriving it again.
22 + //! - **Upcoming** is everything ahead that is not a template.
23 + //! - **Past** is everything behind, newest first. `events.js` reverses it for
24 + //! the same reason: the interesting end of the past is the recent end.
25 + //!
26 + //! # The shape
27 + //!
28 + //! - `GET /events` — the document, all three sections.
29 + //! - `GET /events/list` — the sections alone, which is what the snoozed filter
30 + //! swaps.
31 + //! - `GET /events/{id}` — the detail pane.
32 + //! - `POST /events/{id}/delete` — delete one, and answer with the list.
33 + //!
34 + //! `filter-events-snoozed` is a checkbox held in the DOM in the JS, so the
35 + //! filtered view has no address. Here it is `?snoozed=1`, per decision 2 and
36 + //! for the same reason as every other filter that has moved: a view a user is
37 + //! looking at should be a view they can link to.
38 + //!
39 + //! Watch the layer it reads from. `events.js` calls the `list_events` command,
40 + //! which excludes snoozed rows; the repository's `list_all` underneath it does
41 + //! not, filtering only events converted into contexts. A port that took the
42 + //! repository read as equivalent would show snoozed events on every visit. See
43 + //! [`sections`].
44 + //!
45 + //! # What this port could not say, and did not fake
46 + //!
47 + //! The read side is complete. The write side is one route, delete, and the rest
48 + //! is left out rather than described badly. Four things refused, and none of
49 + //! them is new — each is a vocabulary gap already filed from another screen:
50 + //!
51 + //! 1. **The event form's timezone block.** `initTzKindConfig` shows and hides
52 + //! the zone picker depending on which of the three `TzKind` values is
53 + //! selected. `FieldKind` has no conditionally-present field, which is the
54 + //! same gap `quasi::tasks::edit_fields` records against the recurrence rule.
55 + //! A form that always showed the picker would be describing a different
56 + //! screen.
57 + //! 2. **Reminders.** `collectReminderOffsets` reads a variable number of offsets
58 + //! off the form. A field holds one value or one choice, so a repeating group
59 + //! has nothing to be.
60 + //! 3. **The recurring scope question.** `confirmRecurringScope` asks "this
61 + //! occurrence or the whole series?" before a delete or an edit lands. That is
62 + //! a write that pauses for an answer, and the delete route here refuses a
63 + //! template rather than guessing which the user meant.
64 + //! 4. **Bulk selection.** `toggleEventSelection` and `bulkDeleteEvents` act on a
65 + //! set gathered across rows. `Row::selectable` draws the tick, and nothing
66 + //! carries the set to a write.
67 + //!
68 + //! Recording them here rather than filing four new tasks: they are one gap seen
69 + //! four times, they are already filed against the screens that found them
70 + //! first, and this port is evidence of how often they recur rather than a new
71 + //! finding.
72 +
73 + // Handlers take their request by value because `quasi_router::Handler` is a
74 + // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
75 + // choice made here.
76 + #![allow(clippy::needless_pass_by_value)]
77 +
78 + use chrono::Utc;
79 + use goingson_core::{Event, EventId};
80 + use quasi_router::screen::{Row, Tag};
81 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
82 +
83 + use crate::state::{AppState, DESKTOP_USER_ID};
84 +
85 + #[cfg(test)]
86 + mod tests;
87 +
88 + /// One event, as a row in one of the three sections.
89 + ///
90 + /// The shipped table has five columns (date, time, title, location, actions)
91 + /// and this is the same five facts in a row's slots. The kebab menu is not one
92 + /// of them: `contextMenus.showEvent` offers open, edit, snooze and delete, and
93 + /// three of those four have nowhere to go yet (see the module header), so the
94 + /// row carries the one that does rather than a menu that is mostly disabled.
95 + fn row_for(event: &Event, recurring: bool) -> Row {
96 + // A template's date cell is its pattern, not a start date. `events.js`
97 + // swaps the same cell for the same reason: the arbitrary date a weekly rule
98 + // happens to start on tells the reader nothing about the rule.
99 + let lead = if recurring {
100 + event.effective_recurrence_rule().map_or_else(
101 + || event.recurrence.as_str().to_owned(),
102 + |rule| rule.display(),
103 + )
104 + } else {
105 + event.date_formatted()
106 + };
107 +
108 + let mut row = Row::new(&event.title)
109 + .secondary(lead)
110 + .meta(event.time_formatted());
111 +
112 + if event.has_location() {
113 + row = row.token(Tag::badge(event.location_or_empty()));
114 + }
115 + if event.has_project() {
116 + row = row.token(Tag::badge(event.project_name_or_empty()));
117 + }
118 + if event.is_snoozed() {
119 + row = row.token(Tag::badge("Snoozed").tone(makeover_layout::Tone::Warning));
120 + }
121 + if event.is_linked_to_task() {
122 + // A time block rather than an appointment. The JS draws no marker for
123 + // this and the row reads as an ordinary event; the fact is in the data
124 + // and worth one token, since deleting a block and deleting a meeting
125 + // are different acts.
126 + row = row.token(Tag::badge("Time block").tone(makeover_layout::Tone::Info));
127 + }
128 +
129 + row.activate = Some(Action::get(format!("/events/{}", event.id)));
130 + row
131 + }
132 +
133 + /// The three sections, in the order the screen draws them.
134 + ///
135 + /// Recurring first, which is `events.js`'s own order and not alphabetical
136 + /// accident: the rules are what a reader scans for, and they are the shortest
137 + /// list.
138 + fn sections(state: &AppState, snoozed: bool) -> Result<Vec<Node>, RouteError> {
139 + let mut events = state
140 + .events
141 + .list_all(DESKTOP_USER_ID)
142 + .map_err(|error| RouteError::internal(error.to_string()))?;
143 +
144 + // The snooze filter is applied here rather than read off a narrower query,
145 + // and finding out which was the port's one real correction. `events.js`
146 + // calls the `list_events` COMMAND, which excludes snoozed rows and offers
147 + // `list_snoozed_events` to merge them back in. The repository's `list_all`
148 + // is not that query: it filters only events converted into contexts, so a
149 + // described screen reading it directly would have shown snoozed events
150 + // always, which is not what the shipped screen does.
151 + //
152 + // Filtering here rather than adding a repository read keeps the two
153 + // versions of "what is on the calendar" in one place, and there is no
154 + // second list to de-duplicate against.
155 + if !snoozed {
156 + events.retain(|event| !event.is_snoozed());
157 + }
158 +
159 + if events.is_empty() {
160 + return Ok(vec![Node::empty("No events scheduled.")]);
161 + }
162 +
163 + let now = Utc::now();
164 + let (templates, rest): (Vec<&Event>, Vec<&Event>) =
165 + events.iter().partition(|event| is_template(event));
166 + let (past, upcoming): (Vec<&Event>, Vec<&Event>) =
167 + rest.iter().partition(|event| event.start_time < now);
168 +
169 + let mut out = Vec::new();
170 +
171 + if !templates.is_empty() {
172 + out.push(Node::section(format!("Recurring ({})", templates.len())));
173 + out.push(Node::list(
174 + templates.iter().map(|event| row_for(event, true)),
175 + ));
176 + }
177 +
178 + out.push(Node::section("Upcoming"));
179 + if upcoming.is_empty() {
180 + out.push(Node::empty("Nothing ahead."));
181 + } else {
182 + out.push(Node::list(
183 + upcoming.iter().map(|event| row_for(event, false)),
184 + ));
185 + }
186 +
187 + if !past.is_empty() {
188 + out.push(Node::section(format!("Past ({})", past.len())));
189 + // Newest first. The recent end of the past is the end anyone looks at.
190 + out.push(Node::list(
191 + past.iter().rev().map(|event| row_for(event, false)),
192 + ));
193 + }
194 +
195 + Ok(out)
196 + }
197 +
198 + /// The address of the list under a given filter.
199 + fn list_action(snoozed: bool) -> Action {
200 + let action = Action::get("/events/list");
201 + if snoozed {
202 + action.carrying("snoozed", "1")
203 + } else {
204 + action
205 + }
206 + }
207 +
208 + /// Whether the snoozed filter is on, read off whichever half of the request
209 + /// carries it.
210 + ///
211 + /// A filter arrives carried on a read and, after a write, on the request that
212 + /// answered it. Reading both is what keeps a delete made from the filtered view
213 + /// answering with the filtered view.
214 + fn snoozed_on(request: &quasi_router::Request) -> bool {
215 + let set = |params: &quasi_router::Params| params.get("snoozed").is_some_and(|v| v == "1");
216 + set(&request.carried) || set(&request.payload)
217 + }
218 +
219 + /// The whole screen.
220 + fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
221 + let snoozed = snoozed_on(&request);
222 +
223 + let band = Slot::new("events-band", RegionKind::Band)
224 + .with(Node::page("Events"))
225 + .with(Node::Token(
226 + Tag::chip("Snoozed", list_action(!snoozed)).latched(snoozed),
227 + ));
228 +
229 + let mut pane = Slot::new("events-list", RegionKind::Pane);
230 + for node in sections(state, snoozed)? {
231 + pane = pane.with(node);
232 + }
233 +
234 + Ok(Screen::list_detail("Events", false)
235 + .with(band)
236 + .with(pane)
237 + .with(Slot::new("events-detail", RegionKind::Pane).with(Node::empty("Nothing selected")))
238 + .into())
239 + }
240 +
241 + /// The list alone, which is what the filter and a delete swap.
242 + fn list(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
243 + let snoozed = snoozed_on(&request);
244 + let node = sections(state, snoozed)?;
245 + let mut region = Slot::new("events-list", RegionKind::Pane);
246 + for section in node {
247 + region = region.with(section);
248 + }
249 + Ok(Response::fragment("events-list", Node::Region(region)))
250 + }
251 +
252 + /// Load one event, or answer 404.
253 + fn load(state: &AppState, id: EventId) -> Result<Event, RouteError> {
254 + state
255 + .events
256 + .get_by_id(id, DESKTOP_USER_ID)
257 + .map_err(|error| RouteError::internal(error.to_string()))?
258 + .ok_or_else(|| RouteError::not_found("no such event"))
259 + }
260 +
261 + /// The event a route was addressed at.
262 + fn event_id(request: &quasi_router::Request) -> Result<EventId, RouteError> {
263 + let raw = request
264 + .captures
265 + .get("id")
266 + .ok_or_else(|| RouteError::not_found("no event id"))?;
267 + Ok(EventId::from(
268 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an event id"))?,
269 + ))
270 + }
271 +
272 + /// The detail pane.
273 + ///
274 + /// What the modal shows, minus the controls it carries. `events.js:open` draws
275 + /// title, when, where, project, contact, description, and the reminder list;
276 + /// the reminders are absent here for the reason in the module header.
277 + fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
278 + let event = load(state, event_id(&request)?)?;
279 +
280 + let mut nodes = vec![
281 + Node::section(&event.title),
282 + Node::text(format!(
283 + "{} at {}",
284 + event.date_formatted(),
285 + event.time_formatted()
286 + )),
287 + ];
288 +
289 + if event.has_location() {
290 + nodes.push(Node::text(event.location_or_empty().to_owned()));
291 + }
292 + if event.has_project() {
293 + nodes.push(Node::text(format!(
294 + "Project: {}",
295 + event.project_name_or_empty()
296 + )));
297 + }
298 + if let Some(contact) = &event.contact_name {
299 + nodes.push(Node::text(format!("With: {contact}")));
300 + }
301 + if event.has_description() {
302 + nodes.push(Node::text(event.description.clone()));
303 + }
304 + if event.has_recurrence() {
305 + let label = event.effective_recurrence_rule().map_or_else(
306 + || event.recurrence.as_str().to_owned(),
307 + |rule| rule.display(),
308 + );
309 + nodes.push(Node::text(format!("Repeats: {label}")));
310 + }
311 +
312 + // Delete is offered on an occurrence and withheld on a template, which is
313 + // the scope question the JS asks with a dialog. Refusing is not the answer
314 + // it should end at; it is the honest state until a write can pause for one.
315 + if is_template(&event) {
316 + nodes.push(Node::empty(
317 + "This is a recurring rule. Deleting it needs the scope question, which nothing describes yet.",
318 + ));
319 + } else {
320 + nodes.push(Node::act(
321 + "Delete".to_owned(),
322 + Action::post(format!("/events/{}/delete", event.id)),
323 + ));
324 + }
325 +
326 + let mut region = Slot::new("events-detail", RegionKind::Pane);
327 + for node in nodes {
328 + region = region.with(node);
329 + }
330 + Ok(Response::fragment("events-detail", Node::Region(region)))
331 + }
332 +
333 + /// Delete one event, and answer with the list it came out of.
334 + ///
335 + /// A template is refused rather than deleted: `confirmRecurringScope` exists
336 + /// because deleting a rule and deleting one occurrence of it are different
337 + /// acts, and a route that picked one would be choosing for the user.
338 + fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
339 + let id = event_id(&request)?;
340 + let event = load(state, id)?;
341 + if is_template(&event) {
342 + return Err(RouteError::not_found(
343 + "a recurring rule needs the scope question",
344 + ));
345 + }
346 +
347 + let deleted = state
348 + .events
349 + .delete(id, DESKTOP_USER_ID)
350 + .map_err(|error| RouteError::internal(error.to_string()))?;
351 + if !deleted {
352 + return Err(RouteError::not_found("no such event"));
353 + }
354 +
355 + list(state, request)
356 + }
357 +
358 + /// The events screen's routes.
359 + #[must_use]
360 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
361 + router
362 + // Above `/events/{id}`, so the literal segment is not read as an id.
363 + .get("/events/list", list)
364 + .get("/events", index)
365 + .get("/events/{id}", detail)
366 + .post("/events/{id}/delete", remove)
367 + }
368 +
369 + /// Whether an event is a recurring *rule* rather than one of its occurrences.
370 + ///
371 + /// `EventResponse` computes this for the frontend and the model does not carry
372 + /// it, so it is derived here from the same two facts: a recurrence is set, and
373 + /// this row is not one of the expanded instances.
374 + fn is_template(event: &Event) -> bool {
375 + event.has_recurrence() && !event.is_recurring_instance
376 + }
@@ -1,0 +1,325 @@
1 + //! The calendar, driven through the router against a real database.
2 +
3 + use std::sync::Arc;
4 +
5 + use chrono::{Duration, Utc};
6 + use goingson_core::{Event, EventId, NewEvent, Recurrence};
7 + use quasi_http::Serves as _;
8 + use quasi_router::Outcome;
9 + use quasi_router::{Params, Request, Response};
10 +
11 + use crate::quasi::router;
12 + use crate::state::{AppState, DESKTOP_USER_ID};
13 +
14 + async fn state() -> Arc<AppState> {
15 + let (state, _) = crate::test_utils::setup_test_state().await;
16 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
17 + state
18 + .db
19 + .conn()
20 + .unwrap()
21 + .execute(
22 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
23 + VALUES (?, ?, ?, ?, ?)",
24 + rusqlite::params![
25 + DESKTOP_USER_ID.to_string(),
26 + "desktop@localhost",
27 + "x",
28 + "Desktop User",
29 + &now,
30 + ],
31 + )
32 + .unwrap();
33 + state
34 + }
35 +
36 + /// An event at an offset from now, so "upcoming" and "past" are decidable
37 + /// without freezing the clock.
38 + fn event_at(state: &AppState, title: &str, hours_from_now: i64) -> Event {
39 + make(
40 + state,
41 + NewEvent::builder(title, Utc::now() + Duration::hours(hours_from_now))
42 + .user_id(DESKTOP_USER_ID)
43 + .build(),
44 + )
45 + }
46 +
47 + fn make(state: &AppState, new: NewEvent) -> Event {
48 + state.events.create(DESKTOP_USER_ID, new).unwrap()
49 + }
50 +
51 + fn html(response: Response) -> String {
52 + match response.outcome {
53 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
54 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
55 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
56 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
57 + Outcome::Suggestions { field, .. } => {
58 + panic!("expected content, got a suggestion list for `{field}`")
59 + }
60 + }
61 + }
62 +
63 + fn get(state: &AppState, path: &str, params: Params) -> Response {
64 + router()
65 + .handle(state, Request::get(path).carrying(params))
66 + .expect("the route answers")
67 + }
68 +
69 + fn post(state: &AppState, path: &str, params: Params) -> Response {
70 + router()
71 + .handle(state, Request::post(path).sending(params))
72 + .expect("the route answers")
73 + }
74 +
75 + fn screen(state: &AppState) -> String {
76 + html(get(state, "/events", Params::new()))
77 + }
78 +
79 + #[tokio::test]
80 + async fn an_empty_calendar_says_so() {
81 + let state = state().await;
82 + assert!(screen(&state).contains("No events scheduled."));
83 + }
84 +
85 + #[tokio::test]
86 + async fn the_three_sections_split_the_way_the_js_splits_them() {
87 + let state = state().await;
88 + event_at(&state, "Ahead", 24);
89 + event_at(&state, "Behind", -24);
90 + make(
91 + &state,
92 + NewEvent::builder("Every week", Utc::now() + Duration::hours(48))
93 + .user_id(DESKTOP_USER_ID)
94 + .recurrence(Recurrence::Weekly)
95 + .build(),
96 + );
97 +
98 + let page = screen(&state);
99 + // Recurring first: the rules are the shortest list and what a reader scans
100 + // for, which is `events.js`'s own order.
101 + let recurring = page.find("Recurring").expect("a recurring section");
102 + let upcoming = page.find("Upcoming").expect("an upcoming section");
103 + let past = page.find("Past").expect("a past section");
104 + assert!(recurring < upcoming, "{page}");
105 + assert!(upcoming < past, "{page}");
106 + assert!(page.contains("Every week"), "{page}");
107 + assert!(page.contains("Ahead"), "{page}");
108 + assert!(page.contains("Behind"), "{page}");
109 + }
110 +
111 + #[tokio::test]
112 + async fn a_recurring_rule_leads_with_its_pattern_rather_than_a_date() {
113 + // The arbitrary date a weekly rule happens to start on says nothing about
114 + // the rule, which is why the JS swaps the same cell.
115 + let state = state().await;
116 + make(
117 + &state,
118 + NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
119 + .user_id(DESKTOP_USER_ID)
120 + .recurrence(Recurrence::Weekly)
121 + .build(),
122 + );
123 +
124 + let page = screen(&state);
125 + // `RecurrenceRule::display()` is the human label, not the enum name.
126 + assert!(page.contains("Every week"), "{page}");
127 + }
128 +
129 + #[tokio::test]
130 + async fn an_occurrence_is_not_filed_as_a_rule() {
131 + // `is_template` is a recurrence AND not being an expanded instance. An
132 + // event carrying only the first would put every occurrence in the rules
133 + // section, which is the bug the split exists to avoid.
134 + // The flag is set by recurrence expansion and is never persisted, so this
135 + // flips it on the value rather than storing one.
136 + let state = state().await;
137 + let mut stored = make(
138 + &state,
139 + NewEvent::builder("Standup", Utc::now() + Duration::hours(2))
140 + .user_id(DESKTOP_USER_ID)
141 + .recurrence(Recurrence::Weekly)
142 + .build(),
143 + );
144 + assert!(super::is_template(&stored), "the stored rule is a template");
145 +
146 + stored.is_recurring_instance = true;
147 + assert!(
148 + !super::is_template(&stored),
149 + "an expanded occurrence is not"
150 + );
151 + }
152 +
153 + #[tokio::test]
154 + async fn the_snoozed_filter_is_an_address_not_a_checkbox() {
155 + // `filter-events-snoozed` is DOM state in the JS, so the filtered view has
156 + // no address. Here it does.
157 + let state = state().await;
158 + let hidden = event_at(&state, "Snoozed away", 12);
159 + state
160 + .events
161 + .snooze(hidden.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
162 + .unwrap();
163 +
164 + assert!(!screen(&state).contains("Snoozed away"));
165 +
166 + let shown = html(get(&state, "/events", Params::new().with("snoozed", "1")));
167 + assert!(shown.contains("Snoozed away"), "{shown}");
168 + }
169 +
170 + #[tokio::test]
171 + async fn a_shown_snoozed_event_says_that_is_what_it_is() {
172 + // Turning the filter on mixes snoozed rows in with the rest, so the row has
173 + // to carry the fact or the two are indistinguishable.
174 + let state = state().await;
175 + let event = event_at(&state, "Only once", 12);
176 + state
177 + .events
178 + .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
179 + .unwrap();
180 +
181 + let page = html(get(&state, "/events", Params::new().with("snoozed", "1")));
182 + assert_eq!(page.matches("Only once").count(), 1, "{page}");
183 + assert!(page.contains("Snoozed"), "{page}");
184 + }
185 +
186 + #[tokio::test]
187 + async fn the_repository_read_is_not_the_command_read() {
188 + // The port's one real correction. `events.js` calls `list_events`, which
189 + // excludes snoozed rows; `list_all` underneath it does not. Reading the
190 + // repository directly and calling it equivalent would put snoozed events on
191 + // the screen on every visit.
192 + let state = state().await;
193 + let event = event_at(&state, "Hidden", 12);
194 + state
195 + .events
196 + .snooze(event.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
197 + .unwrap();
198 +
199 + let straight_from_the_repository = state.events.list_all(DESKTOP_USER_ID).unwrap();
200 + assert!(
201 + straight_from_the_repository
202 + .iter()
203 + .any(|e| e.title == "Hidden"),
204 + "list_all still carries it, which is why the screen filters"
205 + );
206 + assert!(!screen(&state).contains("Hidden"));
207 + }
208 +
209 + #[tokio::test]
210 + async fn selecting_an_event_addresses_the_detail_pane() {
211 + let state = state().await;
212 + let event = event_at(&state, "Dentist", 6);
213 +
214 + let page = screen(&state);
215 + assert!(page.contains(&format!("/events/{}", event.id)), "{page}");
216 +
217 + let detail = html(get(&state, &format!("/events/{}", event.id), Params::new()));
218 + assert!(detail.contains("Dentist"), "{detail}");
219 + }
220 +
221 + #[tokio::test]
222 + async fn deleting_an_event_takes_it_off_the_list() {
223 + let state = state().await;
224 + let going = event_at(&state, "Going", 6);
225 + event_at(&state, "Staying", 8);
226 +
227 + let page = html(post(
228 + &state,
229 + &format!("/events/{}/delete", going.id),
230 + Params::new(),
231 + ));
232 + assert!(!page.contains("Going"), "{page}");
233 + assert!(page.contains("Staying"), "{page}");
234 + }
235 +
236 + #[tokio::test]
237 + async fn a_delete_made_from_the_filtered_view_answers_with_the_filtered_view() {
238 + let state = state().await;
239 + let going = event_at(&state, "Going", 6);
240 + let snoozed = event_at(&state, "Snoozed away", 8);
241 + state
242 + .events
243 + .snooze(snoozed.id, DESKTOP_USER_ID, Utc::now() + Duration::days(3))
244 + .unwrap();
245 +
246 + let page = html(post(
247 + &state,
248 + &format!("/events/{}/delete", going.id),
249 + Params::new().with("snoozed", "1"),
250 + ));
251 + assert!(page.contains("Snoozed away"), "{page}");
252 + }
253 +
254 + #[tokio::test]
255 + async fn deleting_a_recurring_rule_is_refused_rather_than_guessed() {
256 + // `confirmRecurringScope` asks "this occurrence or the series?" before the
257 + // write lands. Nothing describes a write that pauses for an answer, so the
258 + // route refuses rather than choosing for the user.
259 + let state = state().await;
260 + let rule = make(
261 + &state,
262 + NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
263 + .user_id(DESKTOP_USER_ID)
264 + .recurrence(Recurrence::Weekly)
265 + .build(),
266 + );
267 +
268 + let error = router()
269 + .handle(
270 + &state,
271 + Request::post(format!("/events/{}/delete", rule.id)).sending(Params::new()),
272 + )
273 + .expect_err("a rule needs the scope question");
274 + assert_eq!(error.class.http_status(), 404);
275 + assert!(
276 + state
277 + .events
278 + .get_by_id(rule.id, DESKTOP_USER_ID)
279 + .unwrap()
280 + .is_some(),
281 + "the rule is still there"
282 + );
283 + }
284 +
285 + #[tokio::test]
286 + async fn the_rule_says_why_it_offers_no_delete() {
287 + let state = state().await;
288 + let rule = make(
289 + &state,
290 + NewEvent::builder("Standup", Utc::now() + Duration::hours(48))
291 + .user_id(DESKTOP_USER_ID)
292 + .recurrence(Recurrence::Weekly)
293 + .build(),
294 + );
295 +
296 + let detail = html(get(&state, &format!("/events/{}", rule.id), Params::new()));
297 + assert!(detail.contains("scope question"), "{detail}");
298 + assert!(
299 + !detail.contains(&format!("/events/{}/delete", rule.id)),
300 + "{detail}"
301 + );
302 + }
303 +
304 + #[tokio::test]
305 + async fn list_is_a_route_rather_than_an_event_called_list() {
306 + // The literal segment is mounted above the capture. Read the other way,
307 + // `/events/list` is an event id that does not parse.
308 + let state = state().await;
309 + event_at(&state, "Dentist", 6);
310 +
311 + let fragment = html(get(&state, "/events/list", Params::new()));
312 + assert!(fragment.contains("Dentist"), "{fragment}");
313 + }
314 +
315 + #[tokio::test]
316 + async fn an_event_that_is_not_there_is_a_not_found() {
317 + let state = state().await;
318 + let error = router()
319 + .handle(
320 + &state,
321 + Request::get(format!("/events/{}", EventId::from(uuid::Uuid::nil()))),
322 + )
323 + .expect_err("no such event");
324 + assert_eq!(error.class.http_status(), 404);
325 + }