Skip to main content

tokenburn_core/features/report/
chart.rs

1//! Chart data shared by every front-end: a stacked time series (per tool) and
2//! the "share by project" pie. Front-ends only *draw* it — ratatui bars, iced
3//! canvas, SVG — so all of them agree on the numbers.
4
5use 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/// What a chart measures.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum Metric {
15    #[default]
16    Tokens,
17    /// Estimated `$` cost (only for tools that log one).
18    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
49/// At most this many buckets are returned (the most recent ones).
50pub const MAX_BUCKETS: usize = 120;
51
52/// Per-bucket, per-tool totals: `values[bucket][tool.series_index()]`.
53#[derive(Debug, Clone, Default, PartialEq)]
54pub struct TimeSeries {
55    pub bucket: Bucket,
56    /// One label per bucket, oldest first (`2026-05-01`, `2026-05`, `2026-05-01 13:00`).
57    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    /// The value of `tool` in bucket `i` for `metric`.
68    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    /// Total of all tools in bucket `i`.
76    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    /// The tallest stacked bar — the y axis maximum.
81    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    /// Tools that have any value for `metric`, in canonical order (the legend).
88    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
96/// Time-series charts always show a trend, so an un-bucketed query is charted at
97/// the natural granularity of its window.
98pub 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
109/// Start of the bucket containing `ts` (local time).
110fn floor(ts: DateTime<Local>, bucket: Bucket) -> NaiveDate {
111    // Hours are handled separately; this is the date part for day/month.
112    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
122/// The label of the chart bucket containing `row` — the same string
123/// [`TimeSeries::labels`] uses, so a clicked bar can be matched to its rows.
124pub fn bucket_label_of(row: &Row, bucket: Bucket) -> String {
125    label(row.ts.with_timezone(&Local), bucket)
126}
127
128/// Build the stacked series for `rows` at [`chart_bucket`] granularity, with
129/// empty buckets filled in so gaps in time show as gaps in the chart.
130pub 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    // Walk every bucket from the first to the last, whether or not it has rows.
153    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
178/// Midnight / hour / first-of-month at or before `ts`.
179fn 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        // Add a day to the *date* (not 24 h) so DST changes cannot skip or repeat one.
204        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
213// ── axes and geometry (shared by the GUI canvas and the web SVG) ───────────
214
215/// A "nice" axis maximum ≥ `max`: 1, 2, 2.5, 5 or 10 × a power of ten.
216pub 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
230/// Axis label for a value: `1.5M` tokens or `$12.50`.
231pub 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
239/// Tokens for an axis: `20M`, `2.5M`, `15k`, `800` — no trailing zeros.
240pub 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
251/// A short x-axis label: drop the year (`2026-05-10` → `05-10`, `2026-05-10 13:00` → `13:00`).
252pub 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
260/// Which bucket is under `x` (relative to the plot's left edge)? `None` outside.
261pub 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
268/// `(start, end)` angles in radians (clockwise from 12 o'clock) for each slice.
269pub 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// ── pie ─────────────────────────────────────────────────────────────────────
290
291/// One pie slice.
292#[derive(Debug, Clone, PartialEq)]
293pub struct Slice {
294    pub label: String,
295    /// The tool, when the label starts with `tool:` (for colouring).
296    pub tool: Option<Tool>,
297    /// The full `tool:project` key (what [`super::Selection`] matches on);
298    /// `None` for the folded `other` slice, which can't be selected.
299    pub key: Option<String>,
300    pub value: f64,
301}
302
303/// What the pie measures and its slices (largest first, tail folded into `other`).
304#[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
316/// Turn the per-project breakdown into at most `top_n` slices plus an `other`
317/// slice for the remainder. The metric is cost when any entry has a cost, tokens
318/// otherwise; zero-valued entries are dropped.
319pub 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
361/// `pi:Users-me-Projects-tokenburn` → `pi:tokenburn` (last path-ish segment).
362pub 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/// `ts` as UTC from an RFC 3339 string.
376#[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        // 3 h 50 min apart: 4 hour-buckets, or 5 in a half-hour-offset time zone.
467        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}