diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..efc037a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f062a82..639debf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -526,7 +526,7 @@ dependencies = [ [[package]] name = "codex-switcher" -version = "0.1.5" +version = "0.1.6" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/src-tauri/src/auth/storage.rs b/src-tauri/src/auth/storage.rs index 966d3e7..003239d 100644 --- a/src-tauri/src/auth/storage.rs +++ b/src-tauri/src/auth/storage.rs @@ -4,9 +4,63 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; use crate::types::{AccountsStore, AuthData, StoredAccount}; +// ============================================================================ +// App Settings (persisted to ~/.codex-switcher/settings.json) +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + /// Whether to sync credentials to OpenCode's auth.json on switch + #[serde(default = "default_true")] + pub opencode_sync_enabled: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + opencode_sync_enabled: true, + } + } +} + +/// Get the path to settings.json +fn get_settings_file() -> Result { + Ok(get_config_dir()?.join("settings.json")) +} + +/// Load app settings from disk +pub fn load_settings() -> Result { + let path = get_settings_file()?; + if !path.exists() { + return Ok(AppSettings::default()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("Failed to read settings file: {}", path.display()))?; + let settings: AppSettings = serde_json::from_str(&content).unwrap_or_default(); + Ok(settings) +} + +/// Save app settings to disk +pub fn save_settings(settings: &AppSettings) -> Result<()> { + let path = get_settings_file()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let content = serde_json::to_string_pretty(settings) + .context("Failed to serialize settings")?; + fs::write(&path, content) + .with_context(|| format!("Failed to write settings file: {}", path.display()))?; + Ok(()) +} + /// Get the path to the codex-switcher config directory pub fn get_config_dir() -> Result { let home = dirs::home_dir().context("Could not find home directory")?; diff --git a/src-tauri/src/auth/switcher.rs b/src-tauri/src/auth/switcher.rs index 8fb4959..6a4309e 100644 --- a/src-tauri/src/auth/switcher.rs +++ b/src-tauri/src/auth/switcher.rs @@ -162,3 +162,106 @@ pub fn has_active_login() -> Result { None => Ok(false), } } + +// ============================================================================ +// OpenCode auth.json support +// ============================================================================ + +/// Get the path to OpenCode's auth.json file +fn get_opencode_auth_file() -> Result { + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + // OpenCode uses ~/.local/share on macOS and Windows. + // See: https://github.com/anomalyco/opencode/issues/5238 + let home = dirs::home_dir().context("Could not find home directory")?; + Ok(home + .join(".local") + .join("share") + .join("opencode") + .join("auth.json")) + } + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + // On Linux: $XDG_DATA_HOME/opencode/auth.json (or ~/.local/share/...) + let data_dir = dirs::data_dir().context("Could not find data directory")?; + Ok(data_dir.join("opencode").join("auth.json")) + } +} + +/// Switch account in OpenCode's auth.json. +/// +/// This function **merges** with the existing file content so that other +/// provider entries (e.g. `"groq"`) are preserved untouched. Only the +/// `"openai"` key is updated. +pub fn switch_to_opencode(account: &StoredAccount) -> Result<()> { + let auth_path = get_opencode_auth_file()?; + + // 1. Read existing file into a generic JSON map to preserve other keys + let mut auth_map: serde_json::Map = if auth_path.exists() { + let content = fs::read_to_string(&auth_path).with_context(|| { + format!("Failed to read OpenCode auth.json: {}", auth_path.display()) + })?; + serde_json::from_str(&content).unwrap_or_default() + } else { + serde_json::Map::new() + }; + + // 2. Build the new "openai" provider value from account credentials + let openai_value = match &account.auth_data { + AuthData::ChatGPT { + access_token, + refresh_token, + account_id, + .. + } => { + serde_json::json!({ + "type": "oauth", + "refresh": refresh_token, + "access": access_token, + "expires": opencode_expires_timestamp(), + "accountId": account_id.clone().unwrap_or_default(), + }) + } + AuthData::ApiKey { key } => { + serde_json::json!({ + "type": "api", + "key": key, + }) + } + }; + + // 3. Replace only the "openai" key, leaving everything else intact + auth_map.insert("openai".to_string(), openai_value); + + // 4. Write back the full merged content + if let Some(parent) = auth_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create OpenCode config dir: {}", parent.display()) + })?; + } + + let content = serde_json::to_string_pretty(&auth_map) + .context("Failed to serialize OpenCode auth.json")?; + + fs::write(&auth_path, &content).with_context(|| { + format!( + "Failed to write OpenCode auth.json: {}", + auth_path.display() + ) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(&auth_path, perms)?; + } + + Ok(()) +} + +/// Calculate an expiry timestamp ~7 days from now, in milliseconds (OpenCode format). +fn opencode_expires_timestamp() -> i64 { + (Utc::now() + chrono::Duration::days(7)).timestamp_millis() +} diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index e25b6f5..3f6da90 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -109,9 +109,16 @@ pub async fn add_account_from_file(path: String, name: String) -> Result Result<(), String> { +pub async fn switch_account(account_id: String) -> Result { let store = load_accounts().map_err(|e| e.to_string())?; // Find the account @@ -124,6 +131,20 @@ pub async fn switch_account(account_id: String) -> Result<(), String> { // Write to ~/.codex/auth.json switch_to_account(account).map_err(|e| e.to_string())?; + // Conditionally sync to OpenCode's auth.json (merge, not overwrite) + let settings = crate::auth::load_settings().unwrap_or_default(); + let opencode_synced = if settings.opencode_sync_enabled { + match crate::auth::switch_to_opencode(account) { + Ok(()) => true, + Err(e) => { + eprintln!("Warning: Failed to update OpenCode auth.json: {e}"); + false + } + } + } else { + false + }; + // Update the active account in our store set_active_account(&account_id).map_err(|e| e.to_string())?; @@ -150,7 +171,7 @@ pub async fn switch_account(account_id: String) -> Result<(), String> { } } - Ok(()) + Ok(SwitchResult { opencode_synced }) } /// Remove an account @@ -702,3 +723,18 @@ pub async fn get_masked_account_ids() -> Result, String> { pub async fn set_masked_account_ids(ids: Vec) -> Result<(), String> { crate::auth::storage::set_masked_account_ids(ids).map_err(|e| e.to_string()) } + +/// Get whether OpenCode sync is enabled +#[tauri::command] +pub async fn get_opencode_sync_enabled() -> Result { + let settings = crate::auth::load_settings().map_err(|e| e.to_string())?; + Ok(settings.opencode_sync_enabled) +} + +/// Set whether OpenCode sync is enabled +#[tauri::command] +pub async fn set_opencode_sync_enabled(enabled: bool) -> Result<(), String> { + let mut settings = crate::auth::load_settings().unwrap_or_default(); + settings.opencode_sync_enabled = enabled; + crate::auth::save_settings(&settings).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6e4ea19..f478faf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,9 +8,10 @@ pub mod types; use commands::{ add_account_from_file, cancel_login, check_codex_processes, complete_login, delete_account, export_accounts_full_encrypted_file, export_accounts_slim_text, get_active_account_info, - get_masked_account_ids, get_usage, import_accounts_full_encrypted_file, - import_accounts_slim_text, list_accounts, refresh_all_accounts_usage, rename_account, - set_masked_account_ids, start_login, switch_account, warmup_account, warmup_all_accounts, + get_masked_account_ids, get_opencode_sync_enabled, get_usage, + import_accounts_full_encrypted_file, import_accounts_slim_text, list_accounts, + refresh_all_accounts_usage, rename_account, set_masked_account_ids, + set_opencode_sync_enabled, start_login, switch_account, warmup_account, warmup_all_accounts, }; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -51,6 +52,9 @@ pub fn run() { warmup_all_accounts, // Process detection check_codex_processes, + // Settings + get_opencode_sync_enabled, + set_opencode_sync_enabled, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/App.css b/src/App.css index f1d8c73..bf34b63 100644 --- a/src/App.css +++ b/src/App.css @@ -1 +1,21 @@ @import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +:root { + color-scheme: light; +} + +:root.dark { + color-scheme: dark; +} + +body { + background: #f8fafc; + color: #0f172a; +} + +:root.dark body { + background: #020617; + color: #e2e8f0; +} diff --git a/src/App.tsx b/src/App.tsx index f0ffdaa..50cbcc6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,11 +1,20 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open, save } from "@tauri-apps/plugin-dialog"; +import { + getAccountNextResetAt, + getAccountRemainingPercent, + getAccountReserveBand, +} from "./accountAnalytics"; import { useAccounts } from "./hooks/useAccounts"; -import { AccountCard, AddAccountModal, UpdateChecker } from "./components"; +import { AccountCard, AccountsInsights, AddAccountModal, UpdateChecker } from "./components"; import type { CodexProcessInfo } from "./types"; import "./App.css"; +type ThemePreference = "system" | "light" | "dark"; + +const THEME_STORAGE_KEY = "codex-switcher-theme"; + function App() { const { accounts, @@ -28,6 +37,8 @@ function App() { cancelOAuthLogin, loadMaskedAccountIds, saveMaskedAccountIds, + loadOpencodeSyncEnabled, + saveOpencodeSyncEnabled, } = useAccounts(); const [isAddModalOpen, setIsAddModalOpen] = useState(false); @@ -60,6 +71,14 @@ function App() { const [isActionsMenuOpen, setIsActionsMenuOpen] = useState(false); const actionsMenuRef = useRef(null); + const [opencodeSyncEnabled, setOpencodeSyncEnabled] = useState(true); + const [switchSuccessToast, setSwitchSuccessToast] = useState<{ + message: string; + show: boolean; + }>({ message: "", show: false }); + const [themePreference, setThemePreference] = useState("system"); + const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("light"); + const toggleMask = (accountId: string) => { setMaskedAccounts((prev) => { const next = new Set(prev); @@ -101,14 +120,63 @@ function App() { return () => clearInterval(interval); }, [checkProcesses]); - // Load masked accounts from storage on mount + // Load masked accounts and OpenCode settings from storage on mount useEffect(() => { loadMaskedAccountIds().then((ids) => { if (ids.length > 0) { setMaskedAccounts(new Set(ids)); } }); - }, [loadMaskedAccountIds]); + loadOpencodeSyncEnabled().then((enabled) => { + setOpencodeSyncEnabled(enabled); + }); + }, [loadMaskedAccountIds, loadOpencodeSyncEnabled]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const saved = window.localStorage.getItem(THEME_STORAGE_KEY); + if (saved === "light" || saved === "dark" || saved === "system") { + setThemePreference(saved); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + const applyTheme = () => { + const nextTheme = + themePreference === "system" + ? mediaQuery.matches + ? "dark" + : "light" + : themePreference; + + setResolvedTheme(nextTheme); + document.documentElement.classList.toggle("dark", nextTheme === "dark"); + document.documentElement.style.colorScheme = nextTheme; + }; + + applyTheme(); + mediaQuery.addEventListener("change", applyTheme); + + return () => { + mediaQuery.removeEventListener("change", applyTheme); + }; + }, [themePreference]); + + const handleThemeChange = (nextTheme: ThemePreference) => { + setThemePreference(nextTheme); + window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + }; + + const toggleOpencodeSync = async () => { + const next = !opencodeSyncEnabled; + setOpencodeSyncEnabled(next); + await saveOpencodeSyncEnabled(next); + }; useEffect(() => { if (!isActionsMenuOpen) return; @@ -133,7 +201,14 @@ function App() { try { setSwitchingId(accountId); - await switchAccount(accountId); + const result = await switchAccount(accountId); + + const message = result.opencode_synced + ? "✓ Synced to Codex CLI + OpenCode" + : "✓ Synced to Codex CLI"; + + setSwitchSuccessToast({ message, show: true }); + setTimeout(() => setSwitchSuccessToast({ message: "", show: false }), 3000); } catch (err) { console.error("Failed to switch account:", err); } finally { @@ -344,197 +419,292 @@ function App() { const hasRunningProcesses = processInfo && processInfo.count > 0; const sortedOtherAccounts = useMemo(() => { - const getResetDeadline = (resetAt: number | null | undefined) => - resetAt ?? Number.POSITIVE_INFINITY; + const getResetDeadline = (account: (typeof otherAccounts)[number]) => + getAccountNextResetAt(account) ?? Number.POSITIVE_INFINITY; - const getRemainingPercent = (usedPercent: number | null | undefined) => { - if (usedPercent === null || usedPercent === undefined) { - return Number.NEGATIVE_INFINITY; - } - return Math.max(0, 100 - usedPercent); - }; + const getRemainingPercent = (account: (typeof otherAccounts)[number]) => + getAccountRemainingPercent(account) ?? Number.NEGATIVE_INFINITY; return [...otherAccounts].sort((a, b) => { if (otherAccountsSort === "deadline_asc" || otherAccountsSort === "deadline_desc") { - const deadlineDiff = - getResetDeadline(a.usage?.primary_resets_at) - - getResetDeadline(b.usage?.primary_resets_at); + const deadlineDiff = getResetDeadline(a) - getResetDeadline(b); if (deadlineDiff !== 0) { return otherAccountsSort === "deadline_asc" ? deadlineDiff : -deadlineDiff; } - const remainingDiff = - getRemainingPercent(b.usage?.primary_used_percent) - - getRemainingPercent(a.usage?.primary_used_percent); + const remainingDiff = getRemainingPercent(b) - getRemainingPercent(a); if (remainingDiff !== 0) return remainingDiff; return a.name.localeCompare(b.name); } - const remainingDiff = - getRemainingPercent(b.usage?.primary_used_percent) - - getRemainingPercent(a.usage?.primary_used_percent); + const remainingDiff = getRemainingPercent(b) - getRemainingPercent(a); if (otherAccountsSort === "remaining_desc" && remainingDiff !== 0) { return remainingDiff; } if (otherAccountsSort === "remaining_asc" && remainingDiff !== 0) { return -remainingDiff; } - const deadlineDiff = - getResetDeadline(a.usage?.primary_resets_at) - - getResetDeadline(b.usage?.primary_resets_at); + const deadlineDiff = getResetDeadline(a) - getResetDeadline(b); if (deadlineDiff !== 0) return deadlineDiff; return a.name.localeCompare(b.name); }); }, [otherAccounts, otherAccountsSort]); + const toolbarIconButtonClass = + "group inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white/92 text-slate-600 shadow-[0_1px_2px_rgba(15,23,42,0.05),0_10px_20px_rgba(15,23,42,0.04)] transition-all hover:-translate-y-0.5 hover:border-slate-300 hover:bg-white hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900/92 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-900 dark:hover:text-white"; + + const primaryToolbarButtonClass = + "group inline-flex h-9 items-center justify-center gap-2 rounded-xl bg-slate-900 px-3.5 text-sm font-semibold text-white shadow-[0_1px_2px_rgba(15,23,42,0.18),0_14px_24px_rgba(15,23,42,0.2)] transition-all hover:-translate-y-0.5 hover:bg-slate-800 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100"; + + const activeReserveBand = activeAccount ? getAccountReserveBand(activeAccount) : null; + const activeReserveState = + activeReserveBand === null + ? null + : { + unknown: { + label: "Unknown reserve", + className: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200", + }, + depleted: { + label: "Depleted reserve", + className: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200", + }, + critical: { + label: "Critical reserve", + className: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-500/12 dark:text-rose-300", + }, + watch: { + label: "Watch reserve", + className: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-500/12 dark:text-amber-300", + }, + healthy: { + label: "Healthy reserve", + className: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-500/12 dark:text-sky-300", + }, + ready: { + label: "Ready reserve", + className: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-500/12 dark:text-emerald-300", + }, + }[activeReserveBand]; + return ( -
+
{/* Header */} -
-
-
-
-
- C -
-
-
-

- Codex Switcher -

- {processInfo && ( - +
+
+
+
+
+
+ +
+
+
+
+
+ C +
+
+ +
+
+

+ Codex Switcher +

+ {processInfo && ( + - - {hasRunningProcesses - ? `${processInfo.count} Codex running` - : "0 Codex running"} + /> + + {hasRunningProcesses + ? `${processInfo.count} Codex running` + : "0 Codex running"} + - - )} + )} +
+ +

+ Multi-account manager for Codex CLI +

-

- Multi-account manager for Codex CLI -

-
-
- + + + + +
+ +
+ - - + -
- - {isActionsMenuOpen && ( -
- - - - - -
- )} + {isActionsMenuOpen && ( +
+ + +
+ +
+
+ Theme + + {resolvedTheme === "dark" ? "Dark" : "Light"} + +
+
+ {(["system", "light", "dark"] as ThemePreference[]).map((theme) => { + const active = themePreference === theme; + + return ( + + ); + })} +
+
+ + + +
+ + + + + +
+ )} +
@@ -542,113 +712,160 @@ function App() {
{/* Main Content */} -
+
{loading && accounts.length === 0 ? (
-
-

Loading accounts...

+
+

Loading accounts...

) : error ? (
Failed to load accounts
-

{error}

+

{error}

) : accounts.length === 0 ? (
-
+
👤
-

+

No accounts yet

-

+

Add your first Codex account to get started

) : ( -
- {/* Active Account */} - {activeAccount && ( +
+ {(activeAccount || otherAccounts.length > 0) && (
-

- Active Account -

- { }} - onWarmup={() => - handleWarmupAccount(activeAccount.id, activeAccount.name) - } - onDelete={() => handleDelete(activeAccount.id)} - onRefresh={() => refreshSingleUsage(activeAccount.id)} - onRename={(newName) => renameAccount(activeAccount.id, newName)} - switching={switchingId === activeAccount.id} - switchDisabled={hasRunningProcesses ?? false} - warmingUp={isWarmingAll || warmingUpId === activeAccount.id} - masked={maskedAccounts.has(activeAccount.id)} - onToggleMask={() => toggleMask(activeAccount.id)} - /> +
+
+ +
+
+

+ Account Overview +

+ {activeAccount && ( + + Live now + + )} + {activeReserveState && ( + + {activeReserveState.label} + + )} + {otherAccounts.length > 0 && ( + + {accounts.length} total / {otherAccounts.length} standby + + )} +
+
+ +
+ {otherAccounts.length > 0 && ( +
+ +
+ )} + + {activeAccount && ( +
+ {}} + onWarmup={() => + handleWarmupAccount(activeAccount.id, activeAccount.name) + } + onDelete={() => handleDelete(activeAccount.id)} + onRefresh={() => refreshSingleUsage(activeAccount.id)} + onRename={(newName) => renameAccount(activeAccount.id, newName)} + switching={switchingId === activeAccount.id} + switchDisabled={hasRunningProcesses ?? false} + warmingUp={isWarmingAll || warmingUpId === activeAccount.id} + masked={maskedAccounts.has(activeAccount.id)} + onToggleMask={() => toggleMask(activeAccount.id)} + embedded + /> +
+ )} +
+
)} {/* Other Accounts */} {otherAccounts.length > 0 && (
-
-

- Other Accounts ({otherAccounts.length}) -

-
- -
- - - +
+ + + + + + +
+
-
+
{sortedOtherAccounts.map((account) => ( + {/* Switch Success Toast */} + {switchSuccessToast.show && ( +
+ {switchSuccessToast.message} +
+ )} + {/* Refresh Success Toast */} {refreshSuccess && ( -
+
Usage refreshed successfully
)} @@ -684,8 +908,8 @@ function App() {
{warmupToast.message} @@ -694,7 +918,7 @@ function App() { {/* Delete Confirmation Toast */} {deleteConfirmId && ( -
+
Click delete again to confirm removal
)} @@ -712,25 +936,25 @@ function App() { {/* Import/Export Config Modal */} {isConfigModalOpen && (
-
-
-

+
+
+

{configModalMode === "slim_export" ? "Export Slim Text" : "Import Slim Text"}

{configModalMode === "slim_import" ? ( -

+

Existing accounts are kept. Only missing accounts are imported.

) : ( -

+

This slim string contains account secrets. Keep it private.

)} @@ -745,18 +969,18 @@ function App() { : "Export string will appear here" : "Paste config string here" } - className="w-full h-48 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400 font-mono" + className="w-full h-48 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-800 placeholder-gray-400 focus:outline-none focus:border-gray-400 focus:ring-1 focus:ring-gray-400 font-mono dark:bg-slate-950 dark:border-slate-800 dark:text-slate-100 dark:placeholder-slate-500 dark:focus:border-slate-700 dark:focus:ring-slate-700" /> {configModalError && ( -
+
{configModalError}
)}
-
+
@@ -773,7 +997,7 @@ function App() { } }} disabled={!configPayload || isExportingSlim} - className="px-4 py-2.5 text-sm font-medium rounded-lg bg-gray-900 hover:bg-gray-800 text-white transition-colors disabled:opacity-50" + className="px-4 py-2.5 text-sm font-medium rounded-lg bg-gray-900 hover:bg-gray-800 text-white transition-colors disabled:opacity-50 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-100" > {configCopied ? "Copied" : "Copy String"} @@ -781,7 +1005,7 @@ function App() { @@ -791,6 +1015,8 @@ function App() {
)} + +
); } diff --git a/src/accountAnalytics.ts b/src/accountAnalytics.ts new file mode 100644 index 0000000..8807576 --- /dev/null +++ b/src/accountAnalytics.ts @@ -0,0 +1,86 @@ +import type { AccountWithUsage, UsageInfo } from "./types"; + +export type ReserveBand = + | "unknown" + | "depleted" + | "critical" + | "watch" + | "healthy" + | "ready"; + +function normalizeRemainingPercent(usedPercent: number | null | undefined): number | null { + if (usedPercent === null || usedPercent === undefined || Number.isNaN(usedPercent)) { + return null; + } + + return Math.max(0, Math.min(100, 100 - usedPercent)); +} + +export function getUsageRemainingPercent(usage?: UsageInfo): number | null { + if (!usage || usage.error) { + return null; + } + + const remainingValues = [ + normalizeRemainingPercent(usage.primary_used_percent), + normalizeRemainingPercent(usage.secondary_used_percent), + ].filter((value): value is number => value !== null); + + if (remainingValues.length === 0) { + return null; + } + + return Math.min(...remainingValues); +} + +export function getUsageNextResetAt(usage?: UsageInfo): number | null { + if (!usage || usage.error) { + return null; + } + + const resetTimes = [usage.primary_resets_at, usage.secondary_resets_at].filter( + (value): value is number => value !== null && value > 0 + ); + + if (resetTimes.length === 0) { + return null; + } + + return Math.min(...resetTimes); +} + +export function getRemainingPercentBand(remainingPercent: number | null): ReserveBand { + if (remainingPercent === null) { + return "unknown"; + } + + if (remainingPercent <= 0) { + return "depleted"; + } + + if (remainingPercent < 25) { + return "critical"; + } + + if (remainingPercent < 50) { + return "watch"; + } + + if (remainingPercent < 70) { + return "healthy"; + } + + return "ready"; +} + +export function getAccountRemainingPercent(account: Pick): number | null { + return getUsageRemainingPercent(account.usage); +} + +export function getAccountNextResetAt(account: Pick): number | null { + return getUsageNextResetAt(account.usage); +} + +export function getAccountReserveBand(account: Pick): ReserveBand { + return getRemainingPercentBand(getAccountRemainingPercent(account)); +} diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 7e1483e..f153c6f 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -1,4 +1,9 @@ -import { useState, useRef, useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; +import { + getAccountNextResetAt, + getAccountRemainingPercent, + getRemainingPercentBand, +} from "../accountAnalytics"; import type { AccountWithUsage } from "../types"; import { UsageBar } from "./UsageBar"; @@ -14,6 +19,7 @@ interface AccountCardProps { warmingUp?: boolean; masked?: boolean; onToggleMask?: () => void; + embedded?: boolean; } function formatLastRefresh(date: Date | null): string { @@ -27,10 +33,96 @@ function formatLastRefresh(date: Date | null): string { return date.toLocaleDateString(); } +function formatTimeUntil(resetAt: number | null): string | null { + if (!resetAt) return null; + + const diff = resetAt - Math.floor(Date.now() / 1000); + if (diff <= 0) return "reset now"; + if (diff < 3600) return `resets in ${Math.ceil(diff / 60)}m`; + + if (diff < 86400) { + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + return `resets in ${minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`}`; + } + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + return `resets in ${hours > 0 ? `${days}d ${hours}h` : `${days}d`}`; +} + +function getReserveState(remainingPercent: number | null) { + const band = getRemainingPercentBand(remainingPercent); + + if (band === "unknown") { + return { + label: "Unknown", + chipClass: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200", + borderClass: "border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600", + accentClass: "bg-slate-300", + glowClass: "bg-slate-200/70", + metaClass: "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300", + }; + } + + if (band === "depleted") { + return { + label: "Depleted", + chipClass: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200", + borderClass: "border-slate-200 hover:border-slate-300 dark:border-slate-700 dark:hover:border-slate-600", + accentClass: "bg-slate-400", + glowClass: "bg-slate-300/70", + metaClass: "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800/80 dark:text-slate-300", + }; + } + + if (band === "critical") { + return { + label: "Critical", + chipClass: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-500/12 dark:text-rose-300", + borderClass: "border-rose-200 hover:border-rose-300 dark:border-rose-900/70 dark:hover:border-rose-800/80", + accentClass: "bg-rose-500", + glowClass: "bg-rose-300/70", + metaClass: "border-rose-100 bg-rose-50/70 text-rose-700 dark:border-rose-900/60 dark:bg-rose-500/10 dark:text-rose-300", + }; + } + + if (band === "watch") { + return { + label: "Watch", + chipClass: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-500/12 dark:text-amber-300", + borderClass: "border-amber-200 hover:border-amber-300 dark:border-amber-900/70 dark:hover:border-amber-800/80", + accentClass: "bg-amber-500", + glowClass: "bg-amber-300/70", + metaClass: "border-amber-100 bg-amber-50/70 text-amber-700 dark:border-amber-900/60 dark:bg-amber-500/10 dark:text-amber-300", + }; + } + + if (band === "healthy") { + return { + label: "Healthy", + chipClass: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-500/12 dark:text-sky-300", + borderClass: "border-sky-200 hover:border-sky-300 dark:border-sky-900/70 dark:hover:border-sky-800/80", + accentClass: "bg-sky-500", + glowClass: "bg-sky-300/70", + metaClass: "border-sky-100 bg-sky-50/70 text-sky-700 dark:border-sky-900/60 dark:bg-sky-500/10 dark:text-sky-300", + }; + } + + return { + label: "Ready", + chipClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-500/12 dark:text-emerald-300", + borderClass: "border-emerald-200 hover:border-emerald-300 dark:border-emerald-900/70 dark:hover:border-emerald-800/80", + accentClass: "bg-emerald-500", + glowClass: "bg-emerald-300/70", + metaClass: "border-emerald-100 bg-emerald-50/70 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-500/10 dark:text-emerald-300", + }; +} + function BlurredText({ children, blur }: { children: React.ReactNode; blur: boolean }) { return ( {children} @@ -50,6 +142,7 @@ export function AccountCard({ warmingUp, masked = false, onToggleMask, + embedded = false, }: AccountCardProps) { const [isRefreshing, setIsRefreshing] = useState(false); const [lastRefresh, setLastRefresh] = useState( @@ -66,6 +159,12 @@ export function AccountCard({ } }, [isEditing]); + useEffect(() => { + if (account.usage && !account.usage.error && !account.usageLoading) { + setLastRefresh(new Date()); + } + }, [account.usage, account.usageLoading]); + const handleRefresh = async () => { setIsRefreshing(true); try { @@ -92,7 +191,7 @@ export function AccountCard({ const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { - handleRename(); + void handleRename(); } else if (e.key === "Escape") { setEditName(account.name); setIsEditing(false); @@ -106,163 +205,253 @@ export function AccountCard({ : "Unknown"; const planColors: Record = { - pro: "bg-indigo-50 text-indigo-700 border-indigo-200", - plus: "bg-emerald-50 text-emerald-700 border-emerald-200", - team: "bg-blue-50 text-blue-700 border-blue-200", - enterprise: "bg-amber-50 text-amber-700 border-amber-200", - free: "bg-gray-50 text-gray-600 border-gray-200", - api_key: "bg-orange-50 text-orange-700 border-orange-200", + pro: "bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-900/70", + plus: "bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:border-emerald-900/70", + team: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:text-blue-300 dark:border-blue-900/70", + enterprise: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:border-amber-900/70", + free: "bg-gray-50 text-gray-600 border-gray-200 dark:bg-slate-800 dark:text-slate-200 dark:border-slate-700", + api_key: "bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-500/10 dark:text-orange-300 dark:border-orange-900/70", }; const planKey = account.plan_type?.toLowerCase() || "api_key"; const planColorClass = planColors[planKey] || planColors.free; - + const lastUpdatedLabel = formatLastRefresh(lastRefresh); + const remainingPercent = getAccountRemainingPercent(account); + const nextResetAt = getAccountNextResetAt(account); + const reserveState = getReserveState(remainingPercent); + const resetSummary = formatTimeUntil(nextResetAt); + const utilityButtonClass = + "flex h-9 w-9 items-center justify-center rounded-lg text-sm transition-colors"; + const containerClass = embedded + ? "relative" + : `relative isolate overflow-hidden rounded-[22px] border px-4 py-3.5 shadow-[0_1px_2px_rgba(15,23,42,0.05),0_12px_24px_rgba(15,23,42,0.05)] ring-1 ring-white/70 transform-gpu [contain:paint] transition-[transform,box-shadow,border-color,background-color] duration-200 hover:-translate-y-0.5 hover:shadow-[0_1px_2px_rgba(15,23,42,0.06),0_18px_30px_rgba(15,23,42,0.08)] dark:ring-slate-900/60 dark:shadow-[0_1px_2px_rgba(2,6,23,0.45),0_16px_26px_rgba(2,6,23,0.35)] dark:hover:shadow-[0_1px_2px_rgba(2,6,23,0.5),0_20px_34px_rgba(2,6,23,0.45)] ${ + account.is_active + ? "border-emerald-400 bg-white dark:border-emerald-700 dark:bg-slate-900" + : `bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.9))] ${reserveState.borderClass}` + }`; return ( -
- {/* Header */} -
-
-
+
+ {!account.is_active && ( + <> +
+
+ + )} + +
+
+
{account.is_active && ( - - + + )} + {isEditing ? ( setEditName(e.target.value)} - onBlur={handleRename} + onBlur={() => { + void handleRename(); + }} onKeyDown={handleKeyDown} - className="font-semibold text-gray-900 bg-gray-100 px-2 py-0.5 rounded border border-gray-300 focus:outline-none focus:border-gray-500 w-full" + className="w-full rounded border border-gray-300 bg-gray-100 px-2 py-0.5 font-semibold text-gray-900 focus:border-gray-500 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-slate-500" /> ) : (

{ - if (masked) return; setEditName(account.name); setIsEditing(true); }} - title={masked ? undefined : "Click to rename"} + title="Click to rename" > - {account.name} + {account.name}

)}
+ {account.email && ( -

+

{account.email}

)}
-
- {/* Eye toggle */} +
{onToggleMask && ( )} - {/* Plan badge */} - + + {planDisplay}
- {/* Usage */} -
+ {!account.is_active && ( +
+
+ + {reserveState.label} + + + {remainingPercent !== null && ( + + {Math.round(remainingPercent)}% reserve + + )} + + {resetSummary && ( + + {resetSummary} + + )} +
+ +

+ Refreshed {lastUpdatedLabel} +

+
+ )} + +
- {/* Last refresh time */} -
- Last updated: {formatLastRefresh(lastRefresh)} -
+ {account.is_active ? ( +
+
+ {embedded ? ( + Updated {lastUpdatedLabel} + ) : ( + <> + + + Current session + + {remainingPercent !== null && ( + + {reserveState.label} reserve + + )} + {resetSummary && {resetSummary}} + Updated {lastUpdatedLabel} + + )} +
- {/* Actions */} -
- {account.is_active ? ( - - ) : ( +
+ + + +
+
+ ) : ( +
- )} - - - -
+ +
+ + + +
+
+ )}
); } diff --git a/src/components/AccountsInsights.tsx b/src/components/AccountsInsights.tsx new file mode 100644 index 0000000..d4b7496 --- /dev/null +++ b/src/components/AccountsInsights.tsx @@ -0,0 +1,236 @@ +import { useMemo } from "react"; +import { getAccountNextResetAt, getAccountRemainingPercent } from "../accountAnalytics"; +import type { AccountWithUsage } from "../types"; + +interface AccountsInsightsProps { + accounts: AccountWithUsage[]; + embedded?: boolean; +} + +const BUCKETS = [ + { + key: "depleted", + label: "Depleted", + fillClass: "bg-slate-500", + dotClass: "bg-slate-500", + pillClass: "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200", + }, + { + key: "critical", + label: "Critical", + fillClass: "bg-rose-500", + dotClass: "bg-rose-500", + pillClass: "border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/70 dark:bg-rose-500/12 dark:text-rose-300", + }, + { + key: "watch", + label: "Watch", + fillClass: "bg-amber-500", + dotClass: "bg-amber-500", + pillClass: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-500/12 dark:text-amber-300", + }, + { + key: "healthy", + label: "Healthy", + fillClass: "bg-sky-500", + dotClass: "bg-sky-500", + pillClass: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/70 dark:bg-sky-500/12 dark:text-sky-300", + }, + { + key: "ready", + label: "Ready", + fillClass: "bg-emerald-500", + dotClass: "bg-emerald-500", + pillClass: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/70 dark:bg-emerald-500/12 dark:text-emerald-300", + }, +] as const; + +function formatTimeUntil(resetAt: number | null): string { + if (!resetAt) { + return "No reset data"; + } + + const diff = resetAt - Math.floor(Date.now() / 1000); + + if (diff <= 0) { + return "now"; + } + + if (diff < 3600) { + return `${Math.ceil(diff / 60)}m`; + } + + if (diff < 86400) { + const hours = Math.floor(diff / 3600); + const minutes = Math.floor((diff % 3600) / 60); + return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`; + } + + const days = Math.floor(diff / 86400); + const hours = Math.floor((diff % 86400) / 3600); + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; +} + +export function AccountsInsights({ accounts, embedded = false }: AccountsInsightsProps) { + const summary = useMemo(() => { + const trackedAccounts = accounts + .map((account) => ({ + remaining: getAccountRemainingPercent(account), + nextResetAt: getAccountNextResetAt(account), + })) + .filter( + (account): account is { remaining: number; nextResetAt: number | null } => + account.remaining !== null + ); + + const buckets = { + depleted: 0, + critical: 0, + watch: 0, + healthy: 0, + ready: 0, + }; + + for (const account of trackedAccounts) { + if (account.remaining <= 0) { + buckets.depleted += 1; + } else if (account.remaining < 25) { + buckets.critical += 1; + } else if (account.remaining < 50) { + buckets.watch += 1; + } else if (account.remaining < 70) { + buckets.healthy += 1; + } else { + buckets.ready += 1; + } + } + + const trackedCount = trackedAccounts.length; + const unknownCount = Math.max(0, accounts.length - trackedCount); + const averageRemaining = trackedCount + ? Math.round( + trackedAccounts.reduce((sum, account) => sum + account.remaining, 0) / trackedCount + ) + : null; + + const now = Math.floor(Date.now() / 1000); + const nextResetAt = + trackedAccounts + .map((account) => account.nextResetAt) + .filter((value): value is number => value !== null && value > now) + .sort((a, b) => a - b)[0] ?? null; + + return { + buckets, + trackedCount, + unknownCount, + averageRemaining, + lowReserveCount: buckets.critical + buckets.watch, + nextResetAt, + highReserveShare: trackedCount ? Math.round((buckets.ready / trackedCount) * 100) : 0, + standbySummary: + trackedCount === accounts.length + ? "all standby tracked" + : `${trackedCount} of ${accounts.length} standby tracked`, + }; + }, [accounts]); + + const containerClass = embedded + ? "relative rounded-[24px] border border-slate-200/80 bg-[linear-gradient(180deg,rgba(248,250,252,0.95),rgba(255,255,255,0.96))] p-4 shadow-[0_1px_2px_rgba(15,23,42,0.05),0_16px_32px_rgba(15,23,42,0.06)] ring-1 ring-white/70 dark:border-slate-800 dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.92))] dark:ring-slate-900/60 dark:shadow-[0_1px_2px_rgba(2,6,23,0.45),0_16px_32px_rgba(2,6,23,0.35)]" + : "relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.08),0_18px_40px_rgba(15,23,42,0.06)] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_1px_2px_rgba(2,6,23,0.45),0_20px_40px_rgba(2,6,23,0.4)]"; + + return ( +
+ {!embedded && ( +
+ )} + +
+
+
+

+ Bench Overview +

+ +
+
+ {summary.trackedCount === 0 ? "--" : summary.buckets.ready} +
+
+

ready now

+

{summary.standbySummary}

+
+
+
+ +
+ + Avg reserve {summary.averageRemaining !== null ? `${summary.averageRemaining}%` : "--"} + + + Next reset {formatTimeUntil(summary.nextResetAt)} + +
+
+ +
+
+
+

Availability mix

+

+ {summary.highReserveShare}% are strong switch candidates. +

+
+
+ + Low reserve {summary.lowReserveCount} + + + Depleted {summary.buckets.depleted} + + {summary.unknownCount > 0 && ( + + Awaiting data {summary.unknownCount} + + )} +
+
+ +
+ {summary.trackedCount === 0 ? ( +
+ ) : ( + BUCKETS.map((bucket) => { + const count = summary.buckets[bucket.key]; + + if (count === 0) { + return null; + } + + return ( +
+ ); + }) + )} +
+ +
+ {BUCKETS.map((bucket) => ( + + + {bucket.label} {summary.buckets[bucket.key]} + + ))} +
+
+
+
+ ); +} diff --git a/src/components/AddAccountModal.tsx b/src/components/AddAccountModal.tsx index 72f30e2..0cc0c41 100644 --- a/src/components/AddAccountModal.tsx +++ b/src/components/AddAccountModal.tsx @@ -118,39 +118,40 @@ export function AddAccountModal({ return (
-
+
{/* Header */} -
-

Add Account

+
+

Add Account

{/* Tabs */} -
+
{(["oauth", "import"] as Tab[]).map((tab) => ( - ))} @@ -160,7 +161,7 @@ export function AddAccountModal({
{/* Account Name (always shown) */}
-
{/* Tab-specific content */} {activeTab === "oauth" && ( -
+
{oauthPending ? (
-
-

Waiting for browser login...

-

+

+

Waiting for browser login...

+

Please open the following link in your browser to proceed:

-
+
@@ -222,21 +223,21 @@ export function AddAccountModal({ {activeTab === "import" && (
-