Skip to main content

max / makenotwork

Describe the analytics tab, chart included
Author: Max Johnson <me@maxj.phd> · 2026-08-11 01:57 UTC
Signed with PGP, not checked
Commit: cfa6887d533f1f32fe792f1189718d6c36071971
Parent: 2cf55d3
4 files changed, +560 insertions, -7 deletions
@@ -159,7 +159,7 @@
159 159 # rather than reached through quasi-router's re-export because a described
160 160 # screen names FieldKind and Tone directly; it has to track what quasi-router
161 161 # resolves or the two `layout::` paths are different crates.
162 - makeover-layout = "0.12.0"
162 + makeover-layout = "0.13.0"
163 163 # For the request head the per-viewer state factory reads. axum re-exports it,
164 164 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
165 165 http = "1.3.1"
@@ -34,6 +34,7 @@
34 34 pub mod forum_memberships;
35 35 pub mod library_contacts;
36 36 pub mod ssh_keys;
37 + pub mod user_analytics;
37 38
38 39 /// The state one request is answered against.
39 40 ///
@@ -52,6 +53,21 @@
52 53 /// handler: `Handle::current` works on a blocking thread today, and
53 54 /// depending on that is depending on where quasi-axum happens to dispatch.
54 55 pub runtime: Handle,
56 + /// Markup for the bespoke regions this request's screen describes.
57 + ///
58 + /// The seam between a handler and its renderer, and the reason it has to be
59 + /// here rather than in either of them: a `Region::Bespoke` is filled on the
60 + /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
61 + /// handler has answered, and the renderer factory is handed `&S` and the
62 + /// answer. So the handler writes what it drew here and the renderer reads
63 + /// it back off the same state. Both see one instance: the adapter builds a
64 + /// single `Arc<Viewer>` per request and passes it to the router and then to
65 + /// the factory.
66 + ///
67 + /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
68 + /// thread. Uncontended in practice: one request writes it, then one
69 + /// renderer reads it, never at once.
70 + fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
55 71 }
56 72
57 73 impl Viewer {
@@ -63,6 +79,24 @@
63 79 pub fn block_on<F: Future>(&self, future: F) -> F::Output {
64 80 self.runtime.block_on(future)
65 81 }
82 +
83 + /// Hand the renderer the markup for one bespoke region.
84 + ///
85 + /// Called by a handler while it builds its description, keyed by the slot
86 + /// id the description gives that region. Markup, not text: a bespoke region
87 + /// is the app's own and is not escaped, which is the whole of what makes it
88 + /// bespoke and the whole of why a handler must not put a reader's string in
89 + /// one without escaping it first.
90 + pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
91 + if let Ok(mut fills) = self.fills.lock() {
92 + fills.insert(slot_id.into(), markup.into());
93 + }
94 + }
95 +
96 + /// Everything the handler drew, for the renderer to mount.
97 + pub fn drawn(&self) -> std::collections::HashMap<String, String> {
98 + self.fills.lock().map(|f| f.clone()).unwrap_or_default()
99 + }
66 100 }
67 101
68 102 /// Build the state factory the adapter calls per request.
@@ -87,7 +121,12 @@
87 121 return Err(quasi_router::RouteError::internal("no session layer"));
88 122 };
89 123 match crate::auth::authenticate(&session, &app).await {
90 - Ok(user) => Ok(Viewer { app, user, runtime }),
124 + Ok(user) => Ok(Viewer {
125 + app,
126 + user,
127 + runtime,
128 + fills: std::sync::Mutex::default(),
129 + }),
91 130 Err(_) => Err(quasi_router::RouteError::denied("sign in to continue")),
92 131 }
93 132 })
@@ -134,6 +173,12 @@
134 173 ),
135 174 ));
136 175 }
176 + if described(app, user_analytics::SCREEN) {
177 + mounted.push((
178 + user_analytics::PATH,
179 + mount(app, user_analytics::screen, user_analytics::renderer),
180 + ));
181 + }
137 182 if described(app, forum_memberships::SETTINGS_SCREEN) {
138 183 mounted.push((
139 184 forum_memberships::SETTINGS_PATH,
@@ -26,7 +26,11 @@
26 26 }
27 27
28 28 /// Convert time-series buckets into chart bar view models.
29 - pub(super) fn build_chart_bars(
29 + ///
30 + /// `pub(crate)` since `crate::quasi::user_analytics` draws the same chart: the
31 + /// description layer has no word for one, so the described screen emits the
32 + /// same markup from the same bars rather than computing its own.
33 + pub(crate) fn build_chart_bars(
30 34 buckets: &[db::analytics::TimeBucket],
31 35 currency: crate::currency::SettlementCurrency,
32 36 ) -> Vec<ChartBar> {
@@ -72,10 +76,6 @@
72 76 get(tabs::dashboard_tab_projects),
73 77 )
74 78 .route_get("/dashboard/tabs/creator", get(tabs::dashboard_tab_creator))
75 - .route_get(
76 - "/dashboard/tabs/analytics",
77 - get(tabs::dashboard_tab_analytics),
78 - )
79 79 .route_get("/dashboard/tabs/synckit", get(tabs::dashboard_tab_synckit))
80 80 .route_get("/dashboard/tabs/media", get(tabs::dashboard_tab_media))
81 81 .route_get("/dashboard/tabs/support", get(tabs::dashboard_tab_support))
@@ -167,6 +167,14 @@
167 167 get(tabs::dashboard_tab_ssh_keys),
168 168 )
169 169 };
170 + let tab_routes = if screens.enabled(crate::quasi::user_analytics::SCREEN) {
171 + tab_routes
172 + } else {
173 + tab_routes.route_get(
174 + crate::quasi::user_analytics::PATH,
175 + get(tabs::dashboard_tab_analytics),
176 + )
177 + };
170 178 let tab_routes = if screens.enabled(crate::quasi::forum_memberships::SETTINGS_SCREEN) {
171 179 tab_routes
172 180 } else {
@@ -1,0 +1,628 @@
1 + //! The user-level analytics tab, described.
2 + //!
3 + //! S4's third batch, and the first screen that is not a table with a heading on
4 + //! it. Five figures with deltas, a range selector, a bar chart, a comparison
5 + //! table and a list, which is why it was taken: it is where the vocabulary
6 + //! stops covering the dashboard, and the only way to find that out is to
7 + //! convert one.
8 + //!
9 + //! Compare `routes::pages::dashboard::tabs::user::dashboard_tab_analytics`,
10 + //! which answers the same address from Askama when the screen is switched off.
11 + //!
12 + //! # What it found, and what each answer cost
13 + //!
14 + //! Three gaps, and the count decided all three differently. That is the method
15 + //! working rather than three separate judgement calls.
16 + //!
17 + //! 1. **A figure had no delta.** Four screens here put a label, a value and a
18 + //! change in one stat card, and the change is the toned part while the
19 + //! number is an ordinary fact, so `Figure::tone` had no consumer at all. Four
20 + //! sites is below the 30-to-53 the earlier table members cleared, and it
21 + //! landed anyway because the alternative was folding the delta into the
22 + //! caption, which loses the tone and turns a small second line into a longer
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::Bespoke`] fill, which is
28 + //! exactly what bespoke regions are for, and it is the first one in the
29 + //! tree. See [`chart_markup`].
30 + //! 3. **A proportion bar inside a table cell is one site.** The comparison
31 + //! table's revenue cell draws a bar behind the number. One site across every
32 + //! template, so it does not earn a `Cell::meter` and the cell is the number
33 + //! alone. **That is a visible loss** and it is recorded rather than hidden:
34 + //! the reader keeps every figure and loses the at-a-glance comparison
35 + //! between rows. If a second site appears, the member is earned and this
36 + //! comes back.
37 + //!
38 + //! # The range selector is four chips, not a segmented control
39 + //!
40 + //! Nothing names a segmented control and it does not need to: four chips, one
41 + //! latched, is what the affordance is, and `latched` is already makeover's word
42 + //! for a held-down chip. Each carries the range it selects, so the address a
43 + //! reader lands on is the view they are looking at.
44 +
45 + use std::fmt::Write as _;
46 +
47 + use makeover_layout as layout;
48 + use quasi_router::screen::{Cell, Cells, Column, Figure, Row, Tag};
49 + use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
50 + use quasi_webview::{Shell, Webview};
51 +
52 + use super::Viewer;
53 + use crate::db;
54 +
55 + /// The conversion switch's name for this screen. `QUASI_SCREENS=user_analytics`.
56 + pub const SCREEN: &str = "user_analytics";
57 +
58 + /// The address this screen answers, and the one the Askama route gives up.
59 + pub const PATH: &str = "/dashboard/tabs/analytics";
60 +
61 + /// The region the answer replaces: the pane the dashboard's tab nav targets.
62 + const REGION: &str = "tab-content";
63 +
64 + /// The slot the chart's own markup mounts into.
65 + const CHART_SLOT: &str = "analytics-chart";
66 +
67 + /// The ranges offered, in the order the selector draws them.
68 + const RANGES: [(&str, &str); 4] = [
69 + ("7d", "Last 7 days"),
70 + ("30d", "Last 30 days"),
71 + ("90d", "Last 90 days"),
72 + ("all", "All time"),
73 + ];
74 +
75 + /// One stat card, as the screen needs it.
76 + pub struct StatView {
77 + label: String,
78 + value: String,
79 + change: Option<String>,
80 + positive: bool,
81 + }
82 +
83 + /// One bar of the revenue chart.
84 + pub struct BarView {
85 + label: String,
86 + value: String,
87 + count: i64,
88 + height_pct: f64,
89 + }
90 +
91 + /// One project's row in the comparison table.
92 + pub struct ProjectView {
93 + title: String,
94 + revenue: String,
95 + sales: String,
96 + views: String,
97 + conversion: String,
98 + }
99 +
100 + /// One project's line in the all-time revenue list.
101 + pub struct TotalView {
102 + title: String,
103 + revenue: String,
104 + }
105 +
106 + /// Everything one answer needs, so the assembly can be tested without a
107 + /// database.
108 + pub struct Analytics {
109 + range: String,
110 + stats: Vec<StatView>,
111 + bars: Vec<BarView>,
112 + projects: Vec<ProjectView>,
113 + totals: Vec<TotalView>,
114 + }
115 +
116 + /// The tab.
117 + pub fn screen(viewer: &Viewer, request: Request) -> Result<Response, RouteError> {
118 + // The selector's chips carry the range they select, and a read's values
119 + // land in `carried`. An unknown or absent one falls back the way the Askama
120 + // handler's `parse().ok().unwrap_or` does rather than refusing: a range is
121 + // a view, and a bad view is not an error worth a page.
122 + // Taken by value because the handler signature is quasi's, so the request is
123 + // consumed here rather than borrowed from.
124 + let carried = request.carried;
125 + let range = carried
126 + .get("range")
127 + .and_then(|r| r.parse::<db::analytics::TimeRange>().ok())
128 + .unwrap_or(db::analytics::TimeRange::Days30);
129 +
130 + let analytics = read(viewer, &range)?;
131 +
132 + // Handed to the renderer through the state both of them see. The chart is
133 + // the app's own markup and there is no node for it; see the module header.
134 + viewer.fill(CHART_SLOT, chart_markup(&analytics.bars));
135 +
136 + Ok(Response::fragment(REGION, pane(&analytics)))
137 + }
138 +
139 + /// Everything the screen reads, in the order the Askama handler reads it.
140 + fn read(viewer: &Viewer, range: &db::analytics::TimeRange) -> Result<Analytics, RouteError> {
141 + let db = &viewer.app.db;
142 + let user_id = viewer.user.id;
143 + let currency = viewer.user.settlement_currency;
144 + let failed = |_| RouteError::internal("your analytics could not be read");
145 +
146 + let buckets = viewer
147 + .block_on(db::analytics::get_revenue_timeseries(
148 + db, user_id, None, None, range,
149 + ))
150 + .map_err(failed)?;
151 + let comparison = viewer
152 + .block_on(db::analytics::get_period_comparison(
153 + db, user_id, None, None, range,
154 + ))
155 + .map_err(failed)?;
156 + let (current_views, prev_views) = viewer
157 + .block_on(db::page_views::get_view_period_comparison(
158 + db, user_id, None, range,
159 + ))
160 + .map_err(failed)?;
161 +
162 + let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency)
163 + .into_iter()
164 + .map(|bar| BarView {
165 + label: bar.label,
166 + value: bar.value,
167 + count: bar.count,
168 + height_pct: bar.height_pct,
169 + })
170 + .collect();
171 +
172 + let view_change = db::analytics::pct_change(current_views, prev_views);
173 + let mut stats = vec![
174 + StatView {
175 + label: "Views".into(),
176 + value: current_views.to_string(),
177 + change: view_change.as_ref().map(|(text, _)| text.clone()),
178 + positive: view_change.is_none_or(|(_, up)| up),
179 + },
180 + StatView {
181 + label: "Revenue".into(),
182 + value: crate::formatting::format_revenue(
183 + comparison.current_revenue_cents.as_i64(),
184 + currency,
185 + ),
186 + change: comparison.revenue_change().map(|(text, _)| text),
187 + positive: comparison.revenue_change().is_none_or(|(_, up)| up),
188 + },
189 + StatView {
190 + label: "Sales".into(),
191 + value: comparison.current_sales.to_string(),
192 + change: comparison.sales_change().map(|(text, _)| text),
193 + positive: comparison.sales_change().is_none_or(|(_, up)| up),
194 + },
195 + StatView {
196 + label: "Followers".into(),
197 + value: comparison.current_followers.to_string(),
198 + change: comparison.followers_change().map(|(text, _)| text),
199 + positive: comparison.followers_change().is_none_or(|(_, up)| up),
200 + },
201 + ];
202 + // Conversion needs a denominator. The Askama version omits the card rather
203 + // than showing a dash where a percentage goes.
204 + if current_views > 0 {
205 + stats.push(StatView {
206 + label: "Conversion".into(),
207 + value: format!(
208 + "{:.1}%",
209 + comparison.current_sales as f64 / current_views as f64 * 100.0
210 + ),
211 + change: None,
212 + positive: true,
213 + });
214 + }
215 +
216 + let project_data = viewer
217 + .block_on(db::transactions::get_revenue_by_user_projects_in_range(
218 + db, user_id, range,
219 + ))
220 + .map_err(failed)?;
221 + let project_views = viewer
222 + .block_on(db::page_views::get_views_by_seller_projects(
223 + db, user_id, range,
224 + ))
225 + .map_err(failed)?;
226 + let projects = project_data
227 + .iter()
228 + .map(|(id, title, revenue, sales)| {
229 + let views = project_views
230 + .iter()
231 + .find(|(other, _)| other == id)
232 + .map_or(0, |(_, seen)| *seen);
233 + ProjectView {
234 + title: title.clone(),
235 + revenue: revenue.display(currency),
236 + sales: sales.to_string(),
237 + views: views.to_string(),
238 + conversion: if views > 0 {
239 + format!("{:.1}%", *sales as f64 / views as f64 * 100.0)
240 + } else {
241 + "-".to_owned()
242 + },
243 + }
244 + })
245 + .collect();
246 +
247 + let totals = viewer
248 + .block_on(db::transactions::get_revenue_by_user_projects(db, user_id))
249 + .map_err(failed)?
250 + .into_iter()
251 + .map(|(_, title, revenue)| TotalView {
252 + title,
253 + revenue: revenue.display(currency),
254 + })
255 + .collect();
256 +
257 + Ok(Analytics {
258 + range: range.to_string(),
259 + stats,
260 + bars,
261 + projects,
262 + totals,
263 + })
264 + }
265 +
266 + /// Everything inside the tab pane.
267 + fn pane(analytics: &Analytics) -> Node {
268 + let mut slot =
269 + Slot::new(REGION, RegionKind::Pane).with(Node::section(range_heading(&analytics.range)));
270 +
271 + for chip in range_chips(&analytics.range) {
272 + slot = slot.with(chip);
273 + }
274 +
275 + slot = slot.with(stats(&analytics.stats));
276 +
277 + slot = slot.with(Node::section("Revenue Over Time"));
278 + slot = if analytics.bars.is_empty() {
279 + slot.with(Node::empty(
280 + "Once you publish items and make sales, revenue data will appear here.",
281 + ))
282 + } else {
283 + // The chart's own markup arrives through the renderer. The description
284 + // says only that there is a region here and what it is called.
285 + slot.with(Node::Region(Slot::bespoke(CHART_SLOT, "revenue-chart")))
286 + };
287 +
288 + // One project is not a comparison, which is the condition the template
289 + // wraps this whole section in.
290 + if analytics.projects.len() > 1 {
291 + slot = slot
292 + .with(Node::section("Project Comparison"))
293 + .with(comparison(&analytics.projects));
294 + }
295 +
296 + slot = slot.with(Node::section("Top Projects by Revenue"));
297 + slot = if analytics.totals.is_empty() {
298 + slot.with(Node::empty(
299 + "No revenue data yet. Sales across your projects will appear here.",
300 + ))
301 + } else {
302 + slot.with(totals(&analytics.totals))
303 + };
304 +
305 + Node::Region(slot)
306 + }
307 +
308 + /// What the current range is called.
309 + fn range_heading(range: &str) -> &'static str {
310 + RANGES
311 + .iter()
312 + .find(|(value, _)| *value == range)
313 + .map_or("All time", |(_, name)| *name)
314 + }
315 +
316 + /// The range selector: one chip per range, the current one held down.
317 + fn range_chips(range: &str) -> Vec<Node> {
318 + RANGES
319 + .iter()
320 + .map(|(value, _)| {
321 + Node::Token(
322 + Tag::chip(*value, Action::get(PATH).carrying("range", *value))
323 + .latched(*value == range),
324 + )
325 + })
326 + .collect()
327 + }
328 +
329 + /// The figures across the top.
330 + fn stats(stats: &[StatView]) -> Node {
331 + Node::Stats {
332 + figures: stats
333 + .iter()
334 + .map(|stat| {
335 + let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
336 + // The tone rides on the delta, which is why a card without one
337 + // stays neutral rather than being coloured green for having
338 + // nothing to report. `positive` is `true` by default in the
339 + // source data, so toning on it alone would paint every
340 + // unchanged card.
341 + if let Some(change) = &stat.change {
342 + figure = figure.change(change.clone()).tone(if stat.positive {
343 + layout::Tone::Success
344 + } else {
345 + layout::Tone::Danger
346 + });
347 + }
348 + (figure, None)
349 + })
350 + .collect(),
351 + }
352 + }
353 +
354 + /// The per-project comparison.
355 + fn comparison(projects: &[ProjectView]) -> Node {
356 + Node::Table {
357 + columns: vec![
358 + Column::new("Project")
359 + .width(layout::Width::Fill)
360 + .priority(layout::Priority::Essential),
361 + Column::new("Revenue")
362 + .width(layout::Width::Content)
363 + .priority(layout::Priority::Essential),
364 + Column::new("Sales").width(layout::Width::Content),
365 + Column::new("Views").width(layout::Width::Content),
366 + Column::new("Conversion")
367 + .width(layout::Width::Content)
368 + .priority(layout::Priority::Optional),
369 + ],
370 + rows: projects
371 + .iter()
372 + .map(|project| {
373 + Cells::new([
374 + Cell::new(project.title.clone()),
375 + // The number alone. The template draws a bar behind it
376 + // scaled against the biggest earner, and that is one site
377 + // in the whole template set, so it does not earn a member.
378 + // See the module header.
379 + Cell::new(project.revenue.clone()),
380 + Cell::new(project.sales.clone()),
381 + Cell::new(project.views.clone()),
382 + Cell::new(project.conversion.clone()),
383 + ])
384 + })
385 + .collect(),
386 + }
387 + }
388 +
389 + /// All-time revenue per project.
390 + fn totals(totals: &[TotalView]) -> Node {
391 + Node::List {
392 + rows: totals
393 + .iter()
394 + .map(|total| Row::new(total.title.clone()).meta(total.revenue.clone()))
395 + .collect(),
396 + more: None,
397 + }
398 + }
399 +
400 + /// The chart, as markup, because no description names one.
401 + ///
402 + /// Byte-for-byte the structure `templates/partials/chart_bars.html` emits, so
403 + /// the existing `.chart-*` rules in `style.css` draw it unchanged and the
404 + /// described screen and the Askama one are the same chart rather than two that
405 + /// drifted.
406 + ///
407 + /// Everything interpolated here is escaped. A bespoke region is not escaped by
408 + /// the renderer, which is what makes it bespoke, so the escaping is this
409 + /// function's job and a label reaching it from a database is exactly why.
410 + fn chart_markup(bars: &[BarView]) -> String {
411 + let mut html = String::from("<div class=\"chart-bars\">");
412 + for bar in bars {
413 + let plural = if bar.count == 1 { "" } else { "s" };
414 + let _ = write!(
415 + html,
416 + "<div class=\"chart-bar-col\" data-tooltip=\"{} / {} sale{plural}\">\
417 + <div class=\"chart-bar\" style=\"--fill: {}%;\"></div>\
418 + <div class=\"chart-bar-label\">{}</div></div>",
419 + escape(&bar.value),
420 + bar.count,
421 + // A float straight from the database, so it is formatted rather
422 + // than printed: `{:?}` on an f64 can emit an exponent, and
423 + // `--fill: 1e-7%` is not a length any browser accepts.
424 + format_args!("{:.4}", bar.height_pct),
425 + escape(&bar.label),
426 + );
427 + }
428 + html.push_str("</div>");
429 + html
430 + }
431 +
432 + /// The five characters that matter in markup and in an attribute value.
433 + fn escape(text: &str) -> String {
434 + text.replace('&', "&amp;")
435 + .replace('<', "&lt;")
436 + .replace('>', "&gt;")
437 + .replace('"', "&quot;")
438 + .replace('\'', "&#39;")
439 + }
440 +
441 + /// The renderer this screen is drawn with.
442 + ///
443 + /// Mounts whatever the handler drew for its bespoke regions. The two see one
444 + /// `Viewer`; see [`super::Viewer::fills`].
445 + pub fn renderer(viewer: &Viewer) -> Webview {
446 + let mut webview = Webview::new().with_shell(Shell::under("/static").layered([
447 + "base",
448 + "components",
449 + "responsive",
450 + ]));
451 + for (slot, markup) in viewer.drawn() {
452 + webview = webview.with_fill(slot, markup);
453 + }
454 + webview
455 + }
456 +
457 + #[cfg(test)]
458 + mod tests {
459 + use super::*;
460 + use quasi_axum::Render;
461 +
462 + fn analytics() -> Analytics {
463 + Analytics {
464 + range: "30d".into(),
465 + stats: vec![
466 + StatView {
467 + label: "Views".into(),
468 + value: "1,204".into(),
469 + change: Some("+12.5%".into()),
470 + positive: true,
471 + },
472 + StatView {
473 + label: "Conversion".into(),
474 + value: "3.1%".into(),
475 + change: None,
476 + positive: true,
477 + },
478 + ],
479 + bars: vec![BarView {
480 + label: "Aug 1".into(),
481 + value: "$42.00".into(),
482 + count: 3,
483 + height_pct: 62.5,
484 + }],
485 + projects: vec![
486 + ProjectView {
487 + title: "Atlas".into(),
488 + revenue: "$120.00".into(),
489 + sales: "4".into(),
490 + views: "300".into(),
491 + conversion: "1.3%".into(),
492 + },
493 + ProjectView {
494 + title: "Beacon".into(),
495 + revenue: "$60.00".into(),
496 + sales: "2".into(),
497 + views: "150".into(),
498 + conversion: "1.3%".into(),
499 + },
500 + ],
Lines truncated