Skip to main content

context_bar_core/
aggregate.rs

1//! Deterministic transforms — slice 2 of folding `usage_signal.py` into Rust
2//! (ROADMAP E1). Ported 1:1 from `empty_metrics`/`_add_metrics`,
3//! `split_logical_sessions`, `_empty_bucket`/`_accumulate`, `bucket_aggregates`,
4//! and `project_name_from_cwd`. Pure given (events, NOW, UTC offset) — pinned by
5//! a golden fixture generated from the Python (`tests/aggregate_golden.rs`).
6//!
7//! Day/week/month bucketing uses a fixed UTC offset (seconds east of UTC). The
8//! Python uses the system local tz via `astimezone()`; for fixed-offset zones
9//! (e.g. Türkiye, permanent UTC+3) that is identical, and the golden pins it
10//! with `TZ=UTC` / offset 0. DST-aware per-timestamp offsets are a later
11//! refinement (would need a tz database); documented in COST_MODEL/ROADMAP.
12
13use time::{Date, OffsetDateTime, UtcOffset};
14
15use crate::usage_signal::{
16    DailyInstance, DailyModelInstance, NamedBucket, SessionRecord, TimeBucket,
17};
18
19/// Idle gap (seconds) that splits one transcript file into logical sessions —
20/// matches Claude's 5h window, which resets 5h after the *first* turn.
21pub const SESSION_IDLE_GAP: f64 = 5.0 * 3600.0;
22/// 30-day rolling window in seconds.
23pub const WIN_30D: f64 = 30.0 * 86400.0;
24
25/// Per-turn token buckets + estimated cost. `total` is the stats token total
26/// (fresh_in + output) the parser computes; the four buckets feed the cost view.
27#[derive(Clone, Copy, Debug, Default, PartialEq)]
28pub struct TurnMetrics {
29    pub total: u64,
30    pub cache_read: u64,
31    pub input: u64,
32    pub output: u64,
33    pub cache_creation: u64,
34    pub cost: f64,
35}
36
37impl TurnMetrics {
38    fn add(&mut self, m: &TurnMetrics) {
39        self.total += m.total;
40        self.cache_read += m.cache_read;
41        self.input += m.input;
42        self.output += m.output;
43        self.cache_creation += m.cache_creation;
44        self.cost += m.cost;
45    }
46}
47
48/// One transcript file's per-turn events plus its resolved model/cwd.
49#[derive(Clone, Debug, Default)]
50pub struct FileEvents {
51    pub model: Option<String>,
52    pub cwd: Option<String>,
53    /// (epoch seconds, metrics) — sorted by ts inside [`split_logical_sessions`].
54    pub events: Vec<(f64, TurnMetrics)>,
55}
56
57/// A logical session chunk that feeds [`bucket_aggregates`].
58#[derive(Clone, Debug)]
59pub struct Session {
60    pub tokens: u64,
61    pub cache_read: u64,
62    pub input: u64,
63    pub output: u64,
64    pub cache_creation: u64,
65    pub cost: f64,
66    pub last_ts: f64,
67    pub first_ts: f64,
68    pub model: Option<String>,
69    pub cwd: Option<String>,
70}
71
72/// `project_name_from_cwd`: the git repository name for the cwd when it lives in
73/// a repo (origin remote name, else the toplevel directory), otherwise the
74/// directory's own basename, or `—` when absent. Keeping it git-aware means the
75/// menubar shows the actual repo (e.g. `context-bar`) rather than whichever
76/// sub-directory the agent happened to start in (e.g. `backend`).
77///
78/// MUST stay in lock-step with the same function in `usage_signal.py`.
79pub fn project_name_from_cwd(cwd: Option<&str>) -> String {
80    match cwd {
81        None => "—".to_string(),
82        Some(c) if c.is_empty() => "—".to_string(),
83        Some(c) => repo_name_from_cwd(c).unwrap_or_else(|| parent_leaf(c)),
84    }
85}
86
87fn basename_of(c: &str) -> String {
88    let trimmed = c.trim_end_matches('/');
89    let base = trimmed.rsplit('/').next().unwrap_or("");
90    if base.is_empty() {
91        c.to_string()
92    } else {
93        base.to_string()
94    }
95}
96
97/// For a cwd that is NOT inside a git repo, show `parent/leaf` (e.g.
98/// `hususi/backend`) instead of the bare leaf, so a plain directory reads as a
99/// location rather than masquerading as a project name. Falls back to the leaf
100/// when there is no meaningful parent.
101fn parent_leaf(c: &str) -> String {
102    let leaf = basename_of(c);
103    let trimmed = c.trim_end_matches('/');
104    if let Some(idx) = trimmed.rfind('/') {
105        let parent = &trimmed[..idx];
106        if !parent.is_empty() {
107            let pbase = basename_of(parent);
108            if !pbase.is_empty() && pbase != leaf {
109                return format!("{pbase}/{leaf}");
110            }
111        }
112    }
113    leaf
114}
115
116/// Walk up from `cwd` to the nearest `.git` and return the repository name:
117/// origin's URL basename when present, else the toplevel directory's basename.
118/// `None` when `cwd` is not absolute or not inside a git repo (callers fall back
119/// to the plain basename). Pure filesystem — no `git` subprocess required.
120fn repo_name_from_cwd(cwd: &str) -> Option<String> {
121    let start = std::path::Path::new(cwd);
122    if !start.is_absolute() {
123        return None;
124    }
125    let mut dir = Some(start);
126    while let Some(d) = dir {
127        let dotgit = d.join(".git");
128        if dotgit.exists() {
129            if let Some(cfg) = git_config_path(&dotgit) {
130                if let Ok(text) = std::fs::read_to_string(&cfg) {
131                    if let Some(name) = origin_repo_name(&text) {
132                        return Some(name);
133                    }
134                }
135            }
136            return d.file_name().and_then(|n| n.to_str()).map(str::to_string);
137        }
138        dir = d.parent();
139    }
140    None
141}
142
143/// The `config` path for a `.git` entry — a directory for normal repos, or a
144/// `gitdir:`-pointer file for linked worktrees / submodules (resolved via the
145/// `commondir` so the shared config is read).
146fn git_config_path(dotgit: &std::path::Path) -> Option<std::path::PathBuf> {
147    if dotgit.is_dir() {
148        return Some(dotgit.join("config"));
149    }
150    let text = std::fs::read_to_string(dotgit).ok()?;
151    let gitdir = text
152        .lines()
153        .find_map(|l| l.strip_prefix("gitdir:").map(str::trim))?;
154    let gitdir_path = {
155        let p = std::path::Path::new(gitdir);
156        if p.is_absolute() {
157            p.to_path_buf()
158        } else {
159            dotgit.parent()?.join(p)
160        }
161    };
162    if let Ok(common) = std::fs::read_to_string(gitdir_path.join("commondir")) {
163        let common = common.trim();
164        let base = if std::path::Path::new(common).is_absolute() {
165            std::path::PathBuf::from(common)
166        } else {
167            gitdir_path.join(common)
168        };
169        return Some(base.join("config"));
170    }
171    Some(gitdir_path.join("config"))
172}
173
174/// Repo name from the `[remote "origin"]` url in a git config, if any.
175fn origin_repo_name(config: &str) -> Option<String> {
176    let mut in_origin = false;
177    for line in config.lines() {
178        let t = line.trim();
179        if t.starts_with('[') {
180            in_origin = t == "[remote \"origin\"]";
181            continue;
182        }
183        if in_origin {
184            if let Some(rest) = t.strip_prefix("url") {
185                if let Some(eq) = rest.trim_start().strip_prefix('=') {
186                    return repo_name_from_url(eq.trim());
187                }
188            }
189        }
190    }
191    None
192}
193
194/// Last path/scp segment of a remote URL with a trailing `.git` removed.
195/// Handles `git@host:owner/repo.git` and `https://host/owner/repo.git`.
196fn repo_name_from_url(url: &str) -> Option<String> {
197    let url = url.trim().trim_end_matches('/');
198    let seg = url.rsplit(|c| c == '/' || c == ':').next()?;
199    let seg = seg.strip_suffix(".git").unwrap_or(seg);
200    if seg.is_empty() {
201        None
202    } else {
203        Some(seg.to_string())
204    }
205}
206
207/// `basename(path)` with the final extension removed (mirrors
208/// `os.path.basename(path).rsplit(".", 1)[0]`).
209fn session_base_id(path: &str) -> String {
210    let base = path.trim_end_matches('/').rsplit('/').next().unwrap_or(path);
211    match base.rsplit_once('.') {
212        Some((stem, _ext)) if !stem.is_empty() => stem.to_string(),
213        _ => base.to_string(),
214    }
215}
216
217// Python's round() is banker's rounding (ties to even); mirror it so the
218// rounded cost/duration values are byte-identical.
219fn round6(x: f64) -> f64 {
220    (x * 1e6).round_ties_even() / 1e6
221}
222
223fn round1(x: f64) -> f64 {
224    (x * 10.0).round_ties_even() / 10.0
225}
226
227/// Format an epoch-seconds value as a UTC ISO8601 string ending in `Z`.
228/// Subsecond is emitted as 6 digits only when nonzero (mirrors Python's
229/// `datetime.fromtimestamp(ts, tz=utc).isoformat().replace("+00:00","Z")`).
230pub fn iso_utc(ts: f64) -> String {
231    let whole = ts.floor() as i64;
232    let micros = ((ts - ts.floor()) * 1_000_000.0).round() as i64;
233    let (whole, micros) = if micros >= 1_000_000 {
234        (whole + 1, 0)
235    } else {
236        (whole, micros)
237    };
238    let dt = OffsetDateTime::from_unix_timestamp(whole)
239        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
240        .to_offset(UtcOffset::UTC);
241    let base = format!(
242        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}",
243        dt.year(),
244        u8::from(dt.month()),
245        dt.day(),
246        dt.hour(),
247        dt.minute(),
248        dt.second(),
249    );
250    if micros == 0 {
251        format!("{base}Z")
252    } else {
253        format!("{base}.{micros:06}Z")
254    }
255}
256
257/// Parse an ISO-8601 / RFC-3339 timestamp to epoch seconds (mirrors Python
258/// `datetime.fromisoformat(...).timestamp()` for the `Z`/offset forms used in
259/// transcripts). Returns `None` on empty/unparseable input.
260pub fn parse_iso(value: Option<&str>) -> Option<f64> {
261    let s = value?;
262    if s.is_empty() {
263        return None;
264    }
265    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
266        .ok()
267        .map(|dt| dt.unix_timestamp_nanos() as f64 / 1e9)
268}
269
270/// Local civil date/labels for an epoch second at a fixed UTC offset.
271fn local_dt(ts: f64, offset: UtcOffset) -> OffsetDateTime {
272    OffsetDateTime::from_unix_timestamp(ts.floor() as i64)
273        .unwrap_or(OffsetDateTime::UNIX_EPOCH)
274        .to_offset(offset)
275}
276
277fn day_key(dt: OffsetDateTime) -> String {
278    format!("{:04}-{:02}-{:02}", dt.year(), u8::from(dt.month()), dt.day())
279}
280
281fn week_key(dt: OffsetDateTime) -> String {
282    let (iso_year, week, _) = dt.to_iso_week_date();
283    format!("{iso_year}-W{week:02}")
284}
285
286fn month_key(dt: OffsetDateTime) -> String {
287    format!("{:04}-{:02}", dt.year(), u8::from(dt.month()))
288}
289
290/// Insertion-ordered string-keyed accumulator, so a later stable sort
291/// reproduces Python's dict-insertion-order tie-breaking exactly.
292#[derive(Default)]
293struct OrderedBuckets {
294    order: Vec<String>,
295    idx: std::collections::HashMap<String, usize>,
296    buckets: Vec<Bucket>,
297}
298
299#[derive(Clone, Default)]
300struct Bucket {
301    tokens: u64,
302    sessions: u64,
303    cache_read: u64,
304    input: u64,
305    output: u64,
306    cache_creation: u64,
307    cost: f64,
308}
309
310impl Bucket {
311    fn accumulate(&mut self, s: &Session) {
312        self.tokens += s.tokens;
313        self.sessions += 1;
314        self.cache_read += s.cache_read;
315        self.input += s.input;
316        self.output += s.output;
317        self.cache_creation += s.cache_creation;
318        self.cost += s.cost;
319    }
320}
321
322impl OrderedBuckets {
323    fn entry(&mut self, key: &str) -> &mut Bucket {
324        if let Some(&i) = self.idx.get(key) {
325            return &mut self.buckets[i];
326        }
327        let i = self.buckets.len();
328        self.idx.insert(key.to_string(), i);
329        self.order.push(key.to_string());
330        self.buckets.push(Bucket::default());
331        &mut self.buckets[i]
332    }
333}
334
335/// Result of [`bucket_aggregates`] — mirrors the snapshot's aggregate fields.
336#[derive(Clone, Debug, Default)]
337pub struct Buckets {
338    pub total_tokens_30d: u64,
339    pub total_sessions_30d: u64,
340    pub total_cost_30d: f64,
341    pub total_input_30d: u64,
342    pub total_output_30d: u64,
343    pub cost_today: f64,
344    pub max_session_minutes: f64,
345    pub by_day: Vec<TimeBucket>,
346    pub by_week: Vec<TimeBucket>,
347    pub by_month: Vec<TimeBucket>,
348    pub by_model: Vec<NamedBucket>,
349    pub by_project: Vec<NamedBucket>,
350    pub by_day_project: Vec<DailyInstance>,
351    pub by_day_model: Vec<DailyModelInstance>,
352}
353
354/// Split each file's events into logical sessions on the 5h idle gap, returning
355/// `(sessions, recent)` — sessions feed [`bucket_aggregates`], recent feeds
356/// `recent_sessions`. `files` is iterated in its existing order (use a
357/// `BTreeMap` for determinism). Mirrors `split_logical_sessions`.
358pub fn split_logical_sessions(
359    files: &std::collections::BTreeMap<String, FileEvents>,
360) -> (Vec<Session>, Vec<SessionRecord>) {
361    let mut sessions = Vec::new();
362    let mut recent = Vec::new();
363
364    for (path, fe) in files {
365        let mut events = fe.events.clone();
366        events.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
367        if events.is_empty() {
368            continue;
369        }
370        // Split into chunks: a new chunk starts when an event is more than
371        // SESSION_IDLE_GAP after the current chunk's FIRST turn.
372        let mut chunks: Vec<Vec<(f64, TurnMetrics)>> = Vec::new();
373        let mut cur: Vec<(f64, TurnMetrics)> = vec![events[0]];
374        let mut session_start = events[0].0;
375        for &nxt in &events[1..] {
376            if nxt.0 - session_start > SESSION_IDLE_GAP {
377                chunks.push(std::mem::take(&mut cur));
378                cur = vec![nxt];
379                session_start = nxt.0;
380            } else {
381                cur.push(nxt);
382            }
383        }
384        chunks.push(cur);
385
386        let base_id = session_base_id(path);
387        let multi = chunks.len() > 1;
388        for (i, chunk) in chunks.iter().enumerate() {
389            let first_ts = chunk[0].0;
390            let last_ts = chunk[chunk.len() - 1].0;
391            let mut agg = TurnMetrics::default();
392            for (_, m) in chunk {
393                agg.add(m);
394            }
395            sessions.push(Session {
396                tokens: agg.total,
397                cache_read: agg.cache_read,
398                input: agg.input,
399                output: agg.output,
400                cache_creation: agg.cache_creation,
401                cost: agg.cost,
402                last_ts,
403                first_ts,
404                model: fe.model.clone(),
405                cwd: fe.cwd.clone(),
406            });
407            recent.push(SessionRecord {
408                id: if multi {
409                    format!("{base_id}#{}", i + 1)
410                } else {
411                    base_id.clone()
412                },
413                started_at: iso_utc(first_ts),
414                ended_at: iso_utc(last_ts),
415                duration_minutes: round1((last_ts - first_ts) / 60.0),
416                tokens: agg.total,
417                cache_read: agg.cache_read,
418                input: agg.input,
419                output: agg.output,
420                cache_creation: agg.cache_creation,
421                cost: round6(agg.cost),
422                model: fe.model.clone().unwrap_or_else(|| "—".to_string()),
423                project: project_name_from_cwd(fe.cwd.as_deref()),
424            });
425        }
426    }
427    (sessions, recent)
428}
429
430#[allow(clippy::too_many_arguments)]
431fn time_bucket(date: String, b: &Bucket) -> TimeBucket {
432    TimeBucket {
433        date,
434        tokens: b.tokens,
435        sessions: b.sessions,
436        input: b.input,
437        output: b.output,
438        cache_creation: b.cache_creation,
439        cache_read: b.cache_read,
440        cost: round6(b.cost),
441    }
442}
443
444fn named_bucket(model: String, b: &Bucket) -> NamedBucket {
445    NamedBucket {
446        model,
447        tokens: b.tokens,
448        sessions: b.sessions,
449        input: b.input,
450        output: b.output,
451        cache_creation: b.cache_creation,
452        cache_read: b.cache_read,
453        cost: round6(b.cost),
454    }
455}
456
457/// Stable top-N by tokens descending (ties keep insertion order).
458fn take_by_tokens(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
459    let mut items: Vec<(String, Bucket)> = buckets
460        .order
461        .iter()
462        .enumerate()
463        .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
464        .collect();
465    items.sort_by(|a, b| b.1.tokens.cmp(&a.1.tokens));
466    items.truncate(n);
467    items
468}
469
470/// Stable top-N by key string descending (for by_week / by_month).
471fn take_by_key(buckets: &OrderedBuckets, n: usize) -> Vec<(String, Bucket)> {
472    let mut items: Vec<(String, Bucket)> = buckets
473        .order
474        .iter()
475        .enumerate()
476        .map(|(i, k)| (k.clone(), buckets.buckets[i].clone()))
477        .collect();
478    items.sort_by(|a, b| b.0.cmp(&a.0));
479    items.truncate(n);
480    items
481}
482
483/// Roll sessions into day/week/month/model/project buckets + the day×project
484/// cross-tab + 30d totals. `now` is epoch seconds; `offset` is the fixed local
485/// UTC offset. Mirrors `bucket_aggregates` (days=365, weeks=52, months=24,
486/// instance_days=30, instance_rows=200).
487pub fn bucket_aggregates(sessions: &[Session], now: f64, offset: UtcOffset) -> Buckets {
488    let mut by_day = OrderedBuckets::default();
489    let mut by_week = OrderedBuckets::default();
490    let mut by_month = OrderedBuckets::default();
491    let mut by_model = OrderedBuckets::default();
492    let mut by_project = OrderedBuckets::default();
493    // (day, project) -> (bucket, ordered models). Keep insertion order.
494    let mut dp_order: Vec<(String, String)> = Vec::new();
495    let mut dp_idx: std::collections::HashMap<(String, String), usize> = Default::default();
496    let mut dp_buckets: Vec<Bucket> = Vec::new();
497    let mut dp_models: Vec<Vec<String>> = Vec::new();
498    // (day, model) -> bucket. Full window (the Stats heatmap scopes the whole
499    // year to a model), insertion-ordered for stable output.
500    let mut dm_order: Vec<(String, String)> = Vec::new();
501    let mut dm_idx: std::collections::HashMap<(String, String), usize> = Default::default();
502    let mut dm_buckets: Vec<Bucket> = Vec::new();
503
504    let mut total30: u64 = 0;
505    let mut sessions30: u64 = 0;
506    let mut cost30: f64 = 0.0;
507    let mut input30: u64 = 0;
508    let mut output30: u64 = 0;
509    let cutoff30 = now - WIN_30D;
510    let today_key = day_key(local_dt(now, offset));
511
512    for s in sessions {
513        let ts = s.last_ts;
514        let dt = local_dt(ts, offset);
515        let day = day_key(dt);
516        let week = week_key(dt);
517        let month = month_key(dt);
518        let proj = project_name_from_cwd(s.cwd.as_deref());
519
520        by_day.entry(&day).accumulate(s);
521        by_week.entry(&week).accumulate(s);
522        by_month.entry(&month).accumulate(s);
523        if let Some(model) = &s.model {
524            if !model.is_empty() {
525                by_model.entry(model).accumulate(s);
526                // Per (day × model) — full window for the model-scoped Stats view.
527                let key = (day.clone(), model.clone());
528                let i = match dm_idx.get(&key) {
529                    Some(&i) => i,
530                    None => {
531                        let i = dm_buckets.len();
532                        dm_idx.insert(key.clone(), i);
533                        dm_order.push(key);
534                        dm_buckets.push(Bucket::default());
535                        i
536                    }
537                };
538                dm_buckets[i].accumulate(s);
539            }
540        }
541        by_project.entry(&proj).accumulate(s);
542
543        // Per (day × project) cross-tab, scoped to the recent 30-day window.
544        if now - ts <= 30.0 * 86400.0 {
545            let key = (day.clone(), proj.clone());
546            let i = match dp_idx.get(&key) {
547                Some(&i) => i,
548                None => {
549                    let i = dp_buckets.len();
550                    dp_idx.insert(key.clone(), i);
551                    dp_order.push(key.clone());
552                    dp_buckets.push(Bucket::default());
553                    dp_models.push(Vec::new());
554                    i
555                }
556            };
557            dp_buckets[i].accumulate(s);
558            if let Some(model) = &s.model {
559                if !model.is_empty() && !dp_models[i].contains(model) {
560                    dp_models[i].push(model.clone());
561                }
562            }
563        }
564
565        if ts >= cutoff30 {
566            total30 += s.tokens;
567            sessions30 += 1;
568            cost30 += s.cost;
569            input30 += s.input;
570            output30 += s.output;
571        }
572    }
573
574    // by_day: pad every calendar day in the 365-day window, newest first.
575    let today_local = local_dt(now, offset);
576    let today_date = today_local.date();
577    let mut padded_day = Vec::with_capacity(365);
578    for i in 0..365i64 {
579        let d: Date = today_date.saturating_sub(time::Duration::days(i));
580        let key = format!("{:04}-{:02}-{:02}", d.year(), u8::from(d.month()), d.day());
581        match by_day.idx.get(&key) {
582            Some(&j) => padded_day.push(time_bucket(key.clone(), &by_day.buckets[j])),
583            None => padded_day.push(time_bucket(key.clone(), &Bucket::default())),
584        }
585    }
586
587    let by_week_out = take_by_key(&by_week, 52)
588        .into_iter()
589        .map(|(k, b)| time_bucket(k, &b))
590        .collect();
591    let by_month_out = take_by_key(&by_month, 24)
592        .into_iter()
593        .map(|(k, b)| time_bucket(k, &b))
594        .collect();
595    let by_model_out = take_by_tokens(&by_model, 20)
596        .into_iter()
597        .map(|(k, b)| named_bucket(k, &b))
598        .collect();
599    let by_project_out = take_by_tokens(&by_project, 20)
600        .into_iter()
601        .map(|(k, b)| named_bucket(k, &b))
602        .collect();
603
604    // by_day_project: newest day first, within a day by cost desc; cap 200.
605    let mut instances: Vec<DailyInstance> = dp_order
606        .iter()
607        .enumerate()
608        .map(|(i, (day, proj))| {
609            let b = &dp_buckets[i];
610            let mut models = dp_models[i].clone();
611            models.sort();
612            DailyInstance {
613                date: day.clone(),
614                project: proj.clone(),
615                models,
616                tokens: b.tokens,
617                sessions: b.sessions,
618                input: b.input,
619                output: b.output,
620                cache_creation: b.cache_creation,
621                cache_read: b.cache_read,
622                cost: round6(b.cost),
623            }
624        })
625        .collect();
626    // Python: sort(key=(date, cost), reverse=True) — a single stable sort with a
627    // composite key, descending on both.
628    instances.sort_by(|a, b| {
629        b.date
630            .cmp(&a.date)
631            .then(b.cost.partial_cmp(&a.cost).unwrap_or(std::cmp::Ordering::Equal))
632    });
633    instances.truncate(200);
634
635    // by_day_model: newest day first, within a day by tokens desc. Capped wide
636    // enough to keep a full year for several models.
637    let mut model_days: Vec<DailyModelInstance> = dm_order
638        .iter()
639        .enumerate()
640        .map(|(i, (day, model))| {
641            let b = &dm_buckets[i];
642            DailyModelInstance {
643                date: day.clone(),
644                model: model.clone(),
645                tokens: b.tokens,
646                sessions: b.sessions,
647                input: b.input,
648                output: b.output,
649                cache_creation: b.cache_creation,
650                cache_read: b.cache_read,
651                cost: round6(b.cost),
652            }
653        })
654        .collect();
655    model_days.sort_by(|a, b| b.date.cmp(&a.date).then(b.tokens.cmp(&a.tokens)));
656    model_days.truncate(2000);
657
658    // Longest single session across all history (minutes).
659    let mut max_session_minutes = 0.0f64;
660    for s in sessions {
661        let dur = (s.last_ts - s.first_ts) / 60.0;
662        if dur > max_session_minutes {
663            max_session_minutes = dur;
664        }
665    }
666
667    let cost_today = by_day
668        .idx
669        .get(&today_key)
670        .map(|&j| by_day.buckets[j].cost)
671        .unwrap_or(0.0);
672
673    Buckets {
674        total_tokens_30d: total30,
675        total_sessions_30d: sessions30,
676        total_cost_30d: round6(cost30),
677        total_input_30d: input30,
678        total_output_30d: output30,
679        cost_today: round6(cost_today),
680        max_session_minutes: round1(max_session_minutes),
681        by_day: padded_day,
682        by_week: by_week_out,
683        by_month: by_month_out,
684        by_model: by_model_out,
685        by_project: by_project_out,
686        by_day_project: instances,
687        by_day_model: model_days,
688    }
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn project_name_basename_rules() {
697        assert_eq!(project_name_from_cwd(None), "—");
698        assert_eq!(project_name_from_cwd(Some("")), "—");
699        // Not a git repo → parent/leaf (so a plain dir reads as a location).
700        assert_eq!(project_name_from_cwd(Some("/a/b/c")), "b/c");
701        assert_eq!(project_name_from_cwd(Some("/a/b/c/")), "b/c");
702        assert_eq!(project_name_from_cwd(Some("/")), "/");
703    }
704
705    #[test]
706    fn session_id_strips_extension() {
707        assert_eq!(session_base_id("/x/y/abc.jsonl"), "abc");
708        assert_eq!(session_base_id("/x/y/a.b.jsonl"), "a.b");
709        assert_eq!(session_base_id("noext"), "noext");
710    }
711
712    #[test]
713    fn idle_gap_splits_from_first_turn() {
714        let mut files = std::collections::BTreeMap::new();
715        let m = TurnMetrics { total: 10, input: 5, output: 5, ..Default::default() };
716        files.insert(
717            "/p/s.jsonl".to_string(),
718            FileEvents {
719                model: Some("claude-opus-4-8".into()),
720                cwd: Some("/home/proj".into()),
721                // t=0, t=1h (same session, <5h from start), t=6h (new session).
722                events: vec![(0.0, m), (3600.0, m), (6.0 * 3600.0, m)],
723            },
724        );
725        let (sessions, recent) = split_logical_sessions(&files);
726        assert_eq!(sessions.len(), 2);
727        assert_eq!(recent.len(), 2);
728        assert_eq!(recent[0].id, "s#1");
729        assert_eq!(sessions[0].tokens, 20); // first two turns
730        assert_eq!(sessions[1].tokens, 10);
731    }
732}