-
-
Notifications
You must be signed in to change notification settings - Fork 48
fix: parse reset timestamps for Claude CLI & Gemini API absolute times #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: parse reset timestamps for Claude CLI & Gemini API absolute times #104
Conversation
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.
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.
📝 WalkthroughWalkthroughEnhances reset-time parsing across probes: adds deduplication of terminal redraw artifacts and new relative/absolute date parsing with timezone handling in Claude; adds ISO8601 reset-time parsing and human-readable formatting in Gemini; and extends tests to cover many formats and deduplication cases. (≤50 words) Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Sources/Infrastructure/Claude/ClaudeUsageProbe.swift`:
- Around line 656-693: The current prefix-stripping in parseAbsoluteDate uses a
start-anchored regex (^\s*[Rr]esets\s+) so lines where "Resets" appears mid-line
(e.g., "$5.41 ... · Resets Jan 1, 2026 (America/New_York)") aren't cleaned;
update parseAbsoluteDate to locate the last occurrence of "Resets"
(case-insensitive) and strip everything before and including that token (or
change the regex to remove any leading content up to the final "Resets"), then
continue with the existing timezone extraction, "at" normalization, and
DateFormatter loop (refer to parseAbsoluteDate, extractTimeZone, and
resolveToFutureDate to find and update the logic).
🧹 Nitpick comments (1)
Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift (1)
118-125: Test fixture "Jan 1, 2026" is already in the past — consider using a future year.Since we're past January 2026,
parseResetDatewill return a date in the past here (thehasYearbranch returns the date as-is). The test passes because it only asserts!= nil, but using a future date (e.g.,"Jan 1, 2027") would be more robust and also let you assert the returned date is actually in the future, consistent with the other tests.♻️ Suggested fix
- // "Resets Jan 1, 2026 (America/New_York)" - let result = probe.parseResetDate("Resets Jan 1, 2026 (America/New_York)") + // Use a future year so the resolved date is always in the future + let result = probe.parseResetDate("Resets Jan 1, 2027 (America/New_York)")
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.
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.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #104 +/- ##
==========================================
+ Coverage 78.96% 79.30% +0.34%
==========================================
Files 61 61
Lines 4715 4856 +141
==========================================
+ Hits 3723 3851 +128
- Misses 992 1005 +13
🚀 New features to boost your workflow:
|
…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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Sources/Infrastructure/Claude/ClaudeUsageProbe.swift`:
- Around line 691-698: The code uses an index from `lower` (the `.lowercased()`
copy) to slice `cleaned`, which is unsafe for Unicode; change the search to
operate directly on `cleaned` using a case-insensitive, backwards range (e.g.
call `cleaned.range(of: "resets", options: [.caseInsensitive, .backwards])`),
then use that resulting `Range<String.Index>` to set `cleaned =
String(cleaned[range.upperBound...])`; remove the `lower` variable and keep the
existing regex trimming steps afterward (symbols: cleaned, lower, lastResets).
🧹 Nitpick comments (3)
Sources/Infrastructure/Claude/ClaudeUsageProbe.swift (1)
729-739:extractTimeZonealways returns the first parenthesized group — consider matching the last one.If the text ever contains multiple parenthesized groups (unlikely today, but defensive), this would return the wrong match. Using
.backwardsoption on therange(of:)call would be more robust, consistent with how"resets"is searched.♻️ Optional: match last parenthesized group
- guard let match = text.range(of: #"\(([^)]+)\)"#, options: .regularExpression) else { + guard let match = text.range(of: #"\(([^)]+)\)"#, options: [.regularExpression, .backwards]) else {Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift (1)
835-887: Deduplication tests are thorough.The integration test (lines 837–868) verifies end-to-end parsing with duplicated terminal artifacts, and the unit test (lines 870–887) isolates
extractResetbehavior. Thecomponents(separatedBy:).counttechnique is a clean way to assert single-occurrence.Minor: Line 885 force-unwraps
result!after a non-nil assertion on line 884. Usinglet result = try#require(...)would be more consistent with the rest of the test suite.♻️ Optional: use `#require` instead of force-unwrap
// Then — should return deduplicated text - `#expect`(result != nil) - let resetsCount = result!.components(separatedBy: "Resets").count - 1 + let unwrapped = try `#require`(result) + let resetsCount = unwrapped.components(separatedBy: "Resets").count - 1 - `#expect`(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(result!)") + `#expect`(resetsCount == 1, "Should contain 'Resets' exactly once, got \(resetsCount) in: \(unwrapped)")Note: the test function signature would also need
throws:- 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 {Sources/Infrastructure/Gemini/GeminiAPIProbe.swift (1)
236-264: Extract duplicated reset-time formatting logic into a shared utility.
formatResetTextis implemented identically inCodexAPIUsageProbe.swift(lines 329–345) andGeminiAPIProbe.swift(lines 248–264), with a functionally equivalent variant inClaudeAPIUsageProbe.swift(lines 341–357). Similarly,parseResetTimeexists in bothGeminiAPIProbe.swift(lines 238–246) andAntigravityUsageProbe.swift(lines 387–395), though with different fallback strategies (fractional seconds vs. epoch seconds).Consider extracting the common formatting logic into a shared utility, such as a
ResetTimeFormatterstruct or an extension onDate, to reduce duplication across probes.
…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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Sources/Infrastructure/Claude/ClaudeUsageProbe.swift`:
- Around line 551-567: The deduplicateResetText function incorrectly builds
indices from a separate lowercased string; change it to search
case-insensitively on the original text so you use indices that belong to `text`
itself (mirror the fix in `parseAbsoluteDate`). Replace the `lower`-based loop
with a loop over ranges found by calling `text.range(of: "resets", options:
.caseInsensitive, range: searchRange, locale: Locale.current)` (or equivalent
case-insensitive API), collect those ranges' lowerBounds from `text`, and when
taking the last occurrence slice `text` with that index and trim whitespace as
before; keep the function name `deduplicateResetText` and behavior otherwise the
same.
🧹 Nitpick comments (1)
Sources/Infrastructure/Claude/ClaudeUsageProbe.swift (1)
641-674:parseRelativeDuration— minute regex could false-positive on month abbreviations.The pattern
(\d+)\s*m(?:in(?:utes?)?)?will match any digit(s) followed bym. In practice the inputs reaching this method (e.g."Resets 4:59pm (America/New_York)") don't appear to trigger a false match because there's no\d+msubstring. However, consider adding a word-boundary or end-of-token anchor (\b) to future-proof this if input formats evolve.This is a minor robustness note, not blocking.
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@Sources/Infrastructure/Claude/ClaudeUsageProbe.swift`:
- Around line 744-806: The hasTime branch in resolveToFutureDate can fall back
to returning parsedDate when calendar.date(byAdding: .day, value: 1, to: now)
returns nil (leading to the year-2000 reference date); update the fallback so it
does not return parsedDate — e.g., if tomorrow calculation fails, return now (or
calendar.date(byAdding: .second, value: 1, to: now)) instead to avoid the stale
year-2000 date; change the return in the hasTime branch that currently falls
through to parsedDate to return a safe "now"-based Date and keep the rest of
resolveToFutureDate logic intact.
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Future-date resolution logic is well-structured and covers all format branches correctly.
Each format maps to the right resolution strategy. One very minor edge case: if calendar.date(byAdding: .day, value: 1, to: now) were to return nil (Line 796), the fallback on Line 805 would return the raw parsedDate (a year-2000 reference date). This is practically impossible but could produce a silently wrong resetsAt rather than nil.
Possible defensive tweak
- return parsedDate
+ return parsedDate > now ? parsedDate : nowThis ensures a clearly-past date is never returned, though the scenario is essentially theoretical.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| } | |
| 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 > now ? parsedDate : now | |
| } |
🤖 Prompt for AI Agents
In `@Sources/Infrastructure/Claude/ClaudeUsageProbe.swift` around lines 744 - 806,
The hasTime branch in resolveToFutureDate can fall back to returning parsedDate
when calendar.date(byAdding: .day, value: 1, to: now) returns nil (leading to
the year-2000 reference date); update the fallback so it does not return
parsedDate — e.g., if tomorrow calculation fails, return now (or
calendar.date(byAdding: .second, value: 1, to: now)) instead to avoid the stale
year-2000 date; change the return in the hasTime branch that currently falls
through to parsedDate to return a safe "now"-based Date and keep the rest of
resolveToFutureDate logic intact.
Summary
parseResetDate()to handle absolute timestamps (e.g.,"Resets 4:59pm (America/New_York)","Resets Jan 15, 3:30pm (America/Los_Angeles)"), which previously returnednil— causing the pace tick indicator to never appear for most Claude CLI users"Resets 4:59pm (TZ)Resets 4:59pm (TZ)"on a single line"2026-02-12T17:41:24Z"directly intoresetTextwithout parsing them intoDateobjects, so the UI showed raw timestamps instead of relative countdowns"Resets in 2d 5h 29m"instead of the expected"Resets in 2d 5h 30m"Problem
Claude CLI absolute timestamps
parseResetDate()only handled relative durations like"2h 15m"or"30m"by matchingd/h/munit suffixes. The Claude CLI frequently returns absolute timestamps, for which the parser returnednil. Without aresetsAtdate,percentTimeElapsedisnil,expectedProgressPercent()returnsnil, and the pace tick is never rendered.Additionally, the Claude CLI redraws the screen using cursor positioning, and wide Unicode progress bar characters (
████) can cause column misalignment in SwiftTerm rendering. This results in reset text appearing twice on a single line (e.g.,"Resets 4:59pm (TZ)Resets 4:59pm (TZ)"), which both breaks the date parser and shows duplicate text in the UI.Gemini API timestamps
GeminiAPIProbe.mapToSnapshot()was passing the raw ISO 8601resetTimestring from the API directly intoresetText(e.g.,"Resets 2026-02-12T17:41:24Z") and never settingresetsAt: Date?. SinceresetsAtwas alwaysnil, the domain model's computed countdown properties (resetTimestampDescription,resetDescription) returnednil, and the UI fell through to showing the raw string.Changes
ClaudeUsageProbe.swiftparseResetDateintoparseRelativeDuration(existing behavior) +parseAbsoluteDate(new fallback)parseAbsoluteDatehandles all observed CLI formats:"4:59pm","3pm""Jan 15, 3:30pm","Dec 25 at 4:59am""Jan 1, 2026""Dec 28""(America/New_York)","(Asia/Shanghai)"deduplicateResetText()inextractResetto strip terminal redraw duplication by keeping only the last"Resets..."occurrenceGeminiAPIProbe.swiftparseResetTime()to parse ISO 8601 strings (with and without fractional seconds) intoDateobjectsformatResetText()to format parsed dates into human-readable countdowns (e.g.,"Resets in 2h 15m")mapToSnapshot()to set bothresetsAt(parsedDate) andresetText(formatted string) onUsageQuota, matching the pattern used by Claude and Antigravity probesTests
parseResetDatewith absolute time formatsresetsAtis populated when parsing existing fixturesresetsAtis parsed andresetTextis human-readable"quota reset timestamp shows days hours and minutes"test by adding a 30-second buffer to interval constructionSummary by CodeRabbit
Bug Fixes
New Features
Tests