//! The project dashboard's Analytics panel, described. //! //! Fifth of the tier-1 batch (wiki `mnw-server-conversion-plan`, "The S4 tab //! inventory"): 64 lines, one `{% include %}`, no `data-action`, no //! `data-after`, no writes, and nothing in `static/` or `frontend/src` reaches //! for any id it writes. //! //! # The project-scoped twin of [`super::user_analytics`] //! //! Same shape, same range chips, same chart, one project rather than all of //! them. What is deliberately NOT shared is the code: the two screens answer //! different questions of the same person, their columns agree today by //! coincidence, and a shared helper would make the next divergence a merge //! conflict instead of an edit. That is the ruling `buyer_contacts` made about //! its table against `library_contacts` and it applies unchanged here. //! //! The chart is the exception, and it is shared by the vocabulary rather than //! by code: both tabs say `chart`, one renderer draws it, and //! `makeover-webview` emits the rules. A chart drawn two ways is two charts //! that drift, which is the whole reason //! `templates/partials/chart_bars.html` existed when three tabs had //! byte-identical copies of it. This used to share a markup-building function //! with `user_analytics` for the same reason; the member replaced it on //! 2026-09-08 (quasicoherent `7d6ad166`). //! //! # A fill, and for neither of the batch's two usual reasons //! //! `project_tab_analytics` has no ETag (it takes a `range` query param, and a //! narrowed panel is a different body under the same tag) and no inline path //! (the project page fills Content, SyncKit and Overview; Analytics is always //! fetched). So unlike the rest of the batch it could have been mounted. //! //! It is a fill anyway, and the reason is the address: `super::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. Filed as a thing to know rather than done here. //! //! # The chart is a bespoke region, and a fill fills it differently //! //! `user_analytics` writes its chart markup onto the `Viewer` and the renderer //! reads it back, which is the seam a mounted screen has. A fill has no //! `Viewer`, so the markup is attached to a local `Webview` with //! [`quasi_webview::Webview::with_fill`] instead. Same mechanism, one fewer //! hop, and the reason it is worth a paragraph is that the two look different //! for no reason a reader could otherwise guess. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::screen::{Bar, Chart, Figure}; use quasi_router::{Node, RegionKind, Slot}; use quasi_webview::Webview; use crate::types::{ContentItem, RevenueChart, StatCard}; /// The region the answer replaces, keeping the id the page already used. pub const REGION: &str = "project-analytics"; /// The bespoke region the chart is drawn into. /// The panel as the route answers it: the region, carrying its own id. #[must_use] pub fn fragment( slug: &str, range: &str, stats: &[StatCard], chart: &RevenueChart, items: &[ContentItem], ) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(slug, range, stats, chart, items) { slot = slot.with(node); } Webview::new().fragment(&Node::Region(slot)) } declare! { /// The panel's contents, in order. /// /// The chart is a bespoke region: the description says only that there is /// one here and what it is called, and [`drawn`] puts the markup in it. shape body( slug: &str, range: &str, stats: &[StatCard], chart: &RevenueChart, items: &[ContentItem], ) -> Vec; link "Docs: Analytics" to get "/docs/analytics" navigating; link "Export data" to get "/dashboard/export" navigating; section super::range_heading(range); for chip in range_chips(slug, range) { include chip; } include figures(stats); section "Revenue Over Time"; empty "No revenue data yet. Revenue will appear here after your first sale. \ Publish an item and share it to get started." when chart.bars.is_empty(); chart Chart::new(chart.most).label("revenue over time") unless chart.bars.is_empty() { for bar in chart.bars.iter() { bar Bar::at(bar.label.clone()) .of(bar.cents) .reading(bar.value.clone()) .note(super::user_analytics::sales(bar.count)); } } section "Top Performing Items"; empty "No sales data yet. Publish and promote your items to see analytics here." when items.is_empty(); include top_items(items) unless items.is_empty(); } declare! { /// The four range controls. /// /// Chips rather than acts, and latched rather than carrying an /// `is-selected` class: which window is showing is a fact about the control, /// so the renderer draws the pressed state from the description instead of /// the template composing a class name. shape range_chips(slug: &str, range: &str) -> Vec; for window in super::RANGES { chip window.value to get "/dashboard/project/{slug}/tabs/analytics" carrying "range" window.value { latched when super::is_shown(window, range); } } } /// What the delta reads, or nothing. fn change(stat: &StatCard) -> &str { stat.change.as_deref().unwrap_or_default() } /// The tone rides on the delta, so a card with nothing to report stays neutral /// rather than going green for having no news. fn delta_tone(stat: &StatCard) -> layout::Tone { if stat.is_positive { layout::Tone::Success } else { layout::Tone::Danger } } declare! { /// The figures across the top. /// /// Toned the way `super::user_analytics::stats` is: see [`delta_tone`]. /// /// The empty list is what the figures accrete onto. `Node::stats` takes the /// whole list and this one is built a card at a time, so `Node::figure` is /// the accreting half, on the rule that closed `Table::column`, /// `Row::cell` and `Field::options` before it. shape figures(stats: &[StatCard]) -> Node; stats [] { for stat in stats.iter() { figure Figure::new(stat.value.clone(), stat.label.clone()) when stat.change.is_none(); figure Figure::new(stat.value.clone(), stat.label.clone()) .change(change(stat)) .tone(delta_tone(stat)) unless stat.change.is_none(); } } } declare! { /// What sold. /// /// A table rather than the template's `