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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 14 additions & 14 deletions ClaudeMeter/Models/API/UsageAPIResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(
Expand Down
16 changes: 13 additions & 3 deletions ClaudeMeter/Models/UsageLimit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }

Expand Down
2 changes: 1 addition & 1 deletion ClaudeMeter/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
26 changes: 18 additions & 8 deletions ClaudeMeter/Services/Protocols/NotificationServiceProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
Expand All @@ -57,7 +67,7 @@ protocol NotificationServiceProtocol {
func sendThresholdNotification(
percentage: Double,
threshold: UsageThresholdType,
resetTime: Date
resetTime: Date?
) async throws

/// Send session reset notification
Expand Down
12 changes: 10 additions & 2 deletions ClaudeMeter/Views/MenuBar/UsageCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)"
}
}

Expand Down
2 changes: 1 addition & 1 deletion ClaudeMeterTests/TestDoubles/NotificationServiceSpy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ final class NotificationServiceSpy: NotificationServiceProtocol {
func sendThresholdNotification(
percentage: Double,
threshold: UsageThresholdType,
resetTime: Date
resetTime: Date?
) async throws {
sentThresholdPercentage = percentage
sentThresholdType = threshold
Expand Down