diff --git a/AGENTS.md b/AGENTS.md
index 6ffbca9..adb452b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,7 +45,7 @@
- Panel style and menu-bar style persist independently; existing installs default to classic panels and numeric indicators. Compact/ring styles retain exact quota and reset data, historical provenance, and provider/window selection. Graphics never show stale values as live; exact menu values remain in tooltip/accessibility. Animations are brief, event-driven, optional, and honor Reduce Motion.
- Use the native segmented control for history modes so selection and labels share one renderer. Year/week transitions must be checked in actual pixels after parent resizing; aligned accessibility frames alone cannot prove that the selection background stayed attached.
- Ring grids must cap columns at the number of visible windows and distribute the occupied row across the card. Two quotas must never leave a reserved third slot; cover this with a wide two-cell render check. Unified ring width follows visible quotas within its bounds.
-- Centralize visual constants in `Theme.swift`; individual panels are 340 pt wide and Unified is 560 pt wide; the panel grows to fit its content, with the layout matrix verified below 640 pt. Never wrap the entire popover in a vertical scroll view; only unusually long provider lists scroll within their own bounded area. Individual views show only their own provider; Unified shows two titled cards side by side. Scroll only when real provider content exceeds its bounded height.
+- Centralize visual constants in `Theme.swift`; individual panels are 340 pt wide and Unified is 560 pt wide; the panel grows to fit its content, with the layout matrix verified below 700 pt. Never wrap the entire popover in a vertical scroll view; only unusually long provider lists scroll within their own bounded area. Individual views show only their own provider; Unified shows two titled cards side by side. Scroll only when real provider content exceeds its bounded height.
- The popover closes on any click outside it: `.transient` alone does not dismiss an accessory app's popover when the click lands in another application (Steven, 2026-08-27). `StatusItemController` arms a global mouse-down monitor plus `didResignActiveNotification` while the popover is shown and tears both down in `popoverDidClose`. Keep the monitor to MOUSE events only — a global key monitor would demand Accessibility, which this app must never request.
- Reset lines scale with distance: under an hour shows minutes plus the clock time, under a day shows hours plus the clock time, the next calendar day shows `mañana` plus the clock time, and anything further shows whole days only. Far distances count the real remaining duration, never midnight crossings — 3.4 days away reads `3 d`, not `4 d`.
- The all-models weekly row never repeats a family that already owns its own scoped weekly row: `ProviderCard` passes those families to `ModelActivity.chips(excludingFamilies:)`, so `Semanal` and `Fable semanal` report disjoint token totals.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f7eab9..02fde68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.4.0] - 2026-09-18
+
+### Added
+
+- Quota ring cards that switch between one large ring, a grid, or compact rows according to the visible windows and the available width, keeping three Claude windows with model chips and pace lines fully readable.
+- A calendar day card, shown by holding the pointer over a day or pinned with a click, with per-provider icons, tokens, percentages, model detail, and current and best streaks.
+- Five calendar intensity levels derived from usage quantiles, so more color means more usage.
+- An in-panel update banner with real download and extraction progress, followed by Install now or Later.
+- A persistent Install button in the panel and in About after choosing Later, preserved across relaunches.
+
+### Changed
+
+- The yearly calendar now shows combined activity in the Codex, Claude, and Unified views.
+- Calendar selection follows the pointer and returns to today when the pointer leaves the calendar; Esc, a click outside, scrolling, or changing view dismisses a pinned day card.
+- The standard panel moves Settings, About, and Quit into a header gear menu instead of footer rows.
+- Manual update checks are available in About whenever no update is in progress.
+- Faster refreshes: transcripts are scanned incrementally with a resume file, a single capture pass replaces two, Claude activity and account data are read concurrently within a 12-second network limit, and each provider's card appears as soon as its own data is ready.
+- Update feed overrides are accepted only over HTTPS.
+
+### Fixed
+
+- Claude readings, history, and pace continuity are scoped to the signed-in account, and Claude helper commands run in a private working directory.
+- A refresh stays marked as busy until local archiving finishes.
+- Local history read handles are closed when the database reports a schema error.
+- Daily totals use the Gregorian calendar regardless of the system calendar setting.
+
## [1.3.0] - 2026-09-13
### Added
diff --git a/README.md b/README.md
index fa8e3f3..5c7cd3f 100644
--- a/README.md
+++ b/README.md
@@ -34,11 +34,11 @@ TokenGauge lives in your Mac’s menu bar and shows how much Claude Code or Code
- **A readable menu bar:** independently choose percentages, mini bars, or mini rings, with Large, Medium, or Small sizing. Exact percentages remain available on hover and in the panel.
- **Quiet by design:** optional brief transitions respect Reduce Motion; menu indicators redraw only when their data or appearance changes.
- **Separate limits:** general weekly quota and additional provider windows stay separate. Hide Luna weekly reserve in Settings if you do not use it.
-- **Browsable activity:** rolling seven days, Monday-based weeks with previous/next navigation, or a yearly contribution calendar. Hover or select a day to inspect totals and available model/effort detail. In Unified, calendar colors show the provider with more recorded tokens, and intensity shows combined daily usage; ties use both colors. Separate month blocks and highlighted weekend rows make the year easier to scan.
+- **Browsable activity:** rolling seven days, Monday-based weeks with previous/next navigation, or a yearly calendar that always shows Codex and Claude together, whichever view you are in. Five intensity levels show how much you used that day, colored by the provider with more recorded tokens. Hold the pointer over a day or click it to pin a card with per-provider tokens, percentages, model detail, and your current and best streaks; Esc, a click outside, scrolling, or changing view closes it. Separate month blocks and highlighted weekend rows make the year easier to scan.
- **Local history:** SQLite preserves daily totals and observed model/reasoning effort metadata after provider logs are trimmed. Missing days remain distinct from recorded zero usage.
- **Hourly quota pace:** an optional estimate in percentage points per hour for each weekly limit, based on verified recent readings, with a short explanation and dated last-active measurement.
- **English and Spanish:** follows your system language initially, with an explicit language choice in Settings.
-- **Native updates:** daily checks, an inline install action, and manual checks in About.
+- **Native updates:** daily checks, an in-panel banner with download progress, and manual checks in About.
- **No telemetry, ads, or model requests.**
## Install and connect
@@ -58,7 +58,7 @@ TokenGauge is a usage viewer, not a replacement for either provider. On a Mac wi
### Updates
-Release builds check for updates daily. When one is available, choose **Install update** in the panel or **About TokenGauge**. Download progress appears inline, then the app relaunches. Automatic checking can be disabled in Settings; manual checks live in About. There are no separate Sparkle update windows.
+Release builds check for updates daily. When one is available, a banner appears in the panel with download and extraction progress, then offers **Install now** or **Later**. Installing relaunches the app. If you choose Later, an Install button stays in the panel and in **About TokenGauge** until you use it, including after restarting the app. Automatic checking can be disabled in Settings; a manual check is available in About whenever no update is in progress. There are no separate Sparkle update windows.
Updates use [Sparkle](https://sparkle-project.org), a signed ZIP, and an EdDSA-signed appcast on GitHub Releases. Development builds stay off the public update channel.
diff --git a/Resources/Info.plist b/Resources/Info.plist
index c2ee42f..dc825e0 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -17,9 +17,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.3.0
+ 1.4.0
CFBundleVersion
- 4
+ 5
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/Sources/TokenGaugeApp/App/AppDelegate.swift b/Sources/TokenGaugeApp/App/AppDelegate.swift
index c976937..1d8d294 100644
--- a/Sources/TokenGaugeApp/App/AppDelegate.swift
+++ b/Sources/TokenGaugeApp/App/AppDelegate.swift
@@ -7,6 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusController: StatusItemController?
func applicationDidFinishLaunching(_ notification: Notification) {
+ signal(SIGPIPE, SIG_IGN)
NSApp.setActivationPolicy(.accessory)
statusController = StatusItemController(store: store, launchAtLogin: launchAtLogin)
requestClaudeRecoveryConsent()
@@ -27,9 +28,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
private func requestClaudeRecoveryConsent() {
- guard UserDefaults.standard.object(forKey: "claudeAutomaticRecovery") == nil,
- ClaudeSessionRecovery.executable() != nil
- else { return }
+ guard !AppPreferences().claudeAutomaticRecoveryDecided, ClaudeSessionRecovery.executable() != nil else {
+ return
+ }
let alert = NSAlert()
alert.messageText = "setup.claude_recovery_title".localized
alert.informativeText = "setup.claude_recovery_message".localized
diff --git a/Sources/TokenGaugeApp/App/StatusItemController.swift b/Sources/TokenGaugeApp/App/StatusItemController.swift
index 3579318..4551813 100644
--- a/Sources/TokenGaugeApp/App/StatusItemController.swift
+++ b/Sources/TokenGaugeApp/App/StatusItemController.swift
@@ -16,6 +16,7 @@ final class StatusItemController: NSObject {
private var popoverSizeObservation: NSKeyValueObservation?
private var positioningUpdatePending = false
private var positionedGeometry: PopoverGeometry?
+ private var history: HistoryDashboardModel?
private struct PopoverGeometry: Equatable {
let sourceWindow: NSRect
@@ -97,7 +98,7 @@ final class StatusItemController: NSObject {
},
showAbout: { [weak self] in
self?.showAbout()
- })
+ }, history: historyModel())
let controller = NSHostingController(rootView: view)
controller.sizingOptions = [.preferredContentSize]
popover.contentViewController = controller
@@ -106,6 +107,14 @@ final class StatusItemController: NSObject {
startDismissMonitors()
}
+ private func historyModel() -> HistoryDashboardModel {
+ if let history { return history }
+ let preview = store.historyReadsEnabled ? nil : [store.claude.snapshot, store.codex.snapshot].compactMap { $0 }
+ let model = HistoryDashboardModel(previewSnapshots: preview, mode: store.historyMode)
+ history = model
+ return model
+ }
+
private func schedulePopoverPositionUpdate() {
guard popover.isShown, !positioningUpdatePending else { return }
positioningUpdatePending = true
@@ -201,5 +210,6 @@ extension StatusItemController: NSPopoverDelegate {
positionedGeometry = nil
stopDismissMonitors()
popover.contentViewController = nil
+ history?.popoverDidClose()
}
}
diff --git a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
index 7292df0..f9a74d5 100644
--- a/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
+++ b/Sources/TokenGaugeApp/Resources/en.lproj/Localizable.strings
@@ -73,10 +73,19 @@
"updates.checking" = "Checking for updates…";
"updates.current" = "You’re up to date";
"updates.check_failed" = "Could not check. Try again";
-"updates.install" = "Install update · %@";
-"updates.downloading" = "Downloading update…";
"updates.installing" = "Installing and restarting…";
-"updates.retry" = "Update failed. Retry";
+"updates.banner_available" = "TokenGauge %@ available";
+"updates.download" = "Download";
+"updates.banner_downloading" = "Downloading %1$@ · %2$d%%";
+"updates.banner_downloading_pending" = "Downloading %@…";
+"updates.banner_extracting" = "Preparing %1$@ · %2$d%%";
+"updates.banner_extracting_pending" = "Preparing %@…";
+"updates.banner_ready" = "%@ is ready to install";
+"updates.install_now" = "Install now";
+"updates.later" = "Later";
+"updates.install_version" = "Install %@";
+"updates.failed" = "The update could not be installed";
+"updates.retry_action" = "Retry";
"about.description" = "Your Claude Code and Codex usage, at a glance. Quotas, reset times and activity from the menu bar.";
"about.local" = "Local history";
"about.private" = "Private by design";
@@ -93,7 +102,6 @@
"setup.claude_session" = "Uses your local Claude session";
"updates.local" = "Local installation";
"settings.mark_cancelled" = "Subscription cancelled";
-"activity.selected_scoped" = "%@: %@";
"view.unified" = "Unified";
"settings.display_mode" = "Menu bar view";
"settings.display_mode_help" = "Choose one provider or both in the menu bar and panel.";
@@ -114,6 +122,7 @@
"status.credential_expired" = "Connection pending";
"message.credential_expired" = "The shared session expired. Enable automatic reconnection in Settings or open Claude Code.";
"quota.last_remaining" = "last remaining";
+"quota.last_remaining_value" = "%@ last remaining";
"settings.claude_recovery" = "Automatic reconnection";
"settings.claude_recovery_help" = "Allow Claude Code to start briefly in the background when its session expires. No messages or model tokens. Requires an existing Claude Code login.";
"setup.claude_recovery_title" = "Allow automatic Claude reconnection?";
@@ -166,6 +175,12 @@
"history.equal_use" = "Equal recorded usage for both providers";
"history.weekends" = "Saturdays and Sundays";
"history.period" = "History period";
+"history.streak" = "Streak %1$@ d · best %2$@ d";
+"history.streak_current" = "Current streak: %@ d";
+"history.streak_longest" = "Longest streak: %@ d";
+"history.share" = "%@ of the day's total";
+"history.no_activity" = "No recorded activity";
+"history.close" = "Close";
"pace.last_value" = "Last ≈ %@ pp/h";
"pace.info_title" = "Weekly usage pace";
"pace.info_explanation" = "Estimates how quickly you use this allowance. For example, 5 pp/h means going from 80% to 75% remaining in one hour.";
diff --git a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
index 19bae90..360a00a 100644
--- a/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
+++ b/Sources/TokenGaugeApp/Resources/es.lproj/Localizable.strings
@@ -73,10 +73,19 @@
"updates.checking" = "Buscando actualizaciones…";
"updates.current" = "Tienes la última versión";
"updates.check_failed" = "No se pudo comprobar. Reintentar";
-"updates.install" = "Instalar actualización · %@";
-"updates.downloading" = "Descargando actualización…";
"updates.installing" = "Instalando y reiniciando…";
-"updates.retry" = "La actualización falló. Reintentar";
+"updates.banner_available" = "TokenGauge %@ disponible";
+"updates.download" = "Descargar";
+"updates.banner_downloading" = "Descargando %1$@ · %2$d %%";
+"updates.banner_downloading_pending" = "Descargando %@…";
+"updates.banner_extracting" = "Preparando %1$@ · %2$d %%";
+"updates.banner_extracting_pending" = "Preparando %@…";
+"updates.banner_ready" = "%@ lista para instalar";
+"updates.install_now" = "Instalar ahora";
+"updates.later" = "Después";
+"updates.install_version" = "Instalar %@";
+"updates.failed" = "No se pudo instalar la actualización";
+"updates.retry_action" = "Reintentar";
"about.description" = "Tu uso de Claude Code y Codex, de un vistazo. Cuotas, reinicios y actividad desde la barra de menú.";
"about.local" = "Historial local";
"about.private" = "Privado por diseño";
@@ -93,7 +102,6 @@
"setup.claude_session" = "Usa tu sesión local de Claude";
"updates.local" = "Instalación local";
"settings.mark_cancelled" = "Suscripción cancelada";
-"activity.selected_scoped" = "%@: %@";
"view.unified" = "Unificado";
"settings.display_mode" = "Vista de la barra";
"settings.display_mode_help" = "Elige un proveedor o ambos para la barra y el panel.";
@@ -114,6 +122,7 @@
"status.credential_expired" = "Conexión pendiente";
"message.credential_expired" = "La sesión compartida caducó. Activa la reconexión automática en Configuración o abre Claude Code.";
"quota.last_remaining" = "último saldo";
+"quota.last_remaining_value" = "%@ último saldo";
"settings.claude_recovery" = "Reconexión automática";
"settings.claude_recovery_help" = "Permite iniciar Claude Code brevemente en segundo plano si su sesión caduca. Sin enviar mensajes ni consumir tokens. Requiere haber iniciado sesión en Claude Code.";
"setup.claude_recovery_title" = "¿Permitir la reconexión automática de Claude?";
@@ -166,6 +175,12 @@
"history.equal_use" = "Mismo uso registrado en ambos proveedores";
"history.weekends" = "Sábados y domingos";
"history.period" = "Periodo del historial";
+"history.streak" = "Racha %1$@ d · mejor %2$@ d";
+"history.streak_current" = "Racha actual: %@ d";
+"history.streak_longest" = "Mejor racha: %@ d";
+"history.share" = "%@ del total del día";
+"history.no_activity" = "Sin actividad registrada";
+"history.close" = "Cerrar";
"pace.last_value" = "Último ≈ %@ pp/h";
"pace.info_title" = "Ritmo de uso semanal";
"pace.info_explanation" = "Estima qué tan rápido consumes esta cuota. Por ejemplo, 5 pp/h equivale a pasar de 80% a 75% libre en una hora.";
diff --git a/Sources/TokenGaugeApp/Services/AppPreferences.swift b/Sources/TokenGaugeApp/Services/AppPreferences.swift
new file mode 100644
index 0000000..aea92b0
--- /dev/null
+++ b/Sources/TokenGaugeApp/Services/AppPreferences.swift
@@ -0,0 +1,143 @@
+import Foundation
+import TokenGaugeCore
+
+struct AppPreferences {
+ enum Key {
+ static let primaryProvider = "primaryProvider"
+ static let displayMode = "displayMode"
+ static let menuBarSize = "menuBarSize"
+ static let quotaPanelStyle = "quotaPanelStyle"
+ static let quotaMenuBarStyle = "quotaMenuBarStyle"
+ static let quotaAnimateChanges = "quotaAnimateChanges"
+ static let historyMode = "historyMode"
+ static let showHourlyPace = "showHourlyPace"
+ static let showLunaReserve = "showLunaReserve"
+ static let hiddenClaudeWindows = "hiddenClaudeWindows"
+ static let claudeMenuBarSource = "claudeMenuBarSource"
+ static let claudeAutomaticRecovery = "claudeAutomaticRecovery"
+ static let claudeRecoveryNextAttempt = "claudeRecoveryNextAttempt"
+ static let claudeRecoveryFailures = "claudeRecoveryFailures"
+ static let appLanguage = "appLanguage"
+ static let codexExecutablePath = "codexExecutablePath"
+
+ static func cancelledAt(_ provider: UsageProvider) -> String {
+ "\(provider.rawValue)CancelledAt"
+ }
+
+ static func cancellationDailyBaseline(_ provider: UsageProvider) -> String {
+ "\(provider.rawValue)CancellationDailyBaseline"
+ }
+ }
+
+ let defaults: UserDefaults
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ }
+
+ var primaryProvider: UsageProvider {
+ get { value(Key.primaryProvider) ?? .codex }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.primaryProvider) }
+ }
+
+ var displayMode: UsageDisplayMode {
+ get {
+ let provider = primaryProvider
+ return UsageDisplayMode(rawValue: defaults.string(forKey: Key.displayMode) ?? provider.rawValue)
+ ?? (provider == .codex ? .codex : .claude)
+ }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.displayMode) }
+ }
+
+ var menuBarSize: MenuBarSize {
+ get { value(Key.menuBarSize) ?? .large }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.menuBarSize) }
+ }
+
+ var panelStyle: QuotaPanelStyle {
+ get { value(Key.quotaPanelStyle) ?? .standard }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.quotaPanelStyle) }
+ }
+
+ var menuBarStyle: QuotaMenuBarStyle {
+ get { value(Key.quotaMenuBarStyle) ?? .numbers }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.quotaMenuBarStyle) }
+ }
+
+ var animateChanges: Bool {
+ get { bool(Key.quotaAnimateChanges, default: true) }
+ nonmutating set { defaults.set(newValue, forKey: Key.quotaAnimateChanges) }
+ }
+
+ var historyMode: HistoryMode {
+ get { value(Key.historyMode) ?? .recent }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.historyMode) }
+ }
+
+ var showHourlyPace: Bool {
+ get { bool(Key.showHourlyPace, default: true) }
+ nonmutating set { defaults.set(newValue, forKey: Key.showHourlyPace) }
+ }
+
+ var showLunaReserve: Bool {
+ get { bool(Key.showLunaReserve, default: true) }
+ nonmutating set { defaults.set(newValue, forKey: Key.showLunaReserve) }
+ }
+
+ var hiddenClaudeWindows: Set {
+ get { ClaudeWindowKind.decode(defaults.stringArray(forKey: Key.hiddenClaudeWindows)) }
+ nonmutating set { defaults.set(newValue.map(\.rawValue).sorted(), forKey: Key.hiddenClaudeWindows) }
+ }
+
+ var claudeMenuBarSource: ClaudeMenuBarSource {
+ get { value(Key.claudeMenuBarSource) ?? .automatic }
+ nonmutating set { defaults.set(newValue.rawValue, forKey: Key.claudeMenuBarSource) }
+ }
+
+ var claudeAutomaticRecovery: Bool {
+ get { bool(Key.claudeAutomaticRecovery, default: false) }
+ nonmutating set { defaults.set(newValue, forKey: Key.claudeAutomaticRecovery) }
+ }
+
+ var claudeAutomaticRecoveryDecided: Bool {
+ defaults.object(forKey: Key.claudeAutomaticRecovery) != nil
+ }
+
+ var claudeRecoveryNextAttempt: Date? {
+ get { defaults.object(forKey: Key.claudeRecoveryNextAttempt) as? Date }
+ nonmutating set { replace(newValue, forKey: Key.claudeRecoveryNextAttempt) }
+ }
+
+ var claudeRecoveryFailures: Int {
+ get { defaults.integer(forKey: Key.claudeRecoveryFailures) }
+ nonmutating set { defaults.set(newValue, forKey: Key.claudeRecoveryFailures) }
+ }
+
+ func cancelledAt(_ provider: UsageProvider) -> Date? {
+ defaults.object(forKey: Key.cancelledAt(provider)) as? Date
+ }
+
+ func setCancelledAt(_ date: Date?, for provider: UsageProvider) {
+ replace(date, forKey: Key.cancelledAt(provider))
+ }
+
+ func cancellationDailyBaseline(_ provider: UsageProvider) -> [String: Int]? {
+ defaults.dictionary(forKey: Key.cancellationDailyBaseline(provider)) as? [String: Int]
+ }
+
+ func setCancellationDailyBaseline(_ totals: [String: Int]?, for provider: UsageProvider) {
+ replace(totals, forKey: Key.cancellationDailyBaseline(provider))
+ }
+
+ private func value(_ key: String) -> Value? where Value.RawValue == String {
+ defaults.string(forKey: key).flatMap(Value.init(rawValue:))
+ }
+
+ private func bool(_ key: String, default fallback: Bool) -> Bool {
+ defaults.object(forKey: key) == nil ? fallback : defaults.bool(forKey: key)
+ }
+
+ private func replace(_ value: Any?, forKey key: String) {
+ if let value { defaults.set(value, forKey: key) } else { defaults.removeObject(forKey: key) }
+ }
+}
diff --git a/Sources/TokenGaugeApp/Services/BlockingWork.swift b/Sources/TokenGaugeApp/Services/BlockingWork.swift
new file mode 100644
index 0000000..ccb7985
--- /dev/null
+++ b/Sources/TokenGaugeApp/Services/BlockingWork.swift
@@ -0,0 +1,46 @@
+import Foundation
+import os
+
+enum BlockingWork {
+ struct TimedOut: Error {}
+
+ private static let queue = DispatchQueue(
+ label: "com.stevenacz.TokenGauge.blocking-work", qos: .utility, attributes: .concurrent)
+
+ static func run(
+ _ work: @escaping @Sendable () throws -> Value
+ ) async throws -> Value {
+ try await withCheckedThrowingContinuation { continuation in
+ queue.async {
+ do {
+ continuation.resume(returning: try work())
+ } catch {
+ continuation.resume(throwing: error)
+ }
+ }
+ }
+ }
+
+ static func waitForResult(
+ timeout: TimeInterval,
+ _ operation: @escaping @Sendable () async throws -> Value
+ ) throws -> Value {
+ let outcome = OSAllocatedUnfairLock?>(initialState: nil)
+ let semaphore = DispatchSemaphore(value: 0)
+ let task = Task.detached(priority: .utility) {
+ do {
+ let value = try await operation()
+ outcome.withLock { $0 = .success(value) }
+ } catch {
+ outcome.withLock { $0 = .failure(error) }
+ }
+ semaphore.signal()
+ }
+ guard semaphore.wait(timeout: .now() + timeout) == .success else {
+ task.cancel()
+ throw TimedOut()
+ }
+ guard let result = outcome.withLock({ $0 }) else { throw TimedOut() }
+ return try result.get()
+ }
+}
diff --git a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift
index 0ebdb15..9eb37ed 100644
--- a/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift
+++ b/Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift
@@ -1,5 +1,6 @@
import Foundation
import TokenGaugeCore
+import os
enum ClaudeAccountUsageError: Error, Equatable {
case authenticationRequired
@@ -29,11 +30,15 @@ struct ClaudeAccountUsageClient: Sendable {
let configuration = URLSessionConfiguration.ephemeral
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
+ configuration.timeoutIntervalForResource = 12
return URLSession(configuration: configuration)
}()
+ private static let log = Logger(subsystem: "com.stevenacz.TokenGauge", category: "claude-account")
- func fetch(now: Date = Date(), identity: ClaudeAccountIdentity?) throws -> ClaudeAccountSnapshot {
- guard let token = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid) else {
+ func fetch(now: Date = Date(), identity: ClaudeAccountIdentity?) async throws -> ClaudeAccountSnapshot {
+ let accountUuid = identity?.accountUuid
+ guard let token = try await BlockingWork.run({ ClaudeOAuthTokenReader.read(accountUuid: accountUuid) })
+ else {
throw ClaudeAccountUsageError.authenticationRequired
}
guard !token.isExpired else { throw ClaudeAccountUsageError.credentialExpired }
@@ -46,34 +51,36 @@ struct ClaudeAccountUsageClient: Sendable {
let outcome: Data
do {
- outcome = try send(request)
+ outcome = try await Self.send(request)
} catch ClaudeAccountUsageError.authenticationRequired {
ClaudeOAuthTokenReader.invalidate()
- guard let fresh = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid), !fresh.isExpired,
- fresh.value != token.value
- else {
+ let refreshed = try await BlockingWork.run({ ClaudeOAuthTokenReader.read(accountUuid: accountUuid) })
+ guard let fresh = refreshed, !fresh.isExpired, fresh.value != token.value else {
throw ClaudeAccountUsageError.authenticationRequired
}
request.setValue("Bearer \(fresh.value)", forHTTPHeaderField: "Authorization")
- outcome = try send(request)
+ outcome = try await Self.send(request)
}
let windows = try Self.parseWindows(outcome)
let snapshot = ClaudeAccountSnapshot(
capturedAt: now, windows: windows, accountFingerprint: identity?.fingerprint)
if !windows.isEmpty {
- try? SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory))
+ let url = UsagePaths.claudeAccountCache(homeDirectory: homeDirectory)
+ do {
+ try SecureMetricStore.write(snapshot, to: url)
+ } catch {
+ Self.log.error("Could not write the Claude account cache at \(url.path, privacy: .public)")
+ }
}
return snapshot
}
static func parseWindows(_ data: Data) throws -> [QuotaWindow] {
do {
- if let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let limits = root["limits"] as? [Any], limits.isEmpty
- {
- return []
+ switch try ClaudeAccountUsageParser.parseLimits(data) {
+ case .empty: return []
+ case .windows(let windows): return windows
}
- return try ClaudeAccountUsageParser.parse(data)
} catch {
throw ClaudeAccountUsageError.unavailable
}
@@ -88,26 +95,20 @@ struct ClaudeAccountUsageClient: Sendable {
return snapshot
}
- private func send(_ request: URLRequest) throws -> Data {
- let semaphore = DispatchSemaphore(value: 0)
- nonisolated(unsafe) var payload: Data?
- nonisolated(unsafe) var status = 0
- let task = Self.session.dataTask(with: request) { data, response, _ in
- payload = data
- status = (response as? HTTPURLResponse)?.statusCode ?? 0
- semaphore.signal()
- }
- task.resume()
- guard semaphore.wait(timeout: .now() + 12) == .success else {
- task.cancel()
+ private static func send(_ request: URLRequest) async throws -> Data {
+ let payload: Data
+ let response: URLResponse
+ do {
+ (payload, response) = try await session.data(for: request)
+ } catch {
+ log.error("Claude usage request failed before a response arrived")
throw ClaudeAccountUsageError.unavailable
}
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard status == 200 else {
+ log.error("Claude usage endpoint returned status \(status, privacy: .public)")
throw ClaudeAccountUsageError.httpStatus(status)
}
- guard let payload else {
- throw ClaudeAccountUsageError.unavailable
- }
return payload
}
}
diff --git a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift
index 4ddbe99..8010d72 100644
--- a/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift
+++ b/Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift
@@ -1,21 +1,23 @@
import Foundation
import TokenGaugeCore
+import os
-final class ClaudeSessionRecovery: @unchecked Sendable {
+final class ClaudeSessionRecovery: Sendable {
static let shared = ClaudeSessionRecovery()
- private let lock = NSLock()
- private let defaults: UserDefaults
- private var running = false
+ private struct State {
+ var running = false
+ let preferences: AppPreferences
+ }
+
+ private let state: OSAllocatedUnfairLock
init(defaults: UserDefaults = .standard) {
- self.defaults = defaults
+ state = OSAllocatedUnfairLock(uncheckedState: State(preferences: AppPreferences(defaults: defaults)))
}
var isRunning: Bool {
- lock.lock()
- defer { lock.unlock() }
- return running
+ state.withLock { $0.running }
}
func attempt(homeDirectory: URL, authorization: ClaudeRecoveryAuthorization) -> Bool {
@@ -34,23 +36,22 @@ final class ClaudeSessionRecovery: @unchecked Sendable {
}
func attempt(now: Date, run: () -> Bool) -> Bool {
- lock.lock()
- let nextAttempt = defaults.object(forKey: "claudeRecoveryNextAttempt") as? Date ?? .distantPast
- guard !running, now >= nextAttempt else {
- lock.unlock()
- return false
+ let failures: Int? = state.withLock { state in
+ let nextAttempt = state.preferences.claudeRecoveryNextAttempt ?? .distantPast
+ guard !state.running, now >= nextAttempt else { return nil }
+ state.running = true
+ let failures = min(max(state.preferences.claudeRecoveryFailures, 0), 2)
+ let delay: TimeInterval = [300, 900, 3600][failures]
+ state.preferences.claudeRecoveryNextAttempt = now.addingTimeInterval(delay)
+ return failures
}
- running = true
- let failures = min(max(defaults.integer(forKey: "claudeRecoveryFailures"), 0), 2)
- let delay: TimeInterval = [300, 900, 3600][failures]
- defaults.set(now.addingTimeInterval(delay), forKey: "claudeRecoveryNextAttempt")
- lock.unlock()
+ guard let failures else { return false }
let recovered = run()
- lock.lock()
- defaults.set(recovered ? 0 : failures + 1, forKey: "claudeRecoveryFailures")
- running = false
- lock.unlock()
+ state.withLock { state in
+ state.preferences.claudeRecoveryFailures = recovered ? 0 : failures + 1
+ state.running = false
+ }
return recovered
}
@@ -65,23 +66,18 @@ final class ClaudeSessionRecovery: @unchecked Sendable {
}
}
-final class ClaudeRecoveryAuthorization: @unchecked Sendable {
- private let lock = NSLock()
- private var allowed: Bool
+final class ClaudeRecoveryAuthorization: Sendable {
+ private let allowed: OSAllocatedUnfairLock
init(allowed: Bool = false) {
- self.allowed = allowed
+ self.allowed = OSAllocatedUnfairLock(initialState: allowed)
}
var isAllowed: Bool {
- lock.lock()
- defer { lock.unlock() }
- return allowed
+ allowed.withLock { $0 }
}
func setAllowed(_ value: Bool) {
- lock.lock()
- allowed = value
- lock.unlock()
+ allowed.withLock { $0 = value }
}
}
diff --git a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift
index 0ab8390..6ff99f0 100644
--- a/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift
+++ b/Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift
@@ -38,23 +38,49 @@ struct ClaudeUsageClient: Sendable {
func fetch(
now: Date = Date(), recoveryAuthorization: ClaudeRecoveryAuthorization = ClaudeRecoveryAuthorization()
- ) throws -> ClaudeUsageResult {
+ ) async -> ClaudeUsageResult {
+ let homeDirectory = homeDirectory
let identity = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory)
- let buckets = try? fetchModelBuckets()
let account = ClaudeAccountUsageClient(homeDirectory: homeDirectory)
- let outcome = Self.readAccount(
- fetch: { try account.fetch(now: Date(), identity: identity) },
- recover: {
- ClaudeSessionRecovery.shared.attempt(homeDirectory: homeDirectory, authorization: recoveryAuthorization)
- }
+ return await Self.fetch(
+ history: { try? await BlockingWork.run { try self.fetchModelBuckets() } },
+ account: {
+ await Self.readAccount(
+ fetch: { try await account.fetch(now: Date(), identity: identity) },
+ recover: {
+ (try? await BlockingWork.run {
+ ClaudeSessionRecovery.shared.attempt(
+ homeDirectory: homeDirectory, authorization: recoveryAuthorization)
+ }) ?? false
+ }
+ )
+ },
+ cached: { account.cached(identity: identity) },
+ capture: {
+ Self.capture(
+ at: UsagePaths.claudeCapture(homeDirectory: homeDirectory),
+ accountFingerprint: identity?.fingerprint)
+ },
+ now: now, accountFingerprint: identity?.fingerprint, accountLabel: identity?.label
)
- let capture = Self.capture(
- at: UsagePaths.claudeCapture(homeDirectory: homeDirectory),
- accountFingerprint: identity?.fingerprint)
- return Self.resolve(
- account: outcome, cached: account.cached(identity: identity), capture: capture,
- modelBuckets: buckets ?? [], now: now, activityReadSucceeded: buckets != nil,
- accountFingerprint: identity?.fingerprint, accountLabel: identity?.label
+ }
+
+ static func fetch(
+ history: @escaping @Sendable () async -> [ModelTokenBucket]?,
+ account: @escaping @Sendable () async -> Result,
+ cached: @escaping @Sendable () -> ClaudeAccountSnapshot?,
+ capture: @escaping @Sendable () -> ClaudeCapturedSnapshot?,
+ now: Date,
+ accountFingerprint: String? = nil,
+ accountLabel: String? = nil
+ ) async -> ClaudeUsageResult {
+ async let buckets = history()
+ async let outcome = account()
+ let (modelBuckets, resolved) = await (buckets, outcome)
+ return resolve(
+ account: resolved, cached: cached(), capture: capture(), modelBuckets: modelBuckets ?? [], now: now,
+ activityReadSucceeded: modelBuckets != nil, accountFingerprint: accountFingerprint,
+ accountLabel: accountLabel
)
}
@@ -66,15 +92,19 @@ struct ClaudeUsageClient: Sendable {
}
static func readAccount(
- fetch: () throws -> ClaudeAccountSnapshot,
- recover: () -> Bool
- ) -> Result {
+ fetch: () async throws -> ClaudeAccountSnapshot,
+ recover: () async -> Bool
+ ) async -> Result {
do {
- return .success(try fetch())
+ return .success(try await fetch())
} catch ClaudeAccountUsageError.credentialExpired {
- guard recover() else { return .failure(ClaudeAccountUsageError.credentialExpired) }
+ guard await recover() else { return .failure(ClaudeAccountUsageError.credentialExpired) }
ClaudeOAuthTokenReader.invalidate()
- return Result { try fetch() }
+ do {
+ return .success(try await fetch())
+ } catch {
+ return .failure(error)
+ }
} catch {
return .failure(error)
}
@@ -178,8 +208,8 @@ struct ClaudeUsageClient: Sendable {
static func historyBuckets(
executable: URL,
- arguments: [String] = ["--history"],
- timeout: TimeInterval = 20,
+ arguments: [String] = ["--collect"],
+ timeout: TimeInterval = 45,
workingDirectory: URL
) throws -> [ModelTokenBucket] {
let result: ProcessResult
@@ -196,11 +226,14 @@ struct ClaudeUsageClient: Sendable {
guard result.exitCode == 0 else {
throw UsageDataError.processFailed("History helper exited with status \(result.exitCode)")
}
+ let collection: CaptureCollection
do {
- return try JSONDecoder().decode([ModelTokenBucket].self, from: result.standardOutput)
+ collection = try JSONDecoder().decode(CaptureCollection.self, from: result.standardOutput)
} catch {
throw UsageDataError.invalidPayload
}
+ EffortHistoryClient.markRecorded(by: collection)
+ return collection.buckets
}
private func resolveCaptureExecutable() -> URL? {
diff --git a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift
index 3d39278..05007b5 100644
--- a/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift
+++ b/Sources/TokenGaugeApp/Services/CodexAppServerClient.swift
@@ -1,10 +1,13 @@
import Darwin
import Foundation
import TokenGaugeCore
+import os
struct CodexAppServerClient: Sendable {
let homeDirectory: URL
+ private static let log = Logger(subsystem: "com.stevenacz.TokenGauge", category: "codex")
+
init(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) {
self.homeDirectory = homeDirectory
}
@@ -32,7 +35,12 @@ struct CodexAppServerClient: Sendable {
throw UsageDataError.processFailed("Codex app-server exited with status \(result.exitCode)")
}
let snapshot = try CodexUsageParser.parse(result.standardOutput, capturedAt: capturedAt)
- try? SecureMetricStore.write(snapshot, to: UsagePaths.codexCache(homeDirectory: homeDirectory))
+ let cache = UsagePaths.codexCache(homeDirectory: homeDirectory)
+ do {
+ try SecureMetricStore.write(snapshot, to: cache)
+ } catch {
+ Self.log.error("Could not write the Codex cache at \(cache.path, privacy: .public)")
+ }
return snapshot
}
@@ -110,8 +118,10 @@ enum ProcessRunner {
let deadline = ProcessInfo.processInfo.systemUptime + timeout
var output = Data()
var responseIDs: Set = []
+ var scanned = 0
var inputClosed = false
var outputClosed = false
+ var buffer = [UInt8](repeating: 0, count: 65_536)
let outputHandle = outputPipe.fileHandleForReading
while ProcessInfo.processInfo.systemUptime < deadline {
@@ -131,15 +141,16 @@ enum ProcessRunner {
throw UsageDataError.processFailed("Could not read Codex app-server output")
}
guard ready > 0 else { continue }
- if let chunk = readAvailableData(from: outputHandle.fileDescriptor) {
+ if let chunk = readAvailableData(from: outputHandle.fileDescriptor, into: &buffer) {
if chunk.isEmpty {
outputClosed = true
+ responseIDs.formUnion(responseIdentifiers(in: output, from: &scanned, includingTail: true))
} else {
guard output.count + chunk.count <= 4 * 1024 * 1024 else {
throw UsageDataError.processFailed("Codex app-server response exceeded the size limit")
}
output.append(chunk)
- responseIDs.formUnion(responseIdentifiers(in: output))
+ responseIDs.formUnion(responseIdentifiers(in: output, from: &scanned))
}
}
}
@@ -157,9 +168,14 @@ enum ProcessRunner {
)
}
- private static func responseIdentifiers(in data: Data) -> Set {
- Set(
- data.split(separator: 0x0A).compactMap { line in
+ static func responseIdentifiers(in data: Data, from offset: inout Int, includingTail: Bool = false) -> Set {
+ let start = data.startIndex + offset
+ let end = includingTail ? data.endIndex : data[start...].lastIndex(of: 0x0A).map { $0 + 1 } ?? start
+ guard end > start else { return [] }
+ let lines = data[start.. Data? {
- var bytes = [UInt8](repeating: 0, count: 65_536)
- let count = Darwin.read(descriptor, &bytes, bytes.count)
+ private static func readAvailableData(from descriptor: Int32, into buffer: inout [UInt8]) -> Data? {
+ let count = Darwin.read(descriptor, &buffer, buffer.count)
guard count >= 0 else { return nil }
- return Data(bytes.prefix(count))
+ return Data(buffer.prefix(count))
}
private static func stop(_ process: Process) {
diff --git a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift
index d7986a0..205f038 100644
--- a/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift
+++ b/Sources/TokenGaugeApp/Services/EffortHistoryClient.swift
@@ -3,16 +3,29 @@ import TokenGaugeCore
import os
enum EffortHistoryClient {
- private static let collecting = OSAllocatedUnfairLock(initialState: false)
+ private struct State {
+ var collecting = false
+ var recordedByCapture = false
+ }
+
+ private static let state = OSAllocatedUnfairLock(initialState: State())
+
+ static func markRecorded(by collection: CaptureCollection) {
+ state.withLock { $0.recordedByCapture = collection.effortRecords != nil }
+ }
static func collect(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) throws {
- let acquired = collecting.withLock { active in
- guard !active else { return false }
- active = true
+ let acquired = state.withLock { state in
+ if state.recordedByCapture {
+ state.recordedByCapture = false
+ return false
+ }
+ guard !state.collecting else { return false }
+ state.collecting = true
return true
}
guard acquired else { return }
- defer { collecting.withLock { $0 = false } }
+ defer { state.withLock { $0.collecting = false } }
guard let directory = Bundle.main.executableURL?.deletingLastPathComponent() else {
throw UsageDataError.executableNotFound
}
diff --git a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift
index d0b8b23..6b12f1e 100644
--- a/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift
+++ b/Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift
@@ -34,10 +34,67 @@ struct HistoryCalendarDay: Identifiable, Equatable, Sendable {
}
}
+// Rects relative to the calendar view's top-left corner; `bounds` is the popover root, so it may start negative.
+struct HistoryDayCardAnchor: Equatable, Sendable {
+ var cell: CGRect
+ var bounds: CGRect
+}
+
+struct HistoryDaySelection: Equatable, Sendable {
+ var dayKey: String
+ var isPinned: Bool
+ var anchor: HistoryDayCardAnchor?
+}
+
+struct HistoryUsageDays: Equatable, Sendable {
+ var claude: Set = []
+ var codex: Set = []
+ var any: Set { claude.union(codex) }
+
+ func days(for providers: [UsageProvider]) -> Set {
+ if providers == [.claude] { return claude }
+ if providers == [.codex] { return codex }
+ return any
+ }
+}
+
+struct HistoryStreak: Equatable, Sendable {
+ var current = 0
+ var longest = 0
+}
+
+struct HistoryStreaks: Equatable, Sendable {
+ var claude = HistoryStreak()
+ var codex = HistoryStreak()
+ var unified = HistoryStreak()
+
+ init() {}
+
+ init(usageDays: HistoryUsageDays, today: String, calendar: Calendar = .current) {
+ claude = Self.streak(usageDays.claude, today: today, calendar: calendar)
+ codex = Self.streak(usageDays.codex, today: today, calendar: calendar)
+ unified = Self.streak(usageDays.any, today: today, calendar: calendar)
+ }
+
+ func streak(for providers: [UsageProvider]) -> HistoryStreak {
+ if providers == [.claude] { return claude }
+ if providers == [.codex] { return codex }
+ return unified
+ }
+
+ private static func streak(_ days: Set, today: String, calendar: Calendar) -> HistoryStreak {
+ let value = HistoryAnalytics.streaks(usageDays: days, today: today, calendar: calendar)
+ return HistoryStreak(current: value.current, longest: value.longest)
+ }
+}
+
@MainActor
final class HistoryDashboardModel: ObservableObject {
@Published var offset = 0
@Published var selectedDayKey: String?
+ @Published var daySelection: HistoryDaySelection?
+ @Published private(set) var usageDays = HistoryUsageDays()
+ @Published private(set) var streaks = HistoryStreaks()
@Published private(set) var days: [HistoryCalendarDay] = []
@Published private(set) var isLoading = false
@Published private(set) var loadedMode: HistoryMode?
@@ -50,15 +107,17 @@ final class HistoryDashboardModel: ObservableObject {
private var savedPaces: [String: [QuotaPace]] = [:]
private var paceActivity: [String: Bool] = [:]
private let previewEfforts: [HistoryEffortRow]
+ private let historyURL: URL
private var cachedReads: [String: ReadResult] = [:]
private var cacheOrder: [String] = []
private var cacheRevision: Int?
init(
previewSnapshots: [ProviderUsageSnapshot]? = nil, mode: HistoryMode = .recent,
- previewEfforts: [HistoryEffortRow] = [], now: Date = Date()
+ previewEfforts: [HistoryEffortRow] = [], now: Date = Date(), historyURL: URL = UsagePaths.history()
) {
self.previewEfforts = previewEfforts
+ self.historyURL = historyURL
if let snapshots = previewSnapshots {
let interval = Self.interval(mode: mode, offset: 0, now: now)
let rows = snapshots.flatMap { snapshot in
@@ -67,6 +126,8 @@ final class HistoryDashboardModel: ObservableObject {
}
}
days = Self.makeDays(interval: interval, tokens: rows, efforts: previewEfforts)
+ usageDays = Self.usageDays(tokens: rows)
+ streaks = HistoryStreaks(usageDays: usageDays, today: Self.dayKey(now))
loadedMode = mode
periodStart = interval.start
periodEnd = interval.end
@@ -92,6 +153,38 @@ final class HistoryDashboardModel: ObservableObject {
selectedDayKey = nil
}
+ func streak(for providers: [UsageProvider]) -> HistoryStreak {
+ streaks.streak(for: providers)
+ }
+
+ func showDayCard(_ dayKey: String, anchor: HistoryDayCardAnchor?) {
+ guard daySelection?.isPinned != true else { return }
+ daySelection = HistoryDaySelection(dayKey: dayKey, isPinned: false, anchor: anchor)
+ }
+
+ func hideDayCard() {
+ guard let selection = daySelection, !selection.isPinned else { return }
+ daySelection = nil
+ }
+
+ func pinDayCard(_ dayKey: String, anchor: HistoryDayCardAnchor?) {
+ if let current = daySelection, current.isPinned, current.dayKey == dayKey {
+ daySelection = nil
+ return
+ }
+ daySelection = HistoryDaySelection(dayKey: dayKey, isPinned: true, anchor: anchor)
+ }
+
+ func dismissDayCard() {
+ guard daySelection != nil else { return }
+ daySelection = nil
+ }
+
+ func popoverDidClose() {
+ daySelection = nil
+ selectedDayKey = nil
+ }
+
func pace(provider: UsageProvider, window: QuotaWindow, capturedAt: Date?) -> QuotaPace? {
let key = provider.rawValue + ":" + window.id
guard let current = Self.usablePace(paces[key], for: window, capturedAt: capturedAt)
@@ -131,13 +224,14 @@ final class HistoryDashboardModel: ObservableObject {
let interval = Self.interval(mode: mode, offset: offset, now: now)
let first = Self.dayKey(interval.start)
let last = Self.dayKey(interval.end.addingTimeInterval(-1))
+ let today = Self.dayKey(now)
if cacheRevision != revision {
cachedReads.removeAll(keepingCapacity: true)
cacheOrder.removeAll(keepingCapacity: true)
cacheRevision = revision
}
let cacheKey =
- first + ":" + last + ":" + (accountFingerprint ?? "")
+ first + ":" + last + ":" + today + ":" + (accountFingerprint ?? "")
+ ":" + paceKeys.map(\.id).sorted().joined(separator: "|")
do {
let result: ReadResult
@@ -151,19 +245,25 @@ final class HistoryDashboardModel: ObservableObject {
}
result = ReadResult(
days: Self.makeDays(interval: interval, tokens: rows, efforts: previewEfforts),
- latest: [], retained: [], first: rows.map(\.day).min())
+ latest: [], retained: [], first: rows.map(\.day).min(), usage: Self.usageDays(tokens: rows),
+ today: today)
} else {
+ let url = historyURL
result = try await Task.detached(priority: .utility) {
- let tokens = try UsageHistoryStore.tokenRows(since: first, through: last)
- let efforts = try UsageHistoryStore.effortRows(since: first, through: last)
+ let tokens = try UsageHistoryStore.tokenRows(since: first, through: last, at: url)
+ let efforts = try UsageHistoryStore.effortRows(since: first, through: last, at: url)
+ let usage = try UsageHistoryStore.usageDaysByProvider(at: url)
return ReadResult(
days: Self.makeDays(interval: interval, tokens: tokens, efforts: efforts),
latest: try UsageHistoryStore.recentPaces(
for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true,
- accountFingerprint: accountFingerprint),
+ accountFingerprint: accountFingerprint, at: url),
retained: try UsageHistoryStore.recentPaces(
- for: paceKeys, activeOnly: true, before: now, accountFingerprint: accountFingerprint),
- first: try UsageHistoryStore.bounds().firstDay)
+ for: paceKeys, activeOnly: true, before: now, accountFingerprint: accountFingerprint,
+ at: url),
+ first: try UsageHistoryStore.bounds(at: url).firstDay,
+ usage: HistoryUsageDays(claude: usage[.claude] ?? [], codex: usage[.codex] ?? []),
+ today: today)
}.value
}
guard !Task.isCancelled, generation == request else { return }
@@ -176,6 +276,8 @@ final class HistoryDashboardModel: ObservableObject {
periodEnd = interval.end
firstRecordedDay = result.first
days = result.days
+ usageDays = result.usage
+ streaks = result.streaks
loadedMode = mode
paces = Dictionary(uniqueKeysWithValues: result.latest.map { ($0.key.id, $0.pace) })
paceActivity = Dictionary(uniqueKeysWithValues: result.latest.map { ($0.key.id, $0.isActive) })
@@ -188,6 +290,8 @@ final class HistoryDashboardModel: ObservableObject {
isLoading = false
paces = [:]
days = []
+ usageDays = HistoryUsageDays()
+ streaks = HistoryStreaks()
}
}
@@ -212,17 +316,15 @@ final class HistoryDashboardModel: ObservableObject {
}
}
- nonisolated static func dayKey(_ date: Date, calendar: Calendar = .current) -> String {
- dayFormatter(calendar: calendar).string(from: date)
+ private nonisolated static var dayCalendar: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = .current
+ return calendar
}
- nonisolated private static func dayFormatter(calendar: Calendar) -> DateFormatter {
- let formatter = DateFormatter()
- formatter.calendar = calendar
- formatter.timeZone = calendar.timeZone
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.dateFormat = "yyyy-MM-dd"
- return formatter
+ nonisolated static func dayKey(_ date: Date, calendar: Calendar = dayCalendar) -> String {
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04ld-%02ld-%02ld", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}
nonisolated static func makeDays(
@@ -230,11 +332,10 @@ final class HistoryDashboardModel: ObservableObject {
) -> [HistoryCalendarDay] {
let tokenDays = Dictionary(grouping: tokens, by: \.day)
let effortDays = Dictionary(grouping: efforts, by: \.day)
- let formatter = dayFormatter(calendar: calendar)
var date = interval.start
var result: [HistoryCalendarDay] = []
while date < interval.end {
- let key = formatter.string(from: date)
+ let key = dayKey(date, calendar: calendar)
var totals: [UsageProvider: Int] = [:]
for provider in UsageProvider.allCases {
let rows = (tokenDays[key] ?? []).filter { $0.provider == provider && $0.tokens >= 0 }
@@ -251,6 +352,13 @@ final class HistoryDashboardModel: ObservableObject {
return result
}
+ nonisolated static func usageDays(tokens: [HistoryTokenRow]) -> HistoryUsageDays {
+ let used = tokens.filter { $0.tokens > 0 }
+ let claude = Set(used.filter { $0.provider == .claude }.map(\.day))
+ let codex = Set(used.filter { $0.provider == .codex }.map(\.day))
+ return HistoryUsageDays(claude: claude, codex: codex)
+ }
+
nonisolated private static func sum(_ values: [Int]) -> Int {
values.reduce(0) { partial, value in
let addition = partial.addingReportingOverflow(value)
@@ -263,5 +371,19 @@ final class HistoryDashboardModel: ObservableObject {
let latest: [HistoryPaceRow]
let retained: [HistoryPaceRow]
let first: String?
+ let usage: HistoryUsageDays
+ let streaks: HistoryStreaks
+
+ init(
+ days: [HistoryCalendarDay], latest: [HistoryPaceRow], retained: [HistoryPaceRow], first: String?,
+ usage: HistoryUsageDays, today: String
+ ) {
+ self.days = days
+ self.latest = latest
+ self.retained = retained
+ self.first = first
+ self.usage = usage
+ streaks = HistoryStreaks(usageDays: usage, today: today)
+ }
}
}
diff --git a/Sources/TokenGaugeApp/Services/LocalizationManager.swift b/Sources/TokenGaugeApp/Services/LocalizationManager.swift
index 6ffb26f..a2eac28 100644
--- a/Sources/TokenGaugeApp/Services/LocalizationManager.swift
+++ b/Sources/TokenGaugeApp/Services/LocalizationManager.swift
@@ -21,7 +21,7 @@ final class LocalizationManager: ObservableObject {
@Published var language: AppLanguage {
didSet {
- UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
+ UserDefaults.standard.set(language.rawValue, forKey: AppPreferences.Key.appLanguage)
bundle = Self.bundle(for: language)
}
}
@@ -29,7 +29,7 @@ final class LocalizationManager: ObservableObject {
@Published private(set) var bundle: Bundle
private init() {
- let stored = UserDefaults.standard.string(forKey: "appLanguage")
+ let stored = UserDefaults.standard.string(forKey: AppPreferences.Key.appLanguage)
let language = AppLanguage(rawValue: stored ?? "") ?? AppLanguage.preferred(from: Locale.preferredLanguages)
self.language = language
bundle = Self.bundle(for: language)
diff --git a/Sources/TokenGaugeApp/Services/RefreshScheduler.swift b/Sources/TokenGaugeApp/Services/RefreshScheduler.swift
new file mode 100644
index 0000000..623f2b0
--- /dev/null
+++ b/Sources/TokenGaugeApp/Services/RefreshScheduler.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+@MainActor
+final class RefreshScheduler {
+ private let interval: TimeInterval
+ private let fire: @MainActor () -> Void
+ private var periodicTimer: Timer?
+ private var resetTimer: Timer?
+
+ init(interval: TimeInterval = 300, fire: @escaping @MainActor () -> Void) {
+ self.interval = interval
+ self.fire = fire
+ }
+
+ func start() {
+ periodicTimer?.invalidate()
+ periodicTimer = timer(after: interval, repeats: true)
+ }
+
+ func scheduleResetRefresh(at reset: Date?, now: Date = Date()) {
+ resetTimer?.invalidate()
+ resetTimer = reset.map { timer(after: max($0.timeIntervalSince(now) + 5, 1), repeats: false) }
+ }
+
+ func stop() {
+ periodicTimer?.invalidate()
+ resetTimer?.invalidate()
+ periodicTimer = nil
+ resetTimer = nil
+ }
+
+ private func timer(after delay: TimeInterval, repeats: Bool) -> Timer {
+ Timer.scheduledTimer(withTimeInterval: delay, repeats: repeats) { [weak self] _ in
+ Task { @MainActor in
+ self?.fire()
+ }
+ }
+ }
+}
diff --git a/Sources/TokenGaugeApp/Services/UpdateManager.swift b/Sources/TokenGaugeApp/Services/UpdateManager.swift
index 45048f5..283726d 100644
--- a/Sources/TokenGaugeApp/Services/UpdateManager.swift
+++ b/Sources/TokenGaugeApp/Services/UpdateManager.swift
@@ -4,6 +4,14 @@ import Foundation
import Sparkle
import os
+@MainActor
+protocol UpdaterSession: AnyObject {
+ var sessionInProgress: Bool { get }
+ func checkForUpdates()
+}
+
+extension SPUUpdater: UpdaterSession {}
+
@MainActor
final class UpdateManager: ObservableObject {
@@ -11,10 +19,24 @@ final class UpdateManager: ObservableObject {
enum Phase: Equatable {
case idle
+ case checking
case available(version: String)
- case downloading(fraction: Double?)
+ case downloading(version: String, fraction: Double?)
+ case extracting(version: String, fraction: Double?)
+ case readyToInstall(version: String, deferred: Bool)
+ case installing(version: String)
+ case failed(message: String)
+ }
+
+ enum Stage: Equatable {
+ case notDownloaded
+ case downloaded
case installing
- case failed(version: String)
+ }
+
+ struct FoundDecision: Equatable {
+ let choice: SPUUserUpdateChoice
+ let phase: Phase
}
enum ManualCheckStatus: Equatable {
@@ -25,6 +47,10 @@ final class UpdateManager: ObservableObject {
}
static let autoCheckDefaultsKey = "autoUpdateCheckEnabled"
+ nonisolated static let feedOverrideDefaultsKey = "updateFeedURLOverride"
+ static let deferredVersionDefaultsKey = "deferredUpdateVersion"
+ static let progressPublishInterval: TimeInterval = 0.05
+ static let failureMessageKey = "updates.failed"
@Published private(set) var phase: Phase = .idle
@Published private(set) var releasePageURL: URL?
@@ -34,25 +60,36 @@ final class UpdateManager: ObservableObject {
let isDevelopmentBuild =
Bundle.main.object(forInfoDictionaryKey: "TokenGaugeDevelopmentBuild") as? Bool ?? true
var available: Bool {
- !isDevelopmentBuild || Self.qaFeedURL(environment: ProcessInfo.processInfo.environment) != nil
+ !isDevelopmentBuild
+ || Self.feedURL(
+ environment: ProcessInfo.processInfo.environment,
+ override: defaults.string(forKey: Self.feedOverrideDefaultsKey),
+ isDevelopmentBuild: isDevelopmentBuild
+ ) != nil
}
private let defaults: UserDefaults
+ private let now: () -> Date
private let log = Logger(subsystem: "com.stevenacz.TokenGauge", category: "updates")
private var updater: SPUUpdater?
+ private var session: (any UpdaterSession)?
private var driver: Driver?
private var updaterDelegate: UpdaterDelegate?
private var installRequested = false
+ private var resumeInstallRequested = false
+ private var readyReply: ((SPUUserUpdateChoice) -> Void)?
private var pendingVersion: String?
private var pendingIsInformationOnly = false
private var expectedDownloadBytes: UInt64 = 0
private var receivedDownloadBytes: UInt64 = 0
+ private var lastProgressPublish = Date.distantPast
private var manualCheckPending = false
private var manualCheckResetTask: Task?
- init(defaults: UserDefaults = .standard) {
+ init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
self.defaults = defaults
+ self.now = now
if defaults.object(forKey: Self.autoCheckDefaultsKey) == nil {
autoCheckEnabled = true
} else {
@@ -70,6 +107,39 @@ final class UpdateManager: ObservableObject {
return raw
}
+ nonisolated static func feedURL(
+ environment: [String: String],
+ override: String?,
+ isDevelopmentBuild: Bool
+ ) -> String? {
+ if isDevelopmentBuild, let qa = qaFeedURL(environment: environment) { return qa }
+ if let override, let url = URL(string: override), url.scheme == "https" {
+ return override
+ }
+ return nil
+ }
+
+ nonisolated static func decideUpdateFound(
+ version: String,
+ stage: Stage,
+ installRequested: Bool,
+ informationOnly: Bool
+ ) -> FoundDecision {
+ if informationOnly {
+ return FoundDecision(choice: .dismiss, phase: .available(version: version))
+ }
+ if installRequested {
+ let phase: Phase =
+ stage == .notDownloaded
+ ? .downloading(version: version, fraction: nil) : .installing(version: version)
+ return FoundDecision(choice: .install, phase: phase)
+ }
+ if stage == .downloaded {
+ return FoundDecision(choice: .dismiss, phase: .readyToInstall(version: version, deferred: true))
+ }
+ return FoundDecision(choice: .dismiss, phase: .available(version: version))
+ }
+
func start() {
guard updater == nil, available else { return }
@@ -96,6 +166,13 @@ final class UpdateManager: ObservableObject {
self.driver = driver
self.updaterDelegate = updaterDelegate
self.updater = updater
+ session = updater
+
+ if deferredVersion != nil { updater.checkForUpdatesInBackground() }
+ }
+
+ func setSession(_ session: any UpdaterSession) {
+ self.session = session
}
func setAutoCheckEnabled(_ enabled: Bool) {
@@ -115,22 +192,64 @@ final class UpdateManager: ObservableObject {
updater.checkForUpdates()
}
+ func installReadyUpdate() {
+ guard case .readyToInstall(let version, let deferred) = phase else { return }
+ guard !deferred else {
+ resumeDeferredInstall()
+ return
+ }
+ installRequested = false
+ resumeInstallRequested = false
+ deferredVersion = nil
+ phase = .installing(version: version)
+ let reply = readyReply
+ readyReply = nil
+ reply?(.install)
+ }
+
+ func deferReadyUpdate() {
+ guard case .readyToInstall(let version, false) = phase else { return }
+ installRequested = false
+ resumeInstallRequested = false
+ let reply = readyReply
+ readyReply = nil
+ reply?(.dismiss)
+ deferredVersion = version
+ phase = .readyToInstall(version: version, deferred: true)
+ }
+
+ func resumeDeferredInstall() {
+ guard case .readyToInstall(let version, true) = phase else { return }
+ guard let session, session.sessionInProgress == false else { return }
+ resumeInstallRequested = true
+ installRequested = true
+ phase = .installing(version: version)
+ session.checkForUpdates()
+ }
+
func checkForUpdatesManually() {
guard available else { return }
- guard let updater else {
+ guard let session else {
handleManualCheckStarted()
finishManualCheck(status: .failed)
+ phase = idleOrPendingPhase
+ return
+ }
+ guard session.sessionInProgress == false else {
+ manualCheckResetTask?.cancel()
+ manualCheckPending = true
+ finishManualCheck(status: .failed)
return
}
- guard updater.sessionInProgress == false else { return }
handleManualCheckStarted()
- updater.checkForUpdates()
+ session.checkForUpdates()
}
func handleManualCheckStarted() {
manualCheckResetTask?.cancel()
manualCheckPending = true
manualCheckStatus = .checking
+ if phase == .idle { phase = .checking }
}
func openReleasePage() {
@@ -140,31 +259,33 @@ final class UpdateManager: ObservableObject {
func handleInstallRequested() {
installRequested = true
- phase = .downloading(fraction: nil)
+ phase = .downloading(version: pendingVersion ?? "", fraction: nil)
}
func handleUpdateFound(
version: String,
releasePage: URL?,
- informationOnly: Bool
+ informationOnly: Bool,
+ stage: Stage = .notDownloaded
) -> SPUUserUpdateChoice {
pendingVersion = version
pendingIsInformationOnly = informationOnly
releasePageURL = releasePage
finishManualCheck(status: .idle)
- if installRequested && !informationOnly {
- return .install
- }
- installRequested = false
- phase = .available(version: version)
- return .dismiss
+ let decision = Self.decideUpdateFound(
+ version: version, stage: stage, installRequested: installRequested, informationOnly: informationOnly)
+ installRequested = decision.choice == .install
+ deferredVersion = decision.phase == .readyToInstall(version: version, deferred: true) ? version : nil
+ phase = decision.phase
+ return decision.choice
}
func handleDownloadInitiated() {
expectedDownloadBytes = 0
receivedDownloadBytes = 0
- phase = .downloading(fraction: nil)
+ lastProgressPublish = .distantPast
+ phase = .downloading(version: pendingVersion ?? "", fraction: nil)
}
func handleDownloadExpectedLength(_ length: UInt64) {
@@ -175,60 +296,108 @@ final class UpdateManager: ObservableObject {
receivedDownloadBytes += bytes
guard expectedDownloadBytes > 0 else { return }
let fraction = min(1.0, Double(receivedDownloadBytes) / Double(expectedDownloadBytes))
- phase = .downloading(fraction: fraction)
+ publish(.downloading(version: pendingVersion ?? "", fraction: fraction), force: fraction >= 1)
}
func handleExtractionStarted() {
- phase = .installing
+ lastProgressPublish = .distantPast
+ if case .downloading(let version, let fraction) = phase, fraction != 1 {
+ phase = .downloading(version: version, fraction: 1)
+ }
+ phase = .extracting(version: pendingVersion ?? "", fraction: nil)
}
- func handleReadyToInstall() -> SPUUserUpdateChoice {
- phase = .installing
- return .install
+ func handleExtractionProgress(_ progress: Double) {
+ let fraction = min(1.0, max(0.0, progress))
+ publish(.extracting(version: pendingVersion ?? "", fraction: fraction), force: fraction >= 1)
+ }
+
+ func handleReadyToInstall(reply: @escaping (SPUUserUpdateChoice) -> Void) {
+ let version = pendingVersion ?? ""
+ if resumeInstallRequested {
+ installRequested = false
+ resumeInstallRequested = false
+ deferredVersion = nil
+ phase = .installing(version: version)
+ reply(.install)
+ return
+ }
+ installRequested = false
+ readyReply = reply
+ phase = .readyToInstall(version: version, deferred: false)
}
func handleInstalling() {
- phase = .installing
+ phase = .installing(version: pendingVersion ?? "")
}
func handleNotFound() {
installRequested = false
+ resumeInstallRequested = false
+ readyReply = nil
pendingVersion = nil
pendingIsInformationOnly = false
releasePageURL = nil
+ deferredVersion = nil
phase = .idle
finishManualCheck(status: .upToDate)
}
func handleError(_ message: String) {
finishManualCheck(status: .failed)
- if installRequested, let pendingVersion {
+ readyReply = nil
+ if installRequested || resumeInstallRequested {
log.error("Update install failed")
- phase = .failed(version: pendingVersion)
+ phase = .failed(message: Self.failureMessageKey)
} else {
log.debug("Update check failed silently")
- phase = pendingVersion.map { .available(version: $0) } ?? .idle
+ phase = idleOrPendingPhase
}
installRequested = false
+ resumeInstallRequested = false
}
func handleDismissInstallation() {
installRequested = false
+ resumeInstallRequested = false
+ readyReply = nil
switch phase {
- case .downloading, .installing:
- phase = pendingVersion.map { .available(version: $0) } ?? .idle
- case .idle, .available, .failed:
+ case .checking, .downloading, .extracting, .installing:
+ phase = idleOrPendingPhase
+ case .idle, .available, .readyToInstall, .failed:
break
}
}
+ private var idleOrPendingPhase: Phase {
+ pendingVersion.map { .available(version: $0) } ?? .idle
+ }
+
+ private var deferredVersion: String? {
+ get { defaults.string(forKey: Self.deferredVersionDefaultsKey) }
+ set {
+ if let newValue {
+ defaults.set(newValue, forKey: Self.deferredVersionDefaultsKey)
+ } else {
+ defaults.removeObject(forKey: Self.deferredVersionDefaultsKey)
+ }
+ }
+ }
+
+ private func publish(_ next: Phase, force: Bool) {
+ let instant = now()
+ guard force || instant.timeIntervalSince(lastProgressPublish) >= Self.progressPublishInterval else { return }
+ lastProgressPublish = instant
+ phase = next
+ }
+
private func finishManualCheck(status: ManualCheckStatus) {
guard manualCheckPending else { return }
manualCheckPending = false
manualCheckStatus = status
guard status != .idle else { return }
manualCheckResetTask = Task { [weak self] in
- try? await Task.sleep(nanoseconds: 3_000_000_000)
+ try? await Task.sleep(for: .seconds(3))
guard !Task.isCancelled else { return }
self?.manualCheckStatus = .idle
}
@@ -261,7 +430,8 @@ private final class Driver: NSObject, SPUUserDriver {
let choice = manager.handleUpdateFound(
version: appcastItem.displayVersionString,
releasePage: appcastItem.infoURL,
- informationOnly: appcastItem.isInformationOnlyUpdate
+ informationOnly: appcastItem.isInformationOnlyUpdate,
+ stage: Self.stage(state.stage)
)
reply(choice)
}
@@ -296,10 +466,12 @@ private final class Driver: NSObject, SPUUserDriver {
manager.handleExtractionStarted()
}
- func showExtractionReceivedProgress(_ progress: Double) {}
+ func showExtractionReceivedProgress(_ progress: Double) {
+ manager.handleExtractionProgress(progress)
+ }
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
- reply(manager.handleReadyToInstall())
+ manager.handleReadyToInstall(reply: reply)
}
func showInstallingUpdate(
@@ -316,14 +488,23 @@ private final class Driver: NSObject, SPUUserDriver {
func dismissUpdateInstallation() {
manager.handleDismissInstallation()
}
+
+ private static func stage(_ stage: SPUUserUpdateStage) -> UpdateManager.Stage {
+ switch stage {
+ case .downloaded: return .downloaded
+ case .installing: return .installing
+ default: return .notDownloaded
+ }
+ }
}
private final class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
nonisolated func feedURLString(for updater: SPUUpdater) -> String? {
- guard Bundle.main.object(forInfoDictionaryKey: "TokenGaugeDevelopmentBuild") as? Bool == true else {
- return nil
- }
- return UpdateManager.qaFeedURL(environment: ProcessInfo.processInfo.environment)
+ UpdateManager.feedURL(
+ environment: ProcessInfo.processInfo.environment,
+ override: UserDefaults.standard.string(forKey: UpdateManager.feedOverrideDefaultsKey),
+ isDevelopmentBuild: Bundle.main.object(forInfoDictionaryKey: "TokenGaugeDevelopmentBuild") as? Bool ?? true
+ )
}
}
diff --git a/Sources/TokenGaugeApp/Services/UsageRefresher.swift b/Sources/TokenGaugeApp/Services/UsageRefresher.swift
new file mode 100644
index 0000000..48dcaa2
--- /dev/null
+++ b/Sources/TokenGaugeApp/Services/UsageRefresher.swift
@@ -0,0 +1,63 @@
+import Foundation
+import TokenGaugeCore
+
+enum ProviderOutcome: Sendable {
+ case claude(ClaudeUsageResult?)
+ case codex(ProviderViewState)
+}
+
+struct UsageRefresher: Sendable {
+ let homeDirectory: URL
+ private let fetchClaude: @Sendable (ClaudeRecoveryAuthorization) async -> ClaudeUsageResult?
+ private let fetchCodex: @Sendable () -> ProviderViewState
+
+ init(claudeClient: ClaudeUsageClient, codexClient: CodexAppServerClient) {
+ self.init(
+ homeDirectory: claudeClient.homeDirectory,
+ fetchClaude: { await claudeClient.fetch(recoveryAuthorization: $0) },
+ fetchCodex: { Self.codexState(client: codexClient) })
+ }
+
+ init(
+ homeDirectory: URL,
+ fetchClaude: @escaping @Sendable (ClaudeRecoveryAuthorization) async -> ClaudeUsageResult?,
+ fetchCodex: @escaping @Sendable () -> ProviderViewState
+ ) {
+ self.homeDirectory = homeDirectory
+ self.fetchClaude = fetchClaude
+ self.fetchCodex = fetchCodex
+ }
+
+ func outcomes(recoveryAuthorization: ClaudeRecoveryAuthorization) -> AsyncStream {
+ let fetchClaude = fetchClaude
+ let fetchCodex = fetchCodex
+ return AsyncStream { continuation in
+ Task.detached(priority: .utility) {
+ await withTaskGroup(of: ProviderOutcome.self) { group in
+ group.addTask { .claude(await fetchClaude(recoveryAuthorization)) }
+ group.addTask { .codex(fetchCodex()) }
+ for await outcome in group { continuation.yield(outcome) }
+ }
+ continuation.finish()
+ }
+ }
+ }
+
+ private static func codexState(client: CodexAppServerClient) -> ProviderViewState {
+ do {
+ let snapshot = try client.fetch()
+ return ProviderViewState(
+ snapshot: snapshot,
+ status: ProviderStateResolver.codexStatus(snapshot: snapshot),
+ isRefreshing: false
+ )
+ } catch UsageDataError.authenticationRequired {
+ return ProviderViewState(
+ snapshot: client.cached(), status: .authenticationRequired, isRefreshing: false)
+ } catch {
+ let cached = client.cached()
+ return ProviderViewState(
+ snapshot: cached, status: cached == nil ? .unavailable : .stale, isRefreshing: false)
+ }
+ }
+}
diff --git a/Sources/TokenGaugeApp/Services/UsageStore.swift b/Sources/TokenGaugeApp/Services/UsageStore.swift
index 8f3370b..853d66c 100644
--- a/Sources/TokenGaugeApp/Services/UsageStore.swift
+++ b/Sources/TokenGaugeApp/Services/UsageStore.swift
@@ -30,49 +30,49 @@ final class UsageStore: ObservableObject {
@Published private(set) var isRefreshing = false
@Published var primaryProvider: UsageProvider {
didSet {
- defaults.set(primaryProvider.rawValue, forKey: "primaryProvider")
+ preferences.primaryProvider = primaryProvider
let mode = primaryProvider == .codex ? UsageDisplayMode.codex : .claude
if displayMode != mode { displayMode = mode }
}
}
@Published var displayMode: UsageDisplayMode {
didSet {
- defaults.set(displayMode.rawValue, forKey: "displayMode")
+ preferences.displayMode = displayMode
if let provider = displayMode.singleProvider, primaryProvider != provider { primaryProvider = provider }
}
}
@Published var menuBarSize: MenuBarSize {
- didSet { defaults.set(menuBarSize.rawValue, forKey: "menuBarSize") }
+ didSet { preferences.menuBarSize = menuBarSize }
}
@Published var panelStyle: QuotaPanelStyle {
- didSet { defaults.set(panelStyle.rawValue, forKey: "quotaPanelStyle") }
+ didSet { preferences.panelStyle = panelStyle }
}
@Published var menuBarStyle: QuotaMenuBarStyle {
- didSet { defaults.set(menuBarStyle.rawValue, forKey: "quotaMenuBarStyle") }
+ didSet { preferences.menuBarStyle = menuBarStyle }
}
@Published var animateChanges: Bool {
- didSet { defaults.set(animateChanges, forKey: "quotaAnimateChanges") }
+ didSet { preferences.animateChanges = animateChanges }
}
@Published var historyMode: HistoryMode {
- didSet { defaults.set(historyMode.rawValue, forKey: "historyMode") }
+ didSet { preferences.historyMode = historyMode }
}
@Published var showHourlyPace: Bool {
- didSet { defaults.set(showHourlyPace, forKey: "showHourlyPace") }
+ didSet { preferences.showHourlyPace = showHourlyPace }
}
@Published private(set) var historyRevision = 0
let historyReadsEnabled: Bool
@Published var showLunaReserve: Bool {
- didSet { defaults.set(showLunaReserve, forKey: "showLunaReserve") }
+ didSet { preferences.showLunaReserve = showLunaReserve }
}
@Published private(set) var hiddenClaudeWindows: Set {
- didSet { defaults.set(hiddenClaudeWindows.map(\.rawValue).sorted(), forKey: "hiddenClaudeWindows") }
+ didSet { preferences.hiddenClaudeWindows = hiddenClaudeWindows }
}
@Published var claudeMenuBarSource: ClaudeMenuBarSource {
- didSet { defaults.set(claudeMenuBarSource.rawValue, forKey: "claudeMenuBarSource") }
+ didSet { preferences.claudeMenuBarSource = claudeMenuBarSource }
}
@Published var claudeAutomaticRecovery: Bool {
didSet {
- defaults.set(claudeAutomaticRecovery, forKey: "claudeAutomaticRecovery")
+ preferences.claudeAutomaticRecovery = claudeAutomaticRecovery
recoveryAuthorization.setAllowed(claudeAutomaticRecovery && claudeCancelledAt == nil)
}
}
@@ -81,49 +81,66 @@ final class UsageStore: ObservableObject {
@Published private(set) var claudeCancelledAt: Date?
@Published private(set) var codexCancelledAt: Date?
- let recoveryAuthorization: ClaudeRecoveryAuthorization
- private let defaults: UserDefaults
+ typealias ArchiveWork = @Sendable (ClaudeUsageResult?, ProviderViewState, URL) async -> Void
- private let claudeClient: ClaudeUsageClient
- private let codexClient: CodexAppServerClient
- private var timer: Timer?
- private var resetTimer: Timer?
+ static let liveArchiveWork: ArchiveWork = { claudeResult, codexState, homeDirectory in
+ try? await BlockingWork.run {
+ archive(
+ claudeResult: claudeResult, codexState: codexState,
+ accountFingerprint: claudeResult?.accountFingerprint)
+ try? EffortHistoryClient.collect(homeDirectory: homeDirectory)
+ }
+ }
- init(
+ let recoveryAuthorization: ClaudeRecoveryAuthorization
+ private let preferences: AppPreferences
+ private let refresher: UsageRefresher
+ private let archiveWork: ArchiveWork
+ private lazy var scheduler = RefreshScheduler { [weak self] in self?.refresh(force: true) }
+
+ convenience init(
claudeClient: ClaudeUsageClient = ClaudeUsageClient(),
codexClient: CodexAppServerClient = CodexAppServerClient(),
defaults: UserDefaults = .standard,
initialSnapshots: [ProviderUsageSnapshot] = [],
historyReadsEnabled: Bool? = nil
) {
- self.claudeClient = claudeClient
- self.codexClient = codexClient
- self.defaults = defaults
+ self.init(
+ refresher: UsageRefresher(claudeClient: claudeClient, codexClient: codexClient),
+ defaults: defaults, initialSnapshots: initialSnapshots, historyReadsEnabled: historyReadsEnabled)
+ }
+
+ init(
+ refresher: UsageRefresher,
+ defaults: UserDefaults = .standard,
+ initialSnapshots: [ProviderUsageSnapshot] = [],
+ historyReadsEnabled: Bool? = nil,
+ archiveWork: @escaping ArchiveWork = UsageStore.liveArchiveWork
+ ) {
+ let preferences = AppPreferences(defaults: defaults)
+ self.refresher = refresher
+ self.archiveWork = archiveWork
+ self.preferences = preferences
self.historyReadsEnabled =
historyReadsEnabled ?? (defaults === UserDefaults.standard && initialSnapshots.isEmpty)
+ let claudeCancelledAt = preferences.cancelledAt(.claude)
recoveryAuthorization = ClaudeRecoveryAuthorization(
- allowed: defaults.bool(forKey: "claudeAutomaticRecovery")
- && defaults.object(forKey: "claudeCancelledAt") == nil)
- let savedProvider = UsageProvider(rawValue: defaults.string(forKey: "primaryProvider") ?? "") ?? .codex
- let mode =
- UsageDisplayMode(rawValue: defaults.string(forKey: "displayMode") ?? savedProvider.rawValue)
- ?? (savedProvider == .codex ? .codex : .claude)
- primaryProvider = mode.singleProvider ?? savedProvider
+ allowed: preferences.claudeAutomaticRecovery && claudeCancelledAt == nil)
+ let mode = preferences.displayMode
+ primaryProvider = mode.singleProvider ?? preferences.primaryProvider
displayMode = mode
- menuBarSize = MenuBarSize(rawValue: defaults.string(forKey: "menuBarSize") ?? "") ?? .large
- historyMode = HistoryMode(rawValue: defaults.string(forKey: "historyMode") ?? "") ?? .recent
- showHourlyPace = defaults.object(forKey: "showHourlyPace") == nil || defaults.bool(forKey: "showHourlyPace")
- panelStyle = QuotaPanelStyle(rawValue: defaults.string(forKey: "quotaPanelStyle") ?? "") ?? .standard
- menuBarStyle = QuotaMenuBarStyle(rawValue: defaults.string(forKey: "quotaMenuBarStyle") ?? "") ?? .numbers
- animateChanges =
- defaults.object(forKey: "quotaAnimateChanges") == nil || defaults.bool(forKey: "quotaAnimateChanges")
- showLunaReserve = defaults.object(forKey: "showLunaReserve") == nil || defaults.bool(forKey: "showLunaReserve")
- hiddenClaudeWindows = ClaudeWindowKind.decode(defaults.stringArray(forKey: "hiddenClaudeWindows"))
- claudeMenuBarSource =
- ClaudeMenuBarSource(rawValue: defaults.string(forKey: "claudeMenuBarSource") ?? "") ?? .automatic
- claudeAutomaticRecovery = defaults.bool(forKey: "claudeAutomaticRecovery")
- claudeCancelledAt = defaults.object(forKey: "claudeCancelledAt") as? Date
- codexCancelledAt = defaults.object(forKey: "codexCancelledAt") as? Date
+ menuBarSize = preferences.menuBarSize
+ historyMode = preferences.historyMode
+ showHourlyPace = preferences.showHourlyPace
+ panelStyle = preferences.panelStyle
+ menuBarStyle = preferences.menuBarStyle
+ animateChanges = preferences.animateChanges
+ showLunaReserve = preferences.showLunaReserve
+ hiddenClaudeWindows = preferences.hiddenClaudeWindows
+ claudeMenuBarSource = preferences.claudeMenuBarSource
+ claudeAutomaticRecovery = preferences.claudeAutomaticRecovery
+ self.claudeCancelledAt = claudeCancelledAt
+ codexCancelledAt = preferences.cancelledAt(.codex)
for snapshot in initialSnapshots {
let state = ProviderViewState(
snapshot: snapshot, status: isCancelled(provider: snapshot.provider) ? .cancelled : .ready,
@@ -132,11 +149,6 @@ final class UsageStore: ObservableObject {
}
if claudeCancelledAt != nil { claude.status = .cancelled }
if codexCancelledAt != nil { codex.status = .cancelled }
- if self.historyReadsEnabled {
- let identity = ClaudeAccountIdentityReader.current(homeDirectory: claudeClient.homeDirectory)
- claudeAccountLabel = identity?.label
- claudeAccountFingerprint = identity?.fingerprint
- }
}
func setPreviewAccountLabel(_ label: String?) {
@@ -145,16 +157,11 @@ final class UsageStore: ObservableObject {
func start() {
refresh(force: true)
- timer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { [weak self] _ in
- Task { @MainActor in
- self?.refresh(force: true)
- }
- }
+ scheduler.start()
}
func stop() {
- timer?.invalidate()
- resetTimer?.invalidate()
+ scheduler.stop()
recoveryAuthorization.setAllowed(false)
}
@@ -162,45 +169,46 @@ final class UsageStore: ObservableObject {
guard !isRefreshing else { return }
if !force, let lastRefresh, Date().timeIntervalSince(lastRefresh) < 60 { return }
isRefreshing = true
- claude.isRefreshing = true
- codex.isRefreshing = true
- let claudeClient = self.claudeClient
- let codexClient = self.codexClient
+ if !claude.isRefreshing { claude.isRefreshing = true }
+ if !codex.isRefreshing { codex.isRefreshing = true }
+ let refresher = self.refresher
let recoveryAuthorization = self.recoveryAuthorization
- let effortHome = claudeClient.homeDirectory
Task {
- async let claudeOutcome = Task.detached(priority: .utility) {
- try? claudeClient.fetch(recoveryAuthorization: recoveryAuthorization)
- }.value
- async let codexOutcome = Task.detached(priority: .utility) {
- Self.fetchCodex(client: codexClient)
- }.value
- let codexState = await codexOutcome
- applyCodexState(codexState)
- let claudeResult = await claudeOutcome
- applyRefreshResults(claudeResult: claudeResult, codexState: codexState)
- if historyReadsEnabled {
- claudeAccountLabel = claudeResult?.accountLabel
- claudeAccountFingerprint = claudeResult?.accountFingerprint
+ var claudeResult: ClaudeUsageResult?
+ var codexState = ProviderViewState.loading
+ for await outcome in refresher.outcomes(recoveryAuthorization: recoveryAuthorization) {
+ switch outcome {
+ case .claude(let result): claudeResult = result
+ case .codex(let state): codexState = state
+ }
+ receive(outcome)
}
lastRefresh = Date()
- isRefreshing = false
scheduleResetRefresh()
if historyReadsEnabled {
- await Task.detached(priority: .background) {
- Self.archive(
- claudeResult: claudeResult, codexState: codexState,
- accountFingerprint: claudeResult?.accountFingerprint)
- try? EffortHistoryClient.collect(homeDirectory: effortHome)
- }.value
+ await archiveWork(claudeResult, codexState, refresher.homeDirectory)
historyRevision &+= 1
}
+ isRefreshing = false
+ }
+ }
+
+ private func receive(_ outcome: ProviderOutcome) {
+ switch outcome {
+ case .claude(let result):
+ applyClaudeResult(result)
+ guard historyReadsEnabled else { return }
+ let label = result?.accountLabel
+ let fingerprint = result?.accountFingerprint
+ if claudeAccountLabel != label { claudeAccountLabel = label }
+ if claudeAccountFingerprint != fingerprint { claudeAccountFingerprint = fingerprint }
+ case .codex(let state):
+ applyCodexState(state)
}
}
private func scheduleResetRefresh() {
- resetTimer?.invalidate()
let now = Date()
let nextReset = [claude.snapshot, codex.snapshot]
.compactMap { $0 }
@@ -208,13 +216,7 @@ final class UsageStore: ObservableObject {
.compactMap(\.resetsAt)
.filter { $0 > now }
.min()
- guard let nextReset else { return }
- let delay = max(nextReset.timeIntervalSince(now) + 5, 1)
- resetTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
- Task { @MainActor in
- self?.refresh(force: true)
- }
- }
+ scheduler.scheduleResetRefresh(at: nextReset, now: now)
}
nonisolated static func archive(
@@ -278,35 +280,32 @@ final class UsageStore: ObservableObject {
} else {
codexCancelledAt = date
}
- let key = "\(provider.rawValue)CancelledAt"
- if let date { defaults.set(date, forKey: key) } else { defaults.removeObject(forKey: key) }
- defaults.removeObject(forKey: baselineKey(for: provider))
+ preferences.setCancelledAt(date, for: provider)
+ preferences.setCancellationDailyBaseline(nil, for: provider)
}
- private func baselineKey(for provider: UsageProvider) -> String {
- "\(provider.rawValue)CancellationDailyBaseline"
+ func applyRefreshResults(claudeResult: ClaudeUsageResult?, codexState: ProviderViewState) {
+ applyClaudeResult(claudeResult)
+ applyCodexState(codexState)
}
- func applyRefreshResults(claudeResult: ClaudeUsageResult?, codexState: ProviderViewState) {
- if ProviderStateResolver.shouldResumeClaude(result: claudeResult, cancelledAt: claudeCancelledAt) {
+ private func applyClaudeResult(_ result: ClaudeUsageResult?) {
+ if ProviderStateResolver.shouldResumeClaude(result: result, cancelledAt: claudeCancelledAt) {
persistCancellation(nil, for: .claude)
} else {
- resumeIfActivityIncreased(
- provider: .claude, snapshot: claudeResult?.snapshot, live: claudeResult?.access == .live)
+ resumeIfActivityIncreased(provider: .claude, snapshot: result?.snapshot, live: result?.access == .live)
}
- resumeIfActivityIncreased(
- provider: .codex, snapshot: codexState.snapshot, live: codexState.status == .ready)
- let previousClaudeSnapshot = claude.snapshot
- claude = ProviderStateResolver.claudeState(result: claudeResult, cancelled: isCancelled(provider: .claude))
- if claude.snapshot == nil { claude.snapshot = previousClaudeSnapshot }
- applyCodexState(codexState)
+ var next = ProviderStateResolver.claudeState(result: result, cancelled: isCancelled(provider: .claude))
+ if next.snapshot == nil { next.snapshot = claude.snapshot }
+ claude = next
}
private func applyCodexState(_ state: ProviderViewState) {
- let previous = codex.snapshot
- codex = state
- if codex.snapshot == nil { codex.snapshot = previous }
- if isCancelled(provider: .codex) { codex.status = .cancelled }
+ resumeIfActivityIncreased(provider: .codex, snapshot: state.snapshot, live: state.status == .ready)
+ var next = state
+ if next.snapshot == nil { next.snapshot = codex.snapshot }
+ if isCancelled(provider: .codex) { next.status = .cancelled }
+ codex = next
}
private func resumeIfActivityIncreased(provider: UsageProvider, snapshot: ProviderUsageSnapshot?, live: Bool) {
@@ -314,21 +313,17 @@ final class UsageStore: ObservableObject {
live, let snapshot, !snapshot.windows.isEmpty, snapshot.activityReadSucceeded,
let capturedAt = snapshot.capturedAt, capturedAt > cancelledAt
else { return }
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.dateFormat = "yyyy-MM-dd"
- let cancelledDay = formatter.string(from: cancelledAt)
+ let calendar = Self.dayCalendar
+ let cancelledDay = Self.dayKey(for: cancelledAt, calendar: calendar)
var totals: [String: Int] = [:]
for usage in snapshot.dailyUsage {
- guard usage.day >= cancelledDay, let day = formatter.date(from: usage.day),
- formatter.string(from: day) == usage.day
+ guard usage.day >= cancelledDay, let day = Self.date(fromDayKey: usage.day, calendar: calendar),
+ Self.dayKey(for: day, calendar: calendar) == usage.day
else { continue }
totals[usage.day] = max(totals[usage.day, default: 0], usage.tokens)
}
- let key = baselineKey(for: provider)
- guard let baseline = defaults.dictionary(forKey: key) as? [String: Int] else {
- defaults.set(totals, forKey: key)
+ guard let baseline = preferences.cancellationDailyBaseline(provider) else {
+ preferences.setCancellationDailyBaseline(totals, for: provider)
return
}
if totals.contains(where: { $0.value > baseline[$0.key, default: 0] }) {
@@ -340,22 +335,21 @@ final class UsageStore: ObservableObject {
ProviderStateResolver.menuBarWindow(state: state(for: primaryProvider), claudeSource: claudeMenuBarSource)
}
- private nonisolated static func fetchCodex(client: CodexAppServerClient) -> ProviderViewState {
- do {
- let snapshot = try client.fetch()
- return ProviderViewState(
- snapshot: snapshot,
- status: ProviderStateResolver.codexStatus(snapshot: snapshot),
- isRefreshing: false
- )
- } catch UsageDataError.authenticationRequired {
- return ProviderViewState(
- snapshot: client.cached(), status: .authenticationRequired, isRefreshing: false)
- } catch {
- let cached = client.cached()
- return ProviderViewState(
- snapshot: cached, status: cached == nil ? .unavailable : .stale, isRefreshing: false)
- }
+ private nonisolated static var dayCalendar: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = .current
+ return calendar
+ }
+
+ nonisolated static func dayKey(for date: Date, calendar: Calendar = dayCalendar) -> String {
+ let parts = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04ld-%02ld-%02ld", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
+ }
+
+ private nonisolated static func date(fromDayKey key: String, calendar: Calendar) -> Date? {
+ let parts = key.split(separator: "-", omittingEmptySubsequences: false).compactMap { Int($0) }
+ guard parts.count == 3 else { return nil }
+ return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2]))
}
}
diff --git a/Sources/TokenGaugeApp/Support/HistoryIntensity.swift b/Sources/TokenGaugeApp/Support/HistoryIntensity.swift
new file mode 100644
index 0000000..cb03fc1
--- /dev/null
+++ b/Sources/TokenGaugeApp/Support/HistoryIntensity.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+enum HistoryIntensity {
+ static let opacities: [Double] = [0.30, 0.48, 0.66, 0.84, 1.0]
+ static let minorityShare = 0.25
+
+ static func thresholds(totals: [Int]) -> [Int] {
+ let sorted = totals.filter { $0 > 0 }.sorted()
+ guard !sorted.isEmpty else { return [] }
+ return [0.2, 0.4, 0.6, 0.8].map { quantile in
+ sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * quantile))]
+ }
+ }
+
+ static func level(total: Int, thresholds: [Int]) -> Int {
+ thresholds.filter { total > $0 }.count
+ }
+
+ static func opacity(total: Int, thresholds: [Int]) -> Double {
+ opacities[min(opacities.count - 1, level(total: total, thresholds: thresholds))]
+ }
+}
diff --git a/Sources/TokenGaugeApp/Support/Theme.swift b/Sources/TokenGaugeApp/Support/Theme.swift
index de02f8d..a8af7f4 100644
--- a/Sources/TokenGaugeApp/Support/Theme.swift
+++ b/Sources/TokenGaugeApp/Support/Theme.swift
@@ -9,30 +9,38 @@ enum Theme {
static let menuBarFontSize: CGFloat = 13.5
static let menuRingGap: CGFloat = 8
static let panelWidth: CGFloat = 340
- static let maximumPanelHeight: CGFloat = 640
+ static let maximumPanelHeight: CGFloat = 700
static let unifiedPanelWidth: CGFloat = 560
- static let providerMaxHeight: CGFloat = 182
- static let compactProviderMaxHeight: CGFloat = 230
- static let ringProviderMaxHeight: CGFloat = 250
+ static let providerMaxHeight: CGFloat = 275
+ static let compactProviderMaxHeight: CGFloat = 335
+ static let ringProviderMaxHeight: CGFloat = 315
static let compactPanelWidth: CGFloat = 320
static let compactUnifiedWidth: CGFloat = 360
static let ringPanelWidth: CGFloat = 360
- static let ringUnifiedWidth: CGFloat = 520
+ static let ringUnifiedWidth: CGFloat = 560
static let activityChartHeight: CGFloat = 64
static let historyCalendarGridTop: CGFloat = 22
static let historyCalendarHeight: CGFloat = historyCalendarGridTop + 87
static let historyMonthWidth: CGFloat = 69
static let historyMonthGap: CGFloat = 8
static let minimumRingUnifiedWidth: CGFloat = 440
- static let quotaRingCellWidth: CGFloat = 80
+ static let quotaRingCellWidth: CGFloat = 118
static let quotaRingSpacing: CGFloat = 10
static let ringSectionSpacing: CGFloat = 10
static let ringHeaderSpacing: CGFloat = 14
- static let ringTitleHeight: CGFloat = 26
- static let minimumRingCardWidth: CGFloat = 155
+ static let ringRowSpacing: CGFloat = 8
+ static let ringTitleHeight: CGFloat = 28
+ static let minimumRingCardWidth: CGFloat = 170
static let cardPadding: CGFloat = 12
static let quotaRingDiameter: CGFloat = 68
static let quotaRingLineWidth: CGFloat = 5
+ static let quotaRingPercentageSize: CGFloat = 17
+ static let quotaRingSingleDiameter: CGFloat = 84
+ static let quotaRingSingleLineWidth: CGFloat = 6
+ static let quotaRingSinglePercentageSize: CGFloat = 20
+ static let quotaRingRowDiameter: CGFloat = 44
+ static let quotaRingRowLineWidth: CGFloat = 4
+ static let quotaRingRowPercentageSize: CGFloat = 12
static let panelPadding: CGFloat = 13
static let panelBottomPadding: CGFloat = 14
static let historyModeSegmentWidth: CGFloat = 48
diff --git a/Sources/TokenGaugeApp/Support/UsageFormatters.swift b/Sources/TokenGaugeApp/Support/UsageFormatters.swift
index 50cf378..06f0022 100644
--- a/Sources/TokenGaugeApp/Support/UsageFormatters.swift
+++ b/Sources/TokenGaugeApp/Support/UsageFormatters.swift
@@ -2,14 +2,15 @@ import Foundation
import TokenGaugeCore
enum UsageFormatters {
+ private static let integer = IntegerFormatStyle.number
+ private static let smallDecimal = FloatingPointFormatStyle.number.precision(.fractionLength(1))
+ private static let largeDecimal = FloatingPointFormatStyle.number.precision(.fractionLength(0...1))
+
static func percentage(_ value: Double) -> String {
"\(Int(value.rounded()))%"
}
static func tokens(_ value: Int) -> String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.maximumFractionDigits = 1
if value >= 1_000_000_000 {
return "\(decimal(Double(value) / 1_000_000_000))B"
}
@@ -19,7 +20,7 @@ enum UsageFormatters {
if value >= 1_000 {
return "\(decimal(Double(value) / 1_000))K"
}
- return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
+ return value.formatted(integer)
}
@MainActor static func reset(
@@ -53,20 +54,13 @@ enum UsageFormatters {
}
@MainActor private static func span(_ interval: TimeInterval, units: NSCalendar.Unit) -> String? {
- let formatter = DateComponentsFormatter()
- formatter.calendar?.locale = Locale(identifier: LocalizationManager.shared.language.rawValue)
- formatter.unitsStyle = .abbreviated
- formatter.maximumUnitCount = 1
+ let formatter = LocalizedFormatters.current.span
formatter.allowedUnits = units
return formatter.string(from: max(interval, 60))
}
@MainActor private static func clock(_ date: Date) -> String {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: LocalizationManager.shared.language.rawValue)
- formatter.dateStyle = .none
- formatter.timeStyle = .short
- return formatter.string(from: date)
+ LocalizedFormatters.current.clock.string(from: date)
}
@MainActor static func resetCredits(_ count: Int) -> String {
@@ -77,10 +71,7 @@ enum UsageFormatters {
guard let date else { return "updated.never".localized }
let age = Date().timeIntervalSince(date)
guard age >= 10 else { return "updated.now".localized }
- let formatter = RelativeDateTimeFormatter()
- formatter.locale = Locale(identifier: LocalizationManager.shared.language.rawValue)
- formatter.unitsStyle = .short
- return formatter.localizedString(for: date, relativeTo: Date())
+ return LocalizedFormatters.current.relative.localizedString(for: date, relativeTo: Date())
}
@MainActor static func windowName(_ window: QuotaWindow) -> String {
@@ -119,10 +110,37 @@ enum UsageFormatters {
}
private static func decimal(_ value: Double) -> String {
- let formatter = NumberFormatter()
- formatter.numberStyle = .decimal
- formatter.maximumFractionDigits = 1
- formatter.minimumFractionDigits = value < 10 ? 1 : 0
- return formatter.string(from: NSNumber(value: value)) ?? String(format: "%.1f", value)
+ value.formatted(value < 10 ? smallDecimal : largeDecimal)
+ }
+}
+
+@MainActor
+private struct LocalizedFormatters {
+ let span: DateComponentsFormatter
+ let clock: DateFormatter
+ let relative: RelativeDateTimeFormatter
+
+ private static var cache: [AppLanguage: LocalizedFormatters] = [:]
+
+ static var current: LocalizedFormatters {
+ let language = LocalizationManager.shared.language
+ if let formatters = cache[language] { return formatters }
+ let formatters = LocalizedFormatters(locale: Locale(identifier: language.rawValue))
+ cache[language] = formatters
+ return formatters
+ }
+
+ private init(locale: Locale) {
+ span = DateComponentsFormatter()
+ span.calendar?.locale = locale
+ span.unitsStyle = .abbreviated
+ span.maximumUnitCount = 1
+ clock = DateFormatter()
+ clock.locale = locale
+ clock.dateStyle = .none
+ clock.timeStyle = .short
+ relative = RelativeDateTimeFormatter()
+ relative.locale = locale
+ relative.unitsStyle = .short
}
}
diff --git a/Sources/TokenGaugeApp/Views/AboutView.swift b/Sources/TokenGaugeApp/Views/AboutView.swift
index a59b405..0f1d00e 100644
--- a/Sources/TokenGaugeApp/Views/AboutView.swift
+++ b/Sources/TokenGaugeApp/Views/AboutView.swift
@@ -62,38 +62,46 @@ struct AboutView: View {
}
struct UpdateActionView: View {
- @ObservedObject private var updates = UpdateManager.shared
+ @ObservedObject var updates: UpdateManager
+
+ init(updates: UpdateManager = .shared) {
+ self.updates = updates
+ }
var body: some View {
Group {
switch updates.phase {
case .idle:
- switch updates.manualCheckStatus {
- case .checking:
- Label("updates.checking".localized, systemImage: "clock")
- case .upToDate:
- Label("updates.current".localized, systemImage: "checkmark.circle")
- case .failed:
- Button("updates.check_failed".localized) { updates.checkForUpdatesManually() }
- case .idle:
- Button("updates.check".localized) { updates.checkForUpdatesManually() }
+ checkControl
+ case .available:
+ VStack(spacing: 8) {
+ Button("updates.download".localized) { updates.installPendingUpdate() }
+ .buttonStyle(.borderedProminent)
+ checkControl
}
- case .available(let version):
- Button("updates.install".localized(version)) { updates.installPendingUpdate() }
- case .downloading(let fraction):
- VStack(alignment: .leading, spacing: 5) {
- Text("updates.downloading".localized)
- if let fraction {
- ProgressView(value: fraction)
- } else {
- ProgressView().controlSize(.small)
- }
+ case .readyToInstall(let version, true):
+ VStack(spacing: 8) {
+ Button("updates.install_version".localized(version)) { updates.resumeDeferredInstall() }
+ .buttonStyle(.borderedProminent)
+ checkControl
}
- case .installing:
- Label("updates.installing".localized, systemImage: "arrow.down.circle")
- case .failed:
- Button("updates.retry".localized) { updates.installPendingUpdate() }
+ case .checking, .downloading, .extracting, .installing, .readyToInstall, .failed:
+ UpdateBannerView(updates: updates)
}
}.font(.callout).frame(maxWidth: .infinity, alignment: .center)
}
+
+ @ViewBuilder
+ private var checkControl: some View {
+ switch updates.manualCheckStatus {
+ case .checking:
+ Label("updates.checking".localized, systemImage: "clock")
+ case .upToDate:
+ Label("updates.current".localized, systemImage: "checkmark.circle")
+ case .failed:
+ Button("updates.check_failed".localized) { updates.checkForUpdatesManually() }
+ case .idle:
+ Button("updates.check".localized) { updates.checkForUpdatesManually() }
+ }
+ }
}
diff --git a/Sources/TokenGaugeApp/Views/ActivityChartView.swift b/Sources/TokenGaugeApp/Views/ActivityChartView.swift
deleted file mode 100644
index 4165ac7..0000000
--- a/Sources/TokenGaugeApp/Views/ActivityChartView.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-import SwiftUI
-import TokenGaugeCore
-
-struct ActivityDay: Identifiable {
- let id: String
- let date: Date
- let claudeTokens: Int
- let codexTokens: Int
-
- func tokens(for provider: UsageProvider) -> Int {
- provider == .claude ? claudeTokens : codexTokens
- }
-}
-
-struct ActivityChartData {
- let days: [ActivityDay]
- let providers: [UsageProvider]
- let maximumTokens: Int
-
- init(
- claude: ProviderUsageSnapshot?, codex: ProviderUsageSnapshot?,
- providers: [UsageProvider] = [.claude, .codex], now: Date = Date(), calendar: Calendar = .current
- ) {
- var seen: Set = []
- self.providers = providers.filter { seen.insert($0).inserted }
- let today = calendar.startOfDay(for: now)
- let formatter = DateFormatter()
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.timeZone = calendar.timeZone
- formatter.dateFormat = "yyyy-MM-dd"
- let claudeTotals = Dictionary((claude?.dailyUsage ?? []).map { ($0.day, $0.tokens) }, uniquingKeysWith: max)
- let codexTotals = Dictionary((codex?.dailyUsage ?? []).map { ($0.day, $0.tokens) }, uniquingKeysWith: max)
- days = (0..<7).compactMap { offset in
- guard let date = calendar.date(byAdding: .day, value: offset - 6, to: today) else { return nil }
- let key = formatter.string(from: date)
- return ActivityDay(
- id: key, date: date, claudeTokens: claudeTotals[key] ?? 0, codexTokens: codexTotals[key] ?? 0)
- }
- maximumTokens = max(days.flatMap { day in providers.map { day.tokens(for: $0) } }.max() ?? 0, 1)
- }
-
- func selectedDay(key: String) -> ActivityDay? {
- days.first { $0.id == key } ?? days.last
- }
-
- @MainActor func summary(for day: ActivityDay, locale: Locale) -> String {
- let label = day.date.formatted(.dateTime.weekday(.abbreviated).day().locale(locale))
- let totals = providers.map { provider in
- let name = (provider == .claude ? "provider.claude_short" : "provider.codex").localized
- return "\(name) \(UsageFormatters.tokens(day.tokens(for: provider)))"
- }.joined(separator: " · ")
- return "activity.selected_scoped".localized(label, totals)
- }
-}
-
-struct ActivityChartView: View {
- private let data: ActivityChartData
- @State private var selectedKey: String
- private let compact: Bool
- private var barHeight: CGFloat { Theme.Layout.activityChartHeight }
-
- init(
- claude: ProviderUsageSnapshot?, codex: ProviderUsageSnapshot?,
- providers: [UsageProvider] = [.claude, .codex], compact: Bool = false
- ) {
- self.compact = compact
- let data = ActivityChartData(claude: claude, codex: codex, providers: providers)
- self.data = data
- _selectedKey = State(initialValue: data.days.last?.id ?? "")
- }
-
- var body: some View {
- let selectedDay = data.selectedDay(key: selectedKey)
- VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 6) {
- Text("activity.title".localized)
- .font(.caption.weight(.semibold))
- Spacer(minLength: 4)
- legend
- }
-
- HStack(alignment: .bottom, spacing: 8) {
- ForEach(data.days) { day in
- let isToday = day.id == data.days.last?.id
- Button {
- select(day)
- } label: {
- VStack(spacing: 5) {
- HStack(alignment: .bottom, spacing: 3) {
- ForEach(data.providers.filter { day.tokens(for: $0) > 0 }, id: \.self) { provider in
- bar(
- tokens: day.tokens(for: provider),
- tint: provider == .claude ? Theme.claude : Theme.codex)
- }
- }
- .frame(height: barHeight, alignment: .bottom)
- .frame(maxWidth: .infinity)
- .overlay(alignment: .bottom) {
- Rectangle().fill(Color.primary.opacity(0.09)).frame(height: 1)
- }
- Text(day.date.formatted(.dateTime.weekday(.narrow).locale(locale)))
- .font(.system(size: 9, weight: isToday || day.id == selectedDay?.id ? .bold : .regular))
- .foregroundStyle(
- isToday ? .white : day.id == selectedDay?.id ? Color.primary : Color.secondary
- )
- .frame(width: 22, height: 16)
- .background {
- if isToday {
- Capsule().fill(data.providers == [.claude] ? Theme.claude : Theme.codex)
- }
- }
- }
- .frame(maxWidth: .infinity)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .onHover { inside in
- if inside { select(day) }
- }
- .accessibilityLabel(summary(for: day))
- .accessibilityAddTraits(day.id == selectedDay?.id ? [.isSelected] : [])
- }
- }
-
- Text(selectedDay.map(summary) ?? "")
- .font(.system(size: 10))
- .foregroundStyle(.secondary)
- .monospacedDigit()
- .lineLimit(1)
- }
- }
-
- private var locale: Locale {
- Locale(identifier: LocalizationManager.shared.language.rawValue)
- }
-
- private func select(_ day: ActivityDay) {
- guard selectedKey != day.id else { return }
- selectedKey = day.id
- }
-
- private func bar(tokens: Int, tint: Color) -> some View {
- RoundedRectangle(cornerRadius: 2)
- .fill(tint)
- .frame(width: compact ? 7 : 9, height: barHeight * Double(tokens) / Double(data.maximumTokens))
- .accessibilityHidden(true)
- }
-
- private func summary(for day: ActivityDay) -> String {
- data.summary(for: day, locale: locale)
- }
-
- private var legend: some View {
- HStack(spacing: 8) {
- ForEach(data.providers, id: \.self) { provider in
- legendItem(
- color: provider == .claude ? Theme.claude : Theme.codex,
- title: (provider == .claude ? "provider.claude_short" : "provider.codex").localized)
- }
- }
- .font(.system(size: 10))
- .foregroundStyle(.secondary)
- }
-
- private func legendItem(color: Color, title: String) -> some View {
- HStack(spacing: 4) {
- RoundedRectangle(cornerRadius: 1.5).fill(color).frame(width: 7, height: 7)
- Text(title)
- }
- }
-
-}
diff --git a/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift b/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift
index b3f26df..b062dc4 100644
--- a/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift
+++ b/Sources/TokenGaugeApp/Views/HistoryCalendarView.swift
@@ -8,6 +8,13 @@ struct HistoryCalendarView: NSViewRepresentable {
let selectedDayKey: String?
let focusID: String
var hoverEnabled = true
+ var accent: Color = Color(nsColor: .labelColor)
+ var pinnedDayKey: String?
+ var pinSafeFrames: [CGRect] = []
+ var onHoverCard: @MainActor (String?, HistoryDayCardAnchor?) -> Void = { _, _ in }
+ var onPinCard: @MainActor (String, HistoryDayCardAnchor?) -> Void = { _, _ in }
+ var onExitCalendar: @MainActor () -> Void = {}
+ var onDismissCard: @MainActor () -> Void = {}
let onSelect: @MainActor (String) -> Void
func makeNSView(context: Context) -> HistoryCalendarScrollView {
@@ -57,11 +64,11 @@ final class HistoryCalendarScrollView: NSScrollView {
} else if oldPadding != calendar.sidePadding {
contentView.scroll(to: NSPoint(x: contentView.bounds.minX + calendar.sidePadding - oldPadding, y: 0))
}
- calendar.updateTooltips()
reflectScrolledClipView(contentView)
}
override func scrollWheel(with event: NSEvent) {
+ calendar.scrollDidMove()
let delta =
abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY)
? event.scrollingDeltaX : event.scrollingDeltaY
@@ -77,11 +84,19 @@ final class HistoryCalendarCanvas: NSView {
private var days: [HistoryCalendarDay] = []
private var providers: [UsageProvider] = []
private var selected: String?
+ private var pinned: String?
private var hoverEnabled = true
private var onSelect: (@MainActor (String) -> Void)?
+ private var onHoverCard: (@MainActor (String?, HistoryDayCardAnchor?) -> Void)?
+ private var onPinCard: (@MainActor (String, HistoryDayCardAnchor?) -> Void)?
+ private var onExitCalendar: (@MainActor () -> Void)?
+ private var onDismissCard: (@MainActor () -> Void)?
+ private var hoverIndex: Int?
+ private var hoverWork: DispatchWorkItem?
+ private var pinSafeFrames: [CGRect] = []
+ private var pinMonitor: Any?
private var locale = Locale.current
private var today = Calendar.current.startOfDay(for: Date())
- private var maximum = 1
private var tracking: NSTrackingArea?
private var lastMousePosition: NSPoint?
private var elements: [HistoryCalendarAccessibilityDay] = []
@@ -89,24 +104,40 @@ final class HistoryCalendarCanvas: NSView {
private var monthLayout = HistoryMonthLayout(days: [])
private var months: [(HistoryMonthLayout.Section, String)] = []
private var fills: [[NSColor]] = []
- var sidePadding: CGFloat = 0 { didSet { if sidePadding != oldValue { tooltipsDirty = true; needsDisplay = true } } }
- private var tooltipsDirty = true
+ private var minorities: [NSColor?] = []
+ var sidePadding: CGFloat = 0 { didSet { if sidePadding != oldValue { needsDisplay = true } } }
var gridWidth: CGFloat { max(9, monthLayout.gridWidth) }
override var isFlipped: Bool { true }
- private var accent: NSColor { NSColor(providers == [.claude] ? Theme.claude : Theme.codex) }
+ private var accentColor = Color(nsColor: .labelColor)
+ private var accent: NSColor { NSColor(accentColor) }
var focusIndex: Int? {
days.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: today) }
?? days.lastIndex { $0.date <= today && $0.total(for: providers) != nil }
?? days.lastIndex { $0.date <= today }
}
+ deinit {
+ MainActor.assumeIsolated {
+ hoverWork?.cancel()
+ if let pinMonitor { NSEvent.removeMonitor(pinMonitor) }
+ }
+ }
+
func update(_ value: HistoryCalendarView) {
onSelect = value.onSelect
+ onHoverCard = value.onHoverCard
+ onPinCard = value.onPinCard
+ onExitCalendar = value.onExitCalendar
+ onDismissCard = value.onDismissCard
hoverEnabled = value.hoverEnabled
+ pinned = value.pinnedDayKey
+ pinSafeFrames = value.pinSafeFrames
+ updatePinMonitor()
let language = Locale(identifier: LocalizationManager.shared.language.rawValue)
let newToday = Calendar.current.startOfDay(for: Date())
let changed =
providers != value.providers || locale != language || today != newToday
+ || accentColor != value.accent
|| days.count != value.days.count
|| !zip(days, value.days).allSatisfy { $0.id == $1.id && $0.date == $1.date && $0.totals == $1.totals }
let selectionChanged = selected != value.selectedDayKey
@@ -115,24 +146,33 @@ final class HistoryCalendarCanvas: NSView {
if changed {
days = value.days
providers = value.providers
+ accentColor = value.accent
locale = language
today = newToday
- maximum = max(1, days.compactMap { $0.total(for: providers) }.max() ?? 0)
monthLayout = HistoryMonthLayout(days: days)
descriptions = days.map(description)
months = monthLayout.sections.map {
($0, $0.date.formatted(.dateTime.month(.wide).locale(locale)).capitalized(with: locale))
}
+ let thresholds = HistoryIntensity.thresholds(totals: days.compactMap { $0.total(for: providers) })
fills = days.map { day in
- let opacity = 0.25 + 0.75 * Double(day.total(for: providers) ?? 0) / Double(maximum)
+ let opacity = HistoryIntensity.opacity(total: day.total(for: providers) ?? 0, thresholds: thresholds)
return day.dominantProviders(for: providers).map { provider in
- NSColor(provider == .codex ? Theme.codex : Theme.claude).withAlphaComponent(opacity)
+ color(provider).withAlphaComponent(opacity)
}
}
+ minorities = days.map { day in
+ let leaders = day.dominantProviders(for: providers)
+ guard leaders.count == 1, let leader = leaders.first, let total = day.total(for: providers), total > 0,
+ let minority = providers.first(where: { $0 != leader }),
+ Double(day.tokens(for: minority) ?? 0) >= HistoryIntensity.minorityShare * Double(total)
+ else { return nil }
+ return color(minority).withAlphaComponent(
+ HistoryIntensity.opacity(total: total, thresholds: thresholds))
+ }
elements = days.indices.map { HistoryCalendarAccessibilityDay(canvas: self, index: $0) }
setAccessibilityElement(false)
setAccessibilityChildren(elements)
- tooltipsDirty = true
}
needsDisplay = true
}
@@ -141,6 +181,28 @@ final class HistoryCalendarCanvas: NSView {
monthLayout.cellRect(index).offsetBy(dx: sidePadding, dy: 0)
}
+ func cardAnchor(_ index: Int) -> HistoryDayCardAnchor? {
+ guard let scroll = enclosingScrollView, let root = window?.contentView else { return nil }
+ return HistoryDayCardAnchor(
+ cell: localRect(convert(cellRect(index), to: scroll), in: scroll),
+ bounds: localRect(scroll.convert(root.bounds, from: root), in: scroll))
+ }
+
+ private func localRect(_ rect: NSRect, in view: NSView) -> CGRect {
+ view.isFlipped
+ ? rect
+ : CGRect(x: rect.minX, y: view.bounds.height - rect.maxY, width: rect.width, height: rect.height)
+ }
+
+ private func localPoint(_ locationInWindow: NSPoint, in view: NSView) -> CGPoint {
+ let point = view.convert(locationInWindow, from: nil)
+ return view.isFlipped ? point : CGPoint(x: point.x, y: view.bounds.height - point.y)
+ }
+
+ private func color(_ provider: UsageProvider) -> NSColor {
+ NSColor(provider == .codex ? Theme.codex : Theme.claude)
+ }
+
override func draw(_ dirtyRect: NSRect) {
for (section, label) in months {
let frame = NSRect(
@@ -191,6 +253,15 @@ final class HistoryCalendarCanvas: NSView {
width: rect.width / CGFloat(colors.count), height: rect.height
).fill()
}
+ if let minority = minorities[index] {
+ minority.setFill()
+ let corner = NSBezierPath()
+ corner.move(to: NSPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.4))
+ corner.line(to: NSPoint(x: rect.maxX, y: rect.maxY))
+ corner.line(to: NSPoint(x: rect.maxX - rect.width * 0.4, y: rect.maxY))
+ corner.close()
+ corner.fill()
+ }
NSGraphicsContext.restoreGraphicsState()
} else {
NSColor.labelColor.withAlphaComponent(0.08).setFill()
@@ -226,27 +297,132 @@ final class HistoryCalendarCanvas: NSView {
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
lastMousePosition = NSEvent.mouseLocation
+ if window == nil { cancelHoverTimer() }
+ updatePinMonitor()
}
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let tracking { removeTrackingArea(tracking) }
- let area = NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeAlways, .inVisibleRect], owner: self)
+ let area = NSTrackingArea(
+ rect: .zero, options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self)
addTrackingArea(area)
tracking = area
}
+ var trackingOptions: NSTrackingArea.Options { tracking?.options ?? [] }
+
+ var hasPinMonitor: Bool { pinMonitor != nil }
+
override func mouseMoved(with event: NSEvent) {
let position = window?.convertPoint(toScreen: event.locationInWindow) ?? event.locationInWindow
defer { lastMousePosition = position }
- guard hoverEnabled, position != lastMousePosition,
- let index = index(at: convert(event.locationInWindow, from: nil))
- else { return }
+ guard hoverEnabled, position != lastMousePosition, pinned == nil else { return }
+ guard let index = index(at: convert(event.locationInWindow, from: nil)) else {
+ if hoverIndex != nil { cancelHoverCard() }
+ return
+ }
+ scheduleHoverCard(index)
select(index)
}
+ override func mouseExited(with event: NSEvent) {
+ cancelHoverCard()
+ guard pinned == nil else { return }
+ onExitCalendar?()
+ }
+
override func mouseDown(with event: NSEvent) {
- if let index = index(at: convert(event.locationInWindow, from: nil)) { select(index) }
+ guard let index = index(at: convert(event.locationInWindow, from: nil)), days.indices.contains(index),
+ days[index].date <= today
+ else {
+ onDismissCard?()
+ cancelHoverCard()
+ return
+ }
+ cancelHoverCard()
+ hoverIndex = index
+ select(index)
+ onPinCard?(days[index].id, cardAnchor(index))
+ }
+
+ func scrollDidMove() {
+ onDismissCard?()
+ cancelHoverCard()
+ }
+
+ private func updatePinMonitor() {
+ guard pinned != nil, window != nil else {
+ if let pinMonitor { NSEvent.removeMonitor(pinMonitor) }
+ pinMonitor = nil
+ if let window, window.firstResponder === self { window.makeFirstResponder(window.contentView) }
+ return
+ }
+ if pinMonitor == nil {
+ pinMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .keyDown]) { [weak self] event in
+ MainActor.assumeIsolated { self?.passesPinnedEvent(event) ?? true } ? event : nil
+ }
+ }
+ if let window, window.firstResponder !== self { window.makeFirstResponder(self) }
+ }
+
+ override var acceptsFirstResponder: Bool { pinned != nil }
+
+ override func cancelOperation(_ sender: Any?) {
+ guard pinned != nil else {
+ nextResponder?.tryToPerform(#selector(NSResponder.cancelOperation(_:)), with: sender)
+ return
+ }
+ dismissPinnedCard()
+ }
+
+ private func dismissPinnedCard() {
+ pinned = nil
+ updatePinMonitor()
+ onDismissCard?()
+ }
+
+ func passesPinnedEvent(_ event: NSEvent) -> Bool {
+ if event.type == .keyDown {
+ guard event.keyCode == 53 else { return true }
+ dismissPinnedCard()
+ return false
+ }
+ if event.window === window, let scroll = enclosingScrollView {
+ let point = localPoint(event.locationInWindow, in: scroll)
+ if scroll.bounds.contains(point) || pinSafeFrames.contains(where: { $0.contains(point) }) {
+ return true
+ }
+ }
+ onDismissCard?()
+ return true
+ }
+
+ private func scheduleHoverCard(_ index: Int) {
+ guard hoverIndex != index else { return }
+ cancelHoverCard()
+ hoverIndex = index
+ guard days.indices.contains(index), days[index].date <= today else { return }
+ let key = days[index].id
+ let work = DispatchWorkItem { [weak self] in
+ MainActor.assumeIsolated {
+ guard let self, self.hoverIndex == index else { return }
+ self.onHoverCard?(key, self.cardAnchor(index))
+ }
+ }
+ hoverWork = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: work)
+ }
+
+ private func cancelHoverTimer() {
+ hoverWork?.cancel()
+ hoverWork = nil
+ hoverIndex = nil
+ }
+
+ private func cancelHoverCard() {
+ cancelHoverTimer()
+ onHoverCard?(nil, nil)
}
private func index(at point: NSPoint) -> Int? {
@@ -258,19 +434,6 @@ final class HistoryCalendarCanvas: NSView {
onSelect?(days[index].id)
}
- @objc func view(
- _ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?
- ) -> String {
- index(at: point).map { descriptions[$0] } ?? ""
- }
-
- func updateTooltips() {
- guard tooltipsDirty else { return }
- removeAllToolTips()
- for index in days.indices { addToolTip(cellRect(index), owner: self, userData: nil) }
- tooltipsDirty = false
- }
-
private func description(_ day: HistoryCalendarDay) -> String {
let prefix = Calendar.current.isDate(day.date, inSameDayAs: today) ? "history.today".localized + " · " : ""
let date = day.date.formatted(.dateTime.weekday(.wide).day().month(.wide).year().locale(locale))
diff --git a/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift b/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift
new file mode 100644
index 0000000..e6f116f
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/HistoryDayCardView.swift
@@ -0,0 +1,148 @@
+import SwiftUI
+import TokenGaugeCore
+
+enum HistoryDayCardPlacement {
+ static let width: CGFloat = 248
+ static let spacing: CGFloat = 6
+
+ static func origin(anchor: CGRect, cardSize: CGSize, bounds: CGRect) -> CGPoint {
+ let below = anchor.maxY + spacing
+ let above = anchor.minY - spacing - cardSize.height
+ let y: CGFloat
+ if below + cardSize.height <= bounds.maxY {
+ y = below
+ } else if above >= bounds.minY {
+ y = above
+ } else {
+ y = max(bounds.minY, min(below, bounds.maxY - cardSize.height))
+ }
+ let trailing = max(bounds.minX, bounds.maxX - cardSize.width)
+ return CGPoint(x: min(max(bounds.minX, anchor.midX - cardSize.width / 2), trailing), y: y)
+ }
+
+ static func percentages(_ values: [Int]) -> [Int] {
+ let total = values.reduce(0) { $0 &+ $1 }
+ guard total > 0 else { return values.map { _ in 0 } }
+ let exact = values.map { Double($0) * 100 / Double(total) }
+ var result = exact.map { Int($0.rounded(.down)) }
+ let order = exact.indices.sorted { left, right in
+ let leftRemainder = exact[left] - Double(result[left])
+ let rightRemainder = exact[right] - Double(result[right])
+ return leftRemainder == rightRemainder ? left < right : leftRemainder > rightRemainder
+ }
+ for index in order.prefix(max(0, 100 - result.reduce(0, +))) { result[index] += 1 }
+ return result
+ }
+}
+
+struct HistoryDayCardView: View {
+ let day: HistoryCalendarDay
+ let providers: [UsageProvider]
+ let isToday: Bool
+ let isPinned: Bool
+ let currentStreak: Int
+ let longestStreak: Int
+ let onClose: () -> Void
+
+ private var locale: Locale { Locale(identifier: LocalizationManager.shared.language.rawValue) }
+ private var rows: [UsageProvider] { providers.filter { (day.tokens(for: $0) ?? 0) > 0 } }
+ private var total: Int { rows.compactMap { day.tokens(for: $0) }.reduce(0, +) }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ header
+ if rows.isEmpty {
+ Text("history.no_activity".localized).font(.system(size: 10)).foregroundStyle(.secondary)
+ } else {
+ let percentages = HistoryDayCardPlacement.percentages(rows.map { day.tokens(for: $0) ?? 0 })
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(Array(rows.enumerated()), id: \.element) { index, provider in
+ providerRow(provider, percentage: percentages[index])
+ }
+ }
+ }
+ Divider()
+ Text(
+ "history.streak_current".localized(String(currentStreak)) + " · "
+ + "history.streak_longest".localized(String(longestStreak))
+ )
+ .font(.system(size: 10)).foregroundStyle(.secondary)
+ }
+ .padding(11)
+ .frame(width: HistoryDayCardPlacement.width, alignment: .leading)
+ .background(RoundedRectangle(cornerRadius: Theme.Layout.cardRadius).fill(.regularMaterial))
+ .overlay(
+ RoundedRectangle(cornerRadius: Theme.Layout.cardRadius).stroke(Theme.Layout.cardStroke, lineWidth: 1)
+ )
+ .shadow(color: Color.black.opacity(0.18), radius: 8, y: 3)
+ }
+
+ private var header: some View {
+ HStack(spacing: 6) {
+ Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.wide).year().locale(locale)))
+ .font(.system(size: 11, weight: .semibold)).lineLimit(2).fixedSize(horizontal: false, vertical: true)
+ if isToday {
+ Text("history.today".localized)
+ .font(.system(size: 9, weight: .medium))
+ .padding(.horizontal, 5).padding(.vertical, 2)
+ .background(Capsule().fill(Color.primary.opacity(0.08)))
+ }
+ Spacer(minLength: 0)
+ if isPinned {
+ Button(action: onClose) {
+ Image(systemName: "xmark").font(.system(size: 9, weight: .semibold))
+ .padding(4).background(Circle().fill(Color.primary.opacity(0.06)))
+ }
+ .buttonStyle(.plain).accessibilityLabel("history.close".localized)
+ }
+ }
+ }
+
+ private func providerRow(_ provider: UsageProvider, percentage: Int) -> some View {
+ let tokens = day.tokens(for: provider) ?? 0
+ let share = total > 0 ? Double(tokens) / Double(total) : 0
+ let percentage = percentage.formatted(.number.locale(locale)) + " %"
+ return VStack(alignment: .leading, spacing: 5) {
+ HStack(spacing: 6) {
+ ProviderLogo(provider: provider, size: 16)
+ Text(name(provider)).font(.system(size: 11, weight: .medium))
+ Spacer(minLength: 6)
+ Text(UsageFormatters.tokens(tokens)).font(.system(size: 11, weight: .semibold)).monospacedDigit()
+ if rows.count > 1 {
+ Text(percentage).font(.system(size: 10)).foregroundStyle(.secondary).monospacedDigit()
+ .accessibilityLabel("history.share".localized(percentage))
+ }
+ }
+ if rows.count > 1 {
+ GeometryReader { proxy in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.08))
+ Capsule().fill(color(provider)).frame(width: max(2, proxy.size.width * share))
+ }
+ }
+ .frame(height: Theme.Layout.barHeight)
+ }
+ let chips = models(provider)
+ if !chips.isEmpty {
+ HStack(spacing: 4) {
+ ForEach(chips, id: \.self) { model in
+ Text(model).font(.system(size: 10)).lineLimit(1)
+ .padding(.horizontal, 5).padding(.vertical, 2)
+ .background(Capsule().fill(color(provider).opacity(0.12)))
+ }
+ }
+ }
+ }
+ }
+
+ private func models(_ provider: UsageProvider) -> [String] {
+ let rows = day.efforts.filter { $0.provider == provider }
+ let totals = Dictionary(grouping: rows, by: \.model).mapValues { $0.map(\.tokens).reduce(0, +) }
+ return totals.sorted { $0.value == $1.value ? $0.key < $1.key : $0.value > $1.value }.prefix(3).map(\.key)
+ }
+
+ private func color(_ provider: UsageProvider) -> Color { provider == .claude ? Theme.claude : Theme.codex }
+ private func name(_ provider: UsageProvider) -> String {
+ (provider == .claude ? "provider.claude_short" : "provider.codex").localized
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
index 5d83631..5df66aa 100644
--- a/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
+++ b/Sources/TokenGaugeApp/Views/HistoryPanelView.swift
@@ -10,18 +10,61 @@ struct HistoryPanelView: View {
@State private var calendarFocusRequest = 0
@State private var showingDetails = false
@State private var lastPointerLocation = NSEvent.mouseLocation
+ @State private var frames: [HistoryFrame: CGRect] = [:]
+ @State private var cardHeight: CGFloat = 150
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.quotaAnimationsEnabled) private var animateChanges
@Namespace private var daySelection
private var locale: Locale { Locale(identifier: LocalizationManager.shared.language.rawValue) }
private var today: Date { Calendar.current.startOfDay(for: Date()) }
- private var accent: Color { providers == [.claude] ? Theme.claude : Theme.codex }
+ private var accent: Color {
+ if providers == [.claude] { return Theme.claude }
+ if providers == [.codex] { return Theme.codex }
+ return Color(nsColor: .labelColor)
+ }
+
+ private var calendarProviders: [UsageProvider] {
+ let recorded = [UsageProvider.codex, .claude].filter { provider in
+ model.days.contains { $0.tokens(for: provider) != nil }
+ }
+ return recorded.isEmpty ? [.codex, .claude] : recorded
+ }
+
+ private var stripProviders: [UsageProvider] {
+ model.loadedMode == .calendar ? calendarProviders : providers
+ }
private var motion: Animation? {
animateChanges && !reduceMotion ? .easeInOut(duration: 0.18) : nil
}
+ private var cardMotion: Animation? {
+ animateChanges && !reduceMotion ? .easeOut(duration: 0.12) : nil
+ }
+
+ private var cardTransition: AnyTransition {
+ animateChanges && !reduceMotion ? .opacity.combined(with: .scale(scale: 0.97)) : .identity
+ }
+
+ private var pinnedDayKey: String? {
+ model.daySelection.flatMap { $0.isPinned ? $0.dayKey : nil }
+ }
+
+ private var cardFrame: CGRect? {
+ guard let anchor = model.daySelection?.anchor, let calendar = frames[.calendar] else { return nil }
+ let size = CGSize(width: HistoryDayCardPlacement.width, height: cardHeight)
+ let origin = HistoryDayCardPlacement.origin(
+ anchor: anchor.cell.offsetBy(dx: calendar.minX, dy: calendar.minY), cardSize: size,
+ bounds: anchor.bounds.offsetBy(dx: calendar.minX, dy: calendar.minY))
+ return CGRect(origin: origin, size: size)
+ }
+
+ private var pinSafeFrames: [CGRect] {
+ guard let calendar = frames[.calendar] else { return [] }
+ return [cardFrame, frames[.summary]].compactMap { $0?.offsetBy(dx: -calendar.minX, dy: -calendar.minY) }
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
@@ -51,22 +94,69 @@ struct HistoryPanelView: View {
selectionSummary
}
}
+ .background(frameReporter(.panel))
+ .overlay(alignment: .topLeading) { dayCard.animation(cardMotion, value: model.daySelection != nil) }
+ .zIndex(1)
+ .onPreferenceChange(HistoryFrameKey.self) { frames = $0 }
+ .onPreferenceChange(HistoryDayCardHeightKey.self) { if $0 > 0 { cardHeight = $0 } }
+ .onChange(of: providers) { _, _ in dismissDayCard() }
.onChange(of: mode) { _, newValue in
model.offset = 0
model.selectedDayKey = nil
+ model.dismissDayCard()
lastPointerLocation = NSEvent.mouseLocation
if newValue == .calendar { model.selectedDayKey = HistoryDashboardModel.dayKey(today) }
}
.onChange(of: model.periodStart) { _, _ in
lastPointerLocation = NSEvent.mouseLocation
+ model.dismissDayCard()
if mode == .calendar {
model.selectedDayKey =
(model.days.first(where: { $0.date == today })
- ?? model.days.last(where: { $0.total(for: providers) != nil }) ?? model.days.last)?.id
+ ?? model.days.last(where: { $0.total(for: calendarProviders) != nil }) ?? model.days.last)?.id
}
}
}
+ @ViewBuilder private var dayCard: some View {
+ if let selection = model.daySelection, let frame = cardFrame, let panel = frames[.panel],
+ let day = model.days.first(where: { $0.id == selection.dayKey })
+ {
+ let streak = model.streak(for: providers)
+ HistoryDayCardView(
+ day: day, providers: calendarProviders,
+ isToday: Calendar.current.isDate(day.date, inSameDayAs: today),
+ isPinned: selection.isPinned, currentStreak: streak.current, longestStreak: streak.longest,
+ onClose: dismissDayCard
+ )
+ .background(
+ GeometryReader { proxy in
+ Color.clear.preference(key: HistoryDayCardHeightKey.self, value: proxy.size.height)
+ }
+ )
+ .allowsHitTesting(selection.isPinned)
+ .offset(x: frame.minX - panel.minX, y: frame.minY - panel.minY)
+ .transition(cardTransition)
+ }
+ }
+
+ private func frameReporter(_ kind: HistoryFrame) -> some View {
+ GeometryReader { proxy in
+ Color.clear.preference(key: HistoryFrameKey.self, value: [kind: proxy.frame(in: .global)])
+ }
+ }
+
+ private func dismissDayCard() {
+ guard model.daySelection != nil else { return }
+ model.dismissDayCard()
+ selectToday()
+ }
+
+ private func selectToday() {
+ guard mode == .calendar else { return }
+ model.selectedDayKey = HistoryDashboardModel.dayKey(today)
+ }
+
private var modePicker: some View {
HistoryModeControl(mode: $mode)
.frame(
@@ -187,12 +277,31 @@ struct HistoryPanelView: View {
}
.padding(.top, Theme.Layout.historyCalendarGridTop)
HistoryCalendarView(
- days: model.days, providers: providers, selectedDayKey: model.selectedDay?.id,
+ days: model.days, providers: calendarProviders, selectedDayKey: model.selectedDay?.id,
focusID: "\(model.periodStart.timeIntervalSince1970):\(calendarFocusRequest)",
- hoverEnabled: !showingDetails && !model.isLoading
+ hoverEnabled: !showingDetails && !model.isLoading,
+ accent: accent, pinnedDayKey: pinnedDayKey, pinSafeFrames: pinSafeFrames,
+ onHoverCard: { key, anchor in
+ guard let key else {
+ model.hideDayCard()
+ return
+ }
+ model.showDayCard(key, anchor: anchor)
+ },
+ onPinCard: { key, anchor in
+ model.pinDayCard(key, anchor: anchor)
+ if model.daySelection == nil { selectToday() }
+ },
+ onExitCalendar: {
+ guard model.daySelection?.isPinned != true else { return }
+ model.hideDayCard()
+ selectToday()
+ },
+ onDismissCard: dismissDayCard
) { key in
if let day = model.days.first(where: { $0.id == key }) { select(day) }
}
+ .background(frameReporter(.calendar))
}
.frame(height: Theme.Layout.historyCalendarHeight)
}
@@ -207,9 +316,13 @@ struct HistoryPanelView: View {
if let day = model.selectedDay {
VStack(alignment: .leading, spacing: 7) {
HStack {
- Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.abbreviated).locale(locale)))
- .font(.system(size: 10)).foregroundStyle(.secondary)
- .contentTransition(.opacity)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(day.date.formatted(.dateTime.weekday(.wide).day().month(.abbreviated).locale(locale)))
+ .font(.system(size: 10)).foregroundStyle(.secondary)
+ .contentTransition(.opacity)
+ Text(streakLine)
+ .font(.system(size: 10)).foregroundStyle(.secondary)
+ }
todayBadge
.opacity(Calendar.current.isDate(day.date, inSameDayAs: today) ? 1 : 0)
.accessibilityHidden(!Calendar.current.isDate(day.date, inSameDayAs: today))
@@ -223,19 +336,20 @@ struct HistoryPanelView: View {
}.font(.system(size: 10))
}
.buttonStyle(.plain).foregroundStyle(.secondary)
- .disabled(day.efforts.filter { providers.contains($0.provider) }.isEmpty)
+ .disabled(day.efforts.filter { stripProviders.contains($0.provider) }.isEmpty)
.popover(isPresented: $showingDetails, arrowEdge: .bottom) {
effortDetails(day)
}
}
HStack(spacing: 10) {
- ForEach(providers, id: \.self) { provider in
+ ForEach(stripProviders, id: \.self) { provider in
providerTotal(provider, day: day)
}
}
}
.padding(10)
.background(RoundedRectangle(cornerRadius: 9).fill(Color.primary.opacity(0.035)))
+ .background(frameReporter(.summary))
.animation(motion, value: day.id)
}
}
@@ -256,7 +370,7 @@ struct HistoryPanelView: View {
}
private func effortDetails(_ day: HistoryCalendarDay) -> some View {
- let rows = day.efforts.filter { providers.contains($0.provider) }.sorted { $0.tokens > $1.tokens }
+ let rows = day.efforts.filter { stripProviders.contains($0.provider) }.sorted { $0.tokens > $1.tokens }
return VStack(alignment: .leading, spacing: 12) {
HStack {
VStack(alignment: .leading, spacing: 3) {
@@ -318,9 +432,32 @@ struct HistoryPanelView: View {
return date + ": " + totals
}
+ private var streakLine: String {
+ let streak = model.streak(for: providers)
+ return "history.streak".localized(String(streak.current), String(streak.longest))
+ }
+
private func exact(_ tokens: Int) -> String { tokens.formatted(.number.locale(locale)) }
private func color(_ provider: UsageProvider) -> Color { provider == .claude ? Theme.claude : Theme.codex }
private func name(_ provider: UsageProvider) -> String {
(provider == .claude ? "provider.claude_short" : "provider.codex").localized
}
}
+
+private enum HistoryFrame {
+ case panel
+ case calendar
+ case summary
+}
+
+private struct HistoryFrameKey: PreferenceKey {
+ static let defaultValue: [HistoryFrame: CGRect] = [:]
+ static func reduce(value: inout [HistoryFrame: CGRect], nextValue: () -> [HistoryFrame: CGRect]) {
+ value.merge(nextValue()) { $1 }
+ }
+}
+
+private struct HistoryDayCardHeightKey: PreferenceKey {
+ static let defaultValue: CGFloat = 0
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) }
+}
diff --git a/Sources/TokenGaugeApp/Views/PopoverView.swift b/Sources/TokenGaugeApp/Views/PopoverView.swift
index f572de3..8f5b1c6 100644
--- a/Sources/TokenGaugeApp/Views/PopoverView.swift
+++ b/Sources/TokenGaugeApp/Views/PopoverView.swift
@@ -23,15 +23,13 @@ struct PopoverView: View {
wrappedValue: history ?? HistoryDashboardModel(previewSnapshots: preview, mode: store.historyMode))
}
- private var providerMaxHeight: CGFloat {
- let height: CGFloat =
- store.panelStyle == .rings
+ var providerMaxHeight: CGFloat {
+ store.panelStyle == .rings
? Theme.Layout.ringProviderMaxHeight
: store.panelStyle == .compact ? Theme.Layout.compactProviderMaxHeight : Theme.Layout.providerMaxHeight
- return height - (updates.phase == .idle ? 0 : 44)
}
- private var panelWidth: CGFloat {
+ var panelWidth: CGFloat {
let unified = store.displayMode == .unified
switch store.panelStyle {
case .standard: return unified ? Theme.Layout.unifiedPanelWidth : Theme.Layout.panelWidth
@@ -61,6 +59,9 @@ struct PopoverView: View {
private var content: some View {
VStack(alignment: .leading, spacing: Theme.Layout.sectionSpacing) {
header
+ if updates.phase != .idle {
+ UpdateBannerView(updates: updates)
+ }
providerPicker
ViewThatFits(in: .vertical) {
@@ -76,13 +77,6 @@ struct PopoverView: View {
HistoryPanelView(
model: history, mode: $store.historyMode,
providers: store.displayMode.providers, compact: store.panelStyle != .standard)
- if store.panelStyle == .standard {
- Divider().padding(.top, 1)
- footer
- } else if updates.phase != .idle {
- Divider()
- UpdateActionView().padding(5)
- }
}
.padding(.horizontal, Theme.Layout.panelPadding)
.padding(.top, 12)
@@ -135,7 +129,7 @@ struct PopoverView: View {
.accessibilityAddTraits(selected ? [.isSelected] : [])
}
- private var providerContent: some View {
+ var providerContent: some View {
let layout =
store.panelStyle == .compact
? AnyLayout(VStackLayout(alignment: .leading, spacing: 6))
@@ -150,6 +144,7 @@ struct PopoverView: View {
claudeMenuBarSource: store.claudeMenuBarSource,
claudeAutomaticRecovery: store.claudeAutomaticRecovery,
showHourlyPace: store.showHourlyPace,
+ stretchesHeight: store.displayMode == .unified && store.panelStyle == .rings,
accountLabel: store.claudeAccountLabel,
paces: paces(for: provider), previousPaces: previousPaces(for: provider)
)
@@ -190,18 +185,12 @@ struct PopoverView: View {
}
private func minimumRingWidth(_ count: CGFloat) -> CGFloat {
- let cells = min(count, 3)
- return max(
- Theme.Layout.minimumRingCardWidth,
- cells * Theme.Layout.quotaRingCellWidth + (cells - 1) * Theme.Layout.quotaRingSpacing
- + Theme.Layout.cardPadding * 2)
+ QuotaRingLayout.minimumCardWidth(cells: Int(count))
}
private var ringUnifiedWidth: CGFloat {
- let content =
- minimumRingWidth(ringWindowCount(.codex)) + minimumRingWidth(ringWindowCount(.claude))
- + Theme.Layout.panelPadding * 2 + Theme.Layout.quotaRingSpacing
- return min(max(content, Theme.Layout.minimumRingUnifiedWidth), Theme.Layout.ringUnifiedWidth)
+ QuotaRingLayout.unifiedPanelWidth(
+ codexCells: Int(ringWindowCount(.codex)), claudeCells: Int(ringWindowCount(.claude)))
}
private func ringCardWidth(for provider: UsageProvider) -> CGFloat? {
@@ -246,18 +235,16 @@ struct PopoverView: View {
.accessibilityLabel("settings.panel_style".localized)
.accessibilityIdentifier("TokenGauge.panelStyle")
- if store.panelStyle != .standard {
- Menu {
- Button("settings.title".localized, action: showSettings)
- Button("about.title".localized, action: showAbout)
- Divider()
- Button("action.quit".localized) { NSApp.terminate(nil) }
- } label: {
- Image(systemName: "gearshape").font(.system(size: 12)).foregroundStyle(.secondary)
- }
- .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize()
- .accessibilityLabel("settings.title".localized)
+ Menu {
+ Button("settings.title".localized, action: showSettings)
+ Button("about.title".localized, action: showAbout)
+ Divider()
+ Button("action.quit".localized) { NSApp.terminate(nil) }
+ } label: {
+ Image(systemName: "gearshape").font(.system(size: 12)).foregroundStyle(.secondary)
}
+ .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize()
+ .accessibilityLabel("settings.title".localized)
Button {
store.refresh(force: true)
@@ -279,53 +266,4 @@ struct PopoverView: View {
.disabled(store.isRefreshing)
}
}
-
- private var footer: some View {
- VStack(spacing: 0) {
- if updates.phase != .idle {
- UpdateActionView().padding(5)
- }
- FooterActionRow(icon: "gearshape", title: "settings.title".localized, action: showSettings)
- FooterActionRow(icon: "info.circle", title: "about.title".localized, action: showAbout)
- FooterActionRow(icon: "power", title: "action.quit".localized) {
- NSApp.terminate(nil)
- }
- }
- }
-}
-
-private struct FooterActionRow: View {
- let icon: String
- let title: String
- let action: () -> Void
-
- @State private var hovered = false
-
- var body: some View {
- Button(action: action) {
- HStack(spacing: 9) {
- Image(systemName: icon)
- .font(.system(size: 11))
- .frame(width: 15)
- Text(title)
- .font(.caption)
- Spacer(minLength: 0)
- if icon != "power" {
- Image(systemName: "chevron.right")
- .font(.system(size: 9, weight: .medium))
- }
- }
- .padding(.horizontal, 5)
- .frame(height: 24)
- .background(
- RoundedRectangle(cornerRadius: Theme.Layout.rowRadius, style: .continuous)
- .fill(hovered ? Color.primary.opacity(0.07) : Color.clear)
- )
- .foregroundStyle(icon == "power" ? Color.red : Color.secondary)
- .contentShape(Rectangle())
- }
- .buttonStyle(.plain)
- .accessibilityIdentifier("TokenGauge.footer.\(icon)")
- .onHover { hovered = $0 }
- }
}
diff --git a/Sources/TokenGaugeApp/Views/ProviderCard.swift b/Sources/TokenGaugeApp/Views/ProviderCard.swift
index 497d654..4cb0806 100644
--- a/Sources/TokenGaugeApp/Views/ProviderCard.swift
+++ b/Sources/TokenGaugeApp/Views/ProviderCard.swift
@@ -12,6 +12,7 @@ struct ProviderCard: View {
var claudeMenuBarSource: ClaudeMenuBarSource = .automatic
var claudeAutomaticRecovery = false
var showHourlyPace = false
+ var stretchesHeight = false
var accountLabel: String?
var paces: [String: QuotaPace] = [:]
var previousPaces: [String: QuotaPace] = [:]
@@ -63,6 +64,7 @@ struct ProviderCard: View {
}
.padding(compact ? 9 : (panelStyle == .compact ? 8 : 12))
.frame(maxWidth: .infinity, alignment: .leading)
+ .frame(maxHeight: stretchesHeight ? .infinity : nil, alignment: .top)
.providerCard()
}
@@ -73,15 +75,11 @@ struct ProviderCard: View {
QuotaRingWindow(
window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id],
- alignsTitleRows: false)
+ metrics: .single, alignsTitleRows: false)
} else {
- QuotaRingGrid {
- ForEach(visibleWindows) { window in
- QuotaRingWindow(
- window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
- showPace: canShowPace(window), pace: paces[window.id],
- previousPace: previousPaces[window.id])
- }
+ ViewThatFits(in: .horizontal) {
+ ringGrid.frame(minWidth: QuotaRingLayout.minimumGridWidth(windows: visibleWindows.count))
+ ringRows
}
}
} else {
@@ -105,6 +103,27 @@ struct ProviderCard: View {
}
}
+ private var ringGrid: some View {
+ QuotaRingGrid {
+ ForEach(visibleWindows) { window in
+ QuotaRingWindow(
+ window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id])
+ }
+ }
+ }
+
+ private var ringRows: some View {
+ VStack(spacing: Theme.Layout.ringRowSpacing) {
+ ForEach(Array(visibleWindows.enumerated()), id: \.element.id) { index, window in
+ if index > 0 { Divider() }
+ QuotaRingRow(
+ window: window, tint: tint, chips: chips(for: window), historical: showsLastKnown,
+ showPace: canShowPace(window), pace: paces[window.id], previousPace: previousPaces[window.id])
+ }
+ }
+ }
+
private func canShowPace(_ window: QuotaWindow) -> Bool {
showHourlyPace && (state.status == .ready || showsLastKnown) && window.durationMinutes == 10080
}
@@ -159,7 +178,7 @@ struct ProviderCard: View {
if !showsTitle { Spacer(minLength: 4) }
if state.status == .ready, let credits = state.snapshot?.availableResetCredits, credits > 0 {
Text(UsageFormatters.resetCredits(credits))
- .font(.system(size: 9, weight: .medium))
+ .font(.system(size: 10, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
diff --git a/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift b/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
index 69c3585..831cb48 100644
--- a/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaPaceLabel.swift
@@ -37,7 +37,7 @@ struct QuotaPaceLabel: View {
QuotaPaceDetailsView(display: display)
}
}
- .font(.system(size: 9)).foregroundStyle(.secondary)
+ .font(.system(size: 10)).foregroundStyle(.secondary)
}
}
@@ -60,9 +60,9 @@ struct QuotaPaceDetailsView: View {
if let previous = display.previous {
reading(previous, title: "pace.previous".localized)
}
- Text("pace.info_method".localized).font(.system(size: 9)).foregroundStyle(.secondary)
+ Text("pace.info_method".localized).font(.system(size: 10)).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
- Text("pace.info_retention".localized).font(.system(size: 9)).foregroundStyle(.secondary)
+ Text("pace.info_retention".localized).font(.system(size: 10)).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(14).frame(width: 285)
@@ -77,7 +77,7 @@ struct QuotaPaceDetailsView: View {
.font(.system(size: 12, weight: .semibold)).monospacedDigit()
}
Text(pace.sampledAt.formatted(.dateTime.day().month(.abbreviated).year().hour().minute().locale(locale)))
- .font(.system(size: 9)).foregroundStyle(.secondary)
+ .font(.system(size: 10)).foregroundStyle(.secondary)
}
}
diff --git a/Sources/TokenGaugeApp/Views/QuotaRing.swift b/Sources/TokenGaugeApp/Views/QuotaRing.swift
index d6becc6..dfb54d9 100644
--- a/Sources/TokenGaugeApp/Views/QuotaRing.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaRing.swift
@@ -7,30 +7,33 @@ struct QuotaRing: View {
let remainingPercentage: Double
let tint: Color
var historical = false
+ var metrics: QuotaRingMetrics = .grid
var body: some View {
ZStack {
- Circle().stroke(Color.primary.opacity(0.09), lineWidth: Theme.Layout.quotaRingLineWidth)
+ Circle().stroke(Color.primary.opacity(0.09), lineWidth: metrics.lineWidth)
Circle()
.trim(from: 0, to: min(max(remainingPercentage / 100, 0), 1))
- .stroke(tint, style: StrokeStyle(lineWidth: Theme.Layout.quotaRingLineWidth, lineCap: .round))
+ .stroke(tint, style: StrokeStyle(lineWidth: metrics.lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(animateChanges && !reduceMotion ? Theme.Motion.value : nil, value: remainingPercentage)
VStack(spacing: 1) {
Text(UsageFormatters.percentage(remainingPercentage))
- .font(.system(size: 17, weight: .semibold, design: .rounded))
+ .font(.system(size: metrics.percentageSize, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(Theme.severity(remaining: remainingPercentage) ?? .primary)
- Text((historical ? "quota.last_remaining" : "quota.remaining").localized)
- .font(.system(size: 8))
- .foregroundStyle(.secondary)
- .lineLimit(2)
- .multilineTextAlignment(.center)
+ if metrics.showsLabel {
+ Text((historical ? "quota.last_remaining" : "quota.remaining").localized)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+ }
}
- .padding(5)
+ .padding(metrics.contentPadding)
}
- .padding(Theme.Layout.quotaRingLineWidth / 2)
- .frame(width: Theme.Layout.quotaRingDiameter, height: Theme.Layout.quotaRingDiameter)
+ .padding(metrics.lineWidth / 2)
+ .frame(width: metrics.diameter, height: metrics.diameter)
.accessibilityElement(children: .combine)
}
}
@@ -43,12 +46,13 @@ struct QuotaRingWindow: View {
var showPace = false
var pace: QuotaPace?
var previousPace: QuotaPace?
+ var metrics: QuotaRingMetrics = .grid
var alignsTitleRows = true
var body: some View {
VStack(spacing: Theme.Layout.ringSectionSpacing) {
Text(UsageFormatters.windowName(window))
- .font(.system(size: 10, weight: .medium))
+ .font(.system(size: 11, weight: .medium))
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(height: alignsTitleRows ? Theme.Layout.ringTitleHeight : nil, alignment: .bottom)
@@ -56,20 +60,23 @@ struct QuotaRingWindow: View {
QuotaRing(
remainingPercentage: window.remainingPercentage,
tint: Theme.severity(remaining: window.remainingPercentage) ?? tint,
- historical: historical)
+ historical: historical,
+ metrics: metrics)
VStack(spacing: 4) {
Text(UsageFormatters.reset(window.resetsAt))
- .font(.system(size: 9))
+ .font(.system(size: 10))
.foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
.multilineTextAlignment(.center)
- .fixedSize(horizontal: false, vertical: true)
if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
if let summary = UsageFormatters.modelChips(chips) {
Text(summary)
- .font(.system(size: 9))
+ .font(.system(size: 10))
.foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.middle)
+ .lineLimit(2)
+ .truncationMode(.tail)
+ .multilineTextAlignment(.center)
.help(summary)
.monospacedDigit()
}
@@ -79,3 +86,54 @@ struct QuotaRingWindow: View {
.accessibilityElement(children: showPace ? .contain : .combine)
}
}
+
+struct QuotaRingRow: View {
+ let window: QuotaWindow
+ let tint: Color
+ let chips: [ModelUsageChip]
+ var historical = false
+ var showPace = false
+ var pace: QuotaPace?
+ var previousPace: QuotaPace?
+
+ var body: some View {
+ HStack(spacing: Theme.Layout.quotaRingSpacing) {
+ QuotaRing(
+ remainingPercentage: window.remainingPercentage,
+ tint: Theme.severity(remaining: window.remainingPercentage) ?? tint,
+ historical: historical,
+ metrics: .row)
+ VStack(alignment: .leading, spacing: 3) {
+ Text(UsageFormatters.windowName(window))
+ .font(.system(size: 11, weight: .medium))
+ .lineLimit(1)
+ .help(UsageFormatters.windowHelp(window))
+ Text(
+ (historical ? "quota.last_remaining_value" : "quota.remaining_value").localized(
+ UsageFormatters.percentage(window.remainingPercentage))
+ )
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .monospacedDigit()
+ Text(UsageFormatters.reset(window.resetsAt))
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ if showPace { QuotaPaceLabel(pace: pace, previousPace: previousPace) }
+ if let summary = UsageFormatters.modelChips(chips) {
+ Text(summary)
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .help(summary)
+ .monospacedDigit()
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .accessibilityElement(children: showPace ? .contain : .combine)
+ }
+}
diff --git a/Sources/TokenGaugeApp/Views/QuotaRingGrid.swift b/Sources/TokenGaugeApp/Views/QuotaRingGrid.swift
index 395fdc3..0e00828 100644
--- a/Sources/TokenGaugeApp/Views/QuotaRingGrid.swift
+++ b/Sources/TokenGaugeApp/Views/QuotaRingGrid.swift
@@ -3,9 +3,7 @@ import SwiftUI
struct QuotaRingGrid: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let cells = min(max(subviews.count, 1), 3)
- let idealWidth =
- CGFloat(cells) * Theme.Layout.quotaRingCellWidth
- + CGFloat(cells - 1) * Theme.Layout.quotaRingSpacing
+ let idealWidth = QuotaRingLayout.minimumGridWidth(windows: cells)
let width = proposal.width.flatMap { $0.isFinite ? max(0, $0) : nil } ?? idealWidth
return geometry(width: width, subviews: subviews).size
}
@@ -22,12 +20,12 @@ struct QuotaRingGrid: Layout {
private func geometry(width: CGFloat, subviews: Subviews) -> (size: CGSize, frames: [CGRect]) {
guard !subviews.isEmpty else { return (.zero, []) }
let spacing = Theme.Layout.quotaRingSpacing
- let columns = min(subviews.count, max(1, Int((width + spacing) / (Theme.Layout.quotaRingCellWidth + spacing))))
+ let columns = QuotaRingLayout.columns(availableWidth: width, windows: subviews.count)
var frames: [CGRect] = []
var y: CGFloat = 0
for start in stride(from: 0, to: subviews.count, by: columns) {
let count = min(columns, subviews.count - start)
- let cellWidth = max(0, (width - CGFloat(count - 1) * spacing) / CGFloat(count))
+ let cellWidth = max(0, QuotaRingLayout.cellWidth(availableWidth: width, columns: count))
let rowHeight =
(start..<(start + count)).map {
subviews[$0].sizeThatFits(ProposedViewSize(width: cellWidth, height: nil)).height
diff --git a/Sources/TokenGaugeApp/Views/QuotaRingLayout.swift b/Sources/TokenGaugeApp/Views/QuotaRingLayout.swift
new file mode 100644
index 0000000..a9e6a10
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/QuotaRingLayout.swift
@@ -0,0 +1,69 @@
+import SwiftUI
+
+enum QuotaRingLayout {
+ enum Mode: Equatable {
+ case single
+ case grid(columns: Int)
+ case rows
+ }
+
+ static func choose(availableWidth: CGFloat, windows: Int) -> Mode {
+ guard windows > 1 else { return .single }
+ guard cellWidth(availableWidth: availableWidth, columns: windows) >= Theme.Layout.quotaRingCellWidth else {
+ return .rows
+ }
+ return .grid(columns: windows)
+ }
+
+ static func cellWidth(availableWidth: CGFloat, columns: Int) -> CGFloat {
+ let columns = max(1, columns)
+ return (availableWidth - CGFloat(columns - 1) * Theme.Layout.quotaRingSpacing) / CGFloat(columns)
+ }
+
+ static func minimumGridWidth(windows: Int) -> CGFloat {
+ let columns = max(1, windows)
+ return CGFloat(columns) * Theme.Layout.quotaRingCellWidth
+ + CGFloat(columns - 1) * Theme.Layout.quotaRingSpacing
+ }
+
+ static func minimumCardWidth(cells: Int) -> CGFloat {
+ max(
+ Theme.Layout.minimumRingCardWidth,
+ minimumGridWidth(windows: min(max(cells, 1), 3)) + Theme.Layout.cardPadding * 2)
+ }
+
+ static func unifiedPanelWidth(codexCells: Int, claudeCells: Int) -> CGFloat {
+ let content =
+ minimumCardWidth(cells: codexCells) + minimumCardWidth(cells: claudeCells)
+ + Theme.Layout.panelPadding * 2 + Theme.Layout.quotaRingSpacing
+ return min(max(content, Theme.Layout.minimumRingUnifiedWidth), Theme.Layout.ringUnifiedWidth)
+ }
+
+ static func columns(availableWidth: CGFloat, windows: Int) -> Int {
+ var columns = max(1, windows)
+ while columns > 1,
+ cellWidth(availableWidth: availableWidth, columns: columns) < Theme.Layout.quotaRingCellWidth
+ {
+ columns -= 1
+ }
+ return columns
+ }
+}
+
+struct QuotaRingMetrics {
+ let diameter: CGFloat
+ let lineWidth: CGFloat
+ let percentageSize: CGFloat
+ let contentPadding: CGFloat
+ let showsLabel: Bool
+
+ static let single = QuotaRingMetrics(
+ diameter: Theme.Layout.quotaRingSingleDiameter, lineWidth: Theme.Layout.quotaRingSingleLineWidth,
+ percentageSize: Theme.Layout.quotaRingSinglePercentageSize, contentPadding: 5, showsLabel: true)
+ static let grid = QuotaRingMetrics(
+ diameter: Theme.Layout.quotaRingDiameter, lineWidth: Theme.Layout.quotaRingLineWidth,
+ percentageSize: Theme.Layout.quotaRingPercentageSize, contentPadding: 5, showsLabel: true)
+ static let row = QuotaRingMetrics(
+ diameter: Theme.Layout.quotaRingRowDiameter, lineWidth: Theme.Layout.quotaRingRowLineWidth,
+ percentageSize: Theme.Layout.quotaRingRowPercentageSize, contentPadding: 2, showsLabel: false)
+}
diff --git a/Sources/TokenGaugeApp/Views/SettingsView.swift b/Sources/TokenGaugeApp/Views/SettingsView.swift
index c131050..01bd78e 100644
--- a/Sources/TokenGaugeApp/Views/SettingsView.swift
+++ b/Sources/TokenGaugeApp/Views/SettingsView.swift
@@ -7,7 +7,7 @@ struct SettingsView: View {
@ObservedObject var launchAtLogin: LaunchAtLoginManager
@ObservedObject private var localization = LocalizationManager.shared
@ObservedObject private var updates = UpdateManager.shared
- @AppStorage("codexExecutablePath") private var codexExecutablePath = ""
+ @AppStorage(AppPreferences.Key.codexExecutablePath) private var codexExecutablePath = ""
var body: some View {
VStack(alignment: .leading, spacing: 18) {
diff --git a/Sources/TokenGaugeApp/Views/UpdateBannerView.swift b/Sources/TokenGaugeApp/Views/UpdateBannerView.swift
new file mode 100644
index 0000000..d78de82
--- /dev/null
+++ b/Sources/TokenGaugeApp/Views/UpdateBannerView.swift
@@ -0,0 +1,160 @@
+import SwiftUI
+
+struct UpdateBannerView: View {
+ @ObservedObject var updates: UpdateManager
+ @ObservedObject private var localization = LocalizationManager.shared
+ @Environment(\.accessibilityReduceMotion) private var reduceMotion
+
+ static let compactHeight: CGFloat = 38
+ static let progressHeight: CGFloat = 46
+ static let cornerRadius: CGFloat = 9
+
+ init(updates: UpdateManager = .shared) {
+ self.updates = updates
+ }
+
+ static func height(for phase: UpdateManager.Phase) -> CGFloat {
+ switch phase {
+ case .idle: return 0
+ case .downloading, .extracting: return progressHeight
+ case .checking, .available, .readyToInstall, .installing, .failed: return compactHeight
+ }
+ }
+
+ var body: some View {
+ Group {
+ switch updates.phase {
+ case .idle:
+ EmptyView()
+ case .checking:
+ row(title: "updates.checking".localized, icon: "arrow.triangle.2.circlepath")
+ case .available(let version):
+ row(title: "updates.banner_available".localized(version), icon: "arrow.down.circle.fill") {
+ action("updates.download".localized) { updates.installPendingUpdate() }
+ }
+ case .downloading(let version, let fraction):
+ progress(
+ title: text(
+ "updates.banner_downloading", pending: "updates.banner_downloading_pending",
+ version: version, fraction: fraction),
+ fraction: fraction)
+ case .extracting(let version, let fraction):
+ progress(
+ title: text(
+ "updates.banner_extracting", pending: "updates.banner_extracting_pending",
+ version: version, fraction: fraction),
+ fraction: fraction)
+ case .readyToInstall(let version, let deferred):
+ if deferred {
+ row(title: "updates.banner_ready".localized(version), icon: "arrow.down.circle.fill") {
+ action("updates.install_now".localized) { updates.resumeDeferredInstall() }
+ }
+ } else {
+ row(title: "updates.banner_ready".localized(version), icon: "arrow.down.circle.fill") {
+ HStack(spacing: 6) {
+ action("updates.install_now".localized) { updates.installReadyUpdate() }
+ Button("updates.later".localized) { updates.deferReadyUpdate() }
+ .buttonStyle(.bordered).controlSize(.small)
+ }
+ }
+ }
+ case .installing:
+ row(title: "updates.installing".localized, icon: "arrow.down.circle.fill")
+ case .failed(let message):
+ row(title: message.localized, icon: "exclamationmark.triangle.fill") {
+ action("updates.retry_action".localized) { updates.installPendingUpdate() }
+ }
+ }
+ }
+ .id(localization.language)
+ }
+
+ private func row(title: String, icon: String) -> some View {
+ row(title: title, icon: icon) { EmptyView() }
+ }
+
+ private func row(
+ title: String,
+ icon: String,
+ @ViewBuilder trailing: () -> some View
+ ) -> some View {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 13))
+ .foregroundStyle(Color.accentColor)
+ .accessibilityHidden(true)
+ Text(title).font(.system(size: 11, weight: .medium)).lineLimit(1)
+ Spacer(minLength: 4)
+ trailing()
+ }
+ .frame(height: Self.compactHeight)
+ .modifier(BannerChrome())
+ }
+
+ private func progress(title: String, fraction: Double?) -> some View {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(title).font(.system(size: 11, weight: .medium)).lineLimit(1)
+ UpdateProgressBar(fraction: fraction, reduceMotion: reduceMotion)
+ }
+ .frame(height: Self.progressHeight)
+ .modifier(BannerChrome())
+ }
+
+ private func action(_ title: String, perform: @escaping () -> Void) -> some View {
+ Button(title, action: perform)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+
+ private func text(_ key: String, pending: String, version: String, fraction: Double?) -> String {
+ guard let fraction else { return pending.localized(version) }
+ return key.localized(version, Int((fraction * 100).rounded()))
+ }
+}
+
+private struct BannerChrome: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .padding(.horizontal, 10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: UpdateBannerView.cornerRadius, style: .continuous)
+ .fill(Color.accentColor.opacity(0.10))
+ )
+ }
+}
+
+private struct UpdateProgressBar: View {
+ let fraction: Double?
+ let reduceMotion: Bool
+
+ @State private var sliding = false
+
+ var body: some View {
+ GeometryReader { geometry in
+ ZStack(alignment: .leading) {
+ Capsule().fill(Color.primary.opacity(0.10))
+ if let fraction {
+ Capsule()
+ .fill(Color.accentColor)
+ .frame(width: max(0, min(1, fraction)) * geometry.size.width)
+ .animation(reduceMotion ? nil : .linear(duration: 0.2), value: fraction)
+ } else if reduceMotion {
+ Capsule().fill(Color.accentColor.opacity(0.35))
+ } else {
+ Capsule()
+ .fill(Color.accentColor)
+ .frame(width: geometry.size.width * 0.3)
+ .offset(x: sliding ? geometry.size.width * 0.7 : 0)
+ .onAppear {
+ withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
+ sliding = true
+ }
+ }
+ }
+ }
+ }
+ .frame(height: 6)
+ .accessibilityHidden(true)
+ }
+}
diff --git a/Sources/TokenGaugeCapture/main.swift b/Sources/TokenGaugeCapture/main.swift
index 4949e4d..da86b14 100644
--- a/Sources/TokenGaugeCapture/main.swift
+++ b/Sources/TokenGaugeCapture/main.swift
@@ -4,24 +4,30 @@ import TokenGaugeCore
@main
enum TokenGaugeCapture {
static func main() throws {
- let recordEffort = CommandLine.arguments.contains("--record-effort-history")
- if recordEffort || CommandLine.arguments.contains("--effort-history") {
+ let arguments = Set(CommandLine.arguments.dropFirst())
+ if arguments.contains("--collect") {
+ let scan = try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL())
+ let recorded = (try? UsageHistoryStore.recordEffort(scan.effortRecords)) != nil
+ try emit(CaptureCollection(buckets: scan.buckets, effortRecords: recorded ? scan.effortRecords.count : nil))
+ return
+ }
+ let recordEffort = arguments.contains("--record-effort-history")
+ if recordEffort || arguments.contains("--effort-history") {
do {
- let records = try EffortUsageScanner.scan()
+ let records = try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL()).effortRecords
if recordEffort {
try UsageHistoryStore.recordEffort(records)
- FileHandle.standardOutput.write(try JSONEncoder().encode(["records": records.count]))
+ try emit(["records": records.count])
} else {
- FileHandle.standardOutput.write(try JSONEncoder().encode(records))
+ try emit(records)
}
} catch {
exit(1)
}
return
}
- if CommandLine.arguments.contains("--history") {
- let usage = try ClaudeHistoryScanner.scan()
- FileHandle.standardOutput.write(try JSONEncoder().encode(usage))
+ if arguments.contains("--history") {
+ try emit(try TranscriptScanner.scan(stateURL: TranscriptScanner.stateURL()).buckets)
return
}
let data = FileHandle.standardInput.readDataToEndOfFile()
@@ -31,4 +37,8 @@ enum TokenGaugeCapture {
else { return }
try SecureMetricStore.write(snapshot, to: UsagePaths.claudeCapture())
}
+
+ private static func emit(_ value: T) throws {
+ FileHandle.standardOutput.write(try JSONEncoder().encode(value))
+ }
}
diff --git a/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift
index 8372416..ff4075e 100644
--- a/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift
+++ b/Sources/TokenGaugeCore/ClaudeAccountIdentity.swift
@@ -7,6 +7,7 @@ public struct ClaudeAccountIdentity: Equatable, Sendable {
public let emailAddress: String?
public let displayName: String?
public let organizationName: String?
+ public let fingerprint: String
public init(
accountUuid: String,
@@ -18,14 +19,7 @@ public struct ClaudeAccountIdentity: Equatable, Sendable {
self.emailAddress = emailAddress
self.displayName = displayName
self.organizationName = organizationName
- }
-
- public var fingerprint: String {
- let hex = Array("0123456789abcdef".utf8)
- return String(
- decoding: SHA256.hash(data: Data(accountUuid.utf8)).flatMap {
- [hex[Int($0 >> 4)], hex[Int($0 & 15)]]
- }, as: UTF8.self)
+ fingerprint = Hex.string(SHA256.hash(data: Data(accountUuid.utf8)))
}
public static func isCompatible(stored: String?, current: String?) -> Bool {
@@ -83,18 +77,21 @@ public enum ClaudeAccountIdentityReader {
}
public static func read(at url: URL) -> ClaudeAccountIdentity? {
- cache.withLock { entry in
- let previous = entry?.url == url ? entry?.identity : nil
- guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else {
- return previous
- }
- let modifiedAt = attributes[.modificationDate] as? Date
- let size = attributes[.size] as? Int ?? 0
- if let entry, entry.url == url, entry.modifiedAt == modifiedAt, entry.size == size {
+ let previous = cache.withLock { entry in entry?.url == url ? entry : nil }
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else {
+ return previous?.identity
+ }
+ let modifiedAt = attributes[.modificationDate] as? Date
+ let size = attributes[.size] as? Int ?? 0
+ if let previous, previous.modifiedAt == modifiedAt, previous.size == size {
+ return previous.identity
+ }
+ guard let data = try? Data(contentsOf: url) else { return previous?.identity }
+ let identity = ClaudeAccountIdentity(data: data)
+ return cache.withLock { entry in
+ if let entry, entry.url == url, let stored = entry.modifiedAt, let read = modifiedAt, stored > read {
return entry.identity
}
- guard let data = try? Data(contentsOf: url) else { return previous }
- let identity = ClaudeAccountIdentity(data: data)
entry = Entry(url: url, modifiedAt: modifiedAt, size: size, identity: identity)
return identity
}
diff --git a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift
index c66798e..a8c3c69 100644
--- a/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift
+++ b/Sources/TokenGaugeCore/ClaudeAccountUsageParser.swift
@@ -16,19 +16,29 @@ public struct ClaudeAccountSnapshot: Codable, Equatable, Sendable {
}
}
+public enum ClaudeAccountLimits: Equatable, Sendable {
+ case empty
+ case windows([QuotaWindow])
+}
+
public enum ClaudeAccountUsageParser {
public static func parse(_ data: Data) throws -> [QuotaWindow] {
- guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ switch try parseLimits(data) {
+ case .empty: throw UsageDataError.invalidPayload
+ case .windows(let windows): return windows
+ }
+ }
+
+ public static func parseLimits(_ data: Data) throws -> ClaudeAccountLimits {
+ guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let entries = JSONValue.array(root["limits"])
+ else {
throw UsageDataError.invalidPayload
}
- let windows = JSONValue.array(root["limits"]).map(limits(from:)) ?? []
+ guard !entries.isEmpty else { return .empty }
+ let windows = limits(from: entries)
guard !windows.isEmpty else { throw UsageDataError.invalidPayload }
- return windows.sorted { left, right in
- let leftDuration = left.durationMinutes ?? Int.max
- let rightDuration = right.durationMinutes ?? Int.max
- if leftDuration != rightDuration { return leftDuration < rightDuration }
- return left.id < right.id
- }
+ return .windows(windows.sorted(by: QuotaWindow.displayOrder))
}
private static func limits(from entries: [Any]) -> [QuotaWindow] {
@@ -76,9 +86,10 @@ public enum ClaudeAccountUsageParser {
}
}
+ private static let fractionalInstant = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
+ private static let instant = Date.ISO8601FormatStyle()
+
private static func date(from text: String) -> Date? {
- let fractional = ISO8601DateFormatter()
- fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return fractional.date(from: text) ?? ISO8601DateFormatter().date(from: text)
+ (try? fractionalInstant.parse(text)) ?? (try? instant.parse(text))
}
}
diff --git a/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift b/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
index cea6ecb..98c1660 100644
--- a/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
+++ b/Sources/TokenGaugeCore/ClaudeHistoryScanner.swift
@@ -7,70 +7,44 @@ public enum ClaudeHistoryScanner {
now: Date = Date(),
calendar: Calendar = .current
) throws -> [ModelTokenBucket] {
- guard days > 0 else { return [] }
- let start = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -(days - 1), to: now) ?? now)
- let keys: [URLResourceKey] = [.isRegularFileKey, .contentModificationDateKey]
- guard
- let enumerator = FileManager.default.enumerator(
- at: projectsRoot,
- includingPropertiesForKeys: keys,
- options: [.skipsHiddenFiles]
- )
- else { return [] }
-
- let fractionalFormatter = ISO8601DateFormatter()
- fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let standardFormatter = ISO8601DateFormatter()
- let decoder = JSONDecoder()
- var messages: [String: (day: String, hour: Date, model: String, tokens: Int)] = [:]
-
- for case let fileURL as URL in enumerator {
- guard fileURL.pathExtension == "jsonl" else { continue }
- let values = try fileURL.resourceValues(forKeys: Set(keys))
- guard values.isRegularFile == true, (values.contentModificationDate ?? .distantPast) >= start else {
- continue
- }
+ try TranscriptScanner.scan(
+ claudeProjects: projectsRoot, codexSessions: nil, stateURL: nil, now: now, calendar: calendar,
+ historyDays: days, effortDays: 0
+ ).buckets
+ }
- try JSONLReader.read(fileURL) { line in
- autoreleasepool {
- guard
- let record = try? decoder.decode(ClaudeTranscriptRecord.self, from: line),
- record.type == "assistant",
- let message = record.message,
- let usage = message.usage,
- let timestamp = record.timestamp,
- let date = fractionalFormatter.date(from: timestamp) ?? standardFormatter.date(from: timestamp),
- date >= start,
- date <= now.addingTimeInterval(300),
- let messageID = message.id ?? record.uuid
- else { return }
+ static func entry(from metadata: EffortTranscriptMetadata, at date: Date) -> ScanEntry? {
+ guard metadata.type == "assistant", let message = metadata.message, let usage = message.usage,
+ let key = message.id ?? metadata.uuid, let tokens = usage.claudeTotal, tokens > 0
+ else { return nil }
+ let time = date.timeIntervalSince1970
+ return ScanEntry(
+ id: EffortUsageScanner.hash(provider: .claude, identifier: key), firstAt: time, peakAt: time,
+ tokens: tokens, model: normalizedModel(message.model),
+ effortModel: EffortTranscriptMetadata.normalizedModel(message.model),
+ effort: EffortTranscriptMetadata.normalizedEffort(metadata.perTurnEffort ?? metadata.effort),
+ eligible: message.id?.isEmpty == false)
+ }
- let tokens =
- max(usage.inputTokens ?? 0, 0)
- + max(usage.outputTokens ?? 0, 0)
- + max(usage.cacheCreationInputTokens ?? 0, 0)
- + max(usage.cacheReadInputTokens ?? 0, 0)
- guard tokens > 0 else { return }
- let model = normalizedModel(message.model)
- let day = dayString(date, calendar: calendar)
- let hour = hourStart(date)
- if let current = messages[messageID], current.tokens >= tokens {
- return
- }
- messages[messageID] = (day, hour, model, tokens)
- }
- }
+ static func buckets(
+ _ entries: some Sequence, start: Date, now: Date, calendar: Calendar
+ ) -> [ModelTokenBucket] {
+ let earliest = start.timeIntervalSince1970
+ let latest = now.timeIntervalSince1970 + 300
+ var totals: [String: ModelTokenBucket] = [:]
+ let ordered = entries.filter { $0.peakAt >= earliest && $0.peakAt <= latest }.sorted {
+ $0.peakAt == $1.peakAt ? $0.id < $1.id : $0.peakAt < $1.peakAt
}
-
- var totals: [ModelTokenBucket.ID: ModelTokenBucket] = [:]
- for message in messages.values {
- let key = "\(message.model)-\(message.hour.timeIntervalSince1970)"
- let merged = (totals[key]?.tokens ?? 0) + message.tokens
+ for entry in ordered {
+ let hour = hourStart(entry.peakAt)
+ let key = "\(entry.model)-\(hour.timeIntervalSince1970)"
+ let current = totals[key]
totals[key] = ModelTokenBucket(
- day: message.day,
- hourStart: message.hour,
- model: message.model,
- tokens: merged
+ day: current?.day
+ ?? TranscriptScanner.dayString(Date(timeIntervalSince1970: entry.peakAt), calendar: calendar),
+ hourStart: hour,
+ model: entry.model,
+ tokens: (current?.tokens ?? 0) + entry.tokens
)
}
return totals.values.sorted { left, right in
@@ -84,87 +58,7 @@ public enum ClaudeHistoryScanner {
return raw
}
- private static func hourStart(_ date: Date) -> Date {
- Date(timeIntervalSince1970: (date.timeIntervalSince1970 / 3600).rounded(.down) * 3600)
- }
-
- private static func dayString(_ date: Date, calendar: Calendar) -> String {
- let components = calendar.dateComponents([.year, .month, .day], from: date)
- return String(
- format: "%04d-%02d-%02d",
- components.year ?? 0,
- components.month ?? 0,
- components.day ?? 0
- )
- }
-}
-
-private struct ClaudeTranscriptRecord: Decodable {
- let type: String?
- let timestamp: String?
- let uuid: String?
- let message: ClaudeTranscriptMessage?
-}
-
-private struct ClaudeTranscriptMessage: Decodable {
- let id: String?
- let model: String?
- let usage: ClaudeTranscriptUsage?
-}
-
-private struct ClaudeTranscriptUsage: Decodable {
- let inputTokens: Int?
- let outputTokens: Int?
- let cacheCreationInputTokens: Int?
- let cacheReadInputTokens: Int?
-
- enum CodingKeys: String, CodingKey {
- case inputTokens = "input_tokens"
- case outputTokens = "output_tokens"
- case cacheCreationInputTokens = "cache_creation_input_tokens"
- case cacheReadInputTokens = "cache_read_input_tokens"
- }
-}
-
-private enum JSONLReader {
- private static let maximumLineBytes = 1_048_576
-
- static func read(_ url: URL, lineHandler: (Data) -> Void) throws {
- let handle = try FileHandle(forReadingFrom: url)
- defer { try? handle.close() }
- var buffer = Data()
- var discardingLongLine = false
-
- while true {
- let chunk = try handle.read(upToCount: 65_536) ?? Data()
- if chunk.isEmpty {
- if !discardingLongLine, !buffer.isEmpty { lineHandler(buffer) }
- return
- }
-
- var segmentStart = chunk.startIndex
- while segmentStart < chunk.endIndex,
- let newline = chunk[segmentStart...].firstIndex(of: 0x0A)
- {
- let segment = chunk[segmentStart.. maximumLineBytes {
- buffer.removeAll(keepingCapacity: false)
- discardingLongLine = true
- } else {
- buffer.append(contentsOf: tail)
- }
- }
+ private static func hourStart(_ time: TimeInterval) -> Date {
+ Date(timeIntervalSince1970: (time / 3600).rounded(.down) * 3600)
}
}
diff --git a/Sources/TokenGaugeCore/ClaudeUsageParser.swift b/Sources/TokenGaugeCore/ClaudeUsageParser.swift
index 5d4d43b..4ba2f7a 100644
--- a/Sources/TokenGaugeCore/ClaudeUsageParser.swift
+++ b/Sources/TokenGaugeCore/ClaudeUsageParser.swift
@@ -63,12 +63,7 @@ public enum ClaudeUsageParser {
durationMinutes: duration(for: key),
displayName: nil
)
- }.sorted { left, right in
- let leftDuration = left.durationMinutes ?? Int.max
- let rightDuration = right.durationMinutes ?? Int.max
- if leftDuration != rightDuration { return leftDuration < rightDuration }
- return left.id < right.id
- }
+ }.sorted(by: QuotaWindow.displayOrder)
return ProviderUsageSnapshot(
provider: .claude,
windows: windows,
diff --git a/Sources/TokenGaugeCore/CodexUsageParser.swift b/Sources/TokenGaugeCore/CodexUsageParser.swift
index 4c6bba6..b596319 100644
--- a/Sources/TokenGaugeCore/CodexUsageParser.swift
+++ b/Sources/TokenGaugeCore/CodexUsageParser.swift
@@ -61,12 +61,7 @@ public enum CodexUsageParser {
unique[window.id] = window
}
}
- return unique.values.sorted { left, right in
- let leftDuration = left.durationMinutes ?? Int.max
- let rightDuration = right.durationMinutes ?? Int.max
- if leftDuration != rightDuration { return leftDuration < rightDuration }
- return left.id < right.id
- }
+ return unique.values.sorted(by: QuotaWindow.displayOrder)
}
private static func windows(from bucket: [String: Any], bucketID: String) -> [QuotaWindow] {
diff --git a/Sources/TokenGaugeCore/EffortJSONLReader.swift b/Sources/TokenGaugeCore/EffortJSONLReader.swift
deleted file mode 100644
index 83aa364..0000000
--- a/Sources/TokenGaugeCore/EffortJSONLReader.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-import Foundation
-
-enum EffortJSONLReader {
- static let maximumLineBytes = 1_048_576
-
- static func read(_ url: URL, lineHandler: (Data) -> Void) throws {
- let handle = try FileHandle(forReadingFrom: url)
- defer { try? handle.close() }
- var buffer = Data()
- var discarding = false
- while true {
- let chunk = try handle.read(upToCount: 65_536) ?? Data()
- if chunk.isEmpty {
- if !discarding, !buffer.isEmpty { lineHandler(buffer) }
- return
- }
- var start = chunk.startIndex
- while start < chunk.endIndex, let newline = chunk[start...].firstIndex(of: 0x0A) {
- let segment = chunk[start.. maximumLineBytes {
- buffer.removeAll(keepingCapacity: false)
- discarding = true
- } else {
- buffer.append(contentsOf: tail)
- }
- }
- }
-}
diff --git a/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift b/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
index 44f935c..4ae3e75 100644
--- a/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
+++ b/Sources/TokenGaugeCore/EffortTranscriptMetadata.swift
@@ -3,6 +3,7 @@ import Foundation
struct EffortTranscriptMetadata: Decodable {
let type: String?
let timestamp: String?
+ let uuid: String?
let perTurnEffort: String?
let effort: String?
let payload: Payload?
@@ -40,18 +41,19 @@ struct EffortTranscriptMetadata: Decodable {
}
}
+ private static let efforts: Set = [
+ "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "auto",
+ ]
+ private static let modelCharacters = CharacterSet(
+ charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
+
static func normalizedEffort(_ raw: String?) -> String {
let value = raw?.lowercased() ?? "unknown"
- return ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "auto"].contains(value)
- ? value : "unknown"
+ return efforts.contains(value) ? value : "unknown"
}
static func normalizedModel(_ raw: String?) -> String {
- guard let raw, !raw.isEmpty, raw.count <= 100,
- raw.unicodeScalars.allSatisfy({
- CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
- .contains($0)
- })
+ guard let raw, !raw.isEmpty, raw.count <= 100, raw.unicodeScalars.allSatisfy(modelCharacters.contains)
else { return "unknown" }
return raw
}
diff --git a/Sources/TokenGaugeCore/EffortUsageRecord.swift b/Sources/TokenGaugeCore/EffortUsageRecord.swift
index fee2371..a685f98 100644
--- a/Sources/TokenGaugeCore/EffortUsageRecord.swift
+++ b/Sources/TokenGaugeCore/EffortUsageRecord.swift
@@ -21,26 +21,42 @@ public struct EffortUsageRecord: Codable, Equatable, Sendable {
self.tokens = tokens
}
+ private static let hexCharacters = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
+ private static let identifierCharacters = CharacterSet(
+ charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
+ private static let efforts: Set = [
+ "unknown", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "none", "auto",
+ ]
+ private static let utcCalendar: Calendar = {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0)!
+ return calendar
+ }()
+
var isValid: Bool {
- let hex = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
- let identifier = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-")
- let efforts: Set = [
- "unknown", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "none", "auto",
- ]
- guard id.utf8.count == 64, id.unicodeScalars.allSatisfy(hex.contains),
- !model.isEmpty, model.utf8.count <= 128, model.unicodeScalars.allSatisfy(identifier.contains),
- efforts.contains(effort), tokens >= 0,
+ guard id.utf8.count == 64, id.unicodeScalars.allSatisfy(Self.hexCharacters.contains),
+ !model.isEmpty, model.utf8.count <= 128,
+ model.unicodeScalars.allSatisfy(Self.identifierCharacters.contains),
+ Self.efforts.contains(effort), tokens >= 0,
recordedAt.timeIntervalSince1970.isFinite,
(0...253_402_300_799).contains(recordedAt.timeIntervalSince1970),
- day.utf8.count == 10, day.range(of: #"^\d{4}-\d{2}-\d{2}$"#, options: .regularExpression) != nil
+ Self.isDayFormat(day)
else { return false }
let parts = day.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3, (1970...9999).contains(parts[0]) else { return false }
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let components = DateComponents(year: parts[0], month: parts[1], day: parts[2])
- guard let date = calendar.date(from: components) else { return false }
- return calendar.dateComponents([.year, .month, .day], from: date) == components
+ guard let date = Self.utcCalendar.date(from: components) else { return false }
+ return Self.utcCalendar.dateComponents([.year, .month, .day], from: date) == components
+ }
+
+ private static func isDayFormat(_ day: String) -> Bool {
+ let bytes = Array(day.utf8)
+ guard bytes.count == 10 else { return false }
+ for (index, byte) in bytes.enumerated() {
+ let expected: ClosedRange = index == 4 || index == 7 ? 0x2D...0x2D : 0x30...0x39
+ guard expected.contains(byte) else { return false }
+ }
+ return true
}
}
diff --git a/Sources/TokenGaugeCore/EffortUsageScanner.swift b/Sources/TokenGaugeCore/EffortUsageScanner.swift
index a1792db..dcb6194 100644
--- a/Sources/TokenGaugeCore/EffortUsageScanner.swift
+++ b/Sources/TokenGaugeCore/EffortUsageScanner.swift
@@ -7,109 +7,47 @@ public enum EffortUsageScanner {
now: Date = Date(),
lookbackDays: Int = 7
) throws -> [EffortUsageRecord] {
- guard lookbackDays > 0 else { return [] }
- let calendar = Calendar.current
- let cutoff = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1 - lookbackDays, to: now) ?? now)
- let fractional = ISO8601DateFormatter()
- fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let standard = ISO8601DateFormatter()
- let decoder = JSONDecoder()
- var records: [String: EffortUsageRecord] = [:]
- let roots: [(UsageProvider, URL)] = [
- (.codex, homeDirectory.appending(path: ".codex/sessions")),
- (.claude, homeDirectory.appending(path: ".claude/projects")),
- ]
- for (provider, root) in roots {
- guard safePath(root, home: homeDirectory) else { continue }
- let recentDirectories = Set(
- provider == .codex ? codexDirectories(root: root, cutoff: cutoff, now: now).map(\.path) : [])
- let keys: Set = [.isRegularFileKey, .isSymbolicLinkKey, .contentModificationDateKey]
- guard
- let files = FileManager.default.enumerator(
- at: root, includingPropertiesForKeys: Array(keys), options: [.skipsHiddenFiles]
- )
- else { continue }
- for case let file as URL in files {
- guard let info = try? file.resourceValues(forKeys: keys), info.isSymbolicLink != true else {
- files.skipDescendants()
- continue
- }
- guard info.isRegularFile == true, file.pathExtension == "jsonl",
- recentDirectories.contains(file.deletingLastPathComponent().path)
- || (info.contentModificationDate ?? .distantPast) >= cutoff
- else { continue }
- var context: EffortTranscriptMetadata.Payload?
- try EffortJSONLReader.read(file) { line in
- autoreleasepool {
- guard let metadata = try? decoder.decode(EffortTranscriptMetadata.self, from: line) else {
- return
- }
- if provider == .codex, metadata.type == "turn_context" {
- context = metadata.payload
- return
- }
- guard metadata.type == (provider == .codex ? "token_usage_record" : "assistant"),
- let timestamp = metadata.timestamp,
- let date = fractional.date(from: timestamp) ?? standard.date(from: timestamp),
- date >= cutoff, date <= now,
- let record = makeRecord(
- metadata, provider: provider, context: context, date: date, calendar: calendar)
- else { return }
- records[record.id] = merge(records[record.id], record)
- }
- }
- }
- }
- return records.values.sorted { $0.recordedAt == $1.recordedAt ? $0.id < $1.id : $0.recordedAt < $1.recordedAt }
+ try TranscriptScanner.scan(
+ homeDirectory: homeDirectory, stateURL: nil, now: now, calendar: .current, historyDays: 0,
+ effortDays: lookbackDays
+ ).effortRecords
}
- private static func makeRecord(
- _ metadata: EffortTranscriptMetadata, provider: UsageProvider,
- context: EffortTranscriptMetadata.Payload?, date: Date, calendar: Calendar
- ) -> EffortUsageRecord? {
- let identifier: String?
- let model: String?
- let effort: String?
- let tokens: Int?
- if provider == .codex {
- guard metadata.type == "token_usage_record", let payload = metadata.payload else { return nil }
- identifier = payload.response_id
- let matching = context?.turn_id != nil && context?.turn_id == payload.turn_id ? context : nil
- model = matching?.model
- effort = matching?.effort
- tokens = payload.usage?.total_tokens
- } else {
- guard metadata.type == "assistant", let message = metadata.message else { return nil }
- identifier = message.id
- model = message.model
- effort = metadata.perTurnEffort ?? metadata.effort
- tokens = message.usage?.claudeTotal
- }
- guard let identifier, !identifier.isEmpty, let tokens, tokens > 0 else { return nil }
- let hex = Array("0123456789abcdef".utf8)
- let hash = String(
- decoding: SHA256.hash(data: Data("\(provider.rawValue):\(identifier)".utf8)).flatMap {
- [hex[Int($0 >> 4)], hex[Int($0 & 15)]]
- }, as: UTF8.self)
- let components = calendar.dateComponents([.year, .month, .day], from: date)
- let day = String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
- return EffortUsageRecord(
- id: hash, provider: provider, recordedAt: date, day: day,
- model: EffortTranscriptMetadata.normalizedModel(model),
- effort: EffortTranscriptMetadata.normalizedEffort(effort), tokens: tokens)
+ static func entry(from metadata: EffortTranscriptMetadata, at date: Date, context: CodexTurnContext?) -> ScanEntry?
+ {
+ guard metadata.type == "token_usage_record", let payload = metadata.payload,
+ let identifier = payload.response_id, !identifier.isEmpty,
+ let tokens = payload.usage?.total_tokens, tokens > 0
+ else { return nil }
+ let matching = context?.turnID != nil && context?.turnID == payload.turn_id ? context : nil
+ let model = matching?.model ?? "unknown"
+ let time = date.timeIntervalSince1970
+ return ScanEntry(
+ id: hash(provider: .codex, identifier: identifier), firstAt: time, peakAt: time, tokens: tokens,
+ model: model, effortModel: model, effort: matching?.effort ?? "unknown", eligible: true)
+ }
+
+ static func records(
+ _ entries: some Sequence<(provider: UsageProvider, entry: ScanEntry)>, cutoff: Date, now: Date,
+ calendar: Calendar
+ ) -> [EffortUsageRecord] {
+ let earliest = cutoff.timeIntervalSince1970
+ let latest = now.timeIntervalSince1970
+ return entries.compactMap { provider, entry -> EffortUsageRecord? in
+ guard entry.eligible, entry.firstAt >= earliest, entry.firstAt <= latest else { return nil }
+ let recordedAt = Date(timeIntervalSince1970: entry.firstAt)
+ return EffortUsageRecord(
+ id: entry.id, provider: provider, recordedAt: recordedAt,
+ day: TranscriptScanner.dayString(recordedAt, calendar: calendar),
+ model: entry.effortModel, effort: entry.effort, tokens: entry.tokens)
+ }.sorted { $0.recordedAt == $1.recordedAt ? $0.id < $1.id : $0.recordedAt < $1.recordedAt }
}
- private static func merge(_ existing: EffortUsageRecord?, _ incoming: EffortUsageRecord) -> EffortUsageRecord {
- guard let existing else { return incoming }
- let earliest = existing.recordedAt <= incoming.recordedAt ? existing : incoming
- return EffortUsageRecord(
- id: existing.id, provider: existing.provider, recordedAt: earliest.recordedAt, day: earliest.day,
- model: existing.model == "unknown" ? incoming.model : existing.model,
- effort: existing.effort == "unknown" ? incoming.effort : existing.effort,
- tokens: max(existing.tokens, incoming.tokens))
+ static func hash(provider: UsageProvider, identifier: String) -> String {
+ Hex.string(SHA256.hash(data: Data("\(provider.rawValue):\(identifier)".utf8)))
}
- private static func codexDirectories(root: URL, cutoff: Date, now: Date) -> [URL] {
+ static func codexDirectories(root: URL, cutoff: Date, now: Date) -> [URL] {
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(secondsFromGMT: 0)!
var date = utc.startOfDay(for: cutoff.addingTimeInterval(-86_400))
@@ -124,7 +62,7 @@ public enum EffortUsageScanner {
return directories
}
- private static func safePath(_ url: URL, home: URL) -> Bool {
+ static func safePath(_ url: URL, home: URL) -> Bool {
var current = url.standardizedFileURL
while current.path != "/" {
guard let values = try? current.resourceValues(forKeys: [.isSymbolicLinkKey]), values.isSymbolicLink != true
diff --git a/Sources/TokenGaugeCore/Hex.swift b/Sources/TokenGaugeCore/Hex.swift
new file mode 100644
index 0000000..ea18aaa
--- /dev/null
+++ b/Sources/TokenGaugeCore/Hex.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+enum Hex {
+ private static let digits = Array("0123456789abcdef".utf8)
+
+ static func string(_ bytes: some Sequence) -> String {
+ String(decoding: bytes.flatMap { [digits[Int($0 >> 4)], digits[Int($0 & 15)]] }, as: UTF8.self)
+ }
+}
diff --git a/Sources/TokenGaugeCore/HistoryAnalytics.swift b/Sources/TokenGaugeCore/HistoryAnalytics.swift
index 8afcf30..167f2fe 100644
--- a/Sources/TokenGaugeCore/HistoryAnalytics.swift
+++ b/Sources/TokenGaugeCore/HistoryAnalytics.swift
@@ -35,6 +35,36 @@ public enum HistoryAnalytics {
return abs(left.timeIntervalSince(right)) <= 1
}
+ public static func streaks(usageDays: Set, today: String, calendar: Calendar = .current)
+ -> (current: Int, longest: Int)
+ {
+ let ordinals = Set(usageDays.compactMap { ordinalDay($0, calendar: calendar) })
+ var longest = 0
+ for start in ordinals where !ordinals.contains(start - 1) {
+ var length = 1
+ while ordinals.contains(start + length) { length += 1 }
+ longest = max(longest, length)
+ }
+ var current = 0
+ if let reference = ordinalDay(today, calendar: calendar) {
+ var cursor = ordinals.contains(reference) ? reference : reference - 1
+ while ordinals.contains(cursor) {
+ current += 1
+ cursor -= 1
+ }
+ }
+ return (current, longest)
+ }
+
+ private static func ordinalDay(_ key: String, calendar: Calendar) -> Int? {
+ let parts = key.split(separator: "-", omittingEmptySubsequences: false)
+ guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]),
+ (1...12).contains(month), (1...31).contains(day),
+ let date = calendar.date(from: DateComponents(year: year, month: month, day: day))
+ else { return nil }
+ return calendar.ordinality(of: .day, in: .era, for: date)
+ }
+
public static func pace(
rows: [HistoryQuotaRow],
provider: UsageProvider,
diff --git a/Sources/TokenGaugeCore/JSONLReader.swift b/Sources/TokenGaugeCore/JSONLReader.swift
new file mode 100644
index 0000000..8cac085
--- /dev/null
+++ b/Sources/TokenGaugeCore/JSONLReader.swift
@@ -0,0 +1,69 @@
+import CryptoKit
+import Foundation
+
+enum JSONLReader {
+ static let maximumLineBytes = 1_048_576
+ static let anchorBytes = 4_096
+
+ struct Progress {
+ let bytesRead: Int
+ let committedOffset: Int
+ }
+
+ enum ReadError: Error {
+ case offsetNotAtLineBoundary
+ }
+
+ static func anchor(_ url: URL, endingAt offset: Int) throws -> String? {
+ guard offset > 0 else { return nil }
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+ let length = min(offset, anchorBytes)
+ try handle.seek(toOffset: UInt64(offset - length))
+ guard let data = try handle.read(upToCount: length), data.count == length else { return nil }
+ return Hex.string(SHA256.hash(data: data))
+ }
+
+ @discardableResult
+ static func read(_ url: URL, from offset: Int = 0, lineHandler: (Data) -> Void) throws -> Progress {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+ if offset > 0 {
+ try handle.seek(toOffset: UInt64(offset - 1))
+ guard try handle.read(upToCount: 1) == Data([0x0A]) else { throw ReadError.offsetNotAtLineBoundary }
+ }
+ var buffer = Data()
+ var discarding = false
+ var bytesRead = 0
+ var committed = offset
+ while true {
+ let chunk = try handle.read(upToCount: 65_536) ?? Data()
+ if chunk.isEmpty {
+ if !discarding, !buffer.isEmpty { lineHandler(buffer) }
+ return Progress(bytesRead: bytesRead, committedOffset: committed)
+ }
+ let chunkOffset = offset + bytesRead
+ bytesRead += chunk.count
+ var start = chunk.startIndex
+ while start < chunk.endIndex, let newline = chunk[start...].firstIndex(of: 0x0A) {
+ let segment = chunk[start.. maximumLineBytes {
+ buffer.removeAll(keepingCapacity: false)
+ discarding = true
+ } else {
+ buffer.append(contentsOf: tail)
+ }
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/SQLiteConnection.swift b/Sources/TokenGaugeCore/SQLiteConnection.swift
new file mode 100644
index 0000000..d63d7d2
--- /dev/null
+++ b/Sources/TokenGaugeCore/SQLiteConnection.swift
@@ -0,0 +1,172 @@
+import Darwin
+import Foundation
+import SQLite3
+
+struct SQLiteStatement {
+ let handle: OpaquePointer
+
+ private static var transientDestructor: sqlite3_destructor_type {
+ unsafeBitCast(Int(-1), to: sqlite3_destructor_type.self)
+ }
+
+ func bind(_ index: Int32, _ value: String?) {
+ guard let value else {
+ sqlite3_bind_null(handle, index)
+ return
+ }
+ sqlite3_bind_text(handle, index, value, -1, Self.transientDestructor)
+ }
+
+ func bind(_ index: Int32, _ value: Int64?) {
+ guard let value else {
+ sqlite3_bind_null(handle, index)
+ return
+ }
+ sqlite3_bind_int64(handle, index, value)
+ }
+
+ func bind(_ index: Int32, _ value: Double) {
+ sqlite3_bind_double(handle, index, value)
+ }
+
+ func bind(_ index: Int32, _ value: Bool) {
+ sqlite3_bind_int(handle, index, value ? 1 : 0)
+ }
+
+ func bind(_ index: Int32, _ value: Date?) {
+ guard let value else {
+ sqlite3_bind_null(handle, index)
+ return
+ }
+ sqlite3_bind_double(handle, index, value.timeIntervalSince1970)
+ }
+
+ func text(_ index: Int32) -> String {
+ optionalText(index) ?? ""
+ }
+
+ func optionalText(_ index: Int32) -> String? {
+ guard let raw = sqlite3_column_text(handle, index) else { return nil }
+ return String(cString: raw)
+ }
+
+ func int(_ index: Int32) -> Int {
+ Int(sqlite3_column_int64(handle, index))
+ }
+
+ func optionalInt(_ index: Int32) -> Int? {
+ guard sqlite3_column_type(handle, index) != SQLITE_NULL else { return nil }
+ return Int(sqlite3_column_int64(handle, index))
+ }
+
+ func double(_ index: Int32) -> Double {
+ sqlite3_column_double(handle, index)
+ }
+
+ func flag(_ index: Int32) -> Bool {
+ sqlite3_column_int(handle, index) == 1
+ }
+
+ func date(_ index: Int32) -> Date? {
+ guard sqlite3_column_type(handle, index) != SQLITE_NULL else { return nil }
+ return Date(timeIntervalSince1970: sqlite3_column_double(handle, index))
+ }
+
+ func instant(_ index: Int32) -> Date {
+ Date(timeIntervalSince1970: sqlite3_column_double(handle, index))
+ }
+}
+
+final class SQLiteConnection {
+ private let handle: OpaquePointer
+
+ private init(handle: OpaquePointer) {
+ self.handle = handle
+ }
+
+ var isReadOnly: Bool {
+ sqlite3_db_readonly(handle, "main") == 1
+ }
+
+ static func open(_ url: URL, readOnly: Bool) throws -> SQLiteConnection {
+ if !readOnly {
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ }
+ var handle: OpaquePointer?
+ let flags =
+ readOnly
+ ? SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX
+ : SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
+ guard sqlite3_open_v2(url.path, &handle, flags, nil) == SQLITE_OK, let handle else {
+ let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? url.path
+ if let handle { sqlite3_close(handle) }
+ throw UsageHistoryError.openFailed(message)
+ }
+ sqlite3_busy_timeout(handle, 3_000)
+ if !readOnly { chmod(url.path, S_IRUSR | S_IWUSR) }
+ return SQLiteConnection(handle: handle)
+ }
+
+ func close() {
+ sqlite3_close_v2(handle)
+ }
+
+ func exec(_ sql: String) throws {
+ var error: UnsafeMutablePointer?
+ guard sqlite3_exec(handle, sql, nil, nil, &error) == SQLITE_OK else {
+ let message = error.map { String(cString: $0) } ?? sql
+ sqlite3_free(error)
+ throw UsageHistoryError.statementFailed(message)
+ }
+ }
+
+ func transaction(_ body: () throws -> T) throws -> T {
+ try exec("BEGIN IMMEDIATE;")
+ do {
+ let result = try body()
+ try exec("COMMIT;")
+ return result
+ } catch {
+ try? exec("ROLLBACK;")
+ throw error
+ }
+ }
+
+ func run(_ sql: String, bindings: (SQLiteStatement) -> Void) throws {
+ let statement = try prepare(sql)
+ defer { sqlite3_finalize(statement.handle) }
+ bindings(statement)
+ guard sqlite3_step(statement.handle) == SQLITE_DONE else {
+ throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
+ }
+ }
+
+ func query(
+ _ sql: String,
+ bindings: (SQLiteStatement) -> Void,
+ each: (SQLiteStatement) -> Void
+ ) throws {
+ let statement = try prepare(sql)
+ defer { sqlite3_finalize(statement.handle) }
+ bindings(statement)
+ while true {
+ switch sqlite3_step(statement.handle) {
+ case SQLITE_ROW: each(statement)
+ case SQLITE_DONE: return
+ default: throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
+ }
+ }
+ }
+
+ private func prepare(_ sql: String) throws -> SQLiteStatement {
+ var statement: OpaquePointer?
+ guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
+ throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
+ }
+ return SQLiteStatement(handle: statement)
+ }
+}
diff --git a/Sources/TokenGaugeCore/ScanState.swift b/Sources/TokenGaugeCore/ScanState.swift
new file mode 100644
index 0000000..3c59afd
--- /dev/null
+++ b/Sources/TokenGaugeCore/ScanState.swift
@@ -0,0 +1,338 @@
+import Foundation
+
+public struct CaptureCollection: Codable, Sendable {
+ public let buckets: [ModelTokenBucket]
+ public let effortRecords: Int?
+
+ public init(buckets: [ModelTokenBucket], effortRecords: Int?) {
+ self.buckets = buckets
+ self.effortRecords = effortRecords
+ }
+}
+
+public enum TranscriptScanner {
+ public struct Result: Sendable {
+ public let buckets: [ModelTokenBucket]
+ public let effortRecords: [EffortUsageRecord]
+ public let bytesRead: Int
+ }
+
+ public static func stateURL(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL {
+ UsagePaths.supportDirectory(homeDirectory: homeDirectory).appending(path: "scan-state.json")
+ }
+
+ public static func scan(
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser,
+ stateURL: URL? = nil,
+ now: Date = Date(),
+ calendar: Calendar = .current,
+ historyDays: Int = 8,
+ effortDays: Int = 7
+ ) throws -> Result {
+ let claude = homeDirectory.appending(path: ".claude/projects")
+ let codex = homeDirectory.appending(path: ".codex/sessions")
+ return try scan(
+ claudeProjects: EffortUsageScanner.safePath(claude, home: homeDirectory) ? claude : nil,
+ codexSessions: EffortUsageScanner.safePath(codex, home: homeDirectory) ? codex : nil,
+ stateURL: stateURL, now: now, calendar: calendar, historyDays: historyDays, effortDays: effortDays)
+ }
+
+ public static func scan(
+ claudeProjects: URL?,
+ codexSessions: URL?,
+ stateURL: URL?,
+ now: Date = Date(),
+ calendar: Calendar = .current,
+ historyDays: Int = 8,
+ effortDays: Int = 7
+ ) throws -> Result {
+ let historyStart = windowStart(days: historyDays, now: now, calendar: calendar)
+ let effortStart = windowStart(days: effortDays, now: now, calendar: calendar)
+ guard let readStart = [historyStart, effortStart].compactMap({ $0?.timeIntervalSince1970 }).min() else {
+ return Result(buckets: [], effortRecords: [], bytesRead: 0)
+ }
+ let loaded = stateURL.flatMap { ScanState.load(from: $0, windowStart: readStart) }
+ let previous = loaded?.state
+ var state = ScanState(version: ScanState.currentVersion, windowStart: readStart, files: [:])
+ var recentCodexDirectories: Set = []
+ var roots: [(provider: UsageProvider, url: URL, wanted: (URL, TimeInterval) -> Bool)] = []
+ if let claudeProjects {
+ roots.append((.claude, claudeProjects, { _, modified in modified >= readStart }))
+ }
+ if let codexSessions, let effortStart {
+ let cutoff = effortStart.timeIntervalSince1970
+ recentCodexDirectories = Set(
+ EffortUsageScanner.codexDirectories(root: codexSessions, cutoff: effortStart, now: now).map(
+ dayDirectory))
+ roots.append(
+ (
+ .codex, codexSessions,
+ { file, modified in
+ modified >= cutoff
+ || recentCodexDirectories.contains(dayDirectory(file.deletingLastPathComponent()))
+ }
+ ))
+ }
+
+ let parser = TranscriptLineParser()
+ var bytesRead = 0
+ var written = loaded?.encoded
+ for root in roots {
+ for (file, attributes) in candidateFiles(root: root.url, wanted: root.wanted) {
+ let key = file.path
+ let scanned = try scanFile(
+ file, attributes: attributes, provider: root.provider, previous: previous?.files[key],
+ parser: parser, prune: readStart)
+ bytesRead += scanned.bytesRead
+ state.files[key] = scanned.file
+ }
+ if let stateURL { persist(state, to: stateURL, encoded: &written) }
+ }
+
+ let historyCutoff = historyStart?.timeIntervalSince1970
+ let effortCutoff = effortStart?.timeIntervalSince1970
+ var historyEntries: [String: ScanEntry] = [:]
+ var effortEntries: [String: (provider: UsageProvider, entry: ScanEntry)] = [:]
+ for key in state.files.keys.sorted() {
+ guard let file = state.files[key] else { continue }
+ let recent = recentCodexDirectories.contains(dayDirectory(URL(filePath: key).deletingLastPathComponent()))
+ let includeHistory = historyCutoff.map { file.provider == .claude && file.modified >= $0 } ?? false
+ let includeEffort = effortCutoff.map { file.modified >= $0 || (file.provider == .codex && recent) } ?? false
+ guard includeHistory || includeEffort else { continue }
+ for entry in file.entries {
+ if includeHistory {
+ historyEntries[entry.id] = historyEntries[entry.id]?.merging(entry) ?? entry
+ }
+ if includeEffort {
+ let merged = effortEntries[entry.id]?.entry.merging(entry) ?? entry
+ effortEntries[entry.id] = (file.provider, merged)
+ }
+ }
+ }
+
+ if let stateURL { persist(state, to: stateURL, encoded: &written) }
+ return Result(
+ buckets: historyStart.map {
+ ClaudeHistoryScanner.buckets(historyEntries.values, start: $0, now: now, calendar: calendar)
+ } ?? [],
+ effortRecords: effortStart.map {
+ EffortUsageScanner.records(effortEntries.values, cutoff: $0, now: now, calendar: calendar)
+ } ?? [],
+ bytesRead: bytesRead)
+ }
+
+ private static func persist(_ state: ScanState, to url: URL, encoded: inout Data?) {
+ guard let data = try? JSONEncoder.tokenGauge.encode(state), data != encoded else { return }
+ try? SecureMetricStore.write(state, to: url)
+ encoded = data
+ }
+
+ private static func dayDirectory(_ url: URL) -> String {
+ url.standardizedFileURL.path
+ }
+
+ static func dayString(_ date: Date, calendar: Calendar) -> String {
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
+ }
+
+ private static func windowStart(days: Int, now: Date, calendar: Calendar) -> Date? {
+ guard days > 0 else { return nil }
+ return calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1 - days, to: now) ?? now)
+ }
+
+ private static let resourceKeys: Set = [
+ .isRegularFileKey, .isSymbolicLinkKey, .contentModificationDateKey, .creationDateKey, .fileSizeKey,
+ ]
+
+ private struct FileAttributes {
+ let size: Int
+ let modified: TimeInterval
+ let created: TimeInterval
+ }
+
+ private static func candidateFiles(root: URL, wanted: (URL, TimeInterval) -> Bool) -> [(URL, FileAttributes)] {
+ guard
+ let enumerator = FileManager.default.enumerator(
+ at: root, includingPropertiesForKeys: Array(resourceKeys), options: [.skipsHiddenFiles])
+ else { return [] }
+ var files: [(URL, FileAttributes)] = []
+ for case let file as URL in enumerator {
+ guard let values = try? file.resourceValues(forKeys: resourceKeys), values.isSymbolicLink != true else {
+ enumerator.skipDescendants()
+ continue
+ }
+ let modified = (values.contentModificationDate ?? .distantPast).timeIntervalSince1970
+ guard values.isRegularFile == true, file.pathExtension == "jsonl", wanted(file, modified) else { continue }
+ let created = (values.creationDate ?? .distantPast).timeIntervalSince1970
+ files.append((file, FileAttributes(size: values.fileSize ?? 0, modified: modified, created: created)))
+ }
+ return files
+ }
+
+ private static func scanFile(
+ _ url: URL, attributes: FileAttributes, provider: UsageProvider, previous: ScanFile?,
+ parser: TranscriptLineParser, prune: TimeInterval
+ ) throws -> (file: ScanFile, bytesRead: Int) {
+ let reusable = previous.flatMap { $0.provider == provider && $0.created == attributes.created ? $0 : nil }
+ if let reusable, reusable.size == attributes.size, reusable.modified == attributes.modified {
+ return (reusable, 0)
+ }
+ var entries: [String: ScanEntry] = [:]
+ var context: CodexTurnContext?
+ var offset = 0
+ if let reusable, attributes.size > reusable.size, attributes.modified >= reusable.modified,
+ ((try? JSONLReader.anchor(url, endingAt: reusable.offset)) ?? nil) == reusable.anchor
+ {
+ entries = Dictionary(reusable.entries.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
+ context = reusable.context
+ offset = reusable.offset
+ }
+ let handler: (Data) -> Void = { line in
+ autoreleasepool {
+ guard let metadata = parser.metadata(line) else { return }
+ let entry: ScanEntry?
+ switch provider {
+ case .claude:
+ guard let date = parser.date(metadata.timestamp) else { return }
+ entry = ClaudeHistoryScanner.entry(from: metadata, at: date)
+ case .codex:
+ if metadata.type == "turn_context" {
+ context = metadata.payload.map(CodexTurnContext.init)
+ return
+ }
+ guard let date = parser.date(metadata.timestamp) else { return }
+ entry = EffortUsageScanner.entry(from: metadata, at: date, context: context)
+ }
+ guard let entry else { return }
+ entries[entry.id] = entries[entry.id]?.merging(entry) ?? entry
+ }
+ }
+ let progress: JSONLReader.Progress
+ do {
+ progress = try JSONLReader.read(url, from: offset, lineHandler: handler)
+ } catch JSONLReader.ReadError.offsetNotAtLineBoundary {
+ entries = [:]
+ context = nil
+ progress = try JSONLReader.read(url, lineHandler: handler)
+ }
+ let kept = entries.values.filter { $0.peakAt >= prune }.sorted {
+ $0.firstAt == $1.firstAt ? $0.id < $1.id : $0.firstAt < $1.firstAt
+ }
+ let file = ScanFile(
+ provider: provider, size: attributes.size, modified: attributes.modified, created: attributes.created,
+ offset: progress.committedOffset, anchor: try JSONLReader.anchor(url, endingAt: progress.committedOffset),
+ context: context, entries: kept)
+ return (file, progress.bytesRead)
+ }
+}
+
+struct TranscriptLineParser {
+ private let decoder = JSONDecoder()
+ private let fractional: ISO8601DateFormatter
+ private let standard = ISO8601DateFormatter()
+
+ init() {
+ fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ }
+
+ func metadata(_ line: Data) -> EffortTranscriptMetadata? {
+ try? decoder.decode(EffortTranscriptMetadata.self, from: line)
+ }
+
+ func date(_ timestamp: String?) -> Date? {
+ guard let timestamp else { return nil }
+ return fractional.date(from: timestamp) ?? standard.date(from: timestamp)
+ }
+}
+
+struct ScanState: Codable {
+ static let currentVersion = 2
+
+ var version: Int
+ var windowStart: TimeInterval
+ var files: [String: ScanFile]
+
+ static func load(from url: URL, windowStart: TimeInterval) -> (state: ScanState, encoded: Data)? {
+ guard let encoded = try? Data(contentsOf: url),
+ let state = try? JSONDecoder.tokenGauge.decode(ScanState.self, from: encoded),
+ state.version == currentVersion, state.windowStart <= windowStart
+ else { return nil }
+ return (state, encoded)
+ }
+}
+
+struct ScanFile: Codable {
+ var provider: UsageProvider
+ var size: Int
+ var modified: TimeInterval
+ var created: TimeInterval
+ var offset: Int
+ var anchor: String?
+ var context: CodexTurnContext?
+ var entries: [ScanEntry]
+}
+
+struct CodexTurnContext: Codable, Equatable {
+ var turnID: String?
+ var model: String
+ var effort: String
+
+ init(payload: EffortTranscriptMetadata.Payload) {
+ turnID = payload.turn_id
+ model = EffortTranscriptMetadata.normalizedModel(payload.model)
+ effort = EffortTranscriptMetadata.normalizedEffort(payload.effort)
+ }
+}
+
+struct ScanEntry: Equatable {
+ var id: String
+ var firstAt: TimeInterval
+ var peakAt: TimeInterval
+ var tokens: Int
+ var model: String
+ var effortModel: String
+ var effort: String
+ var eligible: Bool
+
+ func merging(_ incoming: ScanEntry) -> ScanEntry {
+ var merged = self
+ if incoming.tokens > tokens {
+ merged.tokens = incoming.tokens
+ merged.peakAt = incoming.peakAt
+ merged.model = incoming.model
+ }
+ merged.firstAt = min(firstAt, incoming.firstAt)
+ if effortModel == "unknown" { merged.effortModel = incoming.effortModel }
+ if effort == "unknown" { merged.effort = incoming.effort }
+ merged.eligible = eligible || incoming.eligible
+ return merged
+ }
+}
+
+extension ScanEntry: Codable {
+ init(from decoder: Decoder) throws {
+ var container = try decoder.unkeyedContainer()
+ id = try container.decode(String.self)
+ firstAt = try container.decode(TimeInterval.self)
+ peakAt = try container.decode(TimeInterval.self)
+ tokens = try container.decode(Int.self)
+ model = try container.decode(String.self)
+ effortModel = try container.decode(String.self)
+ effort = try container.decode(String.self)
+ eligible = try container.decode(Bool.self)
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.unkeyedContainer()
+ try container.encode(id)
+ try container.encode(firstAt)
+ try container.encode(peakAt)
+ try container.encode(tokens)
+ try container.encode(model)
+ try container.encode(effortModel)
+ try container.encode(effort)
+ try container.encode(eligible)
+ }
+}
diff --git a/Sources/TokenGaugeCore/SecureMetricStore.swift b/Sources/TokenGaugeCore/SecureMetricStore.swift
index 5af91c7..f006916 100644
--- a/Sources/TokenGaugeCore/SecureMetricStore.swift
+++ b/Sources/TokenGaugeCore/SecureMetricStore.swift
@@ -23,18 +23,18 @@ public enum SecureMetricStore {
}
extension JSONEncoder {
- static var tokenGauge: JSONEncoder {
+ static let tokenGauge: JSONEncoder = {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .secondsSince1970
encoder.outputFormatting = [.sortedKeys]
return encoder
- }
+ }()
}
extension JSONDecoder {
- static var tokenGauge: JSONDecoder {
+ static let tokenGauge: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970
return decoder
- }
+ }()
}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift
new file mode 100644
index 0000000..92d347a
--- /dev/null
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Effort.swift
@@ -0,0 +1,48 @@
+import Foundation
+
+extension UsageHistoryStore {
+ static func readEffortRows(
+ _ connection: SQLiteConnection, since day: String?, through lastDay: String?
+ ) throws -> [HistoryEffortRow] {
+ let sql = """
+ SELECT day, provider, model, effort, SUM(tokens) FROM effort_events
+ WHERE day >= ?1 AND day <= ?2
+ GROUP BY day, provider, model, effort ORDER BY day, provider, model, effort;
+ """
+ var rows: [HistoryEffortRow] = []
+ try connection.query(sql) { statement in
+ statement.bind(1, day ?? "0000-01-01")
+ statement.bind(2, lastDay ?? "9999-12-31")
+ } each: { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(1)) else { return }
+ rows.append(
+ HistoryEffortRow(
+ day: statement.text(0), provider: provider, model: statement.text(2), effort: statement.text(3),
+ tokens: statement.int(4)))
+ }
+ return rows
+ }
+
+ static func writeEffort(_ connection: SQLiteConnection, record: EffortUsageRecord) throws {
+ let sql = """
+ INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
+ ON CONFLICT(id) DO UPDATE SET
+ day = CASE WHEN excluded.occurred_at < effort_events.occurred_at THEN excluded.day ELSE effort_events.day END,
+ occurred_at = MIN(effort_events.occurred_at, excluded.occurred_at),
+ model = CASE WHEN effort_events.model = 'unknown' THEN excluded.model ELSE effort_events.model END,
+ effort = CASE WHEN effort_events.effort = 'unknown' THEN excluded.effort ELSE effort_events.effort END,
+ tokens = MAX(effort_events.tokens, excluded.tokens)
+ WHERE effort_events.provider = excluded.provider;
+ """
+ try connection.run(sql) { statement in
+ statement.bind(1, record.id.lowercased())
+ statement.bind(2, record.provider.rawValue)
+ statement.bind(3, record.recordedAt.timeIntervalSince1970)
+ statement.bind(4, record.day)
+ statement.bind(5, record.model)
+ statement.bind(6, record.effort)
+ statement.bind(7, Int64(record.tokens))
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Pace.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Pace.swift
new file mode 100644
index 0000000..144be57
--- /dev/null
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Pace.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+extension UsageHistoryStore {
+ static func readPaceRows(
+ _ connection: SQLiteConnection, provider: UsageProvider?, windowID: String?, since: Date?, until: Date?,
+ accountFingerprint: String?
+ ) throws -> [HistoryPaceRow] {
+ try readPaces(
+ connection,
+ suffix: """
+ WHERE sampled_at >= ?1 AND sampled_at <= ?2
+ AND (?3 IS NULL OR provider = ?3) AND (?4 IS NULL OR window_id = ?4)
+ AND account_fingerprint IS ?5
+ ORDER BY sampled_at, provider, window_id
+ """
+ ) { statement in
+ statement.bind(1, since?.timeIntervalSince1970 ?? -Double.greatestFiniteMagnitude)
+ statement.bind(2, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
+ statement.bind(3, provider?.rawValue)
+ statement.bind(4, windowID)
+ statement.bind(5, accountFingerprint)
+ }
+ }
+
+ static func readRecentPaces(
+ _ connection: SQLiteConnection, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date?,
+ accountFingerprint: String? = nil
+ ) throws -> [HistoryPaceRow] {
+ let active = activeOnly ? "AND is_active = 1" : ""
+ return try readPaces(
+ connection,
+ suffix: """
+ WHERE provider = ?1 AND window_id = ?2 AND sampled_at < ?3 \(active)
+ AND account_fingerprint IS ?5
+ ORDER BY sampled_at DESC LIMIT ?4
+ """
+ ) { statement in
+ statement.bind(1, key.provider.rawValue)
+ statement.bind(2, key.windowID)
+ statement.bind(3, before?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
+ statement.bind(4, Int64(limit))
+ statement.bind(5, accountFingerprint)
+ }
+ }
+
+ private static func readPaces(
+ _ connection: SQLiteConnection, suffix: String, bindings: (SQLiteStatement) -> Void
+ ) throws -> [HistoryPaceRow] {
+ var rows: [HistoryPaceRow] = []
+ try connection.query(
+ """
+ SELECT provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes,
+ resets_at, last_used_percentage, is_active FROM pace_samples
+ \(suffix);
+ """, bindings: bindings
+ ) { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(0)) else { return }
+ rows.append(
+ HistoryPaceRow(
+ key: HistoryPaceKey(provider: provider, windowID: statement.text(1)),
+ displayName: statement.optionalText(3),
+ pace: QuotaPace(
+ pointsPerHour: statement.double(4),
+ observedMinutes: statement.double(5),
+ sampledAt: statement.instant(2),
+ resetsAt: statement.date(6), lastUsedPercentage: statement.double(7)),
+ isActive: statement.flag(8)))
+ }
+ return rows
+ }
+
+ static func archivePace(
+ _ connection: SQLiteConnection, snapshot: ProviderUsageSnapshot, window: QuotaWindow,
+ previous: HistoryQuotaRow?, rows: [HistoryQuotaRow], now: Date, accountFingerprint: String?
+ ) throws {
+ guard window.durationMinutes == 10_080 else { return }
+ guard let pace = HistoryAnalytics.pace(rows: rows, provider: snapshot.provider, windowID: window.id, now: now)
+ else { return }
+ let key = HistoryPaceKey(provider: snapshot.provider, windowID: window.id)
+ let increased =
+ previous.map {
+ $0.isVerified && $0.durationMinutes == window.durationMinutes
+ && HistoryAnalytics.sameReset($0.resetsAt, window.resetsAt)
+ && window.usedPercentage > $0.usedPercentage
+ } ?? false
+ let activeHistory = try readRecentPaces(
+ connection, key: key, activeOnly: true, limit: 1, before: nil, accountFingerprint: accountFingerprint)
+ let seed = pace.pointsPerHour > 0 && activeHistory.isEmpty
+ try connection.run(
+ """
+ INSERT OR IGNORE INTO pace_samples
+ (provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes,
+ resets_at, last_used_percentage, is_active, account_fingerprint)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);
+ """
+ ) { statement in
+ statement.bind(1, snapshot.provider.rawValue)
+ statement.bind(2, window.id)
+ statement.bind(3, pace.sampledAt.timeIntervalSince1970)
+ statement.bind(4, window.displayName)
+ statement.bind(5, pace.pointsPerHour)
+ statement.bind(6, pace.observedMinutes)
+ statement.bind(7, pace.resetsAt)
+ statement.bind(8, window.usedPercentage)
+ statement.bind(9, increased || seed)
+ statement.bind(10, accountFingerprint)
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Quota.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Quota.swift
new file mode 100644
index 0000000..74e42b8
--- /dev/null
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Quota.swift
@@ -0,0 +1,137 @@
+import Foundation
+
+extension UsageHistoryStore {
+ static func readQuotaRows(
+ _ connection: SQLiteConnection, provider: UsageProvider? = nil, since: Date? = nil, until: Date? = nil
+ ) throws -> [HistoryQuotaRow] {
+ let sql = """
+ SELECT COALESCE(observed_at, sampled_at), provider, window_id, display_name, used_percentage, resets_at,
+ duration_minutes, verified, continuity_started_at, continuity_reset_at, account_fingerprint
+ FROM quota_samples
+ WHERE sampled_at >= ?2 AND sampled_at <= ?3 AND (?1 IS NULL OR provider = ?1)
+ AND COALESCE(observed_at, sampled_at) >= ?4 AND COALESCE(observed_at, sampled_at) <= ?5
+ ORDER BY sampled_at, provider, window_id;
+ """
+ var rows: [HistoryQuotaRow] = []
+ try connection.query(sql) { statement in
+ statement.bind(1, provider?.rawValue)
+ statement.bind(2, since.map { bucket($0) } ?? Int64.min)
+ statement.bind(3, until.map { bucket($0) } ?? Int64.max)
+ statement.bind(4, since?.timeIntervalSince1970 ?? -Double.greatestFiniteMagnitude)
+ statement.bind(5, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
+ } each: { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(1)) else { return }
+ rows.append(
+ HistoryQuotaRow(
+ sampledAt: statement.instant(0),
+ provider: provider,
+ windowID: statement.text(2),
+ displayName: statement.optionalText(3),
+ usedPercentage: statement.double(4),
+ resetsAt: statement.date(5),
+ durationMinutes: statement.optionalInt(6),
+ isVerified: statement.flag(7),
+ continuityStartedAt: statement.date(8), continuityResetAt: statement.date(9),
+ accountFingerprint: statement.optionalText(10)
+ )
+ )
+ }
+ return rows
+ }
+
+ static func writeQuota(
+ _ connection: SQLiteConnection, snapshot: ProviderUsageSnapshot, accountFingerprint: String?
+ ) throws {
+ guard let capturedAt = snapshot.capturedAt else { return }
+ let sampledAt = bucket(capturedAt)
+ let sql = """
+ INSERT INTO quota_samples
+ (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at, account_fingerprint)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11, ?12)
+ ON CONFLICT(provider, window_id, sampled_at) DO UPDATE SET
+ display_name = excluded.display_name,
+ duration_minutes = excluded.duration_minutes,
+ used_percentage = excluded.used_percentage,
+ resets_at = excluded.resets_at,
+ verified = excluded.verified,
+ observed_at = excluded.observed_at,
+ continuity_started_at = excluded.continuity_started_at,
+ continuity_reset_at = excluded.continuity_reset_at,
+ account_fingerprint = excluded.account_fingerprint
+ WHERE excluded.observed_at >= COALESCE(quota_samples.observed_at, quota_samples.sampled_at);
+ """
+ var archived: [(window: QuotaWindow, previous: HistoryQuotaRow?)] = []
+ for window in snapshot.windows {
+ let previous = try latestQuota(connection, provider: snapshot.provider, windowID: window.id)
+ guard previous.map({ capturedAt > $0.sampledAt }) ?? true else { continue }
+ let valid = window.usedPercentage.isFinite && (0...100).contains(window.usedPercentage)
+ var continuityStartedAt = capturedAt
+ var continuityResetAt = window.resetsAt
+ if let previous, previous.isVerified, valid,
+ previous.usedPercentage <= window.usedPercentage,
+ previous.accountFingerprint == accountFingerprint,
+ HistoryAnalytics.sameReset(previous.continuityResetAt ?? previous.resetsAt, window.resetsAt),
+ previous.durationMinutes == window.durationMinutes,
+ capturedAt.timeIntervalSince(previous.sampledAt) <= 1800,
+ let boundary = previous.continuityStartedAt
+ {
+ continuityStartedAt = boundary
+ continuityResetAt = previous.continuityResetAt ?? previous.resetsAt
+ }
+ try connection.run(sql) { statement in
+ statement.bind(1, snapshot.provider.rawValue)
+ statement.bind(2, window.id)
+ statement.bind(3, sampledAt)
+ statement.bind(4, window.displayName)
+ statement.bind(5, window.durationMinutes.map(Int64.init))
+ statement.bind(6, window.usedPercentage.isFinite ? window.usedPercentage : 0)
+ statement.bind(7, window.resetsAt)
+ statement.bind(8, capturedAt)
+ statement.bind(9, continuityStartedAt)
+ statement.bind(10, valid)
+ statement.bind(11, continuityResetAt)
+ statement.bind(12, accountFingerprint)
+ }
+ archived.append((window, previous))
+ }
+ guard archived.contains(where: { $0.window.durationMinutes == 10_080 }) else { return }
+ let recent = try readQuotaRows(
+ connection, provider: snapshot.provider, since: capturedAt.addingTimeInterval(-3600), until: capturedAt)
+ for entry in archived {
+ try archivePace(
+ connection, snapshot: snapshot, window: entry.window, previous: entry.previous, rows: recent,
+ now: capturedAt, accountFingerprint: accountFingerprint)
+ }
+ }
+
+ static func latestQuota(
+ _ connection: SQLiteConnection, provider: UsageProvider, windowID: String
+ ) throws -> HistoryQuotaRow? {
+ let sql = """
+ SELECT COALESCE(observed_at, sampled_at), used_percentage, resets_at, duration_minutes,
+ verified, continuity_started_at, continuity_reset_at, account_fingerprint
+ FROM quota_samples WHERE provider = ?1 AND window_id = ?2 ORDER BY sampled_at DESC LIMIT 1;
+ """
+ var row: HistoryQuotaRow?
+ try connection.query(sql) { statement in
+ statement.bind(1, provider.rawValue)
+ statement.bind(2, windowID)
+ } each: { statement in
+ row = HistoryQuotaRow(
+ sampledAt: statement.instant(0), provider: provider,
+ windowID: windowID, displayName: nil, usedPercentage: statement.double(1),
+ resetsAt: statement.date(2),
+ durationMinutes: statement.optionalInt(3),
+ isVerified: statement.flag(4), continuityStartedAt: statement.date(5),
+ continuityResetAt: statement.date(6), accountFingerprint: statement.optionalText(7))
+ }
+ return row
+ }
+
+ static func prune(_ connection: SQLiteConnection, now: Date) throws {
+ let cutoff = Int64(now.addingTimeInterval(-Double(quotaRetentionDays) * 86_400).timeIntervalSince1970)
+ try connection.run("DELETE FROM quota_samples WHERE sampled_at < ?1;") { statement in
+ statement.bind(1, cutoff)
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift
new file mode 100644
index 0000000..167b6f8
--- /dev/null
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Schema.swift
@@ -0,0 +1,115 @@
+import Foundation
+import SQLite3
+
+extension UsageHistoryStore {
+ enum Schema {
+ static let version: Int32 = 1
+
+ static let statements = """
+ CREATE TABLE IF NOT EXISTS quota_samples (
+ provider TEXT NOT NULL,
+ window_id TEXT NOT NULL,
+ sampled_at INTEGER NOT NULL,
+ display_name TEXT,
+ duration_minutes INTEGER,
+ used_percentage REAL NOT NULL,
+ resets_at INTEGER,
+ PRIMARY KEY (provider, window_id, sampled_at)
+ ) WITHOUT ROWID;
+ CREATE INDEX IF NOT EXISTS quota_samples_sampled_at ON quota_samples(sampled_at);
+ CREATE TABLE IF NOT EXISTS daily_tokens (
+ day TEXT NOT NULL,
+ provider TEXT NOT NULL,
+ model TEXT NOT NULL,
+ tokens INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (day, provider, model)
+ ) WITHOUT ROWID;
+ CREATE TABLE IF NOT EXISTS pace_samples (
+ provider TEXT NOT NULL, window_id TEXT NOT NULL, sampled_at REAL NOT NULL,
+ display_name TEXT, points_per_hour REAL NOT NULL, observed_minutes REAL NOT NULL,
+ resets_at REAL, last_used_percentage REAL, is_active INTEGER NOT NULL,
+ PRIMARY KEY(provider, window_id, sampled_at)
+ ) WITHOUT ROWID;
+ CREATE INDEX IF NOT EXISTS pace_samples_sampled_at ON pace_samples(sampled_at);
+ CREATE INDEX IF NOT EXISTS pace_samples_active ON pace_samples(provider, window_id, is_active, sampled_at);
+ CREATE TABLE IF NOT EXISTS effort_events (
+ id TEXT NOT NULL PRIMARY KEY,
+ provider TEXT NOT NULL,
+ occurred_at REAL NOT NULL,
+ day TEXT NOT NULL,
+ model TEXT NOT NULL,
+ effort TEXT NOT NULL,
+ tokens INTEGER NOT NULL
+ ) WITHOUT ROWID;
+ CREATE INDEX IF NOT EXISTS effort_events_day_provider ON effort_events(day, provider);
+ """
+
+ static let observedTotalsViews = """
+ DROP VIEW IF EXISTS monthly_totals;
+ DROP VIEW IF EXISTS weekly_totals;
+ DROP VIEW IF EXISTS daily_totals;
+ CREATE VIEW daily_totals AS
+ SELECT day, provider, MAX(tokens) AS tokens FROM (
+ SELECT day, provider,
+ MAX(COALESCE(MAX(CASE WHEN model = 'all' THEN tokens END), 0),
+ COALESCE(SUM(CASE WHEN model != 'all' THEN tokens END), 0)) AS tokens
+ FROM daily_tokens GROUP BY day, provider
+ UNION ALL
+ SELECT day, provider, SUM(tokens) AS tokens
+ FROM effort_events GROUP BY day, provider
+ ) GROUP BY day, provider;
+ CREATE VIEW weekly_totals AS
+ SELECT date(day, 'weekday 0', '-6 days') AS week_start, provider, SUM(tokens) AS tokens
+ FROM daily_totals GROUP BY week_start, provider;
+ CREATE VIEW monthly_totals AS
+ SELECT substr(day, 1, 7) AS month, provider, SUM(tokens) AS tokens
+ FROM daily_totals GROUP BY month, provider;
+ """
+
+ }
+
+ static func schemaVersion(_ connection: SQLiteConnection) throws -> Int32 {
+ var version: Int32 = 0
+ try connection.query("PRAGMA user_version;", bindings: { _ in }) { statement in
+ version = sqlite3_column_int(statement.handle, 0)
+ }
+ return version
+ }
+
+ static func prepareSchema(_ connection: SQLiteConnection) throws {
+ try connection.transaction {
+ try connection.exec(Schema.statements)
+ var columns = Set()
+ try connection.query("PRAGMA table_info(quota_samples);", bindings: { _ in }) { statement in
+ columns.insert(statement.text(1))
+ }
+ if !columns.contains("verified") {
+ try connection.exec("ALTER TABLE quota_samples ADD COLUMN verified INTEGER NOT NULL DEFAULT 0;")
+ }
+ if !columns.contains("observed_at") {
+ try connection.exec("ALTER TABLE quota_samples ADD COLUMN observed_at REAL;")
+ }
+ if !columns.contains("continuity_started_at") {
+ try connection.exec("ALTER TABLE quota_samples ADD COLUMN continuity_started_at REAL;")
+ }
+ if !columns.contains("continuity_reset_at") {
+ try connection.exec("ALTER TABLE quota_samples ADD COLUMN continuity_reset_at REAL;")
+ }
+ if !columns.contains("account_fingerprint") {
+ try connection.exec("ALTER TABLE quota_samples ADD COLUMN account_fingerprint TEXT;")
+ }
+ var paceColumns = Set()
+ try connection.query("PRAGMA table_info(pace_samples);", bindings: { _ in }) { statement in
+ paceColumns.insert(statement.text(1))
+ }
+ if !paceColumns.contains("account_fingerprint") {
+ try connection.exec("ALTER TABLE pace_samples ADD COLUMN account_fingerprint TEXT;")
+ }
+ if try schemaVersion(connection) < Schema.version {
+ try connection.exec(Schema.observedTotalsViews)
+ try connection.exec("PRAGMA user_version = \(Schema.version);")
+ }
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore+Tokens.swift b/Sources/TokenGaugeCore/UsageHistoryStore+Tokens.swift
new file mode 100644
index 0000000..e8a2279
--- /dev/null
+++ b/Sources/TokenGaugeCore/UsageHistoryStore+Tokens.swift
@@ -0,0 +1,110 @@
+import Foundation
+
+extension UsageHistoryStore {
+ static func readTokenRows(
+ _ connection: SQLiteConnection, since day: String?, through lastDay: String?
+ ) throws -> [HistoryTokenRow] {
+ let sql = """
+ SELECT day, provider, model, tokens FROM daily_tokens
+ WHERE day >= ?1 AND day <= ?2
+ ORDER BY day, provider, model;
+ """
+ var rows: [HistoryTokenRow] = []
+ try connection.query(sql) { statement in
+ statement.bind(1, day ?? "0000-01-01")
+ statement.bind(2, lastDay ?? "9999-12-31")
+ } each: { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(1)) else { return }
+ rows.append(
+ HistoryTokenRow(
+ day: statement.text(0),
+ provider: provider,
+ model: statement.text(2),
+ tokens: statement.int(3)
+ )
+ )
+ }
+ return rows
+ }
+
+ static func readUsageDays(_ connection: SQLiteConnection, provider: UsageProvider?) throws -> Set {
+ let sql = """
+ SELECT day FROM daily_totals
+ WHERE tokens > 0 AND (?1 IS NULL OR provider = ?1);
+ """
+ var days: Set = []
+ try connection.query(sql) { statement in
+ statement.bind(1, provider?.rawValue)
+ } each: { statement in
+ days.insert(statement.text(0))
+ }
+ return days
+ }
+
+ static func readUsageDaysByProvider(_ connection: SQLiteConnection) throws -> [UsageProvider: Set] {
+ let sql = """
+ SELECT provider, day FROM daily_totals
+ WHERE tokens > 0;
+ """
+ var days: [UsageProvider: Set] = [:]
+ try connection.query(sql, bindings: { _ in }) { statement in
+ guard let provider = UsageProvider(rawValue: statement.text(0)) else { return }
+ days[provider, default: []].insert(statement.text(1))
+ }
+ return days
+ }
+
+ static func readBounds(_ connection: SQLiteConnection) throws -> HistoryBounds {
+ var result = HistoryBounds(firstDay: nil, lastDay: nil)
+ let sql = """
+ SELECT (SELECT MIN(day) FROM daily_tokens), (SELECT MAX(day) FROM daily_tokens),
+ (SELECT MIN(day) FROM effort_events), (SELECT MAX(day) FROM effort_events);
+ """
+ try connection.query(sql, bindings: { _ in }) { statement in
+ let firstDays = [statement.optionalText(0), statement.optionalText(2)].compactMap { $0 }
+ let lastDays = [statement.optionalText(1), statement.optionalText(3)].compactMap { $0 }
+ result = HistoryBounds(firstDay: firstDays.min(), lastDay: lastDays.max())
+ }
+ return result
+ }
+
+ static func writeTokens(_ connection: SQLiteConnection, snapshot: ProviderUsageSnapshot, now: Date) throws {
+ let sql = """
+ INSERT INTO daily_tokens (day, provider, model, tokens, updated_at)
+ VALUES (?1, ?2, ?3, ?4, ?5)
+ ON CONFLICT(day, provider, model) DO UPDATE SET
+ tokens = MAX(daily_tokens.tokens, excluded.tokens),
+ updated_at = excluded.updated_at;
+ """
+ for row in tokenRows(for: snapshot) {
+ try connection.run(sql) { statement in
+ statement.bind(1, row.day)
+ statement.bind(2, row.provider.rawValue)
+ statement.bind(3, row.model)
+ statement.bind(4, Int64(row.tokens))
+ statement.bind(5, Int64(now.timeIntervalSince1970))
+ }
+ }
+ }
+
+ static func tokenRows(for snapshot: ProviderUsageSnapshot) -> [HistoryTokenRow] {
+ if snapshot.modelBuckets.isEmpty {
+ return snapshot.dailyUsage
+ .filter { $0.tokens >= 0 }
+ .map { HistoryTokenRow(day: $0.day, provider: snapshot.provider, model: "all", tokens: $0.tokens) }
+ }
+ var totals: [String: [String: Int]] = [:]
+ for item in snapshot.modelBuckets where item.tokens >= 0 {
+ totals[item.day, default: [:]][item.model, default: 0] += item.tokens
+ }
+ return
+ totals
+ .flatMap { day, models in
+ models.map { HistoryTokenRow(day: day, provider: snapshot.provider, model: $0.key, tokens: $0.value) }
+ }
+ .sorted { left, right in
+ if left.day != right.day { return left.day < right.day }
+ return left.model < right.model
+ }
+ }
+}
diff --git a/Sources/TokenGaugeCore/UsageHistoryStore.swift b/Sources/TokenGaugeCore/UsageHistoryStore.swift
index 02a5e04..ae3f6d0 100644
--- a/Sources/TokenGaugeCore/UsageHistoryStore.swift
+++ b/Sources/TokenGaugeCore/UsageHistoryStore.swift
@@ -1,6 +1,5 @@
-import Darwin
import Foundation
-import SQLite3
+import os
public enum UsageHistoryError: Error, Equatable, Sendable {
case openFailed(String)
@@ -73,22 +72,16 @@ public enum UsageHistoryStore {
recordTokens: Bool = true,
accountFingerprint: String? = nil
) throws {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- try exec(handle, "BEGIN IMMEDIATE;")
- do {
- if recordQuota {
- try writeQuota(handle, snapshot: snapshot, accountFingerprint: accountFingerprint)
- }
- if recordTokens && snapshot.activityReadSucceeded {
- try writeTokens(handle, snapshot: snapshot, now: now)
+ try write(url) { connection in
+ try connection.transaction {
+ if recordQuota {
+ try writeQuota(connection, snapshot: snapshot, accountFingerprint: accountFingerprint)
+ }
+ if recordTokens && snapshot.activityReadSucceeded {
+ try writeTokens(connection, snapshot: snapshot, now: now)
+ }
+ try prune(connection, now: now)
}
- try prune(handle, now: now)
- try exec(handle, "COMMIT;")
- } catch {
- try? exec(handle, "ROLLBACK;")
- throw error
}
}
@@ -97,42 +90,19 @@ public enum UsageHistoryStore {
) throws {
let valid = records.filter(\.isValid)
guard !valid.isEmpty else { return }
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- try exec(handle, "BEGIN IMMEDIATE;")
- do {
- for record in valid { try writeEffort(handle, record: record) }
- try exec(handle, "COMMIT;")
- } catch {
- try? exec(handle, "ROLLBACK;")
- throw error
+ try write(url) { connection in
+ try connection.transaction {
+ for record in valid { try writeEffort(connection, record: record) }
+ }
}
}
public static func effortRows(
since day: String? = nil, through lastDay: String? = nil, at url: URL = UsagePaths.history()
) throws -> [HistoryEffortRow] {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- let sql = """
- SELECT day, provider, model, effort, SUM(tokens) FROM effort_events
- WHERE day >= ?1 AND day <= ?2
- GROUP BY day, provider, model, effort ORDER BY day, provider, model, effort;
- """
- var rows: [HistoryEffortRow] = []
- try query(handle, sql) { statement in
- bind(statement, 1, day ?? "0000-01-01")
- bind(statement, 2, lastDay ?? "9999-12-31")
- } each: { statement in
- guard let provider = UsageProvider(rawValue: text(statement, 1)) else { return }
- rows.append(
- HistoryEffortRow(
- day: text(statement, 0), provider: provider, model: text(statement, 2), effort: text(statement, 3),
- tokens: Int(sqlite3_column_int64(statement, 4))))
+ try read(url) { connection in
+ try readEffortRows(connection, since: day, through: lastDay)
}
- return rows
}
public static func tokenRows(
@@ -140,30 +110,26 @@ public enum UsageHistoryStore {
through lastDay: String? = nil,
at url: URL = UsagePaths.history()
) throws -> [HistoryTokenRow] {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- let sql = """
- SELECT day, provider, model, tokens FROM daily_tokens
- WHERE day >= ?1 AND day <= ?2
- ORDER BY day, provider, model;
- """
- var rows: [HistoryTokenRow] = []
- try query(handle, sql) { statement in
- bind(statement, 1, day ?? "0000-01-01")
- bind(statement, 2, lastDay ?? "9999-12-31")
- } each: { statement in
- guard let provider = UsageProvider(rawValue: text(statement, 1)) else { return }
- rows.append(
- HistoryTokenRow(
- day: text(statement, 0),
- provider: provider,
- model: text(statement, 2),
- tokens: Int(sqlite3_column_int64(statement, 3))
- )
- )
+ try read(url) { connection in
+ try readTokenRows(connection, since: day, through: lastDay)
+ }
+ }
+
+ public static func usageDays(
+ provider: UsageProvider? = nil,
+ at url: URL = UsagePaths.history()
+ ) throws -> Set {
+ try read(url) { connection in
+ try readUsageDays(connection, provider: provider)
+ }
+ }
+
+ public static func usageDaysByProvider(
+ at url: URL = UsagePaths.history()
+ ) throws -> [UsageProvider: Set] {
+ try read(url) { connection in
+ try readUsageDaysByProvider(connection)
}
- return rows
}
public static func quotaRows(
@@ -172,72 +138,19 @@ public enum UsageHistoryStore {
until: Date? = nil,
at url: URL = UsagePaths.history()
) throws -> [HistoryQuotaRow] {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- return try readQuotaRows(handle, provider: provider, since: since, until: until)
- }
-
- private static func readQuotaRows(
- _ handle: OpaquePointer, provider: UsageProvider? = nil, since: Date? = nil, until: Date? = nil
- ) throws -> [HistoryQuotaRow] {
- let sql = """
- SELECT COALESCE(observed_at, sampled_at), provider, window_id, display_name, used_percentage, resets_at,
- duration_minutes, verified, continuity_started_at, continuity_reset_at, account_fingerprint
- FROM quota_samples
- WHERE sampled_at >= ?2 AND sampled_at <= ?3 AND (?1 IS NULL OR provider = ?1)
- AND COALESCE(observed_at, sampled_at) >= ?4 AND COALESCE(observed_at, sampled_at) <= ?5
- ORDER BY sampled_at, provider, window_id;
- """
- var rows: [HistoryQuotaRow] = []
- try query(handle, sql) { statement in
- bind(statement, 1, provider?.rawValue)
- sqlite3_bind_int64(statement, 2, since.map { bucket($0) } ?? Int64.min)
- sqlite3_bind_int64(statement, 3, until.map { bucket($0) } ?? Int64.max)
- sqlite3_bind_double(statement, 4, since?.timeIntervalSince1970 ?? -Double.greatestFiniteMagnitude)
- sqlite3_bind_double(statement, 5, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
- } each: { statement in
- guard let provider = UsageProvider(rawValue: text(statement, 1)) else { return }
- rows.append(
- HistoryQuotaRow(
- sampledAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)),
- provider: provider,
- windowID: text(statement, 2),
- displayName: optionalText(statement, 3),
- usedPercentage: sqlite3_column_double(statement, 4),
- resetsAt: date(statement, 5),
- durationMinutes: sqlite3_column_type(statement, 6) == SQLITE_NULL
- ? nil : Int(sqlite3_column_int64(statement, 6)),
- isVerified: sqlite3_column_int(statement, 7) == 1,
- continuityStartedAt: date(statement, 8), continuityResetAt: date(statement, 9),
- accountFingerprint: optionalText(statement, 10)
- )
- )
+ try read(url) { connection in
+ try readQuotaRows(connection, provider: provider, since: since, until: until)
}
- return rows
}
public static func paceRows(
provider: UsageProvider? = nil, windowID: String? = nil, since: Date? = nil, until: Date? = nil,
accountFingerprint: String? = nil, at url: URL = UsagePaths.history()
) throws -> [HistoryPaceRow] {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- return try readPaces(
- handle,
- suffix: """
- WHERE sampled_at >= ?1 AND sampled_at <= ?2
- AND (?3 IS NULL OR provider = ?3) AND (?4 IS NULL OR window_id = ?4)
- AND account_fingerprint IS ?5
- ORDER BY sampled_at, provider, window_id
- """
- ) { statement in
- sqlite3_bind_double(statement, 1, since?.timeIntervalSince1970 ?? -Double.greatestFiniteMagnitude)
- sqlite3_bind_double(statement, 2, until?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
- bind(statement, 3, provider?.rawValue)
- bind(statement, 4, windowID)
- bind(statement, 5, accountFingerprint)
+ try read(url) { connection in
+ try readPaceRows(
+ connection, provider: provider, windowID: windowID, since: since, until: until,
+ accountFingerprint: accountFingerprint)
}
}
@@ -247,132 +160,27 @@ public enum UsageHistoryStore {
at url: URL = UsagePaths.history()
) throws -> [HistoryPaceRow] {
guard !keys.isEmpty, limitPerWindow > 0 else { return [] }
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- var rows: [HistoryPaceRow] = []
- for key in Set(keys).sorted(by: { $0.id < $1.id }) {
- let candidates = try readRecentPaces(
- handle, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before,
- accountFingerprint: accountFingerprint)
- if matchingLatestQuota {
- let quota = try latestQuota(handle, provider: key.provider, windowID: key.windowID)
- rows += candidates.filter { quota?.isVerified == true && $0.pace.sampledAt == quota?.sampledAt }
- } else {
- rows += candidates
- }
- }
- return rows
- }
-
- private static func readRecentPaces(
- _ handle: OpaquePointer, key: HistoryPaceKey, activeOnly: Bool, limit: Int, before: Date?,
- accountFingerprint: String? = nil
- ) throws -> [HistoryPaceRow] {
- let active = activeOnly ? "AND is_active = 1" : ""
- return try readPaces(
- handle,
- suffix: """
- WHERE provider = ?1 AND window_id = ?2 AND sampled_at < ?3 \(active)
- AND account_fingerprint IS ?5
- ORDER BY sampled_at DESC LIMIT ?4
- """
- ) { statement in
- bind(statement, 1, key.provider.rawValue)
- bind(statement, 2, key.windowID)
- sqlite3_bind_double(statement, 3, before?.timeIntervalSince1970 ?? Double.greatestFiniteMagnitude)
- sqlite3_bind_int64(statement, 4, Int64(limit))
- bind(statement, 5, accountFingerprint)
- }
- }
-
- private static func readPaces(
- _ handle: OpaquePointer, suffix: String, bindings: (OpaquePointer) -> Void
- ) throws -> [HistoryPaceRow] {
- var rows: [HistoryPaceRow] = []
- try query(
- handle,
- """
- SELECT provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes,
- resets_at, last_used_percentage, is_active FROM pace_samples
- \(suffix);
- """, bindings: bindings
- ) { statement in
- guard let provider = UsageProvider(rawValue: text(statement, 0)) else { return }
- rows.append(
- HistoryPaceRow(
- key: HistoryPaceKey(provider: provider, windowID: text(statement, 1)),
- displayName: optionalText(statement, 3),
- pace: QuotaPace(
- pointsPerHour: sqlite3_column_double(statement, 4),
- observedMinutes: sqlite3_column_double(statement, 5),
- sampledAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)),
- resetsAt: date(statement, 6), lastUsedPercentage: sqlite3_column_double(statement, 7)),
- isActive: sqlite3_column_int(statement, 8) == 1))
- }
- return rows
- }
-
- private static func archivePace(
- _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, window: QuotaWindow,
- previous: HistoryQuotaRow?, now: Date, accountFingerprint: String?
- ) throws {
- guard window.durationMinutes == 10_080 else { return }
- let rows = try readQuotaRows(
- handle, provider: snapshot.provider, since: now.addingTimeInterval(-3600), until: now)
- guard let pace = HistoryAnalytics.pace(rows: rows, provider: snapshot.provider, windowID: window.id, now: now)
- else { return }
- let key = HistoryPaceKey(provider: snapshot.provider, windowID: window.id)
- let increased =
- previous.map {
- $0.isVerified && $0.durationMinutes == window.durationMinutes
- && HistoryAnalytics.sameReset($0.resetsAt, window.resetsAt)
- && window.usedPercentage > $0.usedPercentage
- } ?? false
- let activeHistory = try readRecentPaces(
- handle, key: key, activeOnly: true, limit: 1, before: nil, accountFingerprint: accountFingerprint)
- let seed = pace.pointsPerHour > 0 && activeHistory.isEmpty
- try run(
- handle,
- """
- INSERT OR IGNORE INTO pace_samples
- (provider, window_id, sampled_at, display_name, points_per_hour, observed_minutes,
- resets_at, last_used_percentage, is_active, account_fingerprint)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);
- """
- ) { statement in
- bind(statement, 1, snapshot.provider.rawValue)
- bind(statement, 2, window.id)
- sqlite3_bind_double(statement, 3, pace.sampledAt.timeIntervalSince1970)
- bind(statement, 4, window.displayName)
- sqlite3_bind_double(statement, 5, pace.pointsPerHour)
- sqlite3_bind_double(statement, 6, pace.observedMinutes)
- if let reset = pace.resetsAt {
- sqlite3_bind_double(statement, 7, reset.timeIntervalSince1970)
- } else {
- sqlite3_bind_null(statement, 7)
+ return try read(url) { connection in
+ var rows: [HistoryPaceRow] = []
+ for key in Set(keys).sorted(by: { $0.id < $1.id }) {
+ let candidates = try readRecentPaces(
+ connection, key: key, activeOnly: activeOnly, limit: limitPerWindow, before: before,
+ accountFingerprint: accountFingerprint)
+ if matchingLatestQuota {
+ let quota = try latestQuota(connection, provider: key.provider, windowID: key.windowID)
+ rows += candidates.filter { quota?.isVerified == true && $0.pace.sampledAt == quota?.sampledAt }
+ } else {
+ rows += candidates
+ }
}
- sqlite3_bind_double(statement, 8, window.usedPercentage)
- sqlite3_bind_int(statement, 9, increased || seed ? 1 : 0)
- bind(statement, 10, accountFingerprint)
+ return rows
}
}
public static func bounds(at url: URL = UsagePaths.history()) throws -> HistoryBounds {
- let handle = try open(url)
- defer { sqlite3_close(handle) }
- try prepareSchema(handle)
- var result = HistoryBounds(firstDay: nil, lastDay: nil)
- let sql = """
- SELECT (SELECT MIN(day) FROM daily_tokens), (SELECT MAX(day) FROM daily_tokens),
- (SELECT MIN(day) FROM effort_events), (SELECT MAX(day) FROM effort_events);
- """
- try query(handle, sql, bindings: { _ in }) { statement in
- let firstDays = [optionalText(statement, 0), optionalText(statement, 2)].compactMap { $0 }
- let lastDays = [optionalText(statement, 1), optionalText(statement, 3)].compactMap { $0 }
- result = HistoryBounds(firstDay: firstDays.min(), lastDay: lastDays.max())
+ try read(url) { connection in
+ try readBounds(connection)
}
- return result
}
static func bucket(_ date: Date, interval: TimeInterval = sampleInterval) -> Int64 {
@@ -382,375 +190,69 @@ public enum UsageHistoryStore {
}
extension UsageHistoryStore {
- private enum Schema {
- static let statements = """
- CREATE TABLE IF NOT EXISTS quota_samples (
- provider TEXT NOT NULL,
- window_id TEXT NOT NULL,
- sampled_at INTEGER NOT NULL,
- display_name TEXT,
- duration_minutes INTEGER,
- used_percentage REAL NOT NULL,
- resets_at INTEGER,
- PRIMARY KEY (provider, window_id, sampled_at)
- ) WITHOUT ROWID;
- CREATE INDEX IF NOT EXISTS quota_samples_sampled_at ON quota_samples(sampled_at);
- CREATE TABLE IF NOT EXISTS daily_tokens (
- day TEXT NOT NULL,
- provider TEXT NOT NULL,
- model TEXT NOT NULL,
- tokens INTEGER NOT NULL,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (day, provider, model)
- ) WITHOUT ROWID;
- CREATE TABLE IF NOT EXISTS pace_samples (
- provider TEXT NOT NULL, window_id TEXT NOT NULL, sampled_at REAL NOT NULL,
- display_name TEXT, points_per_hour REAL NOT NULL, observed_minutes REAL NOT NULL,
- resets_at REAL, last_used_percentage REAL, is_active INTEGER NOT NULL,
- PRIMARY KEY(provider, window_id, sampled_at)
- ) WITHOUT ROWID;
- CREATE INDEX IF NOT EXISTS pace_samples_sampled_at ON pace_samples(sampled_at);
- CREATE INDEX IF NOT EXISTS pace_samples_active ON pace_samples(provider, window_id, is_active, sampled_at);
- CREATE TABLE IF NOT EXISTS effort_events (
- id TEXT NOT NULL PRIMARY KEY,
- provider TEXT NOT NULL,
- occurred_at REAL NOT NULL,
- day TEXT NOT NULL,
- model TEXT NOT NULL,
- effort TEXT NOT NULL,
- tokens INTEGER NOT NULL
- ) WITHOUT ROWID;
- CREATE INDEX IF NOT EXISTS effort_events_day_provider ON effort_events(day, provider);
- """
-
- static let observedTotalsViews = """
- DROP VIEW IF EXISTS monthly_totals;
- DROP VIEW IF EXISTS weekly_totals;
- DROP VIEW IF EXISTS daily_totals;
- CREATE VIEW daily_totals AS
- SELECT day, provider, MAX(tokens) AS tokens FROM (
- SELECT day, provider,
- MAX(COALESCE(MAX(CASE WHEN model = 'all' THEN tokens END), 0),
- COALESCE(SUM(CASE WHEN model != 'all' THEN tokens END), 0)) AS tokens
- FROM daily_tokens GROUP BY day, provider
- UNION ALL
- SELECT day, provider, SUM(tokens) AS tokens
- FROM effort_events GROUP BY day, provider
- ) GROUP BY day, provider;
- CREATE VIEW weekly_totals AS
- SELECT date(day, 'weekday 0', '-6 days') AS week_start, provider, SUM(tokens) AS tokens
- FROM daily_totals GROUP BY week_start, provider;
- CREATE VIEW monthly_totals AS
- SELECT substr(day, 1, 7) AS month, provider, SUM(tokens) AS tokens
- FROM daily_totals GROUP BY month, provider;
- """
-
- }
-
- private static func prepareSchema(_ handle: OpaquePointer) throws {
- try exec(handle, "BEGIN IMMEDIATE;")
- do {
- try exec(handle, Schema.statements)
- var columns = Set()
- try query(handle, "PRAGMA table_info(quota_samples);", bindings: { _ in }) { statement in
- columns.insert(text(statement, 1))
- }
- if !columns.contains("verified") {
- try exec(handle, "ALTER TABLE quota_samples ADD COLUMN verified INTEGER NOT NULL DEFAULT 0;")
- }
- if !columns.contains("observed_at") {
- try exec(handle, "ALTER TABLE quota_samples ADD COLUMN observed_at REAL;")
- }
- if !columns.contains("continuity_started_at") {
- try exec(handle, "ALTER TABLE quota_samples ADD COLUMN continuity_started_at REAL;")
- }
- if !columns.contains("continuity_reset_at") {
- try exec(handle, "ALTER TABLE quota_samples ADD COLUMN continuity_reset_at REAL;")
- }
- if !columns.contains("account_fingerprint") {
- try exec(handle, "ALTER TABLE quota_samples ADD COLUMN account_fingerprint TEXT;")
- }
- var paceColumns = Set()
- try query(handle, "PRAGMA table_info(pace_samples);", bindings: { _ in }) { statement in
- paceColumns.insert(text(statement, 1))
- }
- if !paceColumns.contains("account_fingerprint") {
- try exec(handle, "ALTER TABLE pace_samples ADD COLUMN account_fingerprint TEXT;")
- }
- var version: Int32 = 0
- try query(handle, "PRAGMA user_version;", bindings: { _ in }) { statement in
- version = sqlite3_column_int(statement, 0)
- }
- if version < 1 {
- try exec(handle, Schema.observedTotalsViews)
- try exec(handle, "PRAGMA user_version = 1;")
- }
- try exec(handle, "COMMIT;")
- } catch {
- try? exec(handle, "ROLLBACK;")
- throw error
- }
- }
-
- private static func writeEffort(_ handle: OpaquePointer, record: EffortUsageRecord) throws {
- let sql = """
- INSERT INTO effort_events (id, provider, occurred_at, day, model, effort, tokens)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
- ON CONFLICT(id) DO UPDATE SET
- day = CASE WHEN excluded.occurred_at < effort_events.occurred_at THEN excluded.day ELSE effort_events.day END,
- occurred_at = MIN(effort_events.occurred_at, excluded.occurred_at),
- model = CASE WHEN effort_events.model = 'unknown' THEN excluded.model ELSE effort_events.model END,
- effort = CASE WHEN effort_events.effort = 'unknown' THEN excluded.effort ELSE effort_events.effort END,
- tokens = MAX(effort_events.tokens, excluded.tokens)
- WHERE effort_events.provider = excluded.provider;
- """
- try run(handle, sql) { statement in
- bind(statement, 1, record.id.lowercased())
- bind(statement, 2, record.provider.rawValue)
- sqlite3_bind_double(statement, 3, record.recordedAt.timeIntervalSince1970)
- bind(statement, 4, record.day)
- bind(statement, 5, record.model)
- bind(statement, 6, record.effort)
- sqlite3_bind_int64(statement, 7, Int64(record.tokens))
- }
- }
-
- private static func writeQuota(
- _ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, accountFingerprint: String?
- ) throws {
- guard let capturedAt = snapshot.capturedAt else { return }
- let sampledAt = bucket(capturedAt)
- let sql = """
- INSERT INTO quota_samples
- (provider, window_id, sampled_at, display_name, duration_minutes, used_percentage, resets_at, verified, observed_at, continuity_started_at, continuity_reset_at, account_fingerprint)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?10, ?8, ?9, ?11, ?12)
- ON CONFLICT(provider, window_id, sampled_at) DO UPDATE SET
- display_name = excluded.display_name,
- duration_minutes = excluded.duration_minutes,
- used_percentage = excluded.used_percentage,
- resets_at = excluded.resets_at,
- verified = excluded.verified,
- observed_at = excluded.observed_at,
- continuity_started_at = excluded.continuity_started_at,
- continuity_reset_at = excluded.continuity_reset_at,
- account_fingerprint = excluded.account_fingerprint
- WHERE excluded.observed_at >= COALESCE(quota_samples.observed_at, quota_samples.sampled_at);
- """
- for window in snapshot.windows {
- let previous = try latestQuota(handle, provider: snapshot.provider, windowID: window.id)
- guard previous.map({ capturedAt > $0.sampledAt }) ?? true else { continue }
- let valid = window.usedPercentage.isFinite && (0...100).contains(window.usedPercentage)
- var continuityStartedAt = capturedAt
- var continuityResetAt = window.resetsAt
- if let previous, previous.isVerified, valid,
- previous.usedPercentage <= window.usedPercentage,
- previous.accountFingerprint == accountFingerprint,
- HistoryAnalytics.sameReset(previous.continuityResetAt ?? previous.resetsAt, window.resetsAt),
- previous.durationMinutes == window.durationMinutes,
- capturedAt.timeIntervalSince(previous.sampledAt) <= 1800,
- let boundary = previous.continuityStartedAt
- {
- continuityStartedAt = boundary
- continuityResetAt = previous.continuityResetAt ?? previous.resetsAt
- }
- try run(handle, sql) { statement in
- bind(statement, 1, snapshot.provider.rawValue)
- bind(statement, 2, window.id)
- sqlite3_bind_int64(statement, 3, sampledAt)
- bind(statement, 4, window.displayName)
- bind(statement, 5, window.durationMinutes.map(Int64.init))
- sqlite3_bind_double(statement, 6, window.usedPercentage.isFinite ? window.usedPercentage : 0)
- if let reset = window.resetsAt {
- sqlite3_bind_double(statement, 7, reset.timeIntervalSince1970)
- } else {
- sqlite3_bind_null(statement, 7)
- }
- sqlite3_bind_double(statement, 8, capturedAt.timeIntervalSince1970)
- sqlite3_bind_double(statement, 9, continuityStartedAt.timeIntervalSince1970)
- sqlite3_bind_int(statement, 10, valid ? 1 : 0)
- if let reset = continuityResetAt {
- sqlite3_bind_double(statement, 11, reset.timeIntervalSince1970)
- } else {
- sqlite3_bind_null(statement, 11)
- }
- bind(statement, 12, accountFingerprint)
- }
- try archivePace(
- handle, snapshot: snapshot, window: window, previous: previous, now: capturedAt,
- accountFingerprint: accountFingerprint)
- }
- }
-
- private static func latestQuota(
- _ handle: OpaquePointer, provider: UsageProvider, windowID: String
- ) throws -> HistoryQuotaRow? {
- let sql = """
- SELECT COALESCE(observed_at, sampled_at), used_percentage, resets_at, duration_minutes,
- verified, continuity_started_at, continuity_reset_at, account_fingerprint
- FROM quota_samples WHERE provider = ?1 AND window_id = ?2 ORDER BY sampled_at DESC LIMIT 1;
- """
- var row: HistoryQuotaRow?
- try query(handle, sql) { statement in
- bind(statement, 1, provider.rawValue)
- bind(statement, 2, windowID)
- } each: { statement in
- row = HistoryQuotaRow(
- sampledAt: Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)), provider: provider,
- windowID: windowID, displayName: nil, usedPercentage: sqlite3_column_double(statement, 1),
- resetsAt: date(statement, 2),
- durationMinutes: sqlite3_column_type(statement, 3) == SQLITE_NULL
- ? nil : Int(sqlite3_column_int64(statement, 3)),
- isVerified: sqlite3_column_int(statement, 4) == 1, continuityStartedAt: date(statement, 5),
- continuityResetAt: date(statement, 6), accountFingerprint: optionalText(statement, 7))
- }
- return row
+ struct Registry {
+ var preparations: [String: Int] = [:]
+ var lastEntryWasReadOnly: Bool?
+ var entries = 0
}
- private static func writeTokens(_ handle: OpaquePointer, snapshot: ProviderUsageSnapshot, now: Date) throws {
- let sql = """
- INSERT INTO daily_tokens (day, provider, model, tokens, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5)
- ON CONFLICT(day, provider, model) DO UPDATE SET
- tokens = MAX(daily_tokens.tokens, excluded.tokens),
- updated_at = excluded.updated_at;
- """
- for row in tokenRows(for: snapshot) {
- try run(handle, sql) { statement in
- bind(statement, 1, row.day)
- bind(statement, 2, row.provider.rawValue)
- bind(statement, 3, row.model)
- sqlite3_bind_int64(statement, 4, Int64(row.tokens))
- sqlite3_bind_int64(statement, 5, Int64(now.timeIntervalSince1970))
- }
- }
- }
+ private static let registry = OSAllocatedUnfairLock(initialState: Registry())
- static func tokenRows(for snapshot: ProviderUsageSnapshot) -> [HistoryTokenRow] {
- if snapshot.modelBuckets.isEmpty {
- return snapshot.dailyUsage
- .filter { $0.tokens >= 0 }
- .map { HistoryTokenRow(day: $0.day, provider: snapshot.provider, model: "all", tokens: $0.tokens) }
- }
- var totals: [String: [String: Int]] = [:]
- for item in snapshot.modelBuckets where item.tokens >= 0 {
- totals[item.day, default: [:]][item.model, default: 0] += item.tokens
- }
- return
- totals
- .flatMap { day, models in
- models.map { HistoryTokenRow(day: day, provider: snapshot.provider, model: $0.key, tokens: $0.value) }
- }
- .sorted { left, right in
- if left.day != right.day { return left.day < right.day }
- return left.model < right.model
- }
+ static var preparedPaths: Set {
+ registry.withLock { Set($0.preparations.keys) }
}
- private static func prune(_ handle: OpaquePointer, now: Date) throws {
- let cutoff = Int64(now.addingTimeInterval(-Double(quotaRetentionDays) * 86_400).timeIntervalSince1970)
- try run(handle, "DELETE FROM quota_samples WHERE sampled_at < ?1;") { statement in
- sqlite3_bind_int64(statement, 1, cutoff)
- }
- }
-}
-
-extension UsageHistoryStore {
- private static var transientDestructor: sqlite3_destructor_type {
- unsafeBitCast(Int(-1), to: sqlite3_destructor_type.self)
+ static func schemaPreparations(for path: String) -> Int {
+ registry.withLock { $0.preparations[path] ?? 0 }
}
- private static func open(_ url: URL) throws -> OpaquePointer {
- let directory = url.deletingLastPathComponent()
- try FileManager.default.createDirectory(
- at: directory,
- withIntermediateDirectories: true,
- attributes: [.posixPermissions: 0o700]
- )
- var handle: OpaquePointer?
- let flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX
- guard sqlite3_open_v2(url.path, &handle, flags, nil) == SQLITE_OK, let handle else {
- let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? url.path
- if let handle { sqlite3_close(handle) }
- throw UsageHistoryError.openFailed(message)
- }
- sqlite3_busy_timeout(handle, 3_000)
- chmod(url.path, S_IRUSR | S_IWUSR)
- return handle
+ static var lastEntryWasReadOnly: Bool? {
+ registry.withLock { $0.lastEntryWasReadOnly }
}
- private static func exec(_ handle: OpaquePointer, _ sql: String) throws {
- var error: UnsafeMutablePointer?
- guard sqlite3_exec(handle, sql, nil, nil, &error) == SQLITE_OK else {
- let message = error.map { String(cString: $0) } ?? sql
- sqlite3_free(error)
- throw UsageHistoryError.statementFailed(message)
- }
+ public static var entryCount: Int {
+ registry.withLock { $0.entries }
}
- private static func run(
- _ handle: OpaquePointer,
- _ sql: String,
- bindings: (OpaquePointer) -> Void
- ) throws {
- var statement: OpaquePointer?
- guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
- throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
- }
- defer { sqlite3_finalize(statement) }
- bindings(statement)
- guard sqlite3_step(statement) == SQLITE_DONE else {
- throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
- }
+ static func write(_ url: URL, _ body: (SQLiteConnection) throws -> T) throws -> T {
+ let connection = try SQLiteConnection.open(url, readOnly: false)
+ defer { connection.close() }
+ recordEntry(readOnly: connection.isReadOnly)
+ try prepareIfNeeded(connection, path: url.path)
+ return try body(connection)
}
- private static func query(
- _ handle: OpaquePointer,
- _ sql: String,
- bindings: (OpaquePointer) -> Void,
- each: (OpaquePointer) -> Void
- ) throws {
- var statement: OpaquePointer?
- guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else {
- throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
- }
- defer { sqlite3_finalize(statement) }
- bindings(statement)
- while true {
- switch sqlite3_step(statement) {
- case SQLITE_ROW: each(statement)
- case SQLITE_DONE: return
- default: throw UsageHistoryError.statementFailed(String(cString: sqlite3_errmsg(handle)))
- }
+ static func read(_ url: URL, _ body: (SQLiteConnection) throws -> T) throws -> T {
+ guard isPrepared(url.path), FileManager.default.fileExists(atPath: url.path) else {
+ return try write(url, body)
}
- }
-
- private static func bind(_ statement: OpaquePointer, _ index: Int32, _ value: String?) {
- guard let value else {
- sqlite3_bind_null(statement, index)
- return
+ let connection = try SQLiteConnection.open(url, readOnly: true)
+ defer { connection.close() }
+ recordEntry(readOnly: connection.isReadOnly)
+ if try schemaVersion(connection) < Schema.version {
+ return try write(url, body)
}
- sqlite3_bind_text(statement, index, value, -1, transientDestructor)
+ return try body(connection)
}
- private static func bind(_ statement: OpaquePointer, _ index: Int32, _ value: Int64?) {
- guard let value else {
- sqlite3_bind_null(statement, index)
- return
+ private static func recordEntry(readOnly: Bool) {
+ registry.withLock {
+ $0.lastEntryWasReadOnly = readOnly
+ $0.entries += 1
}
- sqlite3_bind_int64(statement, index, value)
}
- private static func text(_ statement: OpaquePointer, _ index: Int32) -> String {
- optionalText(statement, index) ?? ""
+ private static func isPrepared(_ path: String) -> Bool {
+ registry.withLock { $0.preparations[path] != nil }
}
- private static func optionalText(_ statement: OpaquePointer, _ index: Int32) -> String? {
- guard let raw = sqlite3_column_text(statement, index) else { return nil }
- return String(cString: raw)
+ private static func markPrepared(_ path: String) {
+ registry.withLock { $0.preparations[path, default: 0] += 1 }
}
- private static func date(_ statement: OpaquePointer, _ index: Int32) -> Date? {
- guard sqlite3_column_type(statement, index) != SQLITE_NULL else { return nil }
- return Date(timeIntervalSince1970: sqlite3_column_double(statement, index))
+ private static func prepareIfNeeded(_ connection: SQLiteConnection, path: String) throws {
+ if isPrepared(path), try schemaVersion(connection) >= Schema.version { return }
+ try prepareSchema(connection)
+ markPrepared(path)
}
}
diff --git a/Sources/TokenGaugeCore/UsageModels.swift b/Sources/TokenGaugeCore/UsageModels.swift
index d0c70ba..2cb7a81 100644
--- a/Sources/TokenGaugeCore/UsageModels.swift
+++ b/Sources/TokenGaugeCore/UsageModels.swift
@@ -30,6 +30,13 @@ public struct QuotaWindow: Codable, Equatable, Identifiable, Sendable {
100 - usedPercentage
}
+ public static func displayOrder(_ left: QuotaWindow, _ right: QuotaWindow) -> Bool {
+ let leftDuration = left.durationMinutes ?? Int.max
+ let rightDuration = right.durationMinutes ?? Int.max
+ if leftDuration != rightDuration { return leftDuration < rightDuration }
+ return left.id < right.id
+ }
+
public var startsAt: Date? {
guard let resetsAt, let durationMinutes else { return nil }
return resetsAt.addingTimeInterval(-Double(durationMinutes) * 60)
@@ -156,12 +163,7 @@ public struct ProviderUsageSnapshot: Codable, Equatable, Sendable {
}
public var longestWindow: QuotaWindow? {
- windows.max { left, right in
- let leftDuration = left.durationMinutes ?? 0
- let rightDuration = right.durationMinutes ?? 0
- if leftDuration != rightDuration { return leftDuration < rightDuration }
- return left.usedPercentage < right.usedPercentage
- }
+ windows.max(by: QuotaWindow.displayOrder)
}
}
diff --git a/Tests/TokenGaugeAppTests/ActivityChartScopeTests.swift b/Tests/TokenGaugeAppTests/ActivityChartScopeTests.swift
deleted file mode 100644
index e744d63..0000000
--- a/Tests/TokenGaugeAppTests/ActivityChartScopeTests.swift
+++ /dev/null
@@ -1,82 +0,0 @@
-import Foundation
-import TokenGaugeCore
-import XCTest
-
-@testable import TokenGaugeApp
-
-@MainActor
-final class ActivityChartScopeTests: XCTestCase {
- private var calendar: Calendar {
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = TimeZone(secondsFromGMT: 0)!
- return calendar
- }
-
- private var today: Date {
- calendar.date(from: DateComponents(year: 2026, month: 9, day: 7))!
- }
-
- func testHiddenProviderCannotAffectScaleOrSummary() {
- for provider in UsageProvider.allCases {
- let other: UsageProvider = provider == .claude ? .codex : .claude
- let small = snapshot(provider, tokens: 50)
- let huge = snapshot(other, tokens: 9_000_000)
- let data = ActivityChartData(
- claude: provider == .claude ? small : huge,
- codex: provider == .codex ? small : huge,
- providers: [provider], now: today, calendar: calendar)
- XCTAssertEqual(data.providers, [provider])
- XCTAssertEqual(data.maximumTokens, 50)
- let day = data.days.last!
- let summary = data.summary(for: day, locale: Locale(identifier: "en_US"))
- XCTAssertTrue(summary.contains(provider == .claude ? "Claude" : "Codex"))
- XCTAssertFalse(summary.contains(provider == .claude ? "Codex" : "Claude"))
- XCTAssertTrue(summary.contains("50"))
- XCTAssertFalse(summary.contains("9M"))
- XCTAssertEqual(Double(day.tokens(for: provider)) / Double(data.maximumTokens), 1)
- }
- }
-
- func testUnifiedIncludesBothAndDeduplicatesDaysAndProviders() {
- let data = ActivityChartData(
- claude: snapshot(.claude, tokens: 50, duplicateTokens: 25),
- codex: snapshot(.codex, tokens: 100), providers: [.claude, .codex, .claude],
- now: today, calendar: calendar)
- XCTAssertEqual(data.providers, [.claude, .codex])
- XCTAssertEqual(data.days.count, 7)
- XCTAssertEqual(Set(data.days.map(\.id)).count, 7)
- XCTAssertEqual(data.days.first?.id, "2026-09-01")
- XCTAssertEqual(data.days.last?.id, "2026-09-07")
- XCTAssertEqual(data.maximumTokens, 100)
- let day = data.days.last!
- XCTAssertEqual(Double(day.claudeTokens) / Double(data.maximumTokens), 0.5)
- let summary = data.summary(for: day, locale: Locale(identifier: "en_US"))
- XCTAssertTrue(summary.contains("Claude 50"))
- XCTAssertTrue(summary.contains("Codex 100"))
- }
-
- func testSelectionSurvivesInputChangeAndFallsBackWhenDayLeavesWindow() {
- let first = ActivityChartData(claude: nil, codex: nil, now: today, calendar: calendar)
- let selectedKey = first.days.first!.id
- let changed = ActivityChartData(
- claude: snapshot(.claude, tokens: 10), codex: nil, providers: [.claude],
- now: today, calendar: calendar)
- XCTAssertEqual(changed.selectedDay(key: selectedKey)?.id, selectedKey)
- let tomorrow = ActivityChartData(
- claude: nil, codex: nil, now: calendar.date(byAdding: .day, value: 1, to: today)!, calendar: calendar)
- XCTAssertEqual(tomorrow.selectedDay(key: selectedKey)?.id, "2026-09-08")
- XCTAssertEqual(tomorrow.selectedDay(key: "2026-09-07")?.id, "2026-09-07")
- XCTAssertEqual(tomorrow.maximumTokens, 1)
- XCTAssertEqual(tomorrow.providers, [.claude, .codex])
- }
-
- private func snapshot(
- _ provider: UsageProvider, tokens: Int, duplicateTokens: Int? = nil
- ) -> ProviderUsageSnapshot {
- var daily = [DailyTokenUsage(day: "2026-09-07", tokens: tokens)]
- if let duplicateTokens { daily.append(DailyTokenUsage(day: "2026-09-07", tokens: duplicateTokens)) }
- return ProviderUsageSnapshot(
- provider: provider, windows: [], dailyUsage: daily, summary: nil,
- availableResetCredits: nil, creditBalance: nil, capturedAt: today)
- }
-}
diff --git a/Tests/TokenGaugeAppTests/AppPreferencesTests.swift b/Tests/TokenGaugeAppTests/AppPreferencesTests.swift
new file mode 100644
index 0000000..02b24e7
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/AppPreferencesTests.swift
@@ -0,0 +1,115 @@
+import Foundation
+import TokenGaugeCore
+import XCTest
+
+@testable import TokenGaugeApp
+
+@MainActor
+final class AppPreferencesTests: XCTestCase {
+ func testPreferenceKeysAreUnchanged() {
+ XCTAssertEqual(AppPreferences.Key.primaryProvider, "primaryProvider")
+ XCTAssertEqual(AppPreferences.Key.displayMode, "displayMode")
+ XCTAssertEqual(AppPreferences.Key.menuBarSize, "menuBarSize")
+ XCTAssertEqual(AppPreferences.Key.quotaPanelStyle, "quotaPanelStyle")
+ XCTAssertEqual(AppPreferences.Key.quotaMenuBarStyle, "quotaMenuBarStyle")
+ XCTAssertEqual(AppPreferences.Key.quotaAnimateChanges, "quotaAnimateChanges")
+ XCTAssertEqual(AppPreferences.Key.historyMode, "historyMode")
+ XCTAssertEqual(AppPreferences.Key.showHourlyPace, "showHourlyPace")
+ XCTAssertEqual(AppPreferences.Key.showLunaReserve, "showLunaReserve")
+ XCTAssertEqual(AppPreferences.Key.hiddenClaudeWindows, "hiddenClaudeWindows")
+ XCTAssertEqual(AppPreferences.Key.claudeMenuBarSource, "claudeMenuBarSource")
+ XCTAssertEqual(AppPreferences.Key.claudeAutomaticRecovery, "claudeAutomaticRecovery")
+ XCTAssertEqual(AppPreferences.Key.claudeRecoveryNextAttempt, "claudeRecoveryNextAttempt")
+ XCTAssertEqual(AppPreferences.Key.claudeRecoveryFailures, "claudeRecoveryFailures")
+ XCTAssertEqual(AppPreferences.Key.appLanguage, "appLanguage")
+ XCTAssertEqual(AppPreferences.Key.codexExecutablePath, "codexExecutablePath")
+ XCTAssertEqual(AppPreferences.Key.cancelledAt(.claude), "claudeCancelledAt")
+ XCTAssertEqual(AppPreferences.Key.cancelledAt(.codex), "codexCancelledAt")
+ XCTAssertEqual(AppPreferences.Key.cancellationDailyBaseline(.claude), "claudeCancellationDailyBaseline")
+ XCTAssertEqual(AppPreferences.Key.cancellationDailyBaseline(.codex), "codexCancellationDailyBaseline")
+ }
+
+ func testLegacyDefaultsAreReadAndWrittenWithTheSameShapes() {
+ withDefaults { defaults in
+ let cancelledAt = Date(timeIntervalSince1970: 1_800_000_000)
+ defaults.set("claude", forKey: "primaryProvider")
+ defaults.set("unified", forKey: "displayMode")
+ defaults.set("small", forKey: "menuBarSize")
+ defaults.set("compact", forKey: "quotaPanelStyle")
+ defaults.set("bars", forKey: "quotaMenuBarStyle")
+ defaults.set(false, forKey: "quotaAnimateChanges")
+ defaults.set("calendar", forKey: "historyMode")
+ defaults.set(false, forKey: "showHourlyPace")
+ defaults.set(false, forKey: "showLunaReserve")
+ defaults.set(["modelWeekly", "session"], forKey: "hiddenClaudeWindows")
+ defaults.set("weekly", forKey: "claudeMenuBarSource")
+ defaults.set(true, forKey: "claudeAutomaticRecovery")
+ defaults.set(cancelledAt, forKey: "codexCancelledAt")
+ defaults.set(["2026-09-17": 12], forKey: "codexCancellationDailyBaseline")
+
+ let store = UsageStore(defaults: defaults)
+ XCTAssertEqual(store.primaryProvider, .claude)
+ XCTAssertEqual(store.displayMode, .unified)
+ XCTAssertEqual(store.menuBarSize, .small)
+ XCTAssertEqual(store.panelStyle, .compact)
+ XCTAssertEqual(store.menuBarStyle, .bars)
+ XCTAssertFalse(store.animateChanges)
+ XCTAssertEqual(store.historyMode, .calendar)
+ XCTAssertFalse(store.showHourlyPace)
+ XCTAssertFalse(store.showLunaReserve)
+ XCTAssertEqual(store.hiddenClaudeWindows, [.modelWeekly, .session])
+ XCTAssertEqual(store.claudeMenuBarSource, .weekly)
+ XCTAssertTrue(store.claudeAutomaticRecovery)
+ XCTAssertEqual(store.codexCancelledAt, cancelledAt)
+ XCTAssertEqual(store.codex.status, .cancelled)
+ XCTAssertTrue(store.recoveryAuthorization.isAllowed)
+
+ store.menuBarSize = .medium
+ store.setClaudeWindow(.session, visible: true)
+ store.claudeAutomaticRecovery = false
+ store.setCancelled(true, for: .claude)
+ XCTAssertEqual(defaults.string(forKey: "menuBarSize"), "medium")
+ XCTAssertEqual(defaults.stringArray(forKey: "hiddenClaudeWindows"), ["modelWeekly"])
+ XCTAssertFalse(defaults.bool(forKey: "claudeAutomaticRecovery"))
+ XCTAssertNotNil(defaults.object(forKey: "claudeCancelledAt") as? Date)
+ XCTAssertNil(defaults.object(forKey: "claudeCancellationDailyBaseline"))
+ XCTAssertEqual(
+ defaults.dictionary(forKey: "codexCancellationDailyBaseline") as? [String: Int], ["2026-09-17": 12])
+ }
+ }
+
+ func testFreshDefaultsKeepTheShippedFallbacks() {
+ withDefaults { defaults in
+ let preferences = AppPreferences(defaults: defaults)
+ XCTAssertEqual(preferences.primaryProvider, .codex)
+ XCTAssertEqual(preferences.displayMode, .codex)
+ XCTAssertEqual(preferences.menuBarSize, .large)
+ XCTAssertEqual(preferences.panelStyle, .standard)
+ XCTAssertEqual(preferences.menuBarStyle, .numbers)
+ XCTAssertTrue(preferences.animateChanges)
+ XCTAssertEqual(preferences.historyMode, .recent)
+ XCTAssertTrue(preferences.showHourlyPace)
+ XCTAssertTrue(preferences.showLunaReserve)
+ XCTAssertTrue(preferences.hiddenClaudeWindows.isEmpty)
+ XCTAssertEqual(preferences.claudeMenuBarSource, .automatic)
+ XCTAssertFalse(preferences.claudeAutomaticRecovery)
+ XCTAssertFalse(preferences.claudeAutomaticRecoveryDecided)
+ XCTAssertNil(preferences.claudeRecoveryNextAttempt)
+ XCTAssertEqual(preferences.claudeRecoveryFailures, 0)
+ XCTAssertNil(preferences.cancelledAt(.claude))
+ XCTAssertNil(preferences.cancellationDailyBaseline(.codex))
+
+ preferences.claudeAutomaticRecovery = false
+ XCTAssertTrue(preferences.claudeAutomaticRecoveryDecided)
+ preferences.claudeRecoveryNextAttempt = nil
+ XCTAssertNil(defaults.object(forKey: "claudeRecoveryNextAttempt"))
+ }
+ }
+
+ private func withDefaults(_ body: (UserDefaults) -> Void) {
+ let suite = "TokenGauge.preferences-tests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ body(defaults)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/BlockingWorkTests.swift b/Tests/TokenGaugeAppTests/BlockingWorkTests.swift
new file mode 100644
index 0000000..e1ba0f6
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/BlockingWorkTests.swift
@@ -0,0 +1,63 @@
+import Foundation
+import XCTest
+import os
+
+@testable import TokenGaugeApp
+
+final class BlockingWorkTests: XCTestCase {
+ private struct Failure: Error {}
+
+ func testBlockingWorkRunsOffCooperativePool() async throws {
+ let started = ProcessInfo.processInfo.systemUptime
+ let values = try await withThrowingTaskGroup(of: Int.self) { group in
+ for index in 0..<8 {
+ group.addTask {
+ try await BlockingWork.run {
+ usleep(150_000)
+ return index
+ }
+ }
+ }
+ var seen: Set = []
+ for try await value in group { seen.insert(value) }
+ return seen
+ }
+
+ XCTAssertEqual(values, Set(0..<8))
+ XCTAssertLessThan(ProcessInfo.processInfo.systemUptime - started, 0.9)
+ }
+
+ func testRunPropagatesThrownErrors() async {
+ do {
+ _ = try await BlockingWork.run { () -> Int in throw Failure() }
+ XCTFail("Expected the blocking work to rethrow")
+ } catch {
+ XCTAssertTrue(error is Failure)
+ }
+ }
+
+ func testWaitForResultReturnsTheValueAndPropagatesErrors() throws {
+ XCTAssertEqual(try BlockingWork.waitForResult(timeout: 5) { 42 }, 42)
+ XCTAssertThrowsError(try BlockingWork.waitForResult(timeout: 5) { () -> Int in throw Failure() }) { error in
+ XCTAssertTrue(error is Failure)
+ }
+ }
+
+ func testWaitForResultTimesOutAndCancelsTheOperation() {
+ let cancelled = expectation(description: "operation cancelled")
+ XCTAssertThrowsError(
+ try BlockingWork.waitForResult(timeout: 0.2) { () -> Int in
+ do {
+ try await Task.sleep(for: .seconds(5))
+ } catch {
+ cancelled.fulfill()
+ throw error
+ }
+ return 1
+ }
+ ) { error in
+ XCTAssertTrue(error is BlockingWork.TimedOut)
+ }
+ wait(for: [cancelled], timeout: 2)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift
index b4d7a0c..85cc973 100644
--- a/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift
+++ b/Tests/TokenGaugeAppTests/ClaudeAccountHeaderRenderingTests.swift
@@ -10,10 +10,7 @@ final class ClaudeAccountHeaderRenderingTests: XCTestCase {
private let label = "very.long.account.name.for.layout@example.com"
func testLongAccountLabelKeepsTheHeaderOnOneRowAndTruncatesOnlyItself() throws {
- let language = LocalizationManager.shared.language
- defer { LocalizationManager.shared.language = language }
- for language in AppLanguage.allCases {
- LocalizationManager.shared.language = language
+ try withEachLanguage { _ in
let plain = try render(card(accountLabel: nil))
let labelled = try render(card(accountLabel: label))
let longer = try render(card(accountLabel: label + label))
@@ -25,17 +22,16 @@ final class ClaudeAccountHeaderRenderingTests: XCTestCase {
}
func testPreviewStoresExposeTheAccountLabelWithoutReadingTheRealConfiguration() throws {
- let suite = "TokenGauge.account.\(UUID().uuidString)"
- let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
- defer { defaults.removePersistentDomain(forName: suite) }
- let store = UsageStore(defaults: defaults, initialSnapshots: [snapshot()], historyReadsEnabled: false)
-
- XCTAssertNil(store.claudeAccountLabel)
- store.setPreviewAccountLabel(label)
- XCTAssertEqual(store.claudeAccountLabel, label)
- XCTAssertNil(store.claudeAccountFingerprint)
- let view = NSHostingView(rootView: PopoverView(store: store, showSettings: {}, showAbout: {}))
- XCTAssertLessThanOrEqual(view.fittingSize.height, Theme.Layout.maximumPanelHeight)
+ try withDefaults { defaults in
+ let store = Fixture.store(defaults: defaults, snapshots: [snapshot()])
+
+ XCTAssertNil(store.claudeAccountLabel)
+ store.setPreviewAccountLabel(label)
+ XCTAssertEqual(store.claudeAccountLabel, label)
+ XCTAssertNil(store.claudeAccountFingerprint)
+ let size = fittingSize(PopoverView(store: store, showSettings: {}, showAbout: {}))
+ XCTAssertLessThanOrEqual(size.height, Theme.Layout.maximumPanelHeight)
+ }
}
private func card(accountLabel: String?) -> some View {
@@ -47,25 +43,12 @@ final class ClaudeAccountHeaderRenderingTests: XCTestCase {
}
private func snapshot() -> ProviderUsageSnapshot {
- ProviderUsageSnapshot(
- provider: .claude,
+ Fixture.snapshot(
+ .claude,
windows: [
- QuotaWindow(
+ Fixture.window(
id: "seven_day", usedPercentage: 30, resetsAt: Date().addingTimeInterval(86_400),
- durationMinutes: 10_080, displayName: nil)
- ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil,
- capturedAt: Date().addingTimeInterval(-600))
- }
-
- private func render(_ content: some View) throws -> NSBitmapImageRep {
- let view = NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false))
- view.frame = NSRect(origin: .zero, size: view.fittingSize)
- let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false)
- window.contentView = view
- defer { window.contentView = nil }
- view.layoutSubtreeIfNeeded()
- let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
- view.cacheDisplay(in: view.bounds, to: bitmap)
- return bitmap
+ durationMinutes: 10_080)
+ ], capturedAt: Date().addingTimeInterval(-600))
}
}
diff --git a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift
index 0700117..7db1445 100644
--- a/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift
+++ b/Tests/TokenGaugeAppTests/ClaudeHistoryProcessTests.swift
@@ -13,7 +13,7 @@ final class ClaudeHistoryProcessTests: XCTestCase {
model: "claude-synthetic", tokens: index + 1
)
}
- let payload = try JSONEncoder().encode(buckets)
+ let payload = try JSONEncoder().encode(CaptureCollection(buckets: buckets, effortRecords: 2000))
XCTAssertGreaterThan(payload.count, 131_072)
let file = temporaryFile()
defer { try? FileManager.default.removeItem(at: file) }
@@ -27,6 +27,25 @@ final class ClaudeHistoryProcessTests: XCTestCase {
XCTAssertEqual(actual, buckets)
}
+ func testCollectReceiptSkipsTheEffortHelperOnce() throws {
+ let sibling = try XCTUnwrap(Bundle.main.executableURL).deletingLastPathComponent()
+ .appending(path: "TokenGaugeCapture")
+ try XCTSkipIf(FileManager.default.isExecutableFile(atPath: sibling.path))
+ let home = workingDirectory()
+ _ = try? EffortHistoryClient.collect(homeDirectory: home)
+
+ XCTAssertEqual(try run(#"printf '{"buckets":[],"effortRecords":3}'"#), [])
+ XCTAssertNoThrow(try EffortHistoryClient.collect(homeDirectory: home))
+ XCTAssertThrowsError(try EffortHistoryClient.collect(homeDirectory: home)) { error in
+ XCTAssertEqual(error as? UsageDataError, .executableNotFound)
+ }
+
+ XCTAssertEqual(try run(#"printf '{"buckets":[]}'"#), [])
+ XCTAssertThrowsError(try EffortHistoryClient.collect(homeDirectory: home)) { error in
+ XCTAssertEqual(error as? UsageDataError, .executableNotFound)
+ }
+ }
+
func testFailedHelperDoesNotExposeStderr() {
XCTAssertThrowsError(try run("printf 'synthetic-private-diagnostic' >&2; exit 9")) { error in
XCTAssertEqual(error as? UsageDataError, .processFailed("History helper exited with status 9"))
diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift
index d815a05..dee3fee 100644
--- a/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift
+++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryLiveTests.swift
@@ -5,7 +5,7 @@ import XCTest
@testable import TokenGaugeApp
final class ClaudeRecoveryLiveTests: XCTestCase {
- func testOptInRealCLIStartupAndUsageRetry() throws {
+ func testOptInRealCLIStartupAndUsageRetry() async throws {
guard ProcessInfo.processInfo.environment["TOKENGAUGE_LIVE_RECOVERY_QA"] == "1" else {
throw XCTSkip("Requires explicit local Claude recovery QA authorization")
}
@@ -13,11 +13,11 @@ final class ClaudeRecoveryLiveTests: XCTestCase {
let executable = try XCTUnwrap(ClaudeSessionRecovery.executable(homeDirectory: home))
let started = ProcessInfo.processInfo.systemUptime
var reads = 0
- let result = ClaudeUsageClient.readAccount(
+ let result = await ClaudeUsageClient.readAccount(
fetch: {
reads += 1
if reads == 1 { throw ClaudeAccountUsageError.credentialExpired }
- return try ClaudeAccountUsageClient(homeDirectory: home).fetch(
+ return try await ClaudeAccountUsageClient(homeDirectory: home).fetch(
identity: ClaudeAccountIdentityReader.current(homeDirectory: home))
},
recover: {
diff --git a/Tests/TokenGaugeAppTests/ClaudeRecoveryTests.swift b/Tests/TokenGaugeAppTests/ClaudeRecoveryTests.swift
index 139bfb3..1574d08 100644
--- a/Tests/TokenGaugeAppTests/ClaudeRecoveryTests.swift
+++ b/Tests/TokenGaugeAppTests/ClaudeRecoveryTests.swift
@@ -5,7 +5,7 @@ import XCTest
@testable import TokenGaugeApp
final class ClaudeRecoveryTests: XCTestCase {
- func testExpiredCredentialRecoversThenReadsEveryRealWindowAgain() throws {
+ func testExpiredCredentialRecoversThenReadsEveryRealWindowAgain() async throws {
var reads = 0
var recoveries = 0
let expected = ClaudeAccountSnapshot(
@@ -13,7 +13,7 @@ final class ClaudeRecoveryTests: XCTestCase {
windows: ["five_hour", "seven_day", "seven_day_fable"].map {
QuotaWindow(id: $0, usedPercentage: 88, resetsAt: nil, durationMinutes: nil, displayName: nil)
})
- let result = ClaudeUsageClient.readAccount(
+ let result = await ClaudeUsageClient.readAccount(
fetch: {
reads += 1
if reads == 1 { throw ClaudeAccountUsageError.credentialExpired }
@@ -27,12 +27,12 @@ final class ClaudeRecoveryTests: XCTestCase {
XCTAssertEqual(recoveries, 1)
}
- func testMissingRevokedDeniedAndNetworkFailuresDoNotStartClaude() {
+ func testMissingRevokedDeniedAndNetworkFailuresDoNotStartClaude() async {
for error in [
ClaudeAccountUsageError.authenticationRequired, .accessDenied, .unavailable,
] {
var recoveries = 0
- let result = ClaudeUsageClient.readAccount(
+ let result = await ClaudeUsageClient.readAccount(
fetch: { throw error },
recover: {
recoveries += 1; return true
@@ -42,9 +42,9 @@ final class ClaudeRecoveryTests: XCTestCase {
}
}
- func testDeclinedOrFailedRecoveryDoesNotRetryAndRetainsExpiryState() {
+ func testDeclinedOrFailedRecoveryDoesNotRetryAndRetainsExpiryState() async {
var reads = 0
- let result = ClaudeUsageClient.readAccount(
+ let result = await ClaudeUsageClient.readAccount(
fetch: {
reads += 1; throw ClaudeAccountUsageError.credentialExpired
}, recover: { false })
@@ -54,10 +54,10 @@ final class ClaudeRecoveryTests: XCTestCase {
XCTAssertEqual(reads, 1)
}
- func testStartupSuccessCannotMaskARejectedUsageRequestOrLoop() {
+ func testStartupSuccessCannotMaskARejectedUsageRequestOrLoop() async {
var reads = 0
var recoveries = 0
- let result = ClaudeUsageClient.readAccount(
+ let result = await ClaudeUsageClient.readAccount(
fetch: {
reads += 1
throw reads == 1 ? ClaudeAccountUsageError.credentialExpired : .authenticationRequired
diff --git a/Tests/TokenGaugeAppTests/ClaudeUsageClientConcurrencyTests.swift b/Tests/TokenGaugeAppTests/ClaudeUsageClientConcurrencyTests.swift
new file mode 100644
index 0000000..ff4cb57
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/ClaudeUsageClientConcurrencyTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+import TokenGaugeCore
+import XCTest
+
+@testable import TokenGaugeApp
+
+final class ClaudeUsageClientConcurrencyTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 2_000_000_000)
+
+ func testHistoryAndAccountFetchOverlap() async throws {
+ let snapshot = ClaudeAccountSnapshot(
+ capturedAt: now,
+ windows: [
+ QuotaWindow(
+ id: "seven_day", usedPercentage: 40, resetsAt: now.addingTimeInterval(86_400),
+ durationMinutes: 10_080, displayName: nil)
+ ]
+ )
+ let buckets = [ModelTokenBucket(day: "2026-09-18", hourStart: now, model: "fable", tokens: 120)]
+ let started = ProcessInfo.processInfo.systemUptime
+
+ let result = await ClaudeUsageClient.fetch(
+ history: {
+ try? await Task.sleep(for: .milliseconds(200))
+ return buckets
+ },
+ account: {
+ try? await Task.sleep(for: .milliseconds(200))
+ return .success(snapshot)
+ },
+ cached: { nil },
+ capture: { nil },
+ now: now
+ )
+ let elapsed = ProcessInfo.processInfo.systemUptime - started
+
+ XCTAssertLessThan(elapsed, 0.35)
+ XCTAssertEqual(result.access, .live)
+ XCTAssertEqual(result.snapshot.windows.map(\.id), ["seven_day"])
+ XCTAssertEqual(result.snapshot.modelBuckets, buckets)
+ XCTAssertTrue(result.snapshot.activityReadSucceeded)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/HistoryCalendarRenderingTests.swift b/Tests/TokenGaugeAppTests/HistoryCalendarRenderingTests.swift
index 84a4166..7892e3b 100644
--- a/Tests/TokenGaugeAppTests/HistoryCalendarRenderingTests.swift
+++ b/Tests/TokenGaugeAppTests/HistoryCalendarRenderingTests.swift
@@ -81,4 +81,175 @@ final class HistoryCalendarRenderingTests: XCTestCase {
XCTAssertEqual(selected.count, 1)
}
}
+
+ func testExitResetsToTodayOnlyWhileNothingIsPinned() throws {
+ let now = Date()
+ let interval = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: now)
+ let days = HistoryDashboardModel.makeDays(interval: interval, tokens: [], efforts: [])
+ let view = HistoryCalendarScrollView()
+ view.frame = NSRect(x: 0, y: 0, width: 440, height: Theme.Layout.historyCalendarHeight)
+ var exits = 0
+ var hovers: [String?] = []
+ view.calendar.update(
+ HistoryCalendarView(
+ days: days, providers: [.codex, .claude], selectedDayKey: nil, focusID: "year",
+ onHoverCard: { key, _ in hovers.append(key) }, onExitCalendar: { exits += 1 }
+ ) { _ in })
+ view.layout()
+ view.calendar.updateTrackingAreas()
+ XCTAssertTrue(view.calendar.trackingOptions.contains(.mouseEnteredAndExited))
+ XCTAssertTrue(view.calendar.trackingOptions.contains(.mouseMoved))
+ view.calendar.mouseExited(with: try exitEvent())
+ XCTAssertEqual(exits, 1)
+ XCTAssertEqual(hovers, [nil])
+ view.calendar.update(
+ HistoryCalendarView(
+ days: days, providers: [.codex, .claude], selectedDayKey: days[0].id, focusID: "year",
+ pinnedDayKey: days[0].id, onHoverCard: { key, _ in hovers.append(key) },
+ onExitCalendar: { exits += 1 }
+ ) { _ in })
+ view.calendar.mouseExited(with: try exitEvent())
+ XCTAssertEqual(exits, 1)
+ }
+
+ func testCardAnchorIsRelativeToTheCalendarAndCarriesTheRootBounds() throws {
+ let now = Date()
+ let interval = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: now)
+ let days = HistoryDashboardModel.makeDays(interval: interval, tokens: [], efforts: [])
+ let today = try XCTUnwrap(days.firstIndex { Calendar.current.isDate($0.date, inSameDayAs: now) })
+ let view = HistoryCalendarScrollView()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 440, height: Theme.Layout.historyCalendarHeight),
+ styleMask: [.borderless], backing: .buffered, defer: false)
+ window.contentView = view
+ defer { window.contentView = nil }
+ view.calendar.update(
+ HistoryCalendarView(days: days, providers: [.codex], selectedDayKey: days[today].id, focusID: "year") {
+ _ in
+ })
+ view.layout()
+ let anchor = try XCTUnwrap(view.calendar.cardAnchor(today))
+ let cell = view.calendar.cellRect(today)
+ XCTAssertEqual(anchor.bounds, CGRect(x: 0, y: 0, width: 440, height: Theme.Layout.historyCalendarHeight))
+ XCTAssertEqual(anchor.cell.minX, cell.minX - view.contentView.bounds.minX, accuracy: 0.5)
+ XCTAssertEqual(anchor.cell.minY, cell.minY, accuracy: 0.5)
+ XCTAssertEqual(anchor.cell.size, cell.size)
+ XCTAssertTrue(anchor.bounds.contains(anchor.cell))
+ window.contentView = nil
+ XCTAssertNil(view.calendar.cardAnchor(today))
+ }
+
+ func testScrollAndEmptyAreaClicksDismissTheCard() throws {
+ let now = Date()
+ let interval = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: now)
+ let days = HistoryDashboardModel.makeDays(interval: interval, tokens: [], efforts: [])
+ let view = HistoryCalendarScrollView()
+ view.frame = NSRect(x: 0, y: 0, width: 440, height: Theme.Layout.historyCalendarHeight)
+ var dismissals = 0
+ var hovers: [String?] = []
+ var pins: [String] = []
+ view.calendar.update(
+ HistoryCalendarView(
+ days: days, providers: [.codex], selectedDayKey: nil, focusID: "year",
+ onHoverCard: { key, _ in hovers.append(key) }, onPinCard: { key, _ in pins.append(key) },
+ onDismissCard: { dismissals += 1 }
+ ) { _ in })
+ view.layout()
+ view.calendar.scrollDidMove()
+ XCTAssertEqual(dismissals, 1)
+ XCTAssertEqual(hovers, [nil])
+ view.calendar.mouseDown(with: try mouseDown(at: NSPoint(x: -1000, y: 0), windowNumber: 0))
+ XCTAssertEqual(dismissals, 2)
+ XCTAssertTrue(pins.isEmpty)
+ }
+
+ func testPinnedEventFilterDismissesOutsideClicksAndSwallowsEscape() throws {
+ let now = Date()
+ let interval = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: now)
+ let days = HistoryDashboardModel.makeDays(interval: interval, tokens: [], efforts: [])
+ let view = HistoryCalendarScrollView()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 440, height: 600), styleMask: [.borderless], backing: .buffered,
+ defer: false)
+ let root = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 600))
+ window.contentView = root
+ defer { window.contentView = nil }
+ view.frame = NSRect(x: 0, y: 200, width: 440, height: Theme.Layout.historyCalendarHeight)
+ root.addSubview(view)
+ var dismissals = 0
+ view.calendar.update(
+ HistoryCalendarView(
+ days: days, providers: [.codex], selectedDayKey: days[0].id, focusID: "year",
+ pinnedDayKey: days[0].id, pinSafeFrames: [CGRect(x: 0, y: -100, width: 440, height: 60)],
+ onDismissCard: { dismissals += 1 }
+ ) { _ in })
+ view.layout()
+ let number = window.windowNumber
+ XCTAssertTrue(view.calendar.passesPinnedEvent(try mouseDown(at: NSPoint(x: 220, y: 250), windowNumber: number)))
+ XCTAssertEqual(dismissals, 0)
+ XCTAssertTrue(view.calendar.passesPinnedEvent(try mouseDown(at: NSPoint(x: 220, y: 380), windowNumber: number)))
+ XCTAssertEqual(dismissals, 0)
+ XCTAssertTrue(view.calendar.passesPinnedEvent(try mouseDown(at: NSPoint(x: 220, y: 100), windowNumber: number)))
+ XCTAssertEqual(dismissals, 1)
+ XCTAssertFalse(view.calendar.passesPinnedEvent(try key(53, windowNumber: number)))
+ XCTAssertEqual(dismissals, 2)
+ XCTAssertTrue(view.calendar.passesPinnedEvent(try key(36, windowNumber: number)))
+ XCTAssertEqual(dismissals, 2)
+ }
+
+ func testPinMonitorIsInstalledWhilePinnedAndRemovedOnDismissOrWindowLoss() throws {
+ let interval = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: Date())
+ let days = HistoryDashboardModel.makeDays(interval: interval, tokens: [], efforts: [])
+ let view = HistoryCalendarScrollView()
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 440, height: 600), styleMask: [.borderless], backing: .buffered,
+ defer: false)
+ let root = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 600))
+ window.contentView = root
+ defer { window.contentView = nil }
+ view.frame = NSRect(x: 0, y: 200, width: 440, height: Theme.Layout.historyCalendarHeight)
+ root.addSubview(view)
+ var dismissals = 0
+ func update(pinned: String?) {
+ view.calendar.update(
+ HistoryCalendarView(
+ days: days, providers: [.codex], selectedDayKey: days[0].id, focusID: "year",
+ pinnedDayKey: pinned, onDismissCard: { dismissals += 1 }
+ ) { _ in })
+ }
+ update(pinned: nil)
+ XCTAssertFalse(view.calendar.hasPinMonitor)
+ update(pinned: days[0].id)
+ XCTAssertTrue(view.calendar.hasPinMonitor)
+ XCTAssertTrue(window.firstResponder === view.calendar)
+ view.calendar.cancelOperation(nil)
+ XCTAssertEqual(dismissals, 1)
+ XCTAssertFalse(view.calendar.hasPinMonitor)
+ XCTAssertFalse(window.firstResponder === view.calendar)
+ update(pinned: days[0].id)
+ XCTAssertTrue(view.calendar.hasPinMonitor)
+ view.removeFromSuperview()
+ XCTAssertFalse(view.calendar.hasPinMonitor)
+ }
+
+ private func mouseDown(at location: NSPoint, windowNumber: Int) throws -> NSEvent {
+ try XCTUnwrap(
+ NSEvent.mouseEvent(
+ with: .leftMouseDown, location: location, modifierFlags: [], timestamp: 0, windowNumber: windowNumber,
+ context: nil, eventNumber: 0, clickCount: 1, pressure: 1))
+ }
+
+ private func key(_ code: UInt16, windowNumber: Int) throws -> NSEvent {
+ try XCTUnwrap(
+ NSEvent.keyEvent(
+ with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: windowNumber,
+ context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: code))
+ }
+
+ private func exitEvent() throws -> NSEvent {
+ try XCTUnwrap(
+ NSEvent.enterExitEvent(
+ with: .mouseExited, location: .zero, modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil,
+ eventNumber: 0, trackingNumber: 0, userData: nil))
+ }
}
diff --git a/Tests/TokenGaugeAppTests/HistoryDashboardTests.swift b/Tests/TokenGaugeAppTests/HistoryDashboardTests.swift
index 07d308d..85e1895 100644
--- a/Tests/TokenGaugeAppTests/HistoryDashboardTests.swift
+++ b/Tests/TokenGaugeAppTests/HistoryDashboardTests.swift
@@ -21,6 +21,16 @@ final class HistoryDashboardTests: XCTestCase {
XCTAssertEqual(HistoryDashboardModel.dayKey(previous.start, calendar: calendar), "2025-12-22")
}
+ func testDayKeysMatchTheWriterEvenWithANonGregorianCurrentCalendar() {
+ var japanese = Calendar(identifier: .japanese)
+ japanese.timeZone = .current
+ for offset in stride(from: -86_400 * 400, through: 86_400 * 400, by: 86_400 * 37) {
+ let date = Date().addingTimeInterval(Double(offset))
+ XCTAssertEqual(HistoryDashboardModel.dayKey(date), UsageStore.dayKey(for: date))
+ XCTAssertNotEqual(HistoryDashboardModel.dayKey(date, calendar: japanese), UsageStore.dayKey(for: date))
+ }
+ }
+
func testLeapYearIncludesEveryDateButDoesNotInventZeroDays() {
let now = calendar.date(from: DateComponents(year: 2024, month: 7, day: 1))!
let year = HistoryDashboardModel.interval(mode: .calendar, offset: 0, now: now, calendar: calendar)
@@ -50,6 +60,24 @@ final class HistoryDashboardTests: XCTestCase {
XCTAssertNil(days.first { $0.id == "2026-09-10" }?.tokens(for: .codex))
}
+ func testHiddenProviderCannotAffectScopedTotalsOrDominance() throws {
+ let now = calendar.date(from: DateComponents(year: 2026, month: 9, day: 7))!
+ let interval = HistoryDashboardModel.interval(mode: .recent, offset: 0, now: now, calendar: calendar)
+ let days = HistoryDashboardModel.makeDays(
+ interval: interval,
+ tokens: [
+ HistoryTokenRow(day: "2026-09-07", provider: .claude, model: "all", tokens: 50),
+ HistoryTokenRow(day: "2026-09-07", provider: .codex, model: "all", tokens: 9_000_000),
+ ], efforts: [], calendar: calendar)
+ let day = try XCTUnwrap(days.last)
+ XCTAssertEqual(day.id, "2026-09-07")
+ XCTAssertEqual(day.total(for: [.claude]), 50)
+ XCTAssertEqual(day.total(for: [.codex]), 9_000_000)
+ XCTAssertEqual(day.total(for: [.claude, .codex]), 9_000_050)
+ XCTAssertEqual(day.dominantProviders(for: [.claude]), [.claude])
+ XCTAssertEqual(day.dominantProviders(for: [.codex, .claude]), [.codex])
+ }
+
func testEffortOnlyHistoryHasAUsableObservedTotal() {
let now = calendar.date(from: DateComponents(year: 2026, month: 9, day: 12))!
let interval = HistoryDashboardModel.interval(mode: .recent, offset: 0, now: now, calendar: calendar)
@@ -170,6 +198,103 @@ final class HistoryDashboardTests: XCTestCase {
XCTAssertEqual(model.days.last?.tokens(for: .codex), 20)
}
+ func testPinnedDayCardSurvivesHoverAndUnpinsOnTheSameCell() {
+ let model = HistoryDashboardModel()
+ let bounds = CGRect(x: -20, y: -300, width: 560, height: 700)
+ model.showDayCard("2026-09-16", anchor: anchor(x: 10, bounds: bounds))
+ XCTAssertEqual(model.daySelection?.dayKey, "2026-09-16")
+ XCTAssertEqual(model.daySelection?.isPinned, false)
+ model.pinDayCard("2026-09-17", anchor: anchor(x: 20, bounds: bounds))
+ XCTAssertEqual(model.daySelection?.isPinned, true)
+ XCTAssertEqual(model.daySelection?.anchor?.bounds, bounds)
+ model.showDayCard("2026-09-15", anchor: nil)
+ model.hideDayCard()
+ XCTAssertEqual(model.daySelection?.dayKey, "2026-09-17")
+ model.pinDayCard("2026-09-17", anchor: anchor(x: 20, bounds: bounds))
+ XCTAssertNil(model.daySelection)
+ model.dismissDayCard()
+ XCTAssertNil(model.daySelection)
+ }
+
+ func testStreakCacheMatchesDirectComputationForEveryScope() async {
+ let model = HistoryDashboardModel()
+ let keys = (0..<5).map { HistoryDashboardModel.dayKey(Date().addingTimeInterval(-86_400 * Double($0))) }
+ await model.load(
+ mode: .recent, revision: 1,
+ previewSnapshots: [
+ ProviderUsageSnapshot(
+ provider: .codex, windows: [],
+ dailyUsage: [DailyTokenUsage(day: keys[1], tokens: 10), DailyTokenUsage(day: keys[2], tokens: 10)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date()),
+ ProviderUsageSnapshot(
+ provider: .claude, windows: [],
+ dailyUsage: [DailyTokenUsage(day: keys[0], tokens: 5), DailyTokenUsage(day: keys[4], tokens: 5)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date()),
+ ])
+ XCTAssertEqual(model.usageDays.any, model.usageDays.claude.union(model.usageDays.codex))
+ for providers in [[UsageProvider.codex], [.claude], [.codex, .claude]] {
+ let direct = HistoryAnalytics.streaks(usageDays: model.usageDays.days(for: providers), today: keys[0])
+ XCTAssertEqual(model.streak(for: providers).current, direct.current, "\(providers)")
+ XCTAssertEqual(model.streak(for: providers).longest, direct.longest, "\(providers)")
+ }
+ XCTAssertEqual(model.streak(for: [.codex]).current, 2)
+ XCTAssertEqual(model.streak(for: [.claude]).current, 1)
+ XCTAssertEqual(model.streak(for: [.codex, .claude]).current, 3)
+ }
+
+ func testFailedLoadClearsDaysUsageDaysAndStreaks() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let blocker = directory.appendingPathComponent("blocker")
+ try Data().write(to: blocker)
+ let model = HistoryDashboardModel(historyURL: blocker.appendingPathComponent("history.sqlite"))
+ let today = HistoryDashboardModel.dayKey(Date())
+ await model.load(
+ mode: .recent, revision: 1,
+ previewSnapshots: [
+ ProviderUsageSnapshot(
+ provider: .codex, windows: [], dailyUsage: [DailyTokenUsage(day: today, tokens: 10)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date())
+ ])
+ XCTAssertEqual(model.streak(for: [.codex]).current, 1)
+ XCTAssertFalse(model.usageDays.codex.isEmpty)
+ await model.load(mode: .recent, revision: 2)
+ XCTAssertTrue(model.loadFailed)
+ XCTAssertFalse(model.isLoading)
+ XCTAssertTrue(model.days.isEmpty)
+ XCTAssertTrue(model.paces.isEmpty)
+ XCTAssertEqual(model.usageDays, HistoryUsageDays())
+ XCTAssertEqual(model.streaks, HistoryStreaks())
+ XCTAssertEqual(model.streak(for: [.codex]), HistoryStreak())
+ }
+
+ private func anchor(x: CGFloat, bounds: CGRect) -> HistoryDayCardAnchor {
+ HistoryDayCardAnchor(cell: CGRect(x: x, y: 10, width: 9, height: 9), bounds: bounds)
+ }
+
+ func testStreakFollowsTheDisplayedProviders() async {
+ let model = HistoryDashboardModel()
+ let today = HistoryDashboardModel.dayKey(Date())
+ let yesterday = HistoryDashboardModel.dayKey(Date().addingTimeInterval(-86_400))
+ await model.load(
+ mode: .recent, revision: 1,
+ previewSnapshots: [
+ ProviderUsageSnapshot(
+ provider: .codex, windows: [],
+ dailyUsage: [DailyTokenUsage(day: today, tokens: 10), DailyTokenUsage(day: yesterday, tokens: 10)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date()),
+ ProviderUsageSnapshot(
+ provider: .claude, windows: [],
+ dailyUsage: [DailyTokenUsage(day: today, tokens: 5)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date()),
+ ])
+ XCTAssertEqual(model.streak(for: [.codex]).current, 2)
+ XCTAssertEqual(model.streak(for: [.claude]).current, 1)
+ XCTAssertEqual(model.streak(for: [.codex, .claude]).current, 2)
+ XCTAssertEqual(model.streak(for: [.codex, .claude]).longest, 2)
+ }
+
func testCalendarDominanceUsesOnlyVisibleRecordedProviders() {
func day(_ totals: [UsageProvider: Int]) -> HistoryCalendarDay {
HistoryCalendarDay(id: "2026-09-12", date: Date(), totals: totals, efforts: [])
@@ -183,6 +308,65 @@ final class HistoryDashboardTests: XCTestCase {
XCTAssertTrue(day([:]).dominantProviders(for: [.codex, .claude]).isEmpty)
}
+ func testSecondLoadWithTheSameInputsServesTheCacheWithoutReadingTheStore() async throws {
+ let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let model = HistoryDashboardModel(historyURL: directory.appending(path: "usage-history.sqlite"))
+ await model.load(mode: .recent, revision: 1)
+ XCTAssertFalse(model.loadFailed)
+ let reads = UsageHistoryStore.entryCount
+ XCTAssertGreaterThan(reads, 0)
+
+ await model.load(mode: .recent, revision: 1)
+ XCTAssertFalse(model.loadFailed)
+ XCTAssertEqual(UsageHistoryStore.entryCount, reads)
+
+ await model.load(mode: .recent, revision: 2)
+ XCTAssertGreaterThan(UsageHistoryStore.entryCount, reads)
+ }
+
+ func testClosingThePopoverDropsTheDaySelection() {
+ let model = HistoryDashboardModel()
+ model.selectedDayKey = "2026-09-15"
+ model.pinDayCard("2026-09-17", anchor: anchor(x: 20, bounds: CGRect(x: 0, y: 0, width: 300, height: 200)))
+ XCTAssertEqual(model.daySelection?.isPinned, true)
+
+ model.popoverDidClose()
+ XCTAssertNil(model.daySelection)
+ XCTAssertNil(model.selectedDayKey)
+ }
+
+ func testDayKeyMatchesTheFormatterAcrossTimeZones() {
+ for seconds in [-5 * 3600, 13 * 3600] {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: seconds)!
+ let formatter = DateFormatter()
+ formatter.calendar = calendar
+ formatter.timeZone = calendar.timeZone
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ let probes = [
+ DateComponents(year: 2026, month: 1, day: 1, hour: 0),
+ DateComponents(year: 2026, month: 1, day: 1, hour: 23, minute: 59),
+ DateComponents(year: 2024, month: 2, day: 29, hour: 12),
+ DateComponents(year: 2026, month: 3, day: 7, hour: 3),
+ DateComponents(year: 2026, month: 9, day: 18, hour: 17),
+ DateComponents(year: 2026, month: 10, day: 31, hour: 23),
+ DateComponents(year: 2026, month: 12, day: 31, hour: 22, minute: 30),
+ DateComponents(year: 1999, month: 7, day: 4, hour: 6),
+ DateComponents(year: 2000, month: 2, day: 28, hour: 1),
+ DateComponents(year: 2035, month: 11, day: 9, hour: 15),
+ ]
+ for components in probes {
+ let date = calendar.date(from: components)!
+ XCTAssertEqual(
+ HistoryDashboardModel.dayKey(date, calendar: calendar), formatter.string(from: date),
+ "\(components) @ \(seconds)")
+ }
+ }
+ }
+
func testPreviewStoresDoNotReadOrCollectLiveHistory() {
let name = "TokenGauge.history-preview.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
diff --git a/Tests/TokenGaugeAppTests/HistoryDayCardTests.swift b/Tests/TokenGaugeAppTests/HistoryDayCardTests.swift
new file mode 100644
index 0000000..ed3bd81
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/HistoryDayCardTests.swift
@@ -0,0 +1,105 @@
+import AppKit
+import SwiftUI
+import TokenGaugeCore
+import XCTest
+
+@testable import TokenGaugeApp
+
+@MainActor
+final class HistoryDayCardTests: XCTestCase {
+ func testIntensityLevelsFollowTheQuantilesOfTheLoadedRange() {
+ let thresholds = HistoryIntensity.thresholds(totals: [0, 10, 20, 30, 40, 50])
+ XCTAssertEqual(thresholds, [10, 20, 30, 40])
+ XCTAssertEqual(HistoryIntensity.level(total: 5, thresholds: thresholds), 0)
+ XCTAssertEqual(HistoryIntensity.level(total: 15, thresholds: thresholds), 1)
+ XCTAssertEqual(HistoryIntensity.level(total: 35, thresholds: thresholds), 3)
+ XCTAssertEqual(HistoryIntensity.level(total: 50, thresholds: thresholds), 4)
+ XCTAssertEqual(HistoryIntensity.opacity(total: 50, thresholds: thresholds), 1)
+ XCTAssertEqual(HistoryIntensity.opacity(total: 1, thresholds: []), HistoryIntensity.opacities[0])
+ }
+
+ func testCardIsPlacedBelowThenAboveAndClampedToThePanel() {
+ let bounds = CGRect(x: 0, y: 0, width: 560, height: 400)
+ let size = CGSize(width: HistoryDayCardPlacement.width, height: 120)
+ let below = HistoryDayCardPlacement.origin(
+ anchor: CGRect(x: 280, y: 100, width: 9, height: 9), cardSize: size, bounds: bounds)
+ XCTAssertEqual(below.y, 109 + HistoryDayCardPlacement.spacing)
+ XCTAssertEqual(below.x, 284.5 - size.width / 2)
+ let above = HistoryDayCardPlacement.origin(
+ anchor: CGRect(x: 280, y: 330, width: 9, height: 9), cardSize: size, bounds: bounds)
+ XCTAssertEqual(above.y, 330 - HistoryDayCardPlacement.spacing - size.height)
+ let trailing = HistoryDayCardPlacement.origin(
+ anchor: CGRect(x: 550, y: 100, width: 9, height: 9), cardSize: size, bounds: bounds)
+ XCTAssertEqual(trailing.x, bounds.maxX - size.width)
+ let leading = HistoryDayCardPlacement.origin(
+ anchor: CGRect(x: 2, y: 100, width: 9, height: 9), cardSize: size, bounds: bounds)
+ XCTAssertEqual(leading.x, bounds.minX)
+ }
+
+ func testTallCardFallsBelowAboveThenClampsToTheBottomOrTop() {
+ let size = CGSize(width: HistoryDayCardPlacement.width, height: 160)
+ let bounds = CGRect(x: -20, y: -300, width: 560, height: 700)
+ func origin(_ y: CGFloat, in bounds: CGRect) -> CGPoint {
+ HistoryDayCardPlacement.origin(
+ anchor: CGRect(x: 280, y: y, width: 9, height: 9), cardSize: size, bounds: bounds)
+ }
+ XCTAssertEqual(origin(100, in: bounds).y, 109 + HistoryDayCardPlacement.spacing)
+ XCTAssertEqual(origin(300, in: bounds).y, 300 - HistoryDayCardPlacement.spacing - size.height)
+ let short = CGRect(x: 0, y: 0, width: 560, height: 200)
+ XCTAssertEqual(origin(100, in: short).y, short.maxY - size.height)
+ let tiny = CGRect(x: 0, y: 0, width: 560, height: 150)
+ XCTAssertEqual(origin(60, in: tiny).y, tiny.minY)
+ for (y, frame) in [(100, bounds), (300, bounds), (100, short)] {
+ let point = origin(CGFloat(y), in: frame)
+ XCTAssertTrue(frame.contains(CGRect(origin: point, size: size)), "\(y) in \(frame)")
+ }
+ }
+
+ func testSharePercentagesUseLargestRemainderAndSumToOneHundred() {
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([505, 495]), [51, 49])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([1, 1, 1]), [34, 33, 33])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([800, 200]), [80, 20])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([1, 999]), [0, 100])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([7]), [100])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([0, 0]), [0, 0])
+ XCTAssertEqual(HistoryDayCardPlacement.percentages([]), [])
+ for values in [[1, 2], [333, 333, 334], [2, 3, 5, 7, 11], [Int.max / 2, Int.max / 2]] {
+ XCTAssertEqual(HistoryDayCardPlacement.percentages(values).reduce(0, +), 100, "\(values)")
+ }
+ }
+
+ func testCardRendersBothProvidersInEveryLanguage() {
+ let language = LocalizationManager.shared.language
+ defer { LocalizationManager.shared.language = language }
+ for value in AppLanguage.allCases {
+ LocalizationManager.shared.language = value
+ let view = NSHostingView(
+ rootView: HistoryDayCardView(
+ day: day, providers: [.codex, .claude], isToday: true, isPinned: true, currentStreak: 3,
+ longestStreak: 12, onClose: {}))
+ XCTAssertEqual(view.fittingSize.width, HistoryDayCardPlacement.width)
+ XCTAssertGreaterThan(view.fittingSize.height, 80)
+ }
+ }
+
+ func testCardWithoutDataAnnouncesTheAbsenceOfActivity() {
+ let empty = HistoryCalendarDay(
+ id: "2026-09-16", date: Date(timeIntervalSince1970: 1_789_000_000), totals: [:], efforts: [])
+ let view = NSHostingView(
+ rootView: HistoryDayCardView(
+ day: empty, providers: [.codex, .claude], isToday: false, isPinned: false, currentStreak: 0,
+ longestStreak: 0, onClose: {}))
+ XCTAssertEqual(view.fittingSize.width, HistoryDayCardPlacement.width)
+ XCTAssertGreaterThan(view.fittingSize.height, 40)
+ }
+
+ private var day: HistoryCalendarDay {
+ HistoryCalendarDay(
+ id: "2026-09-17", date: Date(timeIntervalSince1970: 1_789_000_000),
+ totals: [.codex: 800, .claude: 200],
+ efforts: [
+ HistoryEffortRow(
+ day: "2026-09-17", provider: .codex, model: "gpt-6-astra", effort: "medium", tokens: 800)
+ ])
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/MenuBarPresentationTests.swift b/Tests/TokenGaugeAppTests/MenuBarPresentationTests.swift
index ebc4fee..f19c219 100644
--- a/Tests/TokenGaugeAppTests/MenuBarPresentationTests.swift
+++ b/Tests/TokenGaugeAppTests/MenuBarPresentationTests.swift
@@ -211,13 +211,11 @@ final class MenuBarPresentationTests: XCTestCase {
private func state(provider: UsageProvider, status: ProviderStatus, usedPercentage: Double? = nil)
-> ProviderViewState
{
- let window = QuotaWindow(
+ let window = Fixture.window(
id: provider == .codex ? "codex.primary" : "seven_day",
usedPercentage: usedPercentage ?? (provider == .codex ? 80 : 40),
- resetsAt: Date().addingTimeInterval(86_400), durationMinutes: 10_080, displayName: nil)
- let snapshot = ProviderUsageSnapshot(
- provider: provider, windows: [window], dailyUsage: [], summary: nil,
- availableResetCredits: nil, creditBalance: nil, capturedAt: Date())
+ resetsAt: Date().addingTimeInterval(86_400), durationMinutes: 10_080)
+ let snapshot = Fixture.snapshot(provider, windows: [window])
return ProviderViewState(snapshot: snapshot, status: status, isRefreshing: false)
}
}
diff --git a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
index cf8de96..b47fe3b 100644
--- a/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
+++ b/Tests/TokenGaugeAppTests/PopoverLayoutTests.swift
@@ -24,7 +24,9 @@ final class PopoverLayoutTests: XCTestCase {
resetsAt: Date().addingTimeInterval(86400 * 3), durationMinutes: 10080,
displayName: index == 0 ? nil : "Model \(index)")
}, dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: Date())
- let store = UsageStore(defaults: defaults, initialSnapshots: [snapshot], historyReadsEnabled: false)
+ let store = UsageStore(
+ defaults: defaults, initialSnapshots: [snapshot, claudeSnapshot()], historyReadsEnabled: false)
+ store.showHourlyPace = true
for language in AppLanguage.allCases {
LocalizationManager.shared.language = language
for mode in UsageDisplayMode.allCases {
@@ -43,12 +45,102 @@ final class PopoverLayoutTests: XCTestCase {
}
}
+ func testUnifiedProviderContentFitsEveryStyleWithoutScrolling() throws {
+ let suite = "TokenGauge.layout.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let store = UsageStore(
+ defaults: defaults, initialSnapshots: [codexSnapshot(), claudeSnapshot()], historyReadsEnabled: false)
+ store.showHourlyPace = true
+ store.displayMode = .unified
+ try withEachLanguage { language in
+ for style in QuotaPanelStyle.allCases {
+ store.panelStyle = style
+ let view = PopoverView(store: store, showSettings: {}, showAbout: {})
+ let content = fittingSize(
+ view.providerContent.frame(width: view.panelWidth - Theme.Layout.panelPadding * 2))
+ XCTAssertLessThanOrEqual(
+ content.height, view.providerMaxHeight, "\(language.rawValue) / \(style.rawValue)")
+ assertHeight(store)
+ }
+ }
+ }
+
+ func testUpdateBannerNeverClipsTheRingProviderCards() throws {
+ let suite = "TokenGauge.layout.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer {
+ defaults.removePersistentDomain(forName: suite)
+ UpdateManager.shared.handleNotFound()
+ }
+ let store = UsageStore(
+ defaults: defaults, initialSnapshots: [codexSnapshot(), claudeSnapshot()], historyReadsEnabled: false)
+ store.showHourlyPace = true
+ store.panelStyle = .rings
+ store.displayMode = .unified
+ try withEachLanguage { language in
+ for banner in [false, true] {
+ UpdateManager.shared.handleNotFound()
+ if banner {
+ _ = UpdateManager.shared.handleUpdateFound(
+ version: "1.4.1", releasePage: nil, informationOnly: false)
+ }
+ for downloading in banner ? [false, true] : [false] {
+ if downloading { UpdateManager.shared.handleDownloadInitiated() }
+ let view = PopoverView(store: store, showSettings: {}, showAbout: {})
+ let content = fittingSize(
+ view.providerContent.frame(width: view.panelWidth - Theme.Layout.panelPadding * 2))
+ XCTAssertEqual(view.providerMaxHeight, Theme.Layout.ringProviderMaxHeight)
+ XCTAssertLessThanOrEqual(
+ content.height, view.providerMaxHeight,
+ "\(language.rawValue) / \(UpdateManager.shared.phase)")
+ assertHeight(store)
+ }
+ }
+ }
+ }
+
+ private func claudeSnapshot() -> ProviderUsageSnapshot {
+ Fixture.snapshot(
+ .claude,
+ windows: [
+ Fixture.window(
+ id: "five_hour", usedPercentage: 2, resetsAt: Date().addingTimeInterval(7_200),
+ durationMinutes: 300),
+ Fixture.window(
+ id: "seven_day", usedPercentage: 25, resetsAt: Date().addingTimeInterval(172_800),
+ durationMinutes: 10_080),
+ Fixture.window(
+ id: "seven_day_fable", usedPercentage: 88, resetsAt: Date().addingTimeInterval(172_800),
+ durationMinutes: 10_080, displayName: "Fable"),
+ ], availableResetCredits: 2, capturedAt: Date().addingTimeInterval(-600),
+ modelBuckets: Fixture.modelBuckets([
+ ("claude-opus-4-6", 171_600_000), ("claude-fable-1", 67_900_000), ("claude-haiku-4-5", 1_100_000),
+ ]))
+ }
+
+ private func codexSnapshot() -> ProviderUsageSnapshot {
+ Fixture.snapshot(
+ .codex,
+ windows: [
+ Fixture.window(
+ id: "codex.weekly", usedPercentage: 41, resetsAt: Date().addingTimeInterval(172_800),
+ durationMinutes: 10_080),
+ Fixture.window(
+ id: "base_model_inference.weekly", usedPercentage: 12,
+ resetsAt: Date().addingTimeInterval(172_800), durationMinutes: 10_080,
+ displayName: "gpt-reserve"),
+ ], capturedAt: Date().addingTimeInterval(-600),
+ modelBuckets: Fixture.modelBuckets([("gpt-6-astra", 148_300_000)]))
+ }
+
private func assertHeight(_ store: UsageStore, file: StaticString = #filePath, line: UInt = #line) {
let view = NSHostingView(rootView: PopoverView(store: store, showSettings: {}, showAbout: {}))
+ let budget = Theme.Layout.maximumPanelHeight + UpdateBannerView.height(for: UpdateManager.shared.phase)
XCTAssertGreaterThanOrEqual(view.fittingSize.width, Theme.Layout.compactPanelWidth, file: file, line: line)
XCTAssertLessThanOrEqual(view.fittingSize.width, Theme.Layout.unifiedPanelWidth, file: file, line: line)
XCTAssertLessThanOrEqual(
- view.fittingSize.height, Theme.Layout.maximumPanelHeight,
+ view.fittingSize.height, budget,
"\(LocalizationManager.shared.language.rawValue) / \(store.displayMode.rawValue) / \(store.panelStyle.rawValue) / \(UpdateManager.shared.phase)",
file: file, line: line)
}
diff --git a/Tests/TokenGaugeAppTests/ProcessRunnerParsingTests.swift b/Tests/TokenGaugeAppTests/ProcessRunnerParsingTests.swift
new file mode 100644
index 0000000..275a399
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/ProcessRunnerParsingTests.swift
@@ -0,0 +1,46 @@
+import Foundation
+import XCTest
+
+@testable import TokenGaugeApp
+
+final class ProcessRunnerParsingTests: XCTestCase {
+ func testResponseIdentifiersParseOnlyNewBytes() {
+ let lines = (1...4).map { "{\"id\":\($0),\"result\":{}}\n" }
+ var output = Data()
+ var scanned = 0
+ var identifiers: Set = []
+ var scannedPerChunk: [Int] = []
+
+ for line in lines {
+ let before = scanned
+ output.append(Data(line.utf8))
+ identifiers.formUnion(ProcessRunner.responseIdentifiers(in: output, from: &scanned))
+ scannedPerChunk.append(scanned - before)
+ }
+
+ XCTAssertEqual(identifiers, [1, 2, 3, 4])
+ XCTAssertEqual(scannedPerChunk, lines.map(\.utf8.count))
+ XCTAssertEqual(scanned, output.count)
+ }
+
+ func testIncompleteLineIsScannedOnlyOnceItIsComplete() {
+ var output = Data("{\"id\":7,\"res".utf8)
+ var scanned = 0
+
+ XCTAssertTrue(ProcessRunner.responseIdentifiers(in: output, from: &scanned).isEmpty)
+ XCTAssertEqual(scanned, 0)
+
+ output.append(Data("ult\":{}}\n".utf8))
+ XCTAssertEqual(ProcessRunner.responseIdentifiers(in: output, from: &scanned), [7])
+ XCTAssertEqual(scanned, output.count)
+ }
+
+ func testFinalLineWithoutNewlineIsScannedAtEndOfOutput() {
+ let output = Data("{\"id\":2}\n{\"id\":3}".utf8)
+ var scanned = 0
+
+ XCTAssertEqual(ProcessRunner.responseIdentifiers(in: output, from: &scanned), [2])
+ XCTAssertEqual(ProcessRunner.responseIdentifiers(in: output, from: &scanned, includingTail: true), [3])
+ XCTAssertEqual(scanned, output.count)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/QuotaPanelRenderingTests.swift b/Tests/TokenGaugeAppTests/QuotaPanelRenderingTests.swift
index 1136219..c7349de 100644
--- a/Tests/TokenGaugeAppTests/QuotaPanelRenderingTests.swift
+++ b/Tests/TokenGaugeAppTests/QuotaPanelRenderingTests.swift
@@ -8,15 +8,14 @@ import XCTest
@MainActor
final class QuotaPanelRenderingTests: XCTestCase {
func testNewStylesRenderOneTwoAndThreeWindowsAtSupportedWidths() throws {
- let originalLanguage = LocalizationManager.shared.language
- defer { LocalizationManager.shared.language = originalLanguage }
- for language in AppLanguage.allCases {
- LocalizationManager.shared.language = language
+ try withEachLanguage { _ in
for style in [QuotaPanelStyle.compact, .rings] {
for count in 1...3 {
for width: CGFloat in style == .rings ? [240, 334] : [334, 414] {
+ let claude = state(count: count)
let card = ProviderCard(
- provider: .claude, state: state(count: count), panelStyle: style, showProviderTitle: true)
+ provider: .claude, state: claude, panelStyle: style, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(claude))
let bitmap = try render(card.frame(width: width), maximumHeight: 420)
XCTAssertGreaterThan(bitmap.pixelsWide, 0)
XCTAssertEqual(bitmap.size.width, width, accuracy: 1)
@@ -27,10 +26,7 @@ final class QuotaPanelRenderingTests: XCTestCase {
}
func testCompactOneAndTwoWindowCardsStayBelowCombinedHeightBudget() throws {
- let originalLanguage = LocalizationManager.shared.language
- defer { LocalizationManager.shared.language = originalLanguage }
- for language in AppLanguage.allCases {
- LocalizationManager.shared.language = language
+ try withEachLanguage { _ in
var combinedHeight: CGFloat = Theme.Layout.sectionSpacing
for count in 1...2 {
let card = ProviderCard(
@@ -54,14 +50,14 @@ final class QuotaPanelRenderingTests: XCTestCase {
}
func testProportionalRingWidthsKeepTwoAndThreeWindowsOnOneRow() throws {
- let originalLanguage = LocalizationManager.shared.language
- defer { LocalizationManager.shared.language = originalLanguage }
- for language in AppLanguage.allCases {
- LocalizationManager.shared.language = language
- for (count, width) in [(2, CGFloat(194)), (3, CGFloat(290))] {
+ try withEachLanguage { _ in
+ for count in [2, 3] {
+ let width = QuotaRingLayout.minimumGridWidth(windows: count) + Theme.Layout.cardPadding * 2
+ let claude = state(count: count)
let card = ProviderCard(
- provider: .claude, state: state(count: count), panelStyle: .rings, showProviderTitle: true)
- let bitmap = try render(card.frame(width: width), maximumHeight: 220)
+ provider: .claude, state: claude, panelStyle: .rings, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(claude))
+ let bitmap = try render(card.frame(width: width), maximumHeight: 260)
XCTAssertEqual(bitmap.size.width, width, accuracy: 1)
}
}
@@ -82,12 +78,14 @@ final class QuotaPanelRenderingTests: XCTestCase {
}
func testHiddenWindowsDoNotOccupyRingSpace() throws {
+ let claude = state(count: 3)
var card = ProviderCard(
- provider: .claude, state: state(count: 3), panelStyle: .rings, showProviderTitle: true)
- let all = try render(card.frame(width: 240), maximumHeight: 320)
+ provider: .claude, state: claude, panelStyle: .rings, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(claude))
+ let all = try render(card.frame(width: 240), maximumHeight: 420)
card.hiddenClaudeWindows = [.weekly]
- let filtered = try render(card.frame(width: 240), maximumHeight: 190)
- XCTAssertLessThan(filtered.size.height, all.size.height - 100)
+ let filtered = try render(card.frame(width: 240), maximumHeight: 300)
+ XCTAssertLessThan(filtered.size.height, all.size.height - 50)
}
func testZeroAndFullRingsHaveDistinctRenderedBalances() throws {
@@ -98,6 +96,126 @@ final class QuotaPanelRenderingTests: XCTestCase {
XCTAssertGreaterThan(bluePixelCount(full), bluePixelCount(empty) + 100)
}
+ func testAdaptiveRingCardsRenderEveryWindowCountWithinThePanelHeight() throws {
+ let width = Theme.Layout.ringPanelWidth - Theme.Layout.panelPadding * 2
+ try withEachLanguage { _ in
+ for count in 1...3 {
+ let state = state(count: count)
+ let claude = ProviderCard(
+ provider: .claude, state: state, panelStyle: .rings, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(state))
+ let bitmap = try render(claude.frame(width: width), maximumHeight: Theme.Layout.maximumPanelHeight)
+ XCTAssertEqual(bitmap.size.width, width, accuracy: 1)
+ }
+ for count in 1...2 {
+ let state = codexState(count: count)
+ let codex = ProviderCard(
+ provider: .codex, state: state, panelStyle: .rings, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(state))
+ let bitmap = try render(codex.frame(width: width), maximumHeight: Theme.Layout.maximumPanelHeight)
+ XCTAssertEqual(bitmap.size.width, width, accuracy: 1)
+ }
+ }
+ }
+
+ func testThreeWindowIndividualCardFallsBackToRowsAndStaysOnOneColumn() throws {
+ let cardWidth = Theme.Layout.ringPanelWidth - Theme.Layout.panelPadding * 2
+ let available = cardWidth - Theme.Layout.cardPadding * 2
+ XCTAssertEqual(QuotaRingLayout.choose(availableWidth: available, windows: 3), .rows)
+ XCTAssertEqual(QuotaRingLayout.choose(availableWidth: available, windows: 2), .grid(columns: 2))
+ let claude = state(count: 3)
+ let card = ProviderCard(
+ provider: .claude, state: claude, panelStyle: .rings, showProviderTitle: true,
+ showHourlyPace: true, paces: paces(claude))
+ let rows = try render(card.frame(width: cardWidth), maximumHeight: Theme.Layout.maximumPanelHeight)
+ let grid = try render(
+ card.frame(width: QuotaRingLayout.minimumGridWidth(windows: 3) + Theme.Layout.cardPadding * 2),
+ maximumHeight: Theme.Layout.maximumPanelHeight)
+ XCTAssertGreaterThan(rows.size.height, grid.size.height)
+ }
+
+ func testUnifiedRingCardsShareOneHeight() throws {
+ let available = Theme.Layout.ringUnifiedWidth - Theme.Layout.panelPadding * 2 - Theme.Layout.quotaRingSpacing
+ let balanced = QuotaRingLayout.minimumGridWidth(windows: 2) * 2 + Theme.Layout.cardPadding * 4
+ let matrix: [(Int, Int, CGFloat, CGFloat)] = [
+ (1, 3, Theme.Layout.minimumRingCardWidth, available - Theme.Layout.minimumRingCardWidth),
+ (2, 2, balanced / 2, balanced / 2),
+ ]
+ for (codexWindows, claudeWindows, codexWidth, claudeWidth) in matrix {
+ let codexState = codexState(count: codexWindows)
+ let claudeState = state(count: claudeWindows)
+ let codex = ProviderCard(
+ provider: .codex, state: codexState, panelStyle: .rings,
+ showProviderTitle: true, showHourlyPace: true, stretchesHeight: true, paces: paces(codexState))
+ let claude = ProviderCard(
+ provider: .claude, state: claudeState, panelStyle: .rings,
+ showProviderTitle: true, showHourlyPace: true, stretchesHeight: true, paces: paces(claudeState))
+ let row = HStack(alignment: .top, spacing: Theme.Layout.quotaRingSpacing) {
+ codex.frame(width: codexWidth)
+ claude.frame(width: claudeWidth)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .environment(\.colorScheme, .light)
+ .background(Color.white)
+ let bitmap = try render(row, maximumHeight: Theme.Layout.maximumPanelHeight)
+ let tall = try render(claude.frame(width: claudeWidth), maximumHeight: Theme.Layout.maximumPanelHeight)
+ XCTAssertEqual(bitmap.size.height, max(bitmap.size.height, tall.size.height), accuracy: 1)
+ let scale = CGFloat(bitmap.pixelsWide) / bitmap.size.width
+ let bottom = bitmap.pixelsHigh - Int(4 * scale)
+ let card = try XCTUnwrap(
+ bitmap.colorAt(x: Int(codexWidth / 2 * scale), y: bottom)?.usingColorSpace(.deviceRGB))
+ let gap = try XCTUnwrap(
+ bitmap.colorAt(x: Int((codexWidth + Theme.Layout.quotaRingSpacing / 2) * scale), y: bottom)?
+ .usingColorSpace(.deviceRGB))
+ XCTAssertLessThan(card.redComponent, gap.redComponent - 0.005, "\(codexWindows) vs \(claudeWindows)")
+ }
+ }
+
+ func testStandardCardsFitTheProviderAreaWithoutScrolling() throws {
+ let available = Theme.Layout.unifiedPanelWidth - Theme.Layout.panelPadding * 2
+ let cardWidth = (available - 10) / 2
+ try withEachLanguage { language in
+ let codexState = codexState(count: 2)
+ let claudeState = state(count: 3)
+ let codex = ProviderCard(
+ provider: .codex, state: codexState, showProviderTitle: true, showHourlyPace: true,
+ paces: paces(codexState))
+ let claude = ProviderCard(
+ provider: .claude, state: claudeState, showProviderTitle: true, showHourlyPace: true,
+ paces: paces(claudeState))
+ let unified = HStack(alignment: .top, spacing: 10) {
+ codex.frame(width: cardWidth)
+ claude.frame(width: cardWidth)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ XCTAssertLessThanOrEqual(
+ fittingSize(unified).height, Theme.Layout.providerMaxHeight, language.rawValue)
+ let single = ProviderCard(
+ provider: .claude, state: claudeState, showHourlyPace: true, paces: paces(claudeState))
+ XCTAssertLessThanOrEqual(
+ fittingSize(single.frame(width: Theme.Layout.panelWidth - Theme.Layout.panelPadding * 2)).height,
+ Theme.Layout.providerMaxHeight, language.rawValue)
+ }
+ }
+
+ func testStandardPanelWithLargestProviderCardsStaysWithinTheMaximumHeight() throws {
+ try withDefaults { defaults in
+ let store = Fixture.store(
+ defaults: defaults,
+ snapshots: [codexState(count: 2).snapshot, state(count: 3).snapshot].compactMap { $0 })
+ store.panelStyle = .standard
+ store.showHourlyPace = true
+ try withEachLanguage { language in
+ for mode in UsageDisplayMode.allCases {
+ store.displayMode = mode
+ let size = fittingSize(PopoverView(store: store, showSettings: {}, showAbout: {}))
+ XCTAssertLessThanOrEqual(
+ size.height, Theme.Layout.maximumPanelHeight, "\(language.rawValue) / \(mode.rawValue)")
+ }
+ }
+ }
+ }
+
private func bluePixelCount(_ bitmap: NSBitmapImageRep) -> Int {
var count = 0
for y in 0.. ProviderViewState {
let windows = [
- QuotaWindow(
+ Fixture.window(
id: "five_hour", usedPercentage: 2, resetsAt: Date().addingTimeInterval(7200),
- durationMinutes: 300, displayName: nil),
- QuotaWindow(
+ durationMinutes: 300),
+ Fixture.window(
id: "seven_day", usedPercentage: 25, resetsAt: Date().addingTimeInterval(172800),
- durationMinutes: 10080, displayName: nil),
- QuotaWindow(
+ durationMinutes: 10080),
+ Fixture.window(
id: "seven_day_fable", usedPercentage: 88, resetsAt: Date().addingTimeInterval(172800),
durationMinutes: 10080, displayName: "Fable"),
]
- let snapshot = ProviderUsageSnapshot(
- provider: .claude, windows: Array(windows.prefix(count)), dailyUsage: [], summary: nil,
- availableResetCredits: 2, creditBalance: nil, capturedAt: Date().addingTimeInterval(-600))
+ let snapshot = Fixture.snapshot(
+ .claude, windows: Array(windows.prefix(count)), availableResetCredits: 2,
+ capturedAt: Date().addingTimeInterval(-600),
+ modelBuckets: Fixture.modelBuckets([
+ ("claude-opus-4-6", 171_600_000), ("claude-fable-1", 67_900_000),
+ ("claude-haiku-4-5", 1_100_000),
+ ]))
return ProviderViewState(snapshot: snapshot, status: status, isRefreshing: false)
}
- private func render(_ content: some View, maximumHeight: CGFloat) throws -> NSBitmapImageRep {
- let view = NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false))
- let size = view.fittingSize
- XCTAssertLessThanOrEqual(size.height, maximumHeight)
- view.frame = NSRect(origin: .zero, size: size)
- let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false)
- window.contentView = view
- defer { window.contentView = nil }
- view.layoutSubtreeIfNeeded()
- let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
- view.cacheDisplay(in: view.bounds, to: bitmap)
- return bitmap
+ private func codexState(count: Int, status: ProviderStatus = .ready) -> ProviderViewState {
+ let windows = [
+ Fixture.window(
+ id: "codex.weekly", usedPercentage: 41, resetsAt: Date().addingTimeInterval(172800),
+ durationMinutes: 10080),
+ Fixture.window(
+ id: "base_model_inference.weekly", usedPercentage: 12,
+ resetsAt: Date().addingTimeInterval(172800), durationMinutes: 10080, displayName: "gpt-reserve"),
+ ]
+ let snapshot = Fixture.snapshot(
+ .codex, windows: Array(windows.prefix(count)), capturedAt: Date().addingTimeInterval(-600),
+ modelBuckets: Fixture.modelBuckets([("gpt-6-astra", 148_300_000), ("gpt-6-codex", 52_400_000)]))
+ return ProviderViewState(snapshot: snapshot, status: status, isRefreshing: false)
+ }
+
+ private func paces(_ state: ProviderViewState) -> [String: QuotaPace] {
+ var result: [String: QuotaPace] = [:]
+ for window in state.snapshot?.windows ?? [] {
+ result[window.id] = Fixture.pace(15.6, resetsAt: window.resetsAt)
+ }
+ return result
}
}
diff --git a/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift b/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift
new file mode 100644
index 0000000..c94885d
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/QuotaRingLayoutTests.swift
@@ -0,0 +1,89 @@
+import CoreGraphics
+import XCTest
+
+@testable import TokenGaugeApp
+
+final class QuotaRingLayoutTests: XCTestCase {
+ private let cell = Theme.Layout.quotaRingCellWidth
+ private let spacing = Theme.Layout.quotaRingSpacing
+
+ func testOneWindowAlwaysChoosesTheSingleRing() {
+ for width: CGFloat in [0, 90, 146, 170, 310, 560] {
+ XCTAssertEqual(QuotaRingLayout.choose(availableWidth: width, windows: 1), .single, "width \(width)")
+ }
+ }
+
+ func testGridIsChosenOnlyWhenEveryCellReachesTheMinimumCellWidth() {
+ for windows in 2...5 {
+ let exact = QuotaRingLayout.minimumGridWidth(windows: windows)
+ XCTAssertEqual(exact, cell * CGFloat(windows) + spacing * CGFloat(windows - 1), "windows \(windows)")
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: exact, windows: windows), .grid(columns: windows),
+ "windows \(windows)")
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: exact + 40, windows: windows), .grid(columns: windows),
+ "windows \(windows)")
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: exact - 1, windows: windows), .rows, "windows \(windows)")
+ XCTAssertEqual(QuotaRingLayout.choose(availableWidth: 0, windows: windows), .rows, "windows \(windows)")
+ }
+ }
+
+ func testPanelWidthsMapToTheExpectedModes() {
+ let individual =
+ Theme.Layout.ringPanelWidth - Theme.Layout.panelPadding * 2 - Theme.Layout.cardPadding * 2
+ let unified = Theme.Layout.ringUnifiedWidth - Theme.Layout.panelPadding * 2 - Theme.Layout.quotaRingSpacing
+ let narrowCard = Theme.Layout.minimumRingCardWidth - Theme.Layout.cardPadding * 2
+ let wideCard = unified - Theme.Layout.minimumRingCardWidth - Theme.Layout.cardPadding * 2
+ let halfCard = unified / 2 - Theme.Layout.cardPadding * 2
+ let matrix: [(CGFloat, Int, QuotaRingLayout.Mode)] = [
+ (individual, 1, .single),
+ (individual, 2, .grid(columns: 2)),
+ (individual, 3, .rows),
+ (narrowCard, 1, .single),
+ (narrowCard, 2, .rows),
+ (halfCard, 2, .rows),
+ (wideCard, 3, .rows),
+ (wideCard, 2, .grid(columns: 2)),
+ ]
+ for (width, windows, mode) in matrix {
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: width, windows: windows), mode, "\(width) / \(windows)")
+ }
+ }
+
+ func testColumnsNeverExceedTheWindowCountAndStayAtLeastOne() {
+ XCTAssertEqual(QuotaRingLayout.columns(availableWidth: 600, windows: 2), 2)
+ XCTAssertEqual(
+ QuotaRingLayout.columns(availableWidth: QuotaRingLayout.minimumGridWidth(windows: 3), windows: 3), 3)
+ XCTAssertEqual(QuotaRingLayout.columns(availableWidth: cell * 3, windows: 3), 2)
+ XCTAssertEqual(QuotaRingLayout.columns(availableWidth: cell * 2, windows: 3), 1)
+ XCTAssertEqual(QuotaRingLayout.columns(availableWidth: 0, windows: 3), 1)
+ }
+
+ func testTheGridThresholdIsTheCellFormulaSharedWithTheGridLayout() {
+ for windows in 2...4 {
+ let minimum = QuotaRingLayout.minimumGridWidth(windows: windows)
+ XCTAssertEqual(
+ QuotaRingLayout.cellWidth(availableWidth: minimum, columns: windows), cell, accuracy: 0.001,
+ "windows \(windows)")
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: minimum, windows: windows), .grid(columns: windows),
+ "windows \(windows)")
+ XCTAssertEqual(
+ QuotaRingLayout.choose(availableWidth: minimum - 1, windows: windows), .rows, "windows \(windows)")
+ }
+ }
+
+ func testUnifiedPanelWidthStaysWithinItsBounds() {
+ XCTAssertEqual(
+ QuotaRingLayout.unifiedPanelWidth(codexCells: 1, claudeCells: 1), Theme.Layout.minimumRingUnifiedWidth)
+ XCTAssertEqual(
+ QuotaRingLayout.unifiedPanelWidth(codexCells: 3, claudeCells: 3), Theme.Layout.ringUnifiedWidth)
+ let wideCard = QuotaRingLayout.minimumGridWidth(windows: 2) + Theme.Layout.cardPadding * 2
+ let chrome: CGFloat = Theme.Layout.panelPadding * 2 + spacing
+ XCTAssertEqual(
+ QuotaRingLayout.unifiedPanelWidth(codexCells: 1, claudeCells: 2),
+ Theme.Layout.minimumRingCardWidth + wideCard + chrome)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/ScreenshotTests.swift b/Tests/TokenGaugeAppTests/ScreenshotTests.swift
index 667f5f9..276da8f 100644
--- a/Tests/TokenGaugeAppTests/ScreenshotTests.swift
+++ b/Tests/TokenGaugeAppTests/ScreenshotTests.swift
@@ -14,8 +14,9 @@ final class ScreenshotTests: XCTestCase {
let output = URL(fileURLWithPath: directory)
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let previousIcon = NSApplication.shared.applicationIconImage
- let icon = output.deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent(
- "Resources/AppIcon.icns")
+ let icon = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
+ .appendingPathComponent("Resources/AppIcon.icns")
NSApplication.shared.applicationIconImage = try XCTUnwrap(NSImage(contentsOf: icon))
defer { NSApplication.shared.applicationIconImage = previousIcon }
let suite = "TokenGauge.screenshots.\(UUID().uuidString)"
@@ -35,27 +36,27 @@ final class ScreenshotTests: XCTestCase {
}
let snapshots = UsageProvider.allCases.map { provider in
var windows = [
- QuotaWindow(
+ Fixture.window(
id: "\(provider.rawValue).primary", usedPercentage: provider == .codex ? 28 : 42,
- resetsAt: now.addingTimeInterval(4 * 86400), durationMinutes: 10080, displayName: nil)
+ resetsAt: now.addingTimeInterval(4 * 86400), durationMinutes: 10080)
]
if provider == .codex {
windows.append(
- QuotaWindow(
+ Fixture.window(
id: "base_model_inference.primary", usedPercentage: 0,
- resetsAt: now.addingTimeInterval(6 * 86400), durationMinutes: 10080, displayName: "gpt-reserve")
+ resetsAt: now.addingTimeInterval(6 * 86400), durationMinutes: 10080,
+ displayName: "gpt-reserve")
)
}
- return ProviderUsageSnapshot(
- provider: provider,
+ return Fixture.snapshot(
+ provider,
windows: windows,
dailyUsage: activity.enumerated().map { index, usage in
let tokens = provider == .codex ? usage.tokens : (index >= 3 && index <= 5 ? 0 : usage.tokens / 3)
return DailyTokenUsage(day: usage.day, tokens: tokens)
- },
- summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: now)
+ }, capturedAt: now)
}
- let store = UsageStore(defaults: defaults, initialSnapshots: snapshots, historyReadsEnabled: false)
+ let store = Fixture.store(defaults: defaults, snapshots: snapshots)
try render(
PopoverView(store: store, showSettings: {}, showAbout: {}), to: output.appendingPathComponent("panel.png"))
store.displayMode = .claude
@@ -70,20 +71,19 @@ final class ScreenshotTests: XCTestCase {
SettingsView(store: store, launchAtLogin: LaunchAtLoginManager()).defaultAppStorage(defaults)
}.frame(width: 600, height: 750),
to: output.appendingPathComponent("settings.png"), maximumHeight: 750)
- let historical = ProviderUsageSnapshot(
- provider: .claude,
+ let historical = Fixture.snapshot(
+ .claude,
windows: [
- QuotaWindow(
- id: "five_hour", usedPercentage: 2, resetsAt: now.addingTimeInterval(10_800), durationMinutes: 300,
- displayName: nil),
- QuotaWindow(
+ Fixture.window(
+ id: "five_hour", usedPercentage: 2, resetsAt: now.addingTimeInterval(10_800),
+ durationMinutes: 300),
+ Fixture.window(
id: "seven_day", usedPercentage: 60, resetsAt: now.addingTimeInterval(172_800),
- durationMinutes: 10_080, displayName: nil),
- QuotaWindow(
+ durationMinutes: 10_080),
+ Fixture.window(
id: "seven_day_fable", usedPercentage: 88, resetsAt: now.addingTimeInterval(172_800),
durationMinutes: 10_080, displayName: "Fable"),
- ], dailyUsage: [], summary: nil, availableResetCredits: nil, creditBalance: nil,
- capturedAt: now.addingTimeInterval(-3600))
+ ], capturedAt: now.addingTimeInterval(-3600))
store.claudeAutomaticRecovery = true
store.applyRefreshResults(
claudeResult: ClaudeUsageResult(snapshot: historical, access: .credentialExpired, lastActivityAt: nil),
@@ -172,36 +172,18 @@ final class ScreenshotTests: XCTestCase {
let fitted = try render(
PopoverView(store: store, showSettings: {}, showAbout: {}),
to: output.appendingPathComponent("rings-current.png"))
- XCTAssertEqual(fitted.width, Theme.Layout.minimumRingUnifiedWidth)
-
- }
-
- @discardableResult
- private func render(_ content: some View, to output: URL, maximumHeight: CGFloat = Theme.Layout.maximumPanelHeight)
- throws -> NSSize
- {
- let application = NSApplication.shared
- let previousAppearance = application.appearance
- application.appearance = NSAppearance(named: .darkAqua)
- defer { application.appearance = previousAppearance }
- let view = NSHostingView(
- rootView: content.background(Color(nsColor: .windowBackgroundColor)).environment(\.colorScheme, .dark))
- view.appearance = NSAppearance(named: .darkAqua)
- let size = view.fittingSize
- XCTAssertGreaterThan(size.width, 200)
- XCTAssertLessThanOrEqual(size.height, maximumHeight)
- view.frame = NSRect(origin: .zero, size: size)
- let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false)
- window.backgroundColor = .windowBackgroundColor
- window.contentView = view
- view.layoutSubtreeIfNeeded()
- let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
- view.appearance?.performAsCurrentDrawingAppearance {
- view.cacheDisplay(in: view.bounds, to: bitmap)
+ let visibleWindows = { (provider: UsageProvider) -> Int in
+ max(
+ 1,
+ WindowVisibility.visible(
+ store.state(for: provider).snapshot?.windows ?? [], provider: provider,
+ showLunaReserve: store.showLunaReserve, hiddenClaudeWindows: store.hiddenClaudeWindows
+ ).count)
}
- let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
- try png.write(to: output)
- window.contentView = nil
- return size
+ XCTAssertEqual(
+ fitted.width,
+ QuotaRingLayout.unifiedPanelWidth(
+ codexCells: visibleWindows(.codex), claudeCells: visibleWindows(.claude)))
+
}
}
diff --git a/Tests/TokenGaugeAppTests/Support/Fixtures.swift b/Tests/TokenGaugeAppTests/Support/Fixtures.swift
new file mode 100644
index 0000000..e9664cb
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/Support/Fixtures.swift
@@ -0,0 +1,71 @@
+import Foundation
+import TokenGaugeCore
+import XCTest
+
+@testable import TokenGaugeApp
+
+enum Fixture {
+ static func window(
+ id: String,
+ usedPercentage: Double = 10,
+ resetsAt: Date? = nil,
+ durationMinutes: Int? = nil,
+ displayName: String? = nil
+ ) -> QuotaWindow {
+ QuotaWindow(
+ id: id, usedPercentage: usedPercentage, resetsAt: resetsAt, durationMinutes: durationMinutes,
+ displayName: displayName)
+ }
+
+ static func snapshot(
+ _ provider: UsageProvider,
+ windows: [QuotaWindow] = [],
+ dailyUsage: [DailyTokenUsage] = [],
+ availableResetCredits: Int? = nil,
+ capturedAt: Date? = Date(),
+ modelBuckets: [ModelTokenBucket] = []
+ ) -> ProviderUsageSnapshot {
+ ProviderUsageSnapshot(
+ provider: provider, windows: windows, dailyUsage: dailyUsage, summary: nil,
+ availableResetCredits: availableResetCredits, creditBalance: nil, capturedAt: capturedAt,
+ modelBuckets: modelBuckets)
+ }
+
+ static func modelBuckets(_ tokens: [(String, Int)], at hourStart: Date = Date().addingTimeInterval(-1_800))
+ -> [ModelTokenBucket]
+ {
+ tokens.map { model, amount in
+ ModelTokenBucket(
+ day: UsageStore.dayKey(for: hourStart, calendar: .current), hourStart: hourStart, model: model,
+ tokens: amount)
+ }
+ }
+
+ static func pace(_ pointsPerHour: Double, resetsAt: Date?) -> QuotaPace {
+ QuotaPace(
+ pointsPerHour: pointsPerHour, observedMinutes: 45, sampledAt: Date().addingTimeInterval(-600),
+ resetsAt: resetsAt, lastUsedPercentage: 15.6)
+ }
+
+ @MainActor
+ static func store(defaults: UserDefaults, snapshots: [ProviderUsageSnapshot] = []) -> UsageStore {
+ UsageStore(defaults: defaults, initialSnapshots: snapshots, historyReadsEnabled: false)
+ }
+}
+
+func withDefaults(_ body: (UserDefaults) throws -> Void) throws {
+ let suite = "TokenGauge.tests.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ try body(defaults)
+}
+
+@MainActor
+func withEachLanguage(_ body: (AppLanguage) throws -> Void) rethrows {
+ try withCurrentLanguage {
+ for language in AppLanguage.allCases {
+ LocalizationManager.shared.language = language
+ try body(language)
+ }
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/Support/Rendering.swift b/Tests/TokenGaugeAppTests/Support/Rendering.swift
new file mode 100644
index 0000000..1df94cf
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/Support/Rendering.swift
@@ -0,0 +1,62 @@
+import AppKit
+import SwiftUI
+import XCTest
+
+@testable import TokenGaugeApp
+
+@MainActor
+func withCurrentLanguage(_ body: () throws -> Void) rethrows {
+ let language = LocalizationManager.shared.language
+ defer { LocalizationManager.shared.language = language }
+ try body()
+}
+
+@MainActor
+func fittingSize(_ content: some View) -> NSSize {
+ NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false)).fittingSize
+}
+
+@MainActor
+func render(_ content: some View, maximumHeight: CGFloat? = nil) throws -> NSBitmapImageRep {
+ let view = NSHostingView(rootView: content.environment(\.quotaAnimationsEnabled, false))
+ let size = view.fittingSize
+ if let maximumHeight { XCTAssertLessThanOrEqual(size.height, maximumHeight) }
+ view.frame = NSRect(origin: .zero, size: size)
+ let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false)
+ window.contentView = view
+ defer { window.contentView = nil }
+ view.layoutSubtreeIfNeeded()
+ let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ return bitmap
+}
+
+@MainActor
+@discardableResult
+func render(_ content: some View, to output: URL, maximumHeight: CGFloat = Theme.Layout.maximumPanelHeight) throws
+ -> NSSize
+{
+ let application = NSApplication.shared
+ let previousAppearance = application.appearance
+ application.appearance = NSAppearance(named: .darkAqua)
+ defer { application.appearance = previousAppearance }
+ let view = NSHostingView(
+ rootView: content.background(Color(nsColor: .windowBackgroundColor)).environment(\.colorScheme, .dark))
+ view.appearance = NSAppearance(named: .darkAqua)
+ let size = view.fittingSize
+ XCTAssertGreaterThan(size.width, 200)
+ XCTAssertLessThanOrEqual(size.height, maximumHeight)
+ view.frame = NSRect(origin: .zero, size: size)
+ let window = NSWindow(contentRect: view.frame, styleMask: [.borderless], backing: .buffered, defer: false)
+ window.backgroundColor = .windowBackgroundColor
+ window.contentView = view
+ view.layoutSubtreeIfNeeded()
+ let bitmap = try XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
+ view.appearance?.performAsCurrentDrawingAppearance {
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ }
+ let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:]))
+ try png.write(to: output)
+ window.contentView = nil
+ return size
+}
diff --git a/Tests/TokenGaugeAppTests/UpdateBannerRenderingTests.swift b/Tests/TokenGaugeAppTests/UpdateBannerRenderingTests.swift
new file mode 100644
index 0000000..57f4f54
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/UpdateBannerRenderingTests.swift
@@ -0,0 +1,174 @@
+import AppKit
+import SwiftUI
+import XCTest
+
+@testable import TokenGaugeApp
+
+@MainActor
+final class UpdateBannerRenderingTests: XCTestCase {
+
+ private let phases: [UpdateManager.Phase] = [
+ .checking,
+ .available(version: "1.4.0"),
+ .downloading(version: "1.4.0", fraction: nil),
+ .downloading(version: "1.4.0", fraction: 0.43),
+ .extracting(version: "1.4.0", fraction: 0.6),
+ .readyToInstall(version: "1.4.0", deferred: false),
+ .readyToInstall(version: "1.4.0", deferred: true),
+ .installing(version: "1.4.0"),
+ .failed(message: UpdateManager.failureMessageKey),
+ ]
+
+ func testEveryBannerStateKeepsItsDeclaredHeight() throws {
+ let language = LocalizationManager.shared.language
+ defer { LocalizationManager.shared.language = language }
+ for language in AppLanguage.allCases {
+ LocalizationManager.shared.language = language
+ for phase in phases {
+ let manager = try makeManager(phase: phase)
+ let view = NSHostingView(
+ rootView: UpdateBannerView(updates: manager).frame(width: Theme.Layout.panelWidth))
+ XCTAssertEqual(
+ view.fittingSize.height, UpdateBannerView.height(for: phase), accuracy: 1,
+ "\(language.rawValue) / \(phase)")
+ XCTAssertEqual(view.fittingSize.width, Theme.Layout.panelWidth, accuracy: 1)
+ }
+ }
+ }
+
+ func testIdleBannerRendersNothing() throws {
+ let manager = try makeManager(phase: .idle)
+ let view = NSHostingView(rootView: UpdateBannerView(updates: manager))
+
+ XCTAssertEqual(UpdateBannerView.height(for: .idle), 0)
+ XCTAssertEqual(view.fittingSize.height, 0, accuracy: 1)
+ }
+
+ func testAboutKeepsTheManualCheckReachableOutsideARunningUpdate() throws {
+ let check = try actionBitmap(phase: .idle)
+ for phase in [
+ UpdateManager.Phase.available(version: "1.4.0"), .readyToInstall(version: "1.4.0", deferred: true),
+ ] {
+ let bitmap = try actionBitmap(phase: phase)
+ XCTAssertGreaterThan(bitmap.pixelsHigh, check.pixelsHigh, "\(phase)")
+ XCTAssertTrue(endsWith(bitmap, check), "\(phase)")
+ }
+ let installing = try actionBitmap(phase: .installing(version: "1.4.0"))
+ XCTAssertFalse(endsWith(installing, check))
+
+ let idle = try actionHeight(phase: .idle)
+ XCTAssertGreaterThan(idle, 0)
+ XCTAssertLessThan(idle, UpdateBannerView.compactHeight)
+ XCTAssertEqual(
+ try actionHeight(phase: .installing(version: "1.4.0")), UpdateBannerView.compactHeight, accuracy: 1)
+ }
+
+ func testDeferredBannerKeepsTheReadyTitle() throws {
+ let ready = try bannerBitmap(phase: .readyToInstall(version: "1.4.0", deferred: false))
+ let deferred = try bannerBitmap(phase: .readyToInstall(version: "1.4.0", deferred: true))
+ let checking = try bannerBitmap(phase: .checking)
+
+ XCTAssertTrue(leadingHalfMatches(ready, deferred))
+ XCTAssertFalse(leadingHalfMatches(checking, deferred))
+ }
+
+ private func bannerBitmap(phase: UpdateManager.Phase) throws -> NSBitmapImageRep {
+ let manager = try makeManager(phase: phase)
+ return try render(
+ UpdateBannerView(updates: manager).frame(width: Theme.Layout.panelWidth)
+ .environment(\.colorScheme, .light).background(Color.white))
+ }
+
+ private func leadingHalfMatches(_ lhs: NSBitmapImageRep, _ rhs: NSBitmapImageRep) -> Bool {
+ guard lhs.pixelsWide == rhs.pixelsWide, lhs.pixelsHigh == rhs.pixelsHigh else { return false }
+ let width = lhs.pixelsWide / 2
+ var different = 0
+ for row in 0.. 0.1
+ || abs(left.greenComponent - right.greenComponent) > 0.1
+ || abs(left.blueComponent - right.blueComponent) > 0.1
+ {
+ different += 1
+ }
+ }
+ }
+ return different * 100 < width * lhs.pixelsHigh
+ }
+
+ private func actionHeight(phase: UpdateManager.Phase) throws -> CGFloat {
+ let manager = try makeManager(phase: phase)
+ let view = NSHostingView(
+ rootView: UpdateActionView(updates: manager).frame(width: Theme.Layout.panelWidth))
+ return view.fittingSize.height
+ }
+
+ private func actionBitmap(phase: UpdateManager.Phase) throws -> NSBitmapImageRep {
+ let manager = try makeManager(phase: phase)
+ return try render(
+ UpdateActionView(updates: manager).frame(width: Theme.Layout.panelWidth)
+ .environment(\.colorScheme, .light).background(Color.white))
+ }
+
+ private func endsWith(_ bitmap: NSBitmapImageRep, _ tail: NSBitmapImageRep) -> Bool {
+ guard bitmap.pixelsWide == tail.pixelsWide, bitmap.pixelsHigh >= tail.pixelsHigh else { return false }
+ var different = 0
+ for row in 0.. 0.1
+ || abs(left.greenComponent - right.greenComponent) > 0.1
+ || abs(left.blueComponent - right.blueComponent) > 0.1
+ {
+ different += 1
+ }
+ }
+ }
+ return different * 100 < tail.pixelsWide * tail.pixelsHigh
+ }
+
+ private func makeManager(phase: UpdateManager.Phase) throws -> UpdateManager {
+ let suite = "TokenGauge.banner.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ addTeardownBlock { defaults.removePersistentDomain(forName: suite) }
+ let manager = UpdateManager(defaults: defaults)
+ switch phase {
+ case .idle:
+ break
+ case .checking:
+ manager.handleManualCheckStarted()
+ case .available(let version):
+ _ = manager.handleUpdateFound(version: version, releasePage: nil, informationOnly: false)
+ case .downloading(let version, let fraction):
+ _ = manager.handleUpdateFound(version: version, releasePage: nil, informationOnly: false)
+ manager.handleDownloadInitiated()
+ if let fraction {
+ manager.handleDownloadExpectedLength(1_000)
+ manager.handleDownloadReceived(bytes: UInt64(fraction * 1_000))
+ }
+ case .extracting(let version, let fraction):
+ _ = manager.handleUpdateFound(version: version, releasePage: nil, informationOnly: false)
+ manager.handleExtractionStarted()
+ if let fraction { manager.handleExtractionProgress(fraction) }
+ case .readyToInstall(let version, let deferred):
+ _ = manager.handleUpdateFound(version: version, releasePage: nil, informationOnly: false)
+ manager.handleReadyToInstall { _ in }
+ if deferred { manager.deferReadyUpdate() }
+ case .installing(let version):
+ _ = manager.handleUpdateFound(version: version, releasePage: nil, informationOnly: false)
+ manager.handleInstalling()
+ case .failed:
+ _ = manager.handleUpdateFound(version: "1.4.0", releasePage: nil, informationOnly: false)
+ manager.handleInstallRequested()
+ manager.handleError("failure")
+ }
+ XCTAssertEqual(manager.phase, phase)
+ return manager
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/UpdateManagerTests.swift b/Tests/TokenGaugeAppTests/UpdateManagerTests.swift
index e211b34..7b8ef60 100644
--- a/Tests/TokenGaugeAppTests/UpdateManagerTests.swift
+++ b/Tests/TokenGaugeAppTests/UpdateManagerTests.swift
@@ -1,12 +1,45 @@
+import Combine
+import Sparkle
import XCTest
@testable import TokenGaugeApp
+@MainActor
+private final class FakeUpdaterSession: UpdaterSession {
+ var sessionInProgress: Bool
+ private(set) var checks = 0
+
+ init(sessionInProgress: Bool) {
+ self.sessionInProgress = sessionInProgress
+ }
+
+ func checkForUpdates() {
+ checks += 1
+ }
+}
+
@MainActor
final class UpdateManagerTests: XCTestCase {
- private func makeManager() -> UpdateManager {
- UpdateManager(defaults: UserDefaults(suiteName: "TokenGauge.UpdaterTests." + UUID().uuidString)!)
+ private var clock = Date(timeIntervalSince1970: 1_000)
+
+ private func makeDefaults() -> UserDefaults {
+ let suite = "TokenGauge.UpdaterTests." + UUID().uuidString
+ let defaults = UserDefaults(suiteName: suite)!
+ addTeardownBlock { defaults.removePersistentDomain(forName: suite) }
+ return defaults
+ }
+
+ private func makeManager(defaults: UserDefaults? = nil) -> UpdateManager {
+ UpdateManager(defaults: defaults ?? makeDefaults(), now: { [unowned self] in self.clock })
+ }
+
+ private func found(
+ _ manager: UpdateManager, version: String = "9.9.9", informationOnly: Bool = false,
+ stage: UpdateManager.Stage = .notDownloaded
+ ) -> SPUUserUpdateChoice {
+ manager.handleUpdateFound(
+ version: version, releasePage: nil, informationOnly: informationOnly, stage: stage)
}
func testScheduledFoundUpdateIsDismissedAndSurfaced() {
@@ -26,24 +59,22 @@ final class UpdateManagerTests: XCTestCase {
let manager = makeManager()
manager.handleInstallRequested()
- let choice = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: true)
-
- XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(found(manager, informationOnly: true), .dismiss)
XCTAssertEqual(manager.phase, .available(version: "9.9.9"))
}
func testDownloadProgressIsFractionOfExpectedLength() {
let manager = makeManager()
+ _ = found(manager)
manager.handleDownloadInitiated()
- XCTAssertEqual(manager.phase, .downloading(fraction: nil))
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: nil))
manager.handleDownloadExpectedLength(1_000)
manager.handleDownloadReceived(bytes: 250)
- XCTAssertEqual(manager.phase, .downloading(fraction: 0.25))
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: 0.25))
manager.handleDownloadReceived(bytes: 750)
- XCTAssertEqual(manager.phase, .downloading(fraction: 1.0))
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: 1.0))
}
func testUnknownContentLengthStaysIndeterminate() {
@@ -51,7 +82,7 @@ final class UpdateManagerTests: XCTestCase {
manager.handleDownloadInitiated()
manager.handleDownloadReceived(bytes: 4_096)
- XCTAssertEqual(manager.phase, .downloading(fraction: nil))
+ XCTAssertEqual(manager.phase, .downloading(version: "", fraction: nil))
}
func testDownloadFractionIsCappedAtOne() {
@@ -60,23 +91,137 @@ final class UpdateManagerTests: XCTestCase {
manager.handleDownloadExpectedLength(100)
manager.handleDownloadReceived(bytes: 250)
- XCTAssertEqual(manager.phase, .downloading(fraction: 1.0))
+ XCTAssertEqual(manager.phase, .downloading(version: "", fraction: 1.0))
}
- func testExtractionAndReadyToInstallShowInstalling() {
+ func testProgressPublishesAtMostTwentyTimesPerSecond() {
let manager = makeManager()
+ _ = found(manager)
+ manager.handleDownloadInitiated()
+ manager.handleDownloadExpectedLength(1_000)
+
+ manager.handleDownloadReceived(bytes: 100)
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: 0.1))
+
+ manager.handleDownloadReceived(bytes: 100)
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: 0.1))
+
+ clock.addTimeInterval(UpdateManager.progressPublishInterval * 2)
+ manager.handleDownloadReceived(bytes: 100)
+ XCTAssertEqual(manager.phase, .downloading(version: "9.9.9", fraction: 0.3))
+ }
+
+ func testExtractionProgressIsReported() {
+ let manager = makeManager()
+ _ = found(manager)
manager.handleExtractionStarted()
- XCTAssertEqual(manager.phase, .installing)
+ XCTAssertEqual(manager.phase, .extracting(version: "9.9.9", fraction: nil))
+
+ manager.handleExtractionProgress(0.5)
+ XCTAssertEqual(manager.phase, .extracting(version: "9.9.9", fraction: 0.5))
+
+ manager.handleExtractionProgress(1.0)
+ XCTAssertEqual(manager.phase, .extracting(version: "9.9.9", fraction: 1.0))
+ }
+
+ func testReadyToInstallWaitsForTheUserChoice() {
+ let manager = makeManager()
+ _ = found(manager)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: false))
+ XCTAssertTrue(choices.isEmpty)
- XCTAssertEqual(manager.handleReadyToInstall(), .install)
- XCTAssertEqual(manager.phase, .installing)
+ manager.installReadyUpdate()
+
+ XCTAssertEqual(choices, [.install])
+ XCTAssertEqual(manager.phase, .installing(version: "9.9.9"))
+ }
+
+ func testDeferredReadyUpdateStaysVisibleAndResumesWithInstall() {
+ let manager = makeManager()
+ _ = found(manager)
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ manager.deferReadyUpdate()
+ XCTAssertEqual(choices, [.dismiss])
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: true))
+
+ manager.handleDismissInstallation()
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: true))
+
+ manager.setSession(FakeUpdaterSession(sessionInProgress: false))
+ manager.resumeDeferredInstall()
+ XCTAssertEqual(manager.phase, .installing(version: "9.9.9"))
+ XCTAssertEqual(found(manager, stage: .downloaded), .install)
+
+ manager.handleReadyToInstall { choices.append($0) }
+ XCTAssertEqual(choices, [.dismiss, .install])
+ XCTAssertEqual(manager.phase, .installing(version: "9.9.9"))
+ }
+
+ func testDownloadRequestedFromTheBannerStopsAtReadyToInstall() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager, version: "1.4.0")
+ manager.handleInstallRequested()
+ XCTAssertEqual(found(manager, version: "1.4.0"), .install)
+
+ manager.handleDownloadInitiated()
+ manager.handleDownloadExpectedLength(1_000)
+ manager.handleDownloadReceived(bytes: 1_000)
+ manager.handleExtractionStarted()
+ manager.handleExtractionProgress(1.0)
+
+ var choices: [SPUUserUpdateChoice] = []
+ manager.handleReadyToInstall { choices.append($0) }
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "1.4.0", deferred: false))
+ XCTAssertTrue(choices.isEmpty)
+
+ manager.deferReadyUpdate()
+ XCTAssertEqual(choices, [.dismiss])
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "1.4.0", deferred: true))
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "1.4.0")
+
+ let session = FakeUpdaterSession(sessionInProgress: false)
+ manager.setSession(session)
+ manager.resumeDeferredInstall()
+ XCTAssertEqual(session.checks, 1)
+ XCTAssertEqual(found(manager, version: "1.4.0", stage: .downloaded), .install)
+
+ manager.handleReadyToInstall { choices.append($0) }
+ XCTAssertEqual(choices, [.dismiss, .install])
+ XCTAssertEqual(manager.phase, .installing(version: "1.4.0"))
+ XCTAssertNil(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey))
+ }
+
+ func testDownloadedStageWithoutInstallIntentSurfacesTheDeferredRow() {
+ XCTAssertEqual(
+ UpdateManager.decideUpdateFound(
+ version: "1.4.0", stage: .downloaded, installRequested: false, informationOnly: false),
+ UpdateManager.FoundDecision(
+ choice: .dismiss, phase: .readyToInstall(version: "1.4.0", deferred: true)))
+ XCTAssertEqual(
+ UpdateManager.decideUpdateFound(
+ version: "1.4.0", stage: .downloaded, installRequested: true, informationOnly: false),
+ UpdateManager.FoundDecision(choice: .install, phase: .installing(version: "1.4.0")))
+ XCTAssertEqual(
+ UpdateManager.decideUpdateFound(
+ version: "1.4.0", stage: .notDownloaded, installRequested: true, informationOnly: false),
+ UpdateManager.FoundDecision(
+ choice: .install, phase: .downloading(version: "1.4.0", fraction: nil)))
+ XCTAssertEqual(
+ UpdateManager.decideUpdateFound(
+ version: "1.4.0", stage: .downloaded, installRequested: true, informationOnly: true),
+ UpdateManager.FoundDecision(choice: .dismiss, phase: .available(version: "1.4.0")))
}
func testScheduledCheckErrorStaysSilent() {
let manager = makeManager()
- let choice = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
- XCTAssertEqual(choice, .dismiss)
+ XCTAssertEqual(found(manager), .dismiss)
manager.handleError("network down")
@@ -92,8 +237,7 @@ final class UpdateManagerTests: XCTestCase {
func testDismissDuringDownloadRollsBackToAvailable() {
let manager = makeManager()
- _ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ _ = found(manager)
manager.handleDownloadInitiated()
manager.handleDismissInstallation()
@@ -103,8 +247,7 @@ final class UpdateManagerTests: XCTestCase {
func testDismissKeepsPendingRowAlive() {
let manager = makeManager()
- _ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ _ = found(manager)
manager.handleDismissInstallation()
@@ -113,29 +256,23 @@ final class UpdateManagerTests: XCTestCase {
func testNotFoundClearsPendingState() {
let manager = makeManager()
- _ = manager.handleUpdateFound(
- version: "9.9.9", releasePage: nil, informationOnly: false)
+ _ = found(manager)
manager.handleNotFound()
XCTAssertEqual(manager.phase, .idle)
XCTAssertNil(manager.releasePageURL)
}
+
func testCancelledInstallationCannotAuthorizeTheNextCheck() {
let manager = makeManager()
- _ = manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false)
+ _ = found(manager, version: "1.0.1")
manager.handleInstallRequested()
- XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
- .install)
+ XCTAssertEqual(found(manager, version: "1.0.1"), .install)
manager.handleDismissInstallation()
- XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
- .dismiss)
+ XCTAssertEqual(found(manager, version: "1.0.1"), .dismiss)
manager.handleInstallRequested()
- XCTAssertEqual(
- manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false),
- .install)
+ XCTAssertEqual(found(manager, version: "1.0.1"), .install)
}
func testQAFeedRequiresExplicitOptInAndLoopback() {
@@ -155,15 +292,191 @@ final class UpdateManagerTests: XCTestCase {
raw)
}
}
+
+ func testFeedOverridePrecedence() {
+ let qa = ["TOKENGAUGE_QA_UPDATES": "1", "TOKENGAUGE_UPDATE_FEED_URL": "http://127.0.0.1:18764/appcast.xml"]
+ let override = "https://updates.example.com/override.xml"
+
+ XCTAssertEqual(
+ UpdateManager.feedURL(environment: qa, override: override, isDevelopmentBuild: true),
+ qa["TOKENGAUGE_UPDATE_FEED_URL"])
+ XCTAssertEqual(
+ UpdateManager.feedURL(environment: [:], override: override, isDevelopmentBuild: true), override)
+ XCTAssertEqual(
+ UpdateManager.feedURL(environment: qa, override: override, isDevelopmentBuild: false), override)
+ XCTAssertNil(UpdateManager.feedURL(environment: [:], override: nil, isDevelopmentBuild: false))
+ XCTAssertNil(UpdateManager.feedURL(environment: [:], override: "not a url", isDevelopmentBuild: false))
+ }
+
+ func testFeedOverrideIsHonouredOnlyOverHTTPS() {
+ for raw in ["http://127.0.0.1:8000/override.xml", "http://updates.example.com/appcast.xml"] {
+ XCTAssertNil(UpdateManager.feedURL(environment: [:], override: raw, isDevelopmentBuild: false))
+ XCTAssertNil(UpdateManager.feedURL(environment: [:], override: raw, isDevelopmentBuild: true))
+ }
+ XCTAssertEqual(
+ UpdateManager.feedURL(
+ environment: [:], override: "https://updates.example.com/appcast.xml", isDevelopmentBuild: false),
+ "https://updates.example.com/appcast.xml")
+ let qa = ["TOKENGAUGE_QA_UPDATES": "1", "TOKENGAUGE_UPDATE_FEED_URL": "http://127.0.0.1:18764/appcast.xml"]
+ XCTAssertEqual(
+ UpdateManager.feedURL(environment: qa, override: nil, isDevelopmentBuild: true),
+ qa["TOKENGAUGE_UPDATE_FEED_URL"])
+ }
+
+ func testManualCheckWithoutAnUpdaterReturnsToIdle() {
+ let defaults = makeDefaults()
+ defaults.set("https://updates.example.com/appcast.xml", forKey: UpdateManager.feedOverrideDefaultsKey)
+ let manager = makeManager(defaults: defaults)
+
+ manager.checkForUpdatesManually()
+
+ XCTAssertEqual(manager.manualCheckStatus, .failed)
+ XCTAssertEqual(manager.phase, .idle)
+ }
+
+ func testDismissedCheckReturnsToIdle() {
+ let manager = makeManager()
+ manager.handleManualCheckStarted()
+ XCTAssertEqual(manager.phase, .checking)
+
+ manager.handleDismissInstallation()
+
+ XCTAssertEqual(manager.phase, .idle)
+ }
+
+ func testScheduledCheckNeverShowsTheCheckingPhase() {
+ let manager = makeManager()
+ var phases: [UpdateManager.Phase] = []
+ let subscription = manager.$phase.sink { phases.append($0) }
+
+ _ = found(manager)
+ manager.handleNotFound()
+ subscription.cancel()
+
+ XCTAssertFalse(phases.contains(.checking))
+ XCTAssertEqual(manager.phase, .idle)
+ }
+
+ func testDownloadBarCompletesBeforeExtractionStarts() {
+ let manager = makeManager()
+ _ = found(manager)
+ manager.handleDownloadInitiated()
+ manager.handleDownloadExpectedLength(1_000)
+ manager.handleDownloadReceived(bytes: 400)
+ var phases: [UpdateManager.Phase] = []
+ let subscription = manager.$phase.sink { phases.append($0) }
+
+ manager.handleExtractionStarted()
+ subscription.cancel()
+
+ XCTAssertEqual(
+ phases,
+ [
+ .downloading(version: "9.9.9", fraction: 0.4),
+ .downloading(version: "9.9.9", fraction: 1),
+ .extracting(version: "9.9.9", fraction: nil),
+ ])
+ }
+
+ func testDeferredVersionIsPersistedUntilTheUpdateInstalls() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager)
+ manager.handleReadyToInstall { _ in }
+
+ manager.deferReadyUpdate()
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "9.9.9")
+
+ manager.setSession(FakeUpdaterSession(sessionInProgress: false))
+ manager.resumeDeferredInstall()
+ XCTAssertEqual(found(manager, stage: .downloaded), .install)
+ XCTAssertNil(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey))
+ }
+
+ func testDownloadedStageRestoresTheDeferredVersionAfterRelaunch() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+
+ XCTAssertEqual(found(manager, stage: .downloaded), .dismiss)
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: true))
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "9.9.9")
+ }
+
+ func testDeferredVersionIsClearedByAnotherVersionAndByNoUpdate() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager, stage: .downloaded)
+
+ _ = found(manager, version: "9.9.10")
+ XCTAssertNil(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey))
+
+ _ = found(manager, stage: .downloaded)
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "9.9.9")
+
+ manager.handleNotFound()
+ XCTAssertNil(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey))
+ }
+
+ func testInstallingAReadyUpdateClearsTheDeferredVersion() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager, stage: .downloaded)
+ manager.handleReadyToInstall { _ in }
+ manager.deferReadyUpdate()
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "9.9.9")
+
+ manager.handleReadyToInstall { _ in }
+ manager.installReadyUpdate()
+
+ XCTAssertEqual(manager.phase, .installing(version: "9.9.9"))
+ XCTAssertNil(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey))
+ }
+
func testManualCheckFailureIsVisibleWithoutPublishingErrorDetails() {
let manager = makeManager()
manager.handleManualCheckStarted()
XCTAssertEqual(manager.manualCheckStatus, .checking)
+ XCTAssertEqual(manager.phase, .checking)
manager.handleError("private network error detail")
XCTAssertEqual(manager.manualCheckStatus, .failed)
XCTAssertEqual(manager.phase, .idle)
}
+ func testResumeDeferredInstallIsIgnoredWhileASessionIsInProgress() {
+ let defaults = makeDefaults()
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager)
+ manager.handleReadyToInstall { _ in }
+ manager.deferReadyUpdate()
+ let session = FakeUpdaterSession(sessionInProgress: true)
+ manager.setSession(session)
+
+ manager.resumeDeferredInstall()
+
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: true))
+ XCTAssertEqual(session.checks, 0)
+ XCTAssertEqual(defaults.string(forKey: UpdateManager.deferredVersionDefaultsKey), "9.9.9")
+ XCTAssertEqual(found(manager, stage: .downloaded), .dismiss)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: true))
+ }
+
+ func testManualCheckReportsFailureWhileASessionIsInProgress() {
+ let defaults = makeDefaults()
+ defaults.set("https://updates.example.com/appcast.xml", forKey: UpdateManager.feedOverrideDefaultsKey)
+ let manager = makeManager(defaults: defaults)
+ _ = found(manager)
+ manager.handleReadyToInstall { _ in }
+ let session = FakeUpdaterSession(sessionInProgress: true)
+ manager.setSession(session)
+
+ manager.checkForUpdatesManually()
+
+ XCTAssertEqual(manager.manualCheckStatus, .failed)
+ XCTAssertEqual(manager.phase, .readyToInstall(version: "9.9.9", deferred: false))
+ XCTAssertEqual(session.checks, 0)
+ }
+
func testManualCheckNotFoundReportsUpToDate() {
let manager = makeManager()
manager.handleManualCheckStarted()
@@ -173,10 +486,10 @@ final class UpdateManagerTests: XCTestCase {
func testInstallationErrorRequiresExplicitRetry() {
let manager = makeManager()
- _ = manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false)
+ _ = found(manager, version: "1.0.1")
manager.handleInstallRequested()
manager.handleError("download failed")
- XCTAssertEqual(manager.phase, .failed(version: "1.0.1"))
- XCTAssertEqual(manager.handleUpdateFound(version: "1.0.1", releasePage: nil, informationOnly: false), .dismiss)
+ XCTAssertEqual(manager.phase, .failed(message: UpdateManager.failureMessageKey))
+ XCTAssertEqual(found(manager, version: "1.0.1"), .dismiss)
}
}
diff --git a/Tests/TokenGaugeAppTests/UsageStoreRefreshTests.swift b/Tests/TokenGaugeAppTests/UsageStoreRefreshTests.swift
new file mode 100644
index 0000000..7dd9fb1
--- /dev/null
+++ b/Tests/TokenGaugeAppTests/UsageStoreRefreshTests.swift
@@ -0,0 +1,209 @@
+import Combine
+import Foundation
+import TokenGaugeCore
+import XCTest
+import os
+
+@testable import TokenGaugeApp
+
+@MainActor
+final class UsageStoreRefreshTests: XCTestCase {
+ func testRefreshPublishesEachProviderOnce() async throws {
+ try await withDefaults { defaults in
+ let now = Date()
+ let claudeSnapshot = snapshot(.claude, now: now)
+ let codexSnapshot = snapshot(.codex, now: now)
+ let refresher = UsageRefresher(
+ homeDirectory: FileManager.default.temporaryDirectory,
+ fetchClaude: { _ in ClaudeUsageResult(snapshot: claudeSnapshot, access: .live, lastActivityAt: now) },
+ fetchCodex: { ProviderViewState(snapshot: codexSnapshot, status: .ready, isRefreshing: false) })
+ let store = UsageStore(
+ refresher: refresher, defaults: defaults, initialSnapshots: [claudeSnapshot, codexSnapshot],
+ historyReadsEnabled: false)
+ var claudePublishes: [ProviderViewState] = []
+ var codexPublishes: [ProviderViewState] = []
+ let subscriptions = [
+ store.$claude.dropFirst().sink { claudePublishes.append($0) },
+ store.$codex.dropFirst().sink { codexPublishes.append($0) },
+ ]
+
+ store.refresh(force: true)
+ try await waitUntilIdle(store)
+
+ XCTAssertEqual(claudePublishes.map(\.isRefreshing), [true, false])
+ XCTAssertEqual(codexPublishes.map(\.isRefreshing), [true, false])
+ XCTAssertEqual(store.claude.status, .ready)
+ XCTAssertEqual(store.codex.status, .ready)
+ XCTAssertEqual(store.claude.snapshot, claudeSnapshot)
+ XCTAssertEqual(store.codex.snapshot, codexSnapshot)
+ XCTAssertNotNil(store.lastRefresh)
+ withExtendedLifetime(subscriptions) {}
+ }
+ }
+
+ func testUnforcedRefreshWaitsAMinuteAndForcedRefreshFetchesAgain() async throws {
+ try await withDefaults { defaults in
+ let fetches = OSAllocatedUnfairLock(initialState: 0)
+ let refresher = UsageRefresher(
+ homeDirectory: FileManager.default.temporaryDirectory,
+ fetchClaude: { _ in
+ fetches.withLock { $0 += 1 }
+ return nil
+ },
+ fetchCodex: { ProviderViewState(snapshot: nil, status: .unavailable, isRefreshing: false) })
+ let store = UsageStore(refresher: refresher, defaults: defaults, historyReadsEnabled: false)
+
+ store.refresh(force: true)
+ try await waitUntilIdle(store)
+ XCTAssertEqual(fetches.withLock { $0 }, 1)
+ XCTAssertEqual(store.claude.status, .unavailable)
+
+ store.refresh()
+ XCTAssertFalse(store.isRefreshing)
+ XCTAssertEqual(fetches.withLock { $0 }, 1)
+
+ store.refresh(force: true)
+ try await waitUntilIdle(store)
+ XCTAssertEqual(fetches.withLock { $0 }, 2)
+ }
+ }
+
+ func testCodexOutcomeIsPublishedBeforeClaudeResolves() async throws {
+ try await withDefaults { defaults in
+ let now = Date()
+ let claudeSnapshot = snapshot(.claude, now: now)
+ let codexSnapshot = snapshot(.codex, now: now)
+ let releaseClaude = DispatchSemaphore(value: 0)
+ let refresher = UsageRefresher(
+ homeDirectory: FileManager.default.temporaryDirectory,
+ fetchClaude: { _ in
+ try? await BlockingWork.run { releaseClaude.wait() }
+ return ClaudeUsageResult(snapshot: claudeSnapshot, access: .live, lastActivityAt: now)
+ },
+ fetchCodex: { ProviderViewState(snapshot: codexSnapshot, status: .ready, isRefreshing: false) })
+ let store = UsageStore(
+ refresher: refresher, defaults: defaults, initialSnapshots: [claudeSnapshot, codexSnapshot],
+ historyReadsEnabled: false)
+ let codexPublished = expectation(description: "codex result published")
+ var claudePublishes: [ProviderViewState] = []
+ var codexPublishes: [ProviderViewState] = []
+ let subscriptions = [
+ store.$claude.dropFirst().sink { claudePublishes.append($0) },
+ store.$codex.dropFirst().sink {
+ codexPublishes.append($0)
+ if !$0.isRefreshing { codexPublished.fulfill() }
+ },
+ ]
+
+ store.refresh(force: true)
+ await fulfillment(of: [codexPublished], timeout: 2)
+
+ XCTAssertEqual(codexPublishes.map(\.isRefreshing), [true, false])
+ XCTAssertEqual(store.codex.status, .ready)
+ XCTAssertEqual(store.codex.snapshot, codexSnapshot)
+ XCTAssertEqual(claudePublishes.map(\.isRefreshing), [true])
+ XCTAssertTrue(store.claude.isRefreshing)
+ XCTAssertTrue(store.isRefreshing)
+
+ releaseClaude.signal()
+ try await waitUntilIdle(store)
+
+ XCTAssertEqual(claudePublishes.map(\.isRefreshing), [true, false])
+ XCTAssertEqual(codexPublishes.map(\.isRefreshing), [true, false])
+ XCTAssertEqual(store.claude.status, .ready)
+ XCTAssertNotNil(store.lastRefresh)
+ withExtendedLifetime(subscriptions) {}
+ }
+ }
+
+ func testRefreshStaysBusyUntilTheArchiveBlockFinishes() async throws {
+ try await withDefaults { defaults in
+ let now = Date()
+ let claudeSnapshot = snapshot(.claude, now: now)
+ let fetches = OSAllocatedUnfairLock(initialState: 0)
+ let releaseArchive = DispatchSemaphore(value: 0)
+ let archiveStarted = expectation(description: "archive started")
+ let refresher = UsageRefresher(
+ homeDirectory: FileManager.default.temporaryDirectory,
+ fetchClaude: { _ in
+ fetches.withLock { $0 += 1 }
+ return ClaudeUsageResult(snapshot: claudeSnapshot, access: .live, lastActivityAt: now)
+ },
+ fetchCodex: { ProviderViewState(snapshot: nil, status: .unavailable, isRefreshing: false) })
+ let store = UsageStore(
+ refresher: refresher, defaults: defaults, historyReadsEnabled: true,
+ archiveWork: { _, _, _ in
+ archiveStarted.fulfill()
+ try? await BlockingWork.run { releaseArchive.wait() }
+ })
+
+ store.refresh(force: true)
+ await fulfillment(of: [archiveStarted], timeout: 2)
+
+ XCTAssertTrue(store.isRefreshing)
+ store.refresh(force: true)
+ XCTAssertEqual(fetches.withLock { $0 }, 1)
+
+ releaseArchive.signal()
+ try await waitUntilIdle(store)
+ XCTAssertEqual(fetches.withLock { $0 }, 1)
+ XCTAssertEqual(store.historyRevision, 1)
+ }
+ }
+
+ func testDayKeyMatchesTheLegacyFormatterAndFollowsTheCalendarTimeZone() {
+ let utc = calendar("UTC")
+ let tokyo = calendar("Asia/Tokyo")
+ let lima = calendar("America/Lima")
+ let date = utc.date(from: DateComponents(year: 2026, month: 9, day: 17, hour: 23, minute: 30))!
+
+ XCTAssertEqual(UsageStore.dayKey(for: date, calendar: utc), "2026-09-17")
+ XCTAssertEqual(UsageStore.dayKey(for: date, calendar: tokyo), "2026-09-18")
+ XCTAssertEqual(UsageStore.dayKey(for: date, calendar: lima), "2026-09-17")
+
+ for calendar in [utc, tokyo, lima] {
+ let legacy = DateFormatter()
+ legacy.locale = Locale(identifier: "en_US_POSIX")
+ legacy.calendar = Calendar(identifier: .gregorian)
+ legacy.timeZone = calendar.timeZone
+ legacy.dateFormat = "yyyy-MM-dd"
+ for offset: TimeInterval in [0, 1_800, 86_400 * 100, -86_400 * 400, 86_400 * 3_650] {
+ let probe = date.addingTimeInterval(offset)
+ XCTAssertEqual(UsageStore.dayKey(for: probe, calendar: calendar), legacy.string(from: probe))
+ }
+ }
+ }
+
+ private func calendar(_ timeZone: String) -> Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: timeZone)!
+ return calendar
+ }
+
+ private func waitUntilIdle(_ store: UsageStore) async throws {
+ for _ in 0..<400 where store.isRefreshing {
+ try await Task.sleep(for: .milliseconds(5))
+ }
+ XCTAssertFalse(store.isRefreshing)
+ }
+
+ private func snapshot(_ provider: UsageProvider, now: Date) -> ProviderUsageSnapshot {
+ ProviderUsageSnapshot(
+ provider: provider,
+ windows: [
+ QuotaWindow(
+ id: "\(provider.rawValue).weekly", usedPercentage: 20, resetsAt: now.addingTimeInterval(86_400),
+ durationMinutes: 10_080, displayName: nil)
+ ],
+ dailyUsage: [DailyTokenUsage(day: "2026-09-17", tokens: 100)],
+ summary: nil, availableResetCredits: nil, creditBalance: nil, capturedAt: now,
+ activityReadSucceeded: true)
+ }
+
+ private func withDefaults(_ body: (UserDefaults) async throws -> Void) async rethrows {
+ let suite = "TokenGauge.refresh-tests.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defer { defaults.removePersistentDomain(forName: suite) }
+ try await body(defaults)
+ }
+}
diff --git a/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift b/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
index 969c377..cb85753 100644
--- a/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
+++ b/Tests/TokenGaugeAppTests/WindowVisibilityTests.swift
@@ -7,9 +7,10 @@ import XCTest
final class WindowVisibilityTests: XCTestCase {
func testCodexKeepsOnlyTheWeeklyMainlineWindow() {
let windows = [
- window(id: "codex_bengalfox.primary", duration: 300, name: "GPT-5.3-Codex-Spark"),
- window(id: "codex.primary", duration: 10_080, name: nil),
- window(id: "codex_bengalfox.secondary", duration: 10_080, name: "GPT-5.3-Codex-Spark"),
+ Fixture.window(id: "codex_bengalfox.primary", durationMinutes: 300, displayName: "GPT-5.3-Codex-Spark"),
+ Fixture.window(id: "codex.primary", durationMinutes: 10_080),
+ Fixture.window(
+ id: "codex_bengalfox.secondary", durationMinutes: 10_080, displayName: "GPT-5.3-Codex-Spark"),
]
let visible = WindowVisibility.visible(windows, provider: .codex)
@@ -19,8 +20,8 @@ final class WindowVisibilityTests: XCTestCase {
func testCodexFallsBackToMainlineWindowsWhenNoWeeklyExists() {
let windows = [
- window(id: "codex.primary", duration: 300, name: nil),
- window(id: "codex_spark.primary", duration: 300, name: "Spark"),
+ Fixture.window(id: "codex.primary", durationMinutes: 300),
+ Fixture.window(id: "codex_spark.primary", durationMinutes: 300, displayName: "Spark"),
]
XCTAssertEqual(WindowVisibility.visible(windows, provider: .codex).map(\.id), ["codex.primary"])
@@ -28,8 +29,8 @@ final class WindowVisibilityTests: XCTestCase {
func testClaudeKeepsEveryOfficialWindow() {
let windows = [
- window(id: "five_hour", duration: 300, name: nil),
- window(id: "seven_day", duration: 10_080, name: nil),
+ Fixture.window(id: "five_hour", durationMinutes: 300),
+ Fixture.window(id: "seven_day", durationMinutes: 10_080),
]
XCTAssertEqual(WindowVisibility.visible(windows, provider: .claude).map(\.id), ["five_hour", "seven_day"])
@@ -57,10 +58,6 @@ final class WindowVisibilityTests: XCTestCase {
XCTAssertEqual(chips.map(\.tokens), [400, 100])
}
- private func window(id: String, duration: Int, name: String?) -> QuotaWindow {
- QuotaWindow(id: id, usedPercentage: 10, resetsAt: nil, durationMinutes: duration, displayName: name)
- }
-
private func bucket(hour: Date, model: String, tokens: Int) -> ModelTokenBucket {
ModelTokenBucket(day: "2033-05-18", hourStart: hour, model: model, tokens: tokens)
}
diff --git a/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift
index 9165419..855aeab 100644
--- a/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift
+++ b/Tests/TokenGaugeCoreTests/ClaudeAccountIdentityTests.swift
@@ -65,6 +65,27 @@ final class ClaudeAccountIdentityTests: XCTestCase {
[.modificationDate: stamp.addingTimeInterval(60)], ofItemAtPath: file.path)
XCTAssertEqual(ClaudeAccountIdentityReader.current(homeDirectory: home)?.accountUuid, "uuid-b")
}
+ func testCacheIsReusedForAnUnchangedFileAndRefreshedWhenTheSizeChanges() throws {
+ ClaudeAccountIdentityReader.invalidate()
+ let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ let file = UsagePaths.claudeConfig(homeDirectory: home)
+ let stamp = Date(timeIntervalSince1970: 1_756_500_000)
+ try Data(#"{"oauthAccount":{"accountUuid":"uuid-a"}}"#.utf8).write(to: file)
+ try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path)
+
+ XCTAssertEqual(ClaudeAccountIdentityReader.read(at: file)?.accountUuid, "uuid-a")
+
+ try Data(#"{"oauthAccount":{"accountUuid":"uuid-c"}}"#.utf8).write(to: file)
+ try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path)
+ XCTAssertEqual(ClaudeAccountIdentityReader.read(at: file)?.accountUuid, "uuid-a")
+
+ try Data(#"{"oauthAccount":{"accountUuid":"uuid-c","displayName":"Three"}}"#.utf8).write(to: file)
+ try FileManager.default.setAttributes([.modificationDate: stamp], ofItemAtPath: file.path)
+ XCTAssertEqual(ClaudeAccountIdentityReader.read(at: file)?.displayName, "Three")
+ }
+
func testTransientReadFailureKeepsTheLastIdentityButLogoutClearsIt() throws {
ClaudeAccountIdentityReader.invalidate()
let home = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
diff --git a/Tests/TokenGaugeCoreTests/ClaudeAccountUsageParserTests.swift b/Tests/TokenGaugeCoreTests/ClaudeAccountUsageParserTests.swift
index e5c5f47..d074200 100644
--- a/Tests/TokenGaugeCoreTests/ClaudeAccountUsageParserTests.swift
+++ b/Tests/TokenGaugeCoreTests/ClaudeAccountUsageParserTests.swift
@@ -34,6 +34,29 @@ final class ClaudeAccountUsageParserTests: XCTestCase {
)
}
+ func testWindowsComeOutInDisplayOrderIncludingNilDurations() throws {
+ let input = Data(
+ """
+ {"limits": [
+ {"kind": "weekly_scoped", "percent": 56, "resets_at": null,
+ "scope": {"model": {"id": null, "display_name": "Fable"}}},
+ {"kind": "weekly_all", "percent": 38, "resets_at": null, "scope": null},
+ {"kind": "session", "percent": 13, "resets_at": null, "scope": null}
+ ]}
+ """.utf8
+ )
+
+ XCTAssertEqual(
+ try ClaudeAccountUsageParser.parse(input).map(\.id), ["five_hour", "seven_day", "seven_day_fable"])
+
+ let unbounded = QuotaWindow(
+ id: "unbounded", usedPercentage: 1, resetsAt: nil, durationMinutes: nil, displayName: nil)
+ let windows = try ClaudeAccountUsageParser.parse(input) + [unbounded]
+ XCTAssertEqual(
+ windows.sorted(by: QuotaWindow.displayOrder).map(\.id),
+ ["five_hour", "seven_day", "seven_day_fable", "unbounded"])
+ }
+
func testSkipsScopedLimitsWithoutAModelName() throws {
let input = Data(
"""
@@ -49,4 +72,30 @@ final class ClaudeAccountUsageParserTests: XCTestCase {
func testRejectsPayloadWithoutLimits() {
XCTAssertThrowsError(try ClaudeAccountUsageParser.parse(Data("{\"limits\":[]}".utf8)))
}
+
+ func testReportsEmptyLimitsSeparatelyFromMalformedPayloads() throws {
+ XCTAssertEqual(try ClaudeAccountUsageParser.parseLimits(Data("{\"limits\":[]}".utf8)), .empty)
+ for payload in ["invalid", "{}", "{\"limits\":[{}]}", "{\"limits\":{}}"] {
+ XCTAssertThrowsError(try ClaudeAccountUsageParser.parseLimits(Data(payload.utf8)))
+ }
+ }
+
+ func testParsesResetInstantsWithAndWithoutFractionalSeconds() throws {
+ let input = Data(
+ """
+ {"limits": [
+ {"kind": "session", "percent": 10, "resets_at": "2026-08-25T22:59:59.535623+00:00", "scope": null},
+ {"kind": "weekly_all", "percent": 20, "resets_at": "2026-08-31T07:59:59Z", "scope": null}
+ ]}
+ """.utf8
+ )
+ let windows = try ClaudeAccountUsageParser.parse(input)
+ let expected = [
+ ISO8601DateFormatter().date(from: "2026-08-25T22:59:59+00:00")!,
+ ISO8601DateFormatter().date(from: "2026-08-31T07:59:59Z")!,
+ ]
+ for (window, instant) in zip(windows, expected) {
+ XCTAssertEqual(window.resetsAt?.timeIntervalSince1970 ?? 0, instant.timeIntervalSince1970, accuracy: 1)
+ }
+ }
}
diff --git a/Tests/TokenGaugeCoreTests/HistoryAnalyticsTests.swift b/Tests/TokenGaugeCoreTests/HistoryAnalyticsTests.swift
index ba6bfaa..7e98022 100644
--- a/Tests/TokenGaugeCoreTests/HistoryAnalyticsTests.swift
+++ b/Tests/TokenGaugeCoreTests/HistoryAnalyticsTests.swift
@@ -5,6 +5,11 @@ import XCTest
final class HistoryAnalyticsTests: XCTestCase {
private let now = Date(timeIntervalSince1970: 1_789_000_000)
+ private var calendar: Calendar {
+ var value = Calendar(identifier: .gregorian)
+ value.timeZone = TimeZone(secondsFromGMT: -5 * 3600)!
+ return value
+ }
func testPaceUsesActualElapsedTimeAndDistinctSamples() throws {
let rows = [row(-50, 10), row(-30, 11), row(-5, 13)]
@@ -57,6 +62,46 @@ final class HistoryAnalyticsTests: XCTestCase {
XCTAssertNil(pace(valid + [row(-15, 13)]))
}
+ func testStreaksCountBackFromTodayAndKeepTheLongestRun() {
+ let days: Set = ["2026-09-14", "2026-09-15", "2026-09-16", "2026-09-17"]
+ let result = HistoryAnalytics.streaks(usageDays: days, today: "2026-09-17", calendar: calendar)
+ XCTAssertEqual(result.current, 4)
+ XCTAssertEqual(result.longest, 4)
+ }
+
+ func testStreaksIgnoreGapsAndReportTheLongestPastRun() {
+ let days: Set = [
+ "2026-08-01", "2026-08-02", "2026-08-03", "2026-08-04", "2026-08-05", "2026-09-16", "2026-09-17",
+ ]
+ let result = HistoryAnalytics.streaks(usageDays: days, today: "2026-09-17", calendar: calendar)
+ XCTAssertEqual(result.current, 2)
+ XCTAssertEqual(result.longest, 5)
+ }
+
+ func testCurrentStreakSurvivesATodayWithoutUsageButNotTwoIdleDays() {
+ let days: Set = ["2026-09-15", "2026-09-16"]
+ XCTAssertEqual(
+ HistoryAnalytics.streaks(usageDays: days, today: "2026-09-17", calendar: calendar).current, 2)
+ XCTAssertEqual(
+ HistoryAnalytics.streaks(usageDays: days, today: "2026-09-18", calendar: calendar).current, 0)
+ }
+
+ func testMalformedDayKeysAreIgnoredByStreaks() {
+ let days: Set = ["2026-09-16", "2026-09-17", "garbage", "2026-13-01", "2026-09", "2026-09-32"]
+ let result = HistoryAnalytics.streaks(usageDays: days, today: "2026-09-17", calendar: calendar)
+ XCTAssertEqual(result.current, 2)
+ XCTAssertEqual(result.longest, 2)
+ XCTAssertEqual(HistoryAnalytics.streaks(usageDays: days, today: "today", calendar: calendar).current, 0)
+ }
+
+ func testEmptyAndSingleDayHistories() {
+ XCTAssertEqual(HistoryAnalytics.streaks(usageDays: [], today: "2026-09-17", calendar: calendar).current, 0)
+ XCTAssertEqual(HistoryAnalytics.streaks(usageDays: [], today: "2026-09-17", calendar: calendar).longest, 0)
+ let single = HistoryAnalytics.streaks(usageDays: ["2026-09-17"], today: "2026-09-17", calendar: calendar)
+ XCTAssertEqual(single.current, 1)
+ XCTAssertEqual(single.longest, 1)
+ }
+
private func pace(_ rows: [HistoryQuotaRow]) -> QuotaPace? {
HistoryAnalytics.pace(rows: rows, provider: .claude, windowID: "weekly", now: now)
}
diff --git a/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift b/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift
new file mode 100644
index 0000000..f2fa15c
--- /dev/null
+++ b/Tests/TokenGaugeCoreTests/TranscriptScannerTests.swift
@@ -0,0 +1,535 @@
+import CryptoKit
+import Foundation
+import XCTest
+
+@testable import TokenGaugeCore
+
+final class TranscriptScannerTests: XCTestCase {
+ private let now = ISO8601DateFormatter().date(from: "2026-09-12T18:00:00Z")!
+
+ func testIncrementalRescanReadsOnlyAppendedBytes() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let claudeFile = home.appending(path: ".claude/projects/project/a.jsonl")
+ let codexFile = home.appending(path: ".codex/sessions/2026/09/12/a.jsonl")
+ try write(
+ claudeFile,
+ [
+ claude("m1", time: "12:00:00", output: 3), claude("m1", time: "12:00:01", output: 3),
+ claude("m2", time: "12:05:00", output: 5),
+ ])
+ try write(
+ codexFile,
+ [turnContext("turn-a", effort: "xhigh"), codex("r1", turn: "turn-a", time: "12:30:00", tokens: 40)])
+
+ let cold = try scan(home, state: state)
+ XCTAssertEqual(cold.bytesRead, try size(claudeFile) + size(codexFile))
+ XCTAssertEqual(cold.buckets.map(\.tokens), [128])
+ XCTAssertEqual(cold.effortRecords.map(\.tokens), [63, 65, 40])
+
+ let warm = try scan(home, state: state)
+ XCTAssertEqual(warm.bytesRead, 0)
+ XCTAssertEqual(warm.buckets, cold.buckets)
+ XCTAssertEqual(warm.effortRecords, cold.effortRecords)
+
+ let appended = try append(
+ claudeFile,
+ [
+ claude("m2", time: "12:05:01", output: 9), claude("m3", time: "13:00:00", output: 7),
+ claude("m4", time: "13:10:00", output: 1),
+ ])
+ let incremental = try scan(home, state: state)
+ XCTAssertEqual(incremental.bytesRead, appended)
+ XCTAssertEqual(incremental.buckets.map(\.tokens), [132, 128])
+ XCTAssertEqual(incremental.buckets, try fullBuckets(home))
+ XCTAssertEqual(incremental.effortRecords, try fullRecords(home))
+
+ let grownSize = try size(claudeFile)
+ try truncate(claudeFile, to: grownSize - 10)
+ try append(claudeFile, [claude("m5", time: "14:00:00", output: 2)])
+ XCTAssertGreaterThan(try size(claudeFile), grownSize)
+ let rewritten = try scan(home, state: state)
+ XCTAssertEqual(rewritten.bytesRead, try size(claudeFile))
+ XCTAssertEqual(rewritten.buckets, try fullBuckets(home))
+ XCTAssertEqual(rewritten.effortRecords, try fullRecords(home))
+
+ try truncate(claudeFile, to: Data((claude("m1", time: "12:00:00", output: 3) + "\n").utf8).count)
+ let truncated = try scan(home, state: state)
+ XCTAssertEqual(truncated.bytesRead, try size(claudeFile))
+ XCTAssertEqual(truncated.buckets.map(\.tokens), [63])
+ XCTAssertEqual(truncated.effortRecords.map(\.tokens), [63, 40])
+
+ try FileManager.default.removeItem(at: codexFile)
+ let removed = try scan(home, state: state)
+ XCTAssertEqual(removed.bytesRead, 0)
+ XCTAssertEqual(removed.effortRecords.map(\.provider), [.claude])
+
+ let permissions = try FileManager.default.attributesOfItem(atPath: state.path)[.posixPermissions] as? Int
+ XCTAssertEqual(permissions, 0o600)
+ }
+ }
+
+ func testSinglePassMatchesTwoPassOutputs() throws {
+ try withHome { home in
+ let projects = home.appending(path: ".claude/projects")
+ try write(
+ projects.appending(path: "p1/a.jsonl"),
+ [
+ claude("m1", time: "12:00:00", output: 3), claude("m1", time: "12:00:01", output: 3),
+ claude("m2", time: "12:20:00", output: 4)
+ .replacingOccurrences(of: "claude-fable-5-1", with: ""),
+ claude("m3", time: "12:59:58", output: 1), claude("m3", time: "13:00:02", output: 30),
+ """
+ {"type":"assistant","timestamp":"2026-09-12T14:00:00.000Z","uuid":"uuid-only","message":{"model":"claude-opus-5","usage":{"input_tokens":5,"output_tokens":5}}}
+ """,
+ """
+ {"type":"user","timestamp":"2026-09-12T14:01:00.000Z","uuid":"u1","message":{"role":"user"}}
+ """,
+ "not json",
+ claude("old", time: "12:00:00", output: 2).replacingOccurrences(
+ of: "2026-09-12", with: "2026-08-20"),
+ ])
+ try write(
+ projects.appending(path: "p2/b.jsonl"),
+ [
+ claude("m3", time: "13:00:03", output: 30),
+ claude("m5", time: "15:00:00", output: 8).replacingOccurrences(
+ of: "\"perTurnEffort\":\"medium\",", with: ""),
+ ])
+ let stale = projects.appending(path: "p3/stale.jsonl")
+ try write(stale, [claude("stale", time: "12:00:00", output: 3)])
+ try setModified(stale, Date(timeIntervalSince1970: 0))
+ let aged = projects.appending(path: "p4/aged.jsonl")
+ try write(
+ aged,
+ [claude("aged", time: "12:00:00", output: 6).replacingOccurrences(of: "2026-09-12", with: "2026-09-05")]
+ )
+ try setModified(aged, ISO8601DateFormatter().date(from: "2026-09-05T12:00:00Z")!)
+
+ let sessions = home.appending(path: ".codex/sessions")
+ try write(
+ sessions.appending(path: "2026/09/12/a.jsonl"),
+ [
+ turnContext("turn-a", effort: "xhigh"),
+ codex("r1", turn: "turn-a", time: "12:30:00", tokens: 40),
+ codex("r1", turn: "turn-a", time: "12:30:01", tokens: 40),
+ """
+ {"type":"event_msg","timestamp":"2026-09-12T12:00:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"total_tokens":9999}}}}
+ """,
+ codex("r2", turn: "absent", time: "12:31:00", tokens: 20),
+ turnContext("turn-b", effort: "high"),
+ codex("r3", turn: "turn-b", time: "12:32:00", tokens: 7),
+ ])
+ let resumed = sessions.appending(path: "2026/01/01/resumed.jsonl")
+ try write(
+ resumed,
+ [
+ turnContext("turn-r", effort: "low"),
+ codex("r-old", turn: "turn-r", time: "12:00:00", tokens: 90)
+ .replacingOccurrences(of: "2026-09-12", with: "2026-01-01"),
+ codex("r-new", turn: "turn-r", time: "12:40:00", tokens: 40),
+ ])
+ try setModified(resumed, now)
+ let recentDirectory = sessions.appending(path: "2026/09/11/b.jsonl")
+ try write(
+ recentDirectory,
+ [
+ codex("r-yesterday", time: "12:00:00", tokens: 5).replacingOccurrences(
+ of: "2026-09-12", with: "2026-09-11")
+ ])
+ try setModified(recentDirectory, Date(timeIntervalSince1970: 0))
+
+ let expectedBuckets = try ReferenceScanners.history(projectsRoot: projects, days: 8, now: now)
+ let expectedRecords = try ReferenceScanners.effort(home: home, lookbackDays: 7, now: now)
+ XCTAssertEqual(expectedBuckets.map(\.tokens), [66, 63, 64, 90, 10, 68])
+ XCTAssertEqual(expectedRecords.map(\.tokens), [5, 63, 64, 40, 20, 7, 40, 90, 68])
+
+ let state = home.appending(path: "support/scan-state.json")
+ let single = try TranscriptScanner.scan(homeDirectory: home, stateURL: state, now: now)
+ XCTAssertEqual(single.buckets, expectedBuckets)
+ XCTAssertEqual(single.effortRecords, expectedRecords)
+ XCTAssertEqual(try fullBuckets(home), expectedBuckets)
+ XCTAssertEqual(try fullRecords(home), expectedRecords)
+
+ let warm = try TranscriptScanner.scan(homeDirectory: home, stateURL: state, now: now)
+ XCTAssertEqual(warm.bytesRead, 0)
+ XCTAssertEqual(warm.buckets, expectedBuckets)
+ XCTAssertEqual(warm.effortRecords, expectedRecords)
+ }
+ }
+
+ func testInPlaceRewriteAtALineBoundaryFallsBackToFullRescan() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let file = home.appending(path: ".claude/projects/project/a.jsonl")
+ try write(
+ file,
+ [
+ claude("m1", time: "12:00:00", output: 3), claude("m2", time: "12:05:00", output: 5),
+ claude("m3", time: "12:10:00", output: 7),
+ ])
+ let original = try size(file)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, original)
+
+ let head = claude("n1", time: "13:00:00", output: 4)
+ let padding = original - Data((head + "\n").utf8).count
+ XCTAssertGreaterThan(padding, 0)
+ let boundary = pad(head, by: padding)
+ XCTAssertEqual(Data((boundary + "\n").utf8).count, original)
+ try rewrite(
+ file,
+ [
+ boundary, claude("n2", time: "13:05:00", output: 6), claude("n3", time: "13:10:00", output: 8),
+ claude("n4", time: "13:15:00", output: 2),
+ ])
+ XCTAssertGreaterThan(try size(file), original)
+
+ let rewritten = try scan(home, state: state)
+ XCTAssertEqual(rewritten.bytesRead, try size(file))
+ XCTAssertEqual(rewritten.buckets, try fullBuckets(home))
+ XCTAssertEqual(rewritten.effortRecords, try fullRecords(home))
+ XCTAssertEqual(rewritten.buckets.map(\.tokens), [260])
+ }
+ }
+
+ func testOlderStateVersionForcesAFullRescan() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let file = home.appending(path: ".claude/projects/project/a.jsonl")
+ try write(file, [claude("m1", time: "12:00:00", output: 3)])
+ let total = try size(file)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, total)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, 0)
+
+ var stored = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: try Data(contentsOf: state)) as? [String: Any])
+ XCTAssertEqual(stored["version"] as? Int, 2)
+ stored["version"] = 1
+ try JSONSerialization.data(withJSONObject: stored).write(to: state)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, total)
+ }
+ }
+
+ func testUnchangedScanDoesNotRewriteTheStateFile() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let file = home.appending(path: ".claude/projects/project/a.jsonl")
+ try write(file, [claude("m1", time: "12:00:00", output: 3)])
+ XCTAssertGreaterThan(try scan(home, state: state).bytesRead, 0)
+
+ let marker = Date(timeIntervalSince1970: 1_000_000)
+ try setModified(state, marker)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, 0)
+ XCTAssertEqual(try modified(state), marker)
+
+ try append(file, [claude("m2", time: "12:05:00", output: 5)])
+ XCTAssertGreaterThan(try scan(home, state: state).bytesRead, 0)
+ XCTAssertGreaterThan(try modified(state), marker)
+ }
+ }
+
+ func testNestedBackupDayDirectoryIsIgnored() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let backup = home.appending(path: ".codex/sessions/backup/2026/09/12/a.jsonl")
+ try write(backup, [codex("r-backup", time: "12:30:00", tokens: 40)])
+ try setModified(backup, Date(timeIntervalSince1970: 0))
+
+ let scanned = try scan(home, state: state)
+ XCTAssertEqual(scanned.bytesRead, 0)
+ XCTAssertEqual(scanned.effortRecords, [])
+ XCTAssertEqual(scanned.effortRecords, try fullRecords(home))
+ }
+ }
+
+ func testInterruptedScanKeepsTheFinishedRootPersisted() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let claudeFile = home.appending(path: ".claude/projects/project/a.jsonl")
+ let codexFile = home.appending(path: ".codex/sessions/2026/09/12/a.jsonl")
+ try write(claudeFile, [claude("m1", time: "12:00:00", output: 3)])
+ try write(codexFile, [codex("r1", time: "12:30:00", tokens: 40)])
+ try FileManager.default.setAttributes([.posixPermissions: 0], ofItemAtPath: codexFile.path)
+
+ XCTAssertThrowsError(try scan(home, state: state))
+ let stored = try XCTUnwrap(
+ JSONSerialization.jsonObject(with: try Data(contentsOf: state)) as? [String: Any])
+ let files = try XCTUnwrap(stored["files"] as? [String: Any])
+ XCTAssertEqual(files.keys.map { URL(filePath: $0).standardizedFileURL.path }, [claudeFile.path])
+
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: codexFile.path)
+ let resumed = try scan(home, state: state)
+ XCTAssertEqual(resumed.bytesRead, try size(codexFile))
+ XCTAssertEqual(resumed.effortRecords.map(\.tokens), [63, 40])
+ }
+ }
+
+ func testCorruptOrRegressedStateFallsBackToFullScan() throws {
+ try withHome { home in
+ let state = home.appending(path: "support/scan-state.json")
+ let file = home.appending(path: ".claude/projects/project/a.jsonl")
+ try write(file, [claude("m1", time: "12:00:00", output: 3), claude("m2", time: "12:05:00", output: 5)])
+ let total = try size(file)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, total)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, 0)
+
+ try Data("{not json".utf8).write(to: state)
+ let recovered = try scan(home, state: state)
+ XCTAssertEqual(recovered.bytesRead, total)
+ XCTAssertEqual(recovered.buckets.map(\.tokens), [128])
+
+ XCTAssertEqual(try scan(home, state: state, now: now.addingTimeInterval(-86_400)).bytesRead, total)
+ XCTAssertEqual(try scan(home, state: state).bytesRead, 0)
+ }
+ }
+
+ private func scan(_ home: URL, state: URL, now: Date? = nil) throws -> TranscriptScanner.Result {
+ try TranscriptScanner.scan(homeDirectory: home, stateURL: state, now: now ?? self.now)
+ }
+
+ private func fullBuckets(_ home: URL) throws -> [ModelTokenBucket] {
+ try ClaudeHistoryScanner.scan(projectsRoot: home.appending(path: ".claude/projects"), now: now)
+ }
+
+ private func fullRecords(_ home: URL) throws -> [EffortUsageRecord] {
+ try EffortUsageScanner.scan(homeDirectory: home, now: now)
+ }
+
+ private func withHome(_ body: (URL) throws -> Void) throws {
+ let home = FileManager.default.temporaryDirectory.resolvingSymlinksInPath().appending(path: UUID().uuidString)
+ try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: home) }
+ try body(home)
+ }
+
+ private func write(_ file: URL, _ lines: [String]) throws {
+ try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try Data((lines.joined(separator: "\n") + "\n").utf8).write(to: file)
+ }
+
+ @discardableResult
+ private func append(_ file: URL, _ lines: [String]) throws -> Int {
+ let data = Data((lines.joined(separator: "\n") + "\n").utf8)
+ let handle = try FileHandle(forWritingTo: file)
+ defer { try? handle.close() }
+ try handle.seekToEnd()
+ try handle.write(contentsOf: data)
+ return data.count
+ }
+
+ private func truncate(_ file: URL, to length: Int) throws {
+ let handle = try FileHandle(forWritingTo: file)
+ defer { try? handle.close() }
+ try handle.truncate(atOffset: UInt64(length))
+ }
+
+ private func size(_ file: URL) throws -> Int {
+ try XCTUnwrap(FileManager.default.attributesOfItem(atPath: file.path)[.size] as? Int)
+ }
+
+ private func rewrite(_ file: URL, _ lines: [String]) throws {
+ let handle = try FileHandle(forWritingTo: file)
+ defer { try? handle.close() }
+ try handle.truncate(atOffset: 0)
+ try handle.write(contentsOf: Data((lines.joined(separator: "\n") + "\n").utf8))
+ }
+
+ private func pad(_ line: String, by count: Int) -> String {
+ line.replacingOccurrences(
+ of: "synthetic ignored content", with: "synthetic ignored content" + String(repeating: "x", count: count))
+ }
+
+ private func modified(_ file: URL) throws -> Date {
+ try XCTUnwrap(FileManager.default.attributesOfItem(atPath: file.path)[.modificationDate] as? Date)
+ }
+
+ private func setModified(_ file: URL, _ date: Date) throws {
+ try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: file.path)
+ }
+
+ private func claude(_ id: String, time: String, output: Int) -> String {
+ """
+ {"type":"assistant","timestamp":"2026-09-12T\(time).000Z","uuid":"uuid-\(id)","perTurnEffort":"medium","effort":"high","message":{"id":"\(id)","model":"claude-fable-5-1","usage":{"input_tokens":10,"cache_creation_input_tokens":20,"cache_read_input_tokens":30,"output_tokens":\(output)},"content":[{"type":"text","text":"synthetic ignored content"}]}}
+ """
+ }
+
+ private func codex(_ id: String, turn: String = "unknown", time: String, tokens: Int) -> String {
+ """
+ {"type":"token_usage_record","timestamp":"2026-09-12T\(time)Z","payload":{"turn_id":"\(turn)","response_id":"\(id)","usage":{"total_tokens":\(tokens)}}}
+ """
+ }
+
+ private func turnContext(_ turn: String, effort: String) -> String {
+ """
+ {"type":"turn_context","payload":{"turn_id":"\(turn)","model":"gpt-6-astra","effort":"\(effort)"}}
+ """
+ }
+}
+
+private enum ReferenceScanners {
+ private static let usageKeys = [
+ "input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens",
+ ]
+
+ static func history(projectsRoot: URL, days: Int, now: Date, calendar: Calendar = .current) throws
+ -> [ModelTokenBucket]
+ {
+ let start = calendar.startOfDay(for: calendar.date(byAdding: .day, value: -(days - 1), to: now)!)
+ var messages: [String: (date: Date, model: String, tokens: Int)] = [:]
+ for file in try files(under: projectsRoot) where try modified(file) >= start {
+ for object in try lines(file) {
+ guard object["type"] as? String == "assistant", let message = object["message"] as? [String: Any],
+ let usage = message["usage"] as? [String: Any], let timestamp = object["timestamp"] as? String,
+ let date = parse(timestamp), date >= start, date <= now.addingTimeInterval(300),
+ let id = message["id"] as? String ?? object["uuid"] as? String
+ else { continue }
+ let tokens = total(usage)
+ guard tokens > 0 else { continue }
+ let raw = message["model"] as? String
+ let model = raw.map { $0.isEmpty || $0.hasPrefix("<") ? "unknown" : $0 } ?? "unknown"
+ if let current = messages[id], current.tokens >= tokens { continue }
+ messages[id] = (date, model, tokens)
+ }
+ }
+ var totals: [String: ModelTokenBucket] = [:]
+ for message in messages.values {
+ let hour = Date(timeIntervalSince1970: (message.date.timeIntervalSince1970 / 3600).rounded(.down) * 3600)
+ let key = "\(message.model)-\(hour.timeIntervalSince1970)"
+ totals[key] = ModelTokenBucket(
+ day: day(message.date, calendar), hourStart: hour, model: message.model,
+ tokens: (totals[key]?.tokens ?? 0) + message.tokens)
+ }
+ return totals.values.sorted {
+ $0.hourStart != $1.hourStart ? $0.hourStart < $1.hourStart : $0.model < $1.model
+ }
+ }
+
+ static func effort(home: URL, lookbackDays: Int, now: Date, calendar: Calendar = .current) throws
+ -> [EffortUsageRecord]
+ {
+ let cutoff = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1 - lookbackDays, to: now)!)
+ let codexRoot = home.appending(path: ".codex/sessions")
+ let recent = Set(recentDirectories(cutoff: cutoff, now: now))
+ var records: [String: EffortUsageRecord] = [:]
+ for (provider, root) in [(UsageProvider.codex, codexRoot), (.claude, home.appending(path: ".claude/projects"))]
+ {
+ for file in try files(under: root)
+ where try modified(file) >= cutoff
+ || recent.contains(file.deletingLastPathComponent().pathComponents.suffix(3).joined(separator: "/"))
+ {
+ var context: [String: Any]?
+ for object in try lines(file) {
+ let type = object["type"] as? String
+ if provider == .codex, type == "turn_context" {
+ context = object["payload"] as? [String: Any]
+ continue
+ }
+ guard let timestamp = object["timestamp"] as? String, let date = parse(timestamp), date >= cutoff,
+ date <= now
+ else { continue }
+ let identifier: String?
+ let model: String?
+ let effort: String?
+ let tokens: Int?
+ if provider == .codex {
+ guard type == "token_usage_record", let payload = object["payload"] as? [String: Any] else {
+ continue
+ }
+ identifier = payload["response_id"] as? String
+ let turn = context?["turn_id"] as? String
+ let matching = turn != nil && turn == payload["turn_id"] as? String ? context : nil
+ model = matching?["model"] as? String
+ effort = matching?["effort"] as? String
+ tokens = (payload["usage"] as? [String: Any])?["total_tokens"] as? Int
+ } else {
+ guard type == "assistant", let message = object["message"] as? [String: Any] else { continue }
+ identifier = message["id"] as? String
+ model = message["model"] as? String
+ effort = object["perTurnEffort"] as? String ?? object["effort"] as? String
+ tokens = (message["usage"] as? [String: Any]).map(total)
+ }
+ guard let identifier, !identifier.isEmpty, let tokens, tokens > 0 else { continue }
+ let id = SHA256.hash(data: Data("\(provider.rawValue):\(identifier)".utf8))
+ .map { String(format: "%02x", $0) }.joined()
+ let record = EffortUsageRecord(
+ id: id, provider: provider, recordedAt: date, day: day(date, calendar),
+ model: normalizedModel(model), effort: normalizedEffort(effort), tokens: tokens)
+ records[id] = merge(records[id], record)
+ }
+ }
+ }
+ return records.values.sorted { $0.recordedAt == $1.recordedAt ? $0.id < $1.id : $0.recordedAt < $1.recordedAt }
+ }
+
+ private static func merge(_ existing: EffortUsageRecord?, _ incoming: EffortUsageRecord) -> EffortUsageRecord {
+ guard let existing else { return incoming }
+ let earliest = existing.recordedAt <= incoming.recordedAt ? existing : incoming
+ return EffortUsageRecord(
+ id: existing.id, provider: existing.provider, recordedAt: earliest.recordedAt, day: earliest.day,
+ model: existing.model == "unknown" ? incoming.model : existing.model,
+ effort: existing.effort == "unknown" ? incoming.effort : existing.effort,
+ tokens: max(existing.tokens, incoming.tokens))
+ }
+
+ private static func normalizedModel(_ raw: String?) -> String {
+ let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.")
+ guard let raw, !raw.isEmpty, raw.count <= 100, raw.unicodeScalars.allSatisfy(allowed.contains) else {
+ return "unknown"
+ }
+ return raw
+ }
+
+ private static func normalizedEffort(_ raw: String?) -> String {
+ let value = raw?.lowercased() ?? "unknown"
+ return ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "auto"].contains(value)
+ ? value : "unknown"
+ }
+
+ private static func recentDirectories(cutoff: Date, now: Date) -> [String] {
+ var utc = Calendar(identifier: .gregorian)
+ utc.timeZone = TimeZone(secondsFromGMT: 0)!
+ var date = utc.startOfDay(for: cutoff.addingTimeInterval(-86_400))
+ var directories: [String] = []
+ while date <= now {
+ let components = utc.dateComponents([.year, .month, .day], from: date)
+ directories.append(String(format: "%04d/%02d/%02d", components.year!, components.month!, components.day!))
+ date = date.addingTimeInterval(86_400)
+ }
+ return directories
+ }
+
+ private static func total(_ usage: [String: Any]) -> Int {
+ usageKeys.reduce(0) { $0 + max(usage[$1] as? Int ?? 0, 0) }
+ }
+
+ private static func files(under root: URL) throws -> [URL] {
+ guard
+ let enumerator = FileManager.default.enumerator(
+ at: root, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles])
+ else { return [] }
+ return enumerator.compactMap { $0 as? URL }.filter {
+ $0.pathExtension == "jsonl" && (try? $0.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true
+ }
+ }
+
+ private static func modified(_ file: URL) throws -> Date {
+ try file.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate ?? .distantPast
+ }
+
+ private static func lines(_ file: URL) throws -> [[String: Any]] {
+ try Data(contentsOf: file).split(separator: 0x0A).compactMap {
+ (try? JSONSerialization.jsonObject(with: $0)) as? [String: Any]
+ }
+ }
+
+ private static func parse(_ timestamp: String) -> Date? {
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp)
+ }
+
+ private static func day(_ date: Date, _ calendar: Calendar) -> String {
+ let components = calendar.dateComponents([.year, .month, .day], from: date)
+ return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
+ }
+}
diff --git a/Tests/TokenGaugeCoreTests/UsageHistoryStoreTests.swift b/Tests/TokenGaugeCoreTests/UsageHistoryStoreTests.swift
index be5feea..8bdd4d1 100644
--- a/Tests/TokenGaugeCoreTests/UsageHistoryStoreTests.swift
+++ b/Tests/TokenGaugeCoreTests/UsageHistoryStoreTests.swift
@@ -1,3 +1,4 @@
+import Darwin
import Foundation
import SQLite3
import XCTest
@@ -191,6 +192,105 @@ final class UsageHistoryStoreTests: XCTestCase {
XCTAssertEqual(sqlite3_exec(db, sql, nil, nil, nil), SQLITE_OK)
}
+ func testUsageDaysReportOnlyDaysWithRecordedTokens() throws {
+ try UsageHistoryStore.record(
+ ProviderUsageSnapshot(
+ provider: .codex, windows: [],
+ dailyUsage: [
+ DailyTokenUsage(day: "2026-09-15", tokens: 120), DailyTokenUsage(day: "2026-09-16", tokens: 0),
+ ],
+ summary: nil, availableResetCredits: nil, creditBalance: nil,
+ capturedAt: Date(timeIntervalSince1970: 1_787_000_000)),
+ at: database)
+ try UsageHistoryStore.record(
+ claudeSnapshot(buckets: [bucket(day: "2026-09-17", hour: 0, model: "claude-fable-5", tokens: 40)]),
+ at: database)
+
+ XCTAssertEqual(try UsageHistoryStore.usageDays(provider: .codex, at: database), ["2026-09-15"])
+ XCTAssertEqual(try UsageHistoryStore.usageDays(provider: .claude, at: database), ["2026-09-17"])
+ XCTAssertEqual(try UsageHistoryStore.usageDays(at: database), ["2026-09-15", "2026-09-17"])
+ }
+
+ func testUsageDaysByProviderReturnsEveryProviderInOneRead() throws {
+ try UsageHistoryStore.record(
+ ProviderUsageSnapshot(
+ provider: .codex, windows: [],
+ dailyUsage: [
+ DailyTokenUsage(day: "2026-09-15", tokens: 120), DailyTokenUsage(day: "2026-09-16", tokens: 0),
+ DailyTokenUsage(day: "2026-09-17", tokens: 80),
+ ],
+ summary: nil, availableResetCredits: nil, creditBalance: nil,
+ capturedAt: Date(timeIntervalSince1970: 1_787_000_000)),
+ at: database)
+ try UsageHistoryStore.record(
+ claudeSnapshot(buckets: [bucket(day: "2026-09-17", hour: 0, model: "claude-fable-5", tokens: 40)]),
+ at: database)
+
+ let entries = UsageHistoryStore.entryCount
+ let days = try UsageHistoryStore.usageDaysByProvider(at: database)
+ XCTAssertEqual(UsageHistoryStore.entryCount, entries + 1)
+ XCTAssertEqual(days[.codex], ["2026-09-15", "2026-09-17"])
+ XCTAssertEqual(days[.claude], ["2026-09-17"])
+ XCTAssertEqual(days[.codex], try UsageHistoryStore.usageDays(provider: .codex, at: database))
+ XCTAssertEqual(days[.claude], try UsageHistoryStore.usageDays(provider: .claude, at: database))
+ }
+
+ func testSchemaIsPreparedOncePerProcessAndPath() throws {
+ try UsageHistoryStore.record(claudeSnapshot(), at: database)
+ try UsageHistoryStore.record(claudeSnapshot(used: 60), at: database)
+ _ = try UsageHistoryStore.tokenRows(at: database)
+ _ = try UsageHistoryStore.quotaRows(at: database)
+
+ XCTAssertTrue(UsageHistoryStore.preparedPaths.contains(database.path))
+ XCTAssertEqual(UsageHistoryStore.schemaPreparations(for: database.path), 1)
+
+ let other = root.appending(path: "other-history.sqlite")
+ _ = try UsageHistoryStore.bounds(at: other)
+ _ = try UsageHistoryStore.bounds(at: other)
+ XCTAssertEqual(UsageHistoryStore.schemaPreparations(for: other.path), 1)
+ XCTAssertEqual(UsageHistoryStore.schemaPreparations(for: database.path), 1)
+ }
+
+ func testReadEntriesOpenReadOnly() throws {
+ try UsageHistoryStore.record(claudeSnapshot(), at: database)
+ XCTAssertEqual(UsageHistoryStore.lastEntryWasReadOnly, false)
+
+ _ = try UsageHistoryStore.quotaRows(at: database)
+ XCTAssertEqual(UsageHistoryStore.lastEntryWasReadOnly, true)
+ _ = try UsageHistoryStore.tokenRows(at: database)
+ XCTAssertEqual(UsageHistoryStore.lastEntryWasReadOnly, true)
+ _ = try UsageHistoryStore.bounds(at: database)
+ XCTAssertEqual(UsageHistoryStore.lastEntryWasReadOnly, true)
+
+ try UsageHistoryStore.record(claudeSnapshot(used: 70), at: database)
+ XCTAssertEqual(UsageHistoryStore.lastEntryWasReadOnly, false)
+ }
+
+ func testThrowingSchemaCheckClosesTheReadOnlyHandle() throws {
+ try UsageHistoryStore.record(claudeSnapshot(), at: database)
+ _ = try UsageHistoryStore.quotaRows(at: database)
+
+ try Data(repeating: 0x5A, count: 8_192).write(to: database)
+ let descriptors = openDescriptors(for: database.path)
+ for _ in 0..<5 {
+ XCTAssertThrowsError(try UsageHistoryStore.quotaRows(at: database))
+ }
+ XCTAssertEqual(openDescriptors(for: database.path), descriptors)
+ }
+
+ private func openDescriptors(for path: String) -> Int {
+ let target = URL(filePath: path).standardizedFileURL.path
+ let entries = (try? FileManager.default.contentsOfDirectory(atPath: "/dev/fd")) ?? []
+ return entries.reduce(into: 0) { count, entry in
+ guard let descriptor = Int32(entry) else { return }
+ var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
+ guard fcntl(descriptor, F_GETPATH, &buffer) != -1,
+ URL(filePath: String(cString: buffer)).standardizedFileURL.path == target
+ else { return }
+ count += 1
+ }
+ }
+
private func claudeSnapshot(
used: Double = 44,
capturedAt: Date = Date(timeIntervalSince1970: 1_787_000_400),
diff --git a/Tests/TokenGaugeCoreTests/UsageModelsTests.swift b/Tests/TokenGaugeCoreTests/UsageModelsTests.swift
new file mode 100644
index 0000000..fb2e782
--- /dev/null
+++ b/Tests/TokenGaugeCoreTests/UsageModelsTests.swift
@@ -0,0 +1,28 @@
+import Foundation
+import XCTest
+
+@testable import TokenGaugeCore
+
+final class UsageModelsTests: XCTestCase {
+ func testDisplayOrderSortsUnknownDurationsLastAndBreaksTiesByIdentifier() {
+ let windows = [
+ window(id: "seven_day", duration: 10_080),
+ window(id: "unknown", duration: nil),
+ window(id: "five_hour", duration: 300),
+ window(id: "all_weekly", duration: 10_080),
+ ]
+
+ let sorted = windows.sorted(by: QuotaWindow.displayOrder)
+
+ XCTAssertEqual(sorted.map(\.id), ["five_hour", "all_weekly", "seven_day", "unknown"])
+ XCTAssertEqual(
+ ProviderUsageSnapshot(
+ provider: .claude, windows: windows, dailyUsage: [], summary: nil,
+ availableResetCredits: nil, creditBalance: nil, capturedAt: nil
+ ).longestWindow?.id, "unknown")
+ }
+
+ private func window(id: String, duration: Int?) -> QuotaWindow {
+ QuotaWindow(id: id, usedPercentage: 10, resetsAt: nil, durationMinutes: duration, displayName: nil)
+ }
+}
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index b3a6b00..4f0afc7 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -14,6 +14,8 @@ The release version is `CFBundleShortVersionString` in `Resources/Info.plist`. I
4. Verify a real Sparkle update with two development builds, including a tampered ZIP rejection, retry, installation/relaunch, and preserved preferences.
5. Update CHANGELOG.md, screenshots, and release notes. Use synthetic data for public screenshots.
+The incremental transcript scan keeps its resume state in `scan-state.json`, next to the history database under `~/Library/Application Support/TokenGauge`. Deleting it is safe and forces a full rescan.
+
## Build artifacts
Prerequisites: an Apple Developer membership, an installed Developer ID Application certificate, `create-dmg`, and an existing `notarytool` Keychain profile. Signing/private-key access and public publication follow the owner’s authorization; never export credentials into the repository or logs.
@@ -47,6 +49,10 @@ TOKENGAUGE_UPDATE_FEED_URL=http://127.0.0.1:8000/appcast.xml
Only a loopback HTTP feed is accepted for this explicit QA override. Normal development launches disable updates. Test through About using real input, verify the higher build relaunches with the same Apple Development signing identity, then restore the real version and remove task-owned QA artifacts/server. Do not copy production credentials or user usage history to a test Mac.
+## Feed override
+
+`defaults write com.stevenacz.TokenGauge updateFeedURLOverride ` points any build, including a notarized distribution build, at another appcast; `defaults delete com.stevenacz.TokenGauge updateFeedURLOverride` restores the feed from `Info.plist`. EdDSA signatures still gate every install, so an override cannot install an unsigned update. The precedence is `TOKENGAUGE_QA_UPDATES` plus `TOKENGAUGE_UPDATE_FEED_URL` (development builds, loopback only), then `updateFeedURLOverride`, then `SUFeedURL`. Remove the override after QA.
+
## Repository visibility
Review the entire history before changing a private repository to public. Permission to prepare a release does not by itself prove that publication has occurred. Record the live visibility and release URL after the owner-authorized publication step. Keep generated artifacts and signing material out of Git.
diff --git a/docs/images/claude.png b/docs/images/claude.png
index f8637f7..2554af3 100644
Binary files a/docs/images/claude.png and b/docs/images/claude.png differ
diff --git a/docs/images/history-calendar-rings.png b/docs/images/history-calendar-rings.png
index 1a95999..efe1090 100644
Binary files a/docs/images/history-calendar-rings.png and b/docs/images/history-calendar-rings.png differ
diff --git a/docs/images/panel.png b/docs/images/panel.png
index 7576455..cbaf801 100644
Binary files a/docs/images/panel.png and b/docs/images/panel.png differ
diff --git a/docs/images/rings-current.png b/docs/images/rings-current.png
index 472b49a..599f296 100644
Binary files a/docs/images/rings-current.png and b/docs/images/rings-current.png differ
diff --git a/docs/images/unified.png b/docs/images/unified.png
index b8951dd..ef2bcb8 100644
Binary files a/docs/images/unified.png and b/docs/images/unified.png differ