use crate::usage_stats::model::{SessionId, SessionRecord, TokenUsage};
use crate::usage_stats::query::{GroupBy, StatsQuery, TimeRangeMode};
use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coverage {
pub total_sessions: usize,
pub known_token_sessions: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenTotals {
pub tokens_total_known: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_input_known: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_output_known: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_cache_known: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens_reasoning_known: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SidechainTotals {
pub primary: TokenTotals,
pub sidechain: TokenTotals,
}
#[derive(Default)]
struct TokenTotalsAccum {
total: u64,
input_sum: u64,
input_any: bool,
output_sum: u64,
output_any: bool,
cache_sum: u64,
cache_any: bool,
reasoning_sum: u64,
reasoning_any: bool,
}
impl TokenTotalsAccum {
fn add(&mut self, usage: &TokenUsage) {
if let Some(v) = usage.total {
self.total = self.total.saturating_add(v);
}
if let Some(v) = usage.input {
self.input_any = true;
self.input_sum = self.input_sum.saturating_add(v);
}
if let Some(v) = usage.output {
self.output_any = true;
self.output_sum = self.output_sum.saturating_add(v);
}
if let Some(v) = usage.cache {
self.cache_any = true;
self.cache_sum = self.cache_sum.saturating_add(v);
}
if let Some(v) = usage.reasoning {
self.reasoning_any = true;
self.reasoning_sum = self.reasoning_sum.saturating_add(v);
}
}
fn build(self) -> TokenTotals {
TokenTotals {
tokens_total_known: self.total,
tokens_input_known: self.input_any.then_some(self.input_sum),
tokens_output_known: self.output_any.then_some(self.output_sum),
tokens_cache_known: self.cache_any.then_some(self.cache_sum),
tokens_reasoning_known: self.reasoning_any.then_some(self.reasoning_sum),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SummaryView {
pub totals: TokenTotals,
pub coverage: Coverage,
#[serde(skip_serializing_if = "Option::is_none")]
pub latest_end_ts: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sidechain_totals: Option<SidechainTotals>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatsBucket {
pub label: String,
pub start_local: String,
pub end_local_exclusive: String,
pub totals: TokenTotals,
pub coverage: Coverage,
#[serde(skip_serializing_if = "Option::is_none")]
pub sidechain_totals: Option<SidechainTotals>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrendView {
pub group_by: GroupBy,
pub buckets: Vec<StatsBucket>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionsView {
pub total_sessions: usize,
pub returned_sessions: usize,
pub sessions: Vec<SessionRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionDetailView {
pub session: SessionRecord,
}
pub fn filter_sessions_v1(sessions: &[SessionRecord], query: &StatsQuery) -> Vec<SessionRecord> {
sessions
.iter()
.filter(|session| session.cwd == query.cwd)
.filter(|session| match query.time_range.mode {
TimeRangeMode::All => true,
_ => {
if let Some(since) = query.time_range.since
&& session.end_ts < since
{
return false;
}
if let Some(until) = query.time_range.until
&& session.end_ts >= until
{
return false;
}
true
}
})
.cloned()
.collect()
}
pub fn build_summary_view(sessions: &[SessionRecord]) -> SummaryView {
let coverage = Coverage {
total_sessions: sessions.len(),
known_token_sessions: sessions
.iter()
.filter(|session| session.token_usage.total.is_some())
.count(),
};
let mut totals = TokenTotalsAccum::default();
let mut sidechain_primary = TokenTotalsAccum::default();
let mut sidechain_sidechain = TokenTotalsAccum::default();
let mut has_sidechain_marker = false;
let mut latest: Option<DateTime<Utc>> = None;
for session in sessions {
totals.add(&session.token_usage);
if let Some(is_sidechain) = session.is_sidechain {
has_sidechain_marker = true;
if is_sidechain {
sidechain_sidechain.add(&session.token_usage);
} else {
sidechain_primary.add(&session.token_usage);
}
}
latest = match latest {
None => Some(session.end_ts),
Some(current) => Some(current.max(session.end_ts)),
};
}
SummaryView {
totals: totals.build(),
coverage,
latest_end_ts: latest,
sidechain_totals: has_sidechain_marker.then_some(SidechainTotals {
primary: sidechain_primary.build(),
sidechain: sidechain_sidechain.build(),
}),
}
}
pub fn build_trend_view(sessions: &[SessionRecord], group_by: GroupBy) -> TrendView {
let mut buckets: BTreeMap<NaiveDate, Vec<&SessionRecord>> = BTreeMap::new();
for session in sessions {
let key = bucket_start_date_local(session.end_ts, group_by);
buckets.entry(key).or_default().push(session);
}
let buckets = buckets
.into_iter()
.map(|(start, sessions)| build_bucket(start, group_by, sessions))
.collect();
TrendView { group_by, buckets }
}
fn build_bucket(start: NaiveDate, group_by: GroupBy, sessions: Vec<&SessionRecord>) -> StatsBucket {
let (label, end_exclusive) = bucket_label_and_end_exclusive(start, group_by);
let coverage = Coverage {
total_sessions: sessions.len(),
known_token_sessions: sessions
.iter()
.filter(|session| session.token_usage.total.is_some())
.count(),
};
let mut totals = TokenTotalsAccum::default();
let mut sidechain_primary = TokenTotalsAccum::default();
let mut sidechain_sidechain = TokenTotalsAccum::default();
let mut has_sidechain_marker = false;
for session in sessions {
totals.add(&session.token_usage);
if let Some(is_sidechain) = session.is_sidechain {
has_sidechain_marker = true;
if is_sidechain {
sidechain_sidechain.add(&session.token_usage);
} else {
sidechain_primary.add(&session.token_usage);
}
}
}
StatsBucket {
label,
start_local: start.format("%Y-%m-%d").to_string(),
end_local_exclusive: end_exclusive.format("%Y-%m-%d").to_string(),
totals: totals.build(),
coverage,
sidechain_totals: has_sidechain_marker.then_some(SidechainTotals {
primary: sidechain_primary.build(),
sidechain: sidechain_sidechain.build(),
}),
}
}
fn bucket_start_date_local(end_ts: DateTime<Utc>, group_by: GroupBy) -> NaiveDate {
let local_date = end_ts.with_timezone(&Local).date_naive();
match group_by {
GroupBy::Day => local_date,
GroupBy::Week => {
let days_from_monday = i64::from(local_date.weekday().num_days_from_monday());
local_date - Duration::days(days_from_monday)
}
GroupBy::Month => NaiveDate::from_ymd_opt(local_date.year(), local_date.month(), 1)
.expect("valid first-of-month"),
}
}
fn bucket_label_and_end_exclusive(start: NaiveDate, group_by: GroupBy) -> (String, NaiveDate) {
match group_by {
GroupBy::Day => (
start.format("%Y-%m-%d").to_string(),
start + Duration::days(1),
),
GroupBy::Week => (
start.format("%Y-%m-%d").to_string(),
start + Duration::days(7),
),
GroupBy::Month => {
let label = format!("{:04}-{:02}", start.year(), start.month());
let (year, month) = if start.month() == 12 {
(start.year() + 1, 1)
} else {
(start.year(), start.month() + 1)
};
let end = NaiveDate::from_ymd_opt(year, month, 1).expect("valid first-of-month");
(label, end)
}
}
}
pub fn build_sessions_view(sessions: &[SessionRecord], limit: usize) -> SessionsView {
let mut sorted = sessions.to_vec();
sorted.sort_by_key(|session| std::cmp::Reverse(session.end_ts));
let total_sessions = sorted.len();
let sessions = if limit == 0 {
sorted
} else {
sorted.into_iter().take(limit).collect()
};
SessionsView {
total_sessions,
returned_sessions: sessions.len(),
sessions,
}
}
pub fn build_session_detail_view(
sessions: &[SessionRecord],
id: &SessionId,
) -> Option<SessionDetailView> {
sessions
.iter()
.find(|session| &session.id == id)
.cloned()
.map(|session| SessionDetailView { session })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::usage_stats::model::{SessionRecord, TokenUsage, ToolKind};
use crate::usage_stats::query::{StatsQuery, TimeRange};
use chrono::{TimeZone, Weekday};
use std::path::PathBuf;
fn make_session(
id: &str,
cwd: &str,
end_ts: DateTime<Utc>,
tokens: Option<u64>,
) -> SessionRecord {
SessionRecord {
tool: ToolKind::Codex,
id: SessionId(id.to_string()),
cwd: cwd.into(),
title: None,
start_ts: None,
end_ts,
token_usage: TokenUsage {
total: tokens,
..TokenUsage::default()
},
is_sidechain: None,
}
}
#[test]
fn sessions_view_sorts_desc_and_limits() {
let sessions = vec![
make_session(
"a",
"/p",
DateTime::parse_from_rfc3339("2026-02-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
Some(10),
),
make_session(
"b",
"/p",
DateTime::parse_from_rfc3339("2026-02-02T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
Some(20),
),
];
let view = build_sessions_view(&sessions, 1);
assert_eq!(view.returned_sessions, 1);
assert_eq!(view.sessions[0].id.0, "b");
let view_all = build_sessions_view(&sessions, 0);
assert_eq!(view_all.returned_sessions, 2);
}
#[test]
fn filter_sessions_v1_filters_by_cwd_and_time() {
let sessions = vec![
make_session(
"a",
"/p/a",
DateTime::parse_from_rfc3339("2026-02-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
Some(1),
),
make_session(
"b",
"/p/b",
DateTime::parse_from_rfc3339("2026-02-02T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
Some(1),
),
];
let query = StatsQuery {
view: crate::usage_stats::query::ViewKind::Summary,
group_by: GroupBy::Day,
time_range: TimeRange {
mode: TimeRangeMode::SinceUntil,
since: Some(
DateTime::parse_from_rfc3339("2026-02-02T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
until: None,
last_days: None,
},
cwd: PathBuf::from("/p/b"),
limit: 200,
id: None,
};
let filtered = filter_sessions_v1(&sessions, &query);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id.0, "b");
}
#[test]
fn bucketing_week_starts_monday() {
let end_ts = DateTime::parse_from_rfc3339("2026-02-24T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
let start = bucket_start_date_local(end_ts, GroupBy::Week);
let local_midnight = Local
.from_local_datetime(
&start
.and_hms_opt(0, 0, 0)
.expect("valid midnight NaiveDateTime"),
)
.single()
.expect("local time");
assert_eq!(local_midnight.weekday(), Weekday::Mon);
}
}