max / goingson
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+573 insertions,
-32 deletions
| @@ -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" |
| @@ -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("<script>"), "{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 | + | } |