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// =========================================================================
789// WidgetDataFn
790// =========================================================================
791
792/// Per-request parameters a widget's data closure can read.
793/// Sourced from the query string on
794/// `GET /admin/api/dashboard/widgets/<key>/data?<params>`.
795///
796/// Defaults are all `None` — closures that don't care can use
797/// `WidgetDataFn::new(|user| ...)` and ignore params entirely.
798/// Closures that DO care use `WidgetDataFn::with_params` and
799/// branch on `params.period` / `params.start` / `params.end`.
800#[derive(Debug, Clone, Default)]
801pub struct WidgetParams {
802    /// Period preset like `"7d"`, `"30d"`, `"90d"`. The
803    /// rendering side emits chips that pass this through.
804    pub period: Option<String>,
805    /// Explicit ISO start date (`YYYY-MM-DD`) — overrides
806    /// `period` when both are present.
807    pub start: Option<String>,
808    /// Explicit ISO end date (`YYYY-MM-DD`).
809    pub end: Option<String>,
810    /// Catch-all for any other widget-specific query params
811    /// — `?model=order` for a future per-model filter, etc.
812    /// Closures read by `params.raw.get("...")`.
813    pub raw: std::collections::HashMap<String, String>,
814}
815
816impl WidgetParams {
817    /// Build from a `?key=value&...` query string. Recognised
818    /// keys (`period`, `start`, `end`) populate the typed
819    /// fields; the rest land in `raw`.
820    pub fn from_query<S: AsRef<str>>(query: S) -> Self {
821        let mut out = Self::default();
822        for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
823            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
824            let value = urlencoding_decode(v);
825            match k {
826                "period" => out.period = Some(value),
827                "start" => out.start = Some(value),
828                "end" => out.end = Some(value),
829                _ => {
830                    out.raw.insert(k.to_string(), value);
831                }
832            }
833        }
834        out
835    }
836
837    /// Number of days the `period` preset represents. `"7d"`
838    /// → 7, `"30d"` → 30, `"90d"` → 90. None for unrecognised /
839    /// missing values so callers fall back to a default.
840    pub fn period_days(&self) -> Option<i64> {
841        let p = self.period.as_deref()?;
842        let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
843        digits.parse().ok()
844    }
845}
846
847/// Minimal `%XX` → byte decoder; avoids pulling a query-string
848/// crate just for the four chars we need (`+` → space, `%2F` → `/`,
849/// etc.). Anything malformed passes through unchanged.
850fn urlencoding_decode(raw: &str) -> String {
851    let mut out = String::with_capacity(raw.len());
852    let bytes = raw.as_bytes();
853    let mut i = 0;
854    while i < bytes.len() {
855        match bytes[i] {
856            b'+' => {
857                out.push(' ');
858                i += 1;
859            }
860            b'%' if i + 2 < bytes.len() => {
861                let hi = (bytes[i + 1] as char).to_digit(16);
862                let lo = (bytes[i + 2] as char).to_digit(16);
863                if let (Some(h), Some(l)) = (hi, lo) {
864                    out.push(char::from((h as u8) * 16 + l as u8));
865                    i += 3;
866                } else {
867                    out.push(bytes[i] as char);
868                    i += 1;
869                }
870            }
871            b => {
872                out.push(b as char);
873                i += 1;
874            }
875        }
876    }
877    out
878}
879
880pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
881pub(crate) type DataFnInner =
882    Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
883
884/// Wrapper around the async data closure. Build via
885/// [`WidgetDataFn::new`] (closure ignores per-request params) or
886/// [`WidgetDataFn::with_params`] (closure reads `WidgetParams` to
887/// honour period / date-range filters from the request URL).
888#[derive(Clone)]
889pub struct WidgetDataFn(pub(crate) DataFnInner);
890
891impl WidgetDataFn {
892    /// Create from any `async fn(AuthUser) -> WidgetPayload` —
893    /// per-request params are dropped on the floor. Use when the
894    /// widget renders the same thing regardless of UI controls
895    /// (KPI counts, registry sizes, etc.).
896    pub fn new<F, Fut>(f: F) -> Self
897    where
898        F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
899        Fut: Future<Output = WidgetPayload> + Send + 'static,
900    {
901        Self(Arc::new(move |user, _params| Box::pin(f(user))))
902    }
903
904    /// Create from `async fn(AuthUser, WidgetParams) ->
905    /// WidgetPayload`. Use for filterable widgets — the line
906    /// chart reads `params.period` to switch between 7d / 30d /
907    /// 90d views, a future table widget might read
908    /// `params.raw.get("status")` for status filtering, etc.
909    pub fn with_params<F, Fut>(f: F) -> Self
910    where
911        F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
912        Fut: Future<Output = WidgetPayload> + Send + 'static,
913    {
914        Self(Arc::new(move |user, params| Box::pin(f(user, params))))
915    }
916}
917
918impl std::fmt::Debug for WidgetDataFn {
919    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
920        f.write_str("WidgetDataFn(<fn>)")
921    }
922}
923
924// =========================================================================
925// Widget
926// =========================================================================
927
928/// A registered dashboard widget.
929///
930/// Register via `AdminPlugin::register_widget(...)`.
931#[derive(Debug, Clone)]
932pub struct Widget {
933    /// URL-safe unique key, e.g. `"umbral_total_models"`.
934    pub key: &'static str,
935    /// Human-readable title shown in the widget card header.
936    pub title: String,
937    /// Determines which renderer (KPI card, chart, table, feed).
938    pub kind: WidgetKind,
939    /// Default grid span when the user hasn't customized.
940    pub default_span: Span,
941    /// Optional permission codename. `None` = any staff user may see.
942    pub permission: Option<&'static str>,
943    /// Async function that computes and returns the payload.
944    pub data: WidgetDataFn,
945    /// Default period preset used by line/bar/etc. widgets that
946    /// carry a period-chip strip — `"7d"`, `"30d"`, `"90d"`. When
947    /// `Some`, the handler pre-fills `WidgetParams.period` from
948    /// this value on first load (no `?period=` in the URL), so
949    /// the matching chip renders highlighted AND the data
950    /// closure receives the same period via `params.period_days()`.
951    /// `None` falls back to whatever the template / data closure
952    /// chooses as its fallback.
953    pub default_period: Option<&'static str>,
954}
955
956impl Widget {
957    /// Override the default grid span. Lets a caller resize a
958    /// builtin (or any pre-built widget) at registration time
959    /// without having to re-construct the whole struct literal:
960    ///
961    /// ```rust,ignore
962    /// .register_widget(builtin_total_models_widget().with_span(6, 2))
963    /// .register_widget(builtin_recent_users_widget().with_span(6, 2))
964    /// ```
965    ///
966    /// `cols` is clamped at the 12-col grid; `rows` is whatever
967    /// the dashboard's `auto-rows-[...]` accepts (1 = 120px).
968    pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
969        self.default_span = Span { cols, rows };
970        self
971    }
972
973    /// Pre-select a period chip on the widget — `"7d"`, `"30d"`,
974    /// `"90d"`. On first load (no `?period=` in the URL), the
975    /// handler stamps this into `WidgetParams.period` before
976    /// calling the data closure, so the chip strip highlights
977    /// the right preset AND the data fn computes the right
978    /// window. Override on a per-request basis happens via the
979    /// chip clicks (which send their own `?period=` query).
980    ///
981    /// ```ignore
982    /// shop_daily_sales_chart().with_default_period("7d")
983    /// // → first paint shows 7d highlighted, 7 days of data;
984    /// //   clicking "30d" hands control to the URL state.
985    /// ```
986    pub fn with_default_period(mut self, period: &'static str) -> Self {
987        self.default_period = Some(period);
988        self
989    }
990}
991
992// =========================================================================
993// WidgetInstance (user's saved layout entry)
994// =========================================================================
995
996/// One entry in a user's saved layout JSON.
997#[derive(Debug, Clone, Serialize, Deserialize)]
998pub struct WidgetInstance {
999    pub key: String,
1000    pub span: Span,
1001}
1002
1003// =========================================================================
1004// Widget catalog entry (API response shape)
1005// =========================================================================
1006
1007/// Serialized catalog entry returned by `GET /admin/api/dashboard/catalog`.
1008#[derive(Debug, Clone, Serialize)]
1009pub struct CatalogEntry {
1010    pub key: &'static str,
1011    pub title: String,
1012    pub kind: String,
1013    pub default_span: Span,
1014}
1015
1016// =========================================================================
1017// Sections — grouped widgets
1018// =========================================================================
1019
1020/// A named group of widgets on the dashboard. Each section renders
1021/// as its own heading + (optional) subtitle + widget grid, so a
1022/// dashboard with 20 widgets reads as themed clusters rather than
1023/// one mega-grid.
1024///
1025/// Build with the chainable API:
1026///
1027/// ```rust,ignore
1028/// use umbral_admin::WidgetSection;
1029///
1030/// let sales = WidgetSection::new("Sales overview")
1031///     .subtitle("Daily KPIs across the storefront")
1032///     .widget(shop_total_sales_widget())
1033///     .widget(shop_orders_widget())
1034///     .widget(shop_avg_order_value_widget());
1035///
1036/// AdminPlugin::default().dashboard_section(sales);
1037/// ```
1038///
1039/// Register multiple sections by chaining `.dashboard_section(...)`.
1040/// Widgets registered via the legacy `.register_widget(...)` end up
1041/// in an implicit final section titled "Widgets" — so existing apps
1042/// keep working without code changes.
1043#[derive(Debug, Clone)]
1044pub struct WidgetSection {
1045    /// Heading shown above the section (e.g. "Sales overview").
1046    pub title: String,
1047    /// Optional descriptive line under the title — keep it short,
1048    /// it's not a paragraph.
1049    pub subtitle: Option<String>,
1050    /// Widgets in this section, rendered in registration order.
1051    pub widgets: Vec<Widget>,
1052}
1053
1054impl WidgetSection {
1055    /// New empty section with just a title.
1056    pub fn new(title: impl Into<String>) -> Self {
1057        Self {
1058            title: title.into(),
1059            subtitle: None,
1060            widgets: Vec::new(),
1061        }
1062    }
1063
1064    /// Add a one-line subtitle under the heading.
1065    pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
1066        self.subtitle = Some(subtitle.into());
1067        self
1068    }
1069
1070    /// Append one widget to the section.
1071    pub fn widget(mut self, w: Widget) -> Self {
1072        self.widgets.push(w);
1073        self
1074    }
1075
1076    /// Append many widgets at once (handy for splatting a Vec).
1077    pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
1078        self.widgets.extend(ws);
1079        self
1080    }
1081}
1082
1083#[cfg(test)]
1084mod tests {
1085    use super::*;
1086
1087    #[test]
1088    fn radial_kind_serializes_as_radial() {
1089        assert_eq!(WidgetKind::Radial.as_str(), "radial");
1090    }
1091
1092    #[test]
1093    fn radial_single_builds_one_track() {
1094        let p = RadialPayload::single("Monthly goal", 73.0);
1095        assert_eq!(p.tracks.len(), 1);
1096        assert_eq!(p.tracks[0].label, "Monthly goal");
1097        assert_eq!(p.tracks[0].value, 73.0);
1098        assert!(p.tracks[0].color.is_none());
1099    }
1100
1101    #[test]
1102    fn radial_clamps_out_of_range_and_non_finite_percents() {
1103        // Over 100, under 0, and non-finite all clamp into [0, 100].
1104        assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
1105        assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
1106        // Non-finite (NaN, ±∞) is meaningless as a percent -> 0.
1107        assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
1108        assert_eq!(
1109            RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
1110            0.0,
1111        );
1112    }
1113
1114    #[test]
1115    fn radial_goal_is_current_over_target() {
1116        assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
1117        // current > target clamps to 100 (overachieved, full ring).
1118        assert_eq!(
1119            RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
1120            100.0
1121        );
1122        // A non-positive target has nothing to measure against -> 0%.
1123        assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
1124    }
1125
1126    #[test]
1127    fn radial_from_pairs_keeps_order_and_clamps() {
1128        let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
1129        assert_eq!(p.tracks.len(), 3);
1130        assert_eq!(p.tracks[0].label, "Free");
1131        assert_eq!(p.tracks[1].value, 100.0); // clamped
1132        assert_eq!(p.tracks[2].label, "Team");
1133    }
1134
1135    #[test]
1136    fn radial_payload_serializes_with_kind_tag() {
1137        let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
1138        let json = serde_json::to_value(&payload).expect("serialize");
1139        assert_eq!(json["kind"], "radial");
1140        assert_eq!(json["tracks"][0]["label"], "Quota");
1141        assert_eq!(json["tracks"][0]["value"], 42.0);
1142        // No explicit color -> the field is skipped entirely.
1143        assert!(json["tracks"][0].get("color").is_none());
1144    }
1145
1146    #[test]
1147    fn heatmap_kind_serializes_as_heatmap() {
1148        assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
1149    }
1150
1151    #[test]
1152    fn heatmap_from_grid_is_rectangular_and_padded() {
1153        // A ragged matrix: row 0 short (padded with 0), row 1 long
1154        // (extra dropped), row 2 exact.
1155        let p = HeatmapPayload::from_grid(
1156            ["Mon", "Tue", "Wed"],
1157            ["AM", "PM"],
1158            vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
1159        );
1160        assert_eq!(p.rows.len(), 3);
1161        // Every row has exactly one cell per column label.
1162        for row in &p.rows {
1163            assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
1164            assert_eq!(row.cells[0].x, "AM");
1165            assert_eq!(row.cells[1].x, "PM");
1166        }
1167        assert_eq!(p.rows[0].name, "Mon");
1168        assert_eq!(p.rows[0].cells[1].y, 0.0); // short row padded
1169        assert_eq!(p.rows[1].cells[1].y, 2.0); // extra `99.0` dropped
1170        assert_eq!(p.rows[2].cells[0].y, 4.0);
1171    }
1172
1173    #[test]
1174    fn heatmap_payload_serializes_with_kind_tag() {
1175        let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
1176            ["Row"],
1177            ["a", "b"],
1178            vec![vec![7.0, 8.0]],
1179        ));
1180        let json = serde_json::to_value(&payload).expect("serialize");
1181        assert_eq!(json["kind"], "heatmap");
1182        assert_eq!(json["rows"][0]["name"], "Row");
1183        assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
1184        assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
1185    }
1186
1187    #[test]
1188    fn progress_kind_serializes_as_progress() {
1189        assert_eq!(WidgetKind::Progress.as_str(), "progress");
1190    }
1191
1192    #[test]
1193    fn progress_from_pairs_sizes_against_largest_value() {
1194        let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
1195        assert_eq!(p.items.len(), 3);
1196        // The largest value fills the bar; the rest are proportional.
1197        assert_eq!(p.items[0].percent, 100.0);
1198        assert_eq!(p.items[1].percent, 50.0);
1199        assert_eq!(p.items[2].percent, 25.0);
1200        // `display` is the thousands-grouped value, order preserved.
1201        assert_eq!(p.items[0].label, "A");
1202        assert_eq!(p.items[0].display, "100");
1203    }
1204
1205    #[test]
1206    fn progress_from_pairs_of_sizes_against_target_and_clamps() {
1207        let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
1208        assert_eq!(p.items[0].percent, 82.0);
1209        // Over target fills the bar rather than overrunning it.
1210        assert_eq!(p.items[1].percent, 100.0);
1211    }
1212
1213    #[test]
1214    fn progress_non_positive_target_falls_back_to_max() {
1215        // target = 0 -> size against the largest value (40 -> 100%).
1216        let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
1217        assert_eq!(p.items[0].percent, 100.0);
1218        assert_eq!(p.items[1].percent, 25.0);
1219    }
1220
1221    #[test]
1222    fn progress_payload_serializes_with_kind_tag() {
1223        let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
1224        let json = serde_json::to_value(&payload).expect("serialize");
1225        assert_eq!(json["kind"], "progress");
1226        assert_eq!(json["items"][0]["label"], "Pro");
1227        assert_eq!(json["items"][0]["display"], "48,200");
1228        assert_eq!(json["items"][0]["percent"], 100.0);
1229        // No explicit color -> the field is skipped entirely.
1230        assert!(json["items"][0].get("color").is_none());
1231    }
1232}