Skip to main content

max / makenotwork

server: describe the project Analytics panel Fifth of the tier-1 batch. A fill, and for neither of the batch's usual reasons: this route has no ETag and no inline path, so it could have been mounted. What stops it is the address. quasi::mount nests a screen at a fixed path and this one is /dashboard/project/{slug}/tabs/analytics; no mounted screen in the tree carries a path parameter, and making this the first is a change to mount rather than a conversion. The project-scoped twin of user_analytics, and deliberately not sharing its code: the two answer different questions and their columns agree by coincidence, which is the ruling buyer_contacts made about its table against library_contacts. What they do share is the chart, because a chart drawn two ways is two charts that drift, and three byte-identical copies is exactly why chart_bars.html existed. That needed user_analytics::BarView to go: it was a field-for-field copy of types::ChartBar, remapped from what build_chart_bars already returns, and it meant reusing the chart would have taken a third copy of the type. chart_markup takes the shared type now and the remap is gone. A fill has no Viewer, so the chart's bespoke region is filled on a local Webview with with_fill rather than through the Viewer seam a mounted screen uses. Same mechanism, one fewer hop. Top Performing Items was a <ul> of two <span>s, which is a table pretending not to be. Described it is a table and gets its header back. 194 lib tests green, fmt and clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 22:17 UTC
Signed with PGP, not checked
Commit: 74f29aea6f9f0c681e1a76ddddd031d5080ac0c8
Parent: 86624de
8 files changed, +352 insertions, -110 deletions
@@ -48,6 +48,7 @@
48 48 pub mod library_tabs;
49 49 pub mod media_picker;
50 50 pub mod payout_summary;
51 + pub mod project_analytics;
51 52 pub mod project_content;
52 53 pub mod project_overview;
53 54 pub mod project_tabs;
@@ -105,7 +105,7 @@
105 105 },
106 106 Tab {
107 107 label: "Analytics",
108 - panel: "project-analytics",
108 + panel: super::project_analytics::REGION,
109 109 route: "analytics",
110 110 gate: Gate::Always,
111 111 },
@@ -88,14 +88,6 @@
88 88 positive: bool,
89 89 }
90 90
91 - /// One bar of the revenue chart.
92 - pub struct BarView {
93 - label: String,
94 - value: String,
95 - count: i64,
96 - height_pct: f64,
97 - }
98 -
99 91 /// One project's row in the comparison table.
100 92 pub struct ProjectView {
101 93 title: String,
@@ -116,7 +108,7 @@
116 108 pub struct Analytics {
117 109 range: String,
118 110 stats: Vec<StatView>,
119 - bars: Vec<BarView>,
111 + bars: Vec<crate::types::ChartBar>,
120 112 projects: Vec<ProjectView>,
121 113 totals: Vec<TotalView>,
122 114 }
@@ -167,15 +159,11 @@
167 159 ))
168 160 .map_err(failed)?;
169 161
170 - let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency)
171 - .into_iter()
172 - .map(|bar| BarView {
173 - label: bar.label,
174 - value: bar.value,
175 - count: bar.count,
176 - height_pct: bar.height_pct,
177 - })
178 - .collect();
162 + // `build_chart_bars` already produces exactly what the chart draws. This
163 + // remapped it into a private `BarView` with the same four fields, which
164 + // meant `project_analytics` could not reuse the chart without a third copy
165 + // of the type. Dropped 2026-08-26 when that screen converted.
166 + let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency);
179 167
180 168 let view_change = db::analytics::pct_change(current_views, prev_views);
181 169 let mut stats = vec![
@@ -418,7 +406,7 @@
418 406 /// Everything interpolated here is escaped. A bespoke region is not escaped by
419 407 /// the renderer, which is what makes it bespoke, so the escaping is this
420 408 /// function's job and a label reaching it from a database is exactly why.
421 - fn chart_markup(bars: &[BarView]) -> String {
409 + pub(super) fn chart_markup(bars: &[crate::types::ChartBar]) -> String {
422 410 let mut html = String::from("<div class=\"chart-bars\">");
423 411 for bar in bars {
424 412 let plural = if bar.count == 1 { "" } else { "s" };
@@ -483,7 +471,7 @@
483 471 positive: true,
484 472 },
485 473 ],
486 - bars: vec![BarView {
474 + bars: vec![crate::types::ChartBar {
487 475 label: "Aug 1".into(),
488 476 value: "$42.00".into(),
489 477 count: 3,
@@ -579,7 +567,7 @@
579 567
580 568 // The template's own pluralisation, which a described copy is easy to
581 569 // get wrong in exactly one direction.
582 - let one = chart_markup(&[BarView {
570 + let one = chart_markup(&[crate::types::ChartBar {
583 571 label: "Aug 2".into(),
584 572 count: 1,
585 573 ..analytics().bars.pop().expect("one bar")
@@ -593,7 +581,7 @@
593 581 // of what makes it bespoke. A bar's label is a formatted date today and
594 582 // its value comes from the database, so the escaping is this screen's
595 583 // job and nothing else will do it.
596 - let html = chart_markup(&[BarView {
584 + let html = chart_markup(&[crate::types::ChartBar {
597 585 label: "<script>x()</script>".into(),
598 586 value: "\" onload=\"x()".into(),
599 587 count: 1,
@@ -226,7 +226,6 @@
226 226 UserAccountTabTemplate,
227 227 UserPaymentsTabTemplate,
228 228 UserCreatorTabTemplate,
229 - ProjectAnalyticsTabTemplate,
230 229 ProjectSettingsTabTemplate,
231 230 ProjectCodeTabTemplate,
232 231 ProjectSubscriptionsTabTemplate,
@@ -444,17 +444,6 @@
444 444 pub posts: Vec<BlogPostDashboardRow>,
445 445 }
446 446
447 - /// Dashboard tab: project analytics with stats, chart, and top items.
448 - #[derive(Template)]
449 - #[template(path = "partials/tabs/project_analytics.html")]
450 - pub struct ProjectAnalyticsTabTemplate {
451 - pub stats: Vec<StatCard>,
452 - pub bars: Vec<ChartBar>,
453 - pub items: Vec<ContentItem>,
454 - pub project_slug: String,
455 - pub active_range: String,
456 - }
457 -
458 447 /// Dashboard tab: project settings, categories, labels, and features.
459 448 #[derive(Template)]
460 449 #[template(path = "partials/tabs/project_settings.html")]
@@ -14,10 +14,10 @@
14 14 error::{AppError, Result},
15 15 helpers,
16 16 templates::{
17 - LinkedRepoView, ProjectAnalyticsTabTemplate, ProjectCodeTabTemplate,
18 - ProjectContentTabTemplate, ProjectMembersTabTemplate, ProjectMonetizationTabTemplate,
19 - ProjectOverviewTabTemplate, ProjectSettingsTabTemplate, ProjectSubscriptionsTabTemplate,
20 - ProjectSyncKitTabTemplate, RepoCollaboratorView,
17 + LinkedRepoView, ProjectCodeTabTemplate, ProjectContentTabTemplate,
18 + ProjectMembersTabTemplate, ProjectMonetizationTabTemplate, ProjectOverviewTabTemplate,
19 + ProjectSettingsTabTemplate, ProjectSubscriptionsTabTemplate, ProjectSyncKitTabTemplate,
20 + RepoCollaboratorView,
21 21 },
22 22 types::{
23 23 BlogPostDashboardRow, ContentItem, Project, ProjectMemberRow, PromoCodeRow, StatCard,
@@ -392,13 +392,15 @@
392 392 })
393 393 .collect();
394 394
395 - Ok(ProjectAnalyticsTabTemplate {
396 - stats,
397 - bars,
398 - items,
399 - project_slug: db_project.slug.to_string(),
400 - active_range: range.to_string(),
401 - })
395 + Ok(axum::response::Html(
396 + crate::quasi::project_analytics::fragment(
397 + db_project.slug.as_ref(),
398 + &range.to_string(),
399 + &stats,
400 + &bars,
401 + &items,
402 + ),
403 + ))
402 404 }
403 405
404 406 /// Render the HTMX partial for the project settings tab.
@@ -1,0 +1,327 @@
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 + }
@@ -1,64 +1,0 @@
1 - {%- import "partials/_ui.html" as ui -%}
2 - <div class="tab-docs"><a href="/docs/analytics">Docs: Analytics &rarr;</a> <a href="/dashboard/export" class="tab-docs-extra">Export data &rarr;</a></div>
3 -
4 - <div class="analytics-tab-header">
5 - <h2 class="subsection-title">{% if active_range == "7d" %}Last 7 days{% else if active_range == "30d" %}Last 30 days{% else if active_range == "90d" %}Last 90 days{% else %}All time{% endif %}</h2>
6 - <div class="time-selector">
7 - <button class="segment{% if active_range == "7d" %} is-selected{% endif %}"
8 - hx-get="/dashboard/project/{{ project_slug }}/tabs/analytics?range=7d"
9 - hx-target="#project-analytics"
10 - hx-swap="innerHTML">7d</button>
11 - <button class="segment{% if active_range == "30d" %} is-selected{% endif %}"
12 - hx-get="/dashboard/project/{{ project_slug }}/tabs/analytics?range=30d"
13 - hx-target="#project-analytics"
14 - hx-swap="innerHTML">30d</button>
15 - <button class="segment{% if active_range == "90d" %} is-selected{% endif %}"
16 - hx-get="/dashboard/project/{{ project_slug }}/tabs/analytics?range=90d"
17 - hx-target="#project-analytics"
18 - hx-swap="innerHTML">90d</button>
19 - <button class="segment{% if active_range == "all" %} is-selected{% endif %}"
20 - hx-get="/dashboard/project/{{ project_slug }}/tabs/analytics?range=all"
21 - hx-target="#project-analytics"
22 - hx-swap="innerHTML">All</button>
23 - </div>
24 - </div>
25 -
26 - <div class="stats-grid">
27 - {% for stat in stats %}
28 - <div class="card-muted">
29 - <div class="stat-label">{{ stat.label }}</div>
30 - <div class="stat-value">{{ stat.value }}</div>
31 - {% if let Some(change) = stat.change %}
32 - <div class="stat-change{% if stat.is_positive %} positive{% endif %}">{{ change }}</div>
33 - {% endif %}
34 - </div>
35 - {% endfor %}
36 - </div>
37 -
38 - <div class="chart-container">
39 - <div class="chart-header">
40 - <h2 class="subsection-title">Revenue Over Time</h2>
41 - </div>
42 - {% if bars.is_empty() %}
43 - {% call ui::empty_state_chart("No revenue data yet. Revenue will appear here after your first sale. Publish an item and share it to get started.") %}{% endcall %}
44 - {% else %}
45 - {% include "partials/chart_bars.html" %}
46 - {% endif %}
47 - </div>
48 -
49 - <div class="analytics-grid">
50 - <div class="card-muted">
51 - <h3>Top Performing Items</h3>
52 - {% if items.is_empty() %}
53 - <div class="analytics-empty-block">
54 - <p>No sales data yet. Publish and promote your items to see analytics here.</p>
55 - </div>
56 - {% else %}
57 - <ul class="analytics-list">
58 - {% for item in items %}
59 - <li><span>{{ item.title }}</span><span>{{ item.revenue }}</span></li>
60 - {% endfor %}
61 - </ul>
62 - {% endif %}
63 - </div>
64 - </div>