diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 966d3e7..84f6fef 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -2,10 +2,14 @@ use std::fs; use std::path::PathBuf; +use std::sync::Mutex; use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; -use crate::types::{AccountsStore, AuthData, StoredAccount}; +use crate::types::{AccountsStore, AuthData, StoredAccount, UsageInfo}; + +static STORE_LOCK: Mutex<()> = Mutex::new(()); /// Get the path to the codex-switcher config directory pub fn get_config_dir() -> Result { @@ -20,6 +24,11 @@ pub fn get_accounts_file() -> Result { /// Load the accounts store from disk pub fn load_accounts() -> Result { + let _guard = STORE_LOCK.lock().unwrap(); + load_accounts_unlocked() +} + +fn load_accounts_unlocked() -> Result { let path = get_accounts_file()?; if !path.exists() { @@ -37,6 +46,11 @@ pub fn load_accounts() -> Result { /// Save the accounts store to disk pub fn save_accounts(store: &AccountsStore) -> Result<()> { + let _guard = STORE_LOCK.lock().unwrap(); + save_accounts_unlocked(store) +} + +fn save_accounts_unlocked(store: &AccountsStore) -> Result<()> { let path = get_accounts_file()?; // Ensure the config directory exists @@ -62,59 +76,62 @@ pub fn save_accounts(store: &AccountsStore) -> Result<()> { Ok(()) } +fn with_accounts_store_mut( + mutator: impl FnOnce(&mut AccountsStore) -> Result, +) -> Result { + let _guard = STORE_LOCK.lock().unwrap(); + let mut store = load_accounts_unlocked()?; + let result = mutator(&mut store)?; + save_accounts_unlocked(&store)?; + Ok(result) +} + /// Add a new account to the store pub fn add_account(account: StoredAccount) -> Result { - let mut store = load_accounts()?; - - // Check for duplicate names - if store.accounts.iter().any(|a| a.name == account.name) { - anyhow::bail!("An account with name '{}' already exists", account.name); - } - let account_clone = account.clone(); - store.accounts.push(account); + with_accounts_store_mut(move |store| { + if store.accounts.iter().any(|a| a.name == account.name) { + anyhow::bail!("An account with name '{}' already exists", account.name); + } - // If this is the first account, make it active - if store.accounts.len() == 1 { - store.active_account_id = Some(account_clone.id.clone()); - } + store.accounts.push(account); + + if store.accounts.len() == 1 { + store.active_account_id = Some(account_clone.id.clone()); + } - save_accounts(&store)?; - Ok(account_clone) + Ok(account_clone) + }) } /// Remove an account by ID pub fn remove_account(account_id: &str) -> Result<()> { - let mut store = load_accounts()?; + with_accounts_store_mut(move |store| { + let initial_len = store.accounts.len(); + store.accounts.retain(|a| a.id != account_id); - let initial_len = store.accounts.len(); - store.accounts.retain(|a| a.id != account_id); - - if store.accounts.len() == initial_len { - anyhow::bail!("Account not found: {account_id}"); - } + if store.accounts.len() == initial_len { + anyhow::bail!("Account not found: {account_id}"); + } - // If we removed the active account, clear it or set to first available - if store.active_account_id.as_deref() == Some(account_id) { - store.active_account_id = store.accounts.first().map(|a| a.id.clone()); - } + if store.active_account_id.as_deref() == Some(account_id) { + store.active_account_id = store.accounts.first().map(|a| a.id.clone()); + } - save_accounts(&store)?; - Ok(()) + Ok(()) + }) } /// Update the active account ID pub fn set_active_account(account_id: &str) -> Result<()> { - let mut store = load_accounts()?; - - // Verify the account exists - if !store.accounts.iter().any(|a| a.id == account_id) { - anyhow::bail!("Account not found: {account_id}"); - } + with_accounts_store_mut(move |store| { + if !store.accounts.iter().any(|a| a.id == account_id) { + anyhow::bail!("Account not found: {account_id}"); + } - store.active_account_id = Some(account_id.to_string()); - save_accounts(&store)?; - Ok(()) + store.active_account_id = Some(account_id.to_string()); + Ok(()) + }) } /// Get an account by ID @@ -135,14 +152,13 @@ pub fn get_active_account() -> Result> { /// Update an account's last_used_at timestamp pub fn touch_account(account_id: &str) -> Result<()> { - let mut store = load_accounts()?; - - if let Some(account) = store.accounts.iter_mut().find(|a| a.id == account_id) { - account.last_used_at = Some(chrono::Utc::now()); - save_accounts(&store)?; - } + with_accounts_store_mut(move |store| { + if let Some(account) = store.accounts.iter_mut().find(|a| a.id == account_id) { + account.last_used_at = Some(chrono::Utc::now()); + } - Ok(()) + Ok(()) + }) } /// Update an account's metadata (name, email, plan_type) @@ -152,40 +168,37 @@ pub fn update_account_metadata( email: Option, plan_type: Option, ) -> Result<()> { - let mut store = load_accounts()?; - - // Check for duplicate names first (if renaming) - if let Some(ref new_name) = name { - if store - .accounts - .iter() - .any(|a| a.id != account_id && a.name == *new_name) - { - anyhow::bail!("An account with name '{new_name}' already exists"); + with_accounts_store_mut(move |store| { + if let Some(ref new_name) = name { + if store + .accounts + .iter() + .any(|a| a.id != account_id && a.name == *new_name) + { + anyhow::bail!("An account with name '{new_name}' already exists"); + } } - } - // Now find and update the account - let account = store - .accounts - .iter_mut() - .find(|a| a.id == account_id) - .context("Account not found")?; + let account = store + .accounts + .iter_mut() + .find(|a| a.id == account_id) + .context("Account not found")?; - if let Some(new_name) = name { - account.name = new_name; - } + if let Some(new_name) = name { + account.name = new_name; + } - if email.is_some() { - account.email = email; - } + if email.is_some() { + account.email = email; + } - if plan_type.is_some() { - account.plan_type = plan_type; - } + if plan_type.is_some() { + account.plan_type = plan_type; + } - save_accounts(&store)?; - Ok(()) + Ok(()) + }) } /// Update ChatGPT OAuth tokens for an account and return the updated account. @@ -198,44 +211,62 @@ pub fn update_account_chatgpt_tokens( email: Option, plan_type: Option, ) -> Result { - let mut store = load_accounts()?; - - let account = store - .accounts - .iter_mut() - .find(|a| a.id == account_id) - .context("Account not found")?; - - match &mut account.auth_data { - AuthData::ChatGPT { - id_token: stored_id_token, - access_token: stored_access_token, - refresh_token: stored_refresh_token, - account_id: stored_account_id, - } => { - *stored_id_token = id_token; - *stored_access_token = access_token; - *stored_refresh_token = refresh_token; - if let Some(new_account_id) = chatgpt_account_id { - *stored_account_id = Some(new_account_id); + with_accounts_store_mut(move |store| { + let account = store + .accounts + .iter_mut() + .find(|a| a.id == account_id) + .context("Account not found")?; + + match &mut account.auth_data { + AuthData::ChatGPT { + id_token: stored_id_token, + access_token: stored_access_token, + refresh_token: stored_refresh_token, + account_id: stored_account_id, + } => { + *stored_id_token = id_token; + *stored_access_token = access_token; + *stored_refresh_token = refresh_token; + if let Some(new_account_id) = chatgpt_account_id { + *stored_account_id = Some(new_account_id); + } + } + AuthData::ApiKey { .. } => { + anyhow::bail!("Cannot update OAuth tokens for an API key account"); } } - AuthData::ApiKey { .. } => { - anyhow::bail!("Cannot update OAuth tokens for an API key account"); + + if let Some(new_email) = email { + account.email = Some(new_email); } - } - if let Some(new_email) = email { - account.email = Some(new_email); - } + if let Some(new_plan_type) = plan_type { + account.plan_type = Some(new_plan_type); + } - if let Some(new_plan_type) = plan_type { - account.plan_type = Some(new_plan_type); - } + Ok(account.clone()) + }) +} - let updated = account.clone(); - save_accounts(&store)?; - Ok(updated) +/// Persist the last successful usage snapshot for an account. +pub fn update_account_cached_usage( + account_id: &str, + usage: UsageInfo, + updated_at: DateTime, +) -> Result<()> { + with_accounts_store_mut(move |store| { + let account = store + .accounts + .iter_mut() + .find(|a| a.id == account_id) + .context("Account not found")?; + + account.cached_usage = Some(usage); + account.cached_usage_updated_at = Some(updated_at); + + Ok(()) + }) } /// Get the list of masked account IDs @@ -246,8 +277,8 @@ pub fn get_masked_account_ids() -> Result> { /// Set the list of masked account IDs pub fn set_masked_account_ids(ids: Vec) -> Result<()> { - let mut store = load_accounts()?; - store.masked_account_ids = ids; - save_accounts(&store)?; - Ok(()) + with_accounts_store_mut(move |store| { + store.masked_account_ids = ids; + Ok(()) + }) } diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index e3ee065..6ac92f1 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -1,10 +1,26 @@ //! Usage query Tauri commands use crate::api::usage::{get_account_usage, refresh_all_usage, warmup_account as send_warmup}; -use crate::auth::{get_account, load_accounts}; +use crate::auth::{get_account, load_accounts, update_account_cached_usage}; use crate::types::{UsageInfo, WarmupSummary}; +use chrono::Utc; use futures::{stream, StreamExt}; +fn persist_usage_snapshot(usage: &UsageInfo) { + if usage.error.is_some() { + return; + } + + if let Err(error) = + update_account_cached_usage(&usage.account_id, usage.clone(), Utc::now()) + { + eprintln!( + "[Usage] Failed to persist usage snapshot for {}: {}", + usage.account_id, error + ); + } +} + /// Get usage info for a specific account #[tauri::command] pub async fn get_usage(account_id: String) -> Result { @@ -12,14 +28,22 @@ pub async fn get_usage(account_id: String) -> Result { .map_err(|e| e.to_string())? .ok_or_else(|| format!("Account not found: {account_id}"))?; - get_account_usage(&account).await.map_err(|e| e.to_string()) + let usage = get_account_usage(&account).await.map_err(|e| e.to_string())?; + persist_usage_snapshot(&usage); + Ok(usage) } /// Refresh usage info for all accounts #[tauri::command] pub async fn refresh_all_accounts_usage() -> Result, String> { let store = load_accounts().map_err(|e| e.to_string())?; - Ok(refresh_all_usage(&store.accounts).await) + let usage_list = refresh_all_usage(&store.accounts).await; + + for usage in &usage_list { + persist_usage_snapshot(usage); + } + + Ok(usage_list) } /// Send a minimal warm-up request for one account diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index dc3d2cc..9c9d79b 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -48,6 +48,12 @@ pub struct StoredAccount { pub created_at: DateTime, /// Last time this account was used pub last_used_at: Option>, + /// Last successful usage snapshot for stale-while-revalidate UI. + #[serde(default)] + pub cached_usage: Option, + /// When the cached usage snapshot was recorded. + #[serde(default)] + pub cached_usage_updated_at: Option>, } impl StoredAccount { @@ -62,6 +68,8 @@ impl StoredAccount { auth_data: AuthData::ApiKey { key: api_key }, created_at: Utc::now(), last_used_at: None, + cached_usage: None, + cached_usage_updated_at: None, } } @@ -89,6 +97,8 @@ impl StoredAccount { }, created_at: Utc::now(), last_used_at: None, + cached_usage: None, + cached_usage_updated_at: None, } } } @@ -172,6 +182,8 @@ pub struct AccountInfo { pub is_active: bool, pub created_at: DateTime, pub last_used_at: Option>, + pub cached_usage: Option, + pub cached_usage_updated_at: Option>, } impl AccountInfo { @@ -185,6 +197,8 @@ impl AccountInfo { is_active: active_id == Some(&account.id), created_at: account.created_at, last_used_at: account.last_used_at, + cached_usage: account.cached_usage.clone(), + cached_usage_updated_at: account.cached_usage_updated_at, } } } diff --git a/src/App.css b/src/App.css index f1d8c73..927c375 100644 --- a/src/App.css +++ b/src/App.css @@ -1 +1,44 @@ @import "tailwindcss"; + +@keyframes usage-stale-sheen { + 0% { + transform: translateX(-140%); + opacity: 0; + } + + 20% { + opacity: 1; + } + + 100% { + transform: translateX(160%); + opacity: 0; + } +} + +.usage-stale-fill { + opacity: 0.55; + filter: saturate(0.7); +} + +.usage-stale-sheen { + pointer-events: none; + background: linear-gradient( + 110deg, + transparent 0%, + rgba(255, 255, 255, 0.12) 28%, + rgba(148, 163, 184, 0.36) 50%, + rgba(255, 255, 255, 0.12) 72%, + transparent 100% + ); + transform: translateX(-140%); + animation: usage-stale-sheen 1.6s ease-in-out infinite; +} + +@media (prefers-reduced-motion: reduce) { + .usage-stale-sheen { + animation: none; + opacity: 0.35; + transform: none; + } +} diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 7e1483e..49b8b8b 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -52,12 +52,10 @@ export function AccountCard({ onToggleMask, }: AccountCardProps) { const [isRefreshing, setIsRefreshing] = useState(false); - const [lastRefresh, setLastRefresh] = useState( - account.usage && !account.usage.error ? new Date() : null - ); const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(account.name); const inputRef = useRef(null); + const lastRefresh = account.usageUpdatedAt ? new Date(account.usageUpdatedAt) : null; useEffect(() => { if (isEditing && inputRef.current) { @@ -70,7 +68,6 @@ export function AccountCard({ setIsRefreshing(true); try { await onRefresh(); - setLastRefresh(new Date()); } finally { setIsRefreshing(false); } diff --git a/src/components/UsageBar.tsx b/src/components/UsageBar.tsx index ffb0329..a0d58fc 100644 --- a/src/components/UsageBar.tsx +++ b/src/components/UsageBar.tsx @@ -5,14 +5,25 @@ interface UsageBarProps { loading?: boolean; } -function formatResetTime(resetAt: number | null | undefined): string { +export function formatResetTime( + resetAt: number | null | undefined, + nowSeconds = Math.floor(Date.now() / 1000) +): string { if (!resetAt) return ""; - const now = Math.floor(Date.now() / 1000); - const diff = resetAt - now; + const diff = resetAt - nowSeconds; if (diff <= 0) return "now"; if (diff < 60) return `${diff}s`; - if (diff < 3600) return `${Math.floor(diff / 60)}m`; - return `${Math.floor(diff / 3600)}h ${Math.floor((diff % 3600) / 60)}m`; + + const totalMinutes = Math.floor(diff / 60); + if (totalMinutes < 60) return `${totalMinutes}m`; + + const totalHours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (totalHours < 24) return `${totalHours}h ${minutes}m`; + + const days = Math.floor(totalHours / 24); + const hours = totalHours % 24; + return `${days}d ${hours}h ${minutes}m`; } function formatExactResetTime(resetAt: number | null | undefined): string { @@ -41,16 +52,15 @@ function RateLimitBar({ usedPercent, windowMinutes, resetsAt, + stale = false, }: { label: string; usedPercent: number; windowMinutes?: number | null; resetsAt?: number | null; + stale?: boolean; }) { - // Calculate remaining percentage const remainingPercent = Math.max(0, 100 - usedPercent); - - // Color based on remaining (green = plenty left, red = almost none left) const colorClass = remainingPercent <= 10 ? "bg-red-500" @@ -65,18 +75,27 @@ function RateLimitBar({ return (
- {label} {windowLabel && `(${windowLabel})`} + + {label} {windowLabel && `(${windowLabel})`} + {remainingPercent.toFixed(0)}% left - {resetLabel && ` • resets ${resetLabel}`} + {resetLabel && ` - resets ${resetLabel}`} {resetLabel && exactResetLabel && ` (${exactResetLabel})`}
-
+
+ > +
+ {stale &&
); @@ -86,54 +105,48 @@ export function UsageBar({ usage, loading }: UsageBarProps) { if (loading && !usage) { return (
-
- Fetching usage... -
-
-
+
Fetching usage...
+
+
-
-
+
+
); } if (!usage) { - return ( -
- Fetching usage... -
- ); + return
Fetching usage...
; } if (usage.error) { - return ( -
- {usage.error} -
- ); + return
{usage.error}
; } - const hasPrimary = usage.primary_used_percent !== null && usage.primary_used_percent !== undefined; - const hasSecondary = usage.secondary_used_percent !== null && usage.secondary_used_percent !== undefined; + const hasPrimary = + usage.primary_used_percent !== null && usage.primary_used_percent !== undefined; + const hasSecondary = + usage.secondary_used_percent !== null && usage.secondary_used_percent !== undefined; if (!hasPrimary && !hasSecondary) { - return ( -
- No rate limit data -
- ); + return
No rate limit data
; } + const showRefreshingState = loading && !usage.error; + return (
+ {showRefreshingState && ( +
Refreshing usage...
+ )} {hasPrimary && ( )} {hasSecondary && ( @@ -142,12 +155,11 @@ export function UsageBar({ usage, loading }: UsageBarProps) { usedPercent={usage.secondary_used_percent!} windowMinutes={usage.secondary_window_minutes} resetsAt={usage.secondary_resets_at} + stale={showRefreshingState} /> )} {usage.credits_balance && ( -
- Credits: {usage.credits_balance} -
+
Credits: {usage.credits_balance}
)}
); diff --git a/src/hooks/useAccounts.ts b/src/hooks/useAccounts.ts index a60b55b..dc5c202 100644 --- a/src/hooks/useAccounts.ts +++ b/src/hooks/useAccounts.ts @@ -7,6 +7,18 @@ import type { WarmupSummary, ImportAccountsSummary, } from "../types"; +import { + applyUsageFetchError, + applyUsageFetchResult, + extractCachedUsageEntriesFromAccounts, + filterCachedUsageEntries, + loadCachedUsageFromBrowser, + markAccountsUsageLoading, + mergeCachedUsageEntries, + mergeAccountsWithCachedUsage, + persistCachedUsageToBrowser, + saveCachedUsageToBrowser, +} from "../lib/usageCache"; export function useAccounts() { const [accounts, setAccounts] = useState([]); @@ -62,23 +74,24 @@ export function useAccounts() { try { setLoading(true); setError(null); + const browserCachedUsage = loadCachedUsageFromBrowser(); const accountList = await invoke("list_accounts"); - - if (preserveUsage) { - // Preserve existing usage data when just updating account info - setAccounts((prev) => { - const usageMap = new Map( - prev.map((a) => [a.id, { usage: a.usage, usageLoading: a.usageLoading }]) - ); - return accountList.map((a) => ({ - ...a, - usage: usageMap.get(a.id)?.usage, - usageLoading: usageMap.get(a.id)?.usageLoading, - })); - }); - } else { - setAccounts(accountList.map((a) => ({ ...a, usageLoading: false }))); - } + const backendCachedUsage = extractCachedUsageEntriesFromAccounts(accountList); + const accountIdSet = new Set(accountList.map((account) => account.id)); + const filteredBrowserCachedUsage = filterCachedUsageEntries( + browserCachedUsage, + accountIdSet + ); + const mergedCachedUsage = mergeCachedUsageEntries( + backendCachedUsage, + filteredBrowserCachedUsage + ); + + persistCachedUsageToBrowser(mergedCachedUsage); + + setAccounts((prev) => + mergeAccountsWithCachedUsage(accountList, prev, mergedCachedUsage, preserveUsage) + ); return accountList; } catch (err) { setError(err instanceof Error ? err.message : String(err)); @@ -90,91 +103,98 @@ export function useAccounts() { const refreshUsage = useCallback( async (accountList?: AccountInfo[] | AccountWithUsage[]) => { - try { - const list = accountList ?? accountsRef.current; - if (list.length === 0) { - return; - } + try { + const list = accountList ?? accountsRef.current; + if (list.length === 0) { + return; + } - const accountIds = list.map((account) => account.id); - const accountIdSet = new Set(accountIds); + const accountIds = list.map((account) => account.id); + const accountIdSet = new Set(accountIds); - setAccounts((prev) => - prev.map((account) => - accountIdSet.has(account.id) - ? { ...account, usageLoading: true } - : account - ) - ); + setAccounts((prev) => markAccountsUsageLoading(prev, accountIdSet)); - await runWithConcurrency( - accountIds, - async (accountId) => { - try { - const usage = await invoke("get_usage", { accountId }); - setAccounts((prev) => - prev.map((account) => - account.id === accountId - ? { ...account, usage, usageLoading: false } - : account - ) - ); - } catch (err) { - console.error("Failed to refresh usage:", err); - const message = err instanceof Error ? err.message : String(err); - setAccounts((prev) => - prev.map((account) => - account.id === accountId - ? { - ...account, - usage: buildUsageError(accountId, message, account.plan_type ?? null), - usageLoading: false, - } - : account - ) - ); - } - }, - maxConcurrentUsageRequests - ); - } catch (err) { - console.error("Failed to refresh usage:", err); - throw err; - } + await runWithConcurrency( + accountIds, + async (accountId) => { + try { + const usage = await invoke("get_usage", { accountId }); + const updatedAt = new Date().toISOString(); + + if (!usage.error) { + saveCachedUsageToBrowser({ + account_id: accountId, + usage, + updated_at: updatedAt, + }); + } + + setAccounts((prev) => applyUsageFetchResult(prev, accountId, usage, updatedAt)); + } catch (err) { + console.error("Failed to refresh usage:", err); + const message = err instanceof Error ? err.message : String(err); + setAccounts((prev) => + applyUsageFetchError( + prev, + accountId, + buildUsageError( + accountId, + message, + prev.find((account) => account.id === accountId)?.plan_type ?? null + ) + ) + ); + } + }, + maxConcurrentUsageRequests + ); + } catch (err) { + console.error("Failed to refresh usage:", err); + throw err; + } }, [buildUsageError, maxConcurrentUsageRequests, runWithConcurrency] ); - const refreshSingleUsage = useCallback(async (accountId: string) => { - try { - setAccounts((prev) => - prev.map((a) => - a.id === accountId ? { ...a, usageLoading: true } : a - ) - ); - const usage = await invoke("get_usage", { accountId }); - setAccounts((prev) => - prev.map((a) => - a.id === accountId ? { ...a, usage, usageLoading: false } : a - ) - ); - } catch (err) { - console.error("Failed to refresh single usage:", err); - const message = err instanceof Error ? err.message : String(err); - setAccounts((prev) => - prev.map((a) => - a.id === accountId - ? { - ...a, - usage: buildUsageError(accountId, message, a.plan_type ?? null), - usageLoading: false, - } - : a - ) - ); - throw err; - } - }, []); + const refreshSingleUsage = useCallback( + async (accountId: string) => { + try { + setAccounts((prev) => + prev.map((account) => + account.id === accountId ? { ...account, usageLoading: true } : account + ) + ); + const usage = await invoke("get_usage", { accountId }); + const updatedAt = new Date().toISOString(); + + if (!usage.error) { + saveCachedUsageToBrowser({ + account_id: accountId, + usage, + updated_at: updatedAt, + }); + } + + setAccounts((prev) => applyUsageFetchResult(prev, accountId, usage, updatedAt)); + } catch (err) { + console.error("Failed to refresh single usage:", err); + const message = err instanceof Error ? err.message : String(err); + setAccounts((prev) => + applyUsageFetchError( + prev, + accountId, + buildUsageError( + accountId, + message, + prev.find((account) => account.id === accountId)?.plan_type ?? null + ) + ) + ); + throw err; + } + }, + [buildUsageError] + ); const warmupAccount = useCallback(async (accountId: string) => { try { @@ -198,7 +218,7 @@ export function useAccounts() { async (accountId: string) => { try { await invoke("switch_account", { accountId }); - await loadAccounts(true); // Preserve usage data + await loadAccounts(true); } catch (err) { throw err; } @@ -222,7 +242,7 @@ export function useAccounts() { async (accountId: string, newName: string) => { try { await invoke("rename_account", { accountId, newName }); - await loadAccounts(true); // Preserve usage data + await loadAccounts(true); } catch (err) { throw err; } @@ -245,10 +265,9 @@ export function useAccounts() { const startOAuthLogin = useCallback(async (accountName: string) => { try { - const info = await invoke<{ auth_url: string; callback_port: number }>( - "start_login", - { accountName } - ); + const info = await invoke<{ auth_url: string; callback_port: number }>("start_login", { + accountName, + }); return info; } catch (err) { throw err; @@ -345,12 +364,11 @@ export function useAccounts() { useEffect(() => { loadAccounts().then((accountList) => refreshUsage(accountList)); - - // Auto-refresh usage every 60 seconds (same as official Codex CLI) + const interval = setInterval(() => { refreshUsage().catch(() => {}); }, 60000); - + return () => clearInterval(interval); }, [loadAccounts, refreshUsage]); diff --git a/src/lib/usageCache.ts b/src/lib/usageCache.ts new file mode 100644 index 0000000..4bbcba7 --- /dev/null +++ b/src/lib/usageCache.ts @@ -0,0 +1,193 @@ +import type { AccountInfo, AccountWithUsage, CachedUsageInfo, UsageInfo } from "../types"; + +const BROWSER_USAGE_CACHE_STORAGE_KEY = "codex-switcher.usage-cache.v1"; + +export function extractCachedUsageEntriesFromAccounts( + accountList: AccountInfo[] +): CachedUsageInfo[] { + return accountList + .filter( + (account) => + !!account.cached_usage && + !!account.cached_usage_updated_at && + !account.cached_usage.error + ) + .map((account) => ({ + account_id: account.id, + usage: account.cached_usage!, + updated_at: account.cached_usage_updated_at!, + })); +} + +export function mergeCachedUsageEntries( + ...entryGroups: CachedUsageInfo[][] +): CachedUsageInfo[] { + return entryGroups.reduce( + (merged, entries) => + entries.reduce( + (nextEntries, entry) => upsertCachedUsageEntry(nextEntries, entry), + merged + ), + [] + ); +} + +export function mergeAccountsWithCachedUsage( + accountList: AccountInfo[], + previousAccounts: AccountWithUsage[], + cachedEntries: CachedUsageInfo[], + preserveExistingUsage: boolean +): AccountWithUsage[] { + const previousById = new Map(previousAccounts.map((account) => [account.id, account])); + const cachedById = new Map(cachedEntries.map((entry) => [entry.account_id, entry])); + + return accountList.map((account) => { + const previous = previousById.get(account.id); + const cached = cachedById.get(account.id); + const shouldReusePreviousUsage = + preserveExistingUsage && !!previous?.usage && !previous.usage.error; + + return { + ...account, + usage: shouldReusePreviousUsage ? previous?.usage : cached?.usage ?? previous?.usage, + usageLoading: preserveExistingUsage ? previous?.usageLoading ?? false : false, + usageUpdatedAt: preserveExistingUsage + ? previous?.usageUpdatedAt ?? cached?.updated_at ?? null + : cached?.updated_at ?? null, + }; + }); +} + +export function filterCachedUsageEntries( + entries: CachedUsageInfo[], + accountIds: ReadonlySet +): CachedUsageInfo[] { + return entries.filter((entry) => accountIds.has(entry.account_id)); +} + +export function markAccountsUsageLoading( + accounts: AccountWithUsage[], + accountIds: ReadonlySet +): AccountWithUsage[] { + return accounts.map((account) => + accountIds.has(account.id) ? { ...account, usageLoading: true } : account + ); +} + +export function applyUsageFetchResult( + accounts: AccountWithUsage[], + accountId: string, + usage: UsageInfo, + updatedAt: string +): AccountWithUsage[] { + return accounts.map((account) => + account.id === accountId + ? { + ...account, + usage, + usageLoading: false, + usageUpdatedAt: usage.error ? account.usageUpdatedAt ?? null : updatedAt, + } + : account + ); +} + +export function applyUsageFetchError( + accounts: AccountWithUsage[], + accountId: string, + usage: UsageInfo +): AccountWithUsage[] { + return accounts.map((account) => + account.id === accountId + ? { + ...account, + usage: account.usage && !account.usage.error ? account.usage : usage, + usageLoading: false, + } + : account + ); +} + +export function loadCachedUsageFromBrowser(): CachedUsageInfo[] { + if (typeof window === "undefined") { + return []; + } + + try { + const raw = window.localStorage.getItem(BROWSER_USAGE_CACHE_STORAGE_KEY); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as { entries?: unknown }; + if (!Array.isArray(parsed.entries)) { + return []; + } + + return parsed.entries.filter(isCachedUsageInfo); + } catch (error) { + console.error("Failed to read browser usage cache:", error); + return []; + } +} + +export function persistCachedUsageToBrowser(entries: CachedUsageInfo[]): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + BROWSER_USAGE_CACHE_STORAGE_KEY, + JSON.stringify({ + version: 1, + entries, + }) + ); + } catch (error) { + console.error("Failed to save browser usage cache:", error); + } +} + +export function saveCachedUsageToBrowser(entry: CachedUsageInfo): void { + const entries = upsertCachedUsageEntry(loadCachedUsageFromBrowser(), entry); + persistCachedUsageToBrowser(entries); +} + +function upsertCachedUsageEntry( + entries: CachedUsageInfo[], + entry: CachedUsageInfo +): CachedUsageInfo[] { + const merged = new Map(entries.map((current) => [current.account_id, current])); + const existing = merged.get(entry.account_id); + + if ( + !existing || + getCachedUsageTimestamp(entry.updated_at) >= getCachedUsageTimestamp(existing.updated_at) + ) { + merged.set(entry.account_id, entry); + } + + return Array.from(merged.values()).sort((left, right) => + left.account_id.localeCompare(right.account_id) + ); +} + +function getCachedUsageTimestamp(updatedAt: string): number { + const timestamp = Date.parse(updatedAt); + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function isCachedUsageInfo(value: unknown): value is CachedUsageInfo { + if (!value || typeof value !== "object") { + return false; + } + + const entry = value as Partial; + return ( + typeof entry.account_id === "string" && + typeof entry.updated_at === "string" && + !!entry.usage && + typeof entry.usage === "object" + ); +} diff --git a/src/types/index.ts b/src/types/index.ts index 6b2110b..90060c3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,8 @@ export interface AccountInfo { is_active: boolean; created_at: string; last_used_at: string | null; + cached_usage: UsageInfo | null; + cached_usage_updated_at: string | null; } export interface UsageInfo { @@ -33,9 +35,16 @@ export interface OAuthLoginInfo { callback_port: number; } +export interface CachedUsageInfo { + account_id: string; + usage: UsageInfo; + updated_at: string; +} + export interface AccountWithUsage extends AccountInfo { usage?: UsageInfo; usageLoading?: boolean; + usageUpdatedAt?: string | null; } export interface CodexProcessInfo {