1use 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#[derive(Debug, Clone)]
12pub struct Dashboard {
13 pub query: Query,
14 pub total: Summary,
15 pub savings: Savings,
17 pub savings_by_bucket: Vec<BucketSavings>,
19 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 pub series: TimeSeries,
27 pub pie: Pie,
29 pub selection: Selection,
32 pub currency: Currency,
34 pub updated_at: String,
36}
37
38pub 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
73pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
75
76pub 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#[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
105pub 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#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct BucketRow {
138 pub label: String,
139 pub tokens: String,
140 pub money: String,
141 pub split: String,
143}
144
145pub 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
167pub 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
177pub const MAX_BUCKET_ROWS: usize = 24;
179
180pub fn savings_headline(sv: &Savings, currency: Currency) -> String {
182 format!("{} tok · {}", compact(sv.tokens), currency.format(sv.usd))
183}
184
185pub 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 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}