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