From c385a9a442633f93498e3b021b72503ceb8abcb2 Mon Sep 17 00:00:00 2001 From: Edd Mann Date: Mon, 18 May 2026 15:03:27 +0100 Subject: [PATCH 1/2] fix(api): tolerate null usage reset times Claude can return resets_at: null for usage windows that have no current consumption. The mapper previously treated that valid response as an invalid payload and blocked refresh entirely. Use the relevant window duration as a fallback while preserving strict failure for malformed non-null dates, and cover both paths in UsageServiceTests. Adapted from PR #18 by Jean Friesewinkel, with additional issue context from PR #13 by Rasyid Ridho. Co-authored-by: Jean Friesewinkel Co-authored-by: Rasyid Ridho --- ClaudeMeter/Models/API/UsageAPIResponse.swift | 61 +++++++++++-------- ClaudeMeterTests/UsageServiceTests.swift | 45 +++++++++++++- 2 files changed, 77 insertions(+), 29 deletions(-) diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift index b1bea04..9d69d31 100644 --- a/ClaudeMeter/Models/API/UsageAPIResponse.swift +++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift @@ -49,38 +49,30 @@ enum MappingError: LocalizedError { /// Extension to map API response to domain model extension UsageAPIResponse { func toDomain() throws -> UsageData { - // Configure ISO8601 formatter with proper options let iso8601Formatter = ISO8601DateFormatter() iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - // Parse reset dates (must be present and valid) - let sessionResetDate: Date - let weeklyResetDate: Date - - guard let sessionResetString = fiveHour.resetsAt, - let parsedDate = iso8601Formatter.date(from: sessionResetString) else { - throw MappingError.missingCriticalField(field: "fiveHour.resetsAt") - } - sessionResetDate = parsedDate - - guard let weeklyResetString = sevenDay.resetsAt, - let parsedDate = iso8601Formatter.date(from: weeklyResetString) else { - throw MappingError.missingCriticalField(field: "sevenDay.resetsAt") - } - weeklyResetDate = parsedDate + 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 + ) // Handle optional sonnet usage - let sonnetLimit: UsageLimit? = sevenDaySonnet.flatMap { sonnet in - let sonnetResetDate: Date - - if let sonnetResetString = sonnet.resetsAt, - let parsedDate = iso8601Formatter.date(from: sonnetResetString) { - sonnetResetDate = parsedDate - } else { - // Default to 7 days in the future if no reset date - sonnetResetDate = Date().addingTimeInterval(7 * 24 * 3600) - } - + 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 @@ -100,4 +92,19 @@ extension UsageAPIResponse { lastUpdated: Date() ) } + + 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 { + throw MappingError.missingCriticalField(field: field) + } + return date + } } diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift index f5e1ca0..a40ae1d 100644 --- a/ClaudeMeterTests/UsageServiceTests.swift +++ b/ClaudeMeterTests/UsageServiceTests.swift @@ -181,9 +181,9 @@ final class UsageServiceTests: XCTestCase { assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: TestConstants.weeklyResetDateString) } - func test_usageFetch_withInvalidPayload_surfacesInvalidResponse() async throws { + func test_usageFetch_withMissingResetAt_usesFallbackWindow() async throws { let responseData = try makeUsageResponseData( - sessionUtilization: TestConstants.sessionPercentage, + sessionUtilization: 0, weeklyUtilization: TestConstants.weeklyPercentage, sessionResetAt: nil, weeklyResetAt: TestConstants.weeklyResetDateString, @@ -212,6 +212,47 @@ final class UsageServiceTests: XCTestCase { settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) try await settingsRepository.save(settings) + let usageData = try await service.fetchUsage(forceRefresh: true) + + XCTAssertEqual(usageData.sessionUsage.utilization, 0) + XCTAssertGreaterThan(usageData.sessionUsage.resetAt.timeIntervalSinceNow, 0) + XCTAssertLessThanOrEqual( + usageData.sessionUsage.resetAt.timeIntervalSinceNow, + Constants.Pacing.sessionWindow + 5 + ) + } + + func test_usageFetch_withMalformedResetAt_surfacesInvalidResponse() async throws { + let responseData = try makeUsageResponseData( + sessionUtilization: TestConstants.sessionPercentage, + weeklyUtilization: TestConstants.weeklyPercentage, + sessionResetAt: "not-a-date", + weeklyResetAt: TestConstants.weeklyResetDateString, + sonnetUtilization: nil, + sonnetResetAt: nil + ) + + let networkService = NetworkServiceStub(responseData: responseData) + let cacheRepository = CacheRepositoryFake() + let keychainRepository = KeychainRepositoryFake() + let settingsRepository = SettingsRepositoryFake() + + let service = UsageService( + networkService: networkService, + cacheRepository: cacheRepository, + keychainRepository: keychainRepository, + settingsRepository: settingsRepository + ) + + try await keychainRepository.save( + sessionKey: TestConstants.sessionKeyValue, + account: "default" + ) + + var settings = AppSettings.default + settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) + try await settingsRepository.save(settings) + do { _ = try await service.fetchUsage(forceRefresh: true) XCTFail("Expected invalidResponse error") From 8196425907b8a03ac731d4f545a20c111feb38be Mon Sep 17 00:00:00 2001 From: Edd Mann Date: Mon, 18 May 2026 15:03:44 +0100 Subject: [PATCH 2/2] fix(popover): use native small refresh spinner The refresh button used scaleEffect plus a fixed frame around an AppKit-backed ProgressView. On macOS that combination can produce SwiftUI constraint assertions when the button enters the refreshing state. Use controlSize(.small), which asks AppKit for the smaller spinner without conflicting size constraints. Adapted from PR #19 by Jean Friesewinkel. Co-authored-by: Jean Friesewinkel --- ClaudeMeter/Views/MenuBar/UsagePopoverView.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift index 7147cd6..199bf06 100644 --- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift @@ -32,8 +32,7 @@ struct UsagePopoverView: View { }) { if appModel.isRefreshing { ProgressView() - .scaleEffect(0.7) - .frame(width: 20, height: 20) + .controlSize(.small) } else { Image(systemName: "arrow.clockwise") }