From 38c1f8d1dd259e90cfc5692ba42b1bd50f3d5fb3 Mon Sep 17 00:00:00 2001 From: Matjaz Domen Pecan Date: Mon, 13 Jul 2026 19:07:04 +0200 Subject: [PATCH 1/2] feat(usage): track model-scoped limits from the API's limits array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The claude.ai usage endpoint no longer populates the flat per-model fields. seven_day_sonnet, seven_day_opus and friends return null, and no new ones are added — a model-specific cap now appears only as an entry in the `limits` array, which names its own scope (scope.model.display_name, e.g. "Fable"). ClaudeMeter read seven_day_sonnet, so a model-specific cap showed nothing at all. Read `limits` instead and render a card per scoped entry. Because the API names its own scopes, a model released after this build surfaces with no code change. The flat five_hour/seven_day fields are kept as a fallback. - Replace UsageData.sonnetUsage with a ScopedUsageLimit list - Settings renders one toggle per reported model, stored as an opt-out set so a new model appears rather than hiding behind a switch nobody knows to flip - Migrate saved settings: show_sonnet_usage=false becomes hidden=["Sonnet"], which does not suppress other models - Move the ~/.claudemeter/usage.json contract into a UsageExportPayload owned by the repository layer; sonnet_usage stays as a deprecated alias so existing statusline scripts keep working Co-Authored-By: Claude Opus 4.8 (1M context) --- ClaudeMeter/Models/API/UsageAPIResponse.swift | 207 +++++++++++++---- ClaudeMeter/Models/AppSettings.swift | 37 ++- ClaudeMeter/Models/ScopedUsageLimit.swift | 28 +++ ClaudeMeter/Models/UsageData.swift | 30 ++- .../Repositories/CacheRepository.swift | 2 +- .../Repositories/UsageExportPayload.swift | 37 +++ ClaudeMeter/Utilities/DemoDataFactory.swift | 30 ++- ClaudeMeter/Utilities/DemoMode.swift | 4 +- .../Views/MenuBar/UsagePopoverView.swift | 7 +- ClaudeMeter/Views/Settings/SettingsView.swift | 48 +++- ClaudeMeterTests/AppModelTests.swift | 12 - .../NotificationServiceTests.swift | 13 -- .../ScopedUsageSettingsTests.swift | 125 +++++++++++ .../TestSupport/UsageTestFixtures.swift | 51 +++++ ClaudeMeterTests/UsageAPIResponseTests.swift | 210 ++++++++++++++++++ ClaudeMeterTests/UsageServiceTests.swift | 33 +-- README.md | 27 ++- scripts/demo.sh | 2 +- 18 files changed, 754 insertions(+), 149 deletions(-) create mode 100644 ClaudeMeter/Models/ScopedUsageLimit.swift create mode 100644 ClaudeMeter/Repositories/UsageExportPayload.swift create mode 100644 ClaudeMeterTests/ScopedUsageSettingsTests.swift create mode 100644 ClaudeMeterTests/TestSupport/UsageTestFixtures.swift create mode 100644 ClaudeMeterTests/UsageAPIResponseTests.swift diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift index 9d69d31..32b7235 100644 --- a/ClaudeMeter/Models/API/UsageAPIResponse.swift +++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift @@ -7,23 +7,38 @@ import Foundation -/// API response for usage data +/// `limits` is authoritative. The flat per-model fields (`seven_day_sonnet`, +/// `seven_day_opus`, ...) come back null and the API adds no new ones, so only +/// Sonnet is kept, for accounts still served the older shape. struct UsageAPIResponse: Codable { - let fiveHour: UsageLimitResponse - let sevenDay: UsageLimitResponse + let limits: [LimitEntryResponse]? + let fiveHour: UsageLimitResponse? + let sevenDay: UsageLimitResponse? let sevenDaySonnet: UsageLimitResponse? + init( + fiveHour: UsageLimitResponse? = nil, + sevenDay: UsageLimitResponse? = nil, + sevenDaySonnet: UsageLimitResponse? = nil, + limits: [LimitEntryResponse]? = nil + ) { + self.fiveHour = fiveHour + self.sevenDay = sevenDay + self.sevenDaySonnet = sevenDaySonnet + self.limits = limits + } + enum CodingKeys: String, CodingKey { + case limits case fiveHour = "five_hour" case sevenDay = "seven_day" case sevenDaySonnet = "seven_day_sonnet" } } -/// Individual usage limit response from API struct UsageLimitResponse: Codable { - let utilization: Double // Percentage 0-100 - let resetsAt: String? // ISO8601 string, can be null + let utilization: Double + let resetsAt: String? enum CodingKeys: String, CodingKey { case utilization @@ -31,7 +46,69 @@ struct UsageLimitResponse: Codable { } } -/// Mapping error for API response conversion +struct LimitEntryResponse: Codable { + let kind: String + let percent: Double + let resetsAt: String? + let scope: LimitScopeResponse? + let isActive: Bool? + + init( + kind: String, + percent: Double, + resetsAt: String?, + scope: LimitScopeResponse? = nil, + isActive: Bool? = nil + ) { + self.kind = kind + self.percent = percent + self.resetsAt = resetsAt + self.scope = scope + self.isActive = isActive + } + + enum Kind { + static let session = "session" + static let weeklyAll = "weekly_all" + static let headline = [session, weeklyAll] + } + + enum CodingKeys: String, CodingKey { + case kind + case percent + case resetsAt = "resets_at" + case scope + case isActive = "is_active" + } + + var scopeDisplayName: String? { + let names = [scope?.model?.displayName, scope?.surface?.displayName].compactMap { $0 } + return names.isEmpty ? nil : names.joined(separator: " · ") + } +} + +struct LimitScopeResponse: Codable { + let model: NamedScopeResponse? + let surface: NamedScopeResponse? + + /// A scope shape we do not recognise must degrade to nil, not fail the response. + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + model = try? container.decodeIfPresent(NamedScopeResponse.self, forKey: .model) + surface = try? container.decodeIfPresent(NamedScopeResponse.self, forKey: .surface) + } +} + +struct NamedScopeResponse: Codable { + let id: String? + let displayName: String? + + enum CodingKeys: String, CodingKey { + case id + case displayName = "display_name" + } +} + enum MappingError: LocalizedError { case invalidDateFormat case missingCriticalField(field: String) @@ -46,63 +123,99 @@ enum MappingError: LocalizedError { } } -/// Extension to map API response to domain model extension UsageAPIResponse { - func toDomain() throws -> UsageData { - let iso8601Formatter = ISO8601DateFormatter() - iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - let sessionResetDate = try parseResetDate( - from: fiveHour.resetsAt, - field: "fiveHour.resetsAt", - formatter: iso8601Formatter, - fallback: Constants.Pacing.sessionWindow - ) - let weeklyResetDate = try parseResetDate( - from: sevenDay.resetsAt, - field: "sevenDay.resetsAt", - formatter: iso8601Formatter, - fallback: Constants.Pacing.weeklyWindow - ) + private static let fractionalSecondsFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() - // Handle optional sonnet usage - let sonnetLimit: UsageLimit? = try sevenDaySonnet.flatMap { sonnet -> UsageLimit? in - let sonnetResetDate = try parseResetDate( - from: sonnet.resetsAt, - field: "sevenDaySonnet.resetsAt", - formatter: iso8601Formatter, - fallback: Constants.Pacing.weeklyWindow - ) - return UsageLimit( - utilization: sonnet.utilization, - resetAt: sonnetResetDate - ) + private static let plainFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + func toDomain() throws -> UsageData { + guard let sessionEntry = entry(ofKind: LimitEntryResponse.Kind.session, legacy: fiveHour) else { + throw MappingError.missingCriticalField(field: "five_hour") + } + guard let weeklyEntry = entry(ofKind: LimitEntryResponse.Kind.weeklyAll, legacy: sevenDay) else { + throw MappingError.missingCriticalField(field: "seven_day") } return UsageData( - sessionUsage: UsageLimit( - utilization: fiveHour.utilization, - resetAt: sessionResetDate - ), - weeklyUsage: UsageLimit( - utilization: sevenDay.utilization, - resetAt: weeklyResetDate - ), - sonnetUsage: sonnetLimit, + sessionUsage: try usageLimit(from: sessionEntry, fallback: Constants.Pacing.sessionWindow), + weeklyUsage: try usageLimit(from: weeklyEntry, fallback: Constants.Pacing.weeklyWindow), + scopedUsage: try scopedUsage(), lastUpdated: Date() ) } + private func entry(ofKind kind: String, legacy: UsageLimitResponse?) -> LimitEntryResponse? { + if let entry = limits?.first(where: { $0.kind == kind }) { + return entry + } + return legacy.map { + LimitEntryResponse(kind: kind, percent: $0.utilization, resetsAt: $0.resetsAt) + } + } + + /// Any kind may carry a scope, but headline kinds are excluded so an entry the API + /// later scopes cannot render both as a headline card and a scoped one. + private func scopedUsage() throws -> [ScopedUsageLimit] { + let scoped = try (limits ?? []) + .filter { !LimitEntryResponse.Kind.headline.contains($0.kind) } + .compactMap { entry -> ScopedUsageLimit? in + guard let name = entry.scopeDisplayName else { return nil } + return ScopedUsageLimit( + name: name, + limit: try usageLimit(from: entry, fallback: Constants.Pacing.weeklyWindow), + isActive: entry.isActive ?? false + ) + } + + guard scoped.isEmpty, let sonnet = sevenDaySonnet else { + return scoped + } + + return [ + ScopedUsageLimit( + name: "Sonnet", + limit: try usageLimit( + from: LimitEntryResponse( + kind: "seven_day_sonnet", + percent: sonnet.utilization, + resetsAt: sonnet.resetsAt + ), + fallback: Constants.Pacing.weeklyWindow + ), + isActive: false + ) + ] + } + + private func usageLimit(from entry: LimitEntryResponse, fallback: TimeInterval) throws -> UsageLimit { + UsageLimit( + utilization: entry.percent, + resetAt: try parseResetDate( + from: entry.resetsAt, + field: "\(entry.kind).resets_at", + fallback: fallback + ) + ) + } + private func parseResetDate( from rawValue: String?, field: String, - formatter: ISO8601DateFormatter, fallback: TimeInterval ) throws -> Date { guard let rawValue else { return Date().addingTimeInterval(fallback) } - guard let date = formatter.date(from: rawValue) else { + guard let date = Self.fractionalSecondsFormatter.date(from: rawValue) + ?? Self.plainFormatter.date(from: rawValue) else { throw MappingError.missingCriticalField(field: field) } return date diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 3522384..8a17b86 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -24,8 +24,9 @@ struct AppSettings: Codable, Equatable, Sendable { /// Last known organization ID (cached) var cachedOrganizationId: UUID? - /// Whether to show Sonnet usage in the popover - var isSonnetUsageShown: Bool + /// Opt-out rather than opt-in, so a model the API starts reporting after this + /// build shows up on its own instead of waiting behind a switch. + var hiddenScopedModels: Set /// Menu bar icon display style var iconStyle: IconStyle @@ -39,7 +40,7 @@ struct AppSettings: Codable, Equatable, Sendable { notificationThresholds: .default, isFirstLaunch: true, cachedOrganizationId: nil, - isSonnetUsageShown: false, + hiddenScopedModels: [], iconStyle: .battery, isColoredIcon: true ) @@ -50,10 +51,16 @@ struct AppSettings: Codable, Equatable, Sendable { case notificationThresholds = "notification_thresholds" case isFirstLaunch = "is_first_launch" case cachedOrganizationId = "cached_organization_id" - case isSonnetUsageShown = "show_sonnet_usage" + case hiddenScopedModels = "hidden_scoped_models" case iconStyle = "icon_style" case isColoredIcon = "is_colored_icon" } + + /// Read-only: migrates settings saved before `hiddenScopedModels` existed. + /// Kept out of `CodingKeys` so `encode` stays synthesized. + private enum LegacyCodingKeys: String, CodingKey { + case showSonnetUsage = "show_sonnet_usage" + } } extension AppSettings { @@ -66,9 +73,16 @@ extension AppSettings { notificationThresholds = try container.decodeIfPresent(NotificationThresholds.self, forKey: .notificationThresholds) ?? defaults.notificationThresholds isFirstLaunch = try container.decodeIfPresent(Bool.self, forKey: .isFirstLaunch) ?? defaults.isFirstLaunch cachedOrganizationId = try container.decodeIfPresent(UUID.self, forKey: .cachedOrganizationId) - isSonnetUsageShown = try container.decodeIfPresent(Bool.self, forKey: .isSonnetUsageShown) ?? defaults.isSonnetUsageShown iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle isColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .isColoredIcon) ?? defaults.isColoredIcon + + if let hidden = try container.decodeIfPresent(Set.self, forKey: .hiddenScopedModels) { + hiddenScopedModels = hidden + } else { + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + let wasSonnetShown = try legacy.decodeIfPresent(Bool.self, forKey: .showSonnetUsage) + hiddenScopedModels = wasSonnetShown.map { $0 ? [] : ["Sonnet"] } ?? defaults.hiddenScopedModels + } } } @@ -77,4 +91,17 @@ extension AppSettings { mutating func setRefreshInterval(_ interval: TimeInterval) { refreshInterval = max(60, min(600, interval)) } + + /// Whether a model-scoped limit should appear in the popover + func isScopedModelShown(_ name: String) -> Bool { + !hiddenScopedModels.contains(name) + } + + mutating func setScopedModel(_ name: String, isShown: Bool) { + if isShown { + hiddenScopedModels.remove(name) + } else { + hiddenScopedModels.insert(name) + } + } } diff --git a/ClaudeMeter/Models/ScopedUsageLimit.swift b/ClaudeMeter/Models/ScopedUsageLimit.swift new file mode 100644 index 0000000..f6ff714 --- /dev/null +++ b/ClaudeMeter/Models/ScopedUsageLimit.swift @@ -0,0 +1,28 @@ +// +// ScopedUsageLimit.swift +// ClaudeMeter +// + +import Foundation + +/// A weekly limit the API scopes to a model or surface. The API supplies `name` +/// itself, so a model released after this build still surfaces here. +struct ScopedUsageLimit: Codable, Equatable, Sendable, Identifiable { + let name: String + let limit: UsageLimit + let isActive: Bool + + var id: String { name } + + enum CodingKeys: String, CodingKey { + case name + case limit + case isActive = "is_active" + } +} + +extension ScopedUsageLimit { + var title: String { + "Weekly \(name)" + } +} diff --git a/ClaudeMeter/Models/UsageData.swift b/ClaudeMeter/Models/UsageData.swift index 78e62fb..6e2c7d1 100644 --- a/ClaudeMeter/Models/UsageData.swift +++ b/ClaudeMeter/Models/UsageData.swift @@ -15,8 +15,8 @@ struct UsageData: Codable, Equatable, Sendable { /// 7-day weekly usage across all models let weeklyUsage: UsageLimit - /// 7-day Sonnet-specific usage (nil if not used) - let sonnetUsage: UsageLimit? + /// 7-day limits scoped to a specific model, in the order the API reported them + let scopedUsage: [ScopedUsageLimit] /// Timestamp of when this data was fetched let lastUpdated: Date @@ -24,9 +24,33 @@ struct UsageData: Codable, Equatable, Sendable { enum CodingKeys: String, CodingKey { case sessionUsage = "session_usage" case weeklyUsage = "weekly_usage" - case sonnetUsage = "sonnet_usage" + case scopedUsage = "scoped_usage" case lastUpdated = "last_updated" } + + /// Read-only: caches written before scoped limits existed carry a single Sonnet entry. + /// Kept out of `CodingKeys` so `encode` stays synthesized. + private enum LegacyCodingKeys: String, CodingKey { + case sonnetUsage = "sonnet_usage" + } +} + +extension UsageData { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + sessionUsage = try container.decode(UsageLimit.self, forKey: .sessionUsage) + weeklyUsage = try container.decode(UsageLimit.self, forKey: .weeklyUsage) + lastUpdated = try container.decode(Date.self, forKey: .lastUpdated) + + if let scoped = try container.decodeIfPresent([ScopedUsageLimit].self, forKey: .scopedUsage) { + scopedUsage = scoped + } else { + let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) + scopedUsage = try legacy.decodeIfPresent(UsageLimit.self, forKey: .sonnetUsage) + .map { [ScopedUsageLimit(name: "Sonnet", limit: $0, isActive: false)] } ?? [] + } + } } extension UsageData { diff --git a/ClaudeMeter/Repositories/CacheRepository.swift b/ClaudeMeter/Repositories/CacheRepository.swift index 084df05..20970f5 100644 --- a/ClaudeMeter/Repositories/CacheRepository.swift +++ b/ClaudeMeter/Repositories/CacheRepository.swift @@ -91,7 +91,7 @@ actor CacheRepository: CacheRepositoryProtocol { encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let jsonData = try? encoder.encode(data) else { + guard let jsonData = try? encoder.encode(UsageExportPayload(data)) else { return } diff --git a/ClaudeMeter/Repositories/UsageExportPayload.swift b/ClaudeMeter/Repositories/UsageExportPayload.swift new file mode 100644 index 0000000..35de842 --- /dev/null +++ b/ClaudeMeter/Repositories/UsageExportPayload.swift @@ -0,0 +1,37 @@ +// +// UsageExportPayload.swift +// ClaudeMeter +// + +import Foundation + +/// The public `~/.claudemeter/usage.json` contract. Separate from `UsageData` so the +/// domain model and disk cache can change shape without breaking external scripts. +struct UsageExportPayload: Encodable { + let sessionUsage: UsageLimit + let weeklyUsage: UsageLimit + let scopedUsage: [ScopedUsageLimit] + + /// Deprecated alias for `scopedUsage`, kept so existing statusline scripts keep working. + let sonnetUsage: UsageLimit? + + let lastUpdated: Date + + init(_ data: UsageData) { + sessionUsage = data.sessionUsage + weeklyUsage = data.weeklyUsage + scopedUsage = data.scopedUsage + sonnetUsage = data.scopedUsage + .first { $0.name.caseInsensitiveCompare("Sonnet") == .orderedSame }? + .limit + lastUpdated = data.lastUpdated + } + + enum CodingKeys: String, CodingKey { + case sessionUsage = "session_usage" + case weeklyUsage = "weekly_usage" + case scopedUsage = "scoped_usage" + case sonnetUsage = "sonnet_usage" + case lastUpdated = "last_updated" + } +} diff --git a/ClaudeMeter/Utilities/DemoDataFactory.swift b/ClaudeMeter/Utilities/DemoDataFactory.swift index bea2d82..102f321 100644 --- a/ClaudeMeter/Utilities/DemoDataFactory.swift +++ b/ClaudeMeter/Utilities/DemoDataFactory.swift @@ -46,18 +46,18 @@ enum DemoDataFactory { isLoading: false ) - case .withSonnet: + case .withModelLimits: appModel.applyDemoState( usageData: makeUsageData( sessionPercentage: 65, weeklyPercentage: 40, - sonnetPercentage: 25 + scopedPercentages: [("Fable", 23), ("Opus", 58)] ), isSetupComplete: true, errorMessage: nil, isLoading: false ) - appModel.settings.isSonnetUsageShown = true + appModel.settings.hiddenScopedModels = [] case .loading: appModel.applyDemoState( @@ -89,25 +89,21 @@ enum DemoDataFactory { private static func makeUsageData( sessionPercentage: Double, weeklyPercentage: Double, - sonnetPercentage: Double? = nil + scopedPercentages: [(name: String, percentage: Double)] = [] ) -> UsageData { let sessionResetAt = Date().addingTimeInterval(3 * 3600) // 3 hours from now let weeklyResetAt = Date().addingTimeInterval(4 * 24 * 3600) // 4 days from now - let sessionUsage = UsageLimit(utilization: sessionPercentage, resetAt: sessionResetAt) - let weeklyUsage = UsageLimit(utilization: weeklyPercentage, resetAt: weeklyResetAt) - - let sonnetUsage: UsageLimit? - if let sonnetPercentage { - sonnetUsage = UsageLimit(utilization: sonnetPercentage, resetAt: weeklyResetAt) - } else { - sonnetUsage = nil - } - return UsageData( - sessionUsage: sessionUsage, - weeklyUsage: weeklyUsage, - sonnetUsage: sonnetUsage, + sessionUsage: UsageLimit(utilization: sessionPercentage, resetAt: sessionResetAt), + weeklyUsage: UsageLimit(utilization: weeklyPercentage, resetAt: weeklyResetAt), + scopedUsage: scopedPercentages.enumerated().map { index, scoped in + ScopedUsageLimit( + name: scoped.name, + limit: UsageLimit(utilization: scoped.percentage, resetAt: weeklyResetAt), + isActive: index == 0 + ) + }, lastUpdated: Date() ) } diff --git a/ClaudeMeter/Utilities/DemoMode.swift b/ClaudeMeter/Utilities/DemoMode.swift index af6dbdb..d23a77e 100644 --- a/ClaudeMeter/Utilities/DemoMode.swift +++ b/ClaudeMeter/Utilities/DemoMode.swift @@ -15,7 +15,7 @@ enum DemoMode: String, CaseIterable { case warningUsage case criticalUsage case exceededUsage - case withSonnet + case withModelLimits case loading case error case setupWizard @@ -35,7 +35,7 @@ enum DemoMode: String, CaseIterable { case .warningUsage: "Medium usage - warning state" case .criticalUsage: "High usage - critical state" case .exceededUsage: "Over limit - exceeded state" - case .withSonnet: "Shows Sonnet usage card" + case .withModelLimits: "Shows model-specific usage cards" case .loading: "Loading spinner visible" case .error: "Error banner displayed" case .setupWizard: "First-time setup screen" diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift index 199bf06..23d443b 100644 --- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift @@ -103,11 +103,10 @@ struct UsagePopoverView: View { windowDuration: Constants.Pacing.weeklyWindow ) - // Sonnet usage card (conditional rendering) - if appModel.settings.isSonnetUsageShown, let sonnetUsage = usageData.sonnetUsage { + ForEach(usageData.scopedUsage.filter { appModel.settings.isScopedModelShown($0.name) }) { scoped in UsageCardView( - title: "Weekly Sonnet", - usageLimit: sonnetUsage, + title: scoped.title, + usageLimit: scoped.limit, icon: "sparkles", windowDuration: Constants.Pacing.weeklyWindow ) diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 4fd2588..5904061 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -61,7 +61,7 @@ struct SettingsView: View { } else { sessionKeySection refreshIntervalSection - sonnetUsageSection + modelUsageSection iconStyleSection launchAtLoginSection } @@ -210,28 +210,58 @@ struct SettingsView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } - // MARK: - Sonnet Usage Section + // MARK: - Model Usage Section - private var sonnetUsageSection: some View { - HStack { + /// Includes hidden names so a model that drops out of the response can still be switched back on. + private var scopedModelNames: [String] { + let reported = appModel.usageData?.scopedUsage.map(\.name) ?? [] + return Array(Set(reported).union(appModel.settings.hiddenScopedModels)).sorted() + } + + private var modelUsageSection: some View { + let names = scopedModelNames + + return VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 4) { - Text("Show Sonnet Usage") + Text("Model Usage") .font(.subheadline) - Text("Display weekly Sonnet usage in the menu bar popover") + Text("Choose which model-specific weekly limits appear in the menu bar popover") .font(.caption) .foregroundStyle(.secondary) } - Spacer() + if names.isEmpty { + Text("Your account has no model-specific limits right now. Any that appear will be listed here.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } else { + ForEach(names, id: \.self) { name in + HStack { + Text(name) + .font(.callout) - Toggle("", isOn: $appModel.settings.isSonnetUsageShown) - .labelsHidden() + Spacer() + + Toggle("", isOn: scopedModelBinding(for: name)) + .labelsHidden() + .accessibilityLabel("Show \(name) usage") + } + } + } } .padding() .background(.quaternary.opacity(0.3)) .clipShape(RoundedRectangle(cornerRadius: 8)) } + private func scopedModelBinding(for name: String) -> Binding { + Binding( + get: { appModel.settings.isScopedModelShown(name) }, + set: { appModel.settings.setScopedModel(name, isShown: $0) } + ) + } + // MARK: - Icon Style Section private var iconStyleSection: some View { diff --git a/ClaudeMeterTests/AppModelTests.swift b/ClaudeMeterTests/AppModelTests.swift index 363e660..1fee9e7 100644 --- a/ClaudeMeterTests/AppModelTests.swift +++ b/ClaudeMeterTests/AppModelTests.swift @@ -404,15 +404,3 @@ final class AppModelTests: XCTestCase { // MARK: - Helpers -private func makeUsageData(percentage: Double) -> UsageData { - let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval) - let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate) - let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate) - - return UsageData( - sessionUsage: sessionUsage, - weeklyUsage: weeklyUsage, - sonnetUsage: nil, - lastUpdated: Date() - ) -} diff --git a/ClaudeMeterTests/NotificationServiceTests.swift b/ClaudeMeterTests/NotificationServiceTests.swift index e6ef1d0..14c2a77 100644 --- a/ClaudeMeterTests/NotificationServiceTests.swift +++ b/ClaudeMeterTests/NotificationServiceTests.swift @@ -160,16 +160,3 @@ final class NotificationServiceTests: XCTestCase { // MARK: - Helpers -@MainActor -private func makeUsageData(percentage: Double) -> UsageData { - let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval) - let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate) - let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate) - - return UsageData( - sessionUsage: sessionUsage, - weeklyUsage: weeklyUsage, - sonnetUsage: nil, - lastUpdated: Date() - ) -} diff --git a/ClaudeMeterTests/ScopedUsageSettingsTests.swift b/ClaudeMeterTests/ScopedUsageSettingsTests.swift new file mode 100644 index 0000000..6c91a94 --- /dev/null +++ b/ClaudeMeterTests/ScopedUsageSettingsTests.swift @@ -0,0 +1,125 @@ +// +// ScopedUsageSettingsTests.swift +// ClaudeMeterTests +// + +import XCTest +@testable import ClaudeMeter + +final class ScopedUsageSettingsTests: XCTestCase { + + // MARK: - Settings migration + + func test_decode_withLegacyShowSonnetTrue_showsSonnet() throws { + let settings = try decodeSettings(#"{"show_sonnet_usage": true}"#) + + XCTAssertTrue(settings.hiddenScopedModels.isEmpty) + XCTAssertTrue(settings.isScopedModelShown("Sonnet")) + } + + func test_decode_withLegacyShowSonnetFalse_keepsSonnetHidden() throws { + let settings = try decodeSettings(#"{"show_sonnet_usage": false}"#) + + XCTAssertEqual(settings.hiddenScopedModels, ["Sonnet"]) + XCTAssertFalse(settings.isScopedModelShown("Sonnet")) + } + + func test_decode_withLegacyShowSonnetFalse_stillShowsOtherModels() throws { + let settings = try decodeSettings(#"{"show_sonnet_usage": false}"#) + + XCTAssertTrue(settings.isScopedModelShown("Fable")) + } + + /// The settings shape written by the 1.4.0 release. + func test_decode_settingsFromPreviousRelease_migratesCleanly() throws { + let settings = try decodeSettings(""" + {"is_first_launch":false,"refresh_interval":60,"icon_style":"dualBar", + "show_sonnet_usage":false,"is_colored_icon":false,"notifications_enabled":true, + "notification_thresholds":{"critical_threshold":90,"notify_on_reset":true,"warning_threshold":75}, + "cached_organization_id":"00000000-0000-0000-0000-000000000000"} + """) + + // Unrelated preferences survive + XCTAssertEqual(settings.iconStyle, .dualBar) + XCTAssertFalse(settings.isColoredIcon) + XCTAssertEqual(settings.refreshInterval, 60) + + // The Sonnet opt-out is preserved, and does not suppress Fable + XCTAssertFalse(settings.isScopedModelShown("Sonnet")) + XCTAssertTrue(settings.isScopedModelShown("Fable")) + } + + func test_decode_withHiddenScopedModels_roundTrips() throws { + let settings = try decodeSettings(#"{"hidden_scoped_models": ["Fable"]}"#) + + XCTAssertFalse(settings.isScopedModelShown("Fable")) + XCTAssertTrue(settings.isScopedModelShown("Opus")) + } + + func test_decode_withNeitherKey_showsEverything() throws { + let settings = try decodeSettings("{}") + + XCTAssertTrue(settings.hiddenScopedModels.isEmpty) + XCTAssertTrue(settings.isScopedModelShown("Fable")) + } + + func test_setScopedModel_togglesVisibility() { + var settings = AppSettings.default + + settings.setScopedModel("Fable", isShown: false) + XCTAssertFalse(settings.isScopedModelShown("Fable")) + + settings.setScopedModel("Fable", isShown: true) + XCTAssertTrue(settings.isScopedModelShown("Fable")) + XCTAssertTrue(settings.hiddenScopedModels.isEmpty) + } + + // MARK: - Public JSON export + + /// `~/.claudemeter/usage.json` is a documented contract for statusline scripts. + func test_export_stillEmitsSonnetUsageForExternalTools() throws { + let json = try exportJSON(makeUsageData(percentage: 10, scoped: [("Sonnet", 25), ("Fable", 23)])) + + let sonnet = try XCTUnwrap(json["sonnet_usage"] as? [String: Any]) + XCTAssertEqual(sonnet["utilization"] as? Double, 25) + + let scoped = try XCTUnwrap(json["scoped_usage"] as? [[String: Any]]) + XCTAssertEqual(scoped.compactMap { $0["name"] as? String }, ["Sonnet", "Fable"]) + } + + func test_export_withoutSonnet_omitsLegacyKey() throws { + let json = try exportJSON(makeUsageData(percentage: 10, scoped: [("Fable", 23)])) + + XCTAssertNil(json["sonnet_usage"]) + XCTAssertEqual((json["scoped_usage"] as? [[String: Any]])?.count, 1) + } + + func test_decode_legacyCacheWithSonnetUsage_becomesScopedLimit() throws { + let json = """ + { + "session_usage": { "utilization": 40, "reset_at": "2026-07-13T21:19:59Z" }, + "weekly_usage": { "utilization": 50, "reset_at": "2026-07-19T10:59:59Z" }, + "sonnet_usage": { "utilization": 25, "reset_at": "2026-07-19T10:59:59Z" }, + "last_updated": "2026-07-13T18:00:00Z" + } + """.data(using: .utf8)! + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let data = try decoder.decode(UsageData.self, from: json) + + XCTAssertEqual(data.scopedUsage.map(\.name), ["Sonnet"]) + XCTAssertEqual(data.scopedUsage.first?.limit.utilization, 25) + } + + // MARK: - Helpers + + private func decodeSettings(_ json: String) throws -> AppSettings { + try JSONDecoder().decode(AppSettings.self, from: json.data(using: .utf8)!) + } + + private func exportJSON(_ data: UsageData) throws -> [String: Any] { + let encoded = try JSONEncoder().encode(UsageExportPayload(data)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + } +} diff --git a/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift b/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift new file mode 100644 index 0000000..3f18bcf --- /dev/null +++ b/ClaudeMeterTests/TestSupport/UsageTestFixtures.swift @@ -0,0 +1,51 @@ +// +// UsageTestFixtures.swift +// ClaudeMeterTests +// + +import Foundation +import XCTest +@testable import ClaudeMeter + +func makeUsageData( + percentage: Double, + weeklyPercentage: Double = TestConstants.weeklyPercentage, + scoped: [(name: String, percentage: Double)] = [], + resetAt: Date = Date().addingTimeInterval(TestConstants.oneHourInterval) +) -> UsageData { + UsageData( + sessionUsage: UsageLimit(utilization: percentage, resetAt: resetAt), + weeklyUsage: UsageLimit(utilization: weeklyPercentage, resetAt: resetAt), + scopedUsage: scoped.map { + ScopedUsageLimit( + name: $0.name, + limit: UsageLimit(utilization: $0.percentage, resetAt: resetAt), + isActive: false + ) + }, + lastUpdated: Date() + ) +} + +func assertDate( + _ date: Date, + equalsIso8601String isoString: String, + file: StaticString = #filePath, + line: UInt = #line +) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + guard let expectedDate = formatter.date(from: isoString) else { + XCTFail("Invalid ISO8601 test date: \(isoString)", file: file, line: line) + return + } + + XCTAssertEqual( + date.timeIntervalSince1970, + expectedDate.timeIntervalSince1970, + accuracy: 0.001, + file: file, + line: line + ) +} diff --git a/ClaudeMeterTests/UsageAPIResponseTests.swift b/ClaudeMeterTests/UsageAPIResponseTests.swift new file mode 100644 index 0000000..b9322a8 --- /dev/null +++ b/ClaudeMeterTests/UsageAPIResponseTests.swift @@ -0,0 +1,210 @@ +// +// UsageAPIResponseTests.swift +// ClaudeMeterTests +// + +import XCTest +@testable import ClaudeMeter + +final class UsageAPIResponseTests: XCTestCase { + + // MARK: - Current API shape (`limits` array) + + /// Mirrors the shape the API returns. The flat per-model fields are null — the + /// model-specific data lives only in `limits`. + private let liveResponse = """ + { + "five_hour": { + "utilization": 30, + "resets_at": "2026-01-01T12:00:00.123456+00:00" + }, + "seven_day": { + "utilization": 40, + "resets_at": "2026-01-05T00:00:00.123456+00:00" + }, + "seven_day_opus": null, + "seven_day_sonnet": null, + "limits": [ + { + "kind": "session", + "group": "session", + "percent": 30, + "severity": "normal", + "resets_at": "2026-01-01T12:00:00.123456+00:00", + "scope": null, + "is_active": false + }, + { + "kind": "weekly_all", + "group": "weekly", + "percent": 40, + "severity": "normal", + "resets_at": "2026-01-05T00:00:00.123456+00:00", + "scope": null, + "is_active": false + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 50, + "severity": "normal", + "resets_at": "2026-01-05T00:00:00.123456+00:00", + "scope": { + "model": { "id": null, "display_name": "Fable" }, + "surface": null + }, + "is_active": true + } + ] + } + """.data(using: .utf8)! + + func test_toDomain_withLimitsArray_mapsSessionAndWeekly() throws { + let usageData = try decode(liveResponse).toDomain() + + XCTAssertEqual(usageData.sessionUsage.utilization, 30) + XCTAssertEqual(usageData.weeklyUsage.utilization, 40) + } + + func test_toDomain_withLimitsArray_surfacesScopedModelByDisplayName() throws { + let usageData = try decode(liveResponse).toDomain() + + XCTAssertEqual(usageData.scopedUsage.count, 1) + + let fable = try XCTUnwrap(usageData.scopedUsage.first) + XCTAssertEqual(fable.name, "Fable") + XCTAssertEqual(fable.limit.utilization, 50) + XCTAssertTrue(fable.isActive) + XCTAssertEqual(fable.title, "Weekly Fable") + } + + func test_toDomain_withUnknownFutureModel_surfacesItAnyway() throws { + let json = """ + { + "limits": [ + { "kind": "session", "percent": 10, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_all", "percent": 20, "resets_at": null, "scope": null, "is_active": false }, + { + "kind": "weekly_scoped", + "percent": 66, + "resets_at": null, + "scope": { "model": { "id": null, "display_name": "Nonesuch 9" }, "surface": null }, + "is_active": true + } + ] + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Nonesuch 9"]) + XCTAssertEqual(usageData.scopedUsage.first?.limit.utilization, 66) + } + + func test_toDomain_withMultipleScopedLimits_preservesAPIOrder() throws { + let json = """ + { + "limits": [ + { "kind": "session", "percent": 1, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_all", "percent": 2, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_scoped", "percent": 23, "resets_at": null, + "scope": { "model": { "id": null, "display_name": "Fable" } }, "is_active": true }, + { "kind": "weekly_scoped", "percent": 58, "resets_at": null, + "scope": { "model": { "id": null, "display_name": "Opus" } }, "is_active": false } + ] + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Fable", "Opus"]) + } + + func test_toDomain_withUnrecognisedScopeShape_doesNotThrow() throws { + let json = """ + { + "limits": [ + { "kind": "session", "percent": 3, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_all", "percent": 4, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_scoped", "percent": 30, "resets_at": null, + "scope": { "model": "just-a-string", "surface": 42 }, "is_active": true } + ] + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + XCTAssertEqual(usageData.sessionUsage.utilization, 3) + XCTAssertTrue(usageData.scopedUsage.isEmpty) + } + + func test_toDomain_missingSessionLimit_throws() throws { + let json = """ + { "limits": [ { "kind": "weekly_all", "percent": 4, "resets_at": null, "scope": null, "is_active": false } ] } + """.data(using: .utf8)! + + XCTAssertThrowsError(try decode(json).toDomain()) + } + + func test_toDomain_withScopeOnHeadlineEntry_doesNotDuplicateIt() throws { + let json = """ + { + "limits": [ + { "kind": "session", "percent": 5, "resets_at": null, "scope": null, "is_active": false }, + { "kind": "weekly_all", "percent": 14, "resets_at": null, + "scope": { "model": { "id": null, "display_name": "Fable" } }, "is_active": false } + ] + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + XCTAssertEqual(usageData.weeklyUsage.utilization, 14) + XCTAssertTrue(usageData.scopedUsage.isEmpty) + } + + // MARK: - Legacy API shape (no `limits` array) + + func test_toDomain_withoutLimits_fallsBackToFlatFields() throws { + let json = """ + { + "five_hour": { "utilization": 40, "resets_at": null }, + "seven_day": { "utilization": 50, "resets_at": null }, + "seven_day_sonnet": { "utilization": 25, "resets_at": null } + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + XCTAssertEqual(usageData.sessionUsage.utilization, 40) + XCTAssertEqual(usageData.weeklyUsage.utilization, 50) + XCTAssertEqual(usageData.scopedUsage.map(\.name), ["Sonnet"]) + XCTAssertEqual(usageData.scopedUsage.first?.limit.utilization, 25) + } + + // MARK: - Timestamps + + func test_toDomain_parsesTimestampsWithAndWithoutFractionalSeconds() throws { + let json = """ + { + "limits": [ + { "kind": "session", "percent": 5, "resets_at": "2026-01-01T12:00:00.123456+00:00", + "scope": null, "is_active": false }, + { "kind": "weekly_all", "percent": 14, "resets_at": "2026-01-05T00:00:00Z", + "scope": null, "is_active": false } + ] + } + """.data(using: .utf8)! + + let usageData = try decode(json).toDomain() + + assertDate(usageData.sessionUsage.resetAt, equalsIso8601String: "2026-01-01T12:00:00.123456+00:00") + assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: "2026-01-05T00:00:00.000Z") + } + + // MARK: - Helpers + + private func decode(_ data: Data) throws -> UsageAPIResponse { + try JSONDecoder().decode(UsageAPIResponse.self, from: data) + } +} diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift index a40ae1d..d863148 100644 --- a/ClaudeMeterTests/UsageServiceTests.swift +++ b/ClaudeMeterTests/UsageServiceTests.swift @@ -299,12 +299,10 @@ final class UsageServiceTests: XCTestCase { let usageData = try await service.fetchUsage(forceRefresh: true) - XCTAssertEqual(usageData.sonnetUsage?.utilization, TestConstants.sonnetPercentage) - if let resetAt = usageData.sonnetUsage?.resetAt { - assertDate(resetAt, equalsIso8601String: TestConstants.sonnetResetDateString) - } else { - XCTFail("Expected sonnet usage reset date") - } + let sonnet = try XCTUnwrap(usageData.scopedUsage.first) + XCTAssertEqual(sonnet.name, "Sonnet") + XCTAssertEqual(sonnet.limit.utilization, TestConstants.sonnetPercentage) + assertDate(sonnet.limit.resetAt, equalsIso8601String: TestConstants.sonnetResetDateString) } } @@ -340,27 +338,4 @@ private func makeUsageResponseData( return try JSONEncoder().encode(response) } -private func makeUsageData(percentage: Double) -> UsageData { - let resetDate = Date().addingTimeInterval(TestConstants.oneHourInterval) - let sessionUsage = UsageLimit(utilization: percentage, resetAt: resetDate) - let weeklyUsage = UsageLimit(utilization: TestConstants.weeklyPercentage, resetAt: resetDate) - - return UsageData( - sessionUsage: sessionUsage, - weeklyUsage: weeklyUsage, - sonnetUsage: nil, - lastUpdated: Date() - ) -} - -private func assertDate(_ date: Date, equalsIso8601String isoString: String) { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - guard let expectedDate = formatter.date(from: isoString) else { - XCTFail("Invalid ISO8601 test date: \(isoString)") - return - } - XCTAssertEqual(date.timeIntervalSince1970, expectedDate.timeIntervalSince1970, accuracy: 0.001) -} diff --git a/README.md b/README.md index 4eb2797..aafcf73 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ Keep track of your Claude.ai plan usage at a glance. ## Features -- **Real-time usage monitoring** - Track your 5-hour session, 7-day weekly, and Sonnet-specific usage limits +- **Real-time usage monitoring** - Track your 5-hour session, 7-day weekly, and model-specific usage limits +- **Any model, automatically** - Model-specific limits (Fable, Opus, Sonnet, ...) are read from whatever the API reports, so a newly launched model appears without an app update; toggle each one in Settings - **Menu bar integration** - Clean, colour-coded usage indicator that lives in your macOS menu bar - **Multiple icon styles** - Choose from 6 icon styles: Battery, Circular, Minimal, Segments, Dual Bar, or Gauge - **Pacing indicator** - Flame icon warns when you're using Claude faster than sustainable pace @@ -25,7 +26,7 @@ The menu bar icon changes colour based on your usage levels: Menu bar - Critical threshold

-When using Sonnet models, an additional indicator shows your Sonnet-specific usage: +When a model has its own weekly cap, an additional card shows that model's usage:

Menu bar - Sonnet usage @@ -134,17 +135,31 @@ ClaudeMeter exports usage data to `~/.claudemeter/usage.json` for use with exter "reset_at": "2025-12-24T12:00:00Z", "utilization": 29 }, - "sonnet_usage": { - "reset_at": "2025-12-30T00:00:00Z", - "utilization": 15 - }, "weekly_usage": { "reset_at": "2025-12-30T00:00:00Z", "utilization": 45 + }, + "scoped_usage": [ + { + "name": "Fable", + "is_active": true, + "limit": { + "reset_at": "2025-12-30T00:00:00Z", + "utilization": 23 + } + } + ], + "sonnet_usage": { + "reset_at": "2025-12-30T00:00:00Z", + "utilization": 15 } } ``` +`scoped_usage` lists every model-specific limit the API reports, named as the API names it. +`sonnet_usage` is kept as a deprecated alias so existing statusline scripts keep working; it is +present only when a Sonnet limit exists. Prefer `scoped_usage` for new scripts. + **Example: Claude Code statusline** Create `~/.claude/statusline.sh`: diff --git a/scripts/demo.sh b/scripts/demo.sh index 98f78a0..b496df7 100755 --- a/scripts/demo.sh +++ b/scripts/demo.sh @@ -13,7 +13,7 @@ MODES=( "warningUsage|Medium usage - warning state" "criticalUsage|High usage - critical state" "exceededUsage|Over limit - exceeded state" - "withSonnet|Shows Sonnet usage card" + "withModelLimits|Shows model-specific usage cards" "loading|Loading spinner visible" "error|Error banner displayed" "setupWizard|First-time setup screen" From 331cd974d82cd0d8cb0daab7e7bd39588b5de5ef Mon Sep 17 00:00:00 2001 From: Matjaz Domen Pecan Date: Mon, 13 Jul 2026 19:17:36 +0200 Subject: [PATCH 2/2] refactor(settings): make model-scoped limits opt-in Store shown_scoped_models rather than hidden_scoped_models, defaulting to empty. Opt-out meant a model the API started reporting would appear in the popover on its own, which is the app deciding for the user. Opt-in keeps the popover to what the user actually asked for; Settings still lists every model the API reports, so a new one stays discoverable without an app update. Migration follows the same rule: show_sonnet_usage=true becomes ["Sonnet"], and false (or absent) opts into nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- ClaudeMeter/Models/AppSettings.swift | 26 ++++++------ ClaudeMeter/Utilities/DemoDataFactory.swift | 2 +- ClaudeMeter/Views/Settings/SettingsView.swift | 4 +- .../ScopedUsageSettingsTests.swift | 41 ++++++++++--------- README.md | 2 +- 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/ClaudeMeter/Models/AppSettings.swift b/ClaudeMeter/Models/AppSettings.swift index 8a17b86..ab72a05 100644 --- a/ClaudeMeter/Models/AppSettings.swift +++ b/ClaudeMeter/Models/AppSettings.swift @@ -24,9 +24,9 @@ struct AppSettings: Codable, Equatable, Sendable { /// Last known organization ID (cached) var cachedOrganizationId: UUID? - /// Opt-out rather than opt-in, so a model the API starts reporting after this - /// build shows up on its own instead of waiting behind a switch. - var hiddenScopedModels: Set + /// Model-scoped limits the user has opted into showing, by API display name. + /// Empty by default: nothing appears in the popover until the user asks for it. + var shownScopedModels: Set /// Menu bar icon display style var iconStyle: IconStyle @@ -40,7 +40,7 @@ struct AppSettings: Codable, Equatable, Sendable { notificationThresholds: .default, isFirstLaunch: true, cachedOrganizationId: nil, - hiddenScopedModels: [], + shownScopedModels: [], iconStyle: .battery, isColoredIcon: true ) @@ -51,12 +51,12 @@ struct AppSettings: Codable, Equatable, Sendable { case notificationThresholds = "notification_thresholds" case isFirstLaunch = "is_first_launch" case cachedOrganizationId = "cached_organization_id" - case hiddenScopedModels = "hidden_scoped_models" + case shownScopedModels = "shown_scoped_models" case iconStyle = "icon_style" case isColoredIcon = "is_colored_icon" } - /// Read-only: migrates settings saved before `hiddenScopedModels` existed. + /// Read-only: migrates settings saved before `shownScopedModels` existed. /// Kept out of `CodingKeys` so `encode` stays synthesized. private enum LegacyCodingKeys: String, CodingKey { case showSonnetUsage = "show_sonnet_usage" @@ -76,12 +76,12 @@ extension AppSettings { iconStyle = try container.decodeIfPresent(IconStyle.self, forKey: .iconStyle) ?? defaults.iconStyle isColoredIcon = try container.decodeIfPresent(Bool.self, forKey: .isColoredIcon) ?? defaults.isColoredIcon - if let hidden = try container.decodeIfPresent(Set.self, forKey: .hiddenScopedModels) { - hiddenScopedModels = hidden + if let shown = try container.decodeIfPresent(Set.self, forKey: .shownScopedModels) { + shownScopedModels = shown } else { let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self) - let wasSonnetShown = try legacy.decodeIfPresent(Bool.self, forKey: .showSonnetUsage) - hiddenScopedModels = wasSonnetShown.map { $0 ? [] : ["Sonnet"] } ?? defaults.hiddenScopedModels + let wasSonnetShown = try legacy.decodeIfPresent(Bool.self, forKey: .showSonnetUsage) ?? false + shownScopedModels = wasSonnetShown ? ["Sonnet"] : defaults.shownScopedModels } } } @@ -94,14 +94,14 @@ extension AppSettings { /// Whether a model-scoped limit should appear in the popover func isScopedModelShown(_ name: String) -> Bool { - !hiddenScopedModels.contains(name) + shownScopedModels.contains(name) } mutating func setScopedModel(_ name: String, isShown: Bool) { if isShown { - hiddenScopedModels.remove(name) + shownScopedModels.insert(name) } else { - hiddenScopedModels.insert(name) + shownScopedModels.remove(name) } } } diff --git a/ClaudeMeter/Utilities/DemoDataFactory.swift b/ClaudeMeter/Utilities/DemoDataFactory.swift index 102f321..1b8c4c6 100644 --- a/ClaudeMeter/Utilities/DemoDataFactory.swift +++ b/ClaudeMeter/Utilities/DemoDataFactory.swift @@ -57,7 +57,7 @@ enum DemoDataFactory { errorMessage: nil, isLoading: false ) - appModel.settings.hiddenScopedModels = [] + appModel.settings.shownScopedModels = ["Fable", "Opus"] case .loading: appModel.applyDemoState( diff --git a/ClaudeMeter/Views/Settings/SettingsView.swift b/ClaudeMeter/Views/Settings/SettingsView.swift index 5904061..992ae54 100644 --- a/ClaudeMeter/Views/Settings/SettingsView.swift +++ b/ClaudeMeter/Views/Settings/SettingsView.swift @@ -212,10 +212,10 @@ struct SettingsView: View { // MARK: - Model Usage Section - /// Includes hidden names so a model that drops out of the response can still be switched back on. + /// Includes opted-in names so a model that drops out of the response can still be switched off. private var scopedModelNames: [String] { let reported = appModel.usageData?.scopedUsage.map(\.name) ?? [] - return Array(Set(reported).union(appModel.settings.hiddenScopedModels)).sorted() + return Array(Set(reported).union(appModel.settings.shownScopedModels)).sorted() } private var modelUsageSection: some View { diff --git a/ClaudeMeterTests/ScopedUsageSettingsTests.swift b/ClaudeMeterTests/ScopedUsageSettingsTests.swift index 6c91a94..24abc2e 100644 --- a/ClaudeMeterTests/ScopedUsageSettingsTests.swift +++ b/ClaudeMeterTests/ScopedUsageSettingsTests.swift @@ -13,21 +13,22 @@ final class ScopedUsageSettingsTests: XCTestCase { func test_decode_withLegacyShowSonnetTrue_showsSonnet() throws { let settings = try decodeSettings(#"{"show_sonnet_usage": true}"#) - XCTAssertTrue(settings.hiddenScopedModels.isEmpty) + XCTAssertEqual(settings.shownScopedModels, ["Sonnet"]) XCTAssertTrue(settings.isScopedModelShown("Sonnet")) } - func test_decode_withLegacyShowSonnetFalse_keepsSonnetHidden() throws { - let settings = try decodeSettings(#"{"show_sonnet_usage": false}"#) + func test_decode_withLegacyShowSonnetTrue_doesNotOptIntoOtherModels() throws { + let settings = try decodeSettings(#"{"show_sonnet_usage": true}"#) - XCTAssertEqual(settings.hiddenScopedModels, ["Sonnet"]) - XCTAssertFalse(settings.isScopedModelShown("Sonnet")) + XCTAssertFalse(settings.isScopedModelShown("Fable")) } - func test_decode_withLegacyShowSonnetFalse_stillShowsOtherModels() throws { + func test_decode_withLegacyShowSonnetFalse_showsNothing() throws { let settings = try decodeSettings(#"{"show_sonnet_usage": false}"#) - XCTAssertTrue(settings.isScopedModelShown("Fable")) + XCTAssertTrue(settings.shownScopedModels.isEmpty) + XCTAssertFalse(settings.isScopedModelShown("Sonnet")) + XCTAssertFalse(settings.isScopedModelShown("Fable")) } /// The settings shape written by the 1.4.0 release. @@ -44,34 +45,34 @@ final class ScopedUsageSettingsTests: XCTestCase { XCTAssertFalse(settings.isColoredIcon) XCTAssertEqual(settings.refreshInterval, 60) - // The Sonnet opt-out is preserved, and does not suppress Fable + // The Sonnet opt-out is preserved, and nothing is opted in on the user's behalf XCTAssertFalse(settings.isScopedModelShown("Sonnet")) - XCTAssertTrue(settings.isScopedModelShown("Fable")) + XCTAssertFalse(settings.isScopedModelShown("Fable")) } - func test_decode_withHiddenScopedModels_roundTrips() throws { - let settings = try decodeSettings(#"{"hidden_scoped_models": ["Fable"]}"#) + func test_decode_withShownScopedModels_roundTrips() throws { + let settings = try decodeSettings(#"{"shown_scoped_models": ["Fable"]}"#) - XCTAssertFalse(settings.isScopedModelShown("Fable")) - XCTAssertTrue(settings.isScopedModelShown("Opus")) + XCTAssertTrue(settings.isScopedModelShown("Fable")) + XCTAssertFalse(settings.isScopedModelShown("Opus")) } - func test_decode_withNeitherKey_showsEverything() throws { + func test_decode_withNeitherKey_showsNothing() throws { let settings = try decodeSettings("{}") - XCTAssertTrue(settings.hiddenScopedModels.isEmpty) - XCTAssertTrue(settings.isScopedModelShown("Fable")) + XCTAssertTrue(settings.shownScopedModels.isEmpty) + XCTAssertFalse(settings.isScopedModelShown("Fable")) } func test_setScopedModel_togglesVisibility() { var settings = AppSettings.default - settings.setScopedModel("Fable", isShown: false) - XCTAssertFalse(settings.isScopedModelShown("Fable")) - settings.setScopedModel("Fable", isShown: true) XCTAssertTrue(settings.isScopedModelShown("Fable")) - XCTAssertTrue(settings.hiddenScopedModels.isEmpty) + + settings.setScopedModel("Fable", isShown: false) + XCTAssertFalse(settings.isScopedModelShown("Fable")) + XCTAssertTrue(settings.shownScopedModels.isEmpty) } // MARK: - Public JSON export diff --git a/README.md b/README.md index aafcf73..965b6b2 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Keep track of your Claude.ai plan usage at a glance. ## Features - **Real-time usage monitoring** - Track your 5-hour session, 7-day weekly, and model-specific usage limits -- **Any model, automatically** - Model-specific limits (Fable, Opus, Sonnet, ...) are read from whatever the API reports, so a newly launched model appears without an app update; toggle each one in Settings +- **Any model, no update needed** - Model-specific limits (Fable, Opus, Sonnet, ...) are read from whatever the API reports, so a newly launched model is listed in Settings without an app update; switch on the ones you want shown in the popover - **Menu bar integration** - Clean, colour-coded usage indicator that lives in your macOS menu bar - **Multiple icon styles** - Choose from 6 icon styles: Battery, Circular, Minimal, Segments, Dual Bar, or Gauge - **Pacing indicator** - Flame icon warns when you're using Claude faster than sustainable pace