use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use umbral_auth::AuthUser;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Span {
pub cols: u8,
pub rows: u8,
}
impl Default for Span {
fn default() -> Self {
Self { cols: 3, rows: 1 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum WidgetKind {
Kpi,
Card,
Line,
Bar,
Donut,
Radial,
Heatmap,
Progress,
Table,
Feed,
}
impl WidgetKind {
pub fn as_str(&self) -> &'static str {
match self {
WidgetKind::Kpi => "kpi",
WidgetKind::Card => "card",
WidgetKind::Line => "line",
WidgetKind::Bar => "bar",
WidgetKind::Donut => "donut",
WidgetKind::Radial => "radial",
WidgetKind::Heatmap => "heatmap",
WidgetKind::Progress => "progress",
WidgetKind::Table => "table",
WidgetKind::Feed => "feed",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KpiPayload {
pub value: String,
pub unit: Option<String>,
pub delta: Option<f64>,
pub sparkline: Option<Vec<f64>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardPayload {
pub value: String,
pub unit: Option<String>,
pub icon: Option<String>,
pub subtitle: Option<String>,
pub delta_percent: Option<f64>,
pub delta_label: Option<String>,
pub sparkline: Option<Vec<f64>>,
}
impl CardPayload {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
unit: None,
icon: None,
subtitle: None,
delta_percent: None,
delta_label: None,
sparkline: None,
}
}
pub fn unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
pub fn icon(mut self, icon: impl Into<String>) -> Self {
self.icon = Some(icon.into());
self
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn growth(mut self, current: f64, previous: f64) -> Self {
if previous.is_finite() && previous != 0.0 && current.is_finite() {
self.delta_percent = Some(((current - previous) / previous.abs()) * 100.0);
}
self
}
pub fn delta(mut self, percent: f64, label: impl Into<String>) -> Self {
self.delta_percent = Some(percent);
self.delta_label = Some(label.into());
self
}
pub fn delta_label(mut self, label: impl Into<String>) -> Self {
self.delta_label = Some(label.into());
self
}
pub fn sparkline(mut self, points: impl IntoIterator<Item = f64>) -> Self {
self.sparkline = Some(points.into_iter().collect());
self
}
}
pub fn humanize_number(n: f64) -> String {
if !n.is_finite() {
return "—".to_string();
}
let abs = n.abs();
let sign = if n < 0.0 { "-" } else { "" };
if abs < 1000.0 {
if (abs.fract() - 0.0).abs() < f64::EPSILON {
return format!("{sign}{}", abs as i64);
}
return format!("{sign}{:.2}", abs);
}
if abs < 1_000_000.0 {
if abs < 10_000.0 {
return format_thousands(n);
}
return format!("{sign}{:.1}K", abs / 1_000.0);
}
if abs < 1_000_000_000.0 {
return format!("{sign}{:.2}M", abs / 1_000_000.0);
}
if abs < 1_000_000_000_000.0 {
return format!("{sign}{:.2}B", abs / 1_000_000_000.0);
}
format!("{sign}{:.2}T", abs / 1_000_000_000_000.0)
}
pub fn format_thousands(n: f64) -> String {
if !n.is_finite() {
return "—".to_string();
}
let sign = if n < 0.0 { "-" } else { "" };
let abs = n.abs();
let int_part = abs.trunc() as u128;
let frac_part = abs - abs.trunc();
let int_str = int_part.to_string();
let bytes = int_str.as_bytes();
let mut grouped = String::with_capacity(int_str.len() + int_str.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i) % 3 == 0 {
grouped.push(',');
}
grouped.push(*b as char);
}
if frac_part > 0.0 {
format!("{sign}{grouped}.{:02}", (frac_part * 100.0).round() as u64)
} else {
format!("{sign}{grouped}")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Series {
pub name: String,
pub points: Vec<ChartPoint>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChartPoint {
pub x: String,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinePayload {
pub series: Vec<Series>,
pub x_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BarPayload {
pub series: Vec<Series>,
pub x_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DonutSlice {
pub label: String,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DonutPayload {
pub slices: Vec<DonutSlice>,
}
impl DonutPayload {
pub fn new(slices: Vec<DonutSlice>) -> Self {
Self { slices }
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
Self::new(
pairs
.into_iter()
.map(|(label, value)| DonutSlice {
label: label.into(),
value,
color: None,
})
.collect(),
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadialTrack {
pub label: String,
pub value: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RadialPayload {
pub tracks: Vec<RadialTrack>,
}
impl RadialPayload {
pub fn new(tracks: Vec<RadialTrack>) -> Self {
Self {
tracks: tracks
.into_iter()
.map(|t| RadialTrack {
value: clamp_percent(t.value),
..t
})
.collect(),
}
}
pub fn single(label: impl Into<String>, percent: f64) -> Self {
Self::new(vec![RadialTrack {
label: label.into(),
value: percent,
color: None,
}])
}
pub fn goal(label: impl Into<String>, current: f64, target: f64) -> Self {
let pct = if target > 0.0 {
current / target * 100.0
} else {
0.0
};
Self::single(label, pct)
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
Self::new(
pairs
.into_iter()
.map(|(label, value)| RadialTrack {
label: label.into(),
value,
color: None,
})
.collect(),
)
}
}
fn clamp_percent(v: f64) -> f64 {
if v.is_finite() {
v.clamp(0.0, 100.0)
} else {
0.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapCell {
pub x: String,
pub y: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapRow {
pub name: String,
pub cells: Vec<HeatmapCell>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeatmapPayload {
pub rows: Vec<HeatmapRow>,
}
impl HeatmapPayload {
pub fn new(rows: Vec<HeatmapRow>) -> Self {
Self { rows }
}
pub fn from_grid<R, C>(
row_labels: impl IntoIterator<Item = R>,
col_labels: impl IntoIterator<Item = C>,
values: Vec<Vec<f64>>,
) -> Self
where
R: Into<String>,
C: Into<String>,
{
let cols: Vec<String> = col_labels.into_iter().map(Into::into).collect();
let rows = row_labels
.into_iter()
.enumerate()
.map(|(r, label)| {
let row_vals = values.get(r);
let cells = cols
.iter()
.enumerate()
.map(|(c, x)| HeatmapCell {
x: x.clone(),
y: row_vals.and_then(|v| v.get(c)).copied().unwrap_or(0.0),
})
.collect();
HeatmapRow {
name: label.into(),
cells,
}
})
.collect();
Self { rows }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressItem {
pub label: String,
pub display: String,
pub percent: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgressPayload {
pub items: Vec<ProgressItem>,
}
impl ProgressPayload {
pub fn new(items: Vec<ProgressItem>) -> Self {
Self { items }
}
pub fn from_pairs<L: Into<String>>(pairs: impl IntoIterator<Item = (L, f64)>) -> Self {
let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
let reference = items
.iter()
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.fold(0.0_f64, f64::max);
Self::build(items, reference)
}
pub fn from_pairs_of<L: Into<String>>(
pairs: impl IntoIterator<Item = (L, f64)>,
target: f64,
) -> Self {
let items: Vec<(String, f64)> = pairs.into_iter().map(|(l, v)| (l.into(), v)).collect();
let reference = if target > 0.0 {
target
} else {
items
.iter()
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.fold(0.0_f64, f64::max)
};
Self::build(items, reference)
}
fn build(items: Vec<(String, f64)>, reference: f64) -> Self {
let items = items
.into_iter()
.map(|(label, value)| {
let percent = if reference > 0.0 && value.is_finite() {
(value / reference * 100.0).clamp(0.0, 100.0)
} else {
0.0
};
ProgressItem {
label,
display: format_thousands(value),
percent,
color: None,
}
})
.collect();
Self { items }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableColumn {
pub key: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TablePayload {
pub columns: Vec<TableColumn>,
pub rows: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_all_url: Option<String>,
}
impl TablePayload {
pub fn new(columns: Vec<TableColumn>, rows: Vec<serde_json::Value>) -> Self {
Self {
columns,
rows,
view_all_url: None,
}
}
pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
self.view_all_url = Some(format!(
"{}/{}/",
crate::branding::current().base_path,
T::TABLE,
));
self
}
pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
self.view_all_url = Some(url.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedItem {
pub actor: String,
pub verb: String,
pub object: String,
pub object_link: Option<String>,
pub at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FeedPayload {
pub items: Vec<FeedItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_all_url: Option<String>,
}
impl FeedPayload {
pub fn new(items: Vec<FeedItem>) -> Self {
Self {
items,
view_all_url: None,
}
}
pub fn view_all_for<T: umbral::orm::Model>(mut self) -> Self {
self.view_all_url = Some(format!(
"{}/{}/",
crate::branding::current().base_path,
T::TABLE,
));
self
}
pub fn view_all_url(mut self, url: impl Into<String>) -> Self {
self.view_all_url = Some(url.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum WidgetPayload {
Kpi(KpiPayload),
Card(CardPayload),
Line(LinePayload),
Bar(BarPayload),
Donut(DonutPayload),
Radial(RadialPayload),
Heatmap(HeatmapPayload),
Progress(ProgressPayload),
Table(TablePayload),
Feed(FeedPayload),
}
#[derive(Debug, Clone, Default)]
pub struct WidgetParams {
pub period: Option<String>,
pub start: Option<String>,
pub end: Option<String>,
pub raw: std::collections::HashMap<String, String>,
}
impl WidgetParams {
pub fn from_query<S: AsRef<str>>(query: S) -> Self {
let mut out = Self::default();
for pair in query.as_ref().split('&').filter(|s| !s.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let value = urlencoding_decode(v);
match k {
"period" => out.period = Some(value),
"start" => out.start = Some(value),
"end" => out.end = Some(value),
_ => {
out.raw.insert(k.to_string(), value);
}
}
}
out
}
pub fn period_days(&self) -> Option<i64> {
let p = self.period.as_deref()?;
let digits: String = p.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
}
}
fn urlencoding_decode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let bytes = raw.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(h), Some(l)) = (hi, lo) {
out.push(char::from((h as u8) * 16 + l as u8));
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
b => {
out.push(b as char);
i += 1;
}
}
}
out
}
pub(crate) type DataFuture = Pin<Box<dyn Future<Output = WidgetPayload> + Send + 'static>>;
pub(crate) type DataFnInner =
Arc<dyn Fn(AuthUser, WidgetParams) -> DataFuture + Send + Sync + 'static>;
#[derive(Clone)]
pub struct WidgetDataFn(pub(crate) DataFnInner);
impl WidgetDataFn {
pub fn new<F, Fut>(f: F) -> Self
where
F: Fn(AuthUser) -> Fut + Send + Sync + 'static,
Fut: Future<Output = WidgetPayload> + Send + 'static,
{
Self(Arc::new(move |user, _params| Box::pin(f(user))))
}
pub fn with_params<F, Fut>(f: F) -> Self
where
F: Fn(AuthUser, WidgetParams) -> Fut + Send + Sync + 'static,
Fut: Future<Output = WidgetPayload> + Send + 'static,
{
Self(Arc::new(move |user, params| Box::pin(f(user, params))))
}
}
impl std::fmt::Debug for WidgetDataFn {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("WidgetDataFn(<fn>)")
}
}
#[derive(Debug, Clone)]
pub struct Widget {
pub key: &'static str,
pub title: String,
pub kind: WidgetKind,
pub default_span: Span,
pub permission: Option<&'static str>,
pub data: WidgetDataFn,
pub default_period: Option<&'static str>,
}
impl Widget {
pub fn with_span(mut self, cols: u8, rows: u8) -> Self {
self.default_span = Span { cols, rows };
self
}
pub fn with_default_period(mut self, period: &'static str) -> Self {
self.default_period = Some(period);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WidgetInstance {
pub key: String,
pub span: Span,
}
#[derive(Debug, Clone, Serialize)]
pub struct CatalogEntry {
pub key: &'static str,
pub title: String,
pub kind: String,
pub default_span: Span,
}
#[derive(Debug, Clone)]
pub struct WidgetSection {
pub title: String,
pub subtitle: Option<String>,
pub widgets: Vec<Widget>,
}
impl WidgetSection {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
subtitle: None,
widgets: Vec::new(),
}
}
pub fn subtitle(mut self, subtitle: impl Into<String>) -> Self {
self.subtitle = Some(subtitle.into());
self
}
pub fn widget(mut self, w: Widget) -> Self {
self.widgets.push(w);
self
}
pub fn widgets(mut self, ws: impl IntoIterator<Item = Widget>) -> Self {
self.widgets.extend(ws);
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radial_kind_serializes_as_radial() {
assert_eq!(WidgetKind::Radial.as_str(), "radial");
}
#[test]
fn radial_single_builds_one_track() {
let p = RadialPayload::single("Monthly goal", 73.0);
assert_eq!(p.tracks.len(), 1);
assert_eq!(p.tracks[0].label, "Monthly goal");
assert_eq!(p.tracks[0].value, 73.0);
assert!(p.tracks[0].color.is_none());
}
#[test]
fn radial_clamps_out_of_range_and_non_finite_percents() {
assert_eq!(RadialPayload::single("over", 150.0).tracks[0].value, 100.0);
assert_eq!(RadialPayload::single("under", -20.0).tracks[0].value, 0.0);
assert_eq!(RadialPayload::single("nan", f64::NAN).tracks[0].value, 0.0);
assert_eq!(
RadialPayload::single("inf", f64::INFINITY).tracks[0].value,
0.0,
);
}
#[test]
fn radial_goal_is_current_over_target() {
assert_eq!(RadialPayload::goal("g", 73.0, 100.0).tracks[0].value, 73.0);
assert_eq!(
RadialPayload::goal("g", 120.0, 100.0).tracks[0].value,
100.0
);
assert_eq!(RadialPayload::goal("g", 5.0, 0.0).tracks[0].value, 0.0);
}
#[test]
fn radial_from_pairs_keeps_order_and_clamps() {
let p = RadialPayload::from_pairs([("Free", 8.0), ("Pro", 150.0), ("Team", 34.0)]);
assert_eq!(p.tracks.len(), 3);
assert_eq!(p.tracks[0].label, "Free");
assert_eq!(p.tracks[1].value, 100.0); assert_eq!(p.tracks[2].label, "Team");
}
#[test]
fn radial_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Radial(RadialPayload::single("Quota", 42.0));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "radial");
assert_eq!(json["tracks"][0]["label"], "Quota");
assert_eq!(json["tracks"][0]["value"], 42.0);
assert!(json["tracks"][0].get("color").is_none());
}
#[test]
fn heatmap_kind_serializes_as_heatmap() {
assert_eq!(WidgetKind::Heatmap.as_str(), "heatmap");
}
#[test]
fn heatmap_from_grid_is_rectangular_and_padded() {
let p = HeatmapPayload::from_grid(
["Mon", "Tue", "Wed"],
["AM", "PM"],
vec![vec![3.0], vec![1.0, 2.0, 99.0], vec![4.0, 5.0]],
);
assert_eq!(p.rows.len(), 3);
for row in &p.rows {
assert_eq!(row.cells.len(), 2, "row `{}` must be rectangular", row.name);
assert_eq!(row.cells[0].x, "AM");
assert_eq!(row.cells[1].x, "PM");
}
assert_eq!(p.rows[0].name, "Mon");
assert_eq!(p.rows[0].cells[1].y, 0.0); assert_eq!(p.rows[1].cells[1].y, 2.0); assert_eq!(p.rows[2].cells[0].y, 4.0);
}
#[test]
fn heatmap_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Heatmap(HeatmapPayload::from_grid(
["Row"],
["a", "b"],
vec![vec![7.0, 8.0]],
));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "heatmap");
assert_eq!(json["rows"][0]["name"], "Row");
assert_eq!(json["rows"][0]["cells"][0]["x"], "a");
assert_eq!(json["rows"][0]["cells"][1]["y"], 8.0);
}
#[test]
fn progress_kind_serializes_as_progress() {
assert_eq!(WidgetKind::Progress.as_str(), "progress");
}
#[test]
fn progress_from_pairs_sizes_against_largest_value() {
let p = ProgressPayload::from_pairs([("A", 100.0), ("B", 50.0), ("C", 25.0)]);
assert_eq!(p.items.len(), 3);
assert_eq!(p.items[0].percent, 100.0);
assert_eq!(p.items[1].percent, 50.0);
assert_eq!(p.items[2].percent, 25.0);
assert_eq!(p.items[0].label, "A");
assert_eq!(p.items[0].display, "100");
}
#[test]
fn progress_from_pairs_of_sizes_against_target_and_clamps() {
let p = ProgressPayload::from_pairs_of([("Web", 82.0), ("Mobile", 150.0)], 100.0);
assert_eq!(p.items[0].percent, 82.0);
assert_eq!(p.items[1].percent, 100.0);
}
#[test]
fn progress_non_positive_target_falls_back_to_max() {
let p = ProgressPayload::from_pairs_of([("A", 40.0), ("B", 10.0)], 0.0);
assert_eq!(p.items[0].percent, 100.0);
assert_eq!(p.items[1].percent, 25.0);
}
#[test]
fn progress_payload_serializes_with_kind_tag() {
let payload = WidgetPayload::Progress(ProgressPayload::from_pairs([("Pro", 48200.0)]));
let json = serde_json::to_value(&payload).expect("serialize");
assert_eq!(json["kind"], "progress");
assert_eq!(json["items"][0]["label"], "Pro");
assert_eq!(json["items"][0]["display"], "48,200");
assert_eq!(json["items"][0]["percent"], 100.0);
assert!(json["items"][0].get("color").is_none());
}
}