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, Currency, 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    /// The display currency (costs in the data stay US dollars).
25    pub currency: Currency,
26    /// Local wall-clock time the snapshot was taken (`HH:MM:SS`).
27    pub updated_at: String,
28}
29
30/// Shape collected rows into a [`Dashboard`] scoped to `selection` (pure — no I/O).
31pub 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
53/// How often a connected page re-reads the logs.
54pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
55
56/// Read the logs on a blocking thread and build the [`Dashboard`].
57pub 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
68/// Width (0–100) of a share bar for `value` against `max`.
69pub 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}