opencode-stats 1.3.5

A terminal dashboard for OpenCode usage statistics inspired by the /stats command in Claude Code
use chrono::NaiveDate;

use crate::cache::models_cache::PricingCatalog;
use crate::db::models::{TokenUsage, UsageEvent};
use crate::utils::pricing::{PriceSummary, ZeroCostBehavior, update_price_summary};

#[derive(Clone, Debug)]
pub struct DailyUsage {
    pub date: NaiveDate,
    pub tokens: TokenUsage,
    pub interactions: usize,
    pub cost: PriceSummary,
}

pub fn aggregate_daily(
    events: &[UsageEvent],
    pricing: &PricingCatalog,
    today: NaiveDate,
    zero_cost_behavior: ZeroCostBehavior,
) -> Vec<DailyUsage> {
    let mut grouped = std::collections::BTreeMap::<NaiveDate, DailyUsage>::new();
    for event in events {
        let Some(date) = event.activity_date() else {
            continue;
        };
        if date > today {
            continue;
        }
        let entry = grouped.entry(date).or_insert_with(|| DailyUsage {
            date,
            tokens: TokenUsage::default(),
            interactions: 0,
            cost: PriceSummary::default(),
        });
        entry.tokens.add_assign(&event.tokens);
        entry.interactions += 1;
        update_price_summary(&mut entry.cost, pricing, event, zero_cost_behavior);
    }

    grouped.into_values().collect()
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::path::PathBuf;

    use chrono::{Local, NaiveDate, TimeZone};
    use rust_decimal::Decimal;

    use super::aggregate_daily;
    use crate::cache::models_cache::{ModelPricing, PricingAvailability, PricingCatalog};
    use crate::db::models::{DataSourceKind, TokenUsage, UsageEvent};
    use crate::utils::pricing::ZeroCostBehavior;

    fn pricing_catalog() -> PricingCatalog {
        let mut models = BTreeMap::new();
        models.insert(
            "openai/gpt-5".to_string(),
            ModelPricing {
                input: Decimal::new(100, 0),
                output: Decimal::new(100, 0),
                cache_write: Decimal::ZERO,
                cache_read: Decimal::ZERO,
                context_window: 0,
                session_quota: Decimal::ZERO,
            },
        );

        PricingCatalog {
            models,
            cache_path: PathBuf::from("/tmp/models.json"),
            refresh_needed: false,
            availability: PricingAvailability::Cached,
            load_notice: None,
        }
    }

    fn usage_event(stored_cost_usd: Option<Decimal>, tokens: TokenUsage) -> UsageEvent {
        let created_at = Local
            .with_ymd_and_hms(2026, 3, 12, 9, 30, 0)
            .single()
            .unwrap();

        UsageEvent {
            session_id: "ses_1".to_string(),
            parent_session_id: None,
            session_title: None,
            session_started_at: Some(created_at),
            session_archived_at: None,
            project_name: None,
            project_path: None,
            provider_id: Some("openai".to_string()),
            model_id: "gpt-5".to_string(),
            agent: None,
            finish_reason: Some("stop".to_string()),
            tokens,
            created_at: Some(created_at),
            completed_at: Some(created_at),
            stored_cost_usd,
            source: DataSourceKind::Json,
        }
    }

    #[test]
    fn daily_cost_estimates_when_zero_is_ignored() {
        let day = NaiveDate::from_ymd_opt(2026, 3, 12).unwrap();
        let daily = aggregate_daily(
            &[usage_event(
                Some(Decimal::ZERO),
                TokenUsage {
                    input: 1_000_000,
                    output: 1_000_000,
                    cache_read: 0,
                    cache_write: 0,
                },
            )],
            &pricing_catalog(),
            day,
            ZeroCostBehavior::EstimateWhenZero,
        );

        assert_eq!(daily.len(), 1);
        assert_eq!(daily[0].cost.known, Decimal::new(200, 0));
        assert!(daily[0].cost.has_known);
        assert!(!daily[0].cost.missing);
    }

    #[test]
    fn daily_cost_keeps_zero_when_requested() {
        let day = NaiveDate::from_ymd_opt(2026, 3, 12).unwrap();
        let daily = aggregate_daily(
            &[usage_event(
                Some(Decimal::ZERO),
                TokenUsage {
                    input: 1_000_000,
                    output: 1_000_000,
                    cache_read: 0,
                    cache_write: 0,
                },
            )],
            &pricing_catalog(),
            day,
            ZeroCostBehavior::KeepZero,
        );

        assert_eq!(daily.len(), 1);
        assert_eq!(daily[0].cost.known, Decimal::ZERO);
        assert!(daily[0].cost.has_known);
        assert!(!daily[0].cost.missing);
    }

    #[test]
    fn daily_cost_keeps_true_zero_for_zero_token_events() {
        let day = NaiveDate::from_ymd_opt(2026, 3, 12).unwrap();
        let daily = aggregate_daily(
            &[usage_event(Some(Decimal::ZERO), TokenUsage::default())],
            &pricing_catalog(),
            day,
            ZeroCostBehavior::EstimateWhenZero,
        );

        assert_eq!(daily.len(), 1);
        assert_eq!(daily[0].cost.known, Decimal::ZERO);
        assert!(daily[0].cost.has_known);
        assert!(!daily[0].cost.missing);
    }
}