|
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::bespoke(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 |
+ |
}
|