diff --git a/src-tauri/src/analytics.rs b/src-tauri/src/analytics.rs new file mode 100644 index 00000000..15cc1e5c --- /dev/null +++ b/src-tauri/src/analytics.rs @@ -0,0 +1,642 @@ +//! Read-only local log analytics. Never reads auth files or exports message content. +use crate::{ + models::ModelUsageBreakdown, + pricing::{ModelPricing, PricingStore, TokenBreakdown}, + providers::{ + claude::local_usage as claude, codex::local_usage as codex, log_usage::load_or_parse_log, + DailyUsageAccumulator, + }, + storage::Storage, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{ + collections::{HashMap, HashSet}, + fs, + sync::Arc, +}; +use tauri::{AppHandle, Manager, State}; + +#[derive(Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AnalyticsReport { + pub tokens: u64, + pub estimated_cost_usd: f64, + pub unpriced_events: usize, + pub completed_tasks: usize, + pub priced_tasks: usize, + pub average_cost_usd: Option, + pub timed_tasks: usize, + pub average_seconds: Option, + pub files_read: usize, + pub unreadable_files: usize, + pub malformed_lines: usize, + pub earliest_event: Option>, + pub model_breakdown: Option, + pub unknown_models: Vec, +} + +#[derive(Clone, Serialize, Deserialize)] +struct Task { + id: String, + start: DateTime, + end: DateTime, +} + +#[derive(Serialize, Deserialize)] +struct ParsedLog { + events: Vec, + claude_events: Vec, + tasks: Vec, + malformed: usize, +} + +fn parse_log(content: &str, provider: &str) -> ParsedLog { + let malformed = content + .lines() + .filter(|line| !line.trim().is_empty() && serde_json::from_str::(line).is_err()) + .count(); + if provider == "codex" { + ParsedLog { + events: codex::parse_jsonl(content), + claude_events: Vec::new(), + tasks: tasks(content), + malformed, + } + } else { + ParsedLog { + events: Vec::new(), + claude_events: claude::parse_jsonl(content), + tasks: claude_tasks(content), + malformed, + } + } +} + +// Only explicit terminal assistant messages count as completed Claude turns. +// A subsequent user message is never used to invent a completion time. +fn claude_tasks(content: &str) -> Vec { + let mut active: Option<(String, DateTime)> = None; + let mut output = Vec::new(); + for line in content.lines() { + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + if v["isSidechain"] == true { + continue; + } + let Some(at) = timestamp(&v["timestamp"]) else { + continue; + }; + if v["type"] == "user" && v["message"]["role"] == "user" && v["isMeta"] != true { + // Tool results are not new human tasks. + let tool_result = v["message"]["content"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["type"] == "tool_result")); + if !tool_result { + if let Some(id) = v["uuid"].as_str() { + active = Some((id.to_owned(), at)); + } + } + } else if v["type"] == "assistant" && v["message"]["stop_reason"] == "end_turn" { + if let Some((id, start)) = active.take().filter(|(_, start)| *start <= at) { + output.push(Task { id, start, end: at }); + } + } + } + output +} + +fn timestamp(value: &Value) -> Option> { + if let Some(text) = value.as_str() { + return text.parse().ok(); + } + let seconds = value.as_f64()?; + if !seconds.is_finite() || seconds.abs() > 253402300799.0 { + return None; + } + DateTime::from_timestamp_millis((seconds * 1000.0).round() as i64) +} + +fn tasks(content: &str) -> Vec { + let mut starts = HashMap::new(); + let mut result = Vec::new(); + let mut child_started_at = None; + for line in content.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value["type"] == "session_meta" && codex::is_child_session_meta(&value["payload"]) { + child_started_at = timestamp(&value["timestamp"]); + } + if value["type"] != "event_msg" { + continue; + } + let p = &value["payload"]; + let Some(id) = p["turn_id"].as_str() else { + continue; + }; + let Some(at) = timestamp(&value["timestamp"]) else { + continue; + }; + match p["type"].as_str() { + Some("task_started" | "turn_started") => { + let start = timestamp(&p["started_at"]).unwrap_or(at); + if child_started_at.is_some_and(|boundary| start < boundary) { + continue; + } + starts.insert(id.to_owned(), start); + } + Some("task_complete" | "turn_complete" | "turn_completed") => { + let start = starts.remove(id).or_else(|| timestamp(&p["started_at"])); + let end = timestamp(&p["completed_at"]).unwrap_or(at); + if let Some(start) = start.filter(|start| *start <= end) { + if child_started_at.is_some_and(|boundary| start < boundary) { + continue; + } + result.push(Task { + id: id.to_owned(), + start, + end, + }); + } + } + _ => {} + } + } + result +} + +fn aggregate( + logs: impl IntoIterator, + start: DateTime, + end: DateTime, + pricing: &ModelPricing, +) -> AnalyticsReport { + let mut report = AnalyticsReport::default(); + let mut seen_events = HashSet::new(); + let mut seen_tasks = HashSet::new(); + let mut task_cost = 0.0; + let mut seconds = 0.0; + let mut all_claude_events = Vec::new(); + let mut usage = DailyUsageAccumulator::default(); + let mut saw_codex = false; + let mut saw_claude = false; + for log in logs { + report.files_read += 1; + report.malformed_lines += log.malformed; + let events = log.events; + if !log.claude_events.is_empty() { + saw_claude = true; + let local_events = claude::deduplicate(log.claude_events.clone()); + for task in log.tasks { + if task.start < start || task.end >= end || !seen_tasks.insert(task.id.clone()) { + continue; + } + report.completed_tasks += 1; + report.timed_tasks += 1; + seconds += (task.end - task.start).num_milliseconds() as f64 / 1000.0; + let task_events: Vec<_> = local_events + .iter() + .filter(|e| e.timestamp >= task.start && e.timestamp <= task.end) + .collect(); + if task_events.is_empty() { + continue; + } + let costs: Option> = task_events + .iter() + .map(|e| claude_cost(e, pricing)) + .collect(); + if let Some(costs) = costs { + report.priced_tasks += 1; + task_cost += costs.iter().sum::(); + } + } + all_claude_events.extend(log.claude_events); + continue; + } + saw_codex = true; + for event in &events { + report.earliest_event = Some( + report + .earliest_event + .map_or(event.timestamp, |earliest| earliest.min(event.timestamp)), + ); + if event.timestamp < start || event.timestamp >= end { + continue; + } + // Preserve the upstream cross-file deduplication identity. + let key = ( + event.timestamp, + event.model.clone(), + event.input, + event.cached, + event.output, + event.reasoning, + event.total, + ); + if !seen_events.insert(key) { + continue; + } + report.tokens += event.total; + let date = event.timestamp.with_timezone(&chrono::Local).date_naive(); + match codex::estimate_cost(event, pricing) { + Some(cost) => { + report.estimated_cost_usd += cost; + usage.add(date, event.total, cost, event.model.trim()); + } + None => { + report.unpriced_events += 1; + if event.total > 0 { + usage.add_unknown_model(date, &event.model); + } + } + } + } + for task in log.tasks { + // Means use only complete turns entirely contained in the interval. + if task.start < start || task.end >= end || !seen_tasks.insert(task.id.clone()) { + continue; + } + report.completed_tasks += 1; + report.timed_tasks += 1; + seconds += (task.end - task.start).num_milliseconds() as f64 / 1000.0; + let task_events: Vec<_> = events + .iter() + .filter(|e| e.timestamp >= task.start && e.timestamp <= task.end) + .collect(); + if task_events.is_empty() { + continue; + } + let costs: Option> = task_events + .iter() + .map(|e| codex::estimate_cost(e, pricing)) + .collect(); + if let Some(costs) = costs { + report.priced_tasks += 1; + task_cost += costs.iter().sum::(); + } + } + } + let claude_events = claude::deduplicate(all_claude_events); + for event in &claude_events { + report.earliest_event = Some( + report + .earliest_event + .map_or(event.timestamp, |earliest| earliest.min(event.timestamp)), + ); + if event.timestamp < start || event.timestamp >= end { + continue; + } + report.tokens += claude_tokens(event).total_tokens(); + let date = event.timestamp.with_timezone(&chrono::Local).date_naive(); + let model = event + .model + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()); + match claude_cost(event, pricing) { + Some(cost) => { + report.estimated_cost_usd += cost; + usage.add( + date, + claude_tokens(event).total_tokens(), + cost, + model.unwrap_or("Unattributed"), + ); + } + None => { + report.unpriced_events += 1; + if let Some(model) = model { + usage.add_unknown_model(date, model); + } + } + } + } + report.average_cost_usd = + (report.priced_tasks > 0).then(|| task_cost / report.priced_tasks as f64); + report.average_seconds = (report.timed_tasks > 0).then(|| seconds / report.timed_tasks as f64); + let source_note = if saw_claude && !saw_codex { + "From your Claude usage history (estimated)" + } else if saw_codex && !saw_claude { + "From your Codex logs (estimated)" + } else { + "From your local provider usage histories (estimated)" + }; + (report.model_breakdown, report.unknown_models) = usage.build_range_details(source_note); + report +} + +fn claude_tokens(event: &claude::ClaudeTokenEvent) -> TokenBreakdown { + TokenBreakdown { + input: event.input, + cache_write_5m: event.cache_write_5m, + cache_write_1h: event.cache_write_1h, + cache_read: event.cache_read, + output: event.output, + is_fast: event.is_fast, + } +} + +fn claude_cost(event: &claude::ClaudeTokenEvent, pricing: &ModelPricing) -> Option { + event.cost_usd.or_else(|| { + pricing.estimated_cost_dollars(event.model.as_deref()?, claude_tokens(event), true) + }) +} + +#[tauri::command] +pub async fn get_range_analytics( + start: DateTime, + end: DateTime, + provider: String, + pricing: State<'_, Arc>, + storage: State<'_, Arc>, +) -> Result { + if start >= end || end > Utc::now() + chrono::Duration::minutes(1) { + return Err("Choose a valid past range, start inclusive and end exclusive.".into()); + } + if !matches!(provider.as_str(), "codex" | "claude") { + return Err("Task-level logs are unavailable for this provider.".into()); + } + let pricing = pricing.current(); + let storage = storage.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let files = if provider == "codex" { + let configured = crate::provider_environment::value("CODEX_HOME"); + let homes = codex::codex_homes( + configured.as_deref().map(std::ffi::OsStr::new), + &codex::home_directory(), + ); + codex::discover_session_files(&homes) + } else { + claude::discover_files(&[], true) + }; + let mut unreadable = 0; + let mut cache_error = false; + let cache_id = format!("analytics-{provider}"); + // One raw log at a time; only content-free parsed metrics enter the existing local cache. + let logs = files.into_iter().filter_map(|path| { + match load_or_parse_log(&storage, &cache_id, &path, 1, |content| { + vec![parse_log(content, &provider)] + }) { + Ok(Some(mut logs)) => logs.pop(), + Ok(None) => { + unreadable += 1; + None + } + Err(_) => { + cache_error = true; + None + } + } + }); + let mut report = aggregate(logs, start, end, &pricing); + if cache_error { + return Err( + "The local analytics cache is unavailable; no unreliable total was shown.".into(), + ); + } + report.unreadable_files = unreadable; + Ok(report) + }) + .await + .map_err(|_| "Local analytics reading was interrupted.".to_owned())? +} + +#[tauri::command] +pub fn get_banked_reset_marker( + app: AppHandle, + provider: String, +) -> Result>, String> { + validate_reset_provider(&provider)?; + let dir = app + .path() + .app_data_dir() + .map_err(|_| "Local storage is unavailable.")?; + let path = reset_marker_path(&dir, &provider); + match fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "The saved reset marker is unreadable.".into()), + // Codex markers written before provider-specific reset tracking remain valid. + Err(error) if error.kind() == std::io::ErrorKind::NotFound && provider == "codex" => { + match fs::read(dir.join("analytics-reset.json")) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "The saved reset marker is unreadable.".into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("The reset marker could not be read.".into()), + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => Err("The reset marker could not be read.".into()), + } +} + +#[tauri::command] +pub fn set_banked_reset_marker( + app: AppHandle, + provider: String, + reset_at: DateTime, +) -> Result<(), String> { + validate_reset_provider(&provider)?; + if reset_at > Utc::now() + chrono::Duration::minutes(1) { + return Err("The reset time cannot be in the future.".into()); + } + record_banked_reset(&app, &provider, reset_at) +} + +pub fn record_banked_reset( + app: &AppHandle, + provider: &str, + reset_at: DateTime, +) -> Result<(), String> { + use std::io::Write; + validate_reset_provider(provider)?; + let dir = app + .path() + .app_data_dir() + .map_err(|_| "Local storage is unavailable.")?; + fs::create_dir_all(&dir).map_err(|_| "Local storage is unavailable.")?; + let mut file = tempfile::NamedTempFile::new_in(&dir) + .map_err(|_| "The reset marker could not be written.")?; + file.write_all(&serde_json::to_vec(&reset_at).map_err(|_| "Invalid timestamp.")?) + .map_err(|_| "The reset marker could not be written.")?; + file.as_file() + .sync_all() + .map_err(|_| "The reset marker could not be written.")?; + file.persist(reset_marker_path(&dir, provider)) + .map_err(|_| "The reset marker could not be written.")?; + Ok(()) +} + +fn validate_reset_provider(provider: &str) -> Result<(), String> { + if matches!(provider, "codex" | "claude") { + Ok(()) + } else { + Err("Reset tracking is unavailable for this provider.".into()) + } +} + +fn reset_marker_path(dir: &std::path::Path, provider: &str) -> std::path::PathBuf { + dir.join(format!("analytics-reset-{provider}.json")) +} + +#[cfg(test)] +mod tests { + use super::*; + fn aggregate( + contents: &[String], + start: DateTime, + end: DateTime, + pricing: &ModelPricing, + ) -> AnalyticsReport { + super::aggregate( + contents.iter().map(|content| parse_log(content, "codex")), + start, + end, + pricing, + ) + } + fn date(s: &str) -> DateTime { + s.parse().unwrap() + } + #[test] + fn reset_markers_are_scoped_to_supported_providers() { + assert!(validate_reset_provider("codex").is_ok()); + assert!(validate_reset_provider("claude").is_ok()); + assert!(validate_reset_provider("../claude").is_err()); + assert_eq!( + reset_marker_path(std::path::Path::new("data"), "claude"), + std::path::Path::new("data").join("analytics-reset-claude.json") + ); + } + #[test] + fn numeric_codex_clock_and_fork_history_are_handled() { + let input = [ + r#"{"timestamp":"2026-10-01T10:00:00Z","type":"session_meta","payload":{"forked_from_id":"parent"}}"#, + r#"{"timestamp":"2026-10-01T09:00:00Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"inherited","started_at":1790845140,"completed_at":1790845200}}"#, + r#"{"timestamp":"2026-10-01T10:01:00Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"own","started_at":1790848800,"completed_at":1790848860}}"#, + ].join("\n"); + let tasks = tasks(&input); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].id, "own"); + assert_eq!((tasks[0].end - tasks[0].start).num_seconds(), 60); + } + fn claude_fixture(id: &str, cost: f64) -> String { + [ + format!(r#"{{"timestamp":"2026-10-01T10:00:00Z","type":"user","uuid":"{id}","message":{{"role":"user","content":"synthetic prompt"}}}}"#), + format!(r#"{{"timestamp":"2026-10-01T10:01:00Z","type":"assistant","costUSD":{cost},"message":{{"id":"{id}-message","model":"claude-opus-4-6","stop_reason":"end_turn","usage":{{"input_tokens":100,"output_tokens":20}}}}}}"#), + ].join("\n") + } + #[test] + fn claude_concurrent_sessions_do_not_share_costs_and_duplicates_are_ignored() { + let a = claude_fixture("a", 1.0); + let b = claude_fixture("b", 3.0); + let result = super::aggregate( + [ + parse_log(&a, "claude"), + parse_log(&b, "claude"), + parse_log(&a, "claude"), + ], + date("2026-10-01T10:00:00Z"), + date("2026-10-01T10:02:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.tokens, 240); + assert_eq!(result.estimated_cost_usd, 4.0); + assert_eq!(result.completed_tasks, 2); + assert_eq!(result.average_cost_usd, Some(2.0)); + assert_eq!(result.average_seconds, Some(60.0)); + assert_eq!( + result.model_breakdown.as_ref().unwrap().models[0].model, + "claude-opus-4-6" + ); + } + #[test] + fn claude_requires_explicit_completion_and_cache_contains_no_prompt() { + let input = claude_fixture("a", 1.0).replace("end_turn", "tool_use"); + let parsed = parse_log(&input, "claude"); + assert!(parsed.tasks.is_empty()); + assert!(!serde_json::to_string(&parsed) + .unwrap() + .contains("synthetic prompt")); + } + #[test] + fn unpriced_task_is_not_a_zero_cost_task() { + let input = fixture().replace("gpt-5", "unknown-future-model"); + let result = aggregate( + &[input], + date("2026-10-01T10:00:00Z"), + date("2026-10-01T10:02:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.tokens, 120); + assert_eq!(result.completed_tasks, 1); + assert_eq!(result.unpriced_events, 1); + assert_eq!(result.average_cost_usd, None); + assert_eq!(result.unknown_models, vec!["unknown-future-model"]); + } + fn fixture() -> String { + [ + r#"{"timestamp":"2026-10-01T10:00:00Z","type":"event_msg","payload":{"type":"task_started","turn_id":"a"}}"#, + r#"{"timestamp":"2026-10-01T10:00:30Z","type":"turn_context","payload":{"model":"gpt-5"}}"#, + r#"{"timestamp":"2026-10-01T10:00:40Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":100,"cached_input_tokens":0,"output_tokens":20,"reasoning_output_tokens":0,"total_tokens":120},"last_token_usage":{"input_tokens":100,"cached_input_tokens":0,"output_tokens":20,"reasoning_output_tokens":0,"total_tokens":120}}}}"#, + r#"{"timestamp":"2026-10-01T10:01:00Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"a"}}"#, + ].join("\n") + } + #[test] + fn range_cost_and_real_completed_turn_duration() { + let result = aggregate( + &[fixture()], + date("2026-10-01T10:00:00Z"), + date("2026-10-01T10:02:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.tokens, 120); + assert_eq!(result.completed_tasks, 1); + assert_eq!(result.priced_tasks, 1); + assert_eq!(result.average_seconds, Some(60.0)); + assert_eq!(result.average_cost_usd, Some(result.estimated_cost_usd)); + let model = &result.model_breakdown.as_ref().unwrap().models[0]; + assert_eq!(model.model, "gpt-5"); + assert_eq!(model.total_tokens, 120); + } + #[test] + fn duplicate_logs_are_not_double_counted() { + let result = aggregate( + &[fixture(), fixture()], + date("2026-10-01T10:00:00Z"), + date("2026-10-01T10:02:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.tokens, 120); + assert_eq!(result.completed_tasks, 1); + } + #[test] + fn partial_turns_excluded_from_means_not_consumption() { + let result = aggregate( + &[fixture()], + date("2026-10-01T10:00:35Z"), + date("2026-10-01T10:01:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.tokens, 120); + assert_eq!(result.completed_tasks, 0); + assert_eq!(result.average_seconds, None); + assert_eq!(result.average_cost_usd, None); + } + #[test] + fn incomplete_turn_and_malformed_line_do_not_create_task() { + let input = fixture().lines().take(3).collect::>().join("\n") + "\n{bad"; + let result = aggregate( + &[input], + date("2026-10-01T10:00:00Z"), + date("2026-10-01T10:02:00Z"), + &crate::pricing::test_bundled_pricing(), + ); + assert_eq!(result.completed_tasks, 0); + assert_eq!(result.malformed_lines, 1); + } +} diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 97ed3080..8851eb58 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -34,6 +34,15 @@ pub async fn claim_codex_reset_credit( .await .map_err(|_| "The reset claim could not be completed.".to_owned())?; + if outcome == ResetClaimOutcome::Success { + // Do not turn a successful provider reset into a retryable claim on disk failure. + if crate::analytics::record_banked_reset(&app, "codex", chrono::Utc::now()).is_err() { + let _ = app.emit( + "analytics-reset-warning", + "Reset succeeded, but the local analytics marker was not saved.", + ); + } + } if outcome != ResetClaimOutcome::Failed { let observed_account_revision = AtomicU64::new(settings.account_revision()); service.refresh("codex", true).await; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 61ec3369..17f2e6bc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod analytics; mod child_process; mod commands; mod desktop_integration; @@ -361,11 +362,13 @@ pub fn run() { let app_data_dir = app.path().app_data_dir()?; let database_path = app_data_dir.join("openquota.db"); let storage = Arc::new(Storage::open(&database_path)?); + app.manage(storage.clone()); provider_environment::initialize(storage.load_provider_environment()?); provider_environment::refresh_for_next_launch(storage.clone()); app.manage(Arc::new(PanelResizeSession::new(storage.clone()))); app_debug!("cache", "application database opened"); let pricing = Arc::new(PricingStore::new(app_data_dir.join("pricing"))?); + app.manage(pricing.clone()); let mut providers = claude::runtimes(storage.clone(), pricing.clone())?; providers.extend(vec![ Arc::new(CodexProvider::new(storage.clone(), pricing.clone())?) @@ -482,6 +485,9 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + analytics::get_range_analytics, + analytics::get_banked_reset_marker, + analytics::set_banked_reset_marker, commands::bootstrap::get_bootstrap_state, commands::provider::open_provider_link, commands::provider::get_provider_api_key_state, diff --git a/src-tauri/src/providers/claude/local_usage.rs b/src-tauri/src/providers/claude/local_usage.rs index edd08668..1f6796c0 100644 --- a/src-tauri/src/providers/claude/local_usage.rs +++ b/src-tauri/src/providers/claude/local_usage.rs @@ -112,7 +112,10 @@ pub fn scan_local_usage( Ok(accumulator.build(now, source_note)) } -fn discover_files(configured_roots: &[PathBuf], include_standard_roots: bool) -> Vec { +pub(crate) fn discover_files( + configured_roots: &[PathBuf], + include_standard_roots: bool, +) -> Vec { let home = home_directory(); let mut roots = if include_standard_roots { let config = env_text("CLAUDE_CONFIG_DIR"); @@ -385,7 +388,7 @@ fn usage_event( }) } -fn deduplicate(events: Vec) -> Vec { +pub(crate) fn deduplicate(events: Vec) -> Vec { let mut output = Vec::new(); let mut exact = HashMap::<(String, Option), usize>::new(); let mut by_message = HashMap::>::new(); diff --git a/src-tauri/src/providers/claude/mod.rs b/src-tauri/src/providers/claude/mod.rs index a61f0991..c1d20a2e 100644 --- a/src-tauri/src/providers/claude/mod.rs +++ b/src-tauri/src/providers/claude/mod.rs @@ -1,7 +1,7 @@ mod accounts; pub mod auth; mod client; -mod local_usage; +pub(crate) mod local_usage; mod mapper; use std::{ diff --git a/src-tauri/src/providers/codex/local_usage.rs b/src-tauri/src/providers/codex/local_usage.rs index 1107c65d..1f748cf6 100644 --- a/src-tauri/src/providers/codex/local_usage.rs +++ b/src-tauri/src/providers/codex/local_usage.rs @@ -107,7 +107,7 @@ fn scan_codex_events( Ok(events) } -fn codex_homes(configured_home: Option<&OsStr>, home: &Path) -> Vec { +pub(crate) fn codex_homes(configured_home: Option<&OsStr>, home: &Path) -> Vec { if let Some(configured_home) = configured_home.filter(|value| !value.is_empty()) { let configured_home = configured_home.to_string_lossy(); if !configured_home.trim().is_empty() { @@ -135,14 +135,14 @@ fn expand_home(value: &str, home: &Path) -> PathBuf { PathBuf::from(value) } -fn home_directory() -> PathBuf { +pub(crate) fn home_directory() -> PathBuf { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) .unwrap_or_default() } -fn discover_session_files(homes: &[PathBuf]) -> Vec { +pub(crate) fn discover_session_files(homes: &[PathBuf]) -> Vec { let mut output = Vec::new(); let mut seen_directories = HashSet::new(); for home in homes { @@ -350,7 +350,7 @@ impl ChildReplayGate { } } -fn is_child_session_meta(payload: &Value) -> bool { +pub(crate) fn is_child_session_meta(payload: &Value) -> bool { has_non_null_value(payload.get("forked_from_id")) || has_non_null_value(payload.get("parent_thread_id")) || payload.get("thread_source").and_then(Value::as_str) == Some("subagent") @@ -520,7 +520,7 @@ fn aggregate_into( } } -fn estimate_cost(event: &TokenEvent, pricing: &ModelPricing) -> Option { +pub(crate) fn estimate_cost(event: &TokenEvent, pricing: &ModelPricing) -> Option { let display_model = event.model.trim(); let model = if display_model == "codex-auto-review" { auto_review_fallback(&event.timestamp) diff --git a/src-tauri/src/providers/daily_usage.rs b/src-tauri/src/providers/daily_usage.rs index 059a6213..f201a265 100644 --- a/src-tauri/src/providers/daily_usage.rs +++ b/src-tauri/src/providers/daily_usage.rs @@ -207,6 +207,29 @@ impl DailyUsageAccumulator { } } + /// Builds only the model detail from every event previously admitted by the caller. Range + /// analytics keeps its independently measured total, including events without known pricing. + pub fn build_range_details( + self, + source_note: &str, + ) -> (Option, Vec) { + let mut total = DayAccumulator::default(); + for day in self.days.values() { + for (key, model) in &day.models { + total.models.entry(key.clone()).or_default().merge(model); + } + } + let mut unknown_models = self + .unknown_models_by_day + .values() + .flat_map(|models| models.iter().cloned()) + .collect::>() + .into_iter() + .collect::>(); + unknown_models.sort(); + (model_breakdown(&total, source_note), unknown_models) + } + fn period_for_days(&self, dates: &[NaiveDate], source_note: &str) -> Option { let mut total = DayAccumulator::default(); let mut unknown_models = HashSet::new(); diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 57cf18f0..e99f1e83 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -6,11 +6,12 @@ pub mod copilot; pub mod credential_store; pub mod cursor; mod daily_usage; +pub(crate) use daily_usage::DailyUsageAccumulator; mod detection; pub mod devin; pub mod grok; pub mod kimi; -mod log_usage; +pub(crate) mod log_usage; pub mod minimax; pub mod opencode; pub mod openrouter; diff --git a/src/App.test.ts b/src/App.test.ts index 112f45fe..69dbd934 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -40,6 +40,9 @@ vi.mock('@tauri-apps/api/window', () => ({ type InvokeArgs = { settings?: SettingsViewState['settings']; providerId?: string; + provider?: string; + start?: string; + end?: string; linkIndex?: number; height?: number; }; @@ -100,6 +103,33 @@ describe('OpenQuota dashboard', () => { if (command === 'lock_panel_resize_axis') return Promise.resolve(); if (command === 'get_log_path') return Promise.resolve('C:\\OpenQuota\\logs\\OpenQuota.log'); if (command === 'open_log_folder') return Promise.resolve(); + if (command === 'get_banked_reset_marker') return Promise.resolve(null); + if (command === 'get_range_analytics') + return Promise.resolve({ + tokens: args?.provider === 'claude' ? 500 : 700, + estimatedCostUsd: args?.provider === 'claude' ? 5 : 7, + unpricedEvents: 0, + completedTasks: 1, + pricedTasks: 1, + averageCostUsd: args?.provider === 'claude' ? 5 : 7, + timedTasks: 1, + averageSeconds: 60, + filesRead: 1, + unreadableFiles: 0, + malformedLines: 0, + earliestEvent: args?.start ?? null, + modelBreakdown: { + sourceNote: 'From local logs', + models: [ + { + model: args?.provider === 'claude' ? 'claude-opus' : 'gpt-5', + totalTokens: args?.provider === 'claude' ? 500 : 700, + costUsd: args?.provider === 'claude' ? 5 : 7, + }, + ], + }, + unknownModels: [], + }); if (command === 'dismiss_main_window') return Promise.resolve(); if (command === 'check_for_updates') return Promise.resolve({ @@ -505,6 +535,125 @@ describe('OpenQuota dashboard', () => { ); }); + it('opens the custom range from the top period switcher with all providers selected', async () => { + render(App); + await screen.findByText('Plus'); + await fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + const dialog = screen.getByRole('dialog', { name: 'Custom range' }); + expect(within(dialog).getByLabelText('Provider')).toHaveValue('all'); + expect(within(dialog).getByText('All providers are selected by default.')).toBeVisible(); + expect(within(dialog).getByText('Local times')).toBeVisible(); + await fireEvent.click(within(dialog).getByRole('button', { name: 'Calculate' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Custom' })).toHaveClass('active'); + expect(screen.getByRole('button', { name: 'Today' })).not.toHaveClass('active'); + const spend = screen.getByRole('region', { name: 'Total Spend' }); + expect(spend.querySelector('.spend-legend')).toHaveTextContent('Codex'); + + const codex = screen.getByRole('group', { name: 'Codex provider' }); + await fireEvent.click(within(codex).getByRole('button', { name: 'Show more' })); + expect(within(codex).getByText('Custom')).toBeVisible(); + }); + + it('loads since-last-reset usage from the live provider boundary before reporting data', async () => { + const now = Date.now(); + const nextReset = new Date(now + 4 * 24 * 60 * 60 * 1000).toISOString(); + const rangeState: UsageViewState = { + providers: { + codex: { + ...codexState, + snapshot: { + ...codexState.snapshot!, + quotas: codexState.snapshot!.quotas.map((quota) => + quota.id === 'weekly' ? { ...quota, resetsAt: nextReset } : quota, + ), + }, + }, + }, + }; + const rangeSettings: SettingsViewState = { + ...settingsState, + settings: { + ...settingsState.settings, + providers: settingsState.settings.providers.map((provider) => ({ + ...provider, + expanded: true, + })), + }, + }; + let resolveRange!: (value: { + tokens: number; + estimatedCostUsd: number; + unpricedEvents: number; + completedTasks: number; + pricedTasks: number; + averageCostUsd: number; + timedTasks: number; + averageSeconds: number; + filesRead: number; + unreadableFiles: number; + malformedLines: number; + earliestEvent: string; + modelBreakdown: { + sourceNote: string; + models: Array<{ model: string; totalTokens: number; costUsd: number }>; + }; + unknownModels: string[]; + }) => void; + const pendingRange = new Promise[0]>((resolve) => { + resolveRange = resolve; + }); + const defaultInvoke = mocks.invoke.getMockImplementation()!; + mockInvoke((command: string, args?: InvokeArgs) => { + if (command === 'get_usage_state') return Promise.resolve(rangeState); + if (command === 'get_app_settings') return Promise.resolve(rangeSettings); + if (command === 'get_range_analytics') return pendingRange; + return defaultInvoke(command, args); + }); + + render(App); + const codex = await screen.findByRole('group', { name: 'Codex provider' }); + expect(within(codex).getByText('Since last reset')).toBeVisible(); + expect(within(codex).getByRole('button', { name: 'Loading…' })).toHaveAttribute( + 'aria-busy', + 'true', + ); + expect(within(codex).queryByRole('button', { name: 'No data' })).not.toBeInTheDocument(); + + await waitFor(() => + expect(mocks.invoke).toHaveBeenCalledWith('get_range_analytics', { + start: new Date(Date.parse(nextReset) - 604_800_000).toISOString(), + end: expect.any(String), + provider: 'codex', + }), + ); + resolveRange({ + tokens: 4_200_000, + estimatedCostUsd: 12.5, + unpricedEvents: 0, + completedTasks: 2, + pricedTasks: 2, + averageCostUsd: 6.25, + timedTasks: 2, + averageSeconds: 90, + filesRead: 3, + unreadableFiles: 0, + malformedLines: 0, + earliestEvent: new Date(Date.parse(nextReset) - 604_800_000).toISOString(), + modelBreakdown: { + sourceNote: 'From your Codex logs (estimated)', + models: [{ model: 'gpt-5', totalTokens: 4_200_000, costUsd: 12.5 }], + }, + unknownModels: [], + }); + + const resetUsage = await within(codex).findByRole('button', { + name: '$12.50 · 4.2M tokens', + }); + expect(resetUsage).toHaveAttribute('aria-busy', 'false'); + expect(resetUsage).toHaveClass('usage-reading-interactive'); + }); + it('explains unavailable cost and reveals measured tokens for the same period', async () => { mockInvoke((command: string, args?: { settings?: SettingsViewState['settings'] }) => { if (command === 'get_usage_state') diff --git a/src/lib/Dashboard.svelte b/src/lib/Dashboard.svelte index e8ddf636..c5e6ce7a 100644 --- a/src/lib/Dashboard.svelte +++ b/src/lib/Dashboard.svelte @@ -10,9 +10,19 @@ import Icon from './Icon.svelte'; import MetricRenderer from './MetricRenderer.svelte'; import TotalSpend from './TotalSpend.svelte'; - import type { SpendProjection } from './totalSpend'; + import UsageAnalytics from './UsageAnalytics.svelte'; + import UsageMetric from './UsageMetric.svelte'; + import { getBankedResetMarker, getRangeAnalytics } from './backend'; + import { + analyticsFromDailyHistory, + analyticsReportToUsagePeriod, + mergeAnalyticsReports, + weeklyStart, + } from './analytics'; + import type { CustomSpendSelection, SpendProjection } from './totalSpend'; import type { ProviderCatalogIndex } from './metrics'; import { canRenameProvider } from './providerNames'; + import { providerFamily } from './providerIconPaths'; import type { AppSettings, MetricLayout, @@ -21,6 +31,7 @@ UpdateProgress, UpdateFailure, UsageHistory, + UsagePeriod, UsageViewState, UpdateStatus, } from './types'; @@ -142,6 +153,80 @@ usage: viewState.providers[provider.id]?.snapshot?.usage ?? emptyUsage, })), ); + let analyticsOpen = $state(false); + let customSpendActive = $state(false); + let customSpendSelection = $state(null); + let resetUsage = $state< + Record + >({}); + let resetUsageRequest = 0; + const analyticsProviders = $derived.by(() => { + const seen: string[] = []; + return enabledProviders.flatMap((provider) => { + const id = providerFamily(provider.id); + if (seen.includes(id)) return []; + seen.push(id); + return [{ id, name: providerDisplayName(id) }]; + }); + }); + + $effect(() => { + const state = viewState; + const options = analyticsProviders; + const to = new Date().toISOString(); + const request = ++resetUsageRequest; + const targets = options.flatMap((option) => { + const from = weeklyStart(state, Date.parse(to), option.id); + return from ? [{ ...option, from }] : []; + }); + resetUsage = Object.fromEntries( + targets.map((target) => [target.id, { from: target.from, period: null, loading: true }]), + ); + void Promise.all( + targets.map(async (target) => { + let from = target.from; + const banked = await getBankedResetMarker(target.id).catch(() => null); + if ( + banked && + Date.parse(banked) > Date.parse(from) && + Date.parse(banked) <= Date.parse(to) + ) { + from = banked; + } + try { + return { + id: target.id, + from, + period: analyticsReportToUsagePeriod(await getRangeAnalytics(from, to, target.id)), + }; + } catch { + const reports = Object.entries(state.providers).flatMap(([id, provider]) => { + if (providerFamily(id) !== target.id || !provider.snapshot) return []; + const report = analyticsFromDailyHistory(provider.snapshot.usage, from, to); + return report ? [report] : []; + }); + return { + id: target.id, + from, + period: reports.length + ? analyticsReportToUsagePeriod(mergeAnalyticsReports(reports)) + : null, + }; + } + }), + ).then((loaded) => { + if (request !== resetUsageRequest) return; + resetUsage = Object.fromEntries( + loaded.map((result) => [ + result.id, + { from: result.from, period: result.period, loading: false }, + ]), + ); + }); + return () => { + if (request === resetUsageRequest) resetUsageRequest += 1; + }; + }); function updateProvider(next: ProviderLayout, customization = true) { const changed = { @@ -414,10 +499,32 @@ {catalog} onChange={onSettingsChange} onShare={onShareTotal} + customActive={customSpendActive} + customSelection={customSpendSelection} + onPreset={() => { + customSpendActive = false; + analyticsOpen = false; + }} + onCustom={() => (analyticsOpen = true)} /> {/if} + { + customSpendSelection = selection; + customSpendActive = true; + analyticsOpen = false; + }} +/> + {#each dashboardProviders as { provider, state, snapshot, alwaysMetrics, demandMetrics, links } (provider.id)} + {@const rangeProviderId = providerFamily(provider.id)} + {@const providerResetUsage = resetUsage[rangeProviderId]} + {@const providerCustomPeriod = + customSpendSelection?.providers.find((item) => item.id === rangeProviderId)?.period ?? null}
{/each} - {#if demandMetrics.length > 0 || links.length > 0} + {#if demandMetrics.length > 0 || links.length > 0 || providerResetUsage || providerCustomPeriod} { + onPreset(); + patch({ totalSpendPeriod: option[0] as AppSettings['totalSpendPeriod'] }); + }}>{option[1]} {/each} + + {#if customActive && customSelection} + {customSelection.label} + {/if} {#if projection.centerValue === null}
{emptySpendMessage(settings.totalSpendMetric)} @@ -206,13 +240,23 @@ top: 3px; bottom: 3px; left: 3px; - width: calc((100% - 6px) / 3); + width: calc((100% - 6px) / 4); border-radius: 999px; background: var(--tray); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); transition: transform var(--motion-spring); } + .custom-period { + display: block; + margin: 6px 4px 0; + overflow: hidden; + color: var(--secondary); + font-size: 8px; + text-overflow: ellipsis; + white-space: nowrap; + } + .total-card__body { justify-content: flex-start; padding-top: 10px; @@ -383,6 +427,7 @@ } .period-switcher { + box-sizing: border-box; width: 100%; height: var(--total-switcher-height); padding: 3px; @@ -390,11 +435,15 @@ } .period-switcher button { + min-width: 0; min-height: 0; flex: 1; - padding: 0 12px; + padding: 0 6px; + overflow: hidden; font-size: var(--total-period-size); font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; } .period-switcher button.active { @@ -458,7 +507,7 @@ :root[data-density='compact'] .period-switcher button { min-height: 21px; - padding: 3px 10px; + padding: 3px 6px; } :root[data-density='compact'] .total-card__body { diff --git a/src/lib/UsageAnalytics.svelte b/src/lib/UsageAnalytics.svelte new file mode 100644 index 00000000..caf555a4 --- /dev/null +++ b/src/lib/UsageAnalytics.svelte @@ -0,0 +1,771 @@ + + + { + if (open && event.key === 'Escape') close(); + }} +/> + +{#if open} + +{/if} + + diff --git a/src/lib/UsageAnalytics.test.ts b/src/lib/UsageAnalytics.test.ts new file mode 100644 index 00000000..6be99df9 --- /dev/null +++ b/src/lib/UsageAnalytics.test.ts @@ -0,0 +1,257 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import UsageAnalytics from './UsageAnalytics.svelte'; +import type { UsageViewState } from './types'; +import { + dateTimeInZone, + detectedTimeZone, + zonedDateTimeToIso, + type AnalyticsReport, +} from './analytics'; + +const mocks = vi.hoisted(() => ({ range: vi.fn(), marker: vi.fn(), saveMarker: vi.fn() })); +vi.mock('./backend', () => ({ + getRangeAnalytics: mocks.range, + getBankedResetMarker: mocks.marker, + setBankedResetMarker: mocks.saveMarker, + onAnalyticsResetWarning: vi.fn().mockResolvedValue(() => {}), +})); +const empty = { providers: {}, lastFullRefreshAt: null } as UsageViewState; +const providerOptions = [{ id: 'codex', name: 'Codex' }]; +function renderAnalytics(viewState = empty, onApply = vi.fn()) { + return render(UsageAnalytics, { viewState, providers: providerOptions, open: true, onApply }); +} +afterEach(cleanup); +const report: AnalyticsReport = { + tokens: 120, + estimatedCostUsd: 0.002, + unpricedEvents: 0, + completedTasks: 1, + pricedTasks: 1, + averageCostUsd: 0.002, + timedTasks: 1, + averageSeconds: 60, + filesRead: 1, + unreadableFiles: 0, + malformedLines: 0, + earliestEvent: '2026-10-01T12:00:00Z', + modelBreakdown: { + sourceNote: 'From your Codex logs (estimated)', + models: [{ model: 'gpt-5', totalTokens: 120, costUsd: 0.002 }], + }, + unknownModels: [], +}; +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mocks.range.mockResolvedValue(report); + mocks.marker.mockResolvedValue(null); + mocks.saveMarker.mockResolvedValue(undefined); +}); +describe('UsageAnalytics', () => { + it('sends explicit custom UTC bounds and provider to the real command wrapper', async () => { + const onApply = vi.fn(); + renderAnalytics(empty, onApply); + expect(screen.getByLabelText('Provider')).toHaveValue('all'); + await fireEvent.change(screen.getByLabelText('Local time zone'), { + target: { value: 'America/Toronto' }, + }); + const inputs = [screen.getByLabelText(/Start/), screen.getByLabelText(/End/)]; + await fireEvent.input(inputs[0], { target: { value: '2026-10-01T12:00' } }); + await fireEvent.input(inputs[1], { target: { value: '2026-10-02T12:00' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + await waitFor(() => + expect(mocks.range).toHaveBeenCalledWith( + '2026-10-01T16:00:00.000Z', + '2026-10-02T16:00:00.000Z', + 'codex', + ), + ); + expect((await screen.findAllByText('1.0 min')).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Two-person plans vs API')).toBeInTheDocument(); + expect(screen.getByText('ChatGPT Business Premium 5×')).toBeInTheDocument(); + expect(onApply).toHaveBeenCalledWith( + expect.objectContaining({ + from: '2026-10-01T16:00:00.000Z', + to: '2026-10-02T16:00:00.000Z', + providers: [ + expect.objectContaining({ + id: 'codex', + period: expect.objectContaining({ tokens: 120, estimatedCostUsd: 0.002 }), + }), + ], + }), + ); + }); + it('does not invent a banked reset or issue a provider claim', async () => { + renderAnalytics(); + await fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'codex' } }); + await fireEvent.change(screen.getByLabelText('Usage since'), { + target: { value: 'banked' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + expect(await screen.findByRole('alert')).toHaveTextContent('No reset is recorded'); + expect(mocks.range).not.toHaveBeenCalled(); + }); + it('uses an observed successful banked reset marker', async () => { + mocks.marker.mockResolvedValue('2026-10-01T12:00:00Z'); + renderAnalytics(); + await fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'codex' } }); + await fireEvent.change(screen.getByLabelText('Usage since'), { + target: { value: 'banked' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + await waitFor(() => + expect(mocks.range).toHaveBeenCalledWith('2026-10-01T12:00:00Z', expect.any(String), 'codex'), + ); + }); + it('persists a Claude reset completed outside OpenQuota and uses it for the range', async () => { + render(UsageAnalytics, { + viewState: empty, + providers: [{ id: 'claude', name: 'Claude' }], + open: true, + }); + await fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'claude' } }); + expect(screen.getByLabelText('Usage since')).toHaveTextContent('Last banked Claude reset'); + await fireEvent.change(screen.getByLabelText('Usage since'), { + target: { value: 'banked' }, + }); + await fireEvent.input(screen.getByLabelText(/Earlier reset/), { + target: { value: '2026-10-10T12:00' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + const expected = zonedDateTimeToIso('2026-10-10T12:00', detectedTimeZone()); + await waitFor(() => expect(mocks.saveMarker).toHaveBeenCalledWith('claude', expected)); + expect(mocks.range).toHaveBeenCalledWith(expected, expect.any(String), 'claude'); + }); + it('does not offer a weekly range when the provider exposes no valid boundary', async () => { + renderAnalytics(); + await fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'codex' } }); + expect(screen.getByLabelText('Usage since')).not.toHaveTextContent('Last weekly reset'); + expect(mocks.range).not.toHaveBeenCalled(); + }); + it('does not turn missing measurements into zero averages', async () => { + mocks.range.mockResolvedValue({ + ...report, + averageCostUsd: null, + averageSeconds: null, + pricedTasks: 0, + timedTasks: 0, + }); + renderAnalytics(); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + await waitFor(() => + expect(screen.getAllByText('Unavailable').length).toBeGreaterThanOrEqual(2), + ); + }); + it('includes a daily-only provider in the custom range without inventing tasks', async () => { + const cursorState = { + providers: { + cursor: { + snapshot: { + providerId: 'cursor', + plan: 'Teams Premium', + quotas: [], + valueMetrics: [], + statusMetrics: [], + notices: [], + warnings: [], + refreshedAt: '2026-10-02T14:00:00Z', + usage: { + today: null, + yesterday: null, + last30Days: null, + unknownModels: [], + daily: [ + { + date: '2026-10-01', + tokens: 500, + estimatedCostUsd: 2.5, + estimateComplete: true, + }, + ], + }, + }, + }, + }, + lastFullRefreshAt: null, + } as unknown as UsageViewState; + render(UsageAnalytics, { + viewState: cursorState, + providers: [{ id: 'cursor', name: 'Cursor' }], + open: true, + }); + await fireEvent.input(screen.getByLabelText(/Start/), { + target: { value: '2026-10-01T00:00' }, + }); + await fireEvent.input(screen.getByLabelText(/End/), { + target: { value: '2026-10-02T00:00' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + expect(await screen.findByText('Daily precision')).toBeInTheDocument(); + expect(screen.getByText('Cursor Teams Premium 5×')).toBeInTheDocument(); + expect(mocks.range).not.toHaveBeenCalled(); + }); + it('uses the live weekly boundary for a daily-only provider', async () => { + const zone = detectedTimeZone(); + const insideDay = dateTimeInZone(new Date(), zone).slice(0, 10); + const outsideDay = dateTimeInZone(new Date(Date.now() - 6 * 86400000), zone).slice(0, 10); + const cursorState = { + providers: { + cursor: { + stale: false, + snapshot: { + providerId: 'cursor', + plan: 'Teams Premium', + quotas: [ + { + id: 'weekly', + periodSeconds: 604800, + resetsAt: new Date(Date.now() + 2 * 86400000).toISOString(), + }, + ], + valueMetrics: [], + statusMetrics: [], + notices: [], + warnings: [], + refreshedAt: new Date().toISOString(), + usage: { + today: null, + yesterday: null, + last30Days: null, + unknownModels: [], + daily: [ + { + date: outsideDay, + tokens: 100, + estimatedCostUsd: 1, + estimateComplete: true, + }, + { + date: insideDay, + tokens: 900, + estimatedCostUsd: 9, + estimateComplete: true, + }, + ], + }, + }, + }, + }, + lastFullRefreshAt: null, + } as unknown as UsageViewState; + render(UsageAnalytics, { + viewState: cursorState, + providers: [{ id: 'cursor', name: 'Cursor' }], + open: true, + }); + await fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'cursor' } }); + await fireEvent.change(screen.getByLabelText('Usage since'), { + target: { value: 'weekly' }, + }); + await fireEvent.click(screen.getByRole('button', { name: 'Calculate' })); + expect((await screen.findAllByText('900')).length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('1,000')).toBeNull(); + expect(mocks.range).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/UsageMetric.svelte b/src/lib/UsageMetric.svelte index 7be7631d..7f2c3b53 100644 --- a/src/lib/UsageMetric.svelte +++ b/src/lib/UsageMetric.svelte @@ -8,14 +8,16 @@ interface Props { label: string; period: UsagePeriod | null; + loading?: boolean; } - let { label, period }: Props = $props(); + let { label, period, loading = false }: Props = $props(); let open = $state(false); let detailTop = $state(8); let showTimer: ReturnType | undefined; let hideTimer: ReturnType | undefined; function reading(value: UsagePeriod | null) { + if (loading) return 'Loading…'; if (!value) return 'No data'; const tokens = formatMetricValue(value.tokens, 'count', 'row', 'tokens'); if (value.estimatedCostUsd === null) return tokens; @@ -87,7 +89,8 @@ type="button" class:usage-reading-interactive={period?.modelBreakdown?.models.length} data-tooltip={valueTooltip(period)} - disabled={!period?.modelBreakdown?.models.length} + disabled={loading || !period?.modelBreakdown?.models.length} + aria-busy={loading} onmouseenter={scheduleShow} onmouseleave={scheduleHide} onfocus={scheduleShow} diff --git a/src/lib/UsageMetric.test.ts b/src/lib/UsageMetric.test.ts index dd0d40ed..98d23c57 100644 --- a/src/lib/UsageMetric.test.ts +++ b/src/lib/UsageMetric.test.ts @@ -8,6 +8,20 @@ describe('UsageMetric model detail', () => { vi.useRealTimers(); }); + it('distinguishes a pending local range scan from a completed empty result', () => { + const { rerender } = render(UsageMetric, { + label: 'Since last reset', + period: null, + loading: true, + }); + + expect(screen.getByRole('button', { name: 'Loading…' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.queryByText('No data')).not.toBeInTheDocument(); + + rerender({ label: 'Since last reset', period: null, loading: false }); + expect(screen.getByRole('button', { name: 'No data' })).toHaveAttribute('aria-busy', 'false'); + }); + it('reveals the ranked real model names after the reference hover dwell', async () => { vi.useFakeTimers(); render(UsageMetric, { diff --git a/src/lib/analytics.test.ts b/src/lib/analytics.test.ts new file mode 100644 index 00000000..972a4107 --- /dev/null +++ b/src/lib/analytics.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, it } from 'vitest'; +import { + analyticsFromDailyHistory, + analyticsReportToUsagePeriod, + comparePremiumSeats, + dateTimeInZone, + emptyAnalyticsReport, + mergeAnalyticsReports, + rangeFromZonedLocal, + relativeMoment, + weeklyStart, + zonedDateTimeToIso, +} from './analytics'; +import type { UsageViewState } from './types'; + +const now = Date.parse('2026-10-10T12:00:00Z'); +function state(stale = false, resetsAt = '2026-10-12T12:00:00Z'): UsageViewState { + return { + providers: { + codex: { stale, snapshot: { quotas: [{ id: 'weekly', periodSeconds: 604800, resetsAt }] } }, + }, + } as unknown as UsageViewState; +} +describe('analytics ranges', () => { + it('derives real provider window rather than calendar week', () => { + expect(weeklyStart(state(), now)).toBe('2026-10-05T12:00:00.000Z'); + }); + it('does not invent missing or stale reset boundaries', () => { + expect(weeklyStart(state(true), now)).toBeNull(); + expect(weeklyStart(state(false, '2026-10-09T12:00:00Z'), now)).toBeNull(); + expect(weeklyStart({ providers: {} } as UsageViewState, now)).toBeNull(); + }); + it('resolves account-scoped providers and rejects ambiguous weekly resets', () => { + const firstReset = '2026-10-12T12:00:00Z'; + const matching = { + providers: { + 'claude@first': state(false, firstReset).providers.codex, + 'claude@second': state(false, firstReset).providers.codex, + }, + } as UsageViewState; + expect(weeklyStart(matching, now, 'claude')).toBe('2026-10-05T12:00:00.000Z'); + + matching.providers['claude@second'].snapshot!.quotas[0].resetsAt = '2026-10-12T13:00:00Z'; + expect(weeklyStart(matching, now, 'claude')).toBeNull(); + }); + it('rejects invalid and reversed custom ranges', () => { + expect(() => rangeFromZonedLocal('', '', 'UTC')).toThrow(); + expect(() => rangeFromZonedLocal('bad', 'bad', 'UTC')).toThrow(); + expect(() => rangeFromZonedLocal('2026-10-10T12:00', '2026-10-10T11:00', 'UTC')).toThrow(); + }); + it('converts selected IANA zones independently of the computer zone', () => { + expect(zonedDateTimeToIso('2026-01-10T12:30', 'America/Toronto')).toBe( + '2026-01-10T17:30:00.000Z', + ); + expect(zonedDateTimeToIso('2026-07-10T12:30', 'America/Toronto')).toBe( + '2026-07-10T16:30:00.000Z', + ); + expect(zonedDateTimeToIso('2026-07-10T12:30', 'Asia/Tokyo')).toBe('2026-07-10T03:30:00.000Z'); + }); + it('rejects a wall time skipped by daylight saving', () => { + expect(() => zonedDateTimeToIso('2026-03-08T02:30', 'America/Toronto')).toThrow( + 'does not exist', + ); + }); + it('round trips a displayed instant in the chosen zone', () => { + const date = new Date('2026-10-10T16:30:00Z'); + const wall = dateTimeInZone(date, 'America/Toronto'); + expect(wall).toBe('2026-10-10T12:30'); + expect(zonedDateTimeToIso(wall, 'America/Toronto')).toBe(date.toISOString()); + }); + it('compares two premium seats, added API, and API-equivalent value', () => { + const comparison = comparePremiumSeats({ + seatCount: 2, + monthlyPricePerSeatUsd: 125, + additionalApiCostUsd: 10, + apiEquivalentUsd: 400, + from: '2026-01-01T00:00:00Z', + to: '2026-01-31T10:30:00Z', + }); + expect(comparison.monthlySeatCostUsd).toBe(250); + expect(comparison.proratedSeatCostUsd).toBeCloseTo(250, 8); + expect(comparison.combinedCostUsd).toBeCloseTo(260, 8); + expect(comparison.valueDifferenceUsd).toBeCloseTo(140, 8); + expect(comparison.valueRatio).toBeCloseTo(400 / 260, 8); + }); + it('aggregates daily-only providers without inventing tasks', () => { + const report = analyticsFromDailyHistory( + { + today: null, + yesterday: null, + last30Days: null, + unknownModels: [], + daily: [ + { date: '2026-10-01', tokens: 100, estimatedCostUsd: 1, estimateComplete: true }, + { date: '2026-10-02', tokens: 200, estimatedCostUsd: null, estimateComplete: false }, + { date: '2026-10-03', tokens: 300, estimatedCostUsd: 3, estimateComplete: true }, + ], + }, + '2026-10-01T12:00', + '2026-10-02T12:00', + ); + expect(report).toMatchObject({ + tokens: 300, + estimatedCostUsd: 1, + unpricedEvents: 1, + completedTasks: 0, + averageSeconds: null, + }); + }); + it('does not display an invented zero cost when every event is unpriced', () => { + expect( + analyticsReportToUsagePeriod({ + ...emptyAnalyticsReport(), + tokens: 500, + unpricedEvents: 1, + }), + ).toMatchObject({ estimatedCostUsd: null, estimateComplete: false }); + }); + it('preserves model detail for hoverable custom and reset ranges', () => { + const period = analyticsReportToUsagePeriod({ + ...emptyAnalyticsReport(), + tokens: 120, + estimatedCostUsd: 1.25, + modelBreakdown: { + sourceNote: 'From local logs', + models: [{ model: 'gpt-5', totalTokens: 120, costUsd: 1.25 }], + }, + }); + expect(period.modelBreakdown?.models).toEqual([ + { model: 'gpt-5', totalTokens: 120, costUsd: 1.25 }, + ]); + }); + it('merges weighted task averages across providers', () => { + const first = { + ...emptyAnalyticsReport(), + pricedTasks: 1, + timedTasks: 1, + averageCostUsd: 2, + averageSeconds: 60, + }; + const second = { + ...emptyAnalyticsReport(), + pricedTasks: 3, + timedTasks: 3, + averageCostUsd: 4, + averageSeconds: 100, + modelBreakdown: { + sourceNote: 'Claude logs', + models: [{ model: 'claude-opus', totalTokens: 200, costUsd: 4 }], + }, + }; + expect(mergeAnalyticsReports([first, second])).toMatchObject({ + pricedTasks: 4, + averageCostUsd: 3.5, + timedTasks: 4, + averageSeconds: 90, + modelBreakdown: expect.objectContaining({ + models: [expect.objectContaining({ model: 'claude-opus', totalTokens: 200 })], + }), + }); + }); + it('formats reset and update ages independently of the display zone', () => { + const now = Date.parse('2026-10-10T12:00:00Z'); + expect(relativeMoment('2026-10-15T15:00:00Z', now, true)).toBe('in 5d 3h'); + expect(relativeMoment('2026-10-10T10:00:00Z', now, false)).toBe('2h ago'); + }); +}); diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 00000000..0b2e199c --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,378 @@ +import type { + ModelUsageBreakdown, + ModelUsageEntry, + UsageHistory, + UsagePeriod, + UsageViewState, +} from './types'; + +export interface AnalyticsReport { + tokens: number; + estimatedCostUsd: number; + unpricedEvents: number; + completedTasks: number; + pricedTasks: number; + averageCostUsd: number | null; + timedTasks: number; + averageSeconds: number | null; + filesRead: number; + unreadableFiles: number; + malformedLines: number; + earliestEvent: string | null; + modelBreakdown: ModelUsageBreakdown | null; + unknownModels: string[]; +} + +export interface PremiumSeatComparison { + monthlySeatCostUsd: number; + proratedSeatCostUsd: number; + combinedCostUsd: number; + apiEquivalentUsd: number; + valueDifferenceUsd: number; + valueRatio: number | null; +} + +export function emptyAnalyticsReport(): AnalyticsReport { + return { + tokens: 0, + estimatedCostUsd: 0, + unpricedEvents: 0, + completedTasks: 0, + pricedTasks: 0, + averageCostUsd: null, + timedTasks: 0, + averageSeconds: null, + filesRead: 0, + unreadableFiles: 0, + malformedLines: 0, + earliestEvent: null, + modelBreakdown: null, + unknownModels: [], + }; +} + +export function mergeAnalyticsReports(reports: AnalyticsReport[]): AnalyticsReport { + const merged = emptyAnalyticsReport(); + let pricedTaskCost = 0; + let timedTaskSeconds = 0; + for (const report of reports) { + merged.tokens += report.tokens; + merged.estimatedCostUsd += report.estimatedCostUsd; + merged.unpricedEvents += report.unpricedEvents; + merged.completedTasks += report.completedTasks; + merged.pricedTasks += report.pricedTasks; + merged.timedTasks += report.timedTasks; + merged.filesRead += report.filesRead; + merged.unreadableFiles += report.unreadableFiles; + merged.malformedLines += report.malformedLines; + merged.unknownModels.push(...report.unknownModels); + if (report.averageCostUsd !== null) { + pricedTaskCost += report.averageCostUsd * report.pricedTasks; + } + if (report.averageSeconds !== null) { + timedTaskSeconds += report.averageSeconds * report.timedTasks; + } + if ( + report.earliestEvent && + (!merged.earliestEvent || Date.parse(report.earliestEvent) < Date.parse(merged.earliestEvent)) + ) { + merged.earliestEvent = report.earliestEvent; + } + } + merged.averageCostUsd = merged.pricedTasks > 0 ? pricedTaskCost / merged.pricedTasks : null; + merged.averageSeconds = merged.timedTasks > 0 ? timedTaskSeconds / merged.timedTasks : null; + merged.unknownModels = [...new Set(merged.unknownModels)].sort(); + merged.modelBreakdown = mergeModelBreakdowns( + reports.map((report) => report.modelBreakdown).filter((value) => value !== null), + ); + return merged; +} + +function mergeModelBreakdowns(breakdowns: ModelUsageBreakdown[]): ModelUsageBreakdown | null { + if (!breakdowns.length) return null; + const models = new Map(); + for (const breakdown of breakdowns) { + for (const model of breakdown.models) { + const key = model.model.toLowerCase(); + const current = models.get(key); + models.set(key, { + model: current?.model ?? model.model, + totalTokens: (current?.totalTokens ?? 0) + model.totalTokens, + costUsd: + current?.costUsd === null || model.costUsd === null + ? null + : (current?.costUsd ?? 0) + model.costUsd, + variants: null, + }); + } + } + return { + models: [...models.values()].sort( + (left, right) => + (right.costUsd ?? -1) - (left.costUsd ?? -1) || + right.totalTokens - left.totalTokens || + left.model.localeCompare(right.model), + ), + sourceNote: + breakdowns.length === 1 + ? breakdowns[0].sourceNote + : 'From your selected local provider usage histories (estimated)', + }; +} + +export function analyticsReportToUsagePeriod(report: AnalyticsReport): UsagePeriod { + const incomplete = + report.unpricedEvents > 0 || report.unreadableFiles > 0 || report.malformedLines > 0; + return { + tokens: report.tokens, + estimatedCostUsd: + report.unpricedEvents > 0 && report.estimatedCostUsd <= 0 ? null : report.estimatedCostUsd, + costEstimated: true, + estimateComplete: !incomplete, + modelBreakdown: report.modelBreakdown, + unknownModels: report.unknownModels, + }; +} + +export function analyticsFromDailyHistory( + history: UsageHistory, + startWallTime: string, + endWallTime: string, +): AnalyticsReport | null { + const startDay = startWallTime.slice(0, 10); + const endDay = endWallTime.slice(0, 10); + const endIncludesDay = endWallTime.slice(11, 16) !== '00:00'; + const days = history.daily.filter( + (day) => day.date >= startDay && (day.date < endDay || (endIncludesDay && day.date === endDay)), + ); + if (!days.length) return null; + const report = emptyAnalyticsReport(); + for (const day of days) { + report.tokens += day.tokens; + if (day.estimatedCostUsd === null) { + if (day.tokens > 0) report.unpricedEvents += 1; + } else { + report.estimatedCostUsd += day.estimatedCostUsd; + } + } + return report; +} + +export function relativeMoment(value: string, now: number, future: boolean): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return 'unavailable'; + const difference = Math.max(0, future ? timestamp - now : now - timestamp); + const minutes = Math.floor(difference / 60000); + if (minutes < 1) return future ? 'in less than a minute' : 'just now'; + if (minutes < 60) return future ? `in ${minutes} min` : `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return future ? `in ${hours}h` : `${hours}h ago`; + const days = Math.floor(hours / 24); + const remainingHours = hours % 24; + const duration = `${days}d${remainingHours ? ` ${remainingHours}h` : ''}`; + return future ? `in ${duration}` : `${duration} ago`; +} + +const AVERAGE_MONTH_MS = (365.25 / 12) * 86400000; + +export function comparePremiumSeats({ + seatCount, + monthlyPricePerSeatUsd, + additionalApiCostUsd, + apiEquivalentUsd, + from, + to, +}: { + seatCount: number; + monthlyPricePerSeatUsd: number; + additionalApiCostUsd: number; + apiEquivalentUsd: number; + from: string; + to: string; +}): PremiumSeatComparison { + const start = Date.parse(from); + const end = Date.parse(to); + const values = [seatCount, monthlyPricePerSeatUsd, additionalApiCostUsd, apiEquivalentUsd]; + if ( + !Number.isFinite(start) || + !Number.isFinite(end) || + start >= end || + values.some((value) => !Number.isFinite(value) || value < 0) + ) { + throw new Error('The comparison values are invalid.'); + } + const monthlySeatCostUsd = seatCount * monthlyPricePerSeatUsd; + const proratedSeatCostUsd = monthlySeatCostUsd * ((end - start) / AVERAGE_MONTH_MS); + const combinedCostUsd = proratedSeatCostUsd + additionalApiCostUsd; + return { + monthlySeatCostUsd, + proratedSeatCostUsd, + combinedCostUsd, + apiEquivalentUsd, + valueDifferenceUsd: apiEquivalentUsd - combinedCostUsd, + valueRatio: combinedCostUsd > 0 ? apiEquivalentUsd / combinedCostUsd : null, + }; +} + +// A quota boundary is inferred from the provider's window, never from Monday. +export function weeklyStart( + state: UsageViewState, + now: number, + providerId = 'codex', +): string | null { + const starts = Object.entries(state.providers).flatMap(([id, provider]) => { + if (id.split('@', 1)[0] !== providerId || provider.stale) return []; + const quota = provider.snapshot?.quotas.find( + (q) => q.id === 'weekly' && q.periodSeconds === 604800, + ); + if (!quota?.resetsAt) return []; + const reset = Date.parse(quota.resetsAt); + const start = reset - quota.periodSeconds * 1000; + return Number.isFinite(reset) && start <= now && now < reset + ? [new Date(start).toISOString()] + : []; + }); + const uniqueStarts = [...new Set(starts)]; + return uniqueStarts.length === 1 ? uniqueStarts[0] : null; +} + +interface WallTime { + year: number; + month: number; + day: number; + hour: number; + minute: number; +} + +function parseWallTime(value: string): WallTime | null { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); + if (!match) return null; + const [, year, month, day, hour, minute] = match; + const parsed = { + year: Number(year), + month: Number(month), + day: Number(day), + hour: Number(hour), + minute: Number(minute), + }; + const check = new Date( + Date.UTC(parsed.year, parsed.month - 1, parsed.day, parsed.hour, parsed.minute), + ); + return check.getUTCFullYear() === parsed.year && + check.getUTCMonth() + 1 === parsed.month && + check.getUTCDate() === parsed.day && + check.getUTCHours() === parsed.hour && + check.getUTCMinutes() === parsed.minute + ? parsed + : null; +} + +function zonedParts(date: Date, timeZone: string): WallTime { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', + }).formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value); + return { + year: value('year'), + month: value('month'), + day: value('day'), + hour: value('hour'), + minute: value('minute'), + }; +} + +function wallEpoch(value: WallTime): number { + return Date.UTC(value.year, value.month - 1, value.day, value.hour, value.minute); +} + +function sameWallTime(a: WallTime, b: WallTime): boolean { + return ( + a.year === b.year && + a.month === b.month && + a.day === b.day && + a.hour === b.hour && + a.minute === b.minute + ); +} + +export function detectedTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; +} + +export function availableTimeZones(): string[] { + const intl = Intl as typeof Intl & { + supportedValuesOf?: (key: 'timeZone') => string[]; + }; + const zones = intl.supportedValuesOf?.('timeZone') ?? []; + return Array.from(new Set([detectedTimeZone(), 'UTC', ...zones])).sort((a, b) => + a.localeCompare(b), + ); +} + +export function dateTimeInZone(date: Date, timeZone: string): string { + const value = zonedParts(date, timeZone); + const pad = (part: number) => String(part).padStart(2, '0'); + return `${value.year}-${pad(value.month)}-${pad(value.day)}T${pad(value.hour)}:${pad(value.minute)}`; +} + +export function zonedDateTimeToIso(value: string, timeZone: string): string { + const desired = parseWallTime(value); + if (!desired) throw new Error('Enter a valid date and time.'); + + // Resolve a wall-clock value without assuming the computer's own time zone. + let candidate = wallEpoch(desired); + for (let i = 0; i < 4; i += 1) { + const actual = zonedParts(new Date(candidate), timeZone); + const correction = wallEpoch(desired) - wallEpoch(actual); + candidate += correction; + if (correction === 0) break; + } + + // During a fall-back overlap, choose the earliest matching instant. During a + // spring-forward gap, no matching instant exists and the input is rejected. + const matches: number[] = []; + for (let offsetMinutes = -180; offsetMinutes <= 180; offsetMinutes += 15) { + const instant = candidate + offsetMinutes * 60000; + if (sameWallTime(zonedParts(new Date(instant), timeZone), desired)) matches.push(instant); + } + if (!matches.length) { + throw new Error(`This time does not exist in ${timeZone} because of daylight saving time.`); + } + return new Date(Math.min(...matches)).toISOString(); +} + +export function formatInZone(value: string, timeZone: string): string { + return new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} + +export function localDateTime(date: Date): string { + return dateTimeInZone(date, detectedTimeZone()); +} + +export function rangeFromZonedLocal( + start: string, + end: string, + timeZone: string, +): [string, string] { + const a = zonedDateTimeToIso(start, timeZone); + const b = zonedDateTimeToIso(end, timeZone); + if (Date.parse(a) >= Date.parse(b)) { + throw new Error('Choose a start time before the end time.'); + } + return [a, b]; +} + +export function rangeFromLocal(start: string, end: string): [string, string] { + return rangeFromZonedLocal(start, end, detectedTimeZone()); +} diff --git a/src/lib/backend.ts b/src/lib/backend.ts index c164f018..0168f76f 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; +import type { AnalyticsReport } from './analytics'; import type { ApiKeyMutationOutcome, AppSettings, @@ -29,6 +30,22 @@ export function refreshUsage() { return invoke('refresh_usage'); } +export function getRangeAnalytics(start: string, end: string, provider: string) { + return invoke('get_range_analytics', { start, end, provider }); +} + +export function getBankedResetMarker(provider: string) { + return invoke('get_banked_reset_marker', { provider }); +} + +export function setBankedResetMarker(provider: string, resetAt: string) { + return invoke('set_banked_reset_marker', { provider, resetAt }); +} + +export function onAnalyticsResetWarning(handler: PayloadHandler) { + return onEvent('analytics-reset-warning', handler); +} + export function refreshProviderUsage(providerId: string) { return invoke('refresh_provider_usage', { providerId }); } diff --git a/src/lib/totalSpend.test.ts b/src/lib/totalSpend.test.ts index f31efd1d..09db6409 100644 --- a/src/lib/totalSpend.test.ts +++ b/src/lib/totalSpend.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { projectSpend } from './totalSpend'; +import { projectSpend, projectSpendPeriods } from './totalSpend'; import type { UsageHistory } from './types'; const empty: UsageHistory = { @@ -111,4 +111,38 @@ describe('Total Spend projection', () => { estimateComplete: false, }); }); + + it('projects a custom range with the same provider graph semantics', () => { + expect( + projectSpendPeriods( + [ + { + id: 'claude', + period: { + tokens: 500, + estimatedCostUsd: 5, + costEstimated: true, + estimateComplete: true, + }, + }, + { + id: 'codex', + period: { + tokens: 700, + estimatedCostUsd: 7, + costEstimated: true, + estimateComplete: true, + }, + }, + ], + 'cost', + ), + ).toMatchObject({ + centerValue: 12, + slices: [ + { id: 'codex', value: 7 }, + { id: 'claude', value: 5 }, + ], + }); + }); }); diff --git a/src/lib/totalSpend.ts b/src/lib/totalSpend.ts index 3c6a6571..49222ce5 100644 --- a/src/lib/totalSpend.ts +++ b/src/lib/totalSpend.ts @@ -5,6 +5,19 @@ export interface SpendProvider { usage: UsageHistory; } +export interface SpendPeriodProvider { + id: string; + period: UsagePeriod; +} + +export interface CustomSpendSelection { + from: string; + to: string; + timeZone: string; + label: string; + providers: SpendPeriodProvider[]; +} + export interface SpendSlice { id: string; value: number; @@ -40,13 +53,24 @@ export function projectSpend( providers: SpendProvider[], periodSelection: AppSettings['totalSpendPeriod'], metric: AppSettings['totalSpendMetric'], +): SpendProjection { + return projectSpendPeriods( + providers.flatMap((provider) => { + const period = selectedPeriod(provider.usage, periodSelection); + return period ? [{ id: provider.id, period }] : []; + }), + metric, + ); +} + +export function projectSpendPeriods( + providers: SpendPeriodProvider[], + metric: AppSettings['totalSpendMetric'], ): SpendProjection { const slices = providers .flatMap((provider) => { - const period = selectedPeriod(provider.usage, periodSelection); - if (!period) return []; - const value = valueFor(period, metric); - return value === null ? [] : [{ id: provider.id, value, period }]; + const value = valueFor(provider.period, metric); + return value === null ? [] : [{ id: provider.id, value, period: provider.period }]; }) .sort((left, right) => right.value - left.value || left.id.localeCompare(right.id));