Skip to main content

umbral_admin/
widgets.rs

1//! Dashboard widget system for umbral-admin.
2//!
3//! Plugins register widgets via `AdminPlugin::register_widget`. Each widget
4//! has a `key`, `title`, `kind`, `default_span`, optional `permission`, and
5//! an async data function. The admin dashboard renders a 12-column grid of
6//! the user's saved layout (defaulting to all permitted widgets).
7//!
8//! ## Registration shape
9//!
10//! ```rust,ignore
11//! admin.register_widget(Widget {
12//!     key:          "umbral_total_models",
13//!     title:        "Total Models".to_string(),
14//!     kind:         WidgetKind::Kpi,
15//!     default_span: Span { cols: 3, rows: 1 },
16//!     permission:   None,
17//!     data:         WidgetDataFn::new(|_user| async move {
18//!         WidgetPayload::Kpi(KpiPayload {
19//!             value:     "42".to_string(),
20//!             unit:      None,
21//!             delta:     None,
22//!             sparkline: None,
23//!         })
24//!     }),
25//! });
26//! ```
27//!
28//! ## Endpoint contract
29//!
30//! - `GET /admin/api/dashboard/catalog` — `[{key, title, kind, default_span}]`
31//! - `GET /admin/api/dashboard/layout`  — user's saved layout or default
32//! - `PUT /admin/api/dashboard/layout`  — save user's layout
33//! - `GET /admin/api/dashboard/widgets/{key}/data` — typed payload JSON
34
35use std::future::Future;
36use std::pin::Pin;
37use std::sync::Arc;
38
39use serde::{Deserialize, Serialize};
40use umbral_auth::AuthUser;
41
42// =========================================================================
43// Span
44// =========================================================================
45
46/// Grid span in the 12-column dashboard grid.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Span {
49    /// Number of columns to occupy (1–12).
50    pub cols: u8,
51    /// Number of rows to occupy (1–N).
52    pub rows: u8,
53}
54
55impl Default for Span {
56    fn default() -> Self {
57        Self { cols: 3, rows: 1 }
58    }
59}
60
61// =========================================================================
62// WidgetKind
63// =========================================================================
64
65/// The visual kind of a dashboard widget. Drives how the payload is rendered.
66#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
67#[serde(rename_all = "lowercase")]
68pub enum WidgetKind {
69    /// Simple single-value KPI (legacy, kept for backwards compat).
70    Kpi,
71    /// Shop-style summary card: title + icon + small unit / subtitle
72    /// + large humanized value + optional growth-vs-previous-period.
73    /// The everyday "Total sales / Orders / Customers" tile.
74    Card,
75    Line,
76    Bar,
77    /// Donut chart — labeled slices summing to 100%. Best for
78    /// low-cardinality breakdowns (status distribution, top N
79    /// regions, mode share) where a bar chart's axes are
80    /// overkill. 3-6 slices reads cleanly; past that switch
81    /// to a bar.
82    Donut,
83    /// Radial gauge — one or more 0–100% tracks rendered as
84    /// concentric arcs (ApexCharts `radialBar`). The everyday
85    /// "progress toward a goal" tile: quota attainment, capacity
86    /// used, completion rate, SLA. A single track reads as one big
87    /// ring with the percent in the centre; 2–4 tracks compare
88    /// related ratios (e.g. per-plan conversion).
89    Radial,
90    /// Heatmap — a 2-D grid of cells colored by magnitude (ApexCharts
91    /// `heatmap`). Best for "activity by time" patterns: day-of-week ×
92    /// hour-of-day signups, cohort retention, per-region load. Each
93    /// row is a series; each cell an `(x, value)` pair.
94    Heatmap,
95    /// Progress bars — a ranked list of labeled horizontal bars, each
96    /// filled relative to the largest value (or an explicit target).
97    /// The "top N by metric" tile: revenue by product, traffic by
98    /// source, completion per category. Pure HTML; no chart library.
99    Progress,
100    Table,
101    Feed,
102}
103
104impl WidgetKind {
105    pub fn as_str(&self) -> &'static str {
106        match self {
107            WidgetKind::Kpi => "kpi",
108            WidgetKind::Card => "card",
109            WidgetKind::Line => "line",
110            WidgetKind::Bar => "bar",
111            WidgetKind::Donut => "donut",
112            WidgetKind::Radial => "radial",
113            WidgetKind::Heatmap => "heatmap",
114            WidgetKind::Progress => "progress",
115            WidgetKind::Table => "table",
116            WidgetKind::Feed => "feed",
117        }
118    }
119}
120
121// =========================================================================
122// Typed payloads
123// =========================================================================
124
125/// KPI card payload.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct KpiPayload {
128    /// The primary metric value (displayed large).
129    pub value: String,
130    /// Optional unit label, e.g. "rows" or "MB".
131    pub unit: Option<String>,
132    /// Optional delta percentage; positive = up, negative = down.
133    pub delta: Option<f64>,
134    /// Optional sparkline data points (values only; x is implicit index).
135    pub sparkline: Option<Vec<f64>>,
136}
137
138// =========================================================================
139// Card payload — the everyday "summary tile" widget.
140// =========================================================================
141
142/// Summary card payload. Renders as:
143///
144/// ```text
145/// ┌──────────────────────────────────────────┐
146/// │ TITLE                          [icon]    │  ← title row (from Widget)
147/// │                                          │
148/// │ USD                       12,438.20      │  ← unit (sm, left) + value (lg, right)
149/// │                                          │
150/// │ This month        ↑ 12.3% vs last month  │  ← subtitle + growth
151/// └──────────────────────────────────────────┘
152/// ```
153///
154/// Build with [`CardPayload::new`] + the chained setters; pass to
155/// [`WidgetPayload::Card`].
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct CardPayload {
158    /// Formatted primary value, e.g. "12,438.20" or "12.4K". Use
159    /// [`humanize_number`] for the K/M/B/T compaction.
160    pub value: String,
161    /// Optional unit / context label shown on the left side of the
162    /// value row, e.g. "USD", "rows", "today".
163    pub unit: Option<String>,
164    /// Optional Lucide icon name (e.g. "dollar-sign", "shopping-cart").
165    /// Rendered via the data-lucide attribute the wrapper already
166    /// initializes.
167    pub icon: Option<String>,
168    /// Optional caption below the value, e.g. "This month".
169    pub subtitle: Option<String>,
170    /// Percentage delta vs. the previous period, signed:
171    /// `+12.3` = up 12.3%, `-4.1` = down 4.1%. The renderer picks
172    /// the arrow + color from the sign.
173    pub delta_percent: Option<f64>,
174    /// Optional comparison label, e.g. "vs last month".
175    pub delta_label: Option<String>,
176    /// Optional trend trail — a flat series of N points the
177    /// renderer plots as a fade-right sparkline under the value.
178    /// X is implicit (evenly spaced); Y autoscales between
179    /// min/max. Pair with `growth(...)` so the pill matches the
180    /// trail visually. Keep the series small (7–30 points) —
181    /// anything denser turns into noise at sparkline scale.
182    pub sparkline: Option<Vec<f64>>,
183}
184
185impl CardPayload {
186    /// New card with just a primary value. Caller picks the format
187    /// — strings stay as-is, numbers should be pre-humanized with
188    /// [`humanize_number`] / [`format_thousands`].
189    pub fn new(value: impl Into<String>) -> Self {
190        Self {
191            value: value.into(),
192            unit: None,
193            icon: None,
194            subtitle: None,
195            delta_percent: None,
196            delta_label: None,
197            sparkline: None,
198        }
199    }
200
201    pub fn unit(mut self, unit: impl Into<String>) -> Self {
202        self.unit = Some(unit.into());
203        self
204    }
205
206    pub fn icon(mut self, icon: impl Into<String>) -> Self {
207        self.icon = Some(icon.into());
208        self
209    }
210
211    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
212        self.subtitle = Some(subtitle.into());
213        self
214    }
215
216    /// Compute the delta automatically from current + previous raw
217    /// numbers. Skips the delta when `previous` is zero (no baseline
218    /// to grow from) or non-finite — the renderer just won't show
219    /// the growth row in that case.
220    pub fn growth(mut self, current: f64, previous: f64) -> Self {
221        if previous.is_finite() && previous != 0.0 && current.is_finite() {
222            self.delta_percent = Some(((current - previous) / previous.abs()) * 100.0);
223        }
224        self
225    }
226
227    /// Explicit delta percent (signed) + label. Use when you've
228    /// computed the percentage yourself or want a custom label.
229    pub fn delta(mut self, percent: f64, label: impl Into<String>) -> Self {
230        self.delta_percent = Some(percent);
231        self.delta_label = Some(label.into());
232        self
233    }
234
235    /// Standalone label for the delta — pairs with [`Self::growth`]
236    /// for the common case "auto-compute the percent but customize
237    /// the comparison label" (e.g. `"vs prior 30d"`).
238    pub fn delta_label(mut self, label: impl Into<String>) -> Self {
239        self.delta_label = Some(label.into());
240        self
241    }
242
243    /// Attach a trend trail rendered as a fade-right sparkline
244    /// under the value. Pass 7–30 raw numbers (daily totals,
245    /// hourly counts, etc.); the renderer autoscales and colors
246    /// the stroke to match [`Self::delta_percent`]'s sign.
247    pub fn sparkline(mut self, points: impl IntoIterator<Item = f64>) -> Self {
248        self.sparkline = Some(points.into_iter().collect());
249        self
250    }
251}
252
253/// Humanize a number into a compact display string:
254///
255/// | input            | output     |
256/// |------------------|------------|
257/// | `42.0`           | `"42"`     |
258/// | `1_234.5`        | `"1,234.50"` |
259/// | `12_438.2`       | `"12.4K"`  |
260/// | `1_500_000.0`    | `"1.50M"`  |
261/// | `2_700_000_000.` | `"2.70B"`  |
262///
263/// Suitable for card values where horizontal space is scarce.
264pub fn humanize_number(n: f64) -> String {
265    if !n.is_finite() {
266        return "—".to_string();
267    }
268    let abs = n.abs();
269    let sign = if n < 0.0 { "-" } else { "" };
270    if abs < 1000.0 {
271        // Two decimals when there's a fractional part; integer otherwise.
272        if (abs.fract() - 0.0).abs() < f64::EPSILON {
273            return format!("{sign}{}", abs as i64);
274        }
275        return format!("{sign}{:.2}", abs);
276    }
277    if abs < 1_000_000.0 {
278        if abs < 10_000.0 {
279            // Keep the thousands separator at the low end of the K
280            // range — "9,876" reads better than "9.9K" for amounts a
281            // user is likely to mentally verify against the data.
282            return format_thousands(n);
283        }
284        return format!("{sign}{:.1}K", abs / 1_000.0);
285    }
286    if abs < 1_000_000_000.0 {
287        return format!("{sign}{:.2}M", abs / 1_000_000.0);
288    }
289    if abs < 1_000_000_000_000.0 {
290        return format!("{sign}{:.2}B", abs / 1_000_000_000.0);
291    }
292    format!("{sign}{:.2}T", abs / 1_000_000_000_000.0)
293}
294
295/// Format a number with thousands separators and (when fractional)
296/// two decimal places. Use for values where the full digits matter
297/// (currency totals, audit counts) — for compact display use
298/// [`humanize_number`].
299pub fn format_thousands(n: f64) -> String {
300    if !n.is_finite() {
301        return "—".to_string();
302    }
303    let sign = if n < 0.0 { "-" } else { "" };
304    let abs = n.abs();
305    let int_part = abs.trunc() as u128;
306    let frac_part = abs - abs.trunc();
307
308    // Insert commas every 3 digits, right-to-left.
309    let int_str = int_part.to_string();
310    let bytes = int_str.as_bytes();
311    let mut grouped = String::with_capacity(int_str.len() + int_str.len() / 3);
312    for (i, b) in bytes.iter().enumerate() {
313        if i > 0 && (bytes.len() - i) % 3 == 0 {
314            grouped.push(',');
315        }
316        grouped.push(*b as char);
317    }
318
319    if frac_part > 0.0 {
320        format!("{sign}{grouped}.{:02}", (frac_part * 100.0).round() as u64)
321    } else {
322        format!("{sign}{grouped}")
323    }
324}
325
326/// One data series for Line or Bar charts.
327#[derive(Debug, Clone, Serialize, Deserialize)]
328pub struct Series {
329    pub name: String,
330    pub points: Vec<ChartPoint>,
331}
332
333/// X/Y data point. X is a string for flexible labeling.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ChartPoint {
336    pub x: String,
337    pub y: f64,
338}
339
340/// Line chart payload.
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct LinePayload {
343    pub series: Vec<Series>,
344    /// Describes what `x` represents; e.g. "date", "category".
345    pub x_type: String,
346}
347
348/// Bar chart payload (same shape as Line).
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct BarPayload {
351    pub series: Vec<Series>,
352    pub x_type: String,
353}
354
355/// One slice of a donut chart.
356#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct DonutSlice {
358    pub label: String,
359    pub value: f64,
360    /// Optional explicit color (CSS hex / rgb / token name).
361    /// `None` falls back to the chart's default palette.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub color: Option<String>,
364}
365
366/// Donut chart payload — categorical breakdown summing to 100%.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct DonutPayload {
369    pub slices: Vec<DonutSlice>,
370}
371
372impl DonutPayload {
373    pub fn new(slices: Vec<DonutSlice>) -> Self {
374        Self { slices }
375    }
376
377    /// Build slices from `(label, value)` tuples; the chart
378    /// picks colors from its default palette.
379    pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
380        Self::new(
381            pairs
382                .into_iter()
383                .map(|(label, value)| DonutSlice {
384                    label: label.into(),
385                    value,
386                    color: None,
387                })
388                .collect(),
389        )
390    }
391}
392
393/// One arc of a [`RadialPayload`] gauge — a labeled 0–100% value.
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct RadialTrack {
396    pub label: String,
397    /// Percent in `[0, 100]`. The `RadialPayload` constructors clamp
398    /// this so the arc never overruns the ring.
399    pub value: f64,
400    /// Optional explicit arc color (CSS hex / rgb / token name).
401    /// `None` falls back to the chart's default palette.
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub color: Option<String>,
404}
405
406/// Radial gauge payload — one or more 0–100% tracks rendered as
407/// concentric arcs (ApexCharts `radialBar`). Use for "progress toward
408/// a goal" metrics: quota attainment, capacity used, completion rate.
409///
410/// ```ignore
411/// // One ring: 73% of the monthly sales goal.
412/// RadialPayload::goal("Monthly goal", sales, target)
413/// // Compare conversion across plans.
414/// RadialPayload::from_pairs([("Free", 8.0), ("Pro", 21.5), ("Team", 34.0)])
415/// ```
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct RadialPayload {
418    pub tracks: Vec<RadialTrack>,
419}
420
421impl RadialPayload {
422    /// New payload from explicit tracks; each `value` is clamped to
423    /// `[0, 100]` (non-finite -> 0).
424    pub fn new(tracks: Vec<RadialTrack>) -> Self {
425        Self {
426            tracks: tracks
427                .into_iter()
428                .map(|t| RadialTrack {
429                    value: clamp_percent(t.value),
430                    ..t
431                })
432                .collect(),
433        }
434    }
435
436    /// A single-track gauge — the common case (one big ring with the
437    /// percent in the centre).
438    pub fn single(label: impl Into<String>, percent: f64) -> Self {
439        Self::new(vec![RadialTrack {
440            label: label.into(),
441            value: percent,
442            color: None,
443        }])
444    }
445
446    /// A single-track gauge whose percent is `current / target * 100`
447    /// — the literal "progress toward a goal" shape. A non-positive
448    /// `target` yields 0% (nothing to measure against).
449    pub fn goal(label: impl Into<String>, current: f64, target: f64) -> Self {
450        let pct = if target > 0.0 {
451            current / target * 100.0
452        } else {
453            0.0
454        };
455        Self::single(label, pct)
456    }
457
458    /// Build tracks from `(label, percent)` tuples; the chart picks
459    /// colors from its default palette. Each percent is clamped.
460    pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
461        Self::new(
462            pairs
463                .into_iter()
464                .map(|(label, value)| RadialTrack {
465                    label: label.into(),
466                    value,
467                    color: None,
468                })
469                .collect(),
470        )
471    }
472}
473
474/// Clamp a percentage into `[0, 100]`; non-finite -> 0.
475fn clamp_percent(v: f64) -> f64 {
476    if v.is_finite() {
477        v.clamp(0.0, 100.0)
478    } else {
479        0.0
480    }
481}
482
483/// One cell in a [`HeatmapRow`] — an x-axis bucket and its magnitude.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct HeatmapCell {
486    /// X-axis label for this cell (e.g. an hour `"09"`, a month `"Mar"`).
487    pub x: String,
488    /// The value that colors the cell. Higher = hotter.
489    pub y: f64,
490}
491
492/// One row (series) of a [`HeatmapPayload`] — a label plus its cells
493/// across the shared x-axis.
494#[derive(Debug, Clone, Serialize, Deserialize)]
495pub struct HeatmapRow {
496    pub name: String,
497    pub cells: Vec<HeatmapCell>,
498}
499
500/// Heatmap payload — a 2-D grid of cells colored by magnitude
501/// (ApexCharts `heatmap`). Every row shares the same ordered x-axis.
502/// Use for "activity by time" patterns (day-of-week × hour), cohort
503/// retention, or per-region load.
504///
505/// ```ignore
506/// HeatmapPayload::from_grid(
507///     ["Mon", "Tue", "Wed"],
508///     ["00-06", "06-12", "12-18", "18-24"],
509///     vec![
510///         vec![2.0, 9.0, 14.0, 6.0],
511///         vec![1.0, 11.0, 17.0, 8.0],
512///         vec![3.0, 13.0, 19.0, 7.0],
513///     ],
514/// )
515/// ```
516#[derive(Debug, Clone, Serialize, Deserialize)]
517pub struct HeatmapPayload {
518    pub rows: Vec<HeatmapRow>,
519}
520
521impl HeatmapPayload {
522    /// New payload from explicit rows.
523    pub fn new(rows: Vec<HeatmapRow>) -> Self {
524        Self { rows }
525    }
526
527    /// Build a rectangular grid from row labels, shared column (x)
528    /// labels, and a `values[row][col]` matrix. A short value row is
529    /// padded with `0.0` and extra values past the columns are dropped,
530    /// so the grid is always rectangular regardless of ragged input.
531    pub fn from_grid<R, C>(
532        row_labels: impl IntoIterator<Item = R>,
533        col_labels: impl IntoIterator<Item = C>,
534        values: Vec<Vec<f64>>,
535    ) -> Self
536    where
537        R: Into<String>,
538        C: Into<String>,
539    {
540        let cols: Vec<String> = col_labels.into_iter().map(Into::into).collect();
541        let rows = row_labels
542            .into_iter()
543            .enumerate()
544            .map(|(r, label)| {
545                let row_vals = values.get(r);
546                let cells = cols
547                    .iter()
548                    .enumerate()
549                    .map(|(c, x)| HeatmapCell {
550                        x: x.clone(),
551                        y: row_vals.and_then(|v| v.get(c)).copied().unwrap_or(0.0),
552                    })
553                    .collect();
554                HeatmapRow {
555                    name: label.into(),
556                    cells,
557                }
558            })
559            .collect();
560        Self { rows }
561    }
562}
563
564/// One row of a [`ProgressPayload`] — a labeled horizontal bar.
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct ProgressItem {
567    pub label: String,
568    /// Pre-formatted value shown at the right of the row (the
569    /// `from_pairs` constructors thousands-group it; `new` takes it
570    /// verbatim).
571    pub display: String,
572    /// Bar fill width, `0–100`, relative to the payload's reference
573    /// (the largest value, or an explicit target).
574    pub percent: f64,
575    /// Optional explicit bar color (CSS hex / rgb / token name).
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub color: Option<String>,
578}
579
580/// Progress-bar list payload — a ranked set of labeled horizontal bars,
581/// each filled relative to the largest value (or an explicit target).
582/// The "top N by metric" tile: revenue by product, traffic by source,
583/// completion per category. Rendered as pure HTML — no chart library.
584///
585/// ```ignore
586/// // Revenue by product; the top product fills the bar.
587/// ProgressPayload::from_pairs([("Pro", 48200.0), ("Team", 31000.0), ("Free", 9400.0)])
588/// // Completion per team, each measured against a 100-task target.
589/// ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 57.0)], 100.0)
590/// ```
591#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProgressPayload {
593    pub items: Vec<ProgressItem>,
594}
595
596impl ProgressPayload {
597    /// New payload from explicit items (you set `display` + `percent`).
598    pub fn new(items: Vec<ProgressItem>) -> Self {
599        Self { items }
600    }
601
602    /// From `(label, value)` pairs, with each bar sized relative to the
603    /// LARGEST value (the top item fills the bar). `display` is the
604    /// thousands-grouped value.
605    pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
606        let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
607        let reference = items
608            .iter()
609            .map(|(_, v)| *v)
610            .filter(|v| v.is_finite())
611            .fold(0.0_f64, f64::max);
612        Self::build(items, reference)
613    }
614
615    /// From `(label, value)` pairs, with each bar sized against an
616    /// explicit `target` (e.g. a per-row "% of goal"). A non-positive
617    /// target falls back to sizing against the largest value.
618    pub fn from_pairs_of<L: Into<String>>(
619        pairs: impl IntoIterator<Item = (L, f64)>,
620        target: f64,
621    ) -> Self {
622        let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
623        let reference = if target > 0.0 {
624            target
625        } else {
626            items
627                .iter()
628                .map(|(_, v)| *v)
629                .filter(|v| v.is_finite())
630                .fold(0.0_f64, f64::max)
631        };
632        Self::build(items, reference)
633    }
634
635    /// Shared: turn `(label, value)` + a reference max into rendered
636    /// items. `percent = value / reference * 100`, clamped to
637    /// `[0, 100]`; a zero/non-finite reference yields empty bars.
638    fn build(items: Vec<(String, f64)>, reference: f64) -> Self {
639        let items = items
640            .into_iter()
641            .map(|(label, value)| {
642                let percent = if reference > 0.0 && value.is_finite() {
643                    (value / reference * 100.0).clamp(0.0, 100.0)
644                } else {
645                    0.0
646                };
647                ProgressItem {
648                    label,
649                    display: format_thousands(value),
650                    percent,
651                    color: None,
652                }
653            })
654            .collect();
655        Self { items }
656    }
657}
658
659/// Table widget column descriptor.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct TableColumn {
662    pub key: String,
663    pub label: String,
664}
665
666/// Table widget payload.
667#[derive(Debug, Clone, Serialize, Deserialize)]
668pub struct TablePayload {
669    pub columns: Vec<TableColumn>,
670    pub rows: Vec<serde_json::Value>,
671    /// Optional "View all →" link in the widget header. Populated
672    /// via [`Self::view_all_for`] (auto-resolves the admin URL
673    /// from a `Model` type) or set explicitly when the target
674    /// isn't a managed admin model.
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub view_all_url: Option<String>,
677}
678
679impl TablePayload {
680    /// New payload from columns + rows; no `view_all` link.
681    pub fn new(columns: Vec<TableColumn>, rows: Vec<serde_json::Value>) -> Self {
682        Self {
683            columns,
684            rows,
685            view_all_url: None,
686        }
687    }
688
689    /// Auto-resolve the "View all" link from a `Model` type — the
690    /// admin's changelist URL for that table. Mirrors the pattern
691    /// used by `models![T, U, V]`: rename the struct's
692    /// `#[umbral(table = "...")]` and the link follows automatically.
693    ///
694    /// ```rust,ignore
695    /// WidgetPayload::Table(
696    ///     TablePayload::new(columns, rows)
697    ///         .view_all_for::<Order>()
698    /// )
699    /// // → "View all →" links to {admin_base}/order/
700    /// ```
701    pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
702        self.view_all_url = Some(format!(
703            "{}/{}/",
704            crate::branding::current().base_path,
705            T::TABLE,
706        ));
707        self
708    }
709
710    /// Explicit URL override — use when the link target isn't a
711    /// managed admin model (an external dashboard, a custom route).
712    pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
713        self.view_all_url = Some(url.into());
714        self
715    }
716}
717
718/// One item in an activity feed.
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct FeedItem {
721    pub actor: String,
722    pub verb: String,
723    pub object: String,
724    pub object_link: Option<String>,
725    pub at: String,
726}
727
728/// Activity feed payload.
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct FeedPayload {
731    pub items: Vec<FeedItem>,
732    /// Optional "View all →" link in the widget header. Same
733    /// shape as [`TablePayload::view_all_url`] — auto-resolve
734    /// from a `Model` via [`Self::view_all_for`].
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub view_all_url: Option<String>,
737}
738
739impl FeedPayload {
740    /// New payload from items; no `view_all` link.
741    pub fn new(items: Vec<FeedItem>) -> Self {
742        Self {
743            items,
744            view_all_url: None,
745        }
746    }
747
748    /// Auto-resolve the "View all" link from a `Model` type. The
749    /// recent-signups feed for instance:
750    ///
751    /// ```rust,ignore
752    /// WidgetPayload::Feed(
753    ///     FeedPayload::new(items).view_all_for::<AuthUser>()
754    /// )
755    /// // → "View all →" links to {admin_base}/auth_user/
756    /// ```
757    pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
758        self.view_all_url = Some(format!(
759            "{}/{}/",
760            crate::branding::current().base_path,
761            T::TABLE,
762        ));
763        self
764    }
765
766    pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
767        self.view_all_url = Some(url.into());
768        self
769    }
770}
771
772/// Union of all widget payloads. The JSON discriminant is the variant name.
773#[derive(Debug, Clone, Serialize, Deserialize)]
774#[serde(tag = "kind", rename_all = "lowercase")]
775pub enum WidgetPayload {
776    Kpi(KpiPayload),
777    Card(CardPayload),
778    Line(LinePayload),
779    Bar(BarPayload),
780    Donut(DonutPayload),
781    Radial(RadialPayload),
782    Heatmap(HeatmapPayload),
783    Progress(ProgressPayload),
784    Table(TablePayload),
785    Feed(FeedPayload),
786}
787
788/// Quote a CSV field per RFC 4180: wrap in `"` when it contains a comma, a
789/// quote or a newline, and double any embedded quotes.
790///
791/// Also neutralises the spreadsheet-formula injection vector: a cell whose text
792/// starts with `=`, `+`, `-` or `@` is executed as a formula when the file is
793/// opened in Excel or Sheets, so a row whose value someone typed into your app
794/// (`=HYPERLINK(...)`, `=cmd|...`) becomes code running on the machine of
795/// whoever opened the export. Prefixing a tab keeps the text readable while
796/// making it inert.
797fn csv_field(value: &str) -> String {
798    let dangerous = value
799        .chars()
800        .next()
801        .is_some_and(|c| matches!(c, '=' | '+' | '-' | '@'));
802    let value = if dangerous {
803        format!("\t{value}")
804    } else {
805        value.to_string()
806    };
807    if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
808        format!("\"{}\"", value.replace('"', "\"\""))
809    } else {
810        value
811    }
812}
813
814fn csv_row<I: IntoIterator<Item = String>>(cells: I) -> String {
815    cells
816        .into_iter()
817        .map(|c| csv_field(&c))
818        .collect::<Vec<_>>()
819        .join(",")
820}
821
822fn num(v: f64) -> String {
823    if v.fract() == 0.0 {
824        format!("{}", v as i64)
825    } else {
826        format!("{v}")
827    }
828}
829
830impl WidgetPayload {
831    /// The payload as CSV, when it has rows worth exporting.
832    ///
833    /// `None` for the shapes that are a single number or a prose feed — a KPI
834    /// tile is not a table, and pretending otherwise would hand the user a
835    /// one-cell file. Everything with a series, slices, bars or rows exports.
836    pub fn to_csv(&self) -> Option<String> {
837        let mut out = String::new();
838        match self {
839            // A chart is (series, x, y) once flattened, which is exactly what a
840            // spreadsheet wants — one tidy row per point, series as a column so
841            // a multi-series chart round-trips into a pivot table.
842            WidgetPayload::Line(LinePayload { series, .. })
843            | WidgetPayload::Bar(BarPayload { series, .. }) => {
844                out.push_str("series,x,y\n");
845                for s in series {
846                    for p in &s.points {
847                        out.push_str(&csv_row([s.name.clone(), p.x.clone(), num(p.y)]));
848                        out.push('\n');
849                    }
850                }
851            }
852            WidgetPayload::Donut(p) => {
853                out.push_str("label,value\n");
854                for s in &p.slices {
855                    out.push_str(&csv_row([s.label.clone(), num(s.value)]));
856                    out.push('\n');
857                }
858            }
859            WidgetPayload::Radial(p) => {
860                out.push_str("label,value\n");
861                for t in &p.tracks {
862                    out.push_str(&csv_row([t.label.clone(), num(t.value)]));
863                    out.push('\n');
864                }
865            }
866            WidgetPayload::Progress(p) => {
867                for i in &p.items {
868                    out.push_str(&csv_row([
869                        i.label.clone(),
870                        num(i.percent),
871                        i.display.clone(),
872                    ]));
873                    out.push('\n');
874                }
875            }
876            WidgetPayload::Heatmap(p) => {
877                out.push_str("row,column,value\n");
878                for r in &p.rows {
879                    for c in &r.cells {
880                        out.push_str(&csv_row([r.name.clone(), c.x.clone(), num(c.y)]));
881                        out.push('\n');
882                    }
883                }
884            }
885            WidgetPayload::Table(p) => {
886                out.push_str(&csv_row(p.columns.iter().map(|c| c.label.clone())));
887                out.push('\n');
888                for row in &p.rows {
889                    let cells = p.columns.iter().map(|c| match row.get(&c.key) {
890                        Some(serde_json::Value::String(s)) => s.clone(),
891                        Some(serde_json::Value::Null) | None => String::new(),
892                        Some(v) => v.to_string(),
893                    });
894                    out.push_str(&csv_row(cells));
895                    out.push('\n');
896                }
897            }
898            // A single number or a prose feed is not a table.
899            WidgetPayload::Kpi(_) | WidgetPayload::Card(_) | WidgetPayload::Feed(_) => return None,
900        }
901        Some(out)
902    }
903}
904
905// =========================================================================
906// WidgetDataFn
907// =========================================================================
908
909/// Per-request parameters a widget's data closure can read.
910/// Sourced from the query string on
911/// `GET /admin/api/dashboard/widgets/<key>/data?<params>`.
912///
913/// Defaults are all `None` — closures that don't care can use
914/// `WidgetDataFn::new(|user| ...)` and ignore params entirely.
915/// Closures that DO care use `WidgetDataFn::with_params` and
916/// branch on `params.period` / `params.start` / `params.end`.
917#[derive(Debug, Clone, Default)]
918pub struct WidgetParams {
919    /// Period preset like `"7d"`, `"30d"`, `"90d"`. The
920    /// rendering side emits chips that pass this through.
921    pub period: Option<String>,
922    /// Explicit ISO start date (`YYYY-MM-DD`) — overrides
923    /// `period` when both are present.
924    pub start: Option<String>,
925    /// Explicit ISO end date (`YYYY-MM-DD`).
926    pub end: Option<String>,
927    /// Catch-all for any other widget-specific query params
928    /// — `?model=order` for a future per-model filter, etc.
929    /// Closures read by `params.raw.get("...")`.
930    pub raw: std::collections::HashMap<String, String>,
931}
932
933impl WidgetParams {
934    /// Build from a `?key=value&...` query string. Recognised
935    /// keys (`period`, `start`, `end`) populate the typed
936    /// fields; the rest land in `raw`.
937    pub fn from_query<S: AsRef<str>>(query: S) -> Self {
938        let mut out = Self::default();
939        for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
940            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
941            let value = urlencoding_decode(v);
942            match k {
943                "period" => out.period = Some(value),
944                "start" => out.start = Some(value),
945                "end" => out.end = Some(value),
946                _ => {
947                    out.raw.insert(k.to_string(), value);
948                }
949            }
950        }
951        out
952    }
953
954    /// Number of days the `period` preset represents. `"7d"`
955    /// → 7, `"30d"` → 30, `"90d"` → 90. None for unrecognised /
956    /// missing values so callers fall back to a default.
957    pub fn period_days(&self) -> Option<i64> {
958        let p = self.period.as_deref()?;
959        let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
960        digits.parse().ok()
961    }
962
963    /// Value of a [`WidgetFilter::choice`] control, by its key.
964    ///
965    /// ```rust,ignore
966    /// let status = params.choice("status").unwrap_or("open");
967    /// ```
968    pub fn choice(&self, key: &str) -> Option<&str> {
969        self.raw
970            .get(key)
971            .map(String::as_str)
972            .filter(|v| !v.is_empty())
973    }
974
975    /// The resolved window as `(start, end)` ISO dates, if a date-range filter
976    /// supplied both. Takes precedence over `period` — a caller who picked
977    /// explicit dates meant them.
978    pub fn date_range(&self) -> Option<(&str, &str)> {
979        match (self.start.as_deref(), self.end.as_deref()) {
980            (Some(s), Some(e)) if !s.is_empty() && !e.is_empty() => Some((s, e)),
981            _ => None,
982        }
983    }
984}
985
986/// Minimal `%XX` → byte decoder; avoids pulling a query-string
987/// crate just for the four chars we need (`+` → space, `%2F` → `/`,
988/// etc.). Anything malformed passes through unchanged.
989fn urlencoding_decode(raw: &str) -> String {
990    let mut out = String::with_capacity(raw.len());
991    let bytes = raw.as_bytes();
992    let mut i = 0;
993    while i < bytes.len() {
994        match bytes[i] {
995            b'+' => {
996                out.push(' ');
997                i += 1;
998            }
999            b'%' if i + 2 < bytes.len() => {
1000                let hi = (bytes[i + 1] as char).to_digit(16);
1001                let lo = (bytes[i + 2] as char).to_digit(16);
1002                if let (Some(h), Some(l)) = (hi, lo) {
1003                    out.push(char::from((h as u8) * 16 + l as u8));
1004                    i += 3;
1005                } else {
1006                    out.push(bytes[i] as char);
1007                    i += 1;
1008                }
1009            }
1010            b => {
1011                out.push(b as char);
1012                i += 1;
1013            }
1014        }
1015    }
1016    out
1017}
1018
1019// =========================================================================
1020// WidgetFilter — declarative, per-widget UI controls
1021// =========================================================================
1022
1023/// What kind of control a [`WidgetFilter`] renders.
1024///
1025/// The variants exist because the three shapes reach the data closure by
1026/// different routes: a period lands in `WidgetParams::period`, a date range in
1027/// `start` / `end`, and a choice in `raw[key]`. Keeping that in the type means
1028/// the template never has to guess which query parameter a control writes to.
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030#[serde(tag = "kind", rename_all = "snake_case")]
1031pub enum WidgetFilterKind {
1032    /// A chip strip of presets — `7d`, `30d`, `90d`. Writes `?period=`.
1033    Period { presets: Vec<FilterOption> },
1034    /// Two `<input type="date">` boxes. Writes `?start=` and `?end=`.
1035    DateRange,
1036    /// A `<select>`. Writes `?<key>=`, readable via `params.choice(key)`.
1037    Choice { options: Vec<FilterOption> },
1038}
1039
1040/// One selectable value in a period or choice filter.
1041#[derive(Debug, Clone, Serialize, Deserialize)]
1042pub struct FilterOption {
1043    pub value: String,
1044    pub label: String,
1045}
1046
1047impl FilterOption {
1048    pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
1049        Self {
1050            value: value.into(),
1051            label: label.into(),
1052        }
1053    }
1054}
1055
1056/// A control the admin renders in a widget's header, declared at registration
1057/// time and rendered for **every** widget kind.
1058///
1059/// Before this existed the only filter in the admin was a `[7d][30d][90d]` chip
1060/// strip hardcoded inside `line.html`, which meant a bar chart or a table could
1061/// not be filtered at all, and no widget could offer anything but a period. A
1062/// filter is now data: the widget declares it, the admin renders it, and the
1063/// value arrives in [`WidgetParams`].
1064///
1065/// ```rust,ignore
1066/// Widget::new("orders_by_status", "Orders", WidgetKind::Bar, data_fn)
1067///     .filter(WidgetFilter::period_default())
1068///     .filter(WidgetFilter::choice("status", "Status", [
1069///         ("open", "Open"), ("paid", "Paid"),
1070///     ]))
1071/// ```
1072///
1073/// The chosen value is **sticky per user**: picking one persists to the user's
1074/// admin preferences, so it survives a reload the same way a period chip does.
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct WidgetFilter {
1077    /// Query-parameter name. `"period"` for a period filter, `"start"`/`"end"`
1078    /// are implied by a date range, anything else for a choice.
1079    pub key: String,
1080    /// Label shown beside the control.
1081    pub label: String,
1082    /// Which control to render.
1083    #[serde(flatten)]
1084    pub kind: WidgetFilterKind,
1085    /// Value selected when the user has never touched this filter.
1086    pub default: Option<String>,
1087    /// Resolved value for THIS request — URL, else the user's saved choice,
1088    /// else `default`. Filled in by the handler; ignored at registration.
1089    #[serde(default)]
1090    pub active: Option<String>,
1091    /// Resolved range ends, for a [`WidgetFilterKind::DateRange`] only.
1092    /// Filled in by the handler; ignored at registration.
1093    #[serde(default)]
1094    pub active_start: Option<String>,
1095    #[serde(default)]
1096    pub active_end: Option<String>,
1097    /// Query-string fragment carrying every OTHER filter's current value, e.g.
1098    /// `"&status=paid&period=7d"`. Filled in by the handler.
1099    ///
1100    /// It lives here, computed in Rust, because the alternative was assembling
1101    /// it in the template with `split`/`trim` gymnastics — and a control that
1102    /// gets this wrong silently resets its neighbours the moment you touch it.
1103    #[serde(default)]
1104    pub carry: String,
1105    /// The same fragment without the leading `&`, for a control that starts the
1106    /// query string rather than appending to one (a `<select>` whose own value
1107    /// htmx appends via `hx-include`).
1108    #[serde(default)]
1109    pub carry_lead: String,
1110}
1111
1112impl WidgetFilter {
1113    /// A period chip strip with custom presets.
1114    pub fn period<I, V, L>(presets: I) -> Self
1115    where
1116        I: IntoIterator<Item = (V, L)>,
1117        V: Into<String>,
1118        L: Into<String>,
1119    {
1120        Self {
1121            key: "period".to_string(),
1122            label: "Period".to_string(),
1123            kind: WidgetFilterKind::Period {
1124                presets: presets
1125                    .into_iter()
1126                    .map(|(v, l)| FilterOption::new(v, l))
1127                    .collect(),
1128            },
1129            default: None,
1130            active: None,
1131            active_start: None,
1132            active_end: None,
1133            carry: String::new(),
1134            carry_lead: String::new(),
1135        }
1136    }
1137
1138    /// The conventional `[7d] [30d] [90d]` strip — what `line.html` hardcoded.
1139    pub fn period_default() -> Self {
1140        Self::period([("7d", "7d"), ("30d", "30d"), ("90d", "90d")]).with_default("30d")
1141    }
1142
1143    /// A start/end date pair. Reaches the closure as `params.start` / `params.end`,
1144    /// which take precedence over `period` when both are present.
1145    pub fn date_range() -> Self {
1146        Self {
1147            key: "range".to_string(),
1148            label: "Date range".to_string(),
1149            kind: WidgetFilterKind::DateRange,
1150            default: None,
1151            active: None,
1152            active_start: None,
1153            active_end: None,
1154            carry: String::new(),
1155            carry_lead: String::new(),
1156        }
1157    }
1158
1159    /// A `<select>` writing `?<key>=`. Read it back with `params.choice(key)`.
1160    pub fn choice<I, V, L>(key: impl Into<String>, label: impl Into<String>, options: I) -> Self
1161    where
1162        I: IntoIterator<Item = (V, L)>,
1163        V: Into<String>,
1164        L: Into<String>,
1165    {
1166        Self {
1167            key: key.into(),
1168            label: label.into(),
1169            kind: WidgetFilterKind::Choice {
1170                options: options
1171                    .into_iter()
1172                    .map(|(v, l)| FilterOption::new(v, l))
1173                    .collect(),
1174            },
1175            default: None,
1176            active: None,
1177            active_start: None,
1178            active_end: None,
1179            carry: String::new(),
1180            carry_lead: String::new(),
1181        }
1182    }
1183
1184    /// Pre-select a value on first paint.
1185    pub fn with_default(mut self, value: impl Into<String>) -> Self {
1186        self.default = Some(value.into());
1187        self
1188    }
1189
1190    /// The value in force for this request.
1191    pub fn active_value(&self) -> Option<&str> {
1192        self.active.as_deref().or(self.default.as_deref())
1193    }
1194}
1195
1196pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
1197pub(crate) type DataFnInner =
1198    Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
1199
1200/// Wrapper around the async data closure. Build via
1201/// [`WidgetDataFn::new`] (closure ignores per-request params) or
1202/// [`WidgetDataFn::with_params`] (closure reads `WidgetParams` to
1203/// honour period / date-range filters from the request URL).
1204#[derive(Clone)]
1205pub struct WidgetDataFn(pub(crate) DataFnInner);
1206
1207impl WidgetDataFn {
1208    /// Create from any `async fn(AuthUser) -> WidgetPayload` —
1209    /// per-request params are dropped on the floor. Use when the
1210    /// widget renders the same thing regardless of UI controls
1211    /// (KPI counts, registry sizes, etc.).
1212    pub fn new<F, Fut>(f: F) -> Self
1213    where
1214        F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
1215        Fut: Future<Output = WidgetPayload> + Send + 'static,
1216    {
1217        Self(Arc::new(move |user, _params| Box::pin(f(user))))
1218    }
1219
1220    /// Create from `async fn(AuthUser, WidgetParams) ->
1221    /// WidgetPayload`. Use for filterable widgets — the line
1222    /// chart reads `params.period` to switch between 7d / 30d /
1223    /// 90d views, a future table widget might read
1224    /// `params.raw.get("status")` for status filtering, etc.
1225    pub fn with_params<F, Fut>(f: F) -> Self
1226    where
1227        F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
1228        Fut: Future<Output = WidgetPayload> + Send + 'static,
1229    {
1230        Self(Arc::new(move |user, params| Box::pin(f(user, params))))
1231    }
1232}
1233
1234impl std::fmt::Debug for WidgetDataFn {
1235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1236        f.write_str("WidgetDataFn(<fn>)")
1237    }
1238}
1239
1240// =========================================================================
1241// Widget
1242// =========================================================================
1243
1244/// A registered dashboard widget.
1245///
1246/// Register via `AdminPlugin::register_widget(...)`.
1247#[derive(Debug, Clone)]
1248pub struct Widget {
1249    /// URL-safe unique key, e.g. `"umbral_total_models"`.
1250    pub key: &'static str,
1251    /// Human-readable title shown in the widget card header.
1252    pub title: String,
1253    /// Determines which renderer (KPI card, chart, table, feed).
1254    pub kind: WidgetKind,
1255    /// Default grid span when the user hasn't customized.
1256    pub default_span: Span,
1257    /// Optional permission codename. `None` = any staff user may see.
1258    pub permission: Option<&'static str>,
1259    /// Async function that computes and returns the payload.
1260    pub data: WidgetDataFn,
1261    /// Default period preset used by line/bar/etc. widgets that
1262    /// carry a period-chip strip — `"7d"`, `"30d"`, `"90d"`. When
1263    /// `Some`, the handler pre-fills `WidgetParams.period` from
1264    /// this value on first load (no `?period=` in the URL), so
1265    /// the matching chip renders highlighted AND the data
1266    /// closure receives the same period via `params.period_days()`.
1267    /// `None` falls back to whatever the template / data closure
1268    /// chooses as its fallback.
1269    pub default_period: Option<&'static str>,
1270    /// Controls rendered in this widget's header — see [`WidgetFilter`].
1271    ///
1272    /// Empty means no controls, with one compatibility exception: a
1273    /// [`WidgetKind::Line`] with no declared filters still gets the historic
1274    /// `[7d][30d][90d]` strip, because that strip used to be hardcoded in
1275    /// `line.html` and removing it would silently strip controls from every
1276    /// line chart already in the wild.
1277    pub filters: Vec<WidgetFilter>,
1278}
1279
1280impl Widget {
1281    /// Build a widget without naming every field.
1282    ///
1283    /// Prefer this over a struct literal. `Widget` grows fields as the
1284    /// dashboard grows (this is how `filters` arrived), and every new field
1285    /// breaks every literal that spells out the old ones. Going through the
1286    /// builder means the next field costs you nothing.
1287    ///
1288    /// ```rust,ignore
1289    /// Widget::new("orders", "Orders", WidgetKind::Bar, data_fn)
1290    ///     .with_span(6, 2)
1291    ///     .filter(WidgetFilter::period_default())
1292    /// ```
1293    pub fn new(
1294        key: &'static str,
1295        title: impl Into<String>,
1296        kind: WidgetKind,
1297        data: WidgetDataFn,
1298    ) -> Self {
1299        Self {
1300            key,
1301            title: title.into(),
1302            kind,
1303            default_span: Span::default(),
1304            permission: None,
1305            data,
1306            default_period: None,
1307            filters: Vec::new(),
1308        }
1309    }
1310
1311    /// Gate the widget behind a permission codename.
1312    pub fn with_permission(mut self, codename: &'static str) -> Self {
1313        self.permission = Some(codename);
1314        self
1315    }
1316
1317    /// Add one filter control to the widget header.
1318    pub fn filter(mut self, filter: WidgetFilter) -> Self {
1319        self.filters.push(filter);
1320        self
1321    }
1322
1323    /// Replace the widget's filters wholesale.
1324    pub fn with_filters(mut self, filters: impl IntoIterator<Item = WidgetFilter>) -> Self {
1325        self.filters = filters.into_iter().collect();
1326        self
1327    }
1328
1329    /// The filters to render for this request, including the line-chart
1330    /// compatibility strip described on [`Widget::filters`].
1331    pub(crate) fn effective_filters(&self) -> Vec<WidgetFilter> {
1332        if self.filters.is_empty() && matches!(self.kind, WidgetKind::Line) {
1333            let mut period = WidgetFilter::period_default();
1334            if let Some(d) = self.default_period {
1335                period.default = Some(d.to_string());
1336            }
1337            return vec![period];
1338        }
1339        self.filters.clone()
1340    }
1341
1342    /// Override the default grid span. Lets a caller resize a
1343    /// builtin (or any pre-built widget) at registration time
1344    /// without having to re-construct the whole struct literal:
1345    ///
1346    /// ```rust,ignore
1347    /// .register_widget(builtin_total_models_widget().with_span(6, 2))
1348    /// .register_widget(builtin_recent_users_widget().with_span(6, 2))
1349    /// ```
1350    ///
1351    /// `cols` is clamped at the 12-col grid; `rows` is whatever
1352    /// the dashboard's `auto-rows-[...]` accepts (1 = 120px).
1353    pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
1354        self.default_span = Span { cols, rows };
1355        self
1356    }
1357
1358    /// Pre-select a period chip on the widget — `"7d"`, `"30d"`,
1359    /// `"90d"`. On first load (no `?period=` in the URL), the
1360    /// handler stamps this into `WidgetParams.period` before
1361    /// calling the data closure, so the chip strip highlights
1362    /// the right preset AND the data fn computes the right
1363    /// window. Override on a per-request basis happens via the
1364    /// chip clicks (which send their own `?period=` query).
1365    ///
1366    /// ```ignore
1367    /// shop_daily_sales_chart().with_default_period("7d")
1368    /// // → first paint shows 7d highlighted, 7 days of data;
1369    /// //   clicking "30d" hands control to the URL state.
1370    /// ```
1371    pub fn with_default_period(mut self, period: &'static str) -> Self {
1372        self.default_period = Some(period);
1373        self
1374    }
1375}
1376
1377// =========================================================================
1378// WidgetInstance (user's saved layout entry)
1379// =========================================================================
1380
1381/// One entry in a user's saved layout JSON.
1382#[derive(Debug, Clone, Serialize, Deserialize)]
1383pub struct WidgetInstance {
1384    pub key: String,
1385    pub span: Span,
1386}
1387
1388// =========================================================================
1389// Widget catalog entry (API response shape)
1390// =========================================================================
1391
1392/// Serialized catalog entry returned by `GET /admin/api/dashboard/catalog`.
1393#[derive(Debug, Clone, Serialize)]
1394pub struct CatalogEntry {
1395    pub key: &'static str,
1396    pub title: String,
1397    pub kind: String,
1398    pub default_span: Span,
1399}
1400
1401// =========================================================================
1402// Sections — grouped widgets
1403// =========================================================================
1404
1405/// A named group of widgets on the dashboard. Each section renders
1406/// as its own heading + (optional) subtitle + widget grid, so a
1407/// dashboard with 20 widgets reads as themed clusters rather than
1408/// one mega-grid.
1409///
1410/// Build with the chainable API:
1411///
1412/// ```rust,ignore
1413/// use umbral_admin::WidgetSection;
1414///
1415/// let sales = WidgetSection::new("Sales overview")
1416///     .subtitle("Daily KPIs across the storefront")
1417///     .widget(shop_total_sales_widget())
1418///     .widget(shop_orders_widget())
1419///     .widget(shop_avg_order_value_widget());
1420///
1421/// AdminPlugin::default().dashboard_section(sales);
1422/// ```
1423///
1424/// Register multiple sections by chaining `.dashboard_section(...)`.
1425/// Widgets registered via the legacy `.register_widget(...)` end up
1426/// in an implicit final section titled "Widgets" — so existing apps
1427/// keep working without code changes.
1428#[derive(Debug, Clone)]
1429pub struct WidgetSection {
1430    /// Heading shown above the section (e.g. "Sales overview").
1431    pub title: String,
1432    /// Optional descriptive line under the title — keep it short,
1433    /// it's not a paragraph.
1434    pub subtitle: Option<String>,
1435    /// Widgets in this section, rendered in registration order.
1436    pub widgets: Vec<Widget>,
1437}
1438
1439impl WidgetSection {
1440    /// New empty section with just a title.
1441    pub fn new(title: impl Into<String>) -> Self {
1442        Self {
1443            title: title.into(),
1444            subtitle: None,
1445            widgets: Vec::new(),
1446        }
1447    }
1448
1449    /// Add a one-line subtitle under the heading.
1450    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
1451        self.subtitle = Some(subtitle.into());
1452        self
1453    }
1454
1455    /// Append one widget to the section.
1456    pub fn widget(mut self, w: Widget) -> Self {
1457        self.widgets.push(w);
1458        self
1459    }
1460
1461    /// Append many widgets at once (handy for splatting a Vec).
1462    pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
1463        self.widgets.extend(ws);
1464        self
1465    }
1466}
1467
1468#[cfg(test)]
1469mod tests {
1470    use super::*;
1471
1472    #[test]
1473    fn radial_kind_serializes_as_radial() {
1474        assert_eq!(WidgetKind::Radial.as_str(), "radial");
1475    }
1476
1477    #[test]
1478    fn radial_single_builds_one_track() {
1479        let p = RadialPayload::single("Monthly goal", 73.0);
1480        assert_eq!(p.tracks.len(), 1);
1481        assert_eq!(p.tracks[0].label, "Monthly goal");
1482        assert_eq!(p.tracks[0].value, 73.0);
1483        assert!(p.tracks[0].color.is_none());
1484    }
1485
1486    #[test]
1487    fn radial_clamps_out_of_range_and_non_finite_percents() {
1488        // Over 100, under 0, and non-finite all clamp into [0, 100].
1489        assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
1490        assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
1491        // Non-finite (NaN, ±∞) is meaningless as a percent -> 0.
1492        assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
1493        assert_eq!(
1494            RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
1495            0.0,
1496        );
1497    }
1498
1499    #[test]
1500    fn radial_goal_is_current_over_target() {
1501        assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
1502        // current > target clamps to 100 (overachieved, full ring).
1503        assert_eq!(
1504            RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
1505            100.0
1506        );
1507        // A non-positive target has nothing to measure against -> 0%.
1508        assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
1509    }
1510
1511    #[test]
1512    fn radial_from_pairs_keeps_order_and_clamps() {
1513        let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
1514        assert_eq!(p.tracks.len(), 3);
1515        assert_eq!(p.tracks[0].label, "Free");
1516        assert_eq!(p.tracks[1].value, 100.0); // clamped
1517        assert_eq!(p.tracks[2].label, "Team");
1518    }
1519
1520    #[test]
1521    fn radial_payload_serializes_with_kind_tag() {
1522        let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
1523        let json = serde_json::to_value(&payload).expect("serialize");
1524        assert_eq!(json["kind"], "radial");
1525        assert_eq!(json["tracks"][0]["label"], "Quota");
1526        assert_eq!(json["tracks"][0]["value"], 42.0);
1527        // No explicit color -> the field is skipped entirely.
1528        assert!(json["tracks"][0].get("color").is_none());
1529    }
1530
1531    #[test]
1532    fn heatmap_kind_serializes_as_heatmap() {
1533        assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
1534    }
1535
1536    #[test]
1537    fn heatmap_from_grid_is_rectangular_and_padded() {
1538        // A ragged matrix: row 0 short (padded with 0), row 1 long
1539        // (extra dropped), row 2 exact.
1540        let p = HeatmapPayload::from_grid(
1541            ["Mon", "Tue", "Wed"],
1542            ["AM", "PM"],
1543            vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
1544        );
1545        assert_eq!(p.rows.len(), 3);
1546        // Every row has exactly one cell per column label.
1547        for row in &p.rows {
1548            assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
1549            assert_eq!(row.cells[0].x, "AM");
1550            assert_eq!(row.cells[1].x, "PM");
1551        }
1552        assert_eq!(p.rows[0].name, "Mon");
1553        assert_eq!(p.rows[0].cells[1].y, 0.0); // short row padded
1554        assert_eq!(p.rows[1].cells[1].y, 2.0); // extra `99.0` dropped
1555        assert_eq!(p.rows[2].cells[0].y, 4.0);
1556    }
1557
1558    #[test]
1559    fn heatmap_payload_serializes_with_kind_tag() {
1560        let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
1561            ["Row"],
1562            ["a", "b"],
1563            vec![vec![7.0, 8.0]],
1564        ));
1565        let json = serde_json::to_value(&payload).expect("serialize");
1566        assert_eq!(json["kind"], "heatmap");
1567        assert_eq!(json["rows"][0]["name"], "Row");
1568        assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
1569        assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
1570    }
1571
1572    #[test]
1573    fn progress_kind_serializes_as_progress() {
1574        assert_eq!(WidgetKind::Progress.as_str(), "progress");
1575    }
1576
1577    #[test]
1578    fn progress_from_pairs_sizes_against_largest_value() {
1579        let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
1580        assert_eq!(p.items.len(), 3);
1581        // The largest value fills the bar; the rest are proportional.
1582        assert_eq!(p.items[0].percent, 100.0);
1583        assert_eq!(p.items[1].percent, 50.0);
1584        assert_eq!(p.items[2].percent, 25.0);
1585        // `display` is the thousands-grouped value, order preserved.
1586        assert_eq!(p.items[0].label, "A");
1587        assert_eq!(p.items[0].display, "100");
1588    }
1589
1590    #[test]
1591    fn progress_from_pairs_of_sizes_against_target_and_clamps() {
1592        let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
1593        assert_eq!(p.items[0].percent, 82.0);
1594        // Over target fills the bar rather than overrunning it.
1595        assert_eq!(p.items[1].percent, 100.0);
1596    }
1597
1598    #[test]
1599    fn progress_non_positive_target_falls_back_to_max() {
1600        // target = 0 -> size against the largest value (40 -> 100%).
1601        let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
1602        assert_eq!(p.items[0].percent, 100.0);
1603        assert_eq!(p.items[1].percent, 25.0);
1604    }
1605
1606    #[test]
1607    fn progress_payload_serializes_with_kind_tag() {
1608        let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
1609        let json = serde_json::to_value(&payload).expect("serialize");
1610        assert_eq!(json["kind"], "progress");
1611        assert_eq!(json["items"][0]["label"], "Pro");
1612        assert_eq!(json["items"][0]["display"], "48,200");
1613        assert_eq!(json["items"][0]["percent"], 100.0);
1614        // No explicit color -> the field is skipped entirely.
1615        assert!(json["items"][0].get("color").is_none());
1616    }
1617}
1618
1619#[cfg(test)]
1620mod csv_tests {
1621    use super::*;
1622
1623    #[test]
1624    fn csv_quotes_commas_quotes_and_newlines() {
1625        assert_eq!(csv_field("plain"), "plain");
1626        assert_eq!(csv_field("a,b"), "\"a,b\"");
1627        assert_eq!(csv_field("say \"hi\""), "\"say \"\"hi\"\"\"");
1628        assert_eq!(csv_field("two\nlines"), "\"two\nlines\"");
1629    }
1630
1631    /// A cell beginning `=`, `+`, `-` or `@` is executed as a FORMULA when the
1632    /// file is opened in Excel or Sheets. A product name someone typed into your
1633    /// app then becomes code running on the machine of whoever opened the export.
1634    /// The value must survive as readable text, but inert.
1635    #[test]
1636    fn csv_neutralises_spreadsheet_formulas() {
1637        for (payload, still_reads) in [
1638            ("=HYPERLINK(\"http://evil\")", "HYPERLINK"),
1639            ("+1+1", "+1+1"),
1640            ("-2+3", "-2+3"),
1641            ("@SUM(A1)", "@SUM(A1)"),
1642        ] {
1643            let out = csv_field(payload);
1644            // The tab may sit inside the RFC-4180 quoting, so accept either.
1645            assert!(
1646                out.starts_with('\t') || out.starts_with("\"\t"),
1647                "a formula-leading cell must be neutralised, got {out}"
1648            );
1649            assert!(
1650                out.contains(still_reads),
1651                "and must remain readable, got {out}"
1652            );
1653        }
1654    }
1655
1656    #[test]
1657    fn a_bar_payload_exports_one_row_per_point() {
1658        let payload = WidgetPayload::Bar(BarPayload {
1659            series: vec![Series {
1660                name: "sales".into(),
1661                points: vec![
1662                    ChartPoint {
1663                        x: "Mon".into(),
1664                        y: 3.0,
1665                    },
1666                    ChartPoint {
1667                        x: "Tue".into(),
1668                        y: 4.5,
1669                    },
1670                ],
1671            }],
1672            x_type: "day".into(),
1673        });
1674        let csv = payload.to_csv().expect("a bar chart has rows");
1675        assert_eq!(csv, "series,x,y\nsales,Mon,3\nsales,Tue,4.5\n");
1676    }
1677
1678    #[test]
1679    fn a_kpi_has_nothing_to_export() {
1680        let payload = WidgetPayload::Kpi(KpiPayload {
1681            value: "42".into(),
1682            unit: None,
1683            delta: None,
1684            sparkline: None,
1685        });
1686        assert!(payload.to_csv().is_none());
1687    }
1688}