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