From a3c253a5f97abe98bb5e772d882d7212118ac40d Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 11:10:42 -0500 Subject: [PATCH 1/7] fix: parse absolute timestamps in Claude CLI reset times The CLI probe's parseResetDate() only handled relative durations like "2h 15m" but returned nil for absolute timestamps like "4:59pm (America/New_York)" or "Jan 15, 3:30pm (America/Los_Angeles)". This caused resetsAt to be nil for most Claude CLI users, which meant the pace tick indicator never appeared on the usage bar. Add parseAbsoluteDate() to handle time-only, date+time, date+year, and date-only formats with optional IANA timezone identifiers. Dates are resolved to the next future occurrence. --- .../Claude/ClaudeUsageProbe.swift | 138 ++++++++++++++++++ .../Claude/ClaudeUsageProbeParsingTests.swift | 54 +++++++ .../Claude/ClaudeUsageProbeTests.swift | 86 +++++++++++ 3 files changed, 278 insertions(+) diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index 9179879a..289c869e 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -602,6 +602,17 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { internal func parseResetDate(_ text: String?) -> Date? { guard let text else { return nil } + // Try relative duration first: "2h 15m", "30m", "2d" + if let relativeDate = parseRelativeDuration(text) { + return relativeDate + } + + // Try absolute date/time: "4:59pm (America/New_York)", "Jan 15, 3:30pm (TZ)", etc. + return parseAbsoluteDate(text) + } + + /// Parses relative duration strings like "2h 15m", "30m", "2d" + private func parseRelativeDuration(_ text: String) -> Date? { var totalSeconds: TimeInterval = 0 // Extract days: "2d" or "2 d" or "2 days" @@ -635,6 +646,133 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { return nil } + /// Parses absolute date/time strings from Claude CLI output. + /// + /// Handles these formats (all optionally followed by a timezone in parentheses): + /// - Time-only: "4:59pm", "3pm", "9pm" + /// - Month + day: "Dec 28" + /// - Month + day + time: "Jan 15, 3:30pm" or "Dec 25 at 4:59am" + /// - Month + day + year + time: "Jan 1, 2026 (America/New_York)" + private func parseAbsoluteDate(_ text: String) -> Date? { + // Extract timezone identifier from parentheses, e.g., "(America/New_York)" + let timeZone = extractTimeZone(from: text) + + // Strip "Resets" prefix, timezone suffix, and whitespace for cleaner parsing + var cleaned = text + .replacingOccurrences(of: #"^\s*[Rr]esets\s+"#, with: "", options: .regularExpression) + .replacingOccurrences(of: #"\s*\([^)]+\)\s*$"#, with: "", options: .regularExpression) + .trimmingCharacters(in: .whitespaces) + + // Normalize "at" separator: "Dec 25 at 4:59am" -> "Dec 25, 4:59am" + cleaned = cleaned.replacingOccurrences(of: #"\s+at\s+"#, with: ", ", options: .regularExpression) + + // Try date formats from most specific to least specific + let formats: [String] = [ + "MMM d, yyyy, h:mma", // "Jan 1, 2026, 3:30pm" (with year and minutes) + "MMM d, yyyy, ha", // "Jan 1, 2026, 3pm" (with year, no minutes) + "MMM d, yyyy", // "Jan 1, 2026" (date with year only) + "MMM d, h:mma", // "Jan 15, 3:30pm" (date with minutes) + "MMM d, ha", // "Jan 15, 4pm" (date without minutes) + "h:mma", // "4:59pm" (time-only with minutes) + "ha", // "3pm" (time-only, no minutes) + "MMM d", // "Dec 28" (date only) + ] + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = timeZone ?? .current + + for format in formats { + formatter.dateFormat = format + if let date = formatter.date(from: cleaned) { + return resolveToFutureDate(date, format: format, timeZone: formatter.timeZone) + } + } + + return nil + } + + /// Extracts a timezone identifier from parenthesized text, e.g., "(America/New_York)" + private func extractTimeZone(from text: String) -> TimeZone? { + guard let match = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { + return nil + } + let content = String(text[match]) + .dropFirst() // remove "(" + .dropLast() // remove ")" + let identifier = String(content).trimmingCharacters(in: .whitespaces) + return TimeZone(identifier: identifier) + } + + /// Resolves a parsed date to the next future occurrence. + /// + /// DateFormatter gives us a date with components that may be in the past + /// (e.g., "3pm" today but it's already 5pm, or "Dec 25" but it's Dec 26). + /// This method adjusts to the next occurrence. + private func resolveToFutureDate(_ parsedDate: Date, format: String, timeZone: TimeZone) -> Date { + var calendar = Calendar.current + calendar.timeZone = timeZone + let now = Date() + + let hasYear = format.contains("yyyy") + let hasMonth = format.contains("MMM") + let hasTime = format.contains("h") || format.contains("H") + + if hasYear { + // Explicit year provided — use as-is (e.g., "Jan 1, 2026") + return parsedDate + } + + if hasMonth && hasTime { + // Has month, day, and time (e.g., "Jan 15, 3:30pm") + // Set the year to current or next year + var components = calendar.dateComponents([.month, .day, .hour, .minute, .second], from: parsedDate) + components.year = calendar.component(.year, from: now) + if let candidate = calendar.date(from: components), candidate > now { + return candidate + } + // Already past this year — try next year + components.year = calendar.component(.year, from: now) + 1 + return calendar.date(from: components) ?? parsedDate + } + + if hasMonth { + // Date only, no time (e.g., "Dec 28") — assume start of day + var components = calendar.dateComponents([.month, .day], from: parsedDate) + components.hour = 0 + components.minute = 0 + components.second = 0 + components.year = calendar.component(.year, from: now) + if let candidate = calendar.date(from: components), candidate > now { + return candidate + } + components.year = calendar.component(.year, from: now) + 1 + return calendar.date(from: components) ?? parsedDate + } + + if hasTime { + // Time-only (e.g., "3pm", "4:59pm") — resolve to today or tomorrow + let parsedComponents = calendar.dateComponents([.hour, .minute, .second], from: parsedDate) + var todayComponents = calendar.dateComponents([.year, .month, .day], from: now) + todayComponents.hour = parsedComponents.hour + todayComponents.minute = parsedComponents.minute + todayComponents.second = parsedComponents.second + if let candidate = calendar.date(from: todayComponents), candidate > now { + return candidate + } + // Already past today — use tomorrow + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) { + todayComponents = calendar.dateComponents([.year, .month, .day], from: tomorrow) + todayComponents.hour = parsedComponents.hour + todayComponents.minute = parsedComponents.minute + todayComponents.second = parsedComponents.second + return calendar.date(from: todayComponents) ?? parsedDate + } + } + + return parsedDate + } + // MARK: - Error Detection internal func extractUsageError(_ text: String) -> ProbeError? { diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift index cb0ce108..8717fc19 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift @@ -251,6 +251,60 @@ struct ClaudeUsageProbeParsingTests { } } + // MARK: - Absolute Reset Time Parsing (resetsAt populated) + + @Test + func `populates resetsAt for time only reset format`() throws { + // Given — Pro header with "Resets 4:59pm (America/New_York)" + let output = Self.proHeaderOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — resetsAt must be a Date, not nil (enables pace tick) + let sessionQuota = snapshot.sessionQuota + #expect(sessionQuota?.resetsAt != nil, "resetsAt should be populated for 'Resets 4:59pm (TZ)' format") + #expect(sessionQuota?.percentTimeElapsed != nil, "percentTimeElapsed should be computable") + } + + @Test + func `populates resetsAt for date at time reset format`() throws { + // Given — real CLI output with "Resets Dec 25 at 4:59am (Asia/Shanghai)" + let output = Self.realCliOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — both session and weekly should have resetsAt populated + #expect(snapshot.sessionQuota?.resetsAt != nil, "Session resetsAt should be populated for 'Resets 2:59pm (TZ)' format") + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Dec 25 at 4:59am (TZ)' format") + } + + @Test + func `populates resetsAt for date comma time format`() throws { + // Given — sample output with "Resets Jan 15, 3:30pm (America/Los_Angeles)" + let output = Self.sampleClaudeOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — weekly and opus should have resetsAt populated (session uses relative "2h 15m" which already works) + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Jan 15, 3:30pm (TZ)' format") + } + + @Test + func `populates resetsAt for Claude API quotas with absolute times`() throws { + // Given — Claude API output with "Resets 9pm (Asia/Shanghai)" and "Resets Feb 12 at 4pm (Asia/Shanghai)" + let output = Self.claudeApiWithQuotasOutput + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — all quotas should have resetsAt populated + #expect(snapshot.sessionQuota?.resetsAt != nil, "Session resetsAt should be populated for 'Resets 9pm (TZ)' format") + #expect(snapshot.weeklyQuota?.resetsAt != nil, "Weekly resetsAt should be populated for 'Resets Feb 12 at 4pm (TZ)' format") + } + // MARK: - ANSI Code Handling static let ansiColoredOutput = """ diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift index a8e25ccb..ca44fe5e 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift @@ -62,6 +62,92 @@ struct ClaudeUsageProbeTests { #expect(probe.parseResetDate("no time here") == nil) } + // MARK: - Absolute Time Parsing Tests + + @Test + func `parses reset date with time only and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets 4:59pm (America/New_York)" — should resolve to a Date today or tomorrow + let result = probe.parseResetDate("Resets 4:59pm (America/New_York)") + #expect(result != nil, "Should parse time-only format with timezone") + if let date = result { + // Should be within the next 24 hours + let diff = date.timeIntervalSinceNow + #expect(diff > -60) // Allow small margin for test execution + #expect(diff < 24 * 3600 + 60) + } + } + + @Test + func `parses reset date with short time and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets 3pm (Asia/Shanghai)" — short time without minutes + let result = probe.parseResetDate("Resets 3pm (Asia/Shanghai)") + #expect(result != nil, "Should parse short time format like 3pm") + } + + @Test + func `parses reset date with month day and time with timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Dec 25 at 4:59am (Asia/Shanghai)" + let result = probe.parseResetDate("Resets Dec 25 at 4:59am (Asia/Shanghai)") + #expect(result != nil, "Should parse 'Mon DD at H:MMam (TZ)' format") + } + + @Test + func `parses reset date with month day comma time and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Jan 15, 3:30pm (America/Los_Angeles)" + let result = probe.parseResetDate("Resets Jan 15, 3:30pm (America/Los_Angeles)") + #expect(result != nil, "Should parse 'Mon DD, H:MMpm (TZ)' format") + } + + @Test + func `parses reset date with month day comma time without timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Jan 15, 3:30pm" + let result = probe.parseResetDate("Resets Jan 15, 3:30pm") + #expect(result != nil, "Should parse 'Mon DD, H:MMpm' without timezone") + } + + @Test + func `parses reset date with month day year and timezone`() { + let probe = ClaudeUsageProbe() + + // "Resets Jan 1, 2026 (America/New_York)" + let result = probe.parseResetDate("Resets Jan 1, 2026 (America/New_York)") + #expect(result != nil, "Should parse 'Mon DD, YYYY (TZ)' format") + } + + @Test + func `parses reset date with month day only`() { + let probe = ClaudeUsageProbe() + + // "Resets Dec 28" + let result = probe.parseResetDate("Resets Dec 28") + #expect(result != nil, "Should parse 'Mon DD' date-only format") + } + + @Test + func `parsed absolute date has correct timezone`() { + let probe = ClaudeUsageProbe() + + // Two calls with different timezones for the same time should yield different Dates + let eastern = probe.parseResetDate("Resets 4:59pm (America/New_York)") + let shanghai = probe.parseResetDate("Resets 4:59pm (Asia/Shanghai)") + #expect(eastern != nil) + #expect(shanghai != nil) + if let e = eastern, let s = shanghai { + // These should NOT be equal — different timezones for the same wall-clock time + #expect(e != s, "Same wall-clock time in different timezones should produce different Dates") + } + } + // MARK: - Helper Tests @Test From d08ed7e4b4f44d1e1cbc4b994b98b458fb3fe511 Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 12:02:52 -0500 Subject: [PATCH 2/7] fix(test): add time buffer to reset timestamp tests to prevent flakiness The tests compared exact minute strings but the sub-second delay between Date construction and assertion evaluation could cause the minute count to round down (e.g., "29m" instead of "30m"). Adding a 30-second buffer ensures the tests are deterministic. --- Tests/DomainTests/Provider/UsageQuotaTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/DomainTests/Provider/UsageQuotaTests.swift b/Tests/DomainTests/Provider/UsageQuotaTests.swift index 269e10eb..5406cb15 100644 --- a/Tests/DomainTests/Provider/UsageQuotaTests.swift +++ b/Tests/DomainTests/Provider/UsageQuotaTests.swift @@ -46,8 +46,8 @@ struct UsageQuotaTests { @Test func `quota reset timestamp shows days hours and minutes`() { - // Given - 2 days, 5 hours, 30 minutes from now - let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60) + // Given - 2 days, 5 hours, 30 minutes from now (+ 30s buffer to avoid rounding down) + let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60 + 30) // When let quota = UsageQuota( @@ -63,8 +63,8 @@ struct UsageQuotaTests { @Test func `quota reset timestamp shows only hours and minutes when less than a day`() { - // Given - 3 hours, 15 minutes from now - let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60) + // Given - 3 hours, 15 minutes from now (+ 30s buffer to avoid rounding down) + let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60 + 30) // When let quota = UsageQuota( From e2d98746121ef3dc6c6d155dd3807eee331d65db Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 12:23:27 -0500 Subject: [PATCH 3/7] fix: deduplicate reset text from terminal redraw artifacts The Claude CLI redraws the screen using cursor positioning, and wide Unicode progress bar characters can cause column misalignment. This results in reset text being appended to itself on a single rendered line, e.g. "Resets 4:59pm (TZ)Resets 4:59pm (TZ)". This caused both a display bug (duplicate "Resets" in the UI) and prevented parseResetDate from parsing the absolute timestamp. Fix by detecting multiple "Resets" occurrences in extractReset and keeping only the last one. --- .../Claude/ClaudeUsageProbe.swift | 29 +++++++++- .../Claude/ClaudeUsageProbeParsingTests.swift | 54 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index 289c869e..3c7035a4 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -532,13 +532,40 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { // Look for "resets" or time indicators like "2h" or "30m" if lower.contains("reset") || (lower.contains("in") && (lower.contains("h") || lower.contains("m"))) { - return candidate.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + return deduplicateResetText(trimmed) } } } return nil } + /// Removes duplicate "Resets..." text caused by terminal redraw artifacts. + /// + /// The Claude CLI redraws the screen using cursor positioning. Wide Unicode characters + /// (progress bar blocks) can cause column misalignment, resulting in the reset text + /// being appended to itself on a single line, e.g.: + /// `"Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York)"` + /// + /// This method detects such duplication and returns only the last occurrence. + internal func deduplicateResetText(_ text: String) -> String { + // Find all positions where "Resets" (case-insensitive) starts + let lower = text.lowercased() + var positions: [String.Index] = [] + var searchStart = lower.startIndex + while let range = lower.range(of: "resets", range: searchStart.. 1, let lastPos = positions.last { + return String(text[lastPos...]).trimmingCharacters(in: .whitespaces) + } + + return text + } + internal func extractEmail(text: String) -> String? { // Try old format first: "Account: email" or "Email: email" let oldPattern = #"(?i)(?:Account|Email):\s*([^\s@]+@[^\s@]+)"# diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift index 8717fc19..36d087c3 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift @@ -819,6 +819,60 @@ struct ClaudeUsageProbeParsingTests { #expect(snapshot.weeklyQuota?.percentRemaining == 77) // 23% used = 77% remaining } + // MARK: - Terminal Rendering Deduplication + + @Test + func `handles duplicated reset text from terminal redraw artifact`() throws { + // Given - terminal rendering artifact where cursor misalignment causes + // reset text to appear twice on a single line + let output = """ + Opus 4.5 · Claude Pro · Organization + + Current session + █████░░░░░░░░░░░░░░░ 6% used + Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York) + + Current week (all models) + █████████████████░░░ 36% used + Resets Dec 25 at 2:59pm (America/New_York)Resets Dec 25 at 2:59pm (America/New_York) + """ + + // When + let snapshot = try ClaudeUsageProbe.parse(output) + + // Then — quotas should parse successfully with clean reset text + let session = snapshot.sessionQuota + #expect(session != nil) + #expect(session?.resetsAt != nil, "resetsAt should be populated despite duplicated text") + #expect(session?.resetText?.contains("Resets 4:59pm") == true) + // Should NOT contain the duplication + #expect(session?.resetText?.components(separatedBy: "Resets").count == 2, + "resetText should contain 'Resets' exactly once (prefix + content)") + + let weekly = snapshot.weeklyQuota + #expect(weekly != nil) + #expect(weekly?.resetsAt != nil, "weekly resetsAt should be populated despite duplicated text") + } + + @Test + func `extractReset returns clean text when line has duplicate from terminal redraw`() { + // Given + let probe = ClaudeUsageProbe() + let text = """ + Current session + ████ 6% used + Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York) + """ + + // When + let result = probe.extractReset(labelSubstring: "Current session", text: text) + + // Then — should return deduplicated text + #expect(result != nil) + let resetsCount = result!.components(separatedBy: "Resets").count - 1 + #expect(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(result!)") + } + // MARK: - Helper private func simulateParse(text: String) throws -> UsageSnapshot { From 8185e24ce60e4dc517d300b5502aeae89bb62198 Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 12:36:19 -0500 Subject: [PATCH 4/7] fix: handle mid-line "Resets" token in parseAbsoluteDate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regex (^\s*[Rr]esets\s+) was start-anchored, so lines where "Resets" appears after other content (e.g., "$5.41 / $20.00 spent · Resets Jan 1, 2026 (America/New_York)") were never cleaned, causing the DateFormatter loop to fail and resetsAt to be nil. Now finds the last occurrence of "Resets" (case-insensitive) via backwards string search and strips everything before and including it, then continues with the existing timezone extraction and date parsing. --- .../Infrastructure/Claude/ClaudeUsageProbe.swift | 11 +++++++++-- .../Claude/ClaudeUsageProbeParsingTests.swift | 13 +++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index 3c7035a4..a3f386eb 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -684,9 +684,16 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { // Extract timezone identifier from parentheses, e.g., "(America/New_York)" let timeZone = extractTimeZone(from: text) - // Strip "Resets" prefix, timezone suffix, and whitespace for cleaner parsing + // Strip everything up to and including the last "Resets" token (case-insensitive), + // then remove any trailing timezone in parentheses. + // Using the *last* occurrence handles both start-of-line "Resets Jan 1, 2026" + // and mid-line "$5.41 ... · Resets Jan 1, 2026 (America/New_York)". var cleaned = text - .replacingOccurrences(of: #"^\s*[Rr]esets\s+"#, with: "", options: .regularExpression) + let lower = cleaned.lowercased() + if let lastResets = lower.range(of: "resets", options: .backwards) { + cleaned = String(cleaned[lastResets.upperBound...]) + } + cleaned = cleaned .replacingOccurrences(of: #"\s*\([^)]+\)\s*$"#, with: "", options: .regularExpression) .trimmingCharacters(in: .whitespaces) diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift index 36d087c3..eeb532c4 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift @@ -586,6 +586,19 @@ struct ClaudeUsageProbeParsingTests { #expect(snapshot.quotas.count >= 1) } + @Test + func `parses resetsAt from Extra usage cost line with mid-line Resets`() throws { + // Given — "$5.41 / $20.00 spent · Resets Jan 1, 2026 (America/New_York)" + // "Resets" appears mid-line, not at the start + + // When + let snapshot = try ClaudeUsageProbe.parse(Self.proWithExtraUsageOutput) + + // Then — resetsAt should be populated even though "Resets" is mid-line + #expect(snapshot.costUsage?.resetsAt != nil, + "resetsAt should be populated when 'Resets' appears mid-line in cost line") + } + // MARK: - API Usage Billing Account Detection // Real output from API Usage Billing account showing subscription-only message From 99c2fca1c96a66fb1714357b9382ec18c35ac279 Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 12:51:30 -0500 Subject: [PATCH 5/7] fix: parse Gemini reset timestamps from ISO 8601 into human-readable format The Gemini API probe was passing raw ISO 8601 strings (e.g., "2026-02-12T17:41:24Z") directly into resetText without parsing them into Date objects. This caused the UI to display raw timestamps instead of relative countdowns like "Resets in 2h 15m". Added parseResetTime and formatResetText helpers (matching the pattern used by Claude and Antigravity probes) and now sets both resetsAt and resetText on UsageQuota. --- .../Gemini/GeminiAPIProbe.swift | 36 ++++++++++- .../Gemini/GeminiAPIProbeTests.swift | 64 +++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift b/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift index a4c4839f..c15599ba 100644 --- a/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift +++ b/Sources/Infrastructure/Gemini/GeminiAPIProbe.swift @@ -174,11 +174,13 @@ internal struct GeminiAPIProbe { let quotas: [UsageQuota] = modelQuotaMap .sorted { $0.key < $1.key } .map { modelId, data in - UsageQuota( + let resetsAt = data.resetTime.flatMap { parseResetTime($0) } + return UsageQuota( percentRemaining: data.fraction * 100, quotaType: .modelSpecific(modelId), providerId: "gemini", - resetText: data.resetTime.map { "Resets \($0)" } + resetsAt: resetsAt, + resetText: formatResetText(resetsAt) ) } @@ -231,6 +233,36 @@ internal struct GeminiAPIProbe { ) } + // MARK: - Reset Time Parsing + + private func parseResetTime(_ value: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + + // Try without fractional seconds + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + private func formatResetText(_ date: Date?) -> String? { + guard let date else { return nil } + + let seconds = date.timeIntervalSinceNow + guard seconds > 0 else { return nil } + + let hours = Int(seconds / 3600) + let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60) + + if hours > 0 { + return "Resets in \(hours)h \(minutes)m" + } else if minutes > 0 { + return "Resets in \(minutes)m" + } else { + return "Resets soon" + } + } + private struct QuotaBucket: Decodable { let remainingFraction: Double? let resetTime: String? diff --git a/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift b/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift index febf5e95..c1932759 100644 --- a/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift +++ b/Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift @@ -120,6 +120,70 @@ struct GeminiAPIProbeTests { .called(1) } + @Test + func `probe parses reset time into Date and human readable text`() async throws { + let homeDir = try makeTemporaryHomeDirectory() + try createCredentialsFile(in: homeDir) + let mockService = MockNetworkClient() + + let projectsResponse = """ + { + "projects": [ + { "projectId": "gen-lang-client-123456" } + ] + } + """.data(using: .utf8)! + + // Use a reset time 2 hours in the future + let futureDate = Date().addingTimeInterval(2 * 3600 + 15 * 60) // 2h 15m from now + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let resetTimeString = formatter.string(from: futureDate) + + let quotaResponse = """ + { + "buckets": [ + { + "modelId": "gemini-pro", + "remainingFraction": 0.8, + "resetTime": "\(resetTimeString)" + } + ] + } + """.data(using: .utf8)! + + given(mockService) + .request(.any) + .willProduce { request in + let url = request.url?.absoluteString ?? "" + if url.contains("projects") { + return (projectsResponse, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } else { + return (quotaResponse, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } + } + + let probe = GeminiAPIProbe( + homeDirectory: homeDir.path, + timeout: 1.0, + networkClient: mockService, + maxRetries: 1 + ) + + let snapshot = try await probe.probe() + let quota = try #require(snapshot.quotas.first) + + // resetsAt should be a parsed Date, not nil + let resetsAt = try #require(quota.resetsAt) + let timeDiff = abs(resetsAt.timeIntervalSince(futureDate)) + #expect(timeDiff < 2) // Within 2 seconds tolerance + + // resetText should be human-readable, not raw ISO 8601 + let resetText = try #require(quota.resetText) + #expect(resetText.contains("Resets in")) + #expect(!resetText.contains("T")) // Should NOT contain ISO 8601 'T' separator + } + @Test func `probe handles api error gracefully`() async throws { let homeDir = try makeTemporaryHomeDirectory() From e51fcbdde38c8365a4406b522010790104a6c35a Mon Sep 17 00:00:00 2001 From: Chandler Anderson Date: Wed, 11 Feb 2026 13:28:49 -0500 Subject: [PATCH 6/7] fix: use case-insensitive search on cleaned string directly to avoid Unicode-unsafe index from lowercased copy - Replace `lower` variable with `cleaned.range(of:options:[.caseInsensitive,.backwards])` in `parseAbsoluteDate` - Add `.backwards` option to `extractTimeZone` regex for defensive last-match - Compute future year dynamically in test fixture so it never goes stale - Replace force-unwrap with `try #require` in deduplication test --- Sources/Infrastructure/Claude/ClaudeUsageProbe.swift | 5 ++--- .../Claude/ClaudeUsageProbeParsingTests.swift | 8 ++++---- .../Claude/ClaudeUsageProbeTests.swift | 5 +++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index a3f386eb..4adc847b 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -689,8 +689,7 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { // Using the *last* occurrence handles both start-of-line "Resets Jan 1, 2026" // and mid-line "$5.41 ... · Resets Jan 1, 2026 (America/New_York)". var cleaned = text - let lower = cleaned.lowercased() - if let lastResets = lower.range(of: "resets", options: .backwards) { + if let lastResets = cleaned.range(of: "resets", options: [.caseInsensitive, .backwards]) { cleaned = String(cleaned[lastResets.upperBound...]) } cleaned = cleaned @@ -728,7 +727,7 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { /// Extracts a timezone identifier from parenthesized text, e.g., "(America/New_York)" private func extractTimeZone(from text: String) -> TimeZone? { - guard let match = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { + guard let match = text.range(of: #"\(([^)]+)\)"#, options: [.regularExpression, .backwards]) else { return nil } let content = String(text[match]) diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift index eeb532c4..4ac1574b 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift @@ -868,7 +868,7 @@ struct ClaudeUsageProbeParsingTests { } @Test - func `extractReset returns clean text when line has duplicate from terminal redraw`() { + func `extractReset returns clean text when line has duplicate from terminal redraw`() throws { // Given let probe = ClaudeUsageProbe() let text = """ @@ -881,9 +881,9 @@ struct ClaudeUsageProbeParsingTests { let result = probe.extractReset(labelSubstring: "Current session", text: text) // Then — should return deduplicated text - #expect(result != nil) - let resetsCount = result!.components(separatedBy: "Resets").count - 1 - #expect(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(result!)") + let unwrapped = try #require(result) + let resetsCount = unwrapped.components(separatedBy: "Resets").count - 1 + #expect(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(unwrapped)") } // MARK: - Helper diff --git a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift index ca44fe5e..e27c5701 100644 --- a/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift +++ b/Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift @@ -119,8 +119,9 @@ struct ClaudeUsageProbeTests { func `parses reset date with month day year and timezone`() { let probe = ClaudeUsageProbe() - // "Resets Jan 1, 2026 (America/New_York)" - let result = probe.parseResetDate("Resets Jan 1, 2026 (America/New_York)") + // Always use a future year so the test never goes stale + let futureYear = Calendar.current.component(.year, from: Date()) + 1 + let result = probe.parseResetDate("Resets Jan 1, \(futureYear) (America/New_York)") #expect(result != nil, "Should parse 'Mon DD, YYYY (TZ)' format") } From 911740002460ccea7146e4497e5e7d5416d66663 Mon Sep 17 00:00:00 2001 From: Chandler Anderson <18584424+zenibako@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:44:08 -0500 Subject: [PATCH 7/7] Update Sources/Infrastructure/Claude/ClaudeUsageProbe.swift Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../Claude/ClaudeUsageProbe.swift | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift index 4adc847b..5ad4bc3d 100644 --- a/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift +++ b/Sources/Infrastructure/Claude/ClaudeUsageProbe.swift @@ -549,18 +549,17 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable { /// /// This method detects such duplication and returns only the last occurrence. internal func deduplicateResetText(_ text: String) -> String { - // Find all positions where "Resets" (case-insensitive) starts - let lower = text.lowercased() - var positions: [String.Index] = [] - var searchStart = lower.startIndex - while let range = lower.range(of: "resets", range: searchStart..] = [] + var searchStart = text.startIndex + while let range = text.range(of: "resets", options: .caseInsensitive, range: searchStart.. 1, let lastPos = positions.last { - return String(text[lastPos...]).trimmingCharacters(in: .whitespaces) + if positions.count > 1, let lastRange = positions.last { + return String(text[lastRange.lowerBound...]).trimmingCharacters(in: .whitespaces) } return text