Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 160 additions & 47 deletions ClaudeMeter/Models/API/UsageAPIResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,108 @@

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
case resetsAt = "resets_at"
}
}

/// 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)
Expand All @@ -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
Expand Down
37 changes: 32 additions & 5 deletions ClaudeMeter/Models/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// 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<String>

/// Menu bar icon display style
var iconStyle: IconStyle
Expand All @@ -39,7 +40,7 @@ struct AppSettings: Codable, Equatable, Sendable {
notificationThresholds: .default,
isFirstLaunch: true,
cachedOrganizationId: nil,
isSonnetUsageShown: false,
shownScopedModels: [],
iconStyle: .battery,
isColoredIcon: true
)
Expand All @@ -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 shownScopedModels = "shown_scoped_models"
case iconStyle = "icon_style"
case isColoredIcon = "is_colored_icon"
}

/// 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"
}
}

extension AppSettings {
Expand All @@ -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 shown = try container.decodeIfPresent(Set<String>.self, forKey: .shownScopedModels) {
shownScopedModels = shown
} else {
let legacy = try decoder.container(keyedBy: LegacyCodingKeys.self)
let wasSonnetShown = try legacy.decodeIfPresent(Bool.self, forKey: .showSonnetUsage) ?? false
shownScopedModels = wasSonnetShown ? ["Sonnet"] : defaults.shownScopedModels
}
}
}

Expand All @@ -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 {
shownScopedModels.contains(name)
}

mutating func setScopedModel(_ name: String, isShown: Bool) {
if isShown {
shownScopedModels.insert(name)
} else {
shownScopedModels.remove(name)
}
}
}
28 changes: 28 additions & 0 deletions ClaudeMeter/Models/ScopedUsageLimit.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
Loading