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::utils::fmt::{compact, percent, thousands};
5use tokenburn_core::{
6    BucketSavings, Collected, Currency, Pie, Query, ReportRow, SavedInput, Savings, Selection,
7    Summary, TimeSeries, collect, drill_priced,
8};
9
10/// Everything a page render needs.
11#[derive(Debug, Clone)]
12pub struct Dashboard {
13    pub query: Query,
14    pub total: Summary,
15    /// What prompt caching, rtk and pi compaction saved in this selection.
16    pub savings: Savings,
17    /// Savings per chart bucket (minute, hour, day or month), oldest first.
18    pub savings_by_bucket: Vec<BucketSavings>,
19    /// US dollars per million input tokens the savings are valued at.
20    pub input_price: f64,
21    pub report: Vec<ReportRow>,
22    pub projects: Vec<(String, Summary)>,
23    pub warnings: Vec<String>,
24    pub row_count: usize,
25    /// Stacked per-tool totals over time (the bar chart).
26    pub series: TimeSeries,
27    /// Share by project (the donut).
28    pub pie: Pie,
29    /// The clicked bar and/or slice that everything above is scoped to (stale
30    /// parts already dropped).
31    pub selection: Selection,
32    /// The display currency (costs in the data stay US dollars).
33    pub currency: Currency,
34    /// Local wall-clock time the snapshot was taken (`HH:MM:SS`).
35    pub updated_at: String,
36}
37
38/// Shape collected rows into a [`Dashboard`] scoped to `selection` (pure — no I/O).
39pub fn build_dashboard(
40    query: Query,
41    collected: Collected,
42    selection: &Selection,
43    currency: Currency,
44    input_price: f64,
45) -> Dashboard {
46    let d = drill_priced(
47        &collected.rows,
48        &query,
49        selection,
50        &SavedInput {
51            price: input_price,
52            external: Some(&collected.external),
53        },
54    );
55    Dashboard {
56        query,
57        total: d.total,
58        savings: d.savings,
59        savings_by_bucket: d.savings_by_bucket,
60        input_price,
61        report: d.report,
62        series: d.series,
63        pie: d.pie,
64        projects: d.projects,
65        selection: d.selection,
66        currency,
67        row_count: collected.rows.len(),
68        updated_at: Local::now().format("%H:%M:%S").to_string(),
69        warnings: collected.warnings,
70    }
71}
72
73/// How often a connected page re-reads the logs.
74pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
75
76/// Read the logs on a blocking thread and build the [`Dashboard`].
77pub async fn load(query: Query, selection: &Selection, currency: Currency) -> Dashboard {
78    let collected =
79        tokio::task::spawn_blocking(move || collect(query.tool, query.window.start(Local::now())))
80            .await
81            .unwrap_or_else(|e| Collected {
82                rows: Vec::new(),
83                warnings: vec![format!("loader failed: {e}")],
84                ..Default::default()
85            });
86    build_dashboard(
87        query,
88        collected,
89        selection,
90        currency,
91        tokenburn_core::settings::input_price(),
92    )
93}
94
95/// One printed row of the savings table.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct SavingsRow {
98    pub label: String,
99    pub tokens: String,
100    pub money: String,
101    pub basis: String,
102    pub total: bool,
103}
104
105/// The savings table: a row per source and a closing total (empty when nothing
106/// was saved). `spent_usd` gives the share of the would-be cost.
107pub fn savings_rows(sv: &Savings, currency: Currency, spent_usd: f64) -> Vec<SavingsRow> {
108    if sv.is_empty() {
109        return Vec::new();
110    }
111    let mut rows: Vec<SavingsRow> = sv
112        .sources
113        .iter()
114        .map(|s| SavingsRow {
115            label: s.source.label().to_string(),
116            tokens: thousands(s.tokens),
117            money: currency.format(s.usd),
118            basis: format!("{} {}", thousands(s.events), s.source.event_noun()),
119            total: false,
120        })
121        .collect();
122    rows.push(SavingsRow {
123        label: "total".to_string(),
124        tokens: thousands(sv.tokens),
125        money: currency.format(sv.usd),
126        basis: sv
127            .percent_of_would_be(spent_usd)
128            .map(|p| format!("{} of the would-be cost", percent(p)))
129            .unwrap_or_default(),
130        total: true,
131    });
132    rows
133}
134
135/// One printed row of the per-interval savings table.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct BucketRow {
138    pub label: String,
139    pub tokens: String,
140    pub money: String,
141    /// `Prompt cache $9.00 · rtk $1.00`
142    pub split: String,
143}
144
145/// The per-interval savings table, newest first, at most `limit` rows. The
146/// interval is the chart's: a minute, hour, day or month.
147pub fn bucket_rows(buckets: &[BucketSavings], currency: Currency, limit: usize) -> Vec<BucketRow> {
148    buckets
149        .iter()
150        .rev()
151        .take(limit)
152        .map(|b| BucketRow {
153            label: b.label.clone(),
154            tokens: thousands(b.savings.tokens),
155            money: currency.format(b.savings.usd),
156            split: b
157                .savings
158                .sources
159                .iter()
160                .map(|s| format!("{} {}", s.source.label(), currency.format(s.usd)))
161                .collect::<Vec<_>>()
162                .join(" · "),
163        })
164        .collect()
165}
166
167/// `minute`, `hour`, `day` or `month`: the interval a query's savings are grouped by.
168pub fn bucket_noun(query: &Query) -> &'static str {
169    match tokenburn_core::chart_bucket(query) {
170        tokenburn_core::Bucket::Minute => "minute",
171        tokenburn_core::Bucket::Hour => "hour",
172        tokenburn_core::Bucket::Day => "day",
173        _ => "month",
174    }
175}
176
177/// How many intervals the savings table lists.
178pub const MAX_BUCKET_ROWS: usize = 24;
179
180/// `compact` tokens + money, for the headline card's sub-label.
181pub fn savings_headline(sv: &Savings, currency: Currency) -> String {
182    format!("{} tok · {}", compact(sv.tokens), currency.format(sv.usd))
183}
184
185/// Width (0–100) of a share bar for `value` against `max`.
186pub fn share_percent(value: u64, max: u64) -> u32 {
187    if max == 0 {
188        0
189    } else {
190        ((value as f64 / max as f64) * 100.0)
191            .round()
192            .clamp(0.0, 100.0) as u32
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use tokenburn_core::{Row, Tool};
200
201    fn row(cost: f64) -> Row {
202        Row {
203            tool: Tool::Pi,
204            project: "p".into(),
205            id: "i".into(),
206            ts: "2026-05-01T10:00:00Z".parse().unwrap(),
207            input: 10,
208            output: 5,
209            cache_read: 0,
210            cache_write: 0,
211            cost,
212        }
213    }
214
215    #[test]
216    fn dashboard_totals_and_counts() {
217        let c = Collected {
218            rows: vec![row(0.5), row(0.25)],
219            warnings: vec!["w".into()],
220            ..Default::default()
221        };
222        let d = build_dashboard(
223            Query::default(),
224            c,
225            &Selection::default(),
226            Currency::USD,
227            3.0,
228        );
229        assert_eq!(d.row_count, 2);
230        assert_eq!(d.total.input, 20);
231        assert_eq!(d.warnings.len(), 1);
232        assert_eq!(d.updated_at.len(), 8, "HH:MM:SS");
233        assert_eq!(d.report.last().unwrap().tool, "TOTAL");
234    }
235
236    #[test]
237    fn empty_dashboard_has_no_report() {
238        let d = build_dashboard(
239            Query::default(),
240            Collected::default(),
241            &Selection::default(),
242            Currency::USD,
243            3.0,
244        );
245        assert!(d.report.is_empty() && d.projects.is_empty());
246    }
247
248    #[test]
249    fn a_selection_scopes_the_dashboard_and_stale_ones_are_dropped() {
250        let c = || Collected {
251            rows: vec![row(0.5), row(0.25)],
252            warnings: vec![],
253            ..Default::default()
254        };
255        let sel = Selection {
256            project: Some("pi:p".into()),
257            ..Selection::default()
258        };
259        let d = build_dashboard(Query::default(), c(), &sel, Currency::USD, 3.0);
260        assert_eq!(d.selection, sel);
261        assert_eq!(d.total.turns, 2);
262
263        let stale = Selection {
264            project: Some("pi:ghost".into()),
265            bucket: Some("nope".into()),
266        };
267        let d = build_dashboard(Query::default(), c(), &stale, Currency::USD, 3.0);
268        assert!(d.selection.is_empty());
269        assert_eq!(d.total.turns, 2);
270    }
271
272    #[test]
273    fn share_percent_bounds() {
274        assert_eq!(share_percent(0, 0), 0);
275        assert_eq!(share_percent(50, 100), 50);
276        assert_eq!(share_percent(200, 100), 100);
277    }
278
279    fn cached(read: u64) -> Row {
280        Row {
281            cache_read: read,
282            ..row(0.5)
283        }
284    }
285
286    fn external() -> tokenburn_core::ExternalSavings {
287        tokenburn_core::ExternalSavings {
288            rtk_tokens: 1_000_000,
289            rtk_commands: 5,
290            compaction_tokens: 0,
291            compactions: 0,
292            ..Default::default()
293        }
294    }
295
296    #[test]
297    fn the_dashboard_carries_savings_valued_at_the_price() {
298        let c = Collected {
299            rows: vec![cached(1_000_000), row(0.5)],
300            external: external(),
301            ..Default::default()
302        };
303        let d = build_dashboard(
304            Query::default(),
305            c,
306            &Selection::default(),
307            Currency::USD,
308            10.0,
309        );
310        assert_eq!(d.input_price, 10.0);
311        assert_eq!(d.savings.tokens, 2_000_000);
312        assert!((d.savings.usd - 19.0).abs() < 1e-9, "9 cache + 10 rtk");
313    }
314
315    #[test]
316    fn a_project_selection_keeps_only_that_projects_cache() {
317        let mut other = cached(1_000_000);
318        other.project = "other".into();
319        let c = Collected {
320            rows: vec![cached(2_000_000), other],
321            external: external(),
322            ..Default::default()
323        };
324        let sel = Selection {
325            project: Some("pi:other".into()),
326            ..Selection::default()
327        };
328        let d = build_dashboard(Query::default(), c, &sel, Currency::USD, 10.0);
329        assert_eq!(
330            d.savings.tokens, 1_000_000,
331            "only the project's cache; rtk is not per project"
332        );
333    }
334
335    #[test]
336    fn savings_rows_have_a_line_per_source_and_a_total() {
337        let c = Collected {
338            rows: vec![cached(1_000_000)],
339            external: external(),
340            ..Default::default()
341        };
342        let d = build_dashboard(
343            Query::default(),
344            c,
345            &Selection::default(),
346            Currency::USD,
347            10.0,
348        );
349        let rows = savings_rows(&d.savings, d.currency, d.total.cost_usd);
350        let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect();
351        assert_eq!(labels, ["Prompt cache", "rtk", "total"]);
352        assert_eq!(rows[0].money, "$9.00");
353        assert_eq!(
354            (rows[1].tokens.as_str(), rows[1].basis.as_str()),
355            ("1,000,000", "5 commands")
356        );
357        assert!(rows[2].total && !rows[0].total);
358        assert_eq!(rows[2].money, "$19.00");
359        assert!(
360            rows[2].basis.contains("97% of the would-be cost"),
361            "{}",
362            rows[2].basis
363        );
364        assert_eq!(
365            savings_headline(&d.savings, d.currency),
366            "2.00M tok · $19.00"
367        );
368        let eur = tokenburn_core::currency_by_code("EUR").unwrap();
369        assert_eq!(savings_rows(&d.savings, eur, 0.5)[2].money, "€17.48");
370    }
371
372    #[test]
373    fn no_savings_no_rows() {
374        let d = build_dashboard(
375            Query::default(),
376            Collected {
377                rows: vec![row(0.5)],
378                ..Default::default()
379            },
380            &Selection::default(),
381            Currency::USD,
382            3.0,
383        );
384        assert!(d.savings.is_empty());
385        assert!(savings_rows(&d.savings, d.currency, 0.5).is_empty());
386    }
387
388    fn at(ts: &str) -> chrono::DateTime<chrono::Utc> {
389        ts.parse().unwrap()
390    }
391
392    /// Day 1: 1M cache tokens + a compaction; day 2: rtk.
393    fn two_days() -> Collected {
394        let mut day1 = cached(1_000_000);
395        day1.ts = at("2026-05-01T10:00:00Z");
396        let mut day2 = row(0.5);
397        day2.ts = at("2026-05-02T10:00:00Z");
398        Collected {
399            rows: vec![day1, day2],
400            external: tokenburn_core::ExternalSavings {
401                rtk_tokens: 1_000_000,
402                rtk_commands: 4,
403                compaction_tokens: 1_000_000,
404                compactions: 1,
405                rtk_events: vec![(at("2026-05-02T10:00:00Z"), 1_000_000, 4)],
406                compaction_events: vec![(at("2026-05-01T10:00:00Z"), 1_000_000)],
407            },
408            ..Default::default()
409        }
410    }
411
412    #[test]
413    fn savings_are_listed_per_interval_newest_first() {
414        let d = build_dashboard(
415            Query::default(),
416            two_days(),
417            &Selection::default(),
418            Currency::USD,
419            10.0,
420        );
421        let rows = bucket_rows(&d.savings_by_bucket, d.currency, 10);
422        assert_eq!(rows.len(), 2);
423        assert!(rows[0].label > rows[1].label, "newest first");
424        assert_eq!(
425            (rows[0].money.as_str(), rows[0].split.as_str()),
426            ("$10.00", "rtk $10.00")
427        );
428        assert_eq!(rows[1].money, "$19.00");
429        assert_eq!(rows[1].split, "Prompt cache $9.00 · pi compaction $10.00");
430        assert_eq!(
431            bucket_rows(&d.savings_by_bucket, d.currency, 1).len(),
432            1,
433            "limit"
434        );
435        assert_eq!(bucket_noun(&d.query), "day", "month window charts by day");
436    }
437
438    #[test]
439    fn the_interval_follows_the_bucket_filter() {
440        let q = |bucket| Query {
441            bucket,
442            ..Query::default()
443        };
444        for (bucket, noun, rows) in [
445            (tokenburn_core::Bucket::Minute, "minute", 2),
446            (tokenburn_core::Bucket::Hour, "hour", 2),
447            (tokenburn_core::Bucket::Day, "day", 2),
448            (tokenburn_core::Bucket::Month, "month", 1),
449        ] {
450            let d = build_dashboard(
451                q(bucket),
452                two_days(),
453                &Selection::default(),
454                Currency::USD,
455                10.0,
456            );
457            assert_eq!(bucket_noun(&d.query), noun);
458            assert_eq!(d.savings_by_bucket.len(), rows, "{noun}");
459        }
460    }
461
462    #[test]
463    fn a_selected_bar_narrows_every_source_to_that_interval() {
464        let day1 = tokenburn_core::bucket_label_at(
465            at("2026-05-01T10:00:00Z"),
466            tokenburn_core::Bucket::Day,
467        );
468        let sel = Selection {
469            bucket: Some(day1),
470            ..Selection::default()
471        };
472        let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
473        assert!(
474            (d.savings.usd - 19.0).abs() < 1e-9,
475            "cache + compaction, no rtk: {}",
476            d.savings.usd
477        );
478        assert_eq!(
479            d.savings_by_bucket.len(),
480            2,
481            "the list keeps every interval, like the bars"
482        );
483    }
484
485    #[test]
486    fn a_selected_project_drops_the_unattributed_sources() {
487        let sel = Selection {
488            project: Some("pi:p".into()),
489            ..Selection::default()
490        };
491        let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
492        assert_eq!(d.savings.sources.len(), 1);
493        assert!((d.savings.usd - 9.0).abs() < 1e-9);
494    }
495}