Skip to main content

max / makenotwork

12.1 KB · 335 lines History Blame Raw
1 //! The project dashboard's Analytics panel, described.
2 //!
3 //! Fifth of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab
4 //! inventory"): 64 lines, one `{% include %}`, no `data-action`, no
5 //! `data-after`, no writes, and nothing in `static/` or `frontend/src` reaches
6 //! for any id it writes.
7 //!
8 //! # The project-scoped twin of [`super::user_analytics`]
9 //!
10 //! Same shape, same range chips, same chart, one project rather than all of
11 //! them. What is deliberately NOT shared is the code: the two screens answer
12 //! different questions of the same person, their columns agree today by
13 //! coincidence, and a shared helper would make the next divergence a merge
14 //! conflict instead of an edit. That is the ruling `buyer_contacts` made about
15 //! its table against `library_contacts` and it applies unchanged here.
16 //!
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`).
25 //!
26 //! # A fill, and for neither of the batch's two usual reasons
27 //!
28 //! `project_tab_analytics` has no ETag (it takes a `range` query param, and a
29 //! narrowed panel is a different body under the same tag) and no inline path
30 //! (the project page fills Content, SyncKit and Overview; Analytics is always
31 //! fetched). So unlike the rest of the batch it could have been mounted.
32 //!
33 //! It is a fill anyway, and the reason is the address: `super::mount` nests a
34 //! screen at a fixed path, and this one is
35 //! `/dashboard/project/{slug}/tabs/analytics`. No mounted screen in the tree
36 //! carries a path parameter and making this the first is a change to `mount`
37 //! rather than a conversion. Filed as a thing to know rather than done here.
38 //!
39 //! # The chart is a bespoke region, and a fill fills it differently
40 //!
41 //! `user_analytics` writes its chart markup onto the `Viewer` and the renderer
42 //! reads it back, which is the seam a mounted screen has. A fill has no
43 //! `Viewer`, so the markup is attached to a local `Webview` with
44 //! [`quasi_webview::Webview::with_fill`] instead. Same mechanism, one fewer
45 //! hop, and the reason it is worth a paragraph is that the two look different
46 //! for no reason a reader could otherwise guess.
47
48 use makeover_layout as layout;
49 use quasi_declare::declare;
50 use quasi_router::screen::{Bar, Chart, Figure};
51 use quasi_router::{Node, RegionKind, Slot};
52 use quasi_webview::Webview;
53
54 use crate::types::{ContentItem, RevenueChart, StatCard};
55
56 /// The region the answer replaces, keeping the id the page already used.
57 pub const REGION: &str = "project-analytics";
58
59 /// The bespoke region the chart is drawn into.
60 /// The panel as the route answers it: the region, carrying its own id.
61 #[must_use]
62 pub fn fragment(
63 slug: &str,
64 range: &str,
65 stats: &[StatCard],
66 chart: &RevenueChart,
67 items: &[ContentItem],
68 ) -> String {
69 use quasi_axum::Serves as _;
70
71 let mut slot = Slot::new(REGION, RegionKind::Pane);
72 for node in body(slug, range, stats, chart, items) {
73 slot = slot.with(node);
74 }
75 Webview::new().fragment(&Node::Region(slot))
76 }
77
78 declare! {
79 /// The panel's contents, in order.
80 ///
81 /// The chart is a bespoke region: the description says only that there is
82 /// one here and what it is called, and [`drawn`] puts the markup in it.
83 shape body(
84 slug: &str,
85 range: &str,
86 stats: &[StatCard],
87 chart: &RevenueChart,
88 items: &[ContentItem],
89 ) -> Vec<Node>;
90
91 link "Docs: Analytics" to get "/docs/analytics" navigating;
92 link "Export data" to get "/dashboard/export" navigating;
93 section super::range_heading(range);
94
95 for chip in range_chips(slug, range) {
96 include chip;
97 }
98
99 include figures(stats);
100
101 section "Revenue Over Time";
102 empty "No revenue data yet. Revenue will appear here after your first sale. \
103 Publish an item and share it to get started."
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 }
113
114 section "Top Performing Items";
115 empty "No sales data yet. Publish and promote your items to see analytics here."
116 when items.is_empty();
117 include top_items(items) unless items.is_empty();
118 }
119 declare! {
120 /// The four range controls.
121 ///
122 /// Chips rather than acts, and latched rather than carrying an
123 /// `is-selected` class: which window is showing is a fact about the control,
124 /// so the renderer draws the pressed state from the description instead of
125 /// the template composing a class name.
126 shape range_chips(slug: &str, range: &str) -> Vec<Node>;
127
128 for window in super::RANGES {
129 chip window.value
130 to get "/dashboard/project/{slug}/tabs/analytics" carrying "range" window.value {
131 latched when super::is_shown(window, range);
132 }
133 }
134 }
135
136 /// What the delta reads, or nothing.
137 fn change(stat: &StatCard) -> &str {
138 stat.change.as_deref().unwrap_or_default()
139 }
140
141 /// The tone rides on the delta, so a card with nothing to report stays neutral
142 /// rather than going green for having no news.
143 fn delta_tone(stat: &StatCard) -> layout::Tone {
144 if stat.is_positive {
145 layout::Tone::Success
146 } else {
147 layout::Tone::Danger
148 }
149 }
150
151 declare! {
152 /// The figures across the top.
153 ///
154 /// Toned the way `super::user_analytics::stats` is: see [`delta_tone`].
155 ///
156 /// The empty list is what the figures accrete onto. `Node::stats` takes the
157 /// whole list and this one is built a card at a time, so `Node::figure` is
158 /// the accreting half, on the rule that closed `Table::column`,
159 /// `Row::cell` and `Field::options` before it.
160 shape figures(stats: &[StatCard]) -> Node;
161
162 stats [] {
163 for stat in stats.iter() {
164 figure Figure::new(stat.value.clone(), stat.label.clone())
165 when stat.change.is_none();
166 figure Figure::new(stat.value.clone(), stat.label.clone())
167 .change(change(stat))
168 .tone(delta_tone(stat))
169 unless stat.change.is_none();
170 }
171 }
172 }
173
174 declare! {
175 /// What sold.
176 ///
177 /// A table rather than the template's `<ul>` of two `<span>`s. The two
178 /// columns were already a table pretending not to be, and a described one
179 /// gets its header row back.
180 ///
181 /// The cells stay positional rather than naming their columns. Naming exists
182 /// to stop a cell list drifting from a column list it cannot see, and here
183 /// the two lists are one declaration: every row is the same two cells,
184 /// nothing is conditional, and a reader checking the order reads eight lines
185 /// to do it.
186 shape top_items(items: &[ContentItem]) -> Node;
187
188 table {
189 column "Item" {
190 width Fill;
191 priority Essential;
192 }
193 column "Revenue" {
194 width Content;
195 }
196
197 for item in items.iter() {
198 cells {
199 cell item.title.clone();
200 cell item.revenue.clone();
201 }
202 }
203 }
204 }
205
206 #[cfg(test)]
207 mod tests {
208 use super::*;
209 use crate::types::ChartBar;
210 use quasi_axum::Serves;
211
212 fn stat(label: &str, change: Option<&str>) -> StatCard {
213 StatCard {
214 label: label.into(),
215 value: "$42".into(),
216 change: change.map(Into::into),
217 is_positive: true,
218 }
219 }
220
221 fn bar() -> ChartBar {
222 ChartBar {
223 label: "Aug 1".into(),
224 cents: 1200,
225 value: "$12".into(),
226 count: 3,
227 }
228 }
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
239 fn render(range: &str, bars: &[ChartBar], items: &[ContentItem]) -> String {
240 let chart = chart_of(bars);
241 let nodes = body("an-album", range, &[stat("Revenue", None)], &chart, items);
242 let mut out = String::new();
243 let webview = Webview::new();
244 for node in &nodes {
245 out.push_str(&webview.fragment(node));
246 }
247 out
248 }
249
250 #[test]
251 fn the_heading_names_the_window_and_falls_back_to_all_time() {
252 assert_eq!(super::super::range_heading("7d"), "Last 7 days");
253 assert_eq!(super::super::range_heading("90d"), "Last 90 days");
254 // What the template's `{% else %}` did for anything unrecognised.
255 assert_eq!(super::super::range_heading("nonsense"), "All time");
256 }
257
258 #[test]
259 fn the_showing_range_is_latched_and_the_others_are_not() {
260 let html = render("30d", &[bar()], &[]);
261
262 // One chip is latched. The template composed `is-selected` into a class
263 // string; the renderer draws it from the description now, as
264 // `aria-current` plus a class rather than as a pressed state -- a chip
265 // that navigates is a current-page marker, not a toggle.
266 assert_eq!(html.matches("aria-current=\"true\"").count(), 1, "{html}");
267 assert_eq!(html.matches("class=\"chip latched\"").count(), 1, "{html}");
268 assert_eq!(html.matches("class=\"chip\"").count(), 3, "{html}");
269 }
270
271 #[test]
272 fn every_range_addresses_this_projects_own_analytics() {
273 let html = render("30d", &[bar()], &[]);
274
275 for value in ["7d", "30d", "90d", "all"] {
276 assert!(
277 html.contains(&format!("range={value}")),
278 "missing {value}: {html}"
279 );
280 }
281 assert!(
282 html.contains("/dashboard/project/an-album/tabs/analytics"),
283 "{html}"
284 );
285 }
286
287 #[test]
288 fn the_chart_is_drawn_when_there_are_bars_and_stood_in_for_when_there_are_not() {
289 let with = render("30d", &[bar()], &[]);
290 let without = render("30d", &[], &[]);
291
292 assert!(with.contains("class=\"chart-bars\""), "{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}");
299
300 assert!(!without.contains("class=\"chart-bars\""), "{without}");
301 assert!(without.contains("No revenue data yet."), "{without}");
302 }
303
304 #[test]
305 fn the_chart_is_the_same_one_user_analytics_draws() {
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}");
316 }
317
318 #[test]
319 fn a_bar_label_cannot_smuggle_markup() {
320 let hostile = ChartBar {
321 label: "<script>x()</script>".into(),
322 ..bar()
323 };
324 let html = render("30d", &[hostile], &[]);
325 assert!(!html.contains("<script>x()"), "{html}");
326 }
327
328 #[test]
329 fn an_empty_top_items_says_so_rather_than_drawing_an_empty_table() {
330 let html = render("30d", &[bar()], &[]);
331 assert!(html.contains("No sales data yet."), "{html}");
332 assert!(!html.contains("role=\"table\""), "{html}");
333 }
334 }
335