Skip to main content

max / goingson

A screen for authoring a context, rather than seven checkboxes The context model landed in c5f74e0f with two ways in, both narrow. Migration 067 converts a multi-day event or a vacation_days run. The weekly review's seven checkboxes write spans through set_vacation_week, and can only say Vacation, only inside one week, and cannot name what they wrote: a trip, an illness and a sprint are ContextKind variants nothing could create. src/quasi/contexts.rs is the place to record one as what it is. Six routes, and no new storage: ContextRepository already had create, update, delete and list_all, and the screen scans the list rather than growing a get. PLACEMENT, which the task left open. Its own screen, marked at the Day place, reached from a chip in the day band. Not the calendar, because 4a1237b6 stands: a context sits behind the day's axis and putting it on a timeline is the conflation the model exists to undo. The nav is untouched, because shell's places are the transcription of navigation.js's TAB_GROUPS and a twelfth would be a product decision rather than a placement one. The chip rather than the banner because Node::Notice carries no act. A backwards span is refused rather than swapped. ContextRepository orders the ends on the way in, which is right for a migration reading stored data and wrong for a form: silently swapping what someone typed tells them nothing, and the next thing they do is wonder why the dates moved. WHAT IT COULD NOT SAY. A date interval is one question with two ends, and there is no way to say so. FieldKind::Interval is the right shape and cannot state that its ends are dates, so quasi-webview emits the numeric pair its two measured sites wanted. The span is two Date fields with the crossing rule checked in validate, which is the honest description of two controls with no stated relationship and worse than the one member would be. Recorded in the module header in events' style: it wants Interval learning what its ends are, not a new member. Also fixes a dead-code warning from 3845056: LEDGER is read only by its test, so it is cfg(test) rather than compiled into the app.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 16:52 UTC
Signed with PGP, not checked
Commit: b281a3e44cd6cd4b5b09adfc733be676a6a47cb8
Parent: 5721a47
5 files changed, +850 insertions, -0 deletions
@@ -287,6 +287,12 @@
287 287 });
288 288 }
289 289
290 + // The way to the screen that authors them. A `Notice` carries no act, so the
291 + // banner above cannot be the way in: it says what frames the day and this
292 + // says where that came from. Offered whether or not one is showing, because
293 + // recording a context is most often done for days you are not looking at.
294 + band = band.with(Node::Token(Tag::chip("Contexts", Action::get("/contexts"))));
295 +
290 296 band
291 297 }
292 298
@@ -93,6 +93,7 @@
93 93 pub mod board;
94 94 pub mod compose;
95 95 pub mod contacts;
96 + pub mod contexts;
96 97 pub mod data;
97 98 pub mod day_planning;
98 99 pub mod emails;
@@ -306,6 +307,7 @@
306 307 let router = monthly_review::routes(router);
307 308 let router = problems::routes(router);
308 309 let router = day_planning::routes(router);
310 + let router = contexts::routes(router);
309 311 let router = board::routes(router);
310 312 let router = task_list::routes(router);
311 313 let router = data::routes(router);
@@ -25,6 +25,7 @@
25 25 //! config/credential table can never leak into a group scope. `group_id` is
26 26 //! provenance only, never a synced column.
27 27
28 + #[cfg(test)]
28 29 use synckit_client::store::LedgerEntry;
29 30 use synckit_client::store::config_sync_table;
30 31 use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
@@ -462,6 +463,10 @@
462 463 /// Committed history: it only ever grows a row. `manifest_matches_its_ledger`
463 464 /// fails if the manifest moves without a bump, which is the whole mechanism, a
464 465 /// declared integer alone would rely on someone remembering.
466 + /// Read only by `manifest_matches_its_ledger`, which is the only thing that ever
467 + /// needs it: the ledger is a claim about history that a test checks, not a value
468 + /// the running app consults.
469 + #[cfg(test)]
465 470 const LEDGER: &[LedgerEntry] = &[LedgerEntry {
466 471 version: 1,
467 472 fingerprint: "41d646183a266e6ee4e7a5b58dc054afa96b369923f231bc688f130228764e4e",
@@ -1,0 +1,536 @@
1 + //! Contexts, authored as spans rather than checked off a week.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! A context is a state that frames days, as distinct from an event that
6 + //! occupies them. The model landed 2026-08-18 (`c5f74e0f`) and shipped with two
7 + //! ways in, both narrow: migration 067 converts a multi-day event or a
8 + //! `vacation_days` run, and the weekly review's seven checkboxes write spans
9 + //! through `commands::weekly_review::set_vacation_week`. The checkboxes can only
10 + //! say *Vacation*, only inside one week, and cannot name what they wrote. A
11 + //! trip, an illness and a sprint are `ContextKind` variants nothing could
12 + //! create.
13 + //!
14 + //! This is the place to record one as what it is: a label, a kind, and two
15 + //! dates.
16 + //!
17 + //! # Where it goes, and why not the calendar
18 + //!
19 + //! Not a calendar. `4a1237b6` still stands: a context sits behind the day's axis
20 + //! rather than on it, and putting it on a timeline is the conflation the model
21 + //! exists to undo. Its own screen, marked at the Day place, reached from the day
22 + //! band beside the banners it explains. The nav is untouched: [`shell`]'s places
23 + //! are the transcription of `navigation.js`'s `TAB_GROUPS`, and a twelfth place
24 + //! would be this module answering a product question rather than a placement
25 + //! one.
26 + //!
27 + //! # The shape
28 + //!
29 + //! - `GET /contexts` — the document: the list, and a pane.
30 + //! - `GET /contexts/new` — the create form, in the pane.
31 + //! - `GET /contexts/{id}` — the edit form, in the pane, filled.
32 + //! - `POST /contexts` — create.
33 + //! - `POST /contexts/{id}` — re-label, re-kind, re-span.
34 + //! - `POST /contexts/{id}/delete` — delete, which puts a migrated event back.
35 + //!
36 + //! Every write answers with the whole screen for [`super::projects::wrote`]'s
37 + //! reason: a write lands in the list and in the pane at once, and a `Response`
38 + //! names one region.
39 + //!
40 + //! # What this port could not say, and did not fake
41 + //!
42 + //! **A date interval is one question with two ends, and there is no way to say
43 + //! so.** `FieldKind::Interval` exists and is exactly the right shape: it carries
44 + //! two names, it says the ends constrain each other, and it gives a crossing
45 + //! fault one place to be reported instead of two. What it cannot say is that its
46 + //! ends are dates. The kind is the value's kind, so an interval's ends are
47 + //! whatever `Interval` draws, and `quasi_webview::node::refill` emits them as
48 + //! the numeric pair its two measured sites wanted (audiofiles' BPM axes, the MNW
49 + //! server's price pair).
50 + //!
51 + //! So the span is two `FieldKind::Date` fields here, and the crossing rule is
52 + //! checked in [`validate`] and reported on the start. That is the honest
53 + //! description of two controls with no stated relationship, and it is worse than
54 + //! the one member would be: "off from the 3rd to the 17th" is one decision, and
55 + //! this screen exists because storing its projection as if it were the fact felt
56 + //! wrong to write.
57 + //!
58 + //! Recorded here rather than filed as a task, in `events`' style: it is a
59 + //! vocabulary gap seen from a second screen, and what it wants is `Interval`
60 + //! learning what its ends are rather than a new member.
61 +
62 + // Handlers take their request by value because `quasi_router::Handler` is a
63 + // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
64 + // choice made here.
65 + #![allow(clippy::needless_pass_by_value)]
66 +
67 + use chrono::NaiveDate;
68 + use goingson_core::id_types::ContextId;
69 + use goingson_core::models::{Context, ContextKind};
70 + use quasi_router::screen::{Act, Choice, Field, Row, Tag};
71 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
72 +
73 + use crate::state::{AppState, DESKTOP_USER_ID};
74 +
75 + #[cfg(test)]
76 + mod tests;
77 +
78 + /// The kinds a person can choose.
79 + ///
80 + /// Every variant except `Other`, which is what an unknown stored kind parses to
81 + /// rather than something anyone picks: offering it would ask the user to file a
82 + /// context as "something else" when the label is already where they say what it
83 + /// is.
84 + const KINDS: &[ContextKind] = &[
85 + ContextKind::Vacation,
86 + ContextKind::Trip,
87 + ContextKind::Illness,
88 + ContextKind::Sprint,
89 + ];
90 +
91 + /// What a kind is called on screen.
92 + ///
93 + /// Apart from [`ContextKind::as_str`], which is the stored value and the wire's.
94 + /// A display name that drifted from the stored one would be a second vocabulary;
95 + /// these agree today and are separate so that a rename of either is not
96 + /// automatically a migration.
97 + fn kind_label(kind: ContextKind) -> &'static str {
98 + match kind {
99 + ContextKind::Vacation => "Vacation",
100 + ContextKind::Trip => "Trip",
101 + ContextKind::Illness => "Illness",
102 + ContextKind::Sprint => "Sprint",
103 + ContextKind::Other => "Other",
104 + }
105 + }
106 +
107 + /// Every context, earliest first.
108 + fn all(state: &AppState) -> Result<Vec<Context>, RouteError> {
109 + state
110 + .contexts
111 + .list_all(DESKTOP_USER_ID)
112 + .map_err(|error| RouteError::internal(error.to_string()))
113 + }
114 +
115 + /// One context by id.
116 + ///
117 + /// `ContextRepository` has no `get`, and adding one for this screen would be
118 + /// storage work the task said this is not. The list is the whole set a person
119 + /// authored, so scanning it costs nothing a person would notice.
120 + fn load(state: &AppState, id: ContextId) -> Result<Context, RouteError> {
121 + all(state)?
122 + .into_iter()
123 + .find(|context| context.id == id)
124 + .ok_or_else(|| RouteError::not_found("no such context"))
125 + }
126 +
127 + /// The id in the path.
128 + fn context_id(request: &quasi_router::Request) -> Result<ContextId, RouteError> {
129 + let raw = request
130 + .captures
131 + .get("id")
132 + .ok_or_else(|| RouteError::not_found("no context id"))?;
133 + Ok(ContextId::from(
134 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not a context id"))?,
135 + ))
136 + }
137 +
138 + /// How a context reads in the list.
139 + ///
140 + /// The span is the secondary line rather than a token, because it is what the
141 + /// record *is*: a label without its dates is not a context, and a token would
142 + /// rank it beside the kind.
143 + fn row_for(context: &Context, current: bool) -> Row {
144 + let days = context.days();
145 + let mut row = Row::new(&context.label)
146 + .token(Tag::badge(kind_label(context.kind)))
147 + .secondary(format!(
148 + "{} to {} ({} day{})",
149 + context.starts_on.format("%-d %b %Y"),
150 + context.ends_on.format("%-d %b %Y"),
151 + days,
152 + if days == 1 { "" } else { "s" }
153 + ));
154 +
155 + if context.migrated_from_event_id.is_some() {
156 + row = row.token(Tag::badge("From an event"));
157 + }
158 +
159 + row.current = current;
160 + row.activate = Some(Action::get(format!("/contexts/{}", context.id)));
161 + row
162 + }
163 +
164 + /// The list of contexts.
165 + fn list_node(state: &AppState, current: Option<ContextId>) -> Result<Node, RouteError> {
166 + let contexts = all(state)?;
167 + if contexts.is_empty() {
168 + return Ok(Node::empty("No contexts yet.").offering(Act::new(
169 + "Record your first context",
170 + Action::get("/contexts/new"),
171 + )));
172 + }
173 + Ok(Node::list(contexts.iter().map(|context| {
174 + row_for(context, current == Some(context.id))
175 + })))
176 + }
177 +
178 + /// The form's fields, for a create or an edit.
179 + ///
180 + /// `existing` fills them for an edit; `submitted` refills them after a refusal,
181 + /// and wins, because what the user just typed is nearer to what they meant than
182 + /// what is stored. [`Field::refilled`] is the same repair `1c4a66a4` closed on
183 + /// quasi-router.
184 + fn form_fields(
185 + existing: Option<&Context>,
186 + errors: &[(&str, String)],
187 + submitted: Option<&quasi_router::Params>,
188 + ) -> Vec<Field> {
189 + let error_for = |name: &str| {
190 + errors
191 + .iter()
192 + .find(|(field, _)| *field == name)
193 + .map(|(_, message)| message.clone())
194 + };
195 + let apply = |field: Field, name: &str| match error_for(name) {
196 + Some(message) => field.error(message),
197 + None => field,
198 + };
199 +
200 + let mut label = Field::new(makeover_layout::FieldKind::Text, "label", "Label").required();
201 + label.placeholder = Some("Two weeks in Lisbon".to_owned());
202 + if let Some(context) = existing {
203 + label = label.value(context.label.clone());
204 + }
205 +
206 + let mut kind = Field::select(
207 + "kind",
208 + "Kind",
209 + KINDS
210 + .iter()
211 + .map(|kind| Choice::new(kind.as_str(), kind_label(*kind)))
212 + .collect(),
213 + );
214 + if let Some(context) = existing {
215 + kind = kind.value(context.kind.as_str());
216 + }
217 +
218 + // Two fields rather than one interval. See the module header.
219 + let mut starts = Field::new(makeover_layout::FieldKind::Date, "starts_on", "First day")
220 + .required()
221 + .hint("The first day inside it.");
222 + let mut ends = Field::new(makeover_layout::FieldKind::Date, "ends_on", "Last day")
223 + .required()
224 + .hint("Inclusive: off until the 17th means the 17th is off.");
225 + if let Some(context) = existing {
226 + starts = starts.value(context.starts_on.to_string());
227 + ends = ends.value(context.ends_on.to_string());
228 + }
229 +
230 + let fields = vec![
231 + apply(label, "label"),
232 + apply(kind, "kind"),
233 + apply(starts, "starts_on"),
234 + apply(ends, "ends_on"),
235 + ];
236 +
237 + match submitted {
238 + Some(params) => fields
239 + .into_iter()
240 + .map(|field| field.refilled(params))
241 + .collect(),
242 + None => fields,
243 + }
244 + }
245 +
246 + /// The pane as a region, which is what a fragment answers with.
247 + fn form_pane(
248 + existing: Option<&Context>,
249 + errors: &[(&str, String)],
250 + submitted: Option<&quasi_router::Params>,
251 + ) -> Node {
252 + Node::Region(form_slot(existing, errors, submitted))
253 + }
254 +
255 + /// The pane: a form, and for an edit the things only an edit can offer.
256 + fn form_slot(
257 + existing: Option<&Context>,
258 + errors: &[(&str, String)],
259 + submitted: Option<&quasi_router::Params>,
260 + ) -> Slot {
261 + let mut pane = Slot::new("contexts-detail", RegionKind::Pane);
262 + pane = match existing {
263 + None => pane.with(Node::section("New context")),
264 + Some(context) => pane.with(Node::section(format!("Editing {}", context.label))),
265 + };
266 +
267 + // Provenance, and the reversal it buys, said before the controls rather than
268 + // after: deleting a migrated context is the one delete on this screen that
269 + // does something to a record the user did not author here.
270 + if existing.is_some_and(|context| context.migrated_from_event_id.is_some()) {
271 + pane = pane.with(Node::Notice {
272 + kind: makeover_layout::Notice::Banner,
273 + tone: makeover_layout::Tone::Info,
274 + text: "Converted from an event. Deleting this puts that event back on the timeline, \
275 + with its times intact."
276 + .to_owned(),
277 + });
278 + }
279 +
280 + pane = pane.with(Node::Form {
281 + action: match existing {
282 + None => Action::post("/contexts"),
283 + Some(context) => Action::post(format!("/contexts/{}", context.id)),
284 + },
285 + submit: match existing {
286 + None => "Record context".to_owned(),
287 + Some(_) => "Save".to_owned(),
288 + },
289 + fields: form_fields(existing, errors, submitted),
290 + });
291 +
292 + if let Some(context) = existing {
293 + pane = pane.with(Node::Act(
294 + Act::new(
295 + "Delete",
296 + Action::post(format!("/contexts/{}/delete", context.id)),
297 + )
298 + .tone(makeover_layout::Tone::Danger)
299 + .confirm(match context.migrated_from_event_id {
300 + Some(_) => "Delete this context and put its event back?",
301 + None => "Delete this context?",
302 + }),
303 + ));
304 + }
305 +
306 + pane
307 + }
308 +
309 + /// The whole screen.
310 + fn screen(
311 + state: &AppState,
312 + current: Option<&Context>,
313 + errors: &[(&str, String)],
314 + submitted: Option<&quasi_router::Params>,
315 + ) -> Result<Screen, RouteError> {
316 + let band = Slot::new("contexts-band", RegionKind::Band)
317 + .with(Node::page("Contexts"))
318 + .with(Node::act("New context", Action::get("/contexts/new")));
319 +
320 + let pane = match current {
321 + Some(context) => form_slot(Some(context), errors, submitted),
322 + None => {
323 + Slot::new("contexts-detail", RegionKind::Pane).with(Node::empty("Nothing selected"))
324 + }
325 + };
326 +
327 + Ok(Screen::list_detail("Contexts", false)
328 + // The Day place rather than a twelfth one: a context frames a day, and
329 + // the day view is where its banner is read.
330 + .at_place(super::shell::DAY)
331 + .with(band)
332 + .with(
333 + Slot::new("contexts-list", RegionKind::Pane)
334 + .with(list_node(state, current.map(|context| context.id))?),
335 + )
336 + .with(pane))
337 + }
338 +
339 + /// The document.
340 + fn index(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
341 + Ok(screen(state, None, &[], None)?.into())
342 + }
343 +
344 + /// The create form, in the pane.
345 + fn new(_state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
346 + Ok(Response::fragment(
347 + "contexts-detail",
348 + form_pane(None, &[], None),
349 + ))
350 + }
351 +
352 + /// The edit form, filled, in the pane.
353 + fn detail(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
354 + let context = load(state, context_id(&request)?)?;
355 + Ok(Response::fragment(
356 + "contexts-detail",
357 + form_pane(Some(&context), &[], None),
358 + ))
359 + }
360 +
361 + /// What a context has to be before it can be recorded.
362 + ///
363 + /// The crossing rule is checked rather than corrected. `ContextRepository`
364 + /// orders the ends on the way in, on the argument that a span typed backwards is
365 + /// a slip whose days are unambiguous, and that is right for a migration reading
366 + /// stored data. It is wrong for a form: silently swapping what someone typed
367 + /// tells them nothing, and the next thing they do is wonder why the dates moved.
368 + fn validate(
369 + label: &str,
370 + starts_on: Option<NaiveDate>,
371 + ends_on: Option<NaiveDate>,
372 + ) -> Vec<(&'static str, String)> {
373 + let mut errors = Vec::new();
374 + if label.is_empty() {
375 + errors.push(("label", "A context needs a label.".to_owned()));
376 + } else if label.chars().count() > 100 {
377 + errors.push(("label", "Maximum 100 characters".to_owned()));
378 + }
379 + if starts_on.is_none() {
380 + errors.push(("starts_on", "Needs a first day.".to_owned()));
381 + }
382 + if ends_on.is_none() {
383 + errors.push(("ends_on", "Needs a last day.".to_owned()));
384 + }
385 + if let (Some(starts), Some(ends)) = (starts_on, ends_on)
386 + && starts > ends
387 + {
388 + errors.push((
389 + "starts_on",
390 + "The first day is after the last one.".to_owned(),
391 + ));
392 + }
393 + errors
394 + }
395 +
396 + /// A date out of the payload, or `None` if it is missing or unparseable.
397 + ///
398 + /// Both cases are the same answer here because both are reported the same way:
399 + /// `FieldKind::Date` states the wire format, so a value that will not parse did
400 + /// not come from the control.
401 + fn date(params: &quasi_router::Params, name: &str) -> Option<NaiveDate> {
402 + params.get(name)?.trim().parse().ok()
403 + }
404 +
405 + /// What every write answers with.
406 + ///
407 + /// The whole screen, for [`super::projects`]'s reason: a write lands in the list
408 + /// and in the pane at once, and a `Response` names one region. `outerMorph`
409 + /// keeps focus and scroll across it.
410 + fn wrote(state: &AppState, message: &str) -> Result<Response, RouteError> {
411 + Ok(Response::from(screen(state, None, &[], None)?)
412 + .toast(makeover_layout::Tone::Success, message))
413 + }
414 +
415 + /// The fields a write reads, validated together.
416 + struct Submitted {
417 + label: String,
418 + kind: ContextKind,
419 + starts_on: NaiveDate,
420 + ends_on: NaiveDate,
421 + }
422 +
423 + /// Read and check a submission, or hand back the form saying why not.
424 + ///
425 + /// Every complaint at once. Answering with the first one found is how a form is
426 + /// fixed one round trip per mistake.
427 + fn submitted(
428 + request: &quasi_router::Request,
429 + existing: Option<&Context>,
430 + ) -> Result<Submitted, Box<Node>> {
431 + let label = request
432 + .payload
433 + .get("label")
434 + .unwrap_or_default()
435 + .trim()
436 + .to_owned();
437 + let starts_on = date(&request.payload, "starts_on");
438 + let ends_on = date(&request.payload, "ends_on");
439 +
440 + let errors = validate(&label, starts_on, ends_on);
441 + if !errors.is_empty() {
442 + return Err(Box::new(form_pane(
443 + existing,
444 + &errors,
445 + Some(&request.payload),
446 + )));
447 + }
448 +
449 + // Unlike a status or a project type, an unknown kind cannot be refused:
450 + // `ContextKind::parse` maps one to `Other` and cannot fail, deliberately,
451 + // because a row written by a newer client is still a real span of days.
452 + Ok(Submitted {
453 + label,
454 + kind: ContextKind::parse(request.payload.get("kind").unwrap_or_default()),
455 + starts_on: starts_on.expect("validated"),
456 + ends_on: ends_on.expect("validated"),
457 + })
458 + }
459 +
460 + /// Record a context.
461 + fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
462 + let fields = match submitted(&request, None) {
463 + Ok(fields) => fields,
464 + Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)),
465 + };
466 +
467 + state
468 + .contexts
469 + .create(
470 + DESKTOP_USER_ID,
471 + &fields.label,
472 + fields.kind,
473 + fields.starts_on,
474 + fields.ends_on,
475 + )
476 + .map_err(|error| RouteError::internal(error.to_string()))?;
477 +
478 + wrote(state, "Context recorded.")
479 + }
480 +
481 + /// Re-label, re-kind or re-span one.
482 + fn update(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
483 + let id = context_id(&request)?;
484 + let existing = load(state, id)?;
485 + let fields = match submitted(&request, Some(&existing)) {
486 + Ok(fields) => fields,
487 + Err(pane) => return Ok(Response::fragment("contexts-detail", *pane)),
488 + };
489 +
490 + state
491 + .contexts
492 + .update(
493 + DESKTOP_USER_ID,
494 + id,
495 + &fields.label,
496 + fields.kind,
497 + fields.starts_on,
498 + fields.ends_on,
499 + )
500 + .map_err(|error| RouteError::internal(error.to_string()))?;
Lines truncated
@@ -1,0 +1,337 @@
1 + //! Contexts, driven through the router against a real database.
2 +
3 + use std::sync::Arc;
4 +
5 + use chrono::NaiveDate;
6 + use goingson_core::models::{Context, ContextKind};
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 + fn day(date: &str) -> NaiveDate {
37 + date.parse().expect("a date")
38 + }
39 +
40 + fn make(state: &AppState, label: &str, kind: ContextKind, starts: &str, ends: &str) -> Context {
41 + state
42 + .contexts
43 + .create(DESKTOP_USER_ID, label, kind, day(starts), day(ends))
44 + .unwrap()
45 + }
46 +
47 + fn html(response: Response) -> String {
48 + match response.outcome {
49 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
50 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
51 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
52 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
53 + Outcome::Suggestions { field, .. } => {
54 + panic!("expected content, got a suggestion list for `{field}`")
55 + }
56 + Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
57 + }
58 + }
59 +
60 + fn get(state: &AppState, path: &str, params: Params) -> Response {
61 + router()
62 + .handle(state, Request::get(path).carrying(params))
63 + .expect("the route answers")
64 + }
65 +
66 + fn post(state: &AppState, path: &str, params: Params) -> Response {
67 + router()
68 + .handle(state, Request::post(path).sending(params))
69 + .expect("the route answers")
70 + }
71 +
72 + fn screen(state: &AppState) -> String {
73 + html(get(state, "/contexts", Params::new()))
74 + }
75 +
76 + fn span(label: &str, kind: &str, starts: &str, ends: &str) -> Params {
77 + let mut params = Params::new();
78 + params.insert("label".to_owned(), label.to_owned());
79 + params.insert("kind".to_owned(), kind.to_owned());
80 + params.insert("starts_on".to_owned(), starts.to_owned());
81 + params.insert("ends_on".to_owned(), ends.to_owned());
82 + params
83 + }
84 +
85 + fn only(state: &AppState) -> Context {
86 + let mut all = state.contexts.list_all(DESKTOP_USER_ID).unwrap();
87 + assert_eq!(all.len(), 1, "expected exactly one context");
88 + all.remove(0)
89 + }
90 +
91 + #[tokio::test]
92 + async fn an_empty_list_says_so_and_offers_the_way_in() {
93 + let state = state().await;
94 + let page = screen(&state);
95 + assert!(page.contains("No contexts yet."), "{page}");
96 + assert!(page.contains("/contexts/new"), "{page}");
97 + }
98 +
99 + /// The whole point of the screen: a kind the checkboxes cannot say, with a name.
100 + #[tokio::test]
101 + async fn a_context_can_be_named_and_given_a_kind_the_checkboxes_cannot_say() {
102 + let state = state().await;
103 + post(
104 + &state,
105 + "/contexts",
106 + span("Two weeks in Lisbon", "Trip", "2026-09-03", "2026-09-17"),
107 + );
108 +
109 + let recorded = only(&state);
110 + assert_eq!(recorded.label, "Two weeks in Lisbon");
111 + assert_eq!(recorded.kind, ContextKind::Trip);
112 + assert_eq!(recorded.starts_on, day("2026-09-03"));
113 + assert_eq!(recorded.ends_on, day("2026-09-17"));
114 + }
115 +
116 + /// Authored as a span, so the record is one decision and not fifteen.
117 + #[tokio::test]
118 + async fn a_span_is_one_record_however_many_days_it_covers() {
119 + let state = state().await;
120 + post(
121 + &state,
122 + "/contexts",
123 + span("Leave", "Vacation", "2026-08-03", "2026-08-17"),
124 + );
125 + let recorded = only(&state);
126 + assert_eq!(recorded.days(), 15, "both ends are inside");
127 + assert!(
128 + recorded.covers(day("2026-08-17")),
129 + "and the last day is off"
130 + );
131 + }
132 +
133 + #[tokio::test]
134 + async fn the_list_shows_the_span_rather_than_the_label_alone() {
135 + let state = state().await;
136 + make(
137 + &state,
138 + "Leave",
139 + ContextKind::Vacation,
140 + "2026-08-03",
141 + "2026-08-17",
142 + );
143 + let page = screen(&state);
144 + assert!(page.contains("Leave"), "{page}");
145 + assert!(page.contains("3 Aug 2026"), "{page}");
146 + assert!(page.contains("17 Aug 2026"), "{page}");
147 + assert!(page.contains("15 days"), "{page}");
148 + }
149 +
150 + /// The repository orders the ends on the way in, which is right for a migration
151 + /// reading stored data and wrong for a form. A backwards span is reported.
152 + #[tokio::test]
153 + async fn a_backwards_span_is_refused_rather_than_silently_swapped() {
154 + let state = state().await;
155 + let page = html(post(
156 + &state,
157 + "/contexts",
158 + span("Backwards", "Vacation", "2026-08-17", "2026-08-03"),
159 + ));
160 +
161 + assert!(
162 + page.contains("The first day is after the last one."),
163 + "{page}"
164 + );
165 + assert!(
166 + state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty(),
167 + "nothing was written"
168 + );
169 + }
170 +
171 + /// `1c4a66a4`'s repair, asked of this form: a refusal hands back what was typed.
172 + #[tokio::test]
173 + async fn a_refused_form_comes_back_carrying_what_was_typed() {
174 + let state = state().await;
175 + let page = html(post(
176 + &state,
177 + "/contexts",
178 + span("", "Trip", "2026-09-03", "2026-09-17"),
179 + ));
180 +
181 + assert!(page.contains("A context needs a label."), "{page}");
182 + // The dates the user did fill in survive the round trip.
183 + assert!(page.contains("2026-09-03"), "{page}");
184 + assert!(page.contains("2026-09-17"), "{page}");
185 + }
186 +
187 + #[tokio::test]
188 + async fn every_complaint_arrives_at_once() {
189 + let state = state().await;
190 + let mut params = span("", "Vacation", "", "");
191 + params.insert("kind".to_owned(), "Vacation".to_owned());
192 + let page = html(post(&state, "/contexts", params));
193 +
194 + assert!(page.contains("A context needs a label."), "{page}");
195 + assert!(page.contains("Needs a first day."), "{page}");
196 + assert!(page.contains("Needs a last day."), "{page}");
197 + }
198 +
199 + #[tokio::test]
200 + async fn the_edit_form_arrives_filled() {
201 + let state = state().await;
202 + let existing = make(
203 + &state,
204 + "Leave",
205 + ContextKind::Vacation,
206 + "2026-08-03",
207 + "2026-08-17",
208 + );
209 + let page = html(get(
210 + &state,
211 + &format!("/contexts/{}", existing.id),
212 + Params::new(),
213 + ));
214 +
215 + assert!(page.contains("Leave"), "{page}");
216 + assert!(page.contains("2026-08-03"), "{page}");
217 + assert!(page.contains("2026-08-17"), "{page}");
218 + }
219 +
220 + #[tokio::test]
221 + async fn a_context_can_be_relabelled_rekinded_and_respanned() {
222 + let state = state().await;
223 + let existing = make(
224 + &state,
225 + "Leave",
226 + ContextKind::Vacation,
227 + "2026-08-03",
228 + "2026-08-17",
229 + );
230 + post(
231 + &state,
232 + &format!("/contexts/{}", existing.id),
233 + span("Flu", "Illness", "2026-08-05", "2026-08-08"),
234 + );
235 +
236 + let updated = only(&state);
237 + assert_eq!(updated.id, existing.id, "the same record, changed");
238 + assert_eq!(updated.label, "Flu");
239 + assert_eq!(updated.kind, ContextKind::Illness);
240 + assert_eq!(updated.starts_on, day("2026-08-05"));
241 + assert_eq!(updated.ends_on, day("2026-08-08"));
242 + }
243 +
244 + #[tokio::test]
245 + async fn a_context_can_be_deleted() {
246 + let state = state().await;
247 + let existing = make(
248 + &state,
249 + "Leave",
250 + ContextKind::Vacation,
251 + "2026-08-03",
252 + "2026-08-17",
253 + );
254 + post(
255 + &state,
256 + &format!("/contexts/{}/delete", existing.id),
257 + Params::new(),
258 + );
259 + assert!(state.contexts.list_all(DESKTOP_USER_ID).unwrap().is_empty());
260 + }
261 +
262 + /// The reversal path `8ee5c4fe` made the condition of accepting a migration that
263 + /// guesses: deleting the context puts its event back, and the screen says so
264 + /// before the delete rather than after it.
265 + #[tokio::test]
266 + async fn a_migrated_context_says_where_it_came_from_and_puts_the_event_back() {
267 + let state = state().await;
268 + let event = state
269 + .events
270 + .create(
271 + DESKTOP_USER_ID,
272 + goingson_core::NewEvent::builder("Conference", chrono::Utc::now())
273 + .user_id(DESKTOP_USER_ID)
274 + .build(),
275 + )
276 + .unwrap();
277 + let context = make(
278 + &state,
279 + "Conference",
280 + ContextKind::Trip,
281 + "2026-09-03",
282 + "2026-09-05",
283 + );
284 +
285 + // The migration's own arrangement: the event is hidden rather than deleted,
286 + // and the context points back at it.
287 + let conn = state.db.conn().unwrap();
288 + conn.execute(
289 + "UPDATE contexts SET migrated_from_event_id = ?1 WHERE id = ?2",
290 + rusqlite::params![event.id.to_string(), context.id.to_string()],
291 + )
292 + .unwrap();
293 + conn.execute(
294 + "UPDATE events SET converted_to_context_id = ?1 WHERE id = ?2",
295 + rusqlite::params![context.id.to_string(), event.id.to_string()],
296 + )
297 + .unwrap();
298 + drop(conn);
299 +
300 + let pane = html(get(
301 + &state,
302 + &format!("/contexts/{}", context.id),
303 + Params::new(),
304 + ));
305 + assert!(pane.contains("Converted from an event."), "{pane}");
306 + assert!(
307 + pane.contains("puts that event back on the timeline"),
308 + "{pane}"
309 + );
310 +
311 + post(
312 + &state,
313 + &format!("/contexts/{}/delete", context.id),
314 + Params::new(),
315 + );
316 +
317 + let hidden: Option<String> = state
318 + .db
319 + .conn()
320 + .unwrap()
321 + .query_row(
322 + "SELECT converted_to_context_id FROM events WHERE id = ?1",
323 + rusqlite::params![event.id.to_string()],
324 + |row| row.get(0),
325 + )
326 + .unwrap();
327 + assert!(hidden.is_none(), "the event is back on the timeline");
328 + }
329 +
330 + /// The day band is the way in, since a `Notice` carries no act and the banner
331 + /// therefore cannot be one.
332 + #[tokio::test]
333 + async fn the_day_band_offers_the_way_to_the_contexts_screen() {
334 + let state = state().await;
335 + let page = html(get(&state, "/day", Params::new()));
336 + assert!(page.contains("/contexts"), "{page}");
337 + }