diff --git a/Package.swift b/Package.swift index 0d8b6bd..ef516d2 100644 --- a/Package.swift +++ b/Package.swift @@ -8,14 +8,12 @@ let package = Package( dependencies: [ .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"), .package(url: "https://github.com/sindresorhus/LaunchAtLogin-Modern.git", from: "1.1.0"), - .package(url: "https://github.com/groue/GRDB.swift.git", from: "7.5.0"), ], targets: [ .target( name: "ClaudeUsageCore", dependencies: [ "KeychainAccess", - .product(name: "GRDB", package: "GRDB.swift"), ], path: "Sources/ClaudeUsageCore" ), diff --git a/README.md b/README.md index 9efc706..cfda873 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,17 @@ A macOS menubar app that tracks your Claude (Code) usage in real time. ## Features -- **Menubar indicator** — circular ring gauge with percentage, always visible +- **Dual-percent menubar** — consumer plans show `5h% · 7d%` inline, each color-coded by threshold. Enterprise plans keep the ring gauge with monthly spend. - **Usage cards** — 5-hour, 7-day, Sonnet, and Opus utilization at a glance - **Enterprise support** — monthly credit spend with dollar/percentage toggle and burn rate projection -- **Usage history** — area chart with adaptive time range (auto/7d/30d), stored in SQLite - **Burn rate alerts** — notification when projected to hit limit within 60 minutes - **Threshold notifications** — configurable alerts at 80% and 95% usage -- **CSV export** — export full history for analysis - **OAuth sign-in** — authenticate via your browser with your Claude account - **Claude Code auto-detect** — picks up credentials from Claude Code if installed (file-based, no keychain prompts) - **Token refresh** — keeps your session alive without re-authenticating - **Auto-polling** — configurable interval (1/5/15/30 min) with exponential backoff +- **Response cache** — hydrates the menu bar instantly on launch and defers the first poll when the cached response is still fresh, so rapid restarts don't stack API calls - **Sleep/wake aware** — pauses polling on sleep, resumes on wake -- **Multi-language** — English, Dutch, German, French, Spanish, Portuguese (BR & PT) - **Light & dark mode** — system colors throughout ## Installation @@ -78,7 +76,7 @@ The app polls `https://api.anthropic.com/api/oauth/usage` on a configurable inte | Opus | 7-day usage for Opus models specifically | | Credits | Enterprise monthly spend vs limit | -Usage history is stored locally in `~/.config/claude-usage/history.db` (SQLite) with 30-day retention. +The last successful response is cached at `~/Library/Application Support/cc-stats/last-usage.json` so the menu bar can render immediately on relaunch without a network round-trip. Delete that file if you want to force a clean fetch. ## Configuration diff --git a/Resources/Info.plist b/Resources/Info.plist index ebf11fb..d5b50d1 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -23,12 +23,6 @@ CFBundleLocalizations en - nl - de - fr - es - pt-PT - pt-BR LSUIElement diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index dc10f37..eabc946 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,510 +1,56 @@ { - "sourceLanguage" : "en", - "strings" : { - "Loading..." : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Laden..." } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Cargando..." } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Chargement..." } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Laden..." } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Carregando..." } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "A carregar..." } } - } - }, - "5-Hour" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "5-Stunden" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "5 horas" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "5 heures" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "5-uur" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "5 horas" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "5 horas" } } - } - }, - "7-Day" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "7-Tage" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "7 d\u00edas" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "7 jours" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "7-dagen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "7 dias" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "7 dias" } } - } - }, - "Monthly Spend" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Monatliche Ausgaben" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Gastos mensuales" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "D\u00e9penses mensuelles" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Maandelijkse uitgaven" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Gastos mensais" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Despesas mensais" } } - } - }, - "Extra Usage" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Zusatzverbrauch" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Uso adicional" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Surplus" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Extra verbruik" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Uso adicional" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Uso adicional" } } - } - }, - "Poll interval" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Abrufintervall" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Intervalo de consulta" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Intervalle de mise \u00e0 jour" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ophaalinterval" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Intervalo de consulta" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Intervalo de consulta" } } - } - }, - "Launch at login" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Beim Anmelden starten" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Abrir al iniciar sesi\u00f3n" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Lancer \u00e0 la connexion" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Open bij inloggen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Iniciar ao fazer login" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Iniciar ao entrar" } } - } - }, - "Notify at 80%" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Bei 80% benachrichtigen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Avisar al 80%" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Notifier \u00e0 80%" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Melding bij 80%" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a 80%" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a 80%" } } - } - }, - "Notify at 95%" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Bei 95% benachrichtigen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Avisar al 95%" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Notifier \u00e0 95%" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Melding bij 95%" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a 95%" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a 95%" } } - } - }, - "Sign Out" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Abmelden" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Cerrar sesi\u00f3n" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "D\u00e9connexion" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Uitloggen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sair" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Terminar sess\u00e3o" } } - } - }, - "Quit" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Beenden" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Salir" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Quitter" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stop" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sair" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Sair" } } - } - }, - "Sign in to track your Claude usage" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Melde dich an, um dein Claude-Verbrauch zu sehen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Inicia sesi\u00f3n para ver tu uso de Claude" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connectez-vous pour suivre votre consommation Claude" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Log in om je Claude-verbruik te zien" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Entre para acompanhar seu uso do Claude" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Inicie sess\u00e3o para ver a sua utiliza\u00e7\u00e3o do Claude" } } - } - }, - "Sign in with Claude" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Mit Claude anmelden" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Iniciar sesi\u00f3n con Claude" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Se connecter avec Claude" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Inloggen met Claude" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Entrar com Claude" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Iniciar sess\u00e3o com Claude" } } - } - }, - "Paste the authorization code:" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Autorisierungscode einf\u00fcgen:" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Pega el c\u00f3digo de autorizaci\u00f3n:" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Collez le code d\u2019autorisation :" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Plak de autorisatiecode:" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Cole o c\u00f3digo de autoriza\u00e7\u00e3o:" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Cole o c\u00f3digo de autoriza\u00e7\u00e3o:" } } - } - }, - "Code" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Code" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "C\u00f3digo" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Code" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Code" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "C\u00f3digo" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "C\u00f3digo" } } - } - }, - "Submit" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Absenden" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Enviar" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Envoyer" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Versturen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Enviar" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Enviar" } } - } - }, - "History" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Verlauf" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Historial" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Historique" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Verloop" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Hist\u00f3rico" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Hist\u00f3rico" } } - } - }, - "Not enough data yet \u2014 check back after a few polls" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Noch nicht genug Daten \u2014 schau nach ein paar Abfragen nochmal" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "A\u00fan no hay suficientes datos \u2014 vuelve despu\u00e9s de algunas consultas" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas encore assez de donn\u00e9es \u2014 revenez apr\u00e8s quelques mises \u00e0 jour" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Nog niet genoeg data \u2014 kom terug na een paar updates" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Dados insuficientes \u2014 volte ap\u00f3s algumas consultas" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Dados insuficientes \u2014 volte ap\u00f3s algumas consultas" } } - } - }, - "Export CSV" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "CSV exportieren" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Exportar CSV" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Exporter CSV" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "CSV exporteren" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Exportar CSV" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Exportar CSV" } } - } - }, - "Refresh" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Aktualisieren" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Actualizar" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Actualiser" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Vernieuwen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Atualizar" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Atualizar" } } - } - }, - "Settings" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Einstellungen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Configuraci\u00f3n" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Param\u00e8tres" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Instellingen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Configura\u00e7\u00f5es" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Defini\u00e7\u00f5es" } } - } - }, - "Show as percentage" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Als Prozent anzeigen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar como porcentaje" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher en pourcentage" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon als percentage" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar como porcentagem" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar como percentagem" } } - } - }, - "Show as dollars" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Als Dollar anzeigen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar en d\u00f3lares" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Afficher en dollars" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Toon als dollars" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar em d\u00f3lares" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Mostrar em d\u00f3lares" } } - } - }, - "resetting..." : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "wird zur\u00fcckgesetzt..." } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "reiniciando..." } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "r\u00e9initialise..." } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "wordt gereset..." } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "reiniciando..." } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "a reiniciar..." } } - } - }, - "Auto" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Auto" } } - } - }, - "7d" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "7T" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "7d" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "7j" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "7d" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "7d" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "7d" } } - } - }, - "30d" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "30T" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "30d" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "30j" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "30d" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "30d" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "30d" } } - } - }, - "notification.threshold.title" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Claude-Verbrauchsmeldung" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Claude Usage Alert" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Alerta de uso de Claude" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Alerte Claude" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Claude-gebruiksmelding" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Alerta de uso do Claude" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Alerta de uso do Claude" } } - } - }, - "notification.threshold.body %lld %lld" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "5-Stunden-Verbrauch bei %1$lld%% (Grenzwert: %2$lld%%)" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "5-hour usage at %1$lld%% (threshold: %2$lld%%)" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Uso de 5 horas al %1$lld%% (l\u00edmite: %2$lld%%)" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Conso. 5h \u00e0 %1$lld%% (seuil : %2$lld%%)" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "5-uursverbruik op %1$lld%% (grens: %2$lld%%)" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Uso de 5 horas em %1$lld%% (limite: %2$lld%%)" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Uso de 5 horas em %1$lld%% (limite: %2$lld%%)" } } - } - }, - "notification.burnRate.title" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Claude-Verbrauchswarnung" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Claude Usage Warning" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Aviso de uso de Claude" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Avertissement Claude" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Claude-gebruikswaarschuwing" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aviso de uso do Claude" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Aviso de uso do Claude" } } - } - }, - "notification.burnRate.body %@ %lld" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Bei aktuellem Tempo wird das %1$@-Limit in ~%2$lld Minuten erreicht" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "At current pace, you'll hit the %1$@ limit in ~%2$lld minutes" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Al ritmo actual, alcanzar\u00e1s el l\u00edmite de %1$@ en ~%2$lld minutos" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Au rythme actuel, la limite %1$@ sera atteinte dans ~%2$lld min" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Op dit tempo bereik je de %1$@-limiet in ~%2$lld minuten" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "No ritmo atual, o limite de %1$@ ser\u00e1 atingido em ~%2$lld minutos" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Ao ritmo atual, o limite de %1$@ ser\u00e1 atingido em ~%2$lld minutos" } } - } - }, - "of %@ limit" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "von %@ Limit" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "de un l\u00edmite de %@" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "sur un plafond de %@" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "van %@ limiet" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "de um limite de %@" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "de um limite de %@" } } - } - }, - "Polling" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Abfrage" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Consulta" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Interrogation" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Ophalen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Consulta" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Consulta" } } - } - }, - "Appearance" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Darstellung" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Apariencia" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Apparence" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Weergave" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Apar\u00eancia" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Apar\u00eancia" } } - } - }, - "Icon only" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Nur Symbol" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Solo icono" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ic\u00f4ne seule" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Alleen icoon" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Apenas \u00edcone" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Apenas \u00edcone" } } - } - }, - "Color mode" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Farbmodus" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Modo de color" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Mode couleur" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kleurmodus" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Modo de cor" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Modo de cor" } } - } - }, - "Traffic light" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Ampel" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Sem\u00e1foro" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Feu tricolore" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Stoplicht" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Sem\u00e1foro" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Sem\u00e1foro" } } - } - }, - "Single color" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Einfarbig" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Color \u00fanico" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Couleur unique" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Enkele kleur" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Cor \u00fanica" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Cor \u00fanica" } } - } - }, - "Warning" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Warnung" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Attention" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Waarschuwing" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Aviso" } } - } - }, - "Critical" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kritisch" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Critique" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kritiek" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico" } } - } - }, - "Notifications" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Benachrichtigungen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Notificaciones" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Notifications" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Meldingen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Notifica\u00e7\u00f5es" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Notifica\u00e7\u00f5es" } } - } - }, - "Warn at" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Warnen bei" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Alerter \u00e0" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Melden bij" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Avisar a" } } - } - }, - "Critical at" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kritisch bei" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico a" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Critique \u00e0" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kritiek bij" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico a" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Cr\u00edtico a" } } - } - }, - "Ring color thresholds" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Farbschwellen des Rings" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Umbrales de color del anillo" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Seuils de couleur de l'anneau" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Kleurdrempels ring" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Limites de cor do anel" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Limites de cor do anel" } } - } - }, - "Notification thresholds" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Benachrichtigungsschwellen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Umbrales de notificaci\u00f3n" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Seuils de notification" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Meldingsdrempels" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Limites de notifica\u00e7\u00e3o" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Limites de notifica\u00e7\u00e3o" } } - } - }, - "Cancel" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Abbrechen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Cancelar" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Annuler" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Annuleren" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Cancelar" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Cancelar" } } - } - }, - "Paste from clipboard" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Aus Zwischenablage einf\u00fcgen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Pegar del portapapeles" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Coller depuis le presse-papiers" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Plakken uit klembord" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Colar da \u00e1rea de transfer\u00eancia" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Colar da \u00e1rea de transfer\u00eancia" } } - } - }, - "Retry Now" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Jetzt erneut versuchen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Reintentar" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "R\u00e9essayer" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Opnieuw proberen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Tentar novamente" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Tentar novamente" } } - } - }, - "Pin widget" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Widget anheften" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Fijar widget" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "\u00c9pingler le widget" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Widget vastpinnen" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Fixar widget" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Fixar widget" } } - } - }, - "Unpin widget" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Widget l\u00f6sen" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Desfijar widget" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "D\u00e9tacher le widget" } }, - "nl" : { "stringUnit" : { "state" : "translated", "value" : "Widget losmaken" } }, - "pt-BR" : { "stringUnit" : { "state" : "translated", "value" : "Desfixar widget" } }, - "pt-PT" : { "stringUnit" : { "state" : "translated", "value" : "Desfixar widget" } } - } - } + "sourceLanguage": "en", + "strings": { + "30d": {}, + "5-Hour": {}, + "7-Day": {}, + "7d": {}, + "Appearance": {}, + "Auto": {}, + "Cancel": {}, + "Code": {}, + "Color mode": {}, + "Critical": {}, + "Critical at": {}, + "Export CSV": {}, + "Extra Usage": {}, + "History": {}, + "Icon only": {}, + "Launch at login": {}, + "Loading...": {}, + "Monthly Spend": {}, + "Not enough data yet — check back after a few polls": {}, + "Notification thresholds": {}, + "Notifications": {}, + "Notify at 80%": {}, + "Notify at 95%": {}, + "Paste from clipboard": {}, + "Paste the authorization code:": {}, + "Pin widget": {}, + "Poll interval": {}, + "Polling": {}, + "Quit": {}, + "Refresh": {}, + "Retry Now": {}, + "Ring color thresholds": {}, + "Settings": {}, + "Show as dollars": {}, + "Show as percentage": {}, + "Sign Out": {}, + "Sign in to track your Claude usage": {}, + "Sign in with Claude": {}, + "Single color": {}, + "Submit": {}, + "Traffic light": {}, + "Unpin widget": {}, + "Warn at": {}, + "Warning": {}, + "notification.burnRate.body %@ %lld": {}, + "notification.burnRate.title": {}, + "notification.threshold.body %lld %lld": {}, + "notification.threshold.title": {}, + "of %@ limit": {}, + "resetting...": {} }, - "version" : "1.0" + "version": "1.0" } diff --git a/Sources/ClaudeUsageApp/App.swift b/Sources/ClaudeUsageApp/App.swift index 686e112..46e8c68 100644 --- a/Sources/ClaudeUsageApp/App.swift +++ b/Sources/ClaudeUsageApp/App.swift @@ -15,28 +15,28 @@ struct ClaudeUsageApp: App { MenuBarLabel(viewModel: appDelegate.viewModel) } .menuBarExtraStyle(.window) + + Settings { + SettingsRootView(viewModel: appDelegate.viewModel) + } } } @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private let notificationService = NotificationService() - private let historyStore = UsageHistoryStore() lazy var viewModel = UsageViewModel( credentialProvider: { try? OAuthCredential.fromKeychain() }, - notificationService: notificationService, - historyStore: historyStore + notificationService: notificationService ) lazy var widgetController = FloatingWidgetController(viewModel: viewModel) func applicationDidFinishLaunching(_ notification: Notification) { NSApplication.shared.setActivationPolicy(.accessory) - let dir = UsageHistoryStore.defaultDirectory() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) notificationService.requestPermission() viewModel.startPolling() diff --git a/Sources/ClaudeUsageApp/MenuBarLabel.swift b/Sources/ClaudeUsageApp/MenuBarLabel.swift index ff195bb..537358b 100644 --- a/Sources/ClaudeUsageApp/MenuBarLabel.swift +++ b/Sources/ClaudeUsageApp/MenuBarLabel.swift @@ -8,55 +8,82 @@ struct MenuBarLabel: View { @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 @AppStorage("colorMode") private var colorMode = "traffic_light" - private var pct: Double { + var body: some View { if viewModel.isEnterprise { - let extra = viewModel.usage?.extraUsage - guard let used = extra?.usedCredits, let limit = extra?.monthlyLimit, limit > 0 else { return 0 } - return (used / limit) * 100 + enterpriseLabel + } else { + consumerLabel } - return viewModel.usage?.fiveHour?.utilization ?? 0 } - private var displayText: String { - if viewModel.isEnterprise { - if showPercentage { - return "\(Int(pct))%" - } - guard let used = viewModel.usage?.extraUsage?.usedCreditsAmount else { return "--" } - if used < 1 { return "$0" } - return String(format: "$%.0f", used) + private var consumerLabel: some View { + let primary = MenuBarMetrics.consumerPrimaryPercent(from: viewModel.usage) + let secondary = MenuBarMetrics.consumerSecondaryPercent(from: viewModel.usage) + return HStack(spacing: 2) { + percentText(primary) + Text("·") + .foregroundStyle(.secondary) + percentText(secondary) } - return viewModel.menuBarText + .font(.system(size: 11, weight: .medium, design: .rounded)) + .monospacedDigit() } - private var ringColor: Color { - if colorMode == "single_color" { - // Teal gradient: lighter at low usage, darker at high - return Color(.systemTeal).opacity(0.4 + (pct / 100.0) * 0.6) + @ViewBuilder + private func percentText(_ value: Int?) -> some View { + if let value { + Text("\(value)%") + .foregroundStyle(color(for: Double(value))) + } else { + Text("--") + .foregroundStyle(.secondary) } - // Traffic light mode - switch pct { - case 0.. Color { + switch MenuBarMetrics.thresholdColor( + for: percent, + warning: warningThreshold, + critical: criticalThreshold, + colorMode: colorMode + ) { + case .ok: return Color(.systemGreen) + case .warning: return Color(.systemOrange) + case .critical: return Color(.systemRed) + case .tealScale(let opacity): return Color(.systemTeal).opacity(opacity) } } - var body: some View { + private var enterpriseLabel: some View { HStack(spacing: 4) { ZStack { Circle() .stroke(Color.primary.opacity(0.2), lineWidth: 2) Circle() - .trim(from: 0, to: min(pct / 100.0, 1.0)) - .stroke(ringColor, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .trim(from: 0, to: min(enterprisePct / 100.0, 1.0)) + .stroke(color(for: enterprisePct), style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) } .frame(width: 12, height: 12) - Text(displayText) + Text(enterpriseDisplayText) .font(.system(size: 11, weight: .medium, design: .rounded)) .monospacedDigit() } } + + private var enterprisePct: Double { + let extra = viewModel.usage?.extraUsage + guard let used = extra?.usedCredits, let limit = extra?.monthlyLimit, limit > 0 else { return 0 } + return (used / limit) * 100 + } + + private var enterpriseDisplayText: String { + if showPercentage { + return "\(Int(enterprisePct))%" + } + guard let used = viewModel.usage?.extraUsage?.usedCreditsAmount else { return "--" } + if used < 1 { return "$0" } + return String(format: "$%.0f", used) + } } diff --git a/Sources/ClaudeUsageApp/PopoverView.swift b/Sources/ClaudeUsageApp/PopoverView.swift index 67e87c9..5b3c56e 100644 --- a/Sources/ClaudeUsageApp/PopoverView.swift +++ b/Sources/ClaudeUsageApp/PopoverView.swift @@ -1,22 +1,27 @@ import SwiftUI import ClaudeUsageCore -import UniformTypeIdentifiers struct MenuContentView: View { @ObservedObject var viewModel: UsageViewModel var widgetController: FloatingWidgetController? @StateObject private var authFlow = AuthFlowState() - @State private var exportError: String? - @State private var showSettings = false + @Environment(\.openSettings) private var openSettings var body: some View { VStack(alignment: .leading, spacing: 8) { + if viewModel.usage != nil, viewModel.profile != nil { + HStack(spacing: 4) { + Spacer() + Text(viewModel.planTier.label) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.primary.opacity(0.06), in: Capsule()) + } + } if let usage = viewModel.usage { UsageDetailView(usage: usage, creditProjection: viewModel.creditProjection) - - if !viewModel.historyPoints.isEmpty { - UsageChartView(points: viewModel.historyPoints, isEnterprise: viewModel.isEnterprise) - } } else if authFlow.isAwaitingCode { OAuthCodeEntryView(authFlow: authFlow, viewModel: viewModel) } else if let error = viewModel.error { @@ -27,43 +32,30 @@ struct MenuContentView: View { ProgressView("Loading...") } - // Update banner - if let update = viewModel.availableUpdate { - HStack(spacing: 6) { - Circle() - .fill(.orange) - .frame(width: 6, height: 6) - Link("v\(update.version) available", destination: update.releaseURL) - .font(.caption) - .foregroundStyle(.orange) - Spacer() - Button { - viewModel.dismissUpdate() - } label: { - Image(systemName: "xmark") - .font(.system(size: 8, weight: .bold)) - } - .buttonStyle(.borderless) - .foregroundStyle(.tertiary) - } - } - // Footer - HStack(spacing: 12) { + HStack(spacing: 8) { if let lastUpdated = viewModel.lastUpdated { - Text("Updated \(lastUpdated, style: .relative) ago") + Text(lastUpdated, style: .relative) .foregroundStyle(.tertiary) .font(.system(size: 9)) + .monospacedDigit() } - Spacer() - if !viewModel.historyPoints.isEmpty { - Button { exportCSV() } label: { - Image(systemName: "square.and.arrow.up") + + if let update = viewModel.availableUpdate { + Link(destination: update.releaseURL) { + HStack(spacing: 3) { + Circle().fill(.orange).frame(width: 5, height: 5) + Text("v\(update.version)") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.orange) + } } - .buttonStyle(.borderless) - .foregroundStyle(.secondary) - .help("Export CSV") + .buttonStyle(.plain) + .help("Download update") } + + Spacer() + if viewModel.usage != nil { Button { Task { await viewModel.refresh() } } label: { Image(systemName: "arrow.clockwise") @@ -84,39 +76,18 @@ struct MenuContentView: View { } } Button { - showSettings.toggle() + NSApp.activate(ignoringOtherApps: true) + openSettings() } label: { Image(systemName: "gearshape") } .buttonStyle(.borderless) - .foregroundStyle(showSettings ? .primary : .secondary) + .foregroundStyle(.secondary) .help("Settings") } .font(.system(size: 12)) - - if showSettings { - Divider() - SettingsView(viewModel: viewModel) - } } .padding(12) - .frame(width: 300) - } - - private func exportCSV() { - let csv = CSVExporter.export(viewModel.historyPoints) - let panel = NSSavePanel() - panel.nameFieldStringValue = "claude-usage.csv" - panel.allowedContentTypes = [.commaSeparatedText] - panel.begin { response in - guard response == .OK, let url = panel.url else { return } - do { - try csv.write(to: url, atomically: true, encoding: .utf8) - } catch { - Task { @MainActor in - exportError = "Export failed: \(error.localizedDescription)" - } - } - } + .frame(width: 260) } } diff --git a/Sources/ClaudeUsageApp/SettingsView.swift b/Sources/ClaudeUsageApp/SettingsView.swift index 49d2afb..0fb0611 100644 --- a/Sources/ClaudeUsageApp/SettingsView.swift +++ b/Sources/ClaudeUsageApp/SettingsView.swift @@ -2,134 +2,152 @@ import SwiftUI import ClaudeUsageCore import LaunchAtLogin -struct SettingsView: View { - @ObservedObject var viewModel: UsageViewModel - @AppStorage("pollingMinutes") private var pollingMinutes = 5 - @AppStorage("warningThreshold") private var warningThreshold = 50.0 - @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 - @AppStorage("colorMode") private var colorMode = ColorMode.trafficLight +enum ColorMode: String, CaseIterable { + case trafficLight = "traffic_light" + case singleColor = "single_color" + + var label: LocalizedStringKey { + switch self { + case .trafficLight: return "Traffic light" + case .singleColor: return "Single color" + } + } +} - enum ColorMode: String, CaseIterable { - case trafficLight = "traffic_light" - case singleColor = "single_color" +struct SettingsRootView: View { + @ObservedObject var viewModel: UsageViewModel - var label: LocalizedStringKey { - switch self { - case .trafficLight: return "Traffic light" - case .singleColor: return "Single color" - } + var body: some View { + TabView { + GeneralTab(viewModel: viewModel) + .tabItem { Label("General", systemImage: "gearshape") } + ThresholdsTab() + .tabItem { Label("Thresholds", systemImage: "slider.horizontal.3") } + AccountTab(viewModel: viewModel) + .tabItem { Label("Account", systemImage: "person.crop.circle") } } + .frame(width: 420, height: 260) } +} + +// MARK: - General + +private struct GeneralTab: View { + @ObservedObject var viewModel: UsageViewModel + @AppStorage("pollingMinutes") private var pollingMinutes = 5 private let pollingOptions = [1, 5, 15, 30] var body: some View { - VStack(alignment: .leading, spacing: 8) { - if viewModel.usage != nil { - // Polling section - Text("Polling") - .font(.caption2) - .foregroundStyle(.tertiary) - - Picker("Poll interval", selection: $pollingMinutes) { - ForEach(pollingOptions, id: \.self) { min in - Text("\(min)m").tag(min) - } - } - .pickerStyle(.segmented) - .onChange(of: pollingMinutes) { _, newValue in - viewModel.updatePollingInterval(TimeInterval(newValue * 60)) + Form { + Picker("Poll interval", selection: $pollingMinutes) { + ForEach(pollingOptions, id: \.self) { min in + Text("\(min) min").tag(min) } + } + .pickerStyle(.segmented) + .onChange(of: pollingMinutes) { _, newValue in + viewModel.updatePollingInterval(TimeInterval(newValue * 60)) + } - LaunchAtLogin.Toggle("Launch at login") + LaunchAtLogin.Toggle("Launch at login") + } + .formStyle(.grouped) + } +} - Divider() +// MARK: - Thresholds - // Thresholds section - Text("Thresholds") - .font(.caption2) - .foregroundStyle(.tertiary) +private struct ThresholdsTab: View { + @AppStorage("warningThreshold") private var warningThreshold = 50.0 + @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 + @AppStorage("colorMode") private var colorMode = ColorMode.trafficLight - Picker("Color mode", selection: $colorMode) { - ForEach(ColorMode.allCases, id: \.self) { mode in - Text(mode.label).tag(mode) - } - } - .pickerStyle(.segmented) - - ThresholdSlider( - label: "Warning", - value: $warningThreshold, - range: 10...90, - color: .orange - ) - .onChange(of: warningThreshold) { _, newValue in - if newValue >= criticalThreshold { - criticalThreshold = min(newValue + 10, 100) - } + var body: some View { + Form { + Picker("Color mode", selection: $colorMode) { + ForEach(ColorMode.allCases, id: \.self) { mode in + Text(mode.label).tag(mode) } - - ThresholdSlider( - label: "Critical", - value: $criticalThreshold, - range: 20...100, - color: .red - ) - .onChange(of: criticalThreshold) { _, newValue in - if newValue <= warningThreshold { - warningThreshold = max(newValue - 10, 0) - } + } + .pickerStyle(.segmented) + + HStack { + Text("Warning") + Slider(value: $warningThreshold, in: 10...90, step: 5) + .tint(.orange) + Text("\(Int(warningThreshold))%") + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .onChange(of: warningThreshold) { _, newValue in + if newValue >= criticalThreshold { + criticalThreshold = min(newValue + 10, 100) } - - Divider() } - // Account section - ZStack { - Text("v\(Bundle.main.shortVersionString)") - .foregroundStyle(.tertiary) - .font(.caption2) - - HStack { - if viewModel.usage != nil { - Button("Sign Out") { - CredentialStore.delete() - viewModel.signOut() - } - .foregroundStyle(.red) - } - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - .foregroundStyle(.secondary) + HStack { + Text("Critical") + Slider(value: $criticalThreshold, in: 20...100, step: 5) + .tint(.red) + Text("\(Int(criticalThreshold))%") + .monospacedDigit() + .frame(width: 40, alignment: .trailing) + } + .onChange(of: criticalThreshold) { _, newValue in + if newValue <= warningThreshold { + warningThreshold = max(newValue - 10, 0) } } } - .font(.caption) - .padding(.top, 4) + .formStyle(.grouped) } } -// MARK: - Threshold Slider +// MARK: - Account -private struct ThresholdSlider: View { - let label: LocalizedStringKey - @Binding var value: Double - let range: ClosedRange - let color: Color +private struct AccountTab: View { + @ObservedObject var viewModel: UsageViewModel + + private var isSignedIn: Bool { viewModel.usage != nil } var body: some View { - HStack(spacing: 6) { - Text(label) - .frame(width: 52, alignment: .leading) - Slider(value: $value, in: range, step: 5) - .tint(color) - Text("\(Int(value))%") - .monospacedDigit() - .frame(width: 32, alignment: .trailing) + VStack(spacing: 16) { + VStack(spacing: 4) { + if isSignedIn, let identifier = viewModel.profile?.displayIdentifier { + Text(identifier) + .font(.headline) + Text(viewModel.planTier.label) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(isSignedIn ? "Signed in" : "Signed out") + .font(.headline) + } + Text("v\(Bundle.main.shortVersionString)") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 20) + + Spacer() + + HStack { + Button("Sign Out") { + CredentialStore.delete() + viewModel.signOut() + } + .disabled(!isSignedIn) + .foregroundStyle(isSignedIn ? .red : .secondary) + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .padding() } } } diff --git a/Sources/ClaudeUsageApp/UsageChartView.swift b/Sources/ClaudeUsageApp/UsageChartView.swift deleted file mode 100644 index b04e619..0000000 --- a/Sources/ClaudeUsageApp/UsageChartView.swift +++ /dev/null @@ -1,204 +0,0 @@ -import SwiftUI -import Charts -import ClaudeUsageCore - -struct UsageChartView: View { - let points: [UsageDataPoint] - let isEnterprise: Bool - - @State private var selectedRange: ChartRange = .auto - - enum ChartRange: String, CaseIterable { - case auto = "Auto" - case sevenDay = "7d" - case thirtyDay = "30d" - - var localizedLabel: LocalizedStringKey { - LocalizedStringKey(rawValue) - } - } - - /// How much history we actually have - private var dataSpan: TimeInterval { - guard let first = points.first, let last = points.last else { return 0 } - return last.timestamp.timeIntervalSince(first.timestamp) - } - - /// Available ranges based on actual data span - private var availableRanges: [ChartRange] { - var ranges: [ChartRange] = [.auto] - if dataSpan > 24 * 3600 { ranges.append(.sevenDay) } // > 1 day - if dataSpan > 7 * 24 * 3600 { ranges.append(.thirtyDay) } // > 7 days - return ranges - } - - private var effectiveInterval: TimeInterval { - switch selectedRange { - case .auto: return dataSpan + 60 // show all data + small margin - case .sevenDay: return 7 * 24 * 3600 - case .thirtyDay: return 30 * 24 * 3600 - } - } - - private var filteredPoints: [UsageDataPoint] { - let cutoff = Date().addingTimeInterval(-effectiveInterval) - let filtered = points.filter { $0.timestamp >= cutoff } - if filtered.count > 200 { - return Downsampler.downsample(filtered, targetCount: 200) - } - return filtered - } - - private var xAxisLabel: String { - let hours = dataSpan / 3600 - if hours < 1 { return "minutes" } - if hours < 24 { return "hours" } - return "days" - } - - private var xAxisFormat: Date.FormatStyle { - let hours = effectiveInterval / 3600 - if hours <= 24 { return .dateTime.hour(.defaultDigits(amPM: .abbreviated)) } - if hours <= 7 * 24 { return .dateTime.weekday(.abbreviated) } - return .dateTime.month(.abbreviated).day() - } - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - HStack { - Text("History") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - Spacer() - if availableRanges.count > 1 { - Picker("", selection: $selectedRange) { - ForEach(availableRanges, id: \.self) { range in - Text(range.localizedLabel).tag(range) - } - } - .pickerStyle(.segmented) - .frame(width: CGFloat(availableRanges.count) * 45) - } - } - - if filteredPoints.count >= 2 { - Chart { - ForEach(filteredPoints, id: \.timestamp) { point in - if !isEnterprise { - // 5-Hour area + line - AreaMark( - x: .value("Time", point.timestamp), - y: .value("Usage", point.fiveHourUtilization), - series: .value("Bucket", "5-Hour") - ) - .foregroundStyle( - .linearGradient( - colors: [Color(.systemTeal).opacity(0.25), Color(.systemTeal).opacity(0.03)], - startPoint: .bottom, endPoint: .top - ) - ) - .interpolationMethod(.catmullRom) - - LineMark( - x: .value("Time", point.timestamp), - y: .value("Usage", point.fiveHourUtilization), - series: .value("Bucket", "5-Hour") - ) - .foregroundStyle(Color(.systemTeal)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 1.5)) - - // 7-Day area + line - AreaMark( - x: .value("Time", point.timestamp), - y: .value("Usage", point.sevenDayUtilization), - series: .value("Bucket", "7-Day") - ) - .foregroundStyle( - .linearGradient( - colors: [Color(.systemOrange).opacity(0.25), Color(.systemOrange).opacity(0.03)], - startPoint: .bottom, endPoint: .top - ) - ) - .interpolationMethod(.catmullRom) - - LineMark( - x: .value("Time", point.timestamp), - y: .value("Usage", point.sevenDayUtilization), - series: .value("Bucket", "7-Day") - ) - .foregroundStyle(Color(.systemOrange)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 1.5)) - - } else if let extraPct = point.extraUsageUtilization { - AreaMark( - x: .value("Time", point.timestamp), - y: .value("Spend %", extraPct), - series: .value("Bucket", "Spend") - ) - .foregroundStyle( - .linearGradient( - colors: [Color(.systemOrange).opacity(0.25), Color(.systemOrange).opacity(0.03)], - startPoint: .bottom, endPoint: .top - ) - ) - .interpolationMethod(.catmullRom) - - LineMark( - x: .value("Time", point.timestamp), - y: .value("Spend %", extraPct), - series: .value("Bucket", "Spend") - ) - .foregroundStyle(Color(.systemOrange)) - .interpolationMethod(.catmullRom) - .lineStyle(StrokeStyle(lineWidth: 1.5)) - } - } - } - .chartYScale(domain: 0...100) - .chartYAxis { - AxisMarks(values: [0, 25, 50, 75, 100]) { value in - AxisGridLine() - AxisValueLabel { - if let v = value.as(Int.self) { - Text("\(v)%") - .font(.system(size: 8)) - } - } - } - } - .chartXAxis { - AxisMarks { value in - AxisGridLine() - AxisValueLabel { - if let date = value.as(Date.self) { - Text(date, format: xAxisFormat) - .font(.system(size: 8)) - } - } - } - } - .chartLegend(position: .bottom, spacing: 4) { - if !isEnterprise { - HStack(spacing: 12) { - Label("5-Hour", systemImage: "circle.fill") - .foregroundStyle(Color(.systemTeal)) - Label("7-Day", systemImage: "circle.fill") - .foregroundStyle(Color(.systemOrange)) - } - .font(.system(size: 8)) - } - } - .frame(height: 130) - } else { - Text("Not enough data yet — check back after a few polls") - .font(.caption2) - .foregroundStyle(.tertiary) - .frame(height: 40) - .frame(maxWidth: .infinity) - } - } - } -} diff --git a/Sources/ClaudeUsageApp/UsageViews.swift b/Sources/ClaudeUsageApp/UsageViews.swift index bc56173..b85869d 100644 --- a/Sources/ClaudeUsageApp/UsageViews.swift +++ b/Sources/ClaudeUsageApp/UsageViews.swift @@ -1,157 +1,181 @@ import SwiftUI import ClaudeUsageCore -// MARK: - Card Grid Layout +// MARK: - Hero + Row List Layout struct UsageDetailView: View { let usage: UsageResponse var creditProjection: CreditBurnProjection? - /// Enterprise mode: no 5h/7d limits, only credits private var isEnterprise: Bool { usage.fiveHour == nil && usage.sevenDay == nil && usage.extraUsage?.isEnabled == true } var body: some View { - VStack(alignment: .leading, spacing: 8) { - if isEnterprise { - // Enterprise: credits are the hero - if let extra = usage.extraUsage { - EnterpriseCreditCard(extra: extra, creditProjection: creditProjection) - } + VStack(alignment: .leading, spacing: 0) { + hero + subRows + } + } - // Per-model breakdown below - let hasModel = usage.sevenDaySonnet != nil || usage.sevenDayOpus != nil - if hasModel { - HStack(spacing: 8) { - if let bucket = usage.sevenDaySonnet { - UsageCard(label: "Sonnet", bucket: bucket, compact: true) - } - if let bucket = usage.sevenDayOpus { - UsageCard(label: "Opus", bucket: bucket, compact: true) - } - if usage.sevenDaySonnet == nil || usage.sevenDayOpus == nil { - Color.clear.frame(maxWidth: .infinity) - } - } - } - } else { - // Consumer: 5h/7d cards as primary - HStack(spacing: 8) { - if let bucket = usage.fiveHour { - UsageCard(label: "5-Hour", bucket: bucket) - } - if let bucket = usage.sevenDay { - UsageCard(label: "7-Day", bucket: bucket) - } - } + // MARK: Hero + + @ViewBuilder + private var hero: some View { + if isEnterprise, let extra = usage.extraUsage { + EnterpriseHeroCard(extra: extra, creditProjection: creditProjection) + } else if let bucket = usage.fiveHour { + BucketHeroCard(label: "5-Hour", bucket: bucket) + } else if let bucket = usage.sevenDay { + BucketHeroCard(label: "7-Day", bucket: bucket) + } else { + HeroCard(label: "No active limit", value: nil, caption: nil, pct: nil, color: .secondary) + } + } - let hasModel = usage.sevenDaySonnet != nil || usage.sevenDayOpus != nil - if hasModel { - HStack(spacing: 8) { - if let bucket = usage.sevenDaySonnet { - UsageCard(label: "Sonnet", bucket: bucket, compact: true) - } - if let bucket = usage.sevenDayOpus { - UsageCard(label: "Opus", bucket: bucket, compact: true) - } - if usage.sevenDaySonnet == nil || usage.sevenDayOpus == nil { - Color.clear.frame(maxWidth: .infinity) - } - } - } + // MARK: Sub-rows - // Consumer extra usage (if enabled alongside regular limits) - if let extra = usage.extraUsage, extra.isEnabled { - ExtraUsageCard(extra: extra, creditProjection: creditProjection) + @ViewBuilder + private var subRows: some View { + let rows = buildRows() + if !rows.isEmpty { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.offset) { index, row in + if index > 0 { Divider().opacity(0.2) } + row } } + .padding(.top, 4) } } -} -// MARK: - Usage Card (consumer 5h/7d/model) + private func buildRows() -> [UsageRow] { + var rows: [UsageRow] = [] -struct UsageCard: View { - let label: LocalizedStringKey - let bucket: UsageBucket - var compact: Bool = false - - @AppStorage("warningThreshold") private var warningThreshold = 50.0 - @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 + if !isEnterprise { + // Consumer: if 5-Hour was the hero, show 7-Day as a sub-row + if usage.fiveHour != nil, let sevenDay = usage.sevenDay { + rows.append(UsageRow.bucket(label: "7-Day", bucket: sevenDay)) + } + } - private var pct: Double { bucket.utilization / 100.0 } + if let sonnet = usage.sevenDaySonnet { + rows.append(UsageRow.bucket(label: "Sonnet", bucket: sonnet)) + } + if let opus = usage.sevenDayOpus { + rows.append(UsageRow.bucket(label: "Opus", bucket: opus)) + } - private var barColor: Color { - switch bucket.utilization { - case 0.. 0 { return "\(days)d \(hours)h" } - if hours > 0 { return "\(hours)h \(mins)m" } - return "\(mins)m" +private func thresholdColor(for pct: Double, warning: Double, critical: Double) -> Color { + switch pct { + case 0.. 0 + (extra.monthlyLimit ?? 0) > 0 } private var spendPct: Double { @@ -159,18 +183,6 @@ struct EnterpriseCreditCard: View { return used / limit } - @AppStorage("warningThreshold") private var warningThreshold = 50.0 - @AppStorage("criticalThreshold") private var criticalThreshold = 80.0 - - private var barColor: Color { - let pct = spendPct * 100 - switch pct { - case 0.. UsageRow { + let warning = UserDefaults.standard.object(forKey: "warningThreshold") as? Double ?? 50.0 + let critical = UserDefaults.standard.object(forKey: "criticalThreshold") as? Double ?? 80.0 + return UsageRow( + label: label, + pct: bucket.utilization / 100.0, + percentText: "\(Int(bucket.utilization))%", + meta: nil, + color: thresholdColor(for: bucket.utilization, warning: warning, critical: critical) + ) } + + static func extra(extra: ExtraUsage, creditProjection: CreditBurnProjection?) -> UsageRow { + let warning = UserDefaults.standard.object(forKey: "warningThreshold") as? Double ?? 50.0 + let critical = UserDefaults.standard.object(forKey: "criticalThreshold") as? Double ?? 80.0 + let utilization = extra.utilization ?? 0 + return UsageRow( + label: "Extra", + pct: utilization / 100.0, + percentText: "\(Int(utilization))%", + meta: nil, + color: thresholdColor(for: utilization, warning: warning, critical: critical) + ) + } +} + +// MARK: - Reset time helper + +private func resetCaption(for bucket: UsageBucket) -> String? { + guard let date = bucket.resetsAtDate else { return nil } + let remaining = date.timeIntervalSinceNow + if remaining <= 0 { return "resetting…" } + let totalMinutes = Int(remaining) / 60 + let days = totalMinutes / 1440 + let hours = (totalMinutes % 1440) / 60 + let mins = totalMinutes % 60 + if days > 0 { return "resets in \(days)d \(hours)h" } + if hours > 0 { return "resets in \(hours)h \(mins)m" } + return "resets in \(mins)m" } diff --git a/Sources/ClaudeUsageCore/AnthropicAPIClient.swift b/Sources/ClaudeUsageCore/AnthropicAPIClient.swift index ad57ea0..c4042ac 100644 --- a/Sources/ClaudeUsageCore/AnthropicAPIClient.swift +++ b/Sources/ClaudeUsageCore/AnthropicAPIClient.swift @@ -76,6 +76,42 @@ public final class AnthropicAPIClient: Sendable { } } + public func fetchProfile(accessToken: String) async throws -> ProfileResponse { + guard let url = URL(string: "\(baseURL)/api/oauth/profile") else { + throw APIError.invalidResponse + } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(ClaudeAPI.betaHeader, forHTTPHeaderField: "anthropic-beta") + + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw APIError.networkError(error) + } + + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + + switch http.statusCode { + case 200..<300: + return try JSONDecoder().decode(ProfileResponse.self, from: data) + case 401, 403: + throw APIError.unauthorized + case 429: + let retryAfter = http.value(forHTTPHeaderField: "Retry-After") + .flatMap(TimeInterval.init) + throw APIError.rateLimited(retryAfter: retryAfter) + default: + throw APIError.serverError(http.statusCode) + } + } + public func refreshToken(refreshToken: String) async throws -> TokenRefreshResponse { var request = URLRequest(url: ClaudeAPI.tokenURL) request.httpMethod = "POST" diff --git a/Sources/ClaudeUsageCore/CSVExporter.swift b/Sources/ClaudeUsageCore/CSVExporter.swift deleted file mode 100644 index 0fa1072..0000000 --- a/Sources/ClaudeUsageCore/CSVExporter.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -public enum CSVExporter { - public static func export(_ points: [UsageDataPoint]) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - - var lines = ["date,five_hour_pct,seven_day_pct,sonnet_pct,opus_pct,extra_usage_pct,extra_used_cents,extra_limit_cents"] - for point in points { - let date = formatter.string(from: point.timestamp) - let sonnet = point.sonnetUtilization.map { "\($0)" } ?? "" - let opus = point.opusUtilization.map { "\($0)" } ?? "" - let extraPct = point.extraUsageUtilization.map { "\($0)" } ?? "" - let extraUsed = point.extraUsedCents.map { "\($0)" } ?? "" - let extraLimit = point.extraLimitCents.map { "\($0)" } ?? "" - lines.append("\(date),\(point.fiveHourUtilization),\(point.sevenDayUtilization),\(sonnet),\(opus),\(extraPct),\(extraUsed),\(extraLimit)") - } - return lines.joined(separator: "\n") - } -} diff --git a/Sources/ClaudeUsageCore/Downsampler.swift b/Sources/ClaudeUsageCore/Downsampler.swift deleted file mode 100644 index 0f1f7b2..0000000 --- a/Sources/ClaudeUsageCore/Downsampler.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation - -public enum Downsampler { - /// Reduce data points to targetCount using bucket averaging (single-pass per bucket). - public static func downsample(_ points: [UsageDataPoint], targetCount: Int = 200) -> [UsageDataPoint] { - guard points.count > targetCount else { return points } - - let bucketSize = Double(points.count) / Double(targetCount) - var result: [UsageDataPoint] = [] - result.reserveCapacity(targetCount) - - for i in 0.. [UsageDataPoint] -} - -public final class HistoryService: HistoryServiceProtocol, Sendable { - private let store: UsageHistoryStore - - public init(store: UsageHistoryStore) { - self.store = store - } - - public func record(from usage: UsageResponse) async { - let point = UsageDataPoint( - timestamp: Date(), - fiveHourUtilization: usage.fiveHour?.utilization ?? 0, - sevenDayUtilization: usage.sevenDay?.utilization ?? 0, - sonnetUtilization: usage.sevenDaySonnet?.utilization, - opusUtilization: usage.sevenDayOpus?.utilization, - extraUsageUtilization: usage.extraUsage?.utilization, - extraUsedCents: usage.extraUsage?.usedCredits, - extraLimitCents: usage.extraUsage?.monthlyLimit - ) - try? await store.record(point) - try? await store.prune() - } - - public func loadPoints() async -> [UsageDataPoint] { - (try? await store.load()) ?? [] - } -} diff --git a/Sources/ClaudeUsageCore/MenuBarMetrics.swift b/Sources/ClaudeUsageCore/MenuBarMetrics.swift new file mode 100644 index 0000000..dd321d8 --- /dev/null +++ b/Sources/ClaudeUsageCore/MenuBarMetrics.swift @@ -0,0 +1,37 @@ +import Foundation + +public enum MenuBarColorSemantic: Equatable, Sendable { + case ok + case warning + case critical + case tealScale(opacity: Double) +} + +public enum MenuBarMetrics { + public static func consumerPrimaryPercent(from usage: UsageResponse?) -> Int? { + guard let utilization = usage?.fiveHour?.utilization else { return nil } + return Int(utilization) + } + + public static func consumerSecondaryPercent(from usage: UsageResponse?) -> Int? { + guard let utilization = usage?.sevenDay?.utilization else { return nil } + return Int(utilization) + } + + public static func thresholdColor( + for percent: Double, + warning: Double, + critical: Double, + colorMode: String + ) -> MenuBarColorSemantic { + if colorMode == "single_color" { + let opacity = 0.4 + (percent / 100.0) * 0.6 + return .tealScale(opacity: min(max(opacity, 0.4), 1.0)) + } + switch percent { + case .. NotificationEvaluation - func reset() -} - -public final class NotificationCoordinator: NotificationCoordinatorProtocol, Sendable { +@MainActor +public final class NotificationCoordinator { private let notificationService: NotificationService + private var points: [UsageDataPoint] = [] + + /// Upper bound on retained samples. At 5-minute polling this covers ~5 hours, + /// which is more than the burn-rate projection window needs. + private static let maxPoints = 64 public init(notificationService: NotificationService) { self.notificationService = notificationService } - public func evaluate(usage: UsageResponse, historyPoints: [UsageDataPoint]) -> NotificationEvaluation { - // Burn rate projection + notification + public func evaluate(usage: UsageResponse) -> NotificationEvaluation { + recordSample(from: usage) + if let fiveHourPct = usage.fiveHour?.utilization { let projection = BurnRateCalculator.project( - points: historyPoints, + points: points, currentUtilization: fiveHourPct ) if notificationService.shouldNotifyBurnRate(projection: projection, bucketLabel: "5-Hour") { @@ -32,7 +34,6 @@ public final class NotificationCoordinator: NotificationCoordinatorProtocol, Sen } } - // Enterprise credit projection var creditProjection: CreditBurnProjection? if let extra = usage.extraUsage, extra.isEnabled, let used = extra.usedCreditsAmount, let limit = extra.monthlyLimitAmount { @@ -42,7 +43,6 @@ public final class NotificationCoordinator: NotificationCoordinatorProtocol, Sen ) } - // Threshold notifications if let pct = usage.fiveHour?.utilization { notificationService.checkAndNotify(fiveHourPct: pct) } @@ -52,5 +52,23 @@ public final class NotificationCoordinator: NotificationCoordinatorProtocol, Sen public func reset() { notificationService.resetBurnRateNotification(bucketLabel: "5-Hour") + points.removeAll() + } + + private func recordSample(from usage: UsageResponse) { + let point = UsageDataPoint( + timestamp: Date(), + fiveHourUtilization: usage.fiveHour?.utilization ?? 0, + sevenDayUtilization: usage.sevenDay?.utilization ?? 0, + sonnetUtilization: usage.sevenDaySonnet?.utilization, + opusUtilization: usage.sevenDayOpus?.utilization, + extraUsageUtilization: usage.extraUsage?.utilization, + extraUsedCents: usage.extraUsage?.usedCredits, + extraLimitCents: usage.extraUsage?.monthlyLimit + ) + points.append(point) + if points.count > Self.maxPoints { + points.removeFirst(points.count - Self.maxPoints) + } } } diff --git a/Sources/ClaudeUsageCore/PollingService.swift b/Sources/ClaudeUsageCore/PollingService.swift index bb9512f..051aec2 100644 --- a/Sources/ClaudeUsageCore/PollingService.swift +++ b/Sources/ClaudeUsageCore/PollingService.swift @@ -1,7 +1,7 @@ import Foundation public protocol PollingServiceProtocol: AnyObject, Sendable { - @MainActor func start(fireImmediately: Bool) + @MainActor func start(fireImmediately: Bool, initialDelay: TimeInterval?) @MainActor func stop() @MainActor func updateInterval(_ seconds: TimeInterval) } @@ -20,11 +20,15 @@ public final class PollingService: PollingServiceProtocol { self.pollingInterval = pollingInterval } - public func start(fireImmediately: Bool = true) { + public func start(fireImmediately: Bool = true, initialDelay: TimeInterval? = nil) { stop() pollingTask = Task { if fireImmediately { await fetchAndDeliver() + } else if let initialDelay, initialDelay > 0 { + try? await Task.sleep(for: .seconds(initialDelay)) + guard !Task.isCancelled else { return } + await fetchAndDeliver() } while !Task.isCancelled { let interval = currentBackoff ?? pollingInterval diff --git a/Sources/ClaudeUsageCore/ProfileResponse.swift b/Sources/ClaudeUsageCore/ProfileResponse.swift new file mode 100644 index 0000000..12473e3 --- /dev/null +++ b/Sources/ClaudeUsageCore/ProfileResponse.swift @@ -0,0 +1,105 @@ +import Foundation + +public struct ProfileResponse: Codable, Sendable { + public let account: Account + public let organization: Organization? + + public struct Account: Codable, Sendable { + public let uuid: String? + public let emailAddress: String? + public let fullName: String? + public let displayName: String? + public let memberships: [Membership]? + + enum CodingKeys: String, CodingKey { + case uuid + case emailAddress = "email_address" + case fullName = "full_name" + case displayName = "display_name" + case memberships + } + } + + public struct Membership: Codable, Sendable { + public let role: String? + public let seatTier: String? + public let organization: Organization? + + enum CodingKeys: String, CodingKey { + case role + case seatTier = "seat_tier" + case organization + } + } + + public struct Organization: Codable, Sendable { + public let uuid: String? + public let name: String? + public let capabilities: [String]? + public let billingType: String? + public let rateLimitTier: String? + + enum CodingKeys: String, CodingKey { + case uuid + case name + case capabilities + case billingType = "billing_type" + case rateLimitTier = "rate_limit_tier" + } + } + + /// First membership organization, which is what the Claude app uses for plan display. + public var primaryOrganization: Organization? { + organization ?? account.memberships?.first?.organization + } + + /// Best identifier to show the signed-in user. + public var displayIdentifier: String? { + account.displayName ?? account.fullName ?? account.emailAddress + } +} + +/// Derived plan label shown in the menu bar popover and settings. +public enum PlanTier: Sendable, Equatable { + case enterprise + case max20x + case max5x + case pro + case free + case unknown + + public var label: String { + switch self { + case .enterprise: return "Enterprise" + case .max20x: return "Max 20×" + case .max5x: return "Max 5×" + case .pro: return "Pro" + case .free: return "Free" + case .unknown: return "Claude" + } + } + + public static func derive(profile: ProfileResponse?, isEnterprise: Bool) -> PlanTier { + if isEnterprise { return .enterprise } + guard let org = profile?.primaryOrganization else { return .unknown } + let tier = (org.rateLimitTier ?? "").lowercased() + let caps = org.capabilities ?? [] + + if tier.contains("20x") || caps.contains(where: { $0.contains("max_20x") }) { + return .max20x + } + if tier.contains("5x") || caps.contains(where: { $0.contains("max_5x") }) { + return .max5x + } + if caps.contains(where: { $0.contains("claude_max") }) { + return .max5x + } + if caps.contains(where: { $0.contains("claude_pro") }) { + return .pro + } + if org.billingType?.lowercased() == "free" { + return .free + } + return .unknown + } +} diff --git a/Sources/ClaudeUsageCore/TokenRefreshingClient.swift b/Sources/ClaudeUsageCore/TokenRefreshingClient.swift index 5ef9e09..a5ab089 100644 --- a/Sources/ClaudeUsageCore/TokenRefreshingClient.swift +++ b/Sources/ClaudeUsageCore/TokenRefreshingClient.swift @@ -44,6 +44,25 @@ public final class TokenRefreshingClient: Sendable { } } + public func fetchProfile() async throws -> ProfileResponse { + var credential = try resolveCredential() + if credential.isExpired { + if let refreshed = await refreshOwnToken(credential) { + credential = refreshed + } else { + credential = try resolveCredential() + } + } + do { + return try await apiClient.fetchProfile(accessToken: credential.accessToken) + } catch APIError.unauthorized { + if let refreshed = await refreshOwnToken(credential) { + return try await apiClient.fetchProfile(accessToken: refreshed.accessToken) + } + throw TokenRefreshingClientError.unauthorized + } + } + private func resolveCredential() throws -> OAuthCredential { guard let credential = credentialProvider() else { throw TokenRefreshingClientError.noCredential diff --git a/Sources/ClaudeUsageCore/UsageDataPoint.swift b/Sources/ClaudeUsageCore/UsageDataPoint.swift new file mode 100644 index 0000000..b805249 --- /dev/null +++ b/Sources/ClaudeUsageCore/UsageDataPoint.swift @@ -0,0 +1,34 @@ +import Foundation + +/// In-memory sample of a usage response, used by the burn-rate ring buffer +/// inside NotificationCoordinator. +public struct UsageDataPoint: Sendable { + public let timestamp: Date + public let fiveHourUtilization: Double + public let sevenDayUtilization: Double + public let sonnetUtilization: Double? + public let opusUtilization: Double? + public let extraUsageUtilization: Double? + public let extraUsedCents: Double? + public let extraLimitCents: Double? + + public init( + timestamp: Date, + fiveHourUtilization: Double, + sevenDayUtilization: Double, + sonnetUtilization: Double? = nil, + opusUtilization: Double? = nil, + extraUsageUtilization: Double? = nil, + extraUsedCents: Double? = nil, + extraLimitCents: Double? = nil + ) { + self.timestamp = timestamp + self.fiveHourUtilization = fiveHourUtilization + self.sevenDayUtilization = sevenDayUtilization + self.sonnetUtilization = sonnetUtilization + self.opusUtilization = opusUtilization + self.extraUsageUtilization = extraUsageUtilization + self.extraUsedCents = extraUsedCents + self.extraLimitCents = extraLimitCents + } +} diff --git a/Sources/ClaudeUsageCore/UsageHistoryStore.swift b/Sources/ClaudeUsageCore/UsageHistoryStore.swift deleted file mode 100644 index 6f3590a..0000000 --- a/Sources/ClaudeUsageCore/UsageHistoryStore.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Foundation -import GRDB - -// MARK: - Data Model - -public struct UsageDataPoint: Codable, Sendable, FetchableRecord, PersistableRecord { - public static let databaseTableName = "usage_points" - - public let timestamp: Date - public let fiveHourUtilization: Double - public let sevenDayUtilization: Double - public let sonnetUtilization: Double? - public let opusUtilization: Double? - public let extraUsageUtilization: Double? - public let extraUsedCents: Double? - public let extraLimitCents: Double? - - public init( - timestamp: Date, - fiveHourUtilization: Double, - sevenDayUtilization: Double, - sonnetUtilization: Double? = nil, - opusUtilization: Double? = nil, - extraUsageUtilization: Double? = nil, - extraUsedCents: Double? = nil, - extraLimitCents: Double? = nil - ) { - self.timestamp = timestamp - self.fiveHourUtilization = fiveHourUtilization - self.sevenDayUtilization = sevenDayUtilization - self.sonnetUtilization = sonnetUtilization - self.opusUtilization = opusUtilization - self.extraUsageUtilization = extraUsageUtilization - self.extraUsedCents = extraUsedCents - self.extraLimitCents = extraLimitCents - } - - public enum Columns { - public static let timestamp = Column(CodingKeys.timestamp) - } -} - -// MARK: - Store - -public actor UsageHistoryStore { - private let dbQueue: DatabaseQueue - public static let retentionInterval: TimeInterval = 30 * 24 * 3600 - - public init(directory: URL? = nil) { - let dir = directory ?? Self.defaultDirectory() - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let dbPath = dir.appendingPathComponent("history.db").path - - let queue: DatabaseQueue - if let fileQueue = try? DatabaseQueue(path: dbPath) { - queue = fileQueue - } else { - queue = try! DatabaseQueue() - } - self.dbQueue = queue - try? Self.makeMigrator().migrate(queue) - } - - public static func defaultDirectory() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".config/claude-usage") - } - - private static func makeMigrator() -> DatabaseMigrator { - var migrator = DatabaseMigrator() - migrator.registerMigration("v1") { db in - try db.create(table: "usage_points", ifNotExists: true) { t in - t.column("timestamp", .datetime).notNull() - t.column("fiveHourUtilization", .double).notNull() - t.column("sevenDayUtilization", .double).notNull() - t.column("sonnetUtilization", .double) - t.column("opusUtilization", .double) - t.column("extraUsageUtilization", .double) - t.column("extraUsedCents", .double) - t.column("extraLimitCents", .double) - } - try db.create(indexOn: "usage_points", columns: ["timestamp"]) - } - return migrator - } - - public func record(_ point: UsageDataPoint) throws { - try dbQueue.write { db in - try point.insert(db) - } - } - - public func load() throws -> [UsageDataPoint] { - try dbQueue.read { db in - try UsageDataPoint - .order(UsageDataPoint.Columns.timestamp.asc) - .fetchAll(db) - } - } - - public func load(since interval: TimeInterval) throws -> [UsageDataPoint] { - let cutoff = Date().addingTimeInterval(-interval) - return try dbQueue.read { db in - try UsageDataPoint - .filter(UsageDataPoint.Columns.timestamp >= cutoff) - .order(UsageDataPoint.Columns.timestamp.asc) - .fetchAll(db) - } - } - - public func prune(olderThan interval: TimeInterval = retentionInterval) throws { - let cutoff = Date().addingTimeInterval(-interval) - try dbQueue.write { db in - _ = try UsageDataPoint - .filter(UsageDataPoint.Columns.timestamp < cutoff) - .deleteAll(db) - } - } -} diff --git a/Sources/ClaudeUsageCore/UsageResponseCache.swift b/Sources/ClaudeUsageCore/UsageResponseCache.swift new file mode 100644 index 0000000..a5cbdb5 --- /dev/null +++ b/Sources/ClaudeUsageCore/UsageResponseCache.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Persistent best-effort snapshot of the last successful usage fetch, used +/// to hydrate the UI on launch and to defer the first automatic poll when +/// the cached response is still fresh. Corrupt or missing files fall back +/// to the immediate-fetch behavior. +public struct CachedUsageResponse: Codable, Sendable { + public let fetchedAt: Date + public let response: UsageResponse + + public init(fetchedAt: Date, response: UsageResponse) { + self.fetchedAt = fetchedAt + self.response = response + } +} + +public final class UsageResponseCache: Sendable { + private let fileURL: URL + + public init(fileURL: URL = UsageResponseCache.defaultURL()) { + self.fileURL = fileURL + } + + public static func defaultURL() -> URL { + let base: URL + do { + base = try FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + } catch { + base = FileManager.default.temporaryDirectory + } + let dir = base.appendingPathComponent("cc-stats", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("last-usage.json") + } + + public func load() -> CachedUsageResponse? { + guard let data = try? Data(contentsOf: fileURL) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(CachedUsageResponse.self, from: data) + } + + public func save(_ cached: CachedUsageResponse) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(cached) else { return } + try? data.write(to: fileURL, options: .atomic) + } + + public func clear() { + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Sources/ClaudeUsageCore/UsageViewModel.swift b/Sources/ClaudeUsageCore/UsageViewModel.swift index 6d55de7..88e7e0c 100644 --- a/Sources/ClaudeUsageCore/UsageViewModel.swift +++ b/Sources/ClaudeUsageCore/UsageViewModel.swift @@ -8,15 +8,21 @@ public final class UsageViewModel: ObservableObject { @Published public var error: UsageError? @Published public var lastUpdated: Date? @Published public private(set) var currentBackoff: TimeInterval? - @Published public var historyPoints: [UsageDataPoint] = [] @Published public var creditProjection: CreditBurnProjection? @Published public var availableUpdate: UpdateInfo? + @Published public var profile: ProfileResponse? + + public var planTier: PlanTier { + PlanTier.derive(profile: profile, isEnterprise: isEnterprise) + } private let client: TokenRefreshingClient private let pollingService: PollingService - private let historyService: HistoryServiceProtocol? - private let notificationCoordinator: NotificationCoordinatorProtocol? + private let notificationCoordinator: NotificationCoordinator? private let updateService: UpdateService + private let cache: UsageResponseCache? + private let pollingInterval: TimeInterval + private var cachedFetchedAt: Date? public var isEnterprise: Bool { usage?.fiveHour == nil && usage?.sevenDay == nil @@ -38,15 +44,22 @@ public final class UsageViewModel: ObservableObject { credentialProvider: @escaping CredentialProvider, pollingInterval: TimeInterval = 300, notificationService: NotificationService? = nil, - historyStore: UsageHistoryStore? = nil, - updateChecker: UpdateChecker = UpdateChecker() + updateChecker: UpdateChecker = UpdateChecker(), + cache: UsageResponseCache? = UsageResponseCache() ) { let client = TokenRefreshingClient(apiClient: apiClient, credentialProvider: credentialProvider) self.client = client self.pollingService = PollingService(client: client, pollingInterval: pollingInterval) - self.historyService = historyStore.map { HistoryService(store: $0) } self.notificationCoordinator = notificationService.map { NotificationCoordinator(notificationService: $0) } self.updateService = UpdateService(checker: updateChecker) + self.cache = cache + self.pollingInterval = pollingInterval + + if let cached = cache?.load() { + self.usage = cached.response + self.lastUpdated = cached.fetchedAt + self.cachedFetchedAt = cached.fetchedAt + } pollingService.onResult = { @MainActor [weak self] result in await self?.handleFetchResult(result) @@ -57,7 +70,17 @@ public final class UsageViewModel: ObservableObject { } public func startPolling() { - pollingService.start() + if let fetchedAt = cachedFetchedAt { + let age = Date().timeIntervalSince(fetchedAt) + cachedFetchedAt = nil + if age < pollingInterval { + pollingService.start(fireImmediately: false, initialDelay: pollingInterval - age) + } else { + pollingService.start() + } + } else { + pollingService.start() + } updateService.start() } @@ -83,8 +106,18 @@ public final class UsageViewModel: ObservableObject { error = .noCredential lastUpdated = nil currentBackoff = nil - historyPoints = [] creditProjection = nil + profile = nil + cachedFetchedAt = nil + cache?.clear() + notificationCoordinator?.reset() + } + + private func refreshProfileIfNeeded() async { + if profile != nil { return } + if let fetched = try? await client.fetchProfile() { + profile = fetched + } } /// Single manual refresh — used by UI "refresh" button and tests. @@ -106,34 +139,25 @@ public final class UsageViewModel: ObservableObject { case .success(let fetchResult): usage = fetchResult.usage error = nil - lastUpdated = Date() + let now = Date() + lastUpdated = now currentBackoff = nil + cachedFetchedAt = nil pollingService.resetBackoff() + cache?.save(CachedUsageResponse(fetchedAt: now, response: fetchResult.usage)) - // Record history - if let historyService { - await historyService.record(from: fetchResult.usage) - historyPoints = await historyService.loadPoints() - } + await refreshProfileIfNeeded() - // Notifications + credit projection if let notificationCoordinator { - let evaluation = notificationCoordinator.evaluate( - usage: fetchResult.usage, - historyPoints: historyPoints + creditProjection = notificationCoordinator.evaluate(usage: fetchResult.usage).creditProjection + } else if let extra = fetchResult.usage.extraUsage, extra.isEnabled, + let used = extra.usedCreditsAmount, let limit = extra.monthlyLimitAmount { + creditProjection = BurnRateCalculator.projectCredits( + usedDollars: used, + limitDollars: limit ) - creditProjection = evaluation.creditProjection } else { - // Compute credit projection even without notification service - if let extra = fetchResult.usage.extraUsage, extra.isEnabled, - let used = extra.usedCreditsAmount, let limit = extra.monthlyLimitAmount { - creditProjection = BurnRateCalculator.projectCredits( - usedDollars: used, - limitDollars: limit - ) - } else { - creditProjection = nil - } + creditProjection = nil } case .failure(let err): diff --git a/Tests/ClaudeUsageTests/CSVExporterTests.swift b/Tests/ClaudeUsageTests/CSVExporterTests.swift deleted file mode 100644 index 006fbde..0000000 --- a/Tests/ClaudeUsageTests/CSVExporterTests.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Testing -import Foundation -@testable import ClaudeUsageCore - -@Suite("CSVExporter") -struct CSVExporterTests { - - @Test("Exports header and rows") - func headerAndRows() { - let points = [ - UsageDataPoint( - timestamp: ISO8601DateFormatter().date(from: "2026-03-22T10:00:00Z")!, - fiveHourUtilization: 42.0, - sevenDayUtilization: 17.0 - ), - UsageDataPoint( - timestamp: ISO8601DateFormatter().date(from: "2026-03-22T10:05:00Z")!, - fiveHourUtilization: 43.0, - sevenDayUtilization: 17.5 - ), - ] - - let csv = CSVExporter.export(points) - let lines = csv.split(separator: "\n") - - #expect(lines.count == 3) - #expect(lines[0].hasPrefix("date,five_hour_pct,seven_day_pct")) - #expect(lines[1].contains("2026-03-22T10:00:00")) - #expect(lines[1].contains("42.0")) - #expect(lines[1].contains("17.0")) - } - - @Test("Empty points exports header only") - func emptyExportsHeader() { - let csv = CSVExporter.export([]) - let lines = csv.split(separator: "\n") - #expect(lines.count == 1) - #expect(lines[0].hasPrefix("date,five_hour_pct,seven_day_pct")) - } - - @Test("Dates are ISO8601 UTC") - func datesISO8601() { - let points = [ - UsageDataPoint( - timestamp: ISO8601DateFormatter().date(from: "2026-03-22T10:00:00Z")!, - fiveHourUtilization: 5.0, - sevenDayUtilization: 3.0 - ), - ] - - let csv = CSVExporter.export(points) - #expect(csv.contains("2026-03-22T10:00:00Z")) - } -} diff --git a/Tests/ClaudeUsageTests/DownsamplerTests.swift b/Tests/ClaudeUsageTests/DownsamplerTests.swift deleted file mode 100644 index 7c7f105..0000000 --- a/Tests/ClaudeUsageTests/DownsamplerTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Testing -import Foundation -@testable import ClaudeUsageCore - -@Suite("Downsampler") -struct DownsamplerTests { - - private func makePoints(count: Int) -> [UsageDataPoint] { - (0..= result[i - 1].timestamp) - } - } - - @Test("Empty input returns empty") - func emptyInput() { - let result = Downsampler.downsample([], targetCount: 200) - #expect(result.isEmpty) - } - - @Test("Single point returns single point") - func singlePoint() { - let points = makePoints(count: 1) - let result = Downsampler.downsample(points, targetCount: 200) - #expect(result.count == 1) - } -} diff --git a/Tests/ClaudeUsageTests/HistoryServiceTests.swift b/Tests/ClaudeUsageTests/HistoryServiceTests.swift deleted file mode 100644 index 064f744..0000000 --- a/Tests/ClaudeUsageTests/HistoryServiceTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Testing -import Foundation -@testable import ClaudeUsageCore - -@Suite("HistoryService") -struct HistoryServiceTests { - - private func makeService() -> HistoryService { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("claude-usage-history-test-\(UUID().uuidString)") - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let store = UsageHistoryStore(directory: dir) - return HistoryService(store: store) - } - - private func makeUsage( - fiveHour: Double = 42.0, - sevenDay: Double = 17.0 - ) -> UsageResponse { - // Build a minimal UsageResponse via JSON decoding - let json = """ - { - "five_hour": { "utilization": \(fiveHour), "resets_at": null }, - "seven_day": { "utilization": \(sevenDay), "resets_at": null }, - "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } - } - """ - return try! JSONDecoder().decode(UsageResponse.self, from: json.data(using: .utf8)!) - } - - @Test("Records and loads a data point") - func recordAndLoad() async throws { - let service = makeService() - let usage = makeUsage(fiveHour: 55.0, sevenDay: 20.0) - - await service.record(from: usage) - let points = await service.loadPoints() - - #expect(points.count == 1) - #expect(points[0].fiveHourUtilization == 55.0) - #expect(points[0].sevenDayUtilization == 20.0) - } - - @Test("Records multiple points in order") - func multiplePointsOrdered() async throws { - let service = makeService() - - await service.record(from: makeUsage(fiveHour: 10.0)) - await service.record(from: makeUsage(fiveHour: 20.0)) - await service.record(from: makeUsage(fiveHour: 30.0)) - - let points = await service.loadPoints() - #expect(points.count == 3) - #expect(points[0].fiveHourUtilization == 10.0) - #expect(points[2].fiveHourUtilization == 30.0) - } - - @Test("Maps optional fields correctly") - func optionalFields() async throws { - let service = makeService() - let json = """ - { - "five_hour": { "utilization": 50.0, "resets_at": null }, - "seven_day": { "utilization": 25.0, "resets_at": null }, - "seven_day_sonnet": { "utilization": 30.0, "resets_at": null }, - "seven_day_opus": { "utilization": 15.0, "resets_at": null }, - "extra_usage": { "is_enabled": true, "monthly_limit": 50000, "used_credits": 25000, "utilization": 50.0 } - } - """ - let usage = try JSONDecoder().decode(UsageResponse.self, from: json.data(using: .utf8)!) - - await service.record(from: usage) - let points = await service.loadPoints() - - #expect(points[0].sonnetUtilization == 30.0) - #expect(points[0].opusUtilization == 15.0) - #expect(points[0].extraUsageUtilization == 50.0) - #expect(points[0].extraUsedCents == 25000) - #expect(points[0].extraLimitCents == 50000) - } -} diff --git a/Tests/ClaudeUsageTests/IntegrationTests.swift b/Tests/ClaudeUsageTests/IntegrationTests.swift new file mode 100644 index 0000000..2a55ee0 --- /dev/null +++ b/Tests/ClaudeUsageTests/IntegrationTests.swift @@ -0,0 +1,299 @@ +import Testing +import Foundation +@testable import ClaudeUsageCore + +/// Integration tests that exercise multiple services working together, +/// verifying the full data flow from API response through to ViewModel state. +@Suite("Integration") +struct IntegrationTests { + + // MARK: - Fixtures + + private static let consumerFixture = """ + { + "five_hour": { "utilization": 42.0, "resets_at": "2026-03-22T12:00:00+00:00" }, + "seven_day": { "utilization": 17.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } + } + """.data(using: .utf8)! + + private static let enterpriseFixture = """ + { + "five_hour": null, + "seven_day": null, + "extra_usage": { + "is_enabled": true, + "monthly_limit": 500000, + "used_credits": 125000, + "utilization": 25.0 + } + } + """.data(using: .utf8)! + + private static let highUsageFixture = """ + { + "five_hour": { "utilization": 85.0, "resets_at": "2026-03-22T12:00:00+00:00" }, + "seven_day": { "utilization": 60.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "seven_day_sonnet": { "utilization": 40.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "seven_day_opus": { "utilization": 20.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } + } + """.data(using: .utf8)! + + // MARK: - Enterprise flow + + @Test("Enterprise response flows through to credit projection") + @MainActor + func enterpriseCreditProjection() async throws { + let mockSession = MockURLSession { _ in + (Self.enterpriseFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.isEnterprise == true) + #expect(vm.menuBarText == "$1250") + #expect(vm.usage?.extraUsage?.isEnabled == true) + #expect(vm.usage?.extraUsage?.usedCredits == 125000) + if Calendar.current.component(.day, from: Date()) > 1 { + #expect(vm.creditProjection != nil) + #expect(vm.creditProjection!.burnRatePerDay > 0) + } + } + + @Test("Enterprise usage stats include budget utilization") + @MainActor + func enterpriseUsageStats() async throws { + let mockSession = MockURLSession { _ in + (Self.enterpriseFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.isEnterprise == true) + #expect(vm.usage?.extraUsage?.utilization == 25.0) + } + + @Test("Consumer usage stats include 5-hour and 7-day") + @MainActor + func consumerUsageStats() async throws { + let mockSession = MockURLSession { _ in + (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.isEnterprise == false) + #expect(vm.usage?.fiveHour?.utilization == 42.0) + #expect(vm.usage?.sevenDay?.utilization == 17.0) + } + + // MARK: - Error recovery flow + + @Test("401 → token refresh → successful retry end-to-end") + @MainActor + func errorRecoveryFlow() async throws { + let apiCounter = FetchCounter() + let credCounter = FetchCounter() + + let mockSession = MockURLSession { request in + let path = request.url?.path ?? "" + if path.contains("/profile") { + return (Data(), HTTPURLResponse( + url: request.url!, statusCode: 500, + httpVersion: nil, headerFields: nil)!) + } + apiCounter.increment() + let token = request.value(forHTTPHeaderField: "Authorization") ?? "" + if token.contains("stale") { + return (Data(), HTTPURLResponse( + url: request.url!, statusCode: 401, + httpVersion: nil, headerFields: nil)!) + } + return (Self.consumerFixture, HTTPURLResponse( + url: request.url!, statusCode: 200, + httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { + credCounter.increment() + if credCounter.value == 1 { + return OAuthCredential.mock(accessToken: "stale-token") + } + return OAuthCredential.mock(accessToken: "fresh-token") + }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.error == nil) + #expect(vm.menuBarText == "42%") + #expect(apiCounter.value == 2) + } + + @Test("503 error → backoff → recovery on next refresh") + @MainActor + func serverErrorBackoffRecovery() async throws { + let apiCounter = FetchCounter() + + let mockSession = MockURLSession { _ in + apiCounter.increment() + if apiCounter.value == 1 { + return (Data(), HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 503, httpVersion: nil, headerFields: nil)!) + } + return (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + pollingInterval: 60, + cache: nil + ) + + await vm.refresh() + #expect(vm.error != nil) + #expect(vm.usage == nil) + + await vm.refresh() + #expect(vm.error == nil) + #expect(vm.menuBarText == "42%") + #expect(vm.currentBackoff == nil) + } + + // MARK: - Polling lifecycle integration + + @Test("Polling lifecycle: start → fetch → stop → no more fetches") + @MainActor + func pollingLifecycle() async throws { + let counter = FetchCounter() + + let mockSession = MockURLSession { _ in + counter.increment() + return (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + pollingInterval: 0.1, + cache: nil + ) + + vm.startPolling() + try await Task.sleep(for: .milliseconds(400)) + vm.stopPolling() + // Let any in-flight fetch drain before snapshotting the counter. + try await Task.sleep(for: .milliseconds(150)) + let countAtStop = counter.value + + #expect(countAtStop >= 2) + + try await Task.sleep(for: .milliseconds(300)) + #expect(counter.value == countAtStop) + } + + // MARK: - Sign out flow + + @Test("Sign out clears all ViewModel state") + @MainActor + func signOutClearsState() async throws { + let mockSession = MockURLSession { _ in + (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + + await vm.refresh() + #expect(vm.usage != nil) + + vm.signOut() + + #expect(vm.usage == nil) + #expect(vm.error == .noCredential) + #expect(vm.lastUpdated == nil) + #expect(vm.currentBackoff == nil) + #expect(vm.creditProjection == nil) + } + + // MARK: - No credential → error state + + @Test("No credential flows through entire pipeline as error") + @MainActor + func noCredentialPipeline() async throws { + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(), + credentialProvider: { nil }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.error == .noCredential) + #expect(vm.usage == nil) + #expect(vm.menuBarText == "--") + #expect(vm.creditProjection == nil) + } + + // MARK: - Model breakdown fields flow through + + @Test("Sonnet and Opus utilization flow from API to ViewModel") + @MainActor + func modelBreakdownFlowsThrough() async throws { + let mockSession = MockURLSession { _ in + (Self.highUsageFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil + ) + + await vm.refresh() + + #expect(vm.usage?.sevenDaySonnet?.utilization == 40.0) + #expect(vm.usage?.sevenDayOpus?.utilization == 20.0) + } +} diff --git a/Tests/ClaudeUsageTests/MenuBarMetricsTests.swift b/Tests/ClaudeUsageTests/MenuBarMetricsTests.swift new file mode 100644 index 0000000..a603e46 --- /dev/null +++ b/Tests/ClaudeUsageTests/MenuBarMetricsTests.swift @@ -0,0 +1,117 @@ +import Testing +import Foundation +@testable import ClaudeUsageCore + +@Suite("MenuBarMetrics") +struct MenuBarMetricsTests { + + private func makeUsage(fiveHour: Double?, sevenDay: Double?) -> UsageResponse { + let fiveBucket = fiveHour.map { UsageBucket(utilization: $0, resetsAt: nil) } + let sevenBucket = sevenDay.map { UsageBucket(utilization: $0, resetsAt: nil) } + return UsageResponse( + fiveHour: fiveBucket, + sevenDay: sevenBucket, + sevenDaySonnet: nil, + sevenDayOpus: nil, + extraUsage: nil + ) + } + + // MARK: - Percent extraction + + @Test("Happy path: primary and secondary percentages extracted from buckets") + func extractsBothPercentages() { + let usage = makeUsage(fiveHour: 35, sevenDay: 71) + #expect(MenuBarMetrics.consumerPrimaryPercent(from: usage) == 35) + #expect(MenuBarMetrics.consumerSecondaryPercent(from: usage) == 71) + } + + @Test("Edge: nil fiveHour yields nil primary") + func nilFiveHourYieldsNilPrimary() { + let usage = makeUsage(fiveHour: nil, sevenDay: 42) + #expect(MenuBarMetrics.consumerPrimaryPercent(from: usage) == nil) + #expect(MenuBarMetrics.consumerSecondaryPercent(from: usage) == 42) + } + + @Test("Edge: both buckets nil yields both helpers nil") + func bothNilYieldsBothNil() { + let usage = makeUsage(fiveHour: nil, sevenDay: nil) + #expect(MenuBarMetrics.consumerPrimaryPercent(from: usage) == nil) + #expect(MenuBarMetrics.consumerSecondaryPercent(from: usage) == nil) + } + + @Test("Edge: nil UsageResponse yields both helpers nil") + func nilUsageYieldsBothNil() { + #expect(MenuBarMetrics.consumerPrimaryPercent(from: nil) == nil) + #expect(MenuBarMetrics.consumerSecondaryPercent(from: nil) == nil) + } + + @Test("Fractional utilization truncates via Int conversion") + func fractionalUtilizationTruncates() { + let usage = makeUsage(fiveHour: 49.9, sevenDay: 0.5) + #expect(MenuBarMetrics.consumerPrimaryPercent(from: usage) == 49) + #expect(MenuBarMetrics.consumerSecondaryPercent(from: usage) == 0) + } + + // MARK: - Traffic-light color mode + + @Test("Traffic light: below warning returns ok") + func trafficLightBelowWarning() { + let color = MenuBarMetrics.thresholdColor(for: 10, warning: 50, critical: 80, colorMode: "traffic_light") + #expect(color == .ok) + } + + @Test("Traffic light: at warning returns warning") + func trafficLightAtWarning() { + let color = MenuBarMetrics.thresholdColor(for: 50, warning: 50, critical: 80, colorMode: "traffic_light") + #expect(color == .warning) + } + + @Test("Traffic light: between warning and critical returns warning") + func trafficLightBetween() { + let color = MenuBarMetrics.thresholdColor(for: 65, warning: 50, critical: 80, colorMode: "traffic_light") + #expect(color == .warning) + } + + @Test("Traffic light: at critical returns critical") + func trafficLightAtCritical() { + let color = MenuBarMetrics.thresholdColor(for: 80, warning: 50, critical: 80, colorMode: "traffic_light") + #expect(color == .critical) + } + + @Test("Traffic light: above critical returns critical") + func trafficLightAboveCritical() { + let color = MenuBarMetrics.thresholdColor(for: 95, warning: 50, critical: 80, colorMode: "traffic_light") + #expect(color == .critical) + } + + // MARK: - Single-color tealScale mode + + @Test("Single color: opacity scales from 0.4 at 0% to 1.0 at 100%") + func singleColorScalesWithPercent() { + let zero = MenuBarMetrics.thresholdColor(for: 0, warning: 50, critical: 80, colorMode: "single_color") + #expect(zero == .tealScale(opacity: 0.4)) + + let hundred = MenuBarMetrics.thresholdColor(for: 100, warning: 50, critical: 80, colorMode: "single_color") + #expect(hundred == .tealScale(opacity: 1.0)) + + let fifty = MenuBarMetrics.thresholdColor(for: 50, warning: 50, critical: 80, colorMode: "single_color") + #expect(fifty == .tealScale(opacity: 0.7)) + } + + @Test("Single color: opacity clamped to [0.4, 1.0]") + func singleColorClamped() { + let negative = MenuBarMetrics.thresholdColor(for: -20, warning: 50, critical: 80, colorMode: "single_color") + #expect(negative == .tealScale(opacity: 0.4)) + + let overLimit = MenuBarMetrics.thresholdColor(for: 150, warning: 50, critical: 80, colorMode: "single_color") + #expect(overLimit == .tealScale(opacity: 1.0)) + } + + @Test("Single color: ignores warning/critical thresholds") + func singleColorIgnoresThresholds() { + let a = MenuBarMetrics.thresholdColor(for: 90, warning: 50, critical: 80, colorMode: "single_color") + let b = MenuBarMetrics.thresholdColor(for: 90, warning: 10, critical: 20, colorMode: "single_color") + #expect(a == b) + } +} diff --git a/Tests/ClaudeUsageTests/NotificationCoordinatorTests.swift b/Tests/ClaudeUsageTests/NotificationCoordinatorTests.swift index 97eb890..0db5817 100644 --- a/Tests/ClaudeUsageTests/NotificationCoordinatorTests.swift +++ b/Tests/ClaudeUsageTests/NotificationCoordinatorTests.swift @@ -5,6 +5,7 @@ import Foundation @Suite("NotificationCoordinator") struct NotificationCoordinatorTests { + @MainActor private func makeCoordinator() -> NotificationCoordinator { NotificationCoordinator(notificationService: NotificationService()) } @@ -34,27 +35,27 @@ struct NotificationCoordinatorTests { } @Test("Returns nil credit projection for non-enterprise usage") + @MainActor func nonEnterpriseNoCreditProjection() { let coordinator = makeCoordinator() let usage = makeUsage(fiveHour: 50.0) - let result = coordinator.evaluate(usage: usage, historyPoints: []) + let result = coordinator.evaluate(usage: usage) #expect(result.creditProjection == nil) } @Test("Returns credit projection for enterprise usage") + @MainActor func enterpriseCreditProjection() { let coordinator = makeCoordinator() - // Day > 1 needed for projection let usage = makeUsage( fiveHour: nil, extraEnabled: true, - usedCredits: 25000, // $250 - monthlyLimit: 100000 // $1000 + usedCredits: 25000, + monthlyLimit: 100000 ) - let result = coordinator.evaluate(usage: usage, historyPoints: []) + let result = coordinator.evaluate(usage: usage) - // Whether projection is nil depends on day of month - // On day 1, no projection; on day > 1, should have one + // Projection only exists past day 1 of the month let dayOfMonth = Calendar.current.component(.day, from: Date()) if dayOfMonth > 1 { #expect(result.creditProjection != nil) @@ -63,9 +64,21 @@ struct NotificationCoordinatorTests { } @Test("Reset clears notification state") + @MainActor func resetClearsState() { let coordinator = makeCoordinator() - // Just verify it doesn't crash coordinator.reset() } + + @Test("Ring buffer feeds burn-rate projection across repeated evaluations") + @MainActor + func ringBufferAccumulates() { + let coordinator = makeCoordinator() + // Two samples with the same usage — projection returns zero velocity since + // the coordinator records them at effectively the same timestamp, but it + // must not crash and should still return a well-formed evaluation. + _ = coordinator.evaluate(usage: makeUsage(fiveHour: 40)) + let second = coordinator.evaluate(usage: makeUsage(fiveHour: 40)) + #expect(second.creditProjection == nil) + } } diff --git a/Tests/ClaudeUsageTests/UsageHistoryStoreTests.swift b/Tests/ClaudeUsageTests/UsageHistoryStoreTests.swift deleted file mode 100644 index 859d28b..0000000 --- a/Tests/ClaudeUsageTests/UsageHistoryStoreTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -import Testing -import Foundation -@testable import ClaudeUsageCore - -@Suite("UsageHistoryStore") -struct UsageHistoryStoreTests { - - private func makeTempDir() throws -> URL { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("claude-usage-test-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir - } - - private func makePoint( - hoursAgo: Double = 0, - fiveHour: Double = 10.0, - sevenDay: Double = 5.0 - ) -> UsageDataPoint { - UsageDataPoint( - timestamp: Date().addingTimeInterval(-hoursAgo * 3600), - fiveHourUtilization: fiveHour, - sevenDayUtilization: sevenDay - ) - } - - @Test("Records a point and loads it back") - func recordAndLoad() async throws { - let dir = try makeTempDir() - let store = UsageHistoryStore(directory: dir) - - try await store.record(makePoint(fiveHour: 42.0, sevenDay: 17.0)) - - let loaded = try await store.load() - #expect(loaded.count == 1) - #expect(loaded[0].fiveHourUtilization == 42.0) - #expect(loaded[0].sevenDayUtilization == 17.0) - } - - @Test("Appends multiple points in order") - func appendsInOrder() async throws { - let dir = try makeTempDir() - let store = UsageHistoryStore(directory: dir) - - try await store.record(makePoint(fiveHour: 10.0)) - try await store.record(makePoint(fiveHour: 20.0)) - try await store.record(makePoint(fiveHour: 30.0)) - - let loaded = try await store.load() - #expect(loaded.count == 3) - #expect(loaded[0].fiveHourUtilization == 10.0) - #expect(loaded[2].fiveHourUtilization == 30.0) - } - - @Test("Prune removes points older than 30 days") - func pruneOld() async throws { - let dir = try makeTempDir() - let store = UsageHistoryStore(directory: dir) - - // 31 days ago - try await store.record(makePoint(hoursAgo: 31 * 24, fiveHour: 99.0)) - // 1 hour ago - try await store.record(makePoint(hoursAgo: 1, fiveHour: 5.0)) - - try await store.prune() - - let loaded = try await store.load() - #expect(loaded.count == 1) - #expect(loaded[0].fiveHourUtilization == 5.0) - } - - @Test("Prune after record removes old points") - func pruneAfterRecord() async throws { - let dir = try makeTempDir() - let store = UsageHistoryStore(directory: dir) - - try await store.record(makePoint(hoursAgo: 31 * 24, fiveHour: 99.0)) - try await store.record(makePoint(fiveHour: 5.0)) - try await store.prune() - - let loaded = try await store.load() - #expect(loaded.count == 1) - #expect(loaded[0].fiveHourUtilization == 5.0) - } - - @Test("Load from nonexistent file returns empty") - func loadNonexistent() async throws { - let dir = try makeTempDir() - let store = UsageHistoryStore(directory: dir) - - let loaded = try await store.load() - #expect(loaded.isEmpty) - } - - @Test("Corrupted file returns empty gracefully") - func corruptedFile() async throws { - let dir = try makeTempDir() - let file = dir.appendingPathComponent("history.json") - try "not valid json {{{".data(using: .utf8)!.write(to: file) - - let store = UsageHistoryStore(directory: dir) - let loaded = try await store.load() - #expect(loaded.isEmpty) - } - - @Test("Persists across store instances") - func persistsAcrossInstances() async throws { - let dir = try makeTempDir() - - let store1 = UsageHistoryStore(directory: dir) - try await store1.record(makePoint(fiveHour: 42.0)) - - // New instance reads from same file - let store2 = UsageHistoryStore(directory: dir) - let loaded = try await store2.load() - #expect(loaded.count == 1) - #expect(loaded[0].fiveHourUtilization == 42.0) - } -} diff --git a/Tests/ClaudeUsageTests/UsageResponseCacheTests.swift b/Tests/ClaudeUsageTests/UsageResponseCacheTests.swift new file mode 100644 index 0000000..74347f7 --- /dev/null +++ b/Tests/ClaudeUsageTests/UsageResponseCacheTests.swift @@ -0,0 +1,80 @@ +import Testing +import Foundation +@testable import ClaudeUsageCore + +@Suite("UsageResponseCache") +struct UsageResponseCacheTests { + + private func tempFileURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cc-stats-tests-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("last-usage.json") + } + + private func sampleResponse() -> UsageResponse { + UsageResponse( + fiveHour: UsageBucket(utilization: 42.0, resetsAt: "2026-03-22T12:00:00+00:00"), + sevenDay: UsageBucket(utilization: 17.0, resetsAt: "2026-03-27T12:00:00+00:00"), + sevenDaySonnet: nil, + sevenDayOpus: nil, + extraUsage: nil + ) + } + + @Test("Round-trip: save then load returns equivalent data") + func roundTrip() { + let url = tempFileURL() + defer { try? FileManager.default.removeItem(at: url) } + + let cache = UsageResponseCache(fileURL: url) + let fetchedAt = Date(timeIntervalSince1970: 1_700_100_000) + let original = CachedUsageResponse(fetchedAt: fetchedAt, response: sampleResponse()) + + cache.save(original) + + let loaded = cache.load() + #expect(loaded != nil) + #expect(loaded?.fetchedAt.timeIntervalSince1970 == fetchedAt.timeIntervalSince1970) + #expect(loaded?.response.fiveHour?.utilization == 42.0) + #expect(loaded?.response.sevenDay?.utilization == 17.0) + } + + @Test("Missing file returns nil without throwing") + func missingFile() { + let url = tempFileURL() + let cache = UsageResponseCache(fileURL: url) + + #expect(cache.load() == nil) + } + + @Test("Corrupt file returns nil") + func corruptFile() throws { + let url = tempFileURL() + defer { try? FileManager.default.removeItem(at: url) } + try "not valid json".data(using: .utf8)!.write(to: url) + + let cache = UsageResponseCache(fileURL: url) + #expect(cache.load() == nil) + } + + @Test("Clear removes the cached file") + func clearRemovesFile() { + let url = tempFileURL() + let cache = UsageResponseCache(fileURL: url) + cache.save(CachedUsageResponse(fetchedAt: Date(), response: sampleResponse())) + #expect(FileManager.default.fileExists(atPath: url.path) == true) + + cache.clear() + #expect(FileManager.default.fileExists(atPath: url.path) == false) + #expect(cache.load() == nil) + } + + @Test("Clear on missing file is a no-op") + func clearNoOp() { + let url = tempFileURL() + let cache = UsageResponseCache(fileURL: url) + cache.clear() + #expect(cache.load() == nil) + } +} diff --git a/Tests/ClaudeUsageTests/UsageViewModelTests.swift b/Tests/ClaudeUsageTests/UsageViewModelTests.swift index cf61552..1eb45c3 100644 --- a/Tests/ClaudeUsageTests/UsageViewModelTests.swift +++ b/Tests/ClaudeUsageTests/UsageViewModelTests.swift @@ -24,7 +24,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), - credentialProvider: { OAuthCredential.mock(accessToken: "token") } + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil ) await vm.refresh() @@ -40,7 +41,8 @@ struct UsageViewModelTests { func noCredential() async throws { let vm = UsageViewModel( apiClient: AnthropicAPIClient(), - credentialProvider: { nil } + credentialProvider: { nil }, + cache: nil ) await vm.refresh() @@ -60,7 +62,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), - credentialProvider: { OAuthCredential.mock(accessToken: "token") } + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil ) await vm.refresh() @@ -104,13 +107,14 @@ struct UsageViewModelTests { accessToken: "fresh-token", expiresAt: Int64(Date().timeIntervalSince1970 * 1000) + 3_600_000 ) - } + }, + cache: nil ) await vm.refresh() - // Should have called credentialProvider twice (re-read on expiry) - #expect(counter.value == 2) + // credentialProvider called for: stale usage fetch, fresh usage re-read, profile fetch + #expect(counter.value >= 2) #expect(vm.menuBarText == "5%") } @@ -128,6 +132,12 @@ struct UsageViewModelTests { let apiCounter = FetchCounter() let mockSession = MockURLSession { request in + let path = request.url?.path ?? "" + if path.contains("/profile") { + return (Data(), HTTPURLResponse( + url: request.url!, statusCode: 500, + httpVersion: nil, headerFields: nil)!) + } apiCounter.increment() let token = request.value(forHTTPHeaderField: "Authorization") ?? "" // First call with stale token → 401, second with fresh → 200 @@ -149,7 +159,8 @@ struct UsageViewModelTests { return OAuthCredential.mock(accessToken: "stale-token") } return OAuthCredential.mock(accessToken: "fresh-token") - } + }, + cache: nil ) await vm.refresh() @@ -187,7 +198,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - pollingInterval: 0.1 + pollingInterval: 0.1, + cache: nil ) // First refresh → 429, should set error and backoff @@ -224,7 +236,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - pollingInterval: 0.1 + pollingInterval: 0.1, + cache: nil ) vm.startPolling() @@ -258,7 +271,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), - credentialProvider: { OAuthCredential.mock(accessToken: "token") } + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: nil ) await vm.refresh() @@ -278,7 +292,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - pollingInterval: 30 + pollingInterval: 30, + cache: nil ) // Trigger multiple failures to escalate backoff @@ -312,7 +327,8 @@ struct UsageViewModelTests { let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - pollingInterval: 0.1 + pollingInterval: 0.1, + cache: nil ) vm.startPolling() @@ -325,76 +341,226 @@ struct UsageViewModelTests { #expect(vm.menuBarText == "5%") } - @Test("Records history point on successful fetch") + @Test("Successful refresh clears previous error") @MainActor - func recordsHistory() async throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("claude-usage-vm-test-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - + func successClearsPreviousError() async throws { + let apiCounter = FetchCounter() let fixture = """ { - "five_hour": { "utilization": 42.0, "resets_at": "2026-03-22T12:00:00+00:00" }, - "seven_day": { "utilization": 17.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "five_hour": { "utilization": 20.0, "resets_at": null }, "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } } """.data(using: .utf8)! let mockSession = MockURLSession { _ in + apiCounter.increment() + if apiCounter.value == 1 { + return (Data(), HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 503, httpVersion: nil, headerFields: nil)!) + } return (fixture, HTTPURLResponse( url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } - let store = UsageHistoryStore(directory: dir) let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), credentialProvider: { OAuthCredential.mock(accessToken: "token") }, - historyStore: store + cache: nil ) await vm.refresh() + #expect(vm.error != nil) - #expect(vm.historyPoints.count == 1) - #expect(vm.historyPoints[0].fiveHourUtilization == 42.0) - #expect(vm.historyPoints[0].sevenDayUtilization == 17.0) + await vm.refresh() + #expect(vm.error == nil) + #expect(vm.menuBarText == "20%") + #expect(vm.currentBackoff == nil) } - @Test("Successful refresh clears previous error") + // MARK: - Cache hydration and deferred first fetch + + private static func cacheFileURL() -> URL { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("cc-stats-vm-cache-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir.appendingPathComponent("last-usage.json") + } + + private static let consumerFixture = """ + { + "five_hour": { "utilization": 42.0, "resets_at": "2026-03-22T12:00:00+00:00" }, + "seven_day": { "utilization": 17.0, "resets_at": "2026-03-27T12:00:00+00:00" }, + "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } + } + """.data(using: .utf8)! + + @Test("Constructor hydrates usage and lastUpdated from cache") @MainActor - func successClearsPreviousError() async throws { - let apiCounter = FetchCounter() - let fixture = """ - { - "five_hour": { "utilization": 20.0, "resets_at": null }, - "extra_usage": { "is_enabled": false, "monthly_limit": null, "used_credits": null, "utilization": null } + func constructorHydratesFromCache() async throws { + let url = Self.cacheFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let cache = UsageResponseCache(fileURL: url) + + let cachedResponse = UsageResponse( + fiveHour: UsageBucket(utilization: 55.0, resetsAt: nil), + sevenDay: UsageBucket(utilization: 20.0, resetsAt: nil), + sevenDaySonnet: nil, + sevenDayOpus: nil, + extraUsage: nil + ) + let fetchedAt = Date(timeIntervalSinceNow: -60) + cache.save(CachedUsageResponse(fetchedAt: fetchedAt, response: cachedResponse)) + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: cache + ) + + #expect(vm.usage?.fiveHour?.utilization == 55.0) + #expect(vm.lastUpdated != nil) + #expect(vm.menuBarText == "55%") + } + + @Test("Fresh cache defers the first automatic fetch") + @MainActor + func freshCacheDefersFirstFetch() async throws { + let url = Self.cacheFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let cache = UsageResponseCache(fileURL: url) + + let cachedResponse = UsageResponse( + fiveHour: UsageBucket(utilization: 55.0, resetsAt: nil), + sevenDay: nil, + sevenDaySonnet: nil, + sevenDayOpus: nil, + extraUsage: nil + ) + // Age = 60s, polling interval = 300s → deferred ~240s, well beyond the test window. + cache.save(CachedUsageResponse( + fetchedAt: Date(timeIntervalSinceNow: -60), + response: cachedResponse + )) + + let counter = FetchCounter() + let mockSession = MockURLSession { _ in + counter.increment() + return (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) } - """.data(using: .utf8)! + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + pollingInterval: 300, + cache: cache + ) + + vm.startPolling() + try await Task.sleep(for: .milliseconds(300)) + vm.stopPolling() + + #expect(counter.value == 0) + // Cache-hydrated state should still be visible. + #expect(vm.menuBarText == "55%") + } + + @Test("Stale cache (age > pollingInterval) triggers immediate fetch") + @MainActor + func staleCacheTriggersImmediateFetch() async throws { + let url = Self.cacheFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let cache = UsageResponseCache(fileURL: url) + + let cachedResponse = UsageResponse( + fiveHour: UsageBucket(utilization: 10.0, resetsAt: nil), + sevenDay: nil, + sevenDaySonnet: nil, + sevenDayOpus: nil, + extraUsage: nil + ) + // Age = 10 minutes, polling interval = 0.5s → cache is stale. + cache.save(CachedUsageResponse( + fetchedAt: Date(timeIntervalSinceNow: -600), + response: cachedResponse + )) + + let counter = FetchCounter() let mockSession = MockURLSession { _ in - apiCounter.increment() - if apiCounter.value == 1 { - return (Data(), HTTPURLResponse( - url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, - statusCode: 503, httpVersion: nil, headerFields: nil)!) - } - return (fixture, HTTPURLResponse( + counter.increment() + return (Self.consumerFixture, HTTPURLResponse( url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } let vm = UsageViewModel( apiClient: AnthropicAPIClient(session: mockSession), - credentialProvider: { OAuthCredential.mock(accessToken: "token") } + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + pollingInterval: 0.5, + cache: cache + ) + + vm.startPolling() + try await Task.sleep(for: .milliseconds(200)) + vm.stopPolling() + + #expect(counter.value >= 1) + #expect(vm.menuBarText == "42%") + } + + @Test("Successful fetch writes cache file") + @MainActor + func successfulFetchWritesCache() async throws { + let url = Self.cacheFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let cache = UsageResponseCache(fileURL: url) + + let mockSession = MockURLSession { _ in + (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: cache ) await vm.refresh() - #expect(vm.error != nil) + + let loaded = cache.load() + #expect(loaded != nil) + #expect(loaded?.response.fiveHour?.utilization == 42.0) + } + + @Test("signOut clears cache file") + @MainActor + func signOutClearsCache() async throws { + let url = Self.cacheFileURL() + defer { try? FileManager.default.removeItem(at: url) } + let cache = UsageResponseCache(fileURL: url) + + let mockSession = MockURLSession { _ in + (Self.consumerFixture, HTTPURLResponse( + url: URL(string: "https://api.anthropic.com/api/oauth/usage")!, + statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + + let vm = UsageViewModel( + apiClient: AnthropicAPIClient(session: mockSession), + credentialProvider: { OAuthCredential.mock(accessToken: "token") }, + cache: cache + ) await vm.refresh() - #expect(vm.error == nil) - #expect(vm.menuBarText == "20%") - #expect(vm.currentBackoff == nil) + #expect(cache.load() != nil) + + vm.signOut() + #expect(cache.load() == nil) } } diff --git a/project.yml b/project.yml index 64843c4..39a46fe 100644 --- a/project.yml +++ b/project.yml @@ -5,12 +5,6 @@ options: macOS: "14.0" localizations: - en - - nl - - de - - fr - - es - - pt-PT - - pt-BR settings: base: @@ -32,9 +26,6 @@ packages: LaunchAtLogin-Modern: url: https://github.com/sindresorhus/LaunchAtLogin-Modern.git from: "1.1.0" - GRDB: - url: https://github.com/groue/GRDB.swift.git - from: "7.5.0" targets: ClaudeUsageCore: @@ -44,8 +35,6 @@ targets: - path: Sources/ClaudeUsageCore dependencies: - package: KeychainAccess - - package: GRDB - product: GRDB settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.kootstra.claude-usage.core