From 141de6b0e6323eae2185bbd83e605c4ce69aaa0c Mon Sep 17 00:00:00 2001 From: Symon Date: Tue, 16 Jun 2026 21:01:18 +0300 Subject: [PATCH 1/2] Add usage history export --- README.md | 1 + package.json | 1 + src-tauri/Cargo.lock | 184 ++++- src-tauri/Cargo.toml | 3 + src-tauri/capabilities/default.json | 1 + src-tauri/src/lib.rs | 35 + src-tauri/src/local_http_api/cache.rs | 19 +- src-tauri/src/local_http_api/mod.rs | 5 + src-tauri/src/local_http_api/usage_history.rs | 742 ++++++++++++++++++ src/App.test.tsx | 40 + src/components/panel-footer.test.tsx | 186 ++++- src/components/panel-footer.tsx | 66 +- src/components/usage-export-dialog.tsx | 357 +++++++++ 13 files changed, 1602 insertions(+), 38 deletions(-) create mode 100644 src-tauri/src/local_http_api/usage_history.rs create mode 100644 src/components/usage-export-dialog.tsx diff --git a/README.md b/README.md index 37dfd0389..11f8d369b 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Progress bars, badges, clear labels. No mental math required. * **One glance.** All your AI tools in one panel. * **Always up to date.** Refreshes automatically on a schedule you pick. * **Global shortcut.** Toggle the panel from anywhere with a customizable keyboard shortcut. +* **Usage export.** Download locally collected usage history as CSV or Excel. * **Lightweight.** Opens quickly and stays out of your way. * **Plugin-based.** New providers can be added without changing the whole app. * **[Local HTTP API](docs/local-http-api.md).** Other apps can read your usage data from `127.0.0.1:6736`. diff --git a/package.json b/package.json index c26c38287..67b8f593a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-dialog": "^2.7.1", "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b96de001b..be75d430e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -879,9 +879,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1070,7 +1070,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -1405,6 +1405,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -3241,6 +3242,7 @@ dependencies = [ "regex-lite", "reqwest 0.13.3", "rquickjs", + "rust_xlsxwriter", "serde", "serde_json", "serial_test", @@ -3250,6 +3252,7 @@ dependencies = [ "tauri-nspanel", "tauri-plugin-aptabase", "tauri-plugin-autostart", + "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-opener", @@ -3260,6 +3263,7 @@ dependencies = [ "tokio", "uuid", "x11rb", + "zip 8.6.0", ] [[package]] @@ -4192,6 +4196,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -4299,6 +4327,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.95.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f281b687352597d29efaad39701d1167d5c48aa76fb973e392bc13e9d44e7f36" +dependencies = [ + "zip 7.2.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4669,9 +4706,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -5331,6 +5368,48 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-global-shortcut" version = "2.3.1" @@ -5446,7 +5525,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -5533,7 +5612,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.2+spec-1.1.0", "url", "urlpattern", "uuid", @@ -5762,13 +5841,28 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -5789,9 +5883,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -5827,25 +5921,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 0.7.15", + "winnow 1.0.3", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -5951,6 +6045,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6887,6 +6987,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "winreg" version = "0.10.1" @@ -7283,12 +7389,58 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "7.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.13.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zvariant" version = "5.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d398946df..7e9951173 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,8 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" aes-gcm = "0.10.3" sha2 = "0.11" +rust_xlsxwriter = "0.95.0" +tauri-plugin-dialog = "2.7.1" [target.'cfg(target_os = "linux")'.dependencies] x11rb = "0.13.2" @@ -55,3 +57,4 @@ objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WK [dev-dependencies] serial_test = "3.4.0" +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 95cd3952b..14fe0da68 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-inner-size", "core:window:allow-scale-factor", "opener:default", + "dialog:default", "store:default", "aptabase:allow-track-event", "updater:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 75a50780e..16893d185 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -393,6 +393,38 @@ fn get_log_path(app_handle: tauri::AppHandle) -> Result { Ok(log_file.to_string_lossy().to_string()) } +#[tauri::command] +fn list_usage_history_range( + state: tauri::State<'_, Mutex>, +) -> Result { + let app_data_dir = { + let locked = state.lock().map_err(|e| e.to_string())?; + locked.app_data_dir.clone() + }; + Ok(local_http_api::list_usage_history_range(&app_data_dir)) +} + +#[tauri::command] +fn export_usage_history( + state: tauri::State<'_, Mutex>, + format: local_http_api::ExportFormat, + from_date: String, + to_date: String, + path: String, +) -> Result { + let app_data_dir = { + let locked = state.lock().map_err(|e| e.to_string())?; + locked.app_data_dir.clone() + }; + local_http_api::export_history( + &app_data_dir, + format, + &from_date, + &to_date, + &PathBuf::from(path), + ) +} + /// Update the global shortcut registration. /// Pass `null` to disable the shortcut, or a shortcut string like "CommandOrControl+Shift+U". #[cfg(desktop)] @@ -511,6 +543,7 @@ pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_aptabase::Builder::new("A-US-6435241436").build()) + .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::default().build()); @@ -542,6 +575,8 @@ pub fn run() { start_probe_batch, list_plugins, get_log_path, + list_usage_history_range, + export_usage_history, update_global_shortcut ]) .setup(|app| { diff --git a/src-tauri/src/local_http_api/cache.rs b/src-tauri/src/local_http_api/cache.rs index eddd4f09f..845901774 100644 --- a/src-tauri/src/local_http_api/cache.rs +++ b/src-tauri/src/local_http_api/cache.rs @@ -236,13 +236,22 @@ pub fn cache_successful_output(output: &PluginOutput) { display_name: output.display_name.clone(), plan: output.plan.clone(), lines: output.lines.clone(), - fetched_at, + fetched_at: fetched_at.clone(), }; - let mut state = cache_state().lock().expect("cache state poisoned"); - state.snapshots.insert(output.provider_id.clone(), snapshot); - state.dirty_generation = state.dirty_generation.wrapping_add(1); - schedule_cache_flush_locked(&mut state); + let app_data_dir = { + let mut state = cache_state().lock().expect("cache state poisoned"); + state.snapshots.insert(output.provider_id.clone(), snapshot); + state.dirty_generation = state.dirty_generation.wrapping_add(1); + schedule_cache_flush_locked(&mut state); + state.app_data_dir.clone() + }; + + if let Err(error) = + super::usage_history::record_successful_output(&app_data_dir, output, &fetched_at) + { + log::warn!("failed to record usage history: {}", error); + } } pub fn flush_cache() { diff --git a/src-tauri/src/local_http_api/mod.rs b/src-tauri/src/local_http_api/mod.rs index 4792bc57c..8188d664a 100644 --- a/src-tauri/src/local_http_api/mod.rs +++ b/src-tauri/src/local_http_api/mod.rs @@ -1,5 +1,10 @@ pub(crate) mod cache; mod server; +mod usage_history; pub use cache::{cache_successful_output, flush_cache, init}; pub use server::start_server; +pub use usage_history::{ + ExportFormat, ExportUsageHistoryResult, UsageHistoryRange, export_history, + list_range as list_usage_history_range, +}; diff --git a/src-tauri/src/local_http_api/usage_history.rs b/src-tauri/src/local_http_api/usage_history.rs new file mode 100644 index 000000000..a65e3e7a3 --- /dev/null +++ b/src-tauri/src/local_http_api/usage_history.rs @@ -0,0 +1,742 @@ +use crate::plugin_engine::runtime::{MetricLine, PluginOutput}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; +use time::format_description::well_known::Rfc3339; + +const HISTORY_FILE_NAME: &str = "usage-history.json"; +const HISTORY_VERSION: u32 = 1; +const HISTORY_RETENTION_DAYS: i64 = 365; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UsageHistoryRange { + pub from_date: Option, + pub to_date: Option, + pub row_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageHistoryFile { + version: u32, + snapshots: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct UsageHistorySnapshot { + provider_id: String, + display_name: String, + plan: Option, + lines: Vec, + fetched_at: String, +} + +pub fn record_successful_output( + app_data_dir: &Path, + output: &PluginOutput, + fetched_at: &str, +) -> Result<(), String> { + if output_has_error(output) { + return Ok(()); + } + + let mut snapshots = load_history(app_data_dir); + snapshots.push(UsageHistorySnapshot { + provider_id: output.provider_id.clone(), + display_name: output.display_name.clone(), + plan: output.plan.clone(), + lines: output.lines.clone(), + fetched_at: fetched_at.to_string(), + }); + prune_history(&mut snapshots); + save_history(app_data_dir, &snapshots) +} + +pub fn list_range(app_data_dir: &Path) -> UsageHistoryRange { + let snapshots = load_history(app_data_dir); + let mut dates = snapshots + .iter() + .filter_map(|snapshot| fetched_date(&snapshot.fetched_at)); + let Some(first_date) = dates.next() else { + return UsageHistoryRange { + from_date: None, + to_date: None, + row_count: 0, + }; + }; + + let (min_date, max_date) = snapshots + .iter() + .filter_map(|snapshot| fetched_date(&snapshot.fetched_at)) + .fold((first_date.clone(), first_date), |(min, max), date| { + (min.min(date.clone()), max.max(date)) + }); + + UsageHistoryRange { + from_date: Some(min_date), + to_date: Some(max_date), + row_count: snapshots.iter().map(|snapshot| snapshot.lines.len()).sum(), + } +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExportFormat { + Csv, + Xlsx, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportUsageHistoryResult { + pub row_count: usize, +} + +pub fn export_history( + app_data_dir: &Path, + format: ExportFormat, + from_date: &str, + to_date: &str, + path: &Path, +) -> Result { + let rows = export_rows(app_data_dir, from_date, to_date); + match format { + ExportFormat::Csv => write_csv(path, &rows)?, + ExportFormat::Xlsx => write_xlsx(path, &rows)?, + } + Ok(ExportUsageHistoryResult { + row_count: rows.len(), + }) +} + +fn output_has_error(output: &PluginOutput) -> bool { + output.lines.iter().any(|line| { + matches!( + line, + MetricLine::Badge { label, .. } if label == "Error" + ) + }) +} + +fn history_path(app_data_dir: &Path) -> std::path::PathBuf { + app_data_dir.join(HISTORY_FILE_NAME) +} + +fn load_history(app_data_dir: &Path) -> Vec { + let data = match std::fs::read_to_string(history_path(app_data_dir)) { + Ok(data) => data, + Err(_) => return Vec::new(), + }; + match serde_json::from_str::(&data) { + Ok(file) if file.version == HISTORY_VERSION => file.snapshots, + Ok(_) => { + log::warn!("usage-history.json has unsupported version, starting empty"); + Vec::new() + } + Err(error) => { + log::warn!( + "failed to parse usage-history.json: {}, starting empty", + error + ); + Vec::new() + } + } +} + +fn save_history(app_data_dir: &Path, snapshots: &[UsageHistorySnapshot]) -> Result<(), String> { + std::fs::create_dir_all(app_data_dir) + .map_err(|error| format!("failed to create app data dir: {}", error))?; + let file = UsageHistoryFile { + version: HISTORY_VERSION, + snapshots: snapshots.to_vec(), + }; + let json = serde_json::to_string(&file) + .map_err(|error| format!("failed to serialize usage history: {}", error))?; + let path = history_path(app_data_dir); + let tmp_path = app_data_dir.join(".usage-history.json.tmp"); + std::fs::write(&tmp_path, json) + .map_err(|error| format!("failed to write temp usage history: {}", error))?; + std::fs::rename(&tmp_path, &path) + .map_err(|error| format!("failed to rename usage history: {}", error))?; + Ok(()) +} + +fn prune_history(snapshots: &mut Vec) { + let newest = snapshots + .iter() + .filter_map(|snapshot| parse_rfc3339(&snapshot.fetched_at)) + .max(); + let Some(newest) = newest else { + return; + }; + let cutoff = newest - time::Duration::days(HISTORY_RETENTION_DAYS); + snapshots.retain(|snapshot| { + parse_rfc3339(&snapshot.fetched_at) + .map(|fetched_at| fetched_at > cutoff) + .unwrap_or(false) + }); +} + +fn parse_rfc3339(value: &str) -> Option { + time::OffsetDateTime::parse(value, &Rfc3339).ok() +} + +fn fetched_date(fetched_at: &str) -> Option { + let parsed = parse_rfc3339(fetched_at)?; + let date = parsed.date(); + Some(format!( + "{:04}-{:02}-{:02}", + date.year(), + date.month() as u8, + date.day() + )) +} + +#[derive(Debug, Clone)] +struct ExportRow { + fetched_at: String, + provider_id: String, + provider_name: String, + plan: String, + line_type: String, + metric: String, + used: Option, + limit: Option, + unit: String, + value: String, + reset_at: String, +} + +fn export_rows(app_data_dir: &Path, from_date: &str, to_date: &str) -> Vec { + let mut rows = Vec::new(); + for snapshot in load_history(app_data_dir) { + let Some(date) = fetched_date(&snapshot.fetched_at) else { + continue; + }; + if date.as_str() < from_date || date.as_str() > to_date { + continue; + } + for line in snapshot.lines.clone() { + rows.push(row_from_line(&snapshot, line)); + } + } + rows.sort_by(|a, b| { + a.fetched_at + .cmp(&b.fetched_at) + .then(a.provider_id.cmp(&b.provider_id)) + .then(a.metric.cmp(&b.metric)) + }); + rows +} + +fn row_from_line(snapshot: &UsageHistorySnapshot, line: MetricLine) -> ExportRow { + match line { + MetricLine::Progress { + label, + used, + limit, + format, + resets_at, + .. + } => ExportRow { + fetched_at: snapshot.fetched_at.clone(), + provider_id: snapshot.provider_id.clone(), + provider_name: snapshot.display_name.clone(), + plan: snapshot.plan.clone().unwrap_or_default(), + line_type: "progress".to_string(), + metric: label, + used: Some(used), + limit: Some(limit), + unit: progress_unit(format), + value: String::new(), + reset_at: resets_at.unwrap_or_default(), + }, + MetricLine::Text { label, value, .. } => ExportRow { + fetched_at: snapshot.fetched_at.clone(), + provider_id: snapshot.provider_id.clone(), + provider_name: snapshot.display_name.clone(), + plan: snapshot.plan.clone().unwrap_or_default(), + line_type: "text".to_string(), + metric: label, + used: None, + limit: None, + unit: String::new(), + value, + reset_at: String::new(), + }, + MetricLine::Badge { label, text, .. } => ExportRow { + fetched_at: snapshot.fetched_at.clone(), + provider_id: snapshot.provider_id.clone(), + provider_name: snapshot.display_name.clone(), + plan: snapshot.plan.clone().unwrap_or_default(), + line_type: "badge".to_string(), + metric: label, + used: None, + limit: None, + unit: String::new(), + value: text, + reset_at: String::new(), + }, + } +} + +fn progress_unit(format: crate::plugin_engine::runtime::ProgressFormat) -> String { + match format { + crate::plugin_engine::runtime::ProgressFormat::Percent => "percent".to_string(), + crate::plugin_engine::runtime::ProgressFormat::Dollars => "dollars".to_string(), + crate::plugin_engine::runtime::ProgressFormat::Count { suffix } => suffix, + } +} + +fn write_csv(path: &Path, rows: &[ExportRow]) -> Result<(), String> { + let mut csv = String::from( + "fetchedAt,providerId,providerName,plan,lineType,metric,used,limit,unit,value,resetAt\n", + ); + for row in rows { + csv.push_str(&csv_line(row)); + csv.push('\n'); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create export dir: {}", error))?; + } + std::fs::write(path, csv).map_err(|error| format!("failed to write CSV export: {}", error)) +} + +fn csv_line(row: &ExportRow) -> String { + [ + row.fetched_at.clone(), + row.provider_id.clone(), + row.provider_name.clone(), + row.plan.clone(), + row.line_type.clone(), + row.metric.clone(), + row.used.map(|value| value.to_string()).unwrap_or_default(), + row.limit.map(|value| value.to_string()).unwrap_or_default(), + row.unit.clone(), + row.value.clone(), + row.reset_at.clone(), + ] + .into_iter() + .map(|value| escape_csv(&value)) + .collect::>() + .join(",") +} + +fn escape_csv(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +#[derive(Debug, Clone)] +struct SummaryRow { + provider_id: String, + provider_name: String, + metric: String, + unit: String, + count: usize, + first_fetched_at: String, + last_fetched_at: String, + first: String, + last: String, + min: Option, + max: Option, +} + +fn summary_rows(rows: &[ExportRow]) -> Vec { + let mut map: BTreeMap<(String, String, String), SummaryRow> = BTreeMap::new(); + for row in rows { + let key = ( + row.provider_id.clone(), + row.metric.clone(), + row.unit.clone(), + ); + let display_value = row + .used + .map(|value| value.to_string()) + .unwrap_or_else(|| row.value.clone()); + let entry = map.entry(key).or_insert_with(|| SummaryRow { + provider_id: row.provider_id.clone(), + provider_name: row.provider_name.clone(), + metric: row.metric.clone(), + unit: row.unit.clone(), + count: 0, + first_fetched_at: row.fetched_at.clone(), + last_fetched_at: String::new(), + first: display_value.clone(), + last: String::new(), + min: row.used, + max: row.used, + }); + entry.count += 1; + entry.last_fetched_at = row.fetched_at.clone(); + entry.last = display_value; + if let Some(used) = row.used { + entry.min = Some(entry.min.map(|min| min.min(used)).unwrap_or(used)); + entry.max = Some(entry.max.map(|max| max.max(used)).unwrap_or(used)); + } + } + map.into_values().collect() +} + +fn write_xlsx(path: &Path, rows: &[ExportRow]) -> Result<(), String> { + let mut workbook = rust_xlsxwriter::Workbook::new(); + + { + let worksheet = workbook.add_worksheet(); + worksheet + .set_name("Summary") + .map_err(|error| format!("failed to name summary sheet: {}", error))?; + let headers = [ + "providerId", + "providerName", + "metric", + "unit", + "count", + "firstFetchedAt", + "lastFetchedAt", + "first", + "last", + "min", + "max", + ]; + write_xlsx_headers(worksheet, &headers)?; + for (index, row) in summary_rows(rows).iter().enumerate() { + let xlsx_row = (index + 1) as u32; + worksheet + .write_string(xlsx_row, 0, &row.provider_id) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 1, &row.provider_name) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 2, &row.metric) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 3, &row.unit) + .map_err(xlsx_err)?; + worksheet + .write_number(xlsx_row, 4, row.count as f64) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 5, &row.first_fetched_at) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 6, &row.last_fetched_at) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 7, &row.first) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 8, &row.last) + .map_err(xlsx_err)?; + if let Some(value) = row.min { + worksheet + .write_number(xlsx_row, 9, value) + .map_err(xlsx_err)?; + } + if let Some(value) = row.max { + worksheet + .write_number(xlsx_row, 10, value) + .map_err(xlsx_err)?; + } + } + } + + { + let worksheet = workbook.add_worksheet(); + worksheet + .set_name("Snapshots") + .map_err(|error| format!("failed to name snapshots sheet: {}", error))?; + let headers = [ + "fetchedAt", + "providerId", + "providerName", + "plan", + "lineType", + "metric", + "used", + "limit", + "unit", + "value", + "resetAt", + ]; + write_xlsx_headers(worksheet, &headers)?; + for (index, row) in rows.iter().enumerate() { + let xlsx_row = (index + 1) as u32; + worksheet + .write_string(xlsx_row, 0, &row.fetched_at) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 1, &row.provider_id) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 2, &row.provider_name) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 3, &row.plan) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 4, &row.line_type) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 5, &row.metric) + .map_err(xlsx_err)?; + if let Some(value) = row.used { + worksheet + .write_number(xlsx_row, 6, value) + .map_err(xlsx_err)?; + } + if let Some(value) = row.limit { + worksheet + .write_number(xlsx_row, 7, value) + .map_err(xlsx_err)?; + } + worksheet + .write_string(xlsx_row, 8, &row.unit) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 9, &row.value) + .map_err(xlsx_err)?; + worksheet + .write_string(xlsx_row, 10, &row.reset_at) + .map_err(xlsx_err)?; + } + } + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("failed to create export dir: {}", error))?; + } + workbook + .save(path) + .map_err(|error| format!("failed to write XLSX export: {}", error)) +} + +fn write_xlsx_headers( + worksheet: &mut rust_xlsxwriter::Worksheet, + headers: &[&str], +) -> Result<(), String> { + for (index, header) in headers.iter().enumerate() { + worksheet + .write_string(0, index as u16, *header) + .map_err(xlsx_err)?; + } + Ok(()) +} + +fn xlsx_err(error: rust_xlsxwriter::XlsxError) -> String { + error.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin_engine::runtime::ProgressFormat; + + fn temp_dir(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "openusage-history-test-{}-{}", + label, + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + fn make_output(id: &str, used: f64) -> PluginOutput { + PluginOutput { + provider_id: id.to_string(), + display_name: format!("Provider {}", id), + plan: Some("Pro".to_string()), + lines: vec![ + MetricLine::Progress { + label: "Session".to_string(), + used, + limit: 100.0, + format: ProgressFormat::Percent, + resets_at: Some("2026-06-17T00:00:00Z".to_string()), + period_duration_ms: Some(86_400_000), + color: None, + }, + MetricLine::Text { + label: "Today".to_string(), + value: "$12.34 ยท 56K tokens".to_string(), + color: None, + subtitle: None, + }, + ], + icon_url: String::new(), + } + } + + fn make_error_output(id: &str) -> PluginOutput { + PluginOutput { + provider_id: id.to_string(), + display_name: format!("Provider {}", id), + plan: None, + lines: vec![MetricLine::Badge { + label: "Error".to_string(), + text: "Failed".to_string(), + color: None, + subtitle: None, + }], + icon_url: String::new(), + } + } + + #[test] + fn history_range_returns_empty_for_missing_file() { + let dir = temp_dir("missing"); + + let range = list_range(&dir); + + assert_eq!(range.from_date, None); + assert_eq!(range.to_date, None); + assert_eq!(range.row_count, 0); + } + + #[test] + fn record_successful_output_skips_error_snapshots() { + let dir = temp_dir("skip-error"); + std::fs::create_dir_all(&dir).unwrap(); + + record_successful_output(&dir, &make_error_output("claude"), "2026-06-16T10:00:00Z") + .unwrap(); + + assert_eq!(list_range(&dir).row_count, 0); + assert!(!dir.join(HISTORY_FILE_NAME).exists()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_history_and_lists_available_range() { + let dir = temp_dir("range"); + std::fs::create_dir_all(&dir).unwrap(); + + record_successful_output(&dir, &make_output("claude", 10.0), "2026-06-14T23:59:00Z") + .unwrap(); + record_successful_output(&dir, &make_output("codex", 20.0), "2026-06-16T00:01:00Z") + .unwrap(); + + let range = list_range(&dir); + + assert_eq!(range.from_date, Some("2026-06-14".to_string())); + assert_eq!(range.to_date, Some("2026-06-16".to_string())); + assert_eq!(range.row_count, 4); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn csv_export_filters_dates_inclusively_and_escapes_values() { + let dir = temp_dir("csv"); + std::fs::create_dir_all(&dir).unwrap(); + let output = PluginOutput { + provider_id: "claude".to_string(), + display_name: "Claude, Inc".to_string(), + plan: Some("Team \"Pro\"".to_string()), + lines: vec![MetricLine::Text { + label: "Today".to_string(), + value: "line one\nline two".to_string(), + color: None, + subtitle: None, + }], + icon_url: String::new(), + }; + + record_successful_output(&dir, &make_output("codex", 5.0), "2026-06-13T23:59:59Z").unwrap(); + record_successful_output(&dir, &output, "2026-06-14T00:00:00Z").unwrap(); + record_successful_output(&dir, &make_output("gemini", 7.0), "2026-06-16T00:00:00Z") + .unwrap(); + + let path = dir.join("usage.csv"); + let result = + export_history(&dir, ExportFormat::Csv, "2026-06-14", "2026-06-14", &path).unwrap(); + let csv = std::fs::read_to_string(&path).unwrap(); + + assert_eq!(result.row_count, 1); + assert!(csv.starts_with( + "fetchedAt,providerId,providerName,plan,lineType,metric,used,limit,unit,value,resetAt\n" + )); + assert!(csv.contains("\"Claude, Inc\"")); + assert!(csv.contains("\"Team \"\"Pro\"\"\"")); + assert!(csv.contains("\"line one\nline two\"")); + assert!(!csv.contains("codex")); + assert!(!csv.contains("gemini")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn xlsx_export_creates_workbook_with_snapshot_and_summary_sheets() { + use std::io::Read; + + let dir = temp_dir("xlsx"); + std::fs::create_dir_all(&dir).unwrap(); + record_successful_output(&dir, &make_output("claude", 10.0), "2026-06-14T10:00:00Z") + .unwrap(); + record_successful_output(&dir, &make_output("claude", 30.0), "2026-06-14T12:00:00Z") + .unwrap(); + + let path = dir.join("usage.xlsx"); + let result = + export_history(&dir, ExportFormat::Xlsx, "2026-06-14", "2026-06-14", &path).unwrap(); + let file = std::fs::File::open(&path).unwrap(); + let mut archive = zip::ZipArchive::new(file).unwrap(); + let mut workbook_xml = String::new(); + archive + .by_name("xl/workbook.xml") + .unwrap() + .read_to_string(&mut workbook_xml) + .unwrap(); + + assert_eq!(result.row_count, 4); + assert!(workbook_xml.contains(r#"name="Summary""#)); + assert!(workbook_xml.contains(r#"name="Snapshots""#)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn summary_rows_include_snapshot_timestamps_to_disambiguate_metric_periods() { + let dir = temp_dir("summary-timestamps"); + std::fs::create_dir_all(&dir).unwrap(); + record_successful_output(&dir, &make_output("codex", 10.0), "2026-06-16T08:00:00Z") + .unwrap(); + record_successful_output(&dir, &make_output("codex", 30.0), "2026-06-16T09:00:00Z") + .unwrap(); + + let rows = export_rows(&dir, "2026-06-16", "2026-06-16"); + let summary = summary_rows(&rows); + let today = summary + .iter() + .find(|row| row.metric == "Today") + .expect("today metric summary"); + + assert_eq!(today.count, 2); + assert_eq!(today.first_fetched_at, "2026-06-16T08:00:00Z"); + assert_eq!(today.last_fetched_at, "2026-06-16T09:00:00Z"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn retention_keeps_only_last_365_days() { + let dir = temp_dir("retention"); + std::fs::create_dir_all(&dir).unwrap(); + + record_successful_output(&dir, &make_output("old", 1.0), "2025-06-15T00:00:00Z").unwrap(); + record_successful_output(&dir, &make_output("new", 2.0), "2026-06-15T00:00:00Z").unwrap(); + + let path = dir.join("usage.csv"); + export_history(&dir, ExportFormat::Csv, "2025-06-15", "2026-06-15", &path).unwrap(); + let csv = std::fs::read_to_string(&path).unwrap(); + + assert!(!csv.contains(",old,")); + assert!(csv.contains(",new,")); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/App.test.tsx b/src/App.test.tsx index 3fa3b6aee..f0a6fe411 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -48,6 +48,10 @@ const updaterState = vi.hoisted(() => ({ relaunchMock: vi.fn(async () => undefined), })) +const dialogState = vi.hoisted(() => ({ + saveMock: vi.fn(), +})) + const eventState = vi.hoisted(() => { const handlers = new Map void>() return { @@ -205,6 +209,10 @@ vi.mock("@tauri-apps/plugin-autostart", () => ({ isEnabled: state.autostartIsEnabledMock, })) +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: dialogState.saveMock, +})) + vi.mock("@/lib/tray-bars-icon", async () => { const actual = await vi.importActual("@/lib/tray-bars-icon") return { @@ -303,7 +311,9 @@ describe("App", () => { eventState.listenMock.mockReset() updaterState.checkMock.mockReset() updaterState.relaunchMock.mockReset() + dialogState.saveMock.mockReset() updaterState.checkMock.mockResolvedValue(null) + dialogState.saveMock.mockResolvedValue(null) state.savePluginSettingsMock.mockResolvedValue(undefined) state.saveAutoUpdateIntervalMock.mockResolvedValue(undefined) state.loadThemeModeMock.mockResolvedValue("system") @@ -722,6 +732,36 @@ describe("App", () => { }) }) + it("exports usage history through the app footer", async () => { + state.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_plugins") { + return [ + { id: "a", name: "Alpha", iconUrl: "icon-a", primaryCandidates: [], lines: [{ type: "text", label: "Now", scope: "overview" }] }, + ] + } + if (cmd === "list_usage_history_range") { + return { fromDate: "2026-06-14", toDate: "2026-06-16", rowCount: 2 } + } + if (cmd === "export_usage_history") { + return { rowCount: 2 } + } + return null + }) + dialogState.saveMock.mockResolvedValueOnce("/tmp/openusage.csv") + + render() + + await userEvent.click(await screen.findByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("button", { name: "Save" })) + + expect(state.invokeMock).toHaveBeenCalledWith("export_usage_history", { + format: "csv", + fromDate: "2026-06-14", + toDate: "2026-06-16", + path: "/tmp/openusage.csv", + }) + }) + it("updates display mode in settings", async () => { render() const settingsButtons = await screen.findAllByRole("button", { name: "Settings" }) diff --git a/src/components/panel-footer.test.tsx b/src/components/panel-footer.test.tsx index 786d18067..cdcaa7e39 100644 --- a/src/components/panel-footer.test.tsx +++ b/src/components/panel-footer.test.tsx @@ -1,19 +1,47 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { useState } from "react" -import { describe, expect, it, vi } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { PanelFooter } from "@/components/panel-footer" import type { UpdateStatus } from "@/hooks/use-app-update" +const exportMocks = vi.hoisted(() => ({ + invokeMock: vi.fn(), + saveMock: vi.fn(), +})) + vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn(() => Promise.resolve()), })) +vi.mock("@tauri-apps/api/core", () => ({ + invoke: exportMocks.invokeMock, +})) + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + save: exportMocks.saveMock, +})) + const idle: UpdateStatus = { status: "idle" } const noop = () => {} const footerProps = { showAbout: false, onShowAbout: noop, onCloseAbout: noop, onUpdateCheck: noop } describe("PanelFooter", () => { + beforeEach(() => { + exportMocks.invokeMock.mockReset() + exportMocks.saveMock.mockReset() + exportMocks.invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === "list_usage_history_range") { + return { fromDate: "2026-06-14", toDate: "2026-06-16", rowCount: 4 } + } + if (cmd === "export_usage_history") { + return { rowCount: 4 } + } + return null + }) + exportMocks.saveMock.mockResolvedValue("/tmp/openusage.csv") + }) + it("shows countdown in minutes when >= 60 seconds", () => { const futureTime = Date.now() + 5 * 60 * 1000 // 5 minutes from now render( @@ -189,4 +217,160 @@ describe("PanelFooter", () => { await userEvent.keyboard("{Escape}") expect(screen.queryByText("Open source on")).not.toBeInTheDocument() }) + + it("opens usage export dialog from the footer", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + + expect(await screen.findByText("Export usage")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "From date 06/14/2026" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "To date 06/16/2026" })).toBeInTheDocument() + }) + + it("does not export when the save dialog is cancelled", async () => { + exportMocks.saveMock.mockResolvedValueOnce(null) + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("button", { name: "Save" })) + + expect(exportMocks.saveMock).toHaveBeenCalled() + expect(exportMocks.invokeMock).not.toHaveBeenCalledWith("export_usage_history", expect.anything()) + }) + + it("uses an in-panel calendar instead of native date inputs", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + + expect(await screen.findByRole("button", { name: "From date 06/14/2026" })).toBeInTheDocument() + expect(screen.queryByDisplayValue("2026-06-14")).not.toBeInTheDocument() + }) + + it("keeps the in-panel calendar open while changing months", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("button", { name: "From date 06/14/2026" })) + await userEvent.click(screen.getByRole("button", { name: "Next month" })) + + expect(screen.getByText("July 2026")).toBeInTheDocument() + expect(screen.getByRole("grid", { name: "From calendar" })).toBeInTheDocument() + }) + + it("keeps the in-panel calendar compact by rendering only required weeks", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("button", { name: "From date 06/14/2026" })) + + expect(screen.getAllByRole("button", { name: /^Select / })).toHaveLength(35) + expect(screen.queryByRole("button", { name: "Select 07/11/2026" })).not.toBeInTheDocument() + }) + + it("closes the in-panel calendar after selecting a date", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("button", { name: "From date 06/14/2026" })) + await userEvent.click(screen.getByRole("button", { name: "Select 06/15/2026" })) + + expect(screen.getByRole("button", { name: "From date 06/15/2026" })).toBeInTheDocument() + expect(screen.queryByRole("grid", { name: "From calendar" })).not.toBeInTheDocument() + }) + + it("adds extra panel height while the export calendar is open", async () => { + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + expect(screen.queryByTestId("usage-export-calendar-spacer")).not.toBeInTheDocument() + + await userEvent.click(await screen.findByRole("button", { name: "From date 06/14/2026" })) + expect(screen.getByTestId("usage-export-calendar-spacer")).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: "Select 06/15/2026" })) + expect(screen.queryByTestId("usage-export-calendar-spacer")).not.toBeInTheDocument() + }) + + it("exports Excel with camelCase IPC arguments", async () => { + exportMocks.saveMock.mockResolvedValueOnce("/tmp/openusage.xlsx") + render( + + ) + + await userEvent.click(screen.getByRole("button", { name: "Export usage" })) + await userEvent.click(await screen.findByRole("radio", { name: "Excel" })) + await userEvent.click(screen.getByRole("button", { name: "Save" })) + + expect(exportMocks.invokeMock).toHaveBeenCalledWith("export_usage_history", { + format: "xlsx", + fromDate: "2026-06-14", + toDate: "2026-06-16", + path: "/tmp/openusage.xlsx", + }) + expect(await screen.findByText("Exported 4 rows.")).toBeInTheDocument() + }) }) diff --git a/src/components/panel-footer.tsx b/src/components/panel-footer.tsx index 03f771803..7a1b8a13b 100644 --- a/src/components/panel-footer.tsx +++ b/src/components/panel-footer.tsx @@ -1,6 +1,8 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { Download } from "lucide-react"; import { Button } from "@/components/ui/button"; import { AboutDialog } from "@/components/about-dialog"; +import { UsageExportDialog } from "@/components/usage-export-dialog"; import type { UpdateStatus } from "@/hooks/use-app-update"; import { useNowTicker } from "@/hooks/use-now-ticker"; @@ -95,6 +97,8 @@ export function PanelFooter({ onShowAbout, onCloseAbout, }: PanelFooterProps) { + const [showExport, setShowExport] = useState(false); + const [exportCalendarOpen, setExportCalendarOpen] = useState(false); const now = useNowTicker({ enabled: Boolean(autoUpdateNextAt), resetKey: autoUpdateNextAt, @@ -121,27 +125,57 @@ export function PanelFooter({ onUpdateCheck={onUpdateCheck} onVersionClick={onShowAbout} /> - {autoUpdateNextAt !== null && onRefreshAll ? ( - - ) : ( - - {countdownLabel} - - )} + + + {autoUpdateNextAt !== null && onRefreshAll ? ( + + ) : ( + + {countdownLabel} + + )} + {showAbout && ( )} + {showExport && ( + { + setExportCalendarOpen(false) + setShowExport(false) + }} + onCalendarOpenChange={setExportCalendarOpen} + /> + )} + {showExport && exportCalendarOpen && ( +