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