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 pub limits: tokenburn_core::LimitCheck,
38}
39
40pub fn build_dashboard(
42 query: Query,
43 collected: Collected,
44 selection: &Selection,
45 currency: Currency,
46 input_price: f64,
47) -> Dashboard {
48 let d = drill_priced(
49 &collected.rows,
50 &query,
51 selection,
52 &SavedInput {
53 price: input_price,
54 external: Some(&collected.external),
55 },
56 );
57 Dashboard {
58 query,
59 total: d.total,
60 savings: d.savings,
61 savings_by_bucket: d.savings_by_bucket,
62 input_price,
63 report: d.report,
64 series: d.series,
65 pie: d.pie,
66 projects: d.projects,
67 selection: d.selection,
68 currency,
69 row_count: collected.rows.len(),
70 updated_at: Local::now().format("%H:%M:%S").to_string(),
71 warnings: collected.warnings,
72 limits: Default::default(),
73 }
74}
75
76pub const REFRESH_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
78
79pub async fn load(query: Query, selection: &Selection, currency: Currency) -> Dashboard {
81 let collected =
82 tokio::task::spawn_blocking(move || collect(query.tool, query.window.start(Local::now())))
83 .await
84 .unwrap_or_else(|e| Collected {
85 rows: Vec::new(),
86 warnings: vec![format!("loader failed: {e}")],
87 ..Default::default()
88 });
89 let mut d = build_dashboard(
90 query,
91 collected,
92 selection,
93 currency,
94 tokenburn_core::settings::input_price(),
95 );
96 d.limits = check_limits(currency).await;
97 d
98}
99
100pub async fn check_limits(currency: Currency) -> tokenburn_core::LimitCheck {
103 tokio::task::spawn_blocking(move || {
104 tokenburn_core::run_limit_check(false, |v| currency.format(v))
105 })
106 .await
107 .unwrap_or_default()
108}
109
110pub const WATCH_EVERY: std::time::Duration = std::time::Duration::from_secs(30);
112
113pub async fn watch_limits() -> Vec<tokenburn_core::Alert> {
116 tokio::task::spawn_blocking(|| {
117 let currency = tokenburn_core::settings::currency();
118 tokenburn_core::run_limit_check(true, |v| currency.format(v)).alerts
119 })
120 .await
121 .unwrap_or_default()
122}
123
124pub fn bar_percent(s: &tokenburn_core::LimitStatus) -> u32 {
126 s.percent().min(100)
127}
128
129pub fn level_class(level: tokenburn_core::Level) -> &'static str {
131 match level {
132 tokenburn_core::Level::Ok => "limit ok",
133 tokenburn_core::Level::Warn => "limit warn",
134 tokenburn_core::Level::Over => "limit over",
135 }
136}
137
138pub fn apply_limit_params(
141 current: &tokenburn_core::Limits,
142 p: &crate::params::LimitParams,
143) -> Result<tokenburn_core::Limits, String> {
144 use tokenburn_core::Period;
145 let mut out = current.clone();
146 for (period, text) in [
147 (Period::Day, &p.limit_daily),
148 (Period::Week, &p.limit_weekly),
149 (Period::Month, &p.limit_monthly),
150 ] {
151 let t = text.as_deref().unwrap_or("").trim();
152 if t.is_empty() {
153 out.set(period, None);
154 } else {
155 match t.parse::<f64>() {
156 Ok(v) if v.is_finite() && v >= 0.0 => out.set(period, Some(v)),
157 _ => {
158 return Err(format!(
159 "{} limit must be a number of US dollars (got '{t}')",
160 period.label()
161 ));
162 }
163 }
164 }
165 }
166 out.notify = p.notify.is_some();
167 Ok(out)
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct SavingsRow {
173 pub label: String,
174 pub tokens: String,
175 pub money: String,
176 pub basis: String,
177 pub total: bool,
178}
179
180pub fn savings_rows(sv: &Savings, currency: Currency, spent_usd: f64) -> Vec<SavingsRow> {
183 if sv.is_empty() {
184 return Vec::new();
185 }
186 let mut rows: Vec<SavingsRow> = sv
187 .sources
188 .iter()
189 .map(|s| SavingsRow {
190 label: s.source.label().to_string(),
191 tokens: thousands(s.tokens),
192 money: currency.format(s.usd),
193 basis: format!("{} {}", thousands(s.events), s.source.event_noun()),
194 total: false,
195 })
196 .collect();
197 rows.push(SavingsRow {
198 label: "total".to_string(),
199 tokens: thousands(sv.tokens),
200 money: currency.format(sv.usd),
201 basis: sv
202 .percent_of_would_be(spent_usd)
203 .map(|p| format!("{} of the would-be cost", percent(p)))
204 .unwrap_or_default(),
205 total: true,
206 });
207 rows
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct BucketRow {
213 pub label: String,
214 pub tokens: String,
215 pub money: String,
216 pub split: String,
218}
219
220pub fn bucket_rows(buckets: &[BucketSavings], currency: Currency, limit: usize) -> Vec<BucketRow> {
223 buckets
224 .iter()
225 .rev()
226 .take(limit)
227 .map(|b| BucketRow {
228 label: b.label.clone(),
229 tokens: thousands(b.savings.tokens),
230 money: currency.format(b.savings.usd),
231 split: b
232 .savings
233 .sources
234 .iter()
235 .map(|s| format!("{} {}", s.source.label(), currency.format(s.usd)))
236 .collect::<Vec<_>>()
237 .join(" · "),
238 })
239 .collect()
240}
241
242pub fn bucket_noun(query: &Query) -> &'static str {
244 match tokenburn_core::chart_bucket(query) {
245 tokenburn_core::Bucket::Minute => "minute",
246 tokenburn_core::Bucket::Hour => "hour",
247 tokenburn_core::Bucket::Day => "day",
248 _ => "month",
249 }
250}
251
252pub const MAX_BUCKET_ROWS: usize = 24;
254
255pub fn savings_headline(sv: &Savings, currency: Currency) -> String {
257 format!("{} tok · {}", compact(sv.tokens), currency.format(sv.usd))
258}
259
260pub fn share_percent(value: u64, max: u64) -> u32 {
262 if max == 0 {
263 0
264 } else {
265 ((value as f64 / max as f64) * 100.0)
266 .round()
267 .clamp(0.0, 100.0) as u32
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use tokenburn_core::{Row, Tool};
275
276 fn row(cost: f64) -> Row {
277 Row {
278 tool: Tool::Pi,
279 project: "p".into(),
280 id: "i".into(),
281 ts: "2026-05-01T10:00:00Z".parse().unwrap(),
282 input: 10,
283 output: 5,
284 cache_read: 0,
285 cache_write: 0,
286 cost,
287 }
288 }
289
290 #[test]
291 fn dashboard_totals_and_counts() {
292 let c = Collected {
293 rows: vec![row(0.5), row(0.25)],
294 warnings: vec!["w".into()],
295 ..Default::default()
296 };
297 let d = build_dashboard(
298 Query::default(),
299 c,
300 &Selection::default(),
301 Currency::USD,
302 3.0,
303 );
304 assert_eq!(d.row_count, 2);
305 assert_eq!(d.total.input, 20);
306 assert_eq!(d.warnings.len(), 1);
307 assert_eq!(d.updated_at.len(), 8, "HH:MM:SS");
308 assert_eq!(d.report.last().unwrap().tool, "TOTAL");
309 }
310
311 #[test]
312 fn empty_dashboard_has_no_report() {
313 let d = build_dashboard(
314 Query::default(),
315 Collected::default(),
316 &Selection::default(),
317 Currency::USD,
318 3.0,
319 );
320 assert!(d.report.is_empty() && d.projects.is_empty());
321 }
322
323 #[test]
324 fn a_selection_scopes_the_dashboard_and_stale_ones_are_dropped() {
325 let c = || Collected {
326 rows: vec![row(0.5), row(0.25)],
327 warnings: vec![],
328 ..Default::default()
329 };
330 let sel = Selection {
331 project: Some("pi:p".into()),
332 ..Selection::default()
333 };
334 let d = build_dashboard(Query::default(), c(), &sel, Currency::USD, 3.0);
335 assert_eq!(d.selection, sel);
336 assert_eq!(d.total.turns, 2);
337
338 let stale = Selection {
339 project: Some("pi:ghost".into()),
340 bucket: Some("nope".into()),
341 };
342 let d = build_dashboard(Query::default(), c(), &stale, Currency::USD, 3.0);
343 assert!(d.selection.is_empty());
344 assert_eq!(d.total.turns, 2);
345 }
346
347 #[test]
348 fn share_percent_bounds() {
349 assert_eq!(share_percent(0, 0), 0);
350 assert_eq!(share_percent(50, 100), 50);
351 assert_eq!(share_percent(200, 100), 100);
352 }
353
354 fn cached(read: u64) -> Row {
355 Row {
356 cache_read: read,
357 ..row(0.5)
358 }
359 }
360
361 fn external() -> tokenburn_core::ExternalSavings {
362 tokenburn_core::ExternalSavings {
363 rtk_tokens: 1_000_000,
364 rtk_commands: 5,
365 compaction_tokens: 0,
366 compactions: 0,
367 ..Default::default()
368 }
369 }
370
371 #[test]
372 fn the_dashboard_carries_savings_valued_at_the_price() {
373 let c = Collected {
374 rows: vec![cached(1_000_000), row(0.5)],
375 external: external(),
376 ..Default::default()
377 };
378 let d = build_dashboard(
379 Query::default(),
380 c,
381 &Selection::default(),
382 Currency::USD,
383 10.0,
384 );
385 assert_eq!(d.input_price, 10.0);
386 assert_eq!(d.savings.tokens, 2_000_000);
387 assert!((d.savings.usd - 19.0).abs() < 1e-9, "9 cache + 10 rtk");
388 }
389
390 #[test]
391 fn a_project_selection_keeps_only_that_projects_cache() {
392 let mut other = cached(1_000_000);
393 other.project = "other".into();
394 let c = Collected {
395 rows: vec![cached(2_000_000), other],
396 external: external(),
397 ..Default::default()
398 };
399 let sel = Selection {
400 project: Some("pi:other".into()),
401 ..Selection::default()
402 };
403 let d = build_dashboard(Query::default(), c, &sel, Currency::USD, 10.0);
404 assert_eq!(
405 d.savings.tokens, 1_000_000,
406 "only the project's cache; rtk is not per project"
407 );
408 }
409
410 #[test]
411 fn savings_rows_have_a_line_per_source_and_a_total() {
412 let c = Collected {
413 rows: vec![cached(1_000_000)],
414 external: external(),
415 ..Default::default()
416 };
417 let d = build_dashboard(
418 Query::default(),
419 c,
420 &Selection::default(),
421 Currency::USD,
422 10.0,
423 );
424 let rows = savings_rows(&d.savings, d.currency, d.total.cost_usd);
425 let labels: Vec<&str> = rows.iter().map(|r| r.label.as_str()).collect();
426 assert_eq!(labels, ["Prompt cache", "rtk", "total"]);
427 assert_eq!(rows[0].money, "$9.00");
428 assert_eq!(
429 (rows[1].tokens.as_str(), rows[1].basis.as_str()),
430 ("1,000,000", "5 commands")
431 );
432 assert!(rows[2].total && !rows[0].total);
433 assert_eq!(rows[2].money, "$19.00");
434 assert!(
435 rows[2].basis.contains("97% of the would-be cost"),
436 "{}",
437 rows[2].basis
438 );
439 assert_eq!(
440 savings_headline(&d.savings, d.currency),
441 "2.00M tok · $19.00"
442 );
443 let eur = tokenburn_core::currency_by_code("EUR").unwrap();
444 assert_eq!(savings_rows(&d.savings, eur, 0.5)[2].money, "€17.48");
445 }
446
447 #[test]
448 fn no_savings_no_rows() {
449 let d = build_dashboard(
450 Query::default(),
451 Collected {
452 rows: vec![row(0.5)],
453 ..Default::default()
454 },
455 &Selection::default(),
456 Currency::USD,
457 3.0,
458 );
459 assert!(d.savings.is_empty());
460 assert!(savings_rows(&d.savings, d.currency, 0.5).is_empty());
461 }
462
463 fn at(ts: &str) -> chrono::DateTime<chrono::Utc> {
464 ts.parse().unwrap()
465 }
466
467 fn two_days() -> Collected {
469 let mut day1 = cached(1_000_000);
470 day1.ts = at("2026-05-01T10:00:00Z");
471 let mut day2 = row(0.5);
472 day2.ts = at("2026-05-02T10:00:00Z");
473 Collected {
474 rows: vec![day1, day2],
475 external: tokenburn_core::ExternalSavings {
476 rtk_tokens: 1_000_000,
477 rtk_commands: 4,
478 compaction_tokens: 1_000_000,
479 compactions: 1,
480 rtk_events: vec![(at("2026-05-02T10:00:00Z"), 1_000_000, 4)],
481 compaction_events: vec![(at("2026-05-01T10:00:00Z"), 1_000_000)],
482 },
483 ..Default::default()
484 }
485 }
486
487 #[test]
488 fn savings_are_listed_per_interval_newest_first() {
489 let d = build_dashboard(
490 Query::default(),
491 two_days(),
492 &Selection::default(),
493 Currency::USD,
494 10.0,
495 );
496 let rows = bucket_rows(&d.savings_by_bucket, d.currency, 10);
497 assert_eq!(rows.len(), 2);
498 assert!(rows[0].label > rows[1].label, "newest first");
499 assert_eq!(
500 (rows[0].money.as_str(), rows[0].split.as_str()),
501 ("$10.00", "rtk $10.00")
502 );
503 assert_eq!(rows[1].money, "$19.00");
504 assert_eq!(rows[1].split, "Prompt cache $9.00 · pi compaction $10.00");
505 assert_eq!(
506 bucket_rows(&d.savings_by_bucket, d.currency, 1).len(),
507 1,
508 "limit"
509 );
510 assert_eq!(bucket_noun(&d.query), "day", "month window charts by day");
511 }
512
513 #[test]
514 fn the_interval_follows_the_bucket_filter() {
515 let q = |bucket| Query {
516 bucket,
517 ..Query::default()
518 };
519 for (bucket, noun, rows) in [
520 (tokenburn_core::Bucket::Minute, "minute", 2),
521 (tokenburn_core::Bucket::Hour, "hour", 2),
522 (tokenburn_core::Bucket::Day, "day", 2),
523 (tokenburn_core::Bucket::Month, "month", 1),
524 ] {
525 let d = build_dashboard(
526 q(bucket),
527 two_days(),
528 &Selection::default(),
529 Currency::USD,
530 10.0,
531 );
532 assert_eq!(bucket_noun(&d.query), noun);
533 assert_eq!(d.savings_by_bucket.len(), rows, "{noun}");
534 }
535 }
536
537 #[test]
538 fn a_selected_bar_narrows_every_source_to_that_interval() {
539 let day1 = tokenburn_core::bucket_label_at(
540 at("2026-05-01T10:00:00Z"),
541 tokenburn_core::Bucket::Day,
542 );
543 let sel = Selection {
544 bucket: Some(day1),
545 ..Selection::default()
546 };
547 let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
548 assert!(
549 (d.savings.usd - 19.0).abs() < 1e-9,
550 "cache + compaction, no rtk: {}",
551 d.savings.usd
552 );
553 assert_eq!(
554 d.savings_by_bucket.len(),
555 2,
556 "the list keeps every interval, like the bars"
557 );
558 }
559
560 #[test]
561 fn a_selected_project_drops_the_unattributed_sources() {
562 let sel = Selection {
563 project: Some("pi:p".into()),
564 ..Selection::default()
565 };
566 let d = build_dashboard(Query::default(), two_days(), &sel, Currency::USD, 10.0);
567 assert_eq!(d.savings.sources.len(), 1);
568 assert!((d.savings.usd - 9.0).abs() < 1e-9);
569 }
570
571 fn params(d: &str, w: &str, m: &str, notify: bool) -> crate::params::LimitParams {
572 crate::params::LimitParams {
573 save: Some("1".into()),
574 limit_daily: Some(d.into()),
575 limit_weekly: Some(w.into()),
576 limit_monthly: Some(m.into()),
577 notify: notify.then(|| "on".into()),
578 }
579 }
580
581 #[test]
582 fn limit_fields_parse_clear_and_reject() {
583 use tokenburn_core::{Limits, Period};
584 let cur = Limits::default();
585 let l = apply_limit_params(&cur, ¶ms("20", " 100.5 ", "", true)).unwrap();
586 assert_eq!(
587 (
588 l.get(Period::Day),
589 l.get(Period::Week),
590 l.get(Period::Month)
591 ),
592 (Some(20.0), Some(100.5), None)
593 );
594 assert!(l.notify);
595 let l = apply_limit_params(&l, ¶ms("0", "", "", false)).unwrap();
596 assert!(
597 l.is_empty() && !l.notify,
598 "0 and empty clear; unchecked = off"
599 );
600 for bad in ["abc", "-5", "inf", "NaN", "1e999"] {
601 let e = apply_limit_params(&cur, ¶ms("1", bad, "", true)).unwrap_err();
602 assert!(e.contains("weekly limit must be a number"), "{bad}: {e}");
603 }
604 assert!(
605 apply_limit_params(&cur, ¶ms("x", "", "", true))
606 .unwrap_err()
607 .contains("daily")
608 );
609 }
610
611 #[test]
612 fn bars_are_capped_and_levels_have_classes() {
613 use tokenburn_core::{Level, LimitStatus, Period};
614 let s = |spent: f64| LimitStatus {
615 period: Period::Day,
616 limit_usd: 10.0,
617 spent_usd: spent,
618 level: Level::Ok,
619 };
620 assert_eq!(
621 (
622 bar_percent(&s(0.0)),
623 bar_percent(&s(5.0)),
624 bar_percent(&s(99.0))
625 ),
626 (0, 50, 100)
627 );
628 assert_eq!(level_class(Level::Ok), "limit ok");
629 assert_eq!(level_class(Level::Warn), "limit warn");
630 assert_eq!(level_class(Level::Over), "limit over");
631 }
632}