Skip to main content

max / makenotwork

11.2 KB · 328 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 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.
21 //!
22 //! # A fill, and for neither of the batch's two usual reasons
23 //!
24 //! `project_tab_analytics` has no ETag (it takes a `range` query param, and a
25 //! narrowed panel is a different body under the same tag) and no inline path
26 //! (the project page fills Content, SyncKit and Overview; Analytics is always
27 //! fetched). So unlike the rest of the batch it could have been mounted.
28 //!
29 //! It is a fill anyway, and the reason is the address: `super::mount` nests a
30 //! screen at a fixed path, and this one is
31 //! `/dashboard/project/{slug}/tabs/analytics`. No mounted screen in the tree
32 //! carries a path parameter and making this the first is a change to `mount`
33 //! rather than a conversion. Filed as a thing to know rather than done here.
34 //!
35 //! # The chart is a bespoke region, and a fill fills it differently
36 //!
37 //! `user_analytics` writes its chart markup onto the `Viewer` and the renderer
38 //! reads it back, which is the seam a mounted screen has. A fill has no
39 //! `Viewer`, so the markup is attached to a local `Webview` with
40 //! [`quasi_webview::Webview::with_fill`] instead. Same mechanism, one fewer
41 //! hop, and the reason it is worth a paragraph is that the two look different
42 //! for no reason a reader could otherwise guess.
43
44 use makeover_layout as layout;
45 use quasi_router::screen::{Cell, Cells, Column, Figure, Tag};
46 use quasi_router::{Action, Node, RegionKind, Slot};
47 use quasi_webview::Webview;
48
49 use crate::types::{ChartBar, ContentItem, StatCard};
50
51 /// The region the answer replaces, keeping the id the page already used.
52 pub const REGION: &str = "project-analytics";
53
54 /// The bespoke region the chart is drawn into.
55 const CHART_SLOT: &str = "project-revenue-chart";
56
57 /// The four windows the panel offers, and what each is called.
58 const RANGES: &[(&str, &str)] = &[
59 ("7d", "Last 7 days"),
60 ("30d", "Last 30 days"),
61 ("90d", "Last 90 days"),
62 ("all", "All time"),
63 ];
64
65 /// The panel as the route answers it: the region, carrying its own id.
66 #[must_use]
67 pub fn fragment(
68 slug: &str,
69 range: &str,
70 stats: &[StatCard],
71 bars: &[ChartBar],
72 items: &[ContentItem],
73 ) -> String {
74 use quasi_axum::Serves as _;
75
76 let mut slot = Slot::new(REGION, RegionKind::Pane);
77 for node in body(slug, range, stats, bars, items) {
78 slot = slot.with(node);
79 }
80 drawn(bars).fragment(&Node::Region(slot))
81 }
82
83 /// The renderer, carrying whatever the chart needs.
84 ///
85 /// A local `Webview` rather than the `Viewer` seam a mounted screen uses. See
86 /// the module header.
87 fn drawn(bars: &[ChartBar]) -> Webview {
88 let mut webview = Webview::new();
89 if !bars.is_empty() {
90 webview = webview.with_fill(CHART_SLOT, super::user_analytics::chart_markup(bars));
91 }
92 webview
93 }
94
95 /// The panel's contents, in order.
96 fn body(
97 slug: &str,
98 range: &str,
99 stats: &[StatCard],
100 bars: &[ChartBar],
101 items: &[ContentItem],
102 ) -> Vec<Node> {
103 let mut out = vec![
104 Node::Link {
105 text: "Docs: Analytics".into(),
106 action: Action::get("/docs/analytics").navigating(),
107 },
108 Node::Link {
109 text: "Export data".into(),
110 action: Action::get("/dashboard/export").navigating(),
111 },
112 Node::section(heading(range)),
113 ];
114
115 out.extend(range_chips(slug, range));
116 out.push(figures(stats));
117 out.push(Node::section("Revenue Over Time"));
118
119 out.push(if bars.is_empty() {
120 Node::empty(
121 "No revenue data yet. Revenue will appear here after your first sale. \
122 Publish an item and share it to get started.",
123 )
124 } else {
125 // The description says only that there is a region here and what it is
126 // called; `drawn` puts the markup in it.
127 Node::Region(Slot::ceded(CHART_SLOT, "revenue-chart"))
128 });
129
130 out.push(Node::section("Top Performing Items"));
131 out.push(if items.is_empty() {
132 Node::empty("No sales data yet. Publish and promote your items to see analytics here.")
133 } else {
134 top_items(items)
135 });
136
137 out
138 }
139
140 /// What the window is called, as the heading says it.
141 fn heading(range: &str) -> &'static str {
142 RANGES
143 .iter()
144 .find(|(value, _)| *value == range)
145 .map_or("All time", |(_, label)| *label)
146 }
147
148 /// The four range controls.
149 ///
150 /// Chips rather than acts, and latched rather than carrying an `is-selected`
151 /// class: which window is showing is a fact about the control, so the renderer
152 /// draws the pressed state from the description instead of the template
153 /// composing a class name.
154 fn range_chips(slug: &str, range: &str) -> Vec<Node> {
155 RANGES
156 .iter()
157 .map(|(value, _)| {
158 Node::Token(
159 Tag::chip(
160 *value,
161 Action::get(format!("/dashboard/project/{slug}/tabs/analytics"))
162 .carrying("range", *value),
163 )
164 .latched(*value == range),
165 )
166 })
167 .collect()
168 }
169
170 /// The figures across the top.
171 ///
172 /// Toned the way `super::user_analytics::stats` is: the tone rides on the
173 /// delta, so a card with nothing to report stays neutral rather than going
174 /// green for having no news.
175 fn figures(stats: &[StatCard]) -> Node {
176 Node::Stats {
177 figures: stats
178 .iter()
179 .map(|stat| {
180 let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
181 if let Some(change) = &stat.change {
182 figure = figure.change(change.clone()).tone(if stat.is_positive {
183 layout::Tone::Success
184 } else {
185 layout::Tone::Danger
186 });
187 }
188 (figure, None)
189 })
190 .collect(),
191 }
192 }
193
194 /// What sold.
195 ///
196 /// A table rather than the template's `<ul>` of two `<span>`s. The two columns
197 /// were already a table pretending not to be, and a described one gets its
198 /// header row back.
199 fn top_items(items: &[ContentItem]) -> Node {
200 Node::Table {
201 columns: vec![
202 Column::new("Item")
203 .width(layout::Width::Fill)
204 .priority(layout::Priority::Essential),
205 Column::new("Revenue").width(layout::Width::Content),
206 ],
207 rows: items
208 .iter()
209 .map(|item| {
210 Cells::new([
211 Cell::new(item.title.clone()),
212 Cell::new(item.revenue.clone()),
213 ])
214 })
215 .collect(),
216 more: None,
217 }
218 }
219
220 #[cfg(test)]
221 mod tests {
222 use super::*;
223 use quasi_axum::Serves;
224
225 fn stat(label: &str, change: Option<&str>) -> StatCard {
226 StatCard {
227 label: label.into(),
228 value: "$42".into(),
229 change: change.map(Into::into),
230 is_positive: true,
231 }
232 }
233
234 fn bar() -> ChartBar {
235 ChartBar {
236 label: "Aug 1".into(),
237 height_pct: 42.5,
238 value: "$12".into(),
239 count: 3,
240 }
241 }
242
243 fn render(range: &str, bars: &[ChartBar], items: &[ContentItem]) -> String {
244 let nodes = body("an-album", range, &[stat("Revenue", None)], bars, items);
245 let mut out = String::new();
246 let webview = drawn(bars);
247 for node in &nodes {
248 out.push_str(&webview.fragment(node));
249 }
250 out
251 }
252
253 #[test]
254 fn the_heading_names_the_window_and_falls_back_to_all_time() {
255 assert_eq!(heading("7d"), "Last 7 days");
256 assert_eq!(heading("90d"), "Last 90 days");
257 // What the template's `{% else %}` did for anything unrecognised.
258 assert_eq!(heading("nonsense"), "All time");
259 }
260
261 #[test]
262 fn the_showing_range_is_latched_and_the_others_are_not() {
263 let html = render("30d", &[bar()], &[]);
264
265 // One chip is latched. The template composed `is-selected` into a class
266 // string; the renderer draws it from the description now, as
267 // `aria-current` plus a class rather than as a pressed state -- a chip
268 // that navigates is a current-page marker, not a toggle.
269 assert_eq!(html.matches("aria-current=\"true\"").count(), 1, "{html}");
270 assert_eq!(html.matches("class=\"chip latched\"").count(), 1, "{html}");
271 assert_eq!(html.matches("class=\"chip\"").count(), 3, "{html}");
272 }
273
274 #[test]
275 fn every_range_addresses_this_projects_own_analytics() {
276 let html = render("30d", &[bar()], &[]);
277
278 for value in ["7d", "30d", "90d", "all"] {
279 assert!(
280 html.contains(&format!("range={value}")),
281 "missing {value}: {html}"
282 );
283 }
284 assert!(
285 html.contains("/dashboard/project/an-album/tabs/analytics"),
286 "{html}"
287 );
288 }
289
290 #[test]
291 fn the_chart_is_drawn_when_there_are_bars_and_stood_in_for_when_there_are_not() {
292 let with = render("30d", &[bar()], &[]);
293 let without = render("30d", &[], &[]);
294
295 assert!(with.contains("class=\"chart-bars\""), "{with}");
296 assert!(with.contains("--fill: 42.5000%"), "{with}");
297
298 assert!(!without.contains("class=\"chart-bars\""), "{without}");
299 assert!(without.contains("No revenue data yet."), "{without}");
300 }
301
302 #[test]
303 fn the_chart_is_the_same_one_user_analytics_draws() {
304 // Not a second chart. If these ever diverge the two tabs draw different
305 // pictures of the same shape, which is what chart_bars.html existed to
306 // stop and what this conversion must not undo.
307 let shared = super::super::user_analytics::chart_markup(&[bar()]);
308 assert!(render("30d", &[bar()], &[]).contains(&shared));
309 }
310
311 #[test]
312 fn a_bar_label_cannot_smuggle_markup() {
313 let hostile = ChartBar {
314 label: "<script>x()</script>".into(),
315 ..bar()
316 };
317 let html = render("30d", &[hostile], &[]);
318 assert!(!html.contains("<script>x()"), "{html}");
319 }
320
321 #[test]
322 fn an_empty_top_items_says_so_rather_than_drawing_an_empty_table() {
323 let html = render("30d", &[bar()], &[]);
324 assert!(html.contains("No sales data yet."), "{html}");
325 assert!(!html.contains("role=\"table\""), "{html}");
326 }
327 }
328