Skip to main content

tokenburn_web/
data.rs

1//! Loading and shaping the data shown on the dashboard.
2
3use chrono::Local;
4use tokenburn_core::{
5    Collected, Pie, Query, ReportRow, Selection, Summary, TimeSeries, collect, drill,
6};
7
8/// Everything a page render needs.
9#[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    /// Stacked per-tool totals over time (the bar chart).
18    pub series: TimeSeries,
19    /// Share by project (the donut).
20    pub pie: Pie,
21    /// The clicked bar and/or slice that everything above is scoped to (stale
22    /// parts already dropped).
23    pub selection: Selection,
24    /// Local wall-clock time the snapshot was taken (`HH:MM:SS`).
25    pub updated_at: String,
26}
27
28/// Shape collected rows into a [`Dashboard`] scoped to `selection` (pure — no I/O).
29pub 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
45/// How often a connected page re-reads the logs.
46pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
47
48/// Read the logs on a blocking thread and build the [`Dashboard`].
49pub 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
60/// Width (0–100) of a share bar for `value` against `max`.
61pub 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}