Skip to content

Commit d4271f1

Browse files
authored
Merge pull request #21 from eddmann/codex/null-reset-spinner-fixes
fix: handle null usage resets and spinner sizing
2 parents b8bc69b + 8196425 commit d4271f1

3 files changed

Lines changed: 78 additions & 31 deletions

File tree

ClaudeMeter/Models/API/UsageAPIResponse.swift

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -49,38 +49,30 @@ enum MappingError: LocalizedError {
4949
/// Extension to map API response to domain model
5050
extension UsageAPIResponse {
5151
func toDomain() throws -> UsageData {
52-
// Configure ISO8601 formatter with proper options
5352
let iso8601Formatter = ISO8601DateFormatter()
5453
iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
5554

56-
// Parse reset dates (must be present and valid)
57-
let sessionResetDate: Date
58-
let weeklyResetDate: Date
59-
60-
guard let sessionResetString = fiveHour.resetsAt,
61-
let parsedDate = iso8601Formatter.date(from: sessionResetString) else {
62-
throw MappingError.missingCriticalField(field: "fiveHour.resetsAt")
63-
}
64-
sessionResetDate = parsedDate
65-
66-
guard let weeklyResetString = sevenDay.resetsAt,
67-
let parsedDate = iso8601Formatter.date(from: weeklyResetString) else {
68-
throw MappingError.missingCriticalField(field: "sevenDay.resetsAt")
69-
}
70-
weeklyResetDate = parsedDate
55+
let sessionResetDate = try parseResetDate(
56+
from: fiveHour.resetsAt,
57+
field: "fiveHour.resetsAt",
58+
formatter: iso8601Formatter,
59+
fallback: Constants.Pacing.sessionWindow
60+
)
61+
let weeklyResetDate = try parseResetDate(
62+
from: sevenDay.resetsAt,
63+
field: "sevenDay.resetsAt",
64+
formatter: iso8601Formatter,
65+
fallback: Constants.Pacing.weeklyWindow
66+
)
7167

7268
// Handle optional sonnet usage
73-
let sonnetLimit: UsageLimit? = sevenDaySonnet.flatMap { sonnet in
74-
let sonnetResetDate: Date
75-
76-
if let sonnetResetString = sonnet.resetsAt,
77-
let parsedDate = iso8601Formatter.date(from: sonnetResetString) {
78-
sonnetResetDate = parsedDate
79-
} else {
80-
// Default to 7 days in the future if no reset date
81-
sonnetResetDate = Date().addingTimeInterval(7 * 24 * 3600)
82-
}
83-
69+
let sonnetLimit: UsageLimit? = try sevenDaySonnet.flatMap { sonnet -> UsageLimit? in
70+
let sonnetResetDate = try parseResetDate(
71+
from: sonnet.resetsAt,
72+
field: "sevenDaySonnet.resetsAt",
73+
formatter: iso8601Formatter,
74+
fallback: Constants.Pacing.weeklyWindow
75+
)
8476
return UsageLimit(
8577
utilization: sonnet.utilization,
8678
resetAt: sonnetResetDate
@@ -100,4 +92,19 @@ extension UsageAPIResponse {
10092
lastUpdated: Date()
10193
)
10294
}
95+
96+
private func parseResetDate(
97+
from rawValue: String?,
98+
field: String,
99+
formatter: ISO8601DateFormatter,
100+
fallback: TimeInterval
101+
) throws -> Date {
102+
guard let rawValue else {
103+
return Date().addingTimeInterval(fallback)
104+
}
105+
guard let date = formatter.date(from: rawValue) else {
106+
throw MappingError.missingCriticalField(field: field)
107+
}
108+
return date
109+
}
103110
}

ClaudeMeter/Views/MenuBar/UsagePopoverView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ struct UsagePopoverView: View {
3232
}) {
3333
if appModel.isRefreshing {
3434
ProgressView()
35-
.scaleEffect(0.7)
36-
.frame(width: 20, height: 20)
35+
.controlSize(.small)
3736
} else {
3837
Image(systemName: "arrow.clockwise")
3938
}

ClaudeMeterTests/UsageServiceTests.swift

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,9 +181,9 @@ final class UsageServiceTests: XCTestCase {
181181
assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: TestConstants.weeklyResetDateString)
182182
}
183183

184-
func test_usageFetch_withInvalidPayload_surfacesInvalidResponse() async throws {
184+
func test_usageFetch_withMissingResetAt_usesFallbackWindow() async throws {
185185
let responseData = try makeUsageResponseData(
186-
sessionUtilization: TestConstants.sessionPercentage,
186+
sessionUtilization: 0,
187187
weeklyUtilization: TestConstants.weeklyPercentage,
188188
sessionResetAt: nil,
189189
weeklyResetAt: TestConstants.weeklyResetDateString,
@@ -212,6 +212,47 @@ final class UsageServiceTests: XCTestCase {
212212
settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString)
213213
try await settingsRepository.save(settings)
214214

215+
let usageData = try await service.fetchUsage(forceRefresh: true)
216+
217+
XCTAssertEqual(usageData.sessionUsage.utilization, 0)
218+
XCTAssertGreaterThan(usageData.sessionUsage.resetAt.timeIntervalSinceNow, 0)
219+
XCTAssertLessThanOrEqual(
220+
usageData.sessionUsage.resetAt.timeIntervalSinceNow,
221+
Constants.Pacing.sessionWindow + 5
222+
)
223+
}
224+
225+
func test_usageFetch_withMalformedResetAt_surfacesInvalidResponse() async throws {
226+
let responseData = try makeUsageResponseData(
227+
sessionUtilization: TestConstants.sessionPercentage,
228+
weeklyUtilization: TestConstants.weeklyPercentage,
229+
sessionResetAt: "not-a-date",
230+
weeklyResetAt: TestConstants.weeklyResetDateString,
231+
sonnetUtilization: nil,
232+
sonnetResetAt: nil
233+
)
234+
235+
let networkService = NetworkServiceStub(responseData: responseData)
236+
let cacheRepository = CacheRepositoryFake()
237+
let keychainRepository = KeychainRepositoryFake()
238+
let settingsRepository = SettingsRepositoryFake()
239+
240+
let service = UsageService(
241+
networkService: networkService,
242+
cacheRepository: cacheRepository,
243+
keychainRepository: keychainRepository,
244+
settingsRepository: settingsRepository
245+
)
246+
247+
try await keychainRepository.save(
248+
sessionKey: TestConstants.sessionKeyValue,
249+
account: "default"
250+
)
251+
252+
var settings = AppSettings.default
253+
settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString)
254+
try await settingsRepository.save(settings)
255+
215256
do {
216257
_ = try await service.fetchUsage(forceRefresh: true)
217258
XCTFail("Expected invalidResponse error")

0 commit comments

Comments
 (0)