max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
16 files changed,
+541 insertions,
-354 deletions
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | # through quasi beside its Askama equivalent, so the cost of describing a screen | |
| 164 | 164 | # here is measured rather than argued. Not load-bearing for any shipped route. | |
| 165 | 165 | # See wiki look-wave-2, tier G. | |
| 166 | - | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" } | |
| 166 | + | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" } | |
| 167 | 167 | # The declared form. A screen written as `declare!` is compiled to the same | |
| 168 | 168 | # `quasi_router` constructors the hand-written shape called, at build time, so | |
| 169 | 169 | # nothing new reaches the binary and the macro is not in the request path. | |
| @@ -183,13 +183,13 @@ | |||
| 183 | 183 | # For the request head the per-viewer state factory reads. axum re-exports it, | |
| 184 | 184 | # but the factory's signature is quasi-axum's and names `http::request::Parts`. | |
| 185 | 185 | http = "1.3.1" | |
| 186 | - | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" } | |
| 187 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" } | |
| 186 | + | quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" } | |
| 187 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" } | |
| 188 | 188 | # The first-party widget set. The carousel was proved here against three pages | |
| 189 | 189 | # and then moved there; what is left in `quasi/widgets/carousel.rs` is the | |
| 190 | 190 | # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it | |
| 191 | 191 | # sits above quasi-router rather than inside the description suite. | |
| 192 | - | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" } | |
| 192 | + | quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" } | |
| 193 | 193 | ||
| 194 | 194 | # Git source browser | |
| 195 | 195 | gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] } | |
| @@ -288,7 +288,7 @@ | |||
| 288 | 288 | # Written from the crate rather than copied into the repo for the reason the | |
| 289 | 289 | # constants exist: a stale copy in a static directory is a silently dead bulk | |
| 290 | 290 | # bar, and the version has to be the one the emitter agrees with. | |
| 291 | - | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" } | |
| 291 | + | quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" } | |
| 292 | 292 | ||
| 293 | 293 | [dev-dependencies] | |
| 294 | 294 | tower = { version = "0.5.3", features = ["util"] } |
| @@ -396,6 +396,15 @@ | |||
| 396 | 396 | /// emitted by `quasi-webview` at request time, which no scan of this repo can | |
| 397 | 397 | /// see. A class the description writes is dead here by construction. | |
| 398 | 398 | /// | |
| 399 | + | /// # 34 since 2026-09-08, lowered by the last conversions | |
| 400 | + | /// | |
| 401 | + | /// `/dashboard/tabs/ssh-keys`, `/git`, `/feed` and `/dashboard/tabs/analytics` | |
| 402 | + | /// joined the seam, which is every described screen -- `OFF_THE_SEAM` is empty | |
| 403 | + | /// (mnw-server `14d42beb`). The chart is the interesting part of the fall: it | |
| 404 | + | /// was the app's own markup behind a ceded region, so its classes counted as | |
| 405 | + | /// dead vocabulary while the site drew them on every analytics tab. Described, | |
| 406 | + | /// they are the renderer's. | |
| 407 | + | /// | |
| 399 | 408 | /// # 49 since 2026-09-07, lowered by converting screens | |
| 400 | 409 | /// | |
| 401 | 410 | /// The ratchet doing what the paragraph below says it should. `/team`, | |
| @@ -422,7 +431,7 @@ | |||
| 422 | 431 | /// is mid-flight, and this number should FALL as phase 3 converts screens | |
| 423 | 432 | /// rather than being raised again. If it needs raising a second time, read the | |
| 424 | 433 | /// list before doing it. | |
| 425 | - | const DEAD_VOCABULARY_HIGH_WATER: usize = 49; | |
| 434 | + | const DEAD_VOCABULARY_HIGH_WATER: usize = 34; | |
| 426 | 435 | ||
| 427 | 436 | /// The scripts this build script generates into `static/`. | |
| 428 | 437 | /// |
| @@ -416,62 +416,14 @@ | |||
| 416 | 416 | font-weight: normal; | |
| 417 | 417 | } | |
| 418 | 418 | ||
| 419 | - | .chart-bars { | |
| 420 | - | display: flex; | |
| 421 | - | align-items: flex-end; | |
| 422 | - | gap: 2px; | |
| 423 | - | height: 200px; | |
| 424 | - | padding: 0 var(--gap-peer); | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | .chart-bar-col { | |
| 428 | - | flex: 1; | |
| 429 | - | display: flex; | |
| 430 | - | flex-direction: column; | |
| 431 | - | align-items: center; | |
| 432 | - | min-width: 0; | |
| 433 | - | cursor: default; | |
| 434 | - | position: relative; | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | .chart-bar-col[data-tooltip]:hover::before { | |
| 438 | - | content: attr(data-tooltip); | |
| 439 | - | position: absolute; | |
| 440 | - | bottom: 100%; | |
| 441 | - | left: 50%; | |
| 442 | - | transform: translateX(-50%); | |
| 443 | - | background: var(--primary-dark); | |
| 444 | - | color: var(--primary-light); | |
| 445 | - | font-size: var(--text-fine); | |
| 446 | - | font-family: var(--font-mono); | |
| 447 | - | padding: var(--gap-bound) var(--gap-peer); | |
| 448 | - | white-space: nowrap; | |
| 449 | - | z-index: var(--z-dropdown); | |
| 450 | - | pointer-events: none; | |
| 451 | - | margin-bottom: var(--gap-bound); | |
| 452 | - | } | |
| 453 | - | ||
| 454 | - | .chart-bar { | |
| 455 | - | width: 100%; | |
| 456 | - | height: var(--fill, 0%); | |
| 457 | - | background: var(--primary-dark); | |
| 458 | - | min-height: 2px; | |
| 459 | - | transition: opacity 0.15s; | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | .chart-bar-col:hover .chart-bar { | |
| 463 | - | opacity: 0.8; | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | .chart-bar-label { | |
| 467 | - | font-size: var(--text-fine); | |
| 468 | - | opacity: 0.5; | |
| 469 | - | margin-top: var(--gap-bound); | |
| 470 | - | white-space: nowrap; | |
| 471 | - | overflow: hidden; | |
| 472 | - | text-overflow: ellipsis; | |
| 473 | - | max-width: 100%; | |
| 474 | - | } | |
| 419 | + | /* The revenue chart's rules live in makeover-webview (`chart::rules`) and | |
| 420 | + | arrive through layout.css, so both this site's charts -- the described one on | |
| 421 | + | /dashboard/tabs/analytics and the Askama copy in chart_bars.html -- are drawn | |
| 422 | + | by one set of rules rather than by two that agreed when they were written. | |
| 423 | + | What the app still says about a chart is said as the custom properties that | |
| 424 | + | module defers: --chart-height, --chart-gap, --chart-bar-least, | |
| 425 | + | --chart-readout-padding. Nothing here overrides them today, which means the | |
| 426 | + | defaults are what the design wanted. */ | |
| 475 | 427 | ||
| 476 | 428 | /* Time selector */ | |
| 477 | 429 | .time-selector { |
| @@ -4581,62 +4581,14 @@ | |||
| 4581 | 4581 | font-weight: normal; | |
| 4582 | 4582 | } | |
| 4583 | 4583 | ||
| 4584 | - | .chart-bars { | |
| 4585 | - | display: flex; | |
| 4586 | - | align-items: flex-end; | |
| 4587 | - | gap: 2px; | |
| 4588 | - | height: 200px; | |
| 4589 | - | padding: 0 var(--gap-peer); | |
| 4590 | - | } | |
| 4591 | - | ||
| 4592 | - | .chart-bar-col { | |
| 4593 | - | flex: 1; | |
| 4594 | - | display: flex; | |
| 4595 | - | flex-direction: column; | |
| 4596 | - | align-items: center; | |
| 4597 | - | min-width: 0; | |
| 4598 | - | cursor: default; | |
| 4599 | - | position: relative; | |
| 4600 | - | } | |
| 4601 | - | ||
| 4602 | - | .chart-bar-col[data-tooltip]:hover::before { | |
| 4603 | - | content: attr(data-tooltip); | |
| 4604 | - | position: absolute; | |
| 4605 | - | bottom: 100%; | |
| 4606 | - | left: 50%; | |
| 4607 | - | transform: translateX(-50%); | |
| 4608 | - | background: var(--primary-dark); | |
| 4609 | - | color: var(--primary-light); | |
| 4610 | - | font-size: var(--text-fine); | |
| 4611 | - | font-family: var(--font-mono); | |
| 4612 | - | padding: var(--gap-bound) var(--gap-peer); | |
| 4613 | - | white-space: nowrap; | |
| 4614 | - | z-index: var(--z-dropdown); | |
| 4615 | - | pointer-events: none; | |
| 4616 | - | margin-bottom: var(--gap-bound); | |
| 4617 | - | } | |
| 4618 | - | ||
| 4619 | - | .chart-bar { | |
| 4620 | - | width: 100%; | |
| 4621 | - | height: var(--fill, 0%); | |
| 4622 | - | background: var(--primary-dark); | |
| 4623 | - | min-height: 2px; | |
| 4624 | - | transition: opacity 0.15s; | |
| 4625 | - | } | |
| 4626 | - | ||
| 4627 | - | .chart-bar-col:hover .chart-bar { | |
| 4628 | - | opacity: 0.8; | |
| 4629 | - | } | |
| 4630 | - | ||
| 4631 | - | .chart-bar-label { | |
| 4632 | - | font-size: var(--text-fine); | |
| 4633 | - | opacity: 0.5; | |
| 4634 | - | margin-top: var(--gap-bound); | |
| 4635 | - | white-space: nowrap; | |
| 4636 | - | overflow: hidden; | |
| 4637 | - | text-overflow: ellipsis; | |
| 4638 | - | max-width: 100%; | |
| 4639 | - | } | |
| 4584 | + | /* The revenue chart's rules live in makeover-webview (`chart::rules`) and | |
| 4585 | + | arrive through layout.css, so both this site's charts -- the described one on | |
| 4586 | + | /dashboard/tabs/analytics and the Askama copy in chart_bars.html -- are drawn | |
| 4587 | + | by one set of rules rather than by two that agreed when they were written. | |
| 4588 | + | What the app still says about a chart is said as the custom properties that | |
| 4589 | + | module defers: --chart-height, --chart-gap, --chart-bar-least, | |
| 4590 | + | --chart-readout-padding. Nothing here overrides them today, which means the | |
| 4591 | + | defaults are what the design wanted. */ | |
| 4640 | 4592 | ||
| 4641 | 4593 | /* Time selector */ | |
| 4642 | 4594 | .time-selector { |
| @@ -154,21 +154,6 @@ | |||
| 154 | 154 | /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a | |
| 155 | 155 | /// screen answering for one marks no row as current. | |
| 156 | 156 | pub session_id: Option<crate::db::UserSessionId>, | |
| 157 | - | /// Markup for the bespoke regions this request's screen describes. | |
| 158 | - | /// | |
| 159 | - | /// The seam between a handler and its renderer, and the reason it has to be | |
| 160 | - | /// here rather than in either of them: a `Region::Handover` is filled on the | |
| 161 | - | /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the | |
| 162 | - | /// handler has answered, and the renderer factory is handed `&S` and the | |
| 163 | - | /// answer. So the handler writes what it drew here and the renderer reads | |
| 164 | - | /// it back off the same state. Both see one instance: the adapter builds a | |
| 165 | - | /// single `Arc<Viewer>` per request and passes it to the router and then to | |
| 166 | - | /// the factory. | |
| 167 | - | /// | |
| 168 | - | /// Behind a lock because the handler holds `&Viewer` and runs on a blocking | |
| 169 | - | /// thread. Uncontended in practice: one request writes it, then one | |
| 170 | - | /// renderer reads it, never at once. | |
| 171 | - | fills: std::sync::Mutex<std::collections::HashMap<String, String>>, | |
| 172 | 157 | } | |
| 173 | 158 | ||
| 174 | 159 | impl Viewer { | |
| @@ -197,19 +182,6 @@ | |||
| 197 | 182 | .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue")) | |
| 198 | 183 | } | |
| 199 | 184 | ||
| 200 | - | /// Hand the renderer the markup for one bespoke region. | |
| 201 | - | /// | |
| 202 | - | /// Called by a handler while it builds its description, keyed by the slot | |
| 203 | - | /// id the description gives that region. Markup, not text: a bespoke region | |
| 204 | - | /// is the app's own and is not escaped, which is the whole of what makes it | |
| 205 | - | /// bespoke and the whole of why a handler must not put a reader's string in | |
| 206 | - | /// one without escaping it first. | |
| 207 | - | pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) { | |
| 208 | - | if let Ok(mut fills) = self.fills.lock() { | |
| 209 | - | fills.insert(slot_id.into(), markup.into()); | |
| 210 | - | } | |
| 211 | - | } | |
| 212 | - | ||
| 213 | 185 | /// The shell every described screen is drawn in. | |
| 214 | 186 | /// | |
| 215 | 187 | /// Here rather than in each screen's `renderer` because of what it carries: | |
| @@ -249,11 +221,6 @@ | |||
| 249 | 221 | pub fn document_shell(&self) -> quasi_webview::Shell { | |
| 250 | 222 | document_shell(&self.csrf) | |
| 251 | 223 | } | |
| 252 | - | ||
| 253 | - | /// Everything the handler drew, for the renderer to mount. | |
| 254 | - | pub fn drawn(&self) -> std::collections::HashMap<String, String> { | |
| 255 | - | self.fills.lock().map(|f| f.clone()).unwrap_or_default() | |
| 256 | - | } | |
| 257 | 224 | } | |
| 258 | 225 | ||
| 259 | 226 | /// The shell a described document is drawn in, by the token it carries. | |
| @@ -357,7 +324,6 @@ | |||
| 357 | 324 | runtime, | |
| 358 | 325 | csrf, | |
| 359 | 326 | session_id, | |
| 360 | - | fills: std::sync::Mutex::default(), | |
| 361 | 327 | }) | |
| 362 | 328 | }) | |
| 363 | 329 | } |
| @@ -14,10 +14,14 @@ | |||
| 14 | 14 | //! conflict instead of an edit. That is the ruling `buyer_contacts` made about | |
| 15 | 15 | //! its table against `library_contacts` and it applies unchanged here. | |
| 16 | 16 | //! | |
| 17 | - | //! The one thing that IS shared is [`super::user_analytics::chart_markup`], | |
| 18 | - | //! because a chart drawn two ways is two charts that drift, and the whole | |
| 19 | - | //! reason `templates/partials/chart_bars.html` existed was that three tabs had | |
| 20 | - | //! byte-identical copies of it. | |
| 17 | + | //! The chart is the exception, and it is shared by the vocabulary rather than | |
| 18 | + | //! by code: both tabs say `chart`, one renderer draws it, and | |
| 19 | + | //! `makeover-webview` emits the rules. A chart drawn two ways is two charts | |
| 20 | + | //! that drift, which is the whole reason | |
| 21 | + | //! `templates/partials/chart_bars.html` existed when three tabs had | |
| 22 | + | //! byte-identical copies of it. This used to share a markup-building function | |
| 23 | + | //! with `user_analytics` for the same reason; the member replaced it on | |
| 24 | + | //! 2026-09-08 (quasicoherent `7d6ad166`). | |
| 21 | 25 | //! | |
| 22 | 26 | //! # A fill, and for neither of the batch's two usual reasons | |
| 23 | 27 | //! | |
| @@ -43,45 +47,32 @@ | |||
| 43 | 47 | ||
| 44 | 48 | use makeover_layout as layout; | |
| 45 | 49 | use quasi_declare::declare; | |
| 46 | - | use quasi_router::screen::Figure; | |
| 50 | + | use quasi_router::screen::{Bar, Chart, Figure}; | |
| 47 | 51 | use quasi_router::{Node, RegionKind, Slot}; | |
| 48 | 52 | use quasi_webview::Webview; | |
| 49 | 53 | ||
| 50 | - | use crate::types::{ChartBar, ContentItem, StatCard}; | |
| 54 | + | use crate::types::{ContentItem, RevenueChart, StatCard}; | |
| 51 | 55 | ||
| 52 | 56 | /// The region the answer replaces, keeping the id the page already used. | |
| 53 | 57 | pub const REGION: &str = "project-analytics"; | |
| 54 | 58 | ||
| 55 | 59 | /// The bespoke region the chart is drawn into. | |
| 56 | - | const CHART_SLOT: &str = "project-revenue-chart"; | |
| 57 | 60 | /// The panel as the route answers it: the region, carrying its own id. | |
| 58 | 61 | #[must_use] | |
| 59 | 62 | pub fn fragment( | |
| 60 | 63 | slug: &str, | |
| 61 | 64 | range: &str, | |
| 62 | 65 | stats: &[StatCard], | |
| 63 | - | bars: &[ChartBar], | |
| 66 | + | chart: &RevenueChart, | |
| 64 | 67 | items: &[ContentItem], | |
| 65 | 68 | ) -> String { | |
| 66 | 69 | use quasi_axum::Serves as _; | |
| 67 | 70 | ||
| 68 | 71 | let mut slot = Slot::new(REGION, RegionKind::Pane); | |
| 69 | - | for node in body(slug, range, stats, bars, items) { | |
| 72 | + | for node in body(slug, range, stats, chart, items) { | |
| 70 | 73 | slot = slot.with(node); | |
| 71 | 74 | } | |
| 72 | - | drawn(bars).fragment(&Node::Region(slot)) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | /// The renderer, carrying whatever the chart needs. | |
| 76 | - | /// | |
| 77 | - | /// A local `Webview` rather than the `Viewer` seam a mounted screen uses. See | |
| 78 | - | /// the module header. | |
| 79 | - | fn drawn(bars: &[ChartBar]) -> Webview { | |
| 80 | - | let mut webview = Webview::new(); | |
| 81 | - | if !bars.is_empty() { | |
| 82 | - | webview = webview.with_fill(CHART_SLOT, super::user_analytics::chart_markup(bars)); | |
| 83 | - | } | |
| 84 | - | webview | |
| 75 | + | Webview::new().fragment(&Node::Region(slot)) | |
| 85 | 76 | } | |
| 86 | 77 | ||
| 87 | 78 | declare! { | |
| @@ -93,7 +84,7 @@ | |||
| 93 | 84 | slug: &str, | |
| 94 | 85 | range: &str, | |
| 95 | 86 | stats: &[StatCard], | |
| 96 | - | bars: &[ChartBar], | |
| 87 | + | chart: &RevenueChart, | |
| 97 | 88 | items: &[ContentItem], | |
| 98 | 89 | ) -> Vec<Node>; | |
| 99 | 90 | ||
| @@ -110,8 +101,15 @@ | |||
| 110 | 101 | section "Revenue Over Time"; | |
| 111 | 102 | empty "No revenue data yet. Revenue will appear here after your first sale. \ | |
| 112 | 103 | Publish an item and share it to get started." | |
| 113 | - | when bars.is_empty(); | |
| 114 | - | region CHART_SLOT as RegionKind::ceded("revenue-chart") unless bars.is_empty() {} | |
| 104 | + | when chart.bars.is_empty(); | |
| 105 | + | chart Chart::new(chart.most).label("revenue over time") unless chart.bars.is_empty() { | |
| 106 | + | for bar in chart.bars.iter() { | |
| 107 | + | bar Bar::at(bar.label.clone()) | |
| 108 | + | .of(bar.cents) | |
| 109 | + | .reading(bar.value.clone()) | |
| 110 | + | .note(super::user_analytics::sales(bar.count)); | |
| 111 | + | } | |
| 112 | + | } | |
| 115 | 113 | ||
| 116 | 114 | section "Top Performing Items"; | |
| 117 | 115 | empty "No sales data yet. Publish and promote your items to see analytics here." | |
| @@ -130,7 +128,7 @@ | |||
| 130 | 128 | for window in super::RANGES { | |
| 131 | 129 | chip window.value | |
| 132 | 130 | to get "/dashboard/project/{slug}/tabs/analytics" carrying "range" window.value { | |
| 133 | - | latched super::is_shown(window, range); | |
| 131 | + | latched when super::is_shown(window, range); | |
| 134 | 132 | } | |
| 135 | 133 | } | |
| 136 | 134 | } | |
| @@ -208,6 +206,7 @@ | |||
| 208 | 206 | #[cfg(test)] | |
| 209 | 207 | mod tests { | |
| 210 | 208 | use super::*; | |
| 209 | + | use crate::types::ChartBar; | |
| 211 | 210 | use quasi_axum::Serves; | |
| 212 | 211 | ||
| 213 | 212 | fn stat(label: &str, change: Option<&str>) -> StatCard { | |
| @@ -222,16 +221,26 @@ | |||
| 222 | 221 | fn bar() -> ChartBar { | |
| 223 | 222 | ChartBar { | |
| 224 | 223 | label: "Aug 1".into(), | |
| 225 | - | height_pct: 42.5, | |
| 224 | + | cents: 1200, | |
| 226 | 225 | value: "$12".into(), | |
| 227 | 226 | count: 3, | |
| 228 | 227 | } | |
| 229 | 228 | } | |
| 230 | 229 | ||
| 230 | + | /// The bars against an axis they fill less than half of, so a drawn width | |
| 231 | + | /// could not be mistaken for a full bar. | |
| 232 | + | fn chart_of(bars: &[ChartBar]) -> RevenueChart { | |
| 233 | + | RevenueChart { | |
| 234 | + | most: 2400, | |
| 235 | + | bars: bars.to_vec(), | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 231 | 239 | fn render(range: &str, bars: &[ChartBar], items: &[ContentItem]) -> String { | |
| 232 | - | let nodes = body("an-album", range, &[stat("Revenue", None)], bars, items); | |
| 240 | + | let chart = chart_of(bars); | |
| 241 | + | let nodes = body("an-album", range, &[stat("Revenue", None)], &chart, items); | |
| 233 | 242 | let mut out = String::new(); | |
| 234 | - | let webview = drawn(bars); | |
| 243 | + | let webview = Webview::new(); | |
| 235 | 244 | for node in &nodes { | |
| 236 | 245 | out.push_str(&webview.fragment(node)); | |
| 237 | 246 | } | |
| @@ -281,7 +290,12 @@ | |||
| 281 | 290 | let without = render("30d", &[], &[]); | |
| 282 | 291 | ||
| 283 | 292 | assert!(with.contains("class=\"chart-bars\""), "{with}"); | |
| 284 | - | assert!(with.contains("--fill: 42.5000%"), "{with}"); | |
| 293 | + | // Both numbers as themselves, and no width worked out here. See | |
| 294 | + | // `user_analytics`, which this chart is now shared with as a described | |
| 295 | + | // member rather than as a string of markup. | |
| 296 | + | assert!(with.contains("--most: 2400"), "{with}"); | |
| 297 | + | assert!(with.contains("--value: 1200"), "{with}"); | |
| 298 | + | assert!(!with.contains("--fill"), "{with}"); | |
| 285 | 299 | ||
| 286 | 300 | assert!(!without.contains("class=\"chart-bars\""), "{without}"); | |
| 287 | 301 | assert!(without.contains("No revenue data yet."), "{without}"); | |
| @@ -289,11 +303,16 @@ | |||
| 289 | 303 | ||
| 290 | 304 | #[test] | |
| 291 | 305 | fn the_chart_is_the_same_one_user_analytics_draws() { | |
| 292 | - | // Not a second chart. If these ever diverge the two tabs draw different | |
| 293 | - | // pictures of the same shape, which is what chart_bars.html existed to | |
| 294 | - | // stop and what this conversion must not undo. | |
| 295 | - | let shared = super::super::user_analytics::chart_markup(&[bar()]); | |
| 296 | - | assert!(render("30d", &[bar()], &[]).contains(&shared)); | |
| 306 | + | // Not a second chart. The two tabs used to be held together by sharing | |
| 307 | + | // one markup-building function; they are held together by the | |
| 308 | + | // vocabulary now -- both say `chart`, and one renderer draws it -- which | |
| 309 | + | // is the stronger version of the same guarantee. | |
| 310 | + | let mine = render("30d", &[bar()], &[]); | |
| 311 | + | let theirs = Webview::new().fragment( | |
| 312 | + | &Node::chart(Chart::new(2400).label("revenue over time")) | |
| 313 | + | .bar(Bar::at("Aug 1").of(1200).reading("$12").note("3 sales")), | |
| 314 | + | ); | |
| 315 | + | assert!(mine.contains(&theirs), "{mine}\n\n{theirs}"); | |
| 297 | 316 | } | |
| 298 | 317 | ||
| 299 | 318 | #[test] |
| @@ -108,6 +108,9 @@ | |||
| 108 | 108 | ("FEED", |plan| { | |
| 109 | 109 | Node::Region(super::feeds::page_region_staged(plan)) | |
| 110 | 110 | }), | |
| 111 | + | ("USER_ANALYTICS", |plan| { | |
| 112 | + | super::user_analytics::pane_staged(plan) | |
| 113 | + | }), | |
| 111 | 114 | ] | |
| 112 | 115 | } | |
| 113 | 116 | ||
| @@ -777,6 +780,73 @@ | |||
| 777 | 780 | } | |
| 778 | 781 | } | |
| 779 | 782 | ||
| 783 | + | /// The analytics tab fills to what the renderer builds, at every shape. | |
| 784 | + | /// | |
| 785 | + | /// The last screen to join the seam, and the one that needed a new member | |
| 786 | + | /// to do it (quasicoherent `7d6ad166`). Its revenue chart was a ceded | |
| 787 | + | /// region: the handler drew the markup and the renderer looked it up WHILE | |
| 788 | + | /// it rendered, so a residual derived from a bare `Webview` held the empty | |
| 789 | + | /// container and nothing at serve time could get inside it. | |
| 790 | + | /// | |
| 791 | + | /// Described, the chart is an axis and a run of bars, and what makes it | |
| 792 | + | /// compilable is that no renderer divides. The axis is a hole beside the | |
| 793 | + | /// loop and each magnitude is a hole inside it; a width worked out from the | |
| 794 | + | /// two would have left no stand-in to find and baked one reader's chart | |
| 795 | + | /// into the template. `quasi_router::stage::number_at` states the rule and | |
| 796 | + | /// `quasi-bench`'s `charted` proves the member against it. | |
| 797 | + | /// | |
| 798 | + | /// Four things vary here and each drops a different branch: | |
| 799 | + | /// | |
| 800 | + | /// - **The chart**, present and absent, against its own empty state. | |
| 801 | + | /// - **The stat cards**, which are three guarded figures -- no delta, a | |
| 802 | + | /// toned rise, a toned fall -- so a fill that only saw one would pass on | |
| 803 | + | /// a residual that had baked it. | |
| 804 | + | /// - **The comparison**, whose heading and table share a guard on there | |
| 805 | + | /// being more than one project. | |
| 806 | + | /// - **The totals**, against their own empty state. | |
| 807 | + | /// | |
| 808 | + | /// The range chips are crossed too, and they are the reason `latched` had | |
| 809 | + | /// to become guardable: which chip is held down is a fact a request brings, | |
| 810 | + | /// a `bool` has no stand-in, and a loop body cannot carry "exactly one of | |
| 811 | + | /// these differs" as a value. Said as a guard it is a clean deletion -- | |
| 812 | + | /// `class="chip latched"` against `class="chip"` -- so it derives as a | |
| 813 | + | /// branch inside the loop. That is `Choice::chosen`'s answer on the other | |
| 814 | + | /// control; `quasi_declare::symbolic::PLACED` is where it is allowed. | |
| 815 | + | #[test] | |
| 816 | + | fn the_analytics_residual_fills_to_what_the_renderer_builds() { | |
| 817 | + | use crate::quasi::user_analytics::{pane, pane_serve, sample}; | |
| 818 | + | use quasi_axum::Serves as _; | |
| 819 | + | ||
| 820 | + | // Every combination a card's delta can take, including the mixed strip | |
| 821 | + | // that draws two of the three guarded figures at once. | |
| 822 | + | let strips: &[&[Option<bool>]] = &[ | |
| 823 | + | &[], | |
| 824 | + | &[None], | |
| 825 | + | &[Some(true)], | |
| 826 | + | &[Some(false)], | |
| 827 | + | &[None, Some(true), Some(false)], | |
| 828 | + | ]; | |
| 829 | + | ||
| 830 | + | for bars in [0, 1, 5] { | |
| 831 | + | // One project is not a comparison, which is what that guard says, | |
| 832 | + | // so the three counts are the shapes it has. | |
| 833 | + | for projects in [0, 1, 3] { | |
| 834 | + | for totals in [0, 2] { | |
| 835 | + | for deltas in strips { | |
| 836 | + | let read = sample(bars, projects, totals, deltas); | |
| 837 | + | assert_eq!( | |
| 838 | + | pane_serve(&USER_ANALYTICS, &read), | |
| 839 | + | Webview::new().fragment(&pane(&read)), | |
| 840 | + | "analytics, {bars} bars, {projects} projects, \ | |
| 841 | + | {totals} totals, {} cards", | |
| 842 | + | deltas.len(), | |
| 843 | + | ); | |
| 844 | + | } | |
| 845 | + | } | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | } | |
| 849 | + | ||
| 780 | 850 | /// The git listing fills to what the renderer builds, at every page shape. | |
| 781 | 851 | /// | |
| 782 | 852 | /// The first residual holding a **described pager** (quasicoherent | |
| @@ -960,13 +1030,17 @@ | |||
| 960 | 1030 | ||
| 961 | 1031 | /// The addresses still served by building a `Node` per request. | |
| 962 | 1032 | /// | |
| 963 | - | /// - `/dashboard/tabs/analytics`: its revenue chart is a ceded region, | |
| 964 | - | /// whose markup the renderer looks up while it renders, so there is | |
| 965 | - | /// nowhere in a residual for it to go. Ruled 2026-09-08: the answer | |
| 966 | - | /// is to describe the chart rather than to compile around it, so this | |
| 967 | - | /// screen stays here until a chart is in the vocabulary. | |
| 968 | - | /// quasicoherent `7d6ad166`. | |
| 969 | - | const OFF_THE_SEAM: &[&str] = &[crate::quasi::user_analytics::PATH]; | |
| 1033 | + | /// **Empty, and that is the point.** Every described screen serves from | |
| 1034 | + | /// a generated template. The last one off it was | |
| 1035 | + | /// `/dashboard/tabs/analytics`, whose revenue chart was a ceded region | |
| 1036 | + | /// the renderer looked up while it rendered; ruled 2026-09-08, the | |
| 1037 | + | /// chart was described rather than compiled around, and the screen | |
| 1038 | + | /// joined. quasicoherent `7d6ad166`. | |
| 1039 | + | /// | |
| 1040 | + | /// The list stays rather than going, because what it is for is a screen | |
| 1041 | + | /// that CANNOT join saying so out loud. An empty one is the claim that | |
| 1042 | + | /// none is in that position today. | |
| 1043 | + | const OFF_THE_SEAM: &[&str] = &[]; | |
| 970 | 1044 | ||
| 971 | 1045 | let described = PATHS.len() + DOCUMENT_PATHS.len() + PUBLIC_DOCUMENT_PATHS.len(); | |
| 972 | 1046 | ||
| @@ -1007,8 +1081,9 @@ | |||
| 1007 | 1081 | /// Screens with their own filling test: `/use-cases`, `/fan-plus`, | |
| 1008 | 1082 | /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the | |
| 1009 | 1083 | /// two forum panes, the two contact panes, the payout card, | |
| 1010 | - | /// `/creators`, `/dashboard/tabs/ssh-keys`, `/git` and `/feed`. | |
| 1011 | - | const HOLED: usize = 14; | |
| 1084 | + | /// `/creators`, `/dashboard/tabs/ssh-keys`, `/git`, `/feed` and | |
| 1085 | + | /// `/dashboard/tabs/analytics`. | |
| 1086 | + | const HOLED: usize = 15; | |
| 1012 | 1087 | ||
| 1013 | 1088 | assert_eq!( | |
| 1014 | 1089 | roster().len(), |
| @@ -21,12 +21,16 @@ | |||
| 21 | 21 | //! landed anyway because the alternative was folding the delta into the | |
| 22 | 22 | //! caption, which loses the tone and turns a small second line into a longer | |
| 23 | 23 | //! first one. makeover-layout 0.13.0, makeover-webview 0.24.0. | |
| 24 | - | //! 2. **A bar chart is not describable, and should not be.** makeover-layout | |
| 25 | - | //! names no chart, and the admission test is that a node composes something | |
| 26 | - | //! it already names. Inventing one there would be the layer naming a widget. | |
| 27 | - | //! So the chart is a [`quasi_router::RegionKind::Ceded`] fill, which is | |
| 28 | - | //! exactly what bespoke regions are for, and it is the first one in the | |
| 29 | - | //! tree. See [`chart_markup`]. | |
| 24 | + | //! 2. **A bar chart IS describable, and this screen is why.** It was not, for a | |
| 25 | + | //! while: makeover-layout named no chart, and the admission test is that a | |
| 26 | + | //! node composes something it already names, so the chart was a | |
| 27 | + | //! `RegionKind::Ceded` fill -- the first and last one in the tree. That put | |
| 28 | + | //! this screen alone off the compiled-template seam, because a ceded | |
| 29 | + | //! region's markup is looked up WHILE the renderer renders and a residual | |
| 30 | + | //! has nowhere to keep it. Max ruled on 2026-09-08 that a bespoke region is | |
| 31 | + | //! the mark of a screen the description layer has not finished converting, | |
| 32 | + | //! so the chart was described instead: `makeover_layout::Chart` and `Bar`, | |
| 33 | + | //! drawn by each renderer. quasicoherent `7d6ad166`. | |
| 30 | 34 | //! 3. **A proportion bar inside a table cell is one site.** The comparison | |
| 31 | 35 | //! table's revenue cell draws a bar behind the number. One site across every | |
| 32 | 36 | //! template, so it does not earn a `Cell::meter` and the cell is the number | |
| @@ -42,12 +46,10 @@ | |||
| 42 | 46 | //! for a held-down chip. Each carries the range it selects, so the address a | |
| 43 | 47 | //! reader lands on is the view they are looking at. | |
| 44 | 48 | ||
| 45 | - | use std::fmt::Write as _; | |
| 46 | - | ||
| 47 | 49 | use makeover_layout as layout; | |
| 48 | 50 | use quasi_declare::declare; | |
| 49 | - | use quasi_router::screen::Figure; | |
| 50 | - | use quasi_router::{RegionKind, Request, Response, RouteError}; | |
| 51 | + | use quasi_router::screen::{Bar, Chart, Figure}; | |
| 52 | + | use quasi_router::{Request, Response, RouteError}; | |
| 51 | 53 | use quasi_webview::Webview; | |
| 52 | 54 | ||
| 53 | 55 | use super::Viewer; | |
| @@ -70,9 +72,6 @@ | |||
| 70 | 72 | /// constant and a test there asserts the two agree. | |
| 71 | 73 | pub const REGION: &str = "user-analytics"; | |
| 72 | 74 | ||
| 73 | - | /// The slot the chart's own markup mounts into. | |
| 74 | - | const CHART_SLOT: &str = "analytics-chart"; | |
| 75 | - | ||
| 76 | 75 | /// One stat card, as the screen needs it. | |
| 77 | 76 | pub struct StatView { | |
| 78 | 77 | label: String, | |
| @@ -101,7 +100,7 @@ | |||
| 101 | 100 | pub struct Analytics { | |
| 102 | 101 | range: String, | |
| 103 | 102 | stats: Vec<StatView>, | |
| 104 | - | bars: Vec<crate::types::ChartBar>, | |
| 103 | + | chart: crate::types::RevenueChart, | |
| 105 | 104 | projects: Vec<ProjectView>, | |
| 106 | 105 | totals: Vec<TotalView>, | |
| 107 | 106 | } | |
| @@ -122,10 +121,6 @@ | |||
| 122 | 121 | ||
| 123 | 122 | let analytics = read(viewer, &range)?; | |
| 124 | 123 | ||
| 125 | - | // Handed to the renderer through the state both of them see. The chart is | |
| 126 | - | // the app's own markup and there is no node for it; see the module header. | |
| 127 | - | viewer.fill(CHART_SLOT, chart_markup(&analytics.bars)); | |
| 128 | - | ||
| 129 | 124 | Ok(Response::fragment(REGION, pane(&analytics))) | |
| 130 | 125 | } | |
| 131 | 126 | ||
| @@ -152,11 +147,11 @@ | |||
| 152 | 147 | )) | |
| 153 | 148 | .map_err(failed)?; | |
| 154 | 149 | ||
| 155 | - | // `build_chart_bars` already produces exactly what the chart draws. This | |
| 156 | - | // remapped it into a private `BarView` with the same four fields, which | |
| 157 | - | // meant `project_analytics` could not reuse the chart without a third copy | |
| 158 | - | // of the type. Dropped 2026-08-26 when that screen converted. | |
| 159 | - | let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency); | |
| 150 | + | // `build_revenue_chart` already produces exactly what the chart draws. This | |
| 151 | + | // remapped it into a private `BarView` with the same fields, which meant | |
| 152 | + | // `project_analytics` could not reuse the chart without a third copy of the | |
| 153 | + | // type. Dropped 2026-08-26 when that screen converted. | |
| 154 | + | let chart = crate::routes::pages::dashboard::build_revenue_chart(&buckets, currency); | |
| 160 | 155 | ||
| 161 | 156 | let view_change = db::analytics::pct_change(current_views, prev_views); | |
| 162 | 157 | let mut stats = vec![ | |
| @@ -246,7 +241,7 @@ | |||
| 246 | 241 | Ok(Analytics { | |
| 247 | 242 | range: range.to_string(), | |
| 248 | 243 | stats, | |
| 249 | - | bars, | |
| 244 | + | chart, | |
| 250 | 245 | projects, | |
| 251 | 246 | totals, | |
| 252 | 247 | }) | |
| @@ -258,7 +253,8 @@ | |||
| 258 | 253 | /// One project is not a comparison, which is why that section carries the | |
| 259 | 254 | /// same guard twice: the heading and the table are two members and both are | |
| 260 | 255 | /// absent together. | |
| 261 | - | shape pane(analytics: &Analytics) -> Node; | |
| 256 | + | #[staged] | |
| 257 | + | pub(crate) shape pane(analytics: &Analytics) -> Node; | |
| 262 | 258 | ||
| 263 | 259 | region REGION as Pane { | |
| 264 | 260 | section super::range_heading(&analytics.range); | |
| @@ -269,10 +265,15 @@ | |||
| 269 | 265 | ||
| 270 | 266 | section "Revenue Over Time"; | |
| 271 | 267 | empty "Once you publish items and make sales, revenue data will appear here." | |
| 272 | - | when analytics.bars.is_empty(); | |
| 273 | - | // The chart's own markup arrives through the renderer. The description | |
| 274 | - | // says only that there is a region here and what it is called. | |
| 275 | - | region CHART_SLOT as RegionKind::ceded("revenue-chart") unless analytics.bars.is_empty() {} | |
| 268 | + | when analytics.chart.bars.is_empty(); | |
| 269 | + | chart Chart::new(analytics.chart.most).label("revenue over time") unless analytics.chart.bars.is_empty() { | |
| 270 | + | for bar in analytics.chart.bars.iter() { | |
| 271 | + | bar Bar::at(bar.label.clone()) | |
| 272 | + | .of(bar.cents) | |
| 273 | + | .reading(bar.value.clone()) | |
| 274 | + | .note(sales(bar.count)); | |
| 275 | + | } | |
| 276 | + | } | |
| 276 | 277 | ||
| 277 | 278 | section "Project Comparison" when analytics.projects.len() over 1; | |
| 278 | 279 | include comparison(&analytics.projects) when analytics.projects.len() over 1; | |
| @@ -286,11 +287,12 @@ | |||
| 286 | 287 | ||
| 287 | 288 | declare! { | |
| 288 | 289 | /// The range selector: one chip per range, the current one held down. | |
| 290 | + | #[staged] | |
| 289 | 291 | shape range_chips(range: &str) -> Vec<Node>; | |
| 290 | 292 | ||
| 291 | 293 | for window in super::RANGES { | |
| 292 | 294 | chip window.value to get PATH carrying "range" window.value { | |
| 293 | - | latched super::is_shown(window, range); | |
| 295 | + | latched when super::is_shown(window, range); | |
| 294 | 296 | } | |
| 295 | 297 | } | |
| 296 | 298 | } | |
| @@ -300,34 +302,32 @@ | |||
| 300 | 302 | stat.change.as_deref().unwrap_or_default() | |
| 301 | 303 | } | |
| 302 | 304 | ||
| 303 | - | /// The tone rides on the delta, which is why a card without one stays neutral | |
| 304 | - | /// rather than being coloured green for having nothing to report. | |
| 305 | - | /// | |
| 306 | - | /// `positive` is `true` by default in the source data, so toning on it alone | |
| 307 | - | /// would paint every unchanged card. | |
| 308 | - | fn delta_tone(stat: &StatView) -> layout::Tone { | |
| 309 | - | if stat.positive { | |
| 310 | - | layout::Tone::Success | |
| 311 | - | } else { | |
| 312 | - | layout::Tone::Danger | |
| 313 | - | } | |
| 314 | - | } | |
| 315 | - | ||
| 316 | 305 | declare! { | |
| 317 | 306 | /// The figures across the top. | |
| 318 | 307 | /// | |
| 319 | 308 | /// The empty list is what the figures accrete onto: `Node::stats` takes the | |
| 320 | - | /// whole list and this one is built a card at a time. See [`delta_tone`]. | |
| 309 | + | /// whole list and this one is built a card at a time. | |
| 310 | + | #[staged] | |
| 321 | 311 | shape stats(stats: &[StatView]) -> Node; | |
| 322 | 312 | ||
| 323 | 313 | stats [] { | |
| 324 | 314 | for stat in stats.iter() { | |
| 315 | + | // Three, one per tone a delta can carry, because a tone is not a | |
| 316 | + | // value a residual can hold: `Tone` has no stand-in, so a supplier | |
| 317 | + | // answering one hands the derivation a sentinel where an enum | |
| 318 | + | // belongs. Written out, each tone is a path the derivation bakes and | |
| 319 | + | // the guards are what a request picks between. `symbolic::PLACED` | |
| 320 | + | // names this site. | |
| 325 | 321 | figure Figure::new(stat.value.clone(), stat.label.clone()) | |
| 326 | 322 | when stat.change.is_none(); | |
| 327 | 323 | figure Figure::new(stat.value.clone(), stat.label.clone()) | |
| 328 | 324 | .change(change(stat)) | |
| 329 | - | .tone(delta_tone(stat)) | |
| 330 | - | unless stat.change.is_none(); | |
| 325 | + | .tone(layout::Tone::Success) | |
| 326 | + | when stat.change.is_some() and stat.positive; | |
| 327 | + | figure Figure::new(stat.value.clone(), stat.label.clone()) | |
| 328 | + | .change(change(stat)) | |
| 329 | + | .tone(layout::Tone::Danger) | |
| 330 | + | when stat.change.is_some() and not stat.positive; | |
| 331 | 331 | } | |
| 332 | 332 | } | |
| 333 | 333 | } | |
| @@ -345,6 +345,7 @@ | |||
| 345 | 345 | /// The revenue cell is the number alone. The template drew a bar behind it | |
| 346 | 346 | /// scaled against the biggest earner, and that is one site in the whole | |
| 347 | 347 | /// template set, so it does not earn a member. See the module header. | |
| 348 | + | #[staged] | |
| 348 | 349 | shape comparison(projects: &[ProjectView]) -> Node; | |
| 349 | 350 | ||
| 350 | 351 | table { | |
| @@ -381,6 +382,7 @@ | |||
| 381 | 382 | ||
| 382 | 383 | declare! { | |
| 383 | 384 | /// All-time revenue per project. | |
| 385 | + | #[staged] | |
| 384 | 386 | shape totals(totals: &[TotalView]) -> Node; | |
| 385 | 387 | ||
| 386 | 388 | list { | |
| @@ -392,64 +394,98 @@ | |||
| 392 | 394 | } | |
| 393 | 395 | } | |
| 394 | 396 | ||
| 395 | - | /// The chart, as markup, because no description names one. | |
| 397 | + | /// One reading of the tab, as the tests draw it. | |
| 396 | 398 | /// | |
| 397 | - | /// Byte-for-byte the structure `templates/partials/chart_bars.html` emits, so | |
| 398 | - | /// the existing `.chart-*` rules in `style.css` draw it unchanged and the | |
| 399 | - | /// described screen and the Askama one are the same chart rather than two that | |
| 400 | - | /// drifted. | |
| 399 | + | /// `pub(crate)` so `residuals` can fill the compiled template against the | |
| 400 | + | /// renderer at every shape this screen takes; `ssh_keys::sample_key` is the | |
| 401 | + | /// same arrangement for the same reason. | |
| 401 | 402 | /// | |
| 402 | - | /// Everything interpolated here is escaped. A bespoke region is not escaped by | |
| 403 | - | /// the renderer, which is what makes it bespoke, so the escaping is this | |
| 404 | - | /// function's job and a label reaching it from a database is exactly why. | |
| 405 | - | pub(super) fn chart_markup(bars: &[crate::types::ChartBar]) -> String { | |
| 406 | - | let mut html = String::from("<div class=\"chart-bars\">"); | |
| 407 | - | for bar in bars { | |
| 408 | - | let plural = if bar.count == 1 { "" } else { "s" }; | |
| 409 | - | let _ = write!( | |
| 410 | - | html, | |
| 411 | - | "<div class=\"chart-bar-col\" data-tooltip=\"{} / {} sale{plural}\">\ | |
| 412 | - | <div class=\"chart-bar\" style=\"--fill: {}%;\"></div>\ | |
| 413 | - | <div class=\"chart-bar-label\">{}</div></div>", | |
| 414 | - | escape(&bar.value), | |
| 415 | - | bar.count, | |
| 416 | - | // A float straight from the database, so it is formatted rather | |
| 417 | - | // than printed: `{:?}` on an f64 can emit an exponent, and | |
| 418 | - | // `--fill: 1e-7%` is not a length any browser accepts. | |
| 419 | - | format_args!("{:.4}", bar.height_pct), | |
| 420 | - | escape(&bar.label), | |
| 421 | - | ); | |
| 403 | + | /// `deltas` picks what the stat cards report, because the three figures are | |
| 404 | + | /// three guarded members -- one per tone -- and a fill that only ever saw one | |
| 405 | + | /// of them would pass on a residual that had baked it. | |
| 406 | + | #[cfg(test)] | |
| 407 | + | pub(crate) fn sample( | |
| 408 | + | bars: usize, | |
| 409 | + | projects: usize, | |
| 410 | + | totals: usize, | |
| 411 | + | deltas: &[Option<bool>], | |
| 412 | + | ) -> Analytics { | |
| 413 | + | let cents: Vec<usize> = (0..bars).map(|n| (n + 1) * 137).collect(); | |
| 414 | + | Analytics { | |
| 415 | + | range: "30d".into(), | |
| 416 | + | stats: deltas | |
| 417 | + | .iter() | |
| 418 | + | .enumerate() | |
| 419 | + | .map(|(n, delta)| StatView { | |
| 420 | + | label: format!("Stat {n}"), | |
| 421 | + | value: format!("{n}00"), | |
| 422 | + | change: delta.map(|up| if up { "+1.0%".into() } else { "-1.0%".into() }), | |
| 423 | + | positive: delta.unwrap_or(true), | |
| 424 | + | }) | |
| 425 | + | .collect(), | |
| 426 | + | chart: crate::types::RevenueChart { | |
| 427 | + | most: cents.iter().copied().max().unwrap_or(1).max(1), | |
| 428 | + | bars: cents | |
| 429 | + | .iter() | |
| 430 | + | .enumerate() | |
| 431 | + | .map(|(n, value)| crate::types::ChartBar { | |
| 432 | + | label: format!("Aug {}", n + 1), | |
| 433 | + | cents: *value, | |
| 434 | + | value: crate::formatting::format_revenue( | |
| 435 | + | i64::try_from(*value).unwrap_or(i64::MAX), | |
| 436 | + | crate::currency::SettlementCurrency::default(), | |
| 437 | + | ), | |
| 438 | + | count: n as i64, | |
| 439 | + | }) | |
| 440 | + | .collect(), | |
| 441 | + | }, | |
| 442 | + | projects: (0..projects) | |
| 443 | + | .map(|n| ProjectView { | |
| 444 | + | title: format!("Project {n}"), | |
| 445 | + | revenue: format!("${n}0.00"), | |
| 446 | + | sales: n.to_string(), | |
| 447 | + | views: format!("{n}00"), | |
| 448 | + | conversion: format!("{n}.1%"), | |
| 449 | + | }) | |
| 450 | + | .collect(), | |
| 451 | + | totals: (0..totals) | |
| 452 | + | .map(|n| TotalView { | |
| 453 | + | title: format!("Project {n}"), | |
| 454 | + | revenue: format!("${n}80.00"), | |
| 455 | + | }) | |
| 456 | + | .collect(), | |
| 422 | 457 | } | |
| 423 | - | html.push_str("</div>"); | |
| 424 | - | html | |
| 425 | - | } | |
| 426 | - | ||
| 427 | - | /// The five characters that matter in markup and in an attribute value. | |
| 428 | - | fn escape(text: &str) -> String { | |
| 429 | - | text.replace('&', "&") | |
| 430 | - | .replace('<', "<") | |
| 431 | - | .replace('>', ">") | |
| 432 | - | .replace('"', """) | |
| 433 | - | .replace('\'', "'") | |
| 434 | 458 | } | |
| 435 | 459 | ||
| 460 | + | /// What a bar's sale count says, worded. | |
| 461 | + | /// | |
| 462 | + | /// The description carries this already worded rather than carrying the number | |
| 463 | + | /// and a noun, because the noun inflects with the count and a renderer that | |
| 464 | + | /// pluralised would be growing a lexer for one language. `makeover_layout::Bar` | |
| 465 | + | /// says the same thing from the other side. | |
| 436 | 466 | /// The renderer this screen is drawn with. | |
| 437 | 467 | /// | |
| 438 | - | /// Mounts whatever the handler drew for its bespoke regions. The two see one | |
| 439 | - | /// `Viewer`; see [`super::Viewer::fills`]. | |
| 468 | + | /// The plain one every other converted screen uses. It carried a loop over the | |
| 469 | + | /// handler's drawn markup while the chart was a ceded region; the chart is | |
| 470 | + | /// described now, so there is nothing bespoke left to mount. | |
| 440 | 471 | pub fn renderer(viewer: &Viewer) -> Webview { | |
| 441 | - | let mut webview = Webview::new().with_shell(viewer.shell()); | |
| 442 | - | for (slot, markup) in viewer.drawn() { | |
| 443 | - | webview = webview.with_fill(slot, markup); | |
| 472 | + | Webview::new().with_shell(viewer.shell()) | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | /// What a bar's sale count says, worded. | |
| 476 | + | pub(super) fn sales(count: i64) -> String { | |
| 477 | + | if count == 1 { | |
| 478 | + | "1 sale".to_string() | |
| 479 | + | } else { | |
| 480 | + | format!("{count} sales") | |
| 444 | 481 | } | |
| 445 | - | webview | |
| 446 | 482 | } | |
| 447 | 483 | ||
| 448 | 484 | #[cfg(test)] | |
| 449 | 485 | mod tests { | |
| 450 | 486 | use super::*; | |
| 451 | 487 | use quasi_axum::Serves; | |
| 452 | - | use quasi_router::{Node, Slot}; | |
| 488 | + | use quasi_router::{Node, RegionKind, Slot}; | |
| 453 | 489 | ||
| 454 | 490 | fn analytics() -> Analytics { | |
| 455 | 491 | Analytics { | |
| @@ -468,12 +504,15 @@ | |||
| 468 | 504 | positive: true, | |
| 469 | 505 | }, | |
| 470 | 506 | ], | |
| 471 | - | bars: vec![crate::types::ChartBar { | |
| 472 | - | label: "Aug 1".into(), | |
| 473 | - | value: "$42.00".into(), | |
| 474 | - | count: 3, | |
| 475 | - | height_pct: 62.5, | |
| 476 | - | }], | |
| 507 | + | chart: crate::types::RevenueChart { | |
| 508 | + | most: 6720, | |
| 509 | + | bars: vec![crate::types::ChartBar { | |
| 510 | + | label: "Aug 1".into(), | |
| 511 | + | value: "$42.00".into(), | |
| 512 | + | count: 3, | |
| 513 | + | cents: 4200, | |
| 514 | + | }], | |
| 515 | + | }, | |
| 477 | 516 | projects: vec![ | |
| 478 | 517 | ProjectView { | |
| 479 | 518 | title: "Atlas".into(), | |
| @@ -551,39 +590,40 @@ | |||
| 551 | 590 | } | |
| 552 | 591 | ||
| 553 | 592 | #[test] | |
| 554 | - | fn the_chart_is_the_markup_the_template_already_emits() { | |
| 555 | - | // The described screen and the Askama one draw one chart, against one | |
| 556 | - | // set of `.chart-*` rules. If this structure drifts the two diverge | |
| 557 | - | // silently, because nothing else renders it. | |
| 558 | - | let html = chart_markup(&analytics().bars); | |
| 593 | + | fn the_chart_hands_over_both_numbers_and_computes_no_width() { | |
| 594 | + | // The property the described chart exists for. A width worked out here | |
| 595 | + | // would be baked into the compiled template as one request's constant, | |
| 596 | + | // so the axis and each magnitude have to reach the markup as | |
| 597 | + | // themselves. `quasi_router::stage::number_at` is where this is | |
| 598 | + | // enforced from the other side. | |
| 599 | + | let html = render(&pane(&analytics())); | |
| 559 | 600 | ||
| 560 | - | assert!(html.contains("class=\"chart-bars\""), "{html}"); | |
| 561 | - | assert!(html.contains("class=\"chart-bar-col\""), "{html}"); | |
| 562 | - | assert!(html.contains("--fill: 62.5000%"), "{html}"); | |
| 601 | + | assert!(html.contains("--most: 6720"), "{html}"); | |
| 602 | + | assert!(html.contains("--value: 4200"), "{html}"); | |
| 603 | + | assert!(!html.contains("--fill"), "{html}"); | |
| 563 | 604 | assert!(html.contains("3 sales"), "{html}"); | |
| 564 | - | ||
| 565 | - | // The template's own pluralisation, which a described copy is easy to | |
| 566 | - | // get wrong in exactly one direction. | |
| 567 | - | let one = chart_markup(&[crate::types::ChartBar { | |
| 568 | - | label: "Aug 2".into(), | |
| 569 | - | count: 1, | |
| 570 | - | ..analytics().bars.pop().expect("one bar") | |
| 571 | - | }]); | |
| 572 | - | assert!(one.contains("1 sale ") || one.contains("1 sale\""), "{one}"); | |
| 573 | 605 | } | |
| 574 | 606 | ||
| 575 | 607 | #[test] | |
| 576 | - | fn a_bespoke_fill_is_the_apps_markup_and_still_escapes_its_data() { | |
| 577 | - | // A bespoke region is not escaped by the renderer, which is the whole | |
| 578 | - | // of what makes it bespoke. A bar's label is a formatted date today and | |
| 579 | - | // its value comes from the database, so the escaping is this screen's | |
| 580 | - | // job and nothing else will do it. | |
| 581 | - | let html = chart_markup(&[crate::types::ChartBar { | |
| 582 | - | label: "<script>x()</script>".into(), | |
| 583 | - | value: "\" onload=\"x()".into(), | |
| 584 | - | count: 1, | |
| 585 | - | height_pct: 10.0, | |
| 586 | - | }]); | |
| 608 | + | fn a_count_of_one_is_worded_as_one() { | |
| 609 | + | // The pluralisation the Askama template does with an `{% if %}`, which | |
| 610 | + | // is the description's job here: a renderer that inflected a noun would | |
| 611 | + | // be growing a lexer for one language. | |
| 612 | + | assert_eq!(sales(1), "1 sale"); | |
| 613 | + | assert_eq!(sales(0), "0 sales"); | |
| 614 | + | assert_eq!(sales(3), "3 sales"); | |
| 615 | + | } | |
| 616 | + | ||
| 617 | + | #[test] | |
| 618 | + | fn a_label_and_a_reading_are_escaped_by_the_renderer() { | |
| 619 | + | // What the ceded region made this screen do for itself. The chart is a | |
| 620 | + | // described member now, so the renderer escapes it like every other | |
| 621 | + | // value, and this is the test that the conversion did not quietly drop | |
| 622 | + | // the protection along with the bespoke markup. | |
| 623 | + | let mut hostile = analytics(); | |
| 624 | + | hostile.chart.bars[0].label = "<script>x()</script>".into(); | |
| 625 | + | hostile.chart.bars[0].value = "\" onload=\"x()".into(); | |
| 626 | + | let html = render(&pane(&hostile)); | |
| 587 | 627 | ||
| 588 | 628 | assert!(!html.contains("<script>x()"), "{html}"); | |
| 589 | 629 | assert!(!html.contains("\" onload="), "{html}"); | |
| @@ -606,7 +646,10 @@ | |||
| 606 | 646 | let empty = Analytics { | |
| 607 | 647 | range: "30d".into(), | |
| 608 | 648 | stats: vec![], | |
| 609 | - | bars: vec![], | |
| 649 | + | chart: crate::types::RevenueChart { | |
| 650 | + | most: 1, | |
| 651 | + | bars: vec![], | |
| 652 | + | }, | |
| 610 | 653 | projects: vec![], | |
| 611 | 654 | totals: vec![], | |
| 612 | 655 | }; | |
| @@ -614,8 +657,8 @@ | |||
| 614 | 657 | ||
| 615 | 658 | assert!(html.contains("revenue data will appear here."), "{html}"); | |
| 616 | 659 | assert!(html.contains("Sales across your projects"), "{html}"); | |
| 617 | - | // And the chart region is absent rather than empty, so the stand-in is | |
| 618 | - | // not sitting next to a blank chart. | |
| 619 | - | assert!(!html.contains(CHART_SLOT), "{html}"); | |
| 660 | + | // And the chart is absent rather than empty, so the stand-in is not | |
| 661 | + | // sitting next to a blank chart. | |
| 662 | + | assert!(!html.contains("chart-bars"), "{html}"); | |
| 620 | 663 | } | |
| 621 | 664 | } |