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