//! 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 one thing that IS shared is [`super::user_analytics::chart_markup`], //! because a chart drawn two ways is two charts that drift, and the whole //! reason `templates/partials/chart_bars.html` existed was that three tabs had //! byte-identical copies of it. //! //! # 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_router::screen::{Cell, Cells, Column, Figure, Tag}; use quasi_router::{Action, Node, RegionKind, Slot}; use quasi_webview::Webview; use crate::types::{ChartBar, ContentItem, 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. const CHART_SLOT: &str = "project-revenue-chart"; /// The four windows the panel offers, and what each is called. const RANGES: &[(&str, &str)] = &[ ("7d", "Last 7 days"), ("30d", "Last 30 days"), ("90d", "Last 90 days"), ("all", "All time"), ]; /// The panel as the route answers it: the region, carrying its own id. #[must_use] pub fn fragment( slug: &str, range: &str, stats: &[StatCard], bars: &[ChartBar], items: &[ContentItem], ) -> String { use quasi_axum::Serves as _; let mut slot = Slot::new(REGION, RegionKind::Pane); for node in body(slug, range, stats, bars, items) { slot = slot.with(node); } drawn(bars).fragment(&Node::Region(slot)) } /// The renderer, carrying whatever the chart needs. /// /// A local `Webview` rather than the `Viewer` seam a mounted screen uses. See /// the module header. fn drawn(bars: &[ChartBar]) -> Webview { let mut webview = Webview::new(); if !bars.is_empty() { webview = webview.with_fill(CHART_SLOT, super::user_analytics::chart_markup(bars)); } webview } /// The panel's contents, in order. fn body( slug: &str, range: &str, stats: &[StatCard], bars: &[ChartBar], items: &[ContentItem], ) -> Vec { let mut out = vec![ Node::Link { text: "Docs: Analytics".into(), action: Action::get("/docs/analytics").navigating(), }, Node::Link { text: "Export data".into(), action: Action::get("/dashboard/export").navigating(), }, Node::section(heading(range)), ]; out.extend(range_chips(slug, range)); out.push(figures(stats)); out.push(Node::section("Revenue Over Time")); out.push(if bars.is_empty() { Node::empty( "No revenue data yet. Revenue will appear here after your first sale. \ Publish an item and share it to get started.", ) } else { // The description says only that there is a region here and what it is // called; `drawn` puts the markup in it. Node::Region(Slot::ceded(CHART_SLOT, "revenue-chart")) }); out.push(Node::section("Top Performing Items")); out.push(if items.is_empty() { Node::empty("No sales data yet. Publish and promote your items to see analytics here.") } else { top_items(items) }); out } /// What the window is called, as the heading says it. fn heading(range: &str) -> &'static str { RANGES .iter() .find(|(value, _)| *value == range) .map_or("All time", |(_, label)| *label) } /// 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. fn range_chips(slug: &str, range: &str) -> Vec { RANGES .iter() .map(|(value, _)| { Node::Token( Tag::chip( *value, Action::get(format!("/dashboard/project/{slug}/tabs/analytics")) .carrying("range", *value), ) .latched(*value == range), ) }) .collect() } /// The figures across the top. /// /// Toned the way `super::user_analytics::stats` is: the tone rides on the /// delta, so a card with nothing to report stays neutral rather than going /// green for having no news. fn figures(stats: &[StatCard]) -> Node { Node::Stats { figures: stats .iter() .map(|stat| { let mut figure = Figure::new(stat.value.clone(), stat.label.clone()); if let Some(change) = &stat.change { figure = figure.change(change.clone()).tone(if stat.is_positive { layout::Tone::Success } else { layout::Tone::Danger }); } (figure, None) }) .collect(), } } /// What sold. /// /// A table rather than the template's `