Skip to content
Merged
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
61 changes: 34 additions & 27 deletions ClaudeMeter/Models/API/UsageAPIResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}
3 changes: 1 addition & 2 deletions ClaudeMeter/Views/MenuBar/UsagePopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
45 changes: 43 additions & 2 deletions ClaudeMeterTests/UsageServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down