1use std::collections::BTreeMap;
6
7use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike};
8
9use super::types::{Bucket, Query, Window};
10use crate::features::currency::Currency;
11use crate::features::usage::{Row, Summary, Tool};
12pub use crate::utils::fmt::{axis_tokens, short_label, short_name};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum Metric {
17 #[default]
18 Tokens,
19 Cost,
21}
22
23impl Metric {
24 pub const ALL: [Metric; 2] = [Metric::Tokens, Metric::Cost];
25
26 pub fn label(self) -> &'static str {
27 match self {
28 Metric::Tokens => "tokens",
29 Metric::Cost => "cost",
30 }
31 }
32}
33
34impl std::fmt::Display for Metric {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 f.write_str(self.label())
37 }
38}
39
40impl std::str::FromStr for Metric {
41 type Err = String;
42 fn from_str(s: &str) -> Result<Self, String> {
43 match s.to_ascii_lowercase().as_str() {
44 "tokens" => Ok(Metric::Tokens),
45 "cost" => Ok(Metric::Cost),
46 other => Err(format!("must be tokens or cost (got '{other}')")),
47 }
48 }
49}
50
51pub const MAX_BUCKETS: usize = 120;
53
54#[derive(Debug, Clone, Default, PartialEq)]
56pub struct TimeSeries {
57 pub bucket: Bucket,
58 pub labels: Vec<String>,
60 pub tokens: Vec<[u64; 8]>,
61 pub cost: Vec<[f64; 8]>,
62}
63
64impl TimeSeries {
65 pub fn is_empty(&self) -> bool {
66 self.labels.is_empty()
67 }
68
69 pub fn value(&self, metric: Metric, i: usize, tool: Tool) -> f64 {
71 match metric {
72 Metric::Tokens => self.tokens[i][tool.series_index()] as f64,
73 Metric::Cost => self.cost[i][tool.series_index()],
74 }
75 }
76
77 pub fn bucket_total(&self, metric: Metric, i: usize) -> f64 {
79 Tool::ALL.iter().map(|t| self.value(metric, i, *t)).sum()
80 }
81
82 pub fn max_total(&self, metric: Metric) -> f64 {
84 (0..self.labels.len())
85 .map(|i| self.bucket_total(metric, i))
86 .fold(0.0, f64::max)
87 }
88
89 pub fn active_tools(&self, metric: Metric) -> Vec<Tool> {
91 Tool::ALL
92 .into_iter()
93 .filter(|t| (0..self.labels.len()).any(|i| self.value(metric, i, *t) > 0.0))
94 .collect()
95 }
96}
97
98impl TimeSeries {
99 pub fn in_currency(&self, currency: &Currency) -> TimeSeries {
102 let mut out = self.clone();
103 for bucket in &mut out.cost {
104 for v in bucket.iter_mut() {
105 *v = currency.convert(*v);
106 }
107 }
108 out
109 }
110}
111
112pub fn chart_bucket(query: &Query) -> Bucket {
115 match query.bucket {
116 Bucket::None => match query.window {
117 Window::Today => Bucket::Hour,
118 Window::Month => Bucket::Day,
119 Window::All => Bucket::Month,
120 },
121 b => b,
122 }
123}
124
125fn floor(ts: DateTime<Local>, bucket: Bucket) -> NaiveDate {
127 match bucket {
129 Bucket::Month | Bucket::None => ts.date_naive().with_day(1).unwrap_or(ts.date_naive()),
130 _ => ts.date_naive(),
131 }
132}
133
134fn label(ts: DateTime<Local>, bucket: Bucket) -> String {
135 ts.format(bucket.format()).to_string()
136}
137
138pub fn bucket_label_of(row: &Row, bucket: Bucket) -> String {
141 label(row.ts.with_timezone(&Local), bucket)
142}
143
144pub fn time_series(rows: &[Row], query: &Query) -> TimeSeries {
147 let bucket = chart_bucket(query);
148 let mut map: BTreeMap<String, ([u64; 8], [f64; 8])> = BTreeMap::new();
149 let (mut first, mut last): (Option<DateTime<Local>>, Option<DateTime<Local>>) = (None, None);
150
151 for r in rows {
152 let ts = r.ts.with_timezone(&Local);
153 first = Some(first.map_or(ts, |f| f.min(ts)));
154 last = Some(last.map_or(ts, |l| l.max(ts)));
155 let slot = map.entry(label(ts, bucket)).or_default();
156 let i = r.tool.series_index();
157 slot.0[i] += r.input + r.output + r.cache_read + r.cache_write;
158 slot.1[i] += r.cost;
159 }
160
161 let (Some(first), Some(last)) = (first, last) else {
162 return TimeSeries {
163 bucket,
164 ..Default::default()
165 };
166 };
167
168 let mut labels = Vec::new();
170 let mut cursor = start_of(first, bucket);
171 let end = start_of(last, bucket);
172 while cursor <= end && labels.len() < 100_000 {
173 labels.push(label(cursor, bucket));
174 cursor = next(cursor, bucket);
175 }
176 let skip = labels.len().saturating_sub(MAX_BUCKETS);
177 let labels: Vec<String> = labels.into_iter().skip(skip).collect();
178
179 let mut tokens = Vec::with_capacity(labels.len());
180 let mut cost = Vec::with_capacity(labels.len());
181 for l in &labels {
182 let (t, c) = map.get(l).copied().unwrap_or_default();
183 tokens.push(t);
184 cost.push(c);
185 }
186 TimeSeries {
187 bucket,
188 labels,
189 tokens,
190 cost,
191 }
192}
193
194fn start_of(ts: DateTime<Local>, bucket: Bucket) -> DateTime<Local> {
196 let naive = match bucket {
197 Bucket::Hour => ts.date_naive().and_hms_opt(ts.hour(), 0, 0),
198 _ => floor(ts, bucket).and_hms_opt(0, 0, 0),
199 };
200 naive
201 .and_then(|n| Local.from_local_datetime(&n).earliest())
202 .unwrap_or(ts)
203}
204
205fn next(ts: DateTime<Local>, bucket: Bucket) -> DateTime<Local> {
206 match bucket {
207 Bucket::Hour => ts + Duration::hours(1),
208 Bucket::Month | Bucket::None => {
209 let (y, m) = if ts.month() == 12 {
210 (ts.year() + 1, 1)
211 } else {
212 (ts.year(), ts.month() + 1)
213 };
214 Local
215 .with_ymd_and_hms(y, m, 1, 0, 0, 0)
216 .earliest()
217 .unwrap_or(ts + Duration::days(31))
218 }
219 Bucket::Day => ts
221 .date_naive()
222 .succ_opt()
223 .and_then(|d| d.and_hms_opt(0, 0, 0))
224 .and_then(|n| Local.from_local_datetime(&n).earliest())
225 .unwrap_or(ts + Duration::days(1)),
226 }
227}
228
229pub fn nice_max(max: f64) -> f64 {
233 if max <= 0.0 || !max.is_finite() {
234 return 1.0;
235 }
236 let exp = max.log10().floor();
237 let base = 10f64.powf(exp);
238 let f = max / base;
239 let nice = [1.0, 2.0, 2.5, 5.0, 10.0]
240 .into_iter()
241 .find(|n| *n >= f - 1e-9)
242 .unwrap_or(10.0);
243 nice * base
244}
245
246pub fn axis_label(metric: Metric, v: f64, currency: &Currency) -> String {
249 match metric {
250 Metric::Tokens => axis_tokens(v),
251 Metric::Cost => currency.axis(v),
252 }
253}
254
255pub fn bucket_at(x: f32, plot_width: f32, buckets: usize) -> Option<usize> {
257 if buckets == 0 || x < 0.0 || x >= plot_width || plot_width <= 0.0 {
258 return None;
259 }
260 Some(((x / plot_width) * buckets as f32) as usize).filter(|i| *i < buckets)
261}
262
263pub fn slice_angles(values: &[f64]) -> Vec<(f32, f32)> {
265 let total: f64 = values.iter().sum();
266 if total <= 0.0 {
267 return Vec::new();
268 }
269 let mut acc = 0.0;
270 values
271 .iter()
272 .map(|v| {
273 let start = acc / total;
274 acc += v;
275 let end = acc / total;
276 (
277 (start * std::f64::consts::TAU) as f32,
278 (end * std::f64::consts::TAU) as f32,
279 )
280 })
281 .collect()
282}
283
284#[derive(Debug, Clone, PartialEq)]
288pub struct Slice {
289 pub label: String,
290 pub tool: Option<Tool>,
292 pub key: Option<String>,
295 pub value: f64,
296}
297
298#[derive(Debug, Clone, PartialEq)]
300pub struct Pie {
301 pub metric: Metric,
302 pub slices: Vec<Slice>,
303}
304
305impl Pie {
306 pub fn in_currency(&self, currency: &Currency) -> Pie {
309 let mut out = self.clone();
310 if out.metric == Metric::Cost {
311 for s in &mut out.slices {
312 s.value = currency.convert(s.value);
313 }
314 }
315 out
316 }
317
318 pub fn total(&self) -> f64 {
319 self.slices.iter().map(|s| s.value).sum()
320 }
321}
322
323pub fn pie(projects: &[(String, Summary)], top_n: usize) -> Pie {
327 let metric = if projects.iter().any(|(_, s)| s.cost_usd > 0.0) {
328 Metric::Cost
329 } else {
330 Metric::Tokens
331 };
332 let value = |s: &Summary| match metric {
333 Metric::Cost => s.cost_usd,
334 Metric::Tokens => s.total_tokens as f64,
335 };
336
337 let mut slices: Vec<Slice> = projects
338 .iter()
339 .map(|(name, s)| Slice {
340 label: short_name(name),
341 tool: name
342 .split_once(':')
343 .and_then(|(t, _)| t.parse::<Tool>().ok()),
344 key: Some(name.clone()),
345 value: value(s),
346 })
347 .filter(|s| s.value > 0.0)
348 .collect();
349 slices.sort_by(|a, b| {
350 b.value
351 .partial_cmp(&a.value)
352 .unwrap_or(std::cmp::Ordering::Equal)
353 });
354
355 let top_n = top_n.max(1);
356 if slices.len() > top_n {
357 let rest: f64 = slices.split_off(top_n).iter().map(|s| s.value).sum();
358 slices.push(Slice {
359 label: "other".into(),
360 tool: None,
361 key: None,
362 value: rest,
363 });
364 }
365 Pie { metric, slices }
366}
367
368#[cfg(test)]
370fn utc(s: &str) -> chrono::DateTime<chrono::Utc> {
371 s.parse().unwrap()
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn row(tool: Tool, ts: &str, tokens: u64, cost: f64) -> Row {
379 Row {
380 tool,
381 project: "p".into(),
382 id: "i".into(),
383 ts: utc(ts),
384 input: tokens,
385 output: 0,
386 cache_read: 0,
387 cache_write: 0,
388 cost,
389 }
390 }
391
392 fn q(bucket: Bucket, window: Window) -> Query {
393 Query {
394 bucket,
395 window,
396 ..Query::default()
397 }
398 }
399
400 #[test]
401 fn empty_input_gives_an_empty_series() {
402 let ts = time_series(&[], &q(Bucket::Day, Window::Month));
403 assert!(ts.is_empty());
404 assert_eq!(ts.max_total(Metric::Tokens), 0.0);
405 assert!(ts.active_tools(Metric::Tokens).is_empty());
406 }
407
408 #[test]
409 fn rows_are_stacked_per_bucket_and_tool() {
410 let rows = [
411 row(Tool::Pi, "2026-05-10T12:00:00Z", 100, 1.0),
412 row(Tool::Pi, "2026-05-10T13:00:00Z", 50, 0.5),
413 row(Tool::Claude, "2026-05-10T12:30:00Z", 25, 0.0),
414 ];
415 let ts = time_series(&rows, &q(Bucket::Day, Window::Month));
416 assert_eq!(ts.labels.len(), 1);
417 assert_eq!(ts.value(Metric::Tokens, 0, Tool::Pi), 150.0);
418 assert_eq!(ts.value(Metric::Tokens, 0, Tool::Claude), 25.0);
419 assert_eq!(ts.bucket_total(Metric::Tokens, 0), 175.0);
420 assert!((ts.value(Metric::Cost, 0, Tool::Pi) - 1.5).abs() < 1e-9);
421 assert_eq!(ts.active_tools(Metric::Tokens), [Tool::Pi, Tool::Claude]);
422 assert_eq!(
423 ts.active_tools(Metric::Cost),
424 [Tool::Pi],
425 "claude has no cost"
426 );
427 }
428
429 #[test]
430 fn gaps_between_the_first_and_last_day_are_filled_with_zeros() {
431 let rows = [
432 row(Tool::Pi, "2026-05-10T12:00:00Z", 10, 0.0),
433 row(Tool::Pi, "2026-05-14T12:00:00Z", 20, 0.0),
434 ];
435 let ts = time_series(&rows, &q(Bucket::Day, Window::Month));
436 assert_eq!(ts.labels.len(), 5, "10th…14th");
437 let totals: Vec<f64> = (0..5).map(|i| ts.bucket_total(Metric::Tokens, i)).collect();
438 assert_eq!(totals, [10.0, 0.0, 0.0, 0.0, 20.0]);
439 assert!(ts.labels.windows(2).all(|w| w[0] < w[1]), "oldest first");
440 }
441
442 #[test]
443 fn monthly_series_steps_over_year_ends() {
444 let rows = [
445 row(Tool::Pi, "2025-11-15T12:00:00Z", 1, 0.0),
446 row(Tool::Pi, "2026-02-15T12:00:00Z", 1, 0.0),
447 ];
448 let ts = time_series(&rows, &q(Bucket::Month, Window::All));
449 assert_eq!(ts.labels, ["2025-11", "2025-12", "2026-01", "2026-02"]);
450 }
451
452 #[test]
453 fn hourly_series_steps_by_the_hour() {
454 let rows = [
455 row(Tool::Pi, "2026-05-10T10:05:00Z", 1, 0.0),
456 row(Tool::Pi, "2026-05-10T13:55:00Z", 1, 0.0),
457 ];
458 let ts = time_series(&rows, &q(Bucket::Hour, Window::Today));
459 assert!((4..=5).contains(&ts.labels.len()), "{:?}", ts.labels);
461 }
462
463 #[test]
464 fn an_unbucketed_query_is_charted_at_the_windows_natural_granularity() {
465 assert_eq!(chart_bucket(&q(Bucket::None, Window::Today)), Bucket::Hour);
466 assert_eq!(chart_bucket(&q(Bucket::None, Window::Month)), Bucket::Day);
467 assert_eq!(chart_bucket(&q(Bucket::None, Window::All)), Bucket::Month);
468 assert_eq!(
469 chart_bucket(&q(Bucket::Hour, Window::All)),
470 Bucket::Hour,
471 "an explicit bucket is kept"
472 );
473 }
474
475 #[test]
476 fn only_the_most_recent_buckets_are_kept() {
477 let rows = [
478 row(Tool::Pi, "2020-01-01T12:00:00Z", 1, 0.0),
479 row(Tool::Pi, "2026-05-14T12:00:00Z", 1, 0.0),
480 ];
481 let ts = time_series(&rows, &q(Bucket::Day, Window::All));
482 assert_eq!(ts.labels.len(), MAX_BUCKETS);
483 assert_eq!(ts.tokens.len(), MAX_BUCKETS);
484 assert!(
485 ts.labels.last().unwrap().starts_with("2026-05"),
486 "the newest bucket survives"
487 );
488 }
489
490 #[test]
491 fn metric_parses_and_displays() {
492 assert_eq!("COST".parse::<Metric>().unwrap(), Metric::Cost);
493 assert!("bytes".parse::<Metric>().is_err());
494 assert_eq!(Metric::Tokens.to_string(), "tokens");
495 }
496
497 fn s(cost: f64, tokens: u64) -> Summary {
498 Summary {
499 turns: 1,
500 total_tokens: tokens,
501 cost_usd: cost,
502 ..Summary::default()
503 }
504 }
505
506 #[test]
507 fn pie_uses_cost_when_any_entry_has_one() {
508 let p = pie(
509 &[("pi:a".into(), s(2.0, 10)), ("zed:m".into(), s(0.0, 99))],
510 6,
511 );
512 assert_eq!(p.metric, Metric::Cost);
513 assert_eq!(p.slices.len(), 1, "zero-cost entries are dropped");
514 assert_eq!(p.slices[0].tool, Some(Tool::Pi));
515 }
516
517 #[test]
518 fn pie_falls_back_to_tokens() {
519 let p = pie(
520 &[("zed:m".into(), s(0.0, 50)), ("zed:n".into(), s(0.0, 70))],
521 6,
522 );
523 assert_eq!(p.metric, Metric::Tokens);
524 assert_eq!(p.slices[0].label, "zed:n", "largest first");
525 assert_eq!(p.total(), 120.0);
526 }
527
528 #[test]
529 fn pie_folds_the_tail_into_other() {
530 let projects: Vec<_> = (1..=5)
531 .map(|i| (format!("pi:p{i}"), s(f64::from(i), 1)))
532 .collect();
533 let p = pie(&projects, 3);
534 assert_eq!(p.slices.len(), 4);
535 assert_eq!(p.slices[3].label, "other");
536 assert_eq!(p.slices[3].tool, None);
537 assert!((p.slices[3].value - 3.0).abs() < 1e-9, "1 + 2");
538 }
539
540 #[test]
541 fn nice_max_rounds_up_to_one_two_two_and_a_half_five_ten() {
542 assert_eq!(nice_max(0.0), 1.0);
543 assert_eq!(nice_max(f64::NAN), 1.0);
544 assert_eq!(nice_max(0.9), 1.0);
545 assert_eq!(nice_max(1.2), 2.0);
546 assert_eq!(nice_max(2.3), 2.5);
547 assert_eq!(nice_max(3.0), 5.0);
548 assert_eq!(nice_max(7.0), 10.0);
549 assert_eq!(nice_max(1_300_000.0), 2_000_000.0);
550 assert_eq!(nice_max(12.0), 20.0);
551 for v in [0.3, 1.0, 4.9, 99.0, 123_456.0, 8e9] {
552 assert!(nice_max(v) >= v, "nice_max({v}) must not cut the data off");
553 }
554 }
555
556 #[test]
557 fn axis_labels_use_compact_numbers_and_dollars() {
558 let usd = Currency::USD;
559 assert_eq!(axis_label(Metric::Tokens, 1_500_000.0, &usd), "1.5M");
560 assert_eq!(axis_label(Metric::Cost, 12.5, &usd), "$12.50");
561 assert_eq!(axis_label(Metric::Cost, 0.25, &usd), "$0.25");
562 assert_eq!(axis_label(Metric::Cost, 250.0, &usd), "$250");
563 let eur = crate::features::currency::find("EUR").unwrap();
564 assert_eq!(axis_label(Metric::Cost, 12.5, &eur), "€12.50");
565 assert_eq!(
566 axis_label(Metric::Tokens, 2_000.0, &eur),
567 "2k",
568 "tokens ignore the currency"
569 );
570 }
571
572 #[test]
573 fn hover_maps_x_to_a_bucket() {
574 assert_eq!(bucket_at(0.0, 100.0, 4), Some(0));
575 assert_eq!(bucket_at(24.9, 100.0, 4), Some(0));
576 assert_eq!(bucket_at(25.0, 100.0, 4), Some(1));
577 assert_eq!(bucket_at(99.9, 100.0, 4), Some(3));
578 assert_eq!(bucket_at(100.0, 100.0, 4), None, "right edge is outside");
579 assert_eq!(bucket_at(-1.0, 100.0, 4), None);
580 assert_eq!(bucket_at(5.0, 100.0, 0), None);
581 }
582
583 #[test]
584 fn slice_angles_cover_the_whole_circle_without_gaps() {
585 let a = slice_angles(&[1.0, 1.0, 2.0]);
586 assert_eq!(a.len(), 3);
587 assert_eq!(a[0].0, 0.0);
588 for w in a.windows(2) {
589 assert!((w[0].1 - w[1].0).abs() < 1e-5, "slices must touch");
590 }
591 assert!((a[2].1 - std::f32::consts::TAU).abs() < 1e-4);
592 assert!(
593 (a[2].1 - a[2].0 - std::f32::consts::PI).abs() < 1e-4,
594 "half the total = half the circle"
595 );
596 assert!(slice_angles(&[]).is_empty());
597 assert!(slice_angles(&[0.0, 0.0]).is_empty());
598 }
599
600 #[test]
601 fn an_empty_pie_has_no_slices() {
602 assert!(pie(&[], 6).slices.is_empty());
603 }
604
605 #[test]
606 fn converting_a_series_scales_costs_only() {
607 let rows = [Row {
608 tool: Tool::Pi,
609 project: "p".into(),
610 id: "i".into(),
611 ts: utc("2026-05-10T12:00:00Z"),
612 input: 1000,
613 output: 0,
614 cache_read: 0,
615 cache_write: 0,
616 cost: 10.0,
617 }];
618 let q = Query {
619 bucket: Bucket::Day,
620 window: Window::All,
621 ..Query::default()
622 };
623 let usd = time_series(&rows, &q);
624 let eur = crate::features::currency::find("EUR").unwrap();
625 let conv = usd.in_currency(&eur);
626 assert_eq!(conv.labels, usd.labels);
627 assert_eq!(conv.tokens, usd.tokens, "tokens are not money");
628 assert!((conv.bucket_total(Metric::Cost, 0) - 9.2).abs() < 1e-9);
629 assert_eq!(usd.in_currency(&Currency::USD), usd, "USD is the identity");
630 }
631
632 #[test]
633 fn converting_a_pie_scales_a_cost_pie_and_keeps_the_proportions() {
634 let slice = |v: f64| Slice {
635 label: "x".into(),
636 tool: None,
637 key: None,
638 value: v,
639 };
640 let eur = crate::features::currency::find("EUR").unwrap();
641 let cost = Pie {
642 metric: Metric::Cost,
643 slices: vec![slice(30.0), slice(10.0)],
644 };
645 let conv = cost.in_currency(&eur);
646 assert!((conv.total() - 36.8).abs() < 1e-9);
647 assert!((conv.slices[0].value / conv.total() - 0.75).abs() < 1e-9);
648 let tokens = Pie {
649 metric: Metric::Tokens,
650 slices: vec![slice(300.0)],
651 };
652 assert_eq!(
653 tokens.in_currency(&eur),
654 tokens,
655 "a tokens pie is untouched"
656 );
657 }
658}