1use chrono::Local;
4use tokenburn_core::{
5 Collected, Currency, Pie, Query, ReportRow, Selection, Summary, TimeSeries, collect, drill,
6};
7
8#[derive(Debug, Clone)]
10pub struct Dashboard {
11 pub query: Query,
12 pub total: Summary,
13 pub report: Vec<ReportRow>,
14 pub projects: Vec<(String, Summary)>,
15 pub warnings: Vec<String>,
16 pub row_count: usize,
17 pub series: TimeSeries,
19 pub pie: Pie,
21 pub selection: Selection,
24 pub currency: Currency,
26 pub updated_at: String,
28}
29
30pub fn build_dashboard(
32 query: Query,
33 collected: Collected,
34 selection: &Selection,
35 currency: Currency,
36) -> Dashboard {
37 let d = drill(&collected.rows, &query, selection);
38 Dashboard {
39 query,
40 total: d.total,
41 report: d.report,
42 series: d.series,
43 pie: d.pie,
44 projects: d.projects,
45 selection: d.selection,
46 currency,
47 row_count: collected.rows.len(),
48 updated_at: Local::now().format("%H:%M:%S").to_string(),
49 warnings: collected.warnings,
50 }
51}
52
53pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
55
56pub async fn load(query: Query, selection: &Selection, currency: Currency) -> Dashboard {
58 let collected =
59 tokio::task::spawn_blocking(move || collect(query.tool, query.window.start(Local::now())))
60 .await
61 .unwrap_or_else(|e| Collected {
62 rows: Vec::new(),
63 warnings: vec![format!("loader failed: {e}")],
64 });
65 build_dashboard(query, collected, selection, currency)
66}
67
68pub fn share_percent(value: u64, max: u64) -> u32 {
70 if max == 0 {
71 0
72 } else {
73 ((value as f64 / max as f64) * 100.0)
74 .round()
75 .clamp(0.0, 100.0) as u32
76 }
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use tokenburn_core::{Row, Tool};
83
84 fn row(cost: f64) -> Row {
85 Row {
86 tool: Tool::Pi,
87 project: "p".into(),
88 id: "i".into(),
89 ts: "2026-05-01T10:00:00Z".parse().unwrap(),
90 input: 10,
91 output: 5,
92 cache_read: 0,
93 cache_write: 0,
94 cost,
95 }
96 }
97
98 #[test]
99 fn dashboard_totals_and_counts() {
100 let c = Collected {
101 rows: vec![row(0.5), row(0.25)],
102 warnings: vec!["w".into()],
103 };
104 let d = build_dashboard(Query::default(), c, &Selection::default(), Currency::USD);
105 assert_eq!(d.row_count, 2);
106 assert_eq!(d.total.input, 20);
107 assert_eq!(d.warnings.len(), 1);
108 assert_eq!(d.updated_at.len(), 8, "HH:MM:SS");
109 assert_eq!(d.report.last().unwrap().tool, "TOTAL");
110 }
111
112 #[test]
113 fn empty_dashboard_has_no_report() {
114 let d = build_dashboard(
115 Query::default(),
116 Collected::default(),
117 &Selection::default(),
118 Currency::USD,
119 );
120 assert!(d.report.is_empty() && d.projects.is_empty());
121 }
122
123 #[test]
124 fn a_selection_scopes_the_dashboard_and_stale_ones_are_dropped() {
125 let c = || Collected {
126 rows: vec![row(0.5), row(0.25)],
127 warnings: vec![],
128 };
129 let sel = Selection {
130 project: Some("pi:p".into()),
131 ..Selection::default()
132 };
133 let d = build_dashboard(Query::default(), c(), &sel, Currency::USD);
134 assert_eq!(d.selection, sel);
135 assert_eq!(d.total.turns, 2);
136
137 let stale = Selection {
138 project: Some("pi:ghost".into()),
139 bucket: Some("nope".into()),
140 };
141 let d = build_dashboard(Query::default(), c(), &stale, Currency::USD);
142 assert!(d.selection.is_empty());
143 assert_eq!(d.total.turns, 2);
144 }
145
146 #[test]
147 fn share_percent_bounds() {
148 assert_eq!(share_percent(0, 0), 0);
149 assert_eq!(share_percent(50, 100), 50);
150 assert_eq!(share_percent(200, 100), 100);
151 }
152}