From c79182f2d32f0952115bf9b24f0c72a2831b0841 Mon Sep 17 00:00:00 2001 From: Rasyid Ridho Date: Fri, 3 Apr 2026 12:38:41 +0700 Subject: [PATCH 1/2] fix: handle null response after claude reset --- ClaudeMeter/Models/API/UsageAPIResponse.swift | 28 +++++++++---------- ClaudeMeter/Models/UsageLimit.swift | 16 +++++++++-- .../Services/NotificationService.swift | 2 +- .../NotificationServiceProtocol.swift | 26 +++++++++++------ ClaudeMeter/Views/MenuBar/UsageCardView.swift | 12 ++++++-- .../TestDoubles/NotificationServiceSpy.swift | 2 +- 6 files changed, 57 insertions(+), 29 deletions(-) diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift index b1bea04..dc9cea1 100644 --- a/ClaudeMeter/Models/API/UsageAPIResponse.swift +++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift @@ -53,16 +53,13 @@ extension UsageAPIResponse { 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 + // Parse session reset date (nil when session hasn't started - e.g., after reset) + let sessionResetDate: Date? = fiveHour.resetsAt.flatMap { iso8601Formatter.date(from: $0) } + // When session hasn't started (resets_at is null), utilization should be 0% + let sessionUtilization = sessionResetDate == nil ? 0.0 : fiveHour.utilization + // Parse weekly reset date (required) + let weeklyResetDate: Date guard let weeklyResetString = sevenDay.resetsAt, let parsedDate = iso8601Formatter.date(from: weeklyResetString) else { throw MappingError.missingCriticalField(field: "sevenDay.resetsAt") @@ -71,25 +68,28 @@ extension UsageAPIResponse { // Handle optional sonnet usage let sonnetLimit: UsageLimit? = sevenDaySonnet.flatMap { sonnet in - let sonnetResetDate: Date + 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) + // Sonnet reset date is optional (nil when not started) + sonnetResetDate = nil } + // When sonnet hasn't started, utilization should be 0% + let sonnetUtilization = sonnetResetDate == nil ? 0.0 : sonnet.utilization + return UsageLimit( - utilization: sonnet.utilization, + utilization: sonnetUtilization, resetAt: sonnetResetDate ) } return UsageData( sessionUsage: UsageLimit( - utilization: fiveHour.utilization, + utilization: sessionUtilization, resetAt: sessionResetDate ), weeklyUsage: UsageLimit( diff --git a/ClaudeMeter/Models/UsageLimit.swift b/ClaudeMeter/Models/UsageLimit.swift index fefd7d6..d764737 100644 --- a/ClaudeMeter/Models/UsageLimit.swift +++ b/ClaudeMeter/Models/UsageLimit.swift @@ -12,8 +12,8 @@ struct UsageLimit: Codable, Equatable, Sendable { /// Utilization percentage (0-100) let utilization: Double - /// ISO8601 timestamp when limit resets - let resetAt: Date + /// ISO8601 timestamp when limit resets (nil for session when not started) + let resetAt: Date? enum CodingKeys: String, CodingKey { case utilization @@ -41,14 +41,22 @@ extension UsageLimit { } /// Human-readable reset time (uses system timezone via RelativeDateTimeFormatter) + /// Returns "Starts when a message is sent" if resetAt is nil (session not started) var resetDescription: String { + guard let resetAt else { + return "Starts when a message is sent" + } let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .full return formatter.localizedString(for: resetAt, relativeTo: Date()) } /// Exact reset time formatted in user's timezone for tooltip display + /// Returns empty string if resetAt is nil var resetTimeFormatted: String { + guard let resetAt else { + return "" + } let formatter = DateFormatter() formatter.dateStyle = .medium formatter.timeStyle = .short @@ -63,12 +71,14 @@ extension UsageLimit { /// Check if reset time has passed but usage hasn't reset var isResetting: Bool { - resetAt < Date() && utilization > 0 + guard let resetAt else { return false } + return resetAt < Date() && utilization > 0 } /// Returns true if current usage rate will likely exceed limit before reset /// - Parameter windowDuration: Duration of the usage window (e.g., 5 hours for session) func isAtRisk(windowDuration: TimeInterval) -> Bool { + guard let resetAt else { return false } let now = Date() guard resetAt > now else { return false } diff --git a/ClaudeMeter/Services/NotificationService.swift b/ClaudeMeter/Services/NotificationService.swift index ecd91fc..28c042b 100644 --- a/ClaudeMeter/Services/NotificationService.swift +++ b/ClaudeMeter/Services/NotificationService.swift @@ -98,7 +98,7 @@ final class NotificationService: NSObject, NotificationServiceProtocol, UNUserNo func sendThresholdNotification( percentage: Double, threshold: UsageThresholdType, - resetTime: Date + resetTime: Date? ) async throws { // Check if notifications are enabled guard await shouldSendNotifications() else { return } diff --git a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift index 55ad812..c2351e6 100644 --- a/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift +++ b/ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift @@ -22,16 +22,26 @@ enum UsageThresholdType: String { } } - func body(percentage: Double, resetTime: Date) -> String { - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - let resetString = formatter.localizedString(for: resetTime, relativeTo: Date()) - + func body(percentage: Double, resetTime: Date?) -> String { switch self { case .warning: - return "You've used \(Int(percentage))% of your 5-hour session. Resets \(resetString)" + if let resetTime { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let resetString = formatter.localizedString(for: resetTime, relativeTo: Date()) + return "You've used \(Int(percentage))% of your 5-hour session. Resets \(resetString)" + } else { + return "You've used \(Int(percentage))% of your 5-hour session." + } case .critical: - return "Critical: \(Int(percentage))% of session used. Resets \(resetString)" + if let resetTime { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let resetString = formatter.localizedString(for: resetTime, relativeTo: Date()) + return "Critical: \(Int(percentage))% of session used. Resets \(resetString)" + } else { + return "Critical: \(Int(percentage))% of session used." + } case .reset: return "Your usage limits have been reset. Fresh capacity available!" } @@ -57,7 +67,7 @@ protocol NotificationServiceProtocol { func sendThresholdNotification( percentage: Double, threshold: UsageThresholdType, - resetTime: Date + resetTime: Date? ) async throws /// Send session reset notification diff --git a/ClaudeMeter/Views/MenuBar/UsageCardView.swift b/ClaudeMeter/Views/MenuBar/UsageCardView.swift index cc425c5..e628422 100644 --- a/ClaudeMeter/Views/MenuBar/UsageCardView.swift +++ b/ClaudeMeter/Views/MenuBar/UsageCardView.swift @@ -68,7 +68,7 @@ struct UsageCardView: View { HStack(spacing: 4) { Image(systemName: "clock") .font(.caption) - Text("Resets \(usageLimit.resetDescription)") + Text(resetTimeText) .font(.caption) } .help(usageLimit.resetTimeFormatted) @@ -91,7 +91,15 @@ struct UsageCardView: View { .cornerRadius(12) .accessibilityElement(children: .combine) .accessibilityLabel("\(title): \(Int(usageLimit.percentage))% used, \(usageLimit.status.accessibilityDescription)") - .accessibilityValue("Resets \(usageLimit.resetDescription)") + .accessibilityValue(resetTimeText) + } + + /// Formatted reset time text with appropriate prefix + private var resetTimeText: String { + if usageLimit.resetAt == nil { + return usageLimit.resetDescription + } + return "Resets \(usageLimit.resetDescription)" } } diff --git a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift index 400b2a9..c4fde40 100644 --- a/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift +++ b/ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift @@ -30,7 +30,7 @@ final class NotificationServiceSpy: NotificationServiceProtocol { func sendThresholdNotification( percentage: Double, threshold: UsageThresholdType, - resetTime: Date + resetTime: Date? ) async throws { sentThresholdPercentage = percentage sentThresholdType = threshold From 609dcab30563c3b64ac7ca09246882a44788d5be Mon Sep 17 00:00:00 2001 From: Rasyid Ridho Date: Fri, 3 Apr 2026 12:46:20 +0700 Subject: [PATCH 2/2] docs(changelog): add v1.3.1 entry with reset fix --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ecc420..2af3723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.1] - 2026-04-03 + +### Fixed + +- Handle null `resets_at` field when Claude session hasn't started (after account reset) +- Show "Starts when a message is sent" message instead of reset time for inactive sessions + +### Changed + +- Bump GitHub Actions to latest versions + ## [1.3.0] - 2026-02-02 ### Added @@ -129,6 +140,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Smart notifications with configurable alerts at warning and critical thresholds (defaults: 75% and 90%) - Auto-refresh with automatic usage updates every 1-10 minutes (customizable) +[1.3.1]: https://github.com/eddmann/ClaudeMeter/compare/v1.3.0...v1.3.1 [1.3.0]: https://github.com/eddmann/ClaudeMeter/compare/v1.2.1...v1.3.0 [1.2.1]: https://github.com/eddmann/ClaudeMeter/compare/v1.2.0...v1.2.1 [1.2.0]: https://github.com/eddmann/ClaudeMeter/compare/v1.1.2...v1.2.0