|
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 |
+ |
}
|