Skip to main content

max / goingson

Describe the day view, the screen that could not be described Tenth port, and the first that needed the vocabulary to grow. Every one before it took a screen the description could already say. day-planning*.js is 1,585 lines across four files and was on every "cannot be described" list in the tree, on the argument that a description expressive enough to draw a timeline is a component library wearing a description's name. Held against the actual file, what it needed and could not get was where a thing starts and how long it lasts. Two integers, now makeover-layout 0.24.0's Placement. The clearest evidence that the gap was expressive rather than real: nothing was added to the backend. TimelineItem has carried day_offset_minutes and visible_duration_minutes all along, in the units Placement wants. The screen was always computable and never sayable. Drawn here: the axis, everything on it, conflicts, the all-day strip, the unscheduled pool with its plan gates, the vacation banner, and stepping between days. Overlapping items take lanes so neither is drawn over the other; that packing is quasi-webview's, because it is a fact about how wide the box is rather than about the day. Not here, and each a decision rather than an omission: drag-to-reschedule and drag-to-paint. Both are continuous gestures, both are quasicoherent e41079b2, and both are interactions rather than drawings -- so the host keeps them and the screen does not wait on them. That is the arrangement RegionKind::Bespoke used to be needed for, minus the bespoke region. get_day_planning splits into a sync day_plan(&AppState, NaiveDate) the route can call. A second query assembling the same plan is how a described screen starts disagreeing with the shipped one about what today looks like. Takes quasi 0.6 and the makeover 0.24.0 cascade: layout 0.24.0, webview 0.42.0, build 0.35.0, tui 0.22.0. DEAD_VOCABULARY_HIGH_WATER 23 -> 26 for the three track-* classes. It does not tighten when this screen ships, because quasi-webview emits them rather than any string in this repo -- the gap the constant's own header names. 550 tests, up from 541: nine for placement, lanes, the back-to-back off-by-one that reads every appointment on the hour as a clash, the ruler, the focus moment, and that a placed row's title still cannot become markup.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-15 15:58 UTC
Signed with PGP, not checked
Commit: b0933729e91bfa04e5674476499585d021f2a032
Parent: 41c47ff
8 files changed, +573 insertions, -32 deletions
M Cargo.lock +21 -21
@@ -3398,9 +3398,9 @@
3398 3398
3399 3399 [[package]]
3400 3400 name = "makeover-build"
3401 - version = "0.34.0"
3401 + version = "0.35.0"
3402 3402 source = "registry+https://github.com/rust-lang/crates.io-index"
3403 - checksum = "c6d50592e37f4daa302a35c35b19f2bb7ef14926a64efa2133218516b14dc012"
3403 + checksum = "a97084502058507e10cad14d490963d74ad453e654d4fe3b3e83fbb6df5a64b8"
3404 3404 dependencies = [
3405 3405 "makeover",
3406 3406 "makeover-geometry",
@@ -3415,15 +3415,15 @@
3415 3415
3416 3416 [[package]]
3417 3417 name = "makeover-layout"
3418 - version = "0.23.0"
3418 + version = "0.24.0"
3419 3419 source = "registry+https://github.com/rust-lang/crates.io-index"
3420 - checksum = "6109712c400d787df788da4379559540b6b262b18b6cd4f42d7c084b2e6e8d79"
3420 + checksum = "d7e8857e635b4b23c21bbea360a4b33eefb4c6ff252a4cf4b3564a55de55d19e"
3421 3421
3422 3422 [[package]]
3423 3423 name = "makeover-touch"
3424 - version = "0.15.0"
3424 + version = "0.16.0"
3425 3425 source = "registry+https://github.com/rust-lang/crates.io-index"
3426 - checksum = "e3e9a630a8003974a31b3ceeea36684c94254e7e517a0fb3029b5fd9ed667692"
3426 + checksum = "af562d05e17947bb8fe7f3ce45d8f9b803627ae14b8931ebf578e12ddae85bdc"
3427 3427 dependencies = [
3428 3428 "makeover-geometry",
3429 3429 "makeover-layout",
@@ -3431,9 +3431,9 @@
3431 3431
3432 3432 [[package]]
3433 3433 name = "makeover-tui"
3434 - version = "0.21.0"
3434 + version = "0.22.0"
3435 3435 source = "registry+https://github.com/rust-lang/crates.io-index"
3436 - checksum = "3f4bb2e3873442d9f429bcc5083bc7a8217ef0f496b2da6d10e80206e889f5e4"
3436 + checksum = "027e2be8f603b034a809ac2a7be7dc055ac7d3a864a3fa6f1a4418b8d976ad4f"
3437 3437 dependencies = [
3438 3438 "makeover",
3439 3439 "makeover-layout",
@@ -3442,9 +3442,9 @@
3442 3442
3443 3443 [[package]]
3444 3444 name = "makeover-webview"
3445 - version = "0.41.0"
3445 + version = "0.42.0"
3446 3446 source = "registry+https://github.com/rust-lang/crates.io-index"
3447 - checksum = "8dce89f8aff6c9b0a91e0f5c601b4321b8f059feee437bc7b570850b5c665e26"
3447 + checksum = "6937a91744ab84f225f36656819a9db4b0a08d5adfb65a7f71f0b03b1281cd0e"
3448 3448 dependencies = [
3449 3449 "makeover-geometry",
3450 3450 "makeover-layout",
@@ -4651,7 +4651,7 @@
4651 4651
4652 4652 [[package]]
4653 4653 name = "quasi-http"
4654 - version = "0.5.0"
4654 + version = "0.6.0"
4655 4655 dependencies = [
4656 4656 "form_urlencoded",
4657 4657 "http",
@@ -4660,14 +4660,14 @@
4660 4660
4661 4661 [[package]]
4662 4662 name = "quasi-router"
4663 - version = "0.5.0"
4663 + version = "0.6.0"
4664 4664 dependencies = [
4665 4665 "makeover-layout",
4666 4666 ]
4667 4667
4668 4668 [[package]]
4669 4669 name = "quasi-tauri"
4670 - version = "0.5.0"
4670 + version = "0.6.0"
4671 4671 dependencies = [
4672 4672 "http",
4673 4673 "quasi-http",
@@ -4677,7 +4677,7 @@
4677 4677
4678 4678 [[package]]
4679 4679 name = "quasi-webview"
4680 - version = "0.5.0"
4680 + version = "0.6.0"
4681 4681 dependencies = [
4682 4682 "docengine",
4683 4683 "makeover-layout",
@@ -8373,22 +8373,22 @@
8373 8373 "winnow 1.0.4",
8374 8374 ]
8375 8375
8376 - [[patch.unused]]
8377 - name = "ops-status"
8378 - version = "0.1.0"
8379 -
8380 8376 [[patch.unused]]
8381 8377 name = "quasi-axum"
8382 - version = "0.5.0"
8378 + version = "0.6.0"
8383 8379
8384 8380 [[patch.unused]]
8385 8381 name = "quasi-basics"
8386 - version = "0.5.0"
8382 + version = "0.6.0"
8387 8383
8388 8384 [[patch.unused]]
8389 8385 name = "quasi-immediate"
8390 - version = "0.5.0"
8386 + version = "0.6.0"
8391 8387
8392 8388 [[patch.unused]]
8393 8389 name = "quasi-store"
8394 8390 version = "0.1.0"
8391 +
8392 + [[patch.unused]]
8393 + name = "ops-status"
8394 + version = "0.1.0"
M Cargo.toml +1 -1
@@ -95,7 +95,7 @@
95 95 notify = "8.2"
96 96 notify-debouncer-mini = "0.7"
97 97 makeover = "2.5"
98 - makeover-tui = "0.21.0"
98 + makeover-tui = "0.22.0"
99 99 toml = "1.1"
100 100
101 101 # Enums
@@ -18,12 +18,12 @@
18 18 # Materialises all three generated files: themes/, geometry.css, layout.css,
19 19 # and holds both drift checks this build.rs used to carry itself: touch density
20 20 # as of 0.17, breakpoints as of 0.18.
21 - makeover-build = "0.34.0"
21 + makeover-build = "0.35.0"
22 22 # The table CSS is generated here too: the columns are this app's, so the
23 23 # shared helper cannot know them, but the tracks and the narrowing rules come
24 24 # from the description rather than from hand-written nth-child cuts.
25 - makeover-webview = "0.41.0"
26 - makeover-layout = "0.23.0"
25 + makeover-webview = "0.42.0"
26 + makeover-layout = "0.24.0"
27 27 # Width. Direct rather than through makeover-webview, because the narrow table
28 28 # pass keys off SizeClass::Compact and a boundary reached transitively is a
29 29 # boundary nobody pinned.
@@ -45,10 +45,10 @@
45 45 # dependency's source even with the feature off -- so a public clone could not
46 46 # build at all against a URL it could not reach. quasi went public that day and
47 47 # the URL moved here with it.
48 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.5", optional = true }
49 - quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.5", optional = true }
50 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.5", optional = true }
51 - quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.5", optional = true }
48 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
49 + quasi-http = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
50 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
51 + quasi-tauri = { git = "https://makenot.work/git/max/quasi.git", version = "0.6", optional = true }
52 52
53 53 # Tauri
54 54 tauri = { workspace = true, features = ["image-png", "devtools"] }
@@ -112,8 +112,8 @@
112 112 # The forms emitter and the description it renders. Runtime deps, not build:
113 113 # form markup depends on the value, the error and the options at the moment a
114 114 # modal opens, so it cannot be materialised the way the stylesheet is.
115 - makeover-webview = "0.41.0"
116 - makeover-layout = "0.23.0"
115 + makeover-webview = "0.42.0"
116 + makeover-layout = "0.24.0"
117 117 # Browser opening
118 118 open = { workspace = true }
119 119
@@ -499,10 +499,24 @@
499 499 /// The denominator grew from 46 classes to 50 and goingson's usage did not
500 500 /// change.
501 501 ///
502 + /// # 23 to 26, 2026-08-15: the three `track-*` classes
503 + ///
504 + /// `makeover-layout` 0.24.0 named a time axis and `makeover-webview` 0.42.0
505 + /// emits its rules, so `layout_css` now generates `.track`, `.track-slot`,
506 + /// `.track-tick` and `.track-entry`. Three of the four land here; `.track` does
507 + /// not, because `day-planning-render.js` already writes that word.
508 + ///
509 + /// **This one does not tighten when the day view ports, and that is the gap
510 + /// this constant's header already names.** The described day view emits these
511 + /// classes from `quasi-webview` rather than from any string in this repo, so
512 + /// they read as dead here whether or not a described screen is using them. The
513 + /// count is loose in the safe direction, which is the documented state of
514 + /// affairs and not a new problem.
515 + ///
502 516 /// The one direction this number should move is down, so if it ever needs
503 517 /// raising for a class goingson's own markup *should* be writing, that is the
504 518 /// defect this exists to catch and the answer is the markup, not the seal.
505 - const DEAD_VOCABULARY_HIGH_WATER: usize = 23;
519 + const DEAD_VOCABULARY_HIGH_WATER: usize = 26;
506 520
507 521 /// Every file that can carry a class name.
508 522 fn markup_files(frontend: &Path) -> Vec<PathBuf> {
@@ -133,6 +133,25 @@
133 133 ) -> Result<DayPlanningResponse, ApiError> {
134 134 let parsed_date = chrono::NaiveDate::parse_from_str(&date, "%Y-%m-%d")
135 135 .map_err(|e| ApiError::parse(format!("Invalid date format: {e}. Expected YYYY-MM-DD")))?;
136 + day_plan(&state, parsed_date)
137 + }
138 +
139 + /// One day's plan, built without a Tauri handle.
140 + ///
141 + /// Split out of [`get_day_planning`] on 2026-08-15 so the described day view can
142 + /// call it. `quasi_router::Handler` is a plain `fn(&AppState, Request)`, so a
143 + /// command taking `State<'_, Arc<AppState>>` and returning a future cannot be a
144 + /// route; and a second query assembling the same plan is how a described screen
145 + /// starts disagreeing with the shipped one about what today looks like.
146 + ///
147 + /// The command keeps the string parsing, because a `YYYY-MM-DD` from JS and a
148 + /// `NaiveDate` from a route capture are different problems and only one of them
149 + /// is this function's.
150 + pub(crate) fn day_plan(
151 + state: &AppState,
152 + parsed_date: chrono::NaiveDate,
153 + ) -> Result<DayPlanningResponse, ApiError> {
154 + let date = parsed_date.format("%Y-%m-%d").to_string();
136 155
137 156 // Look up vacation status from the weekly review for the date's week
138 157 let days_from_monday = parsed_date.weekday().num_days_from_monday();
@@ -35,6 +35,7 @@
35 35 use crate::state::AppState;
36 36
37 37 pub mod contacts;
38 + pub mod day_planning;
38 39 pub mod emails;
39 40 pub mod monthly_review;
40 41 pub mod problems;
@@ -54,6 +55,7 @@
54 55 let router = weekly_review::routes(router);
55 56 let router = monthly_review::routes(router);
56 57 let router = problems::routes(router);
58 + let router = day_planning::routes(router);
57 59 emails::routes(router)
58 60 }
59 61
@@ -1,0 +1,294 @@
1 + //! The day view, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Tenth screen ported, and the one the vocabulary had to grow for. Every port
6 + //! before this took a screen the description could already say; this one took
7 + //! `makeover-layout` 0.24.0's [`Track`](makeover_layout::Track), added because
8 + //! this screen needed it.
9 + //!
10 + //! `frontend/js/day-planning*.js` is 1,585 lines across four files and was on
11 + //! every "cannot be described" list in the tree. The claim was that a
12 + //! description expressive enough to draw a timeline is a component library
13 + //! wearing a description's name. Held against the actual file, what it needed
14 + //! and could not get was where a thing starts and how long it lasts. Two
15 + //! integers.
16 + //!
17 + //! # The shape
18 + //!
19 + //! - `GET /day` — today.
20 + //! - `GET /day/{date}` — one day, `YYYY-MM-DD`.
21 + //! - `GET /day/{date}/timeline` — the axis alone, which is what stepping
22 + //! between days replaces.
23 + //!
24 + //! # What is here and what is still `day-planning.js`'s
25 + //!
26 + //! Drawn here: the axis, everything on it, the conflict tone, the all-day
27 + //! strip, the unscheduled pool and the vacation banner.
28 + //!
29 + //! Not here, and each one a decision rather than an omission:
30 + //!
31 + //! - **Drag to reschedule.** Moving an existing item is a continuous gesture
32 + //! and the vocabulary names no such thing. quasicoherent `e41079b2` is the
33 + //! live question, and Max's direction there is that placing pre-portioned
34 + //! blocks replaces the paint interaction entirely. Either way it is an
35 + //! interaction and not a drawing, so it does not block this screen.
36 + //! - **Drag to paint a new block.** Same question, same task.
37 + //!
38 + //! Both are what the host keeps. A described screen that draws the day and
39 + //! hands the two gestures to JS is the arrangement `RegionKind::Bespoke` used
40 + //! to be needed for, minus the bespoke region.
41 +
42 + // Handlers take their request by value because `quasi_router::Handler` is a
43 + // plain `fn(&S, Request)` pointer, so the signature is the router's.
44 + #![allow(clippy::needless_pass_by_value)]
45 +
46 + use chrono::{Local, NaiveDate};
47 + use goingson_core::TimelineItem;
48 + use makeover_layout::{Placement, Tone, Track};
49 + use quasi_router::screen::{Placed, Row, Tag};
50 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
51 +
52 + use crate::commands::{DayPlanningResponse, day_plan};
53 + use crate::state::AppState;
54 +
55 + #[cfg(test)]
56 + mod tests;
57 +
58 + /// The day a route was addressed at, or today.
59 + ///
60 + /// A missing capture is `/day`, which is today. An unparseable one is a 404
61 + /// rather than a silent fallback to today: `/day/yesterday` is an address that
62 + /// names nothing, and answering it with today's plan would be this screen
63 + /// deciding it knows better than the URL.
64 + fn date_of(request: &quasi_router::Request) -> Result<NaiveDate, RouteError> {
65 + let Some(raw) = request.captures.get("date") else {
66 + return Ok(Local::now().date_naive());
67 + };
68 + NaiveDate::parse_from_str(raw, "%Y-%m-%d").map_err(|_| RouteError::not_found("not a date"))
69 + }
70 +
71 + /// The plan, or an internal error.
72 + fn plan(state: &AppState, date: NaiveDate) -> Result<DayPlanningResponse, RouteError> {
73 + day_plan(state, date).map_err(|error| RouteError::internal(error.to_string()))
74 + }
75 +
76 + /// One item as a row, without its placement.
77 + ///
78 + /// The whole body is vocabulary that existed before this screen: a title, the
79 + /// project it belongs to, a tone, and a chip when the item continues past the
80 + /// edge of the day. That is the measurement the timeline refusal never took.
81 + fn row_for(item: &TimelineItem, conflicted: bool) -> Row {
82 + let mut row = Row::new(&item.title);
83 +
84 + if let Some(project) = &item.project_name {
85 + row = row.meta(project);
86 + }
87 +
88 + // An event that runs over midnight is one row and two bars, and the bar
89 + // this day draws is clipped to this day. The chip says the clipping
90 + // happened, which the JS says with a CSS pseudo-element and no text at all.
91 + if item.continues_before {
92 + row = row.token(Tag::badge("from earlier").tone(Tone::Neutral));
93 + }
94 + if item.continues_after {
95 + row = row.token(Tag::badge("continues").tone(Tone::Neutral));
96 + }
97 + if conflicted {
98 + row = row.token(Tag::badge("clashes").tone(Tone::Danger));
99 + }
100 +
101 + // A focus block is a different kind of thing from an appointment and the
102 + // shipped screen says so with a colour. A row carries no tone of its own --
103 + // deliberately, since a whole line in a colour is a slab -- so the fact
104 + // travels on a badge, which is where every other per-row judgment on this
105 + // screen already is.
106 + if item.item_type == "block" {
107 + row = row.token(Tag::badge("block").tone(Tone::Info));
108 + }
109 +
110 + // A scheduled task opens the task it stands for, not the event row that
111 + // holds it. `linked_task_id` exists for exactly this: the row's own id is
112 + // the event's, and opening that would address the wrong thing.
113 + if let Some(task) = item.linked_task_id {
114 + row = row.activate(Action::get(format!("/tasks/{task}")));
115 + }
116 +
117 + row
118 + }
119 +
120 + /// The axis, and everything placed on it.
121 + fn timeline(response: &DayPlanningResponse) -> Node {
122 + // Which items clash, from the pairs the backend already computes. Collected
123 + // into a set because a row needs to know only whether it is in one, and
124 + // `detect_conflicts` answers in pairs.
125 + let clashing: std::collections::HashSet<_> = response
126 + .conflicts
127 + .iter()
128 + .flat_map(|c| [c.item1_id, c.item2_id])
129 + .collect();
130 +
131 + let entries: Vec<Placed> = response
132 + .timeline_items
133 + .iter()
134 + // All-day items are not on the axis. A bar covering all 24 hours papers
135 + // over every real appointment, which is the reason `is_all_day` exists
136 + // on the item at all; they go in the strip above, drawn by `all_day`.
137 + .filter(|item| !item.is_all_day)
138 + .map(|item| {
139 + let conflicted = clashing.contains(&item.id);
140 + Placed {
141 + // Both already computed for the shipped screen, in the units
142 + // `Placement` wants. Nothing was added to the backend for this
143 + // port, which is the clearest evidence that what the vocabulary
144 + // was missing was the ability to *say* this rather than anything
145 + // anyone had to work out.
146 + placement: Placement::new(
147 + u16::try_from(item.day_offset_minutes.max(0)).unwrap_or(0),
148 + u16::try_from(item.visible_duration_minutes.max(1)).unwrap_or(1),
149 + ),
150 + row: row_for(item, conflicted),
151 + }
152 + })
153 + .collect();
154 +
155 + Node::Timeline {
156 + track: Track::DAY,
157 + entries,
158 + // The interesting hour, said as a moment. `day-planning-render.js:321`
159 + // is `const targetHour = 9`, a literal inside the renderer; the app is
160 + // what knows which hour matters and this is where it says so.
161 + focus: Some(9 * 60),
162 + }
163 + }
164 +
165 + /// The all-day strip above the axis.
166 + ///
167 + /// A separate list rather than entries with a full-day placement, for the
168 + /// reason `is_all_day` is a field: a bar covering the whole span hides
169 + /// everything under it. Absent, not empty, when nothing is all-day.
170 + fn all_day(response: &DayPlanningResponse) -> Option<Node> {
171 + let rows: Vec<Row> = response
172 + .timeline_items
173 + .iter()
174 + .filter(|item| item.is_all_day)
175 + .map(|item| row_for(item, false))
176 + .collect();
177 +
178 + (!rows.is_empty()).then(|| Node::List { rows, more: None })
179 + }
180 +
181 + /// The unscheduled pool.
182 + ///
183 + /// Everything due today that is not on the axis yet. Each row opens its task;
184 + /// putting one *onto* the day is the interaction `e41079b2` decides, so the
185 + /// pool draws and does not place.
186 + fn pool(response: &DayPlanningResponse) -> Node {
187 + let rows: Vec<Row> = response
188 + .unscheduled_tasks
189 + .iter()
190 + .map(|task| {
191 + let mut row = Row::new(&task.title);
192 + if let Some(project) = &task.project_name {
193 + row = row.meta(project);
194 + }
195 + // What this task still waits on. The shipped screen draws the same
196 + // marker; it is a fact about the task and the description says it
197 + // with a badge rather than with a class.
198 + if let Some(gate) = response.gates.get(&task.id) {
199 + if let Some(first) = gate.after.first() {
200 + let label = if gate.after.len() > 1 {
201 + format!("after {} +{}", first.title, gate.after.len() - 1)
202 + } else {
203 + format!("after {}", first.title)
204 + };
205 + row = row.token(Tag::badge(&label).tone(Tone::Warning));
206 + }
207 + if gate.out_of_order {
208 + row = row.token(Tag::badge("out of order").tone(Tone::Warning));
209 + }
210 + }
211 + row.activate(Action::get(format!("/tasks/{}", task.id)))
212 + })
213 + .collect();
214 +
215 + if rows.is_empty() {
216 + Node::Text {
217 + text: "Nothing else due today.".into(),
218 + tone: Tone::Neutral,
219 + }
220 + } else {
221 + Node::List { rows, more: None }
222 + }
223 + }
224 +
225 + /// The step-a-day controls and the date, as a band.
226 + fn band(date: NaiveDate, response: &DayPlanningResponse) -> Slot {
227 + let mut band = Slot::new("day-band", RegionKind::Band)
228 + .with(Node::Heading {
229 + level: makeover_layout::Heading::Page,
230 + text: date.format("%A, %-d %B").to_string(),
231 + })
232 + .with(Node::Token(Tag::chip(
233 + "Previous",
234 + Action::get(format!("/day/{}", date.pred_opt().unwrap_or(date))),
235 + )))
236 + .with(Node::Token(Tag::chip(
237 + "Next",
238 + Action::get(format!("/day/{}", date.succ_opt().unwrap_or(date))),
239 + )));
240 +
241 + // A vacation day says so once, at the top, rather than by greying the axis.
242 + // The JS draws a banner here too and this is the same claim.
243 + if response.is_vacation_day {
244 + band = band.with(Node::Notice {
245 + kind: makeover_layout::Notice::Banner,
246 + tone: Tone::Info,
247 + text: "Vacation day.".into(),
248 + });
249 + }
250 +
251 + if response.time_tracked_today > 0 {
252 + let hours = response.time_tracked_today / 60;
253 + let minutes = response.time_tracked_today % 60;
254 + band = band.with(Node::Text {
255 + text: format!("Tracked today: {hours}h {minutes:02}m"),
256 + tone: Tone::Neutral,
257 + });
258 + }
259 +
260 + band
261 + }
262 +
263 + /// The whole day.
264 + fn day(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
265 + let date = date_of(&request)?;
266 + let response = plan(state, date)?;
267 +
268 + let mut axis = Slot::new("day-timeline", RegionKind::Pane);
269 + if let Some(strip) = all_day(&response) {
270 + axis = axis.with(strip);
271 + }
272 + axis = axis.with(timeline(&response));
273 +
274 + Ok(Screen::list_detail("Day", false)
275 + .with(band(date, &response))
276 + .with(axis)
277 + .with(Slot::new("day-pool", RegionKind::Pane).with(pool(&response)))
278 + .into())
279 + }
280 +
281 + /// The axis alone, which is what stepping a day replaces.
282 + fn timeline_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
283 + let date = date_of(&request)?;
284 + let response = plan(state, date)?;
285 + Ok(Response::fragment("day-timeline", timeline(&response)))
286 + }
287 +
288 + /// This screen's routes.
289 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
290 + router
291 + .get("/day", day)
292 + .get("/day/{date}", day)
293 + .get("/day/{date}/timeline", timeline_only)
294 + }
@@ -1,0 +1,212 @@
1 + //! The day view, driven through the router against a real database.
2 + //!
3 + //! Same property as its siblings: no Tauri runtime and no window. What is new
4 + //! here is that the assertions are about *placement*, which is the thing the
5 + //! vocabulary could not carry until `makeover-layout` 0.24.0.
6 +
7 + use std::sync::Arc;
8 +
9 + use chrono::{Duration, Local, NaiveDate, TimeZone, Utc};
10 + use goingson_core::NewEvent;
11 + use quasi_http::Serves as _;
12 + use quasi_router::{Outcome, Params, Request, Response};
13 +
14 + use super::super::router;
15 + use crate::state::{AppState, DESKTOP_USER_ID};
16 +
17 + /// State with the desktop user in place, which is who the handlers read as.
18 + async fn state() -> Arc<AppState> {
19 + let (state, _) = crate::test_utils::setup_test_state().await;
20 + let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 + state
22 + .db
23 + .conn()
24 + .unwrap()
25 + .execute(
26 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 + VALUES (?, ?, ?, ?, ?)",
28 + rusqlite::params![
29 + DESKTOP_USER_ID.to_string(),
30 + "desktop@localhost",
31 + "x",
32 + "Desktop User",
33 + &now,
34 + ],
35 + )
36 + .unwrap();
37 + state
38 + }
39 +
40 + /// The day every test in this file draws.
41 + ///
42 + /// Fixed rather than "today", because a test whose fixtures are relative to the
43 + /// clock passes at 09:00 and fails at 23:50 when the local day rolls over
44 + /// between building the event and reading the screen.
45 + const DAY: &str = "2026-03-04";
46 +
47 + fn day() -> NaiveDate {
48 + NaiveDate::parse_from_str(DAY, "%Y-%m-%d").unwrap()
49 + }
50 +
51 + /// An event on the test day, at a local wall-clock time.
52 + ///
53 + /// Local rather than UTC because the screen's whole job is placing things on a
54 + /// *local* day, and a fixture written in UTC would silently test a different
55 + /// hour on any machine not on UTC.
56 + fn event(state: &AppState, title: &str, from: (u32, u32), minutes: i64) {
57 + let start = Local
58 + .from_local_datetime(&day().and_hms_opt(from.0, from.1, 0).unwrap())
59 + .unwrap()
60 + .with_timezone(&Utc);
61 + state
62 + .events
63 + .create(
64 + DESKTOP_USER_ID,
65 + NewEvent::builder(title, start)
66 + .end_time(start + Duration::minutes(minutes))
67 + .build(),
68 + )
69 + .unwrap();
70 + }
71 +
72 + fn get(state: &AppState, path: &str) -> Response {
73 + router()
74 + .handle(state, Request::get(path).carrying(Params::new()))
75 + .expect("the route answers")
76 + }
77 +
78 + fn html(response: Response) -> String {
79 + match response.outcome {
80 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
81 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
82 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
83 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
84 + }
85 + }
86 +
87 + fn screen(state: &AppState) -> String {
88 + html(get(state, &format!("/day/{DAY}")))
89 + }
90 +
91 + #[tokio::test]
92 + async fn an_item_is_placed_at_the_hour_it_starts() {
93 + let state = state().await;
94 + event(&state, "Standup", (9, 0), 60);
95 +
96 + let markup = screen(&state);
97 +
98 + // 09:00 on a midnight-to-midnight axis is 540/1440 = 37.5%, and an hour is
99 + // 60/1440 = 4.1667%. This is the assertion the whole vocabulary change
100 + // exists for: before Track there was no way to say either number.
101 + assert!(markup.contains("--track-at:37.5000%"), "{markup}");
102 + assert!(markup.contains("--track-for:4.1667%"), "{markup}");
103 + assert!(markup.contains("Standup"), "{markup}");
104 + }
105 +
106 + #[tokio::test]
107 + async fn two_things_at_once_take_lanes_and_say_they_clash() {
108 + let state = state().await;
109 + event(&state, "Standup", (9, 0), 60);
110 + event(&state, "Interview", (9, 30), 60);
111 +
112 + let markup = screen(&state);
113 +
114 + // The geometry: two lanes, so neither is drawn on top of the other.
115 + assert!(markup.contains("--track-lane:0"), "{markup}");
116 + assert!(markup.contains("--track-lane:1"), "{markup}");
117 + assert!(markup.contains("--track-lanes:2"), "{markup}");
118 +
119 + // And the judgment, which is a different thing from the geometry and comes
120 + // from the backend's own `detect_conflicts` rather than from the overlap.
121 + assert!(markup.contains("clashes"), "{markup}");
122 + }
123 +
124 + #[tokio::test]
125 + async fn back_to_back_items_are_not_a_clash() {
126 + let state = state().await;
127 + event(&state, "First", (9, 0), 60);
128 + event(&state, "Second", (10, 0), 60);
129 +
130 + let markup = screen(&state);
131 +
132 + // The off-by-one this guards is the classic day-view bug: every appointment
133 + // on the hour reading as a conflict with the one before it.
134 + assert!(markup.contains("--track-lanes:1"), "{markup}");
135 + assert!(!markup.contains("clashes"), "{markup}");
136 + }
137 +
138 + #[tokio::test]
139 + async fn the_axis_carries_an_hourly_ruler() {
140 + let state = state().await;
141 + let markup = screen(&state);
142 +
143 + // 96 quarter-hour slots, labelled every fourth. Matches
144 + // `day-planning-render.js:56`'s `for (let hour = 0; hour < 24; hour++)`.
145 + assert!(markup.contains(">00:00<"), "{markup}");
146 + assert!(markup.contains(">09:00<"), "{markup}");
147 + assert!(markup.contains(">23:00<"), "{markup}");
148 + assert!(!markup.contains(">09:15<"), "labelled hourly, not per slot");
149 + }
150 +
151 + #[tokio::test]
152 + async fn the_interesting_hour_travels_as_a_moment() {
153 + let state = state().await;
154 + let markup = screen(&state);
155 +
156 + // What replaces `day-planning-render.js:321`'s `const targetHour = 9`. The
157 + // app says which hour matters; the host decides how to get there.
158 + assert!(markup.contains("data-focus=\"540\""), "{markup}");
159 + }
160 +
161 + #[tokio::test]
162 + async fn an_events_title_cannot_become_markup() {
163 + let state = state().await;
164 + event(&state, "<script>alert('x')</script>", (9, 0), 60);
165 +
166 + let markup = screen(&state);
167 +
168 + // The property this whole port exists for. Being placed on an axis is not a
169 + // way around the renderer's escaping.
170 + assert!(!markup.contains("<script>"), "{markup}");
171 + assert!(markup.contains("&lt;script&gt;"), "{markup}");
172 + }
173 +
174 + #[tokio::test]
175 + async fn a_day_with_nothing_on_it_still_draws_its_axis() {
176 + let state = state().await;
177 + let markup = screen(&state);
178 +
179 + // An empty day is a day, not an empty state. The ruler is what makes it
180 + // legible as one, and the JS draws it the same way.
181 + assert!(markup.contains(">12:00<"), "{markup}");
182 + assert!(markup.contains("Nothing else due today."), "{markup}");
183 + }
184 +
185 + #[tokio::test]
186 + async fn a_date_that_names_nothing_is_a_404_rather_than_today() {
187 + let state = state().await;
188 +
189 + // `/day/yesterday` addresses nothing. Answering it with today's plan would
190 + // be the screen deciding it knows better than the URL, which is the failure
191 + // an address exists to prevent.
192 + let answered = router().handle(
193 + &state,
194 + Request::get("/day/yesterday").carrying(Params::new()),
195 + );
196 + assert!(answered.is_err(), "an unparseable date should not resolve");
197 + }
198 +
199 + #[tokio::test]
200 + async fn stepping_a_day_answers_the_axis_alone() {
201 + let state = state().await;
202 + event(&state, "Standup", (9, 0), 60);
203 +
204 + let response = get(&state, &format!("/day/{DAY}/timeline"));
205 + let Outcome::Fragment { region, .. } = &response.outcome else {
206 + panic!("stepping a day replaces one region, not the screen");
207 + };
208 + assert_eq!(region, "day-timeline");
209 +
210 + let markup = html(response);
211 + assert!(markup.contains("Standup"), "{markup}");
212 + }