Skip to content

Conversation

@zenibako
Copy link
Contributor

@zenibako zenibako commented Feb 11, 2026

Summary

  • Fix the CLI probe's parseResetDate() to handle absolute timestamps (e.g., "Resets 4:59pm (America/New_York)", "Resets Jan 15, 3:30pm (America/Los_Angeles)"), which previously returned nil — causing the pace tick indicator to never appear for most Claude CLI users
  • Fix duplicated reset text from terminal redraw artifacts — wide Unicode progress bar characters can cause column misalignment during CLI screen redraws, resulting in "Resets 4:59pm (TZ)Resets 4:59pm (TZ)" on a single line
  • Fix Gemini reset timestamps displayed as raw ISO 8601 strings — the Gemini API probe was passing raw strings like "2026-02-12T17:41:24Z" directly into resetText without parsing them into Date objects, so the UI showed raw timestamps instead of relative countdowns
  • Fix a flaky Domain test where a sub-second timing race caused "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 matching d/h/m unit suffixes. The Claude CLI frequently returns absolute timestamps, for which the parser returned nil. Without a resetsAt date, percentTimeElapsed is nil, expectedProgressPercent() returns nil, 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 8601 resetTime string from the API directly into resetText (e.g., "Resets 2026-02-12T17:41:24Z") and never setting resetsAt: Date?. Since resetsAt was always nil, the domain model's computed countdown properties (resetTimestampDescription, resetDescription) returned nil, and the UI fell through to showing the raw string.

Changes

ClaudeUsageProbe.swift

  • Refactored parseResetDate into parseRelativeDuration (existing behavior) + parseAbsoluteDate (new fallback)
  • parseAbsoluteDate handles all observed CLI formats:
    • Time-only: "4:59pm", "3pm"
    • Date + time: "Jan 15, 3:30pm", "Dec 25 at 4:59am"
    • Date + year: "Jan 1, 2026"
    • Date only: "Dec 28"
    • All optionally with IANA timezone: "(America/New_York)", "(Asia/Shanghai)"
  • Parsed dates are resolved to the next future occurrence and respect the timezone from CLI output
  • Added deduplicateResetText() in extractReset to strip terminal redraw duplication by keeping only the last "Resets..." occurrence

GeminiAPIProbe.swift

  • Added parseResetTime() to parse ISO 8601 strings (with and without fractional seconds) into Date objects
  • Added formatResetText() to format parsed dates into human-readable countdowns (e.g., "Resets in 2h 15m")
  • Updated mapToSnapshot() to set both resetsAt (parsed Date) and resetText (formatted string) on UsageQuota, matching the pattern used by Claude and Antigravity probes

Tests

  • 8 new unit tests for parseResetDate with absolute time formats
  • 4 new integration tests verifying resetsAt is populated when parsing existing fixtures
  • 2 new tests for terminal redraw deduplication (unit + integration with full fixture)
  • 1 new test verifying Gemini resetsAt is parsed and resetText is human-readable
  • Fixed flaky "quota reset timestamp shows days hours and minutes" test by adding a 30-second buffer to interval construction

Summary by CodeRabbit

  • Bug Fixes

    • Improved parsing of quota reset times to handle many absolute formats, relative durations, and time zones.
    • Deduplicated and normalized reset text extraction to handle terminal redraw artifacts.
  • New Features

    • Quotas now include parsed reset timestamps alongside human-readable reset hints.
  • Tests

    • Added comprehensive tests for parsing formats, time zones, relative durations, deduplication, and small timestamp rounding adjustments.

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.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

📝 Walkthrough

Walkthrough

Enhances 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

Cohort / File(s) Summary
Claude parsing
Sources/Infrastructure/Claude/ClaudeUsageProbe.swift
Adds deduplicateResetText and helpers parseRelativeDuration, parseAbsoluteDate, extractTimeZone, resolveToFutureDate; parseResetDate now attempts relative then absolute parsing and returns deduplicated reset text.
Gemini parsing
Sources/Infrastructure/Gemini/GeminiAPIProbe.swift
Adds parseResetTime and formatResetText; parses ISO8601 resetTime into resetsAt Date and produces human-readable resetText when mapping quotas.
Claude parsing tests
Tests/InfrastructureTests/Claude/ClaudeUsageProbeParsingTests.swift, Tests/InfrastructureTests/Claude/ClaudeUsageProbeTests.swift
Adds many unit tests for time-only, date+time, month/day formats, timezone handling, mid-line "Resets", and deduplication of terminal redraw artifacts.
Gemini tests
Tests/InfrastructureTests/Gemini/GeminiAPIProbeTests.swift
Adds test verifying ISO8601 resetTime is parsed into resetsAt Date and formatted into human-readable resetText.
Quota tests
Tests/DomainTests/Provider/UsageQuotaTests.swift
Adjusts two tests to include a 30-second buffer in expected reset timestamps (minor test expectation change).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰
I nibbled through timestamps, hopped past the clocks,
From "2h 15m" burrows to timezone-boxed rocks.
I folded duplicate echoes, smoothed each reset line,
Quotas wake on time now — my whiskers say it's fine. 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title directly and clearly summarizes the main change: adding support for parsing absolute reset timestamps in both Claude CLI and Gemini API probes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@zenibako zenibako marked this pull request as draft February 11, 2026 17:10
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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, parseResetDate will return a date in the past here (the hasYear branch 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
Copy link

codecov bot commented Feb 11, 2026

Codecov Report

❌ Patch coverage is 90.15152% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.30%. Comparing base (79dac61) to head (9117400).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
...urces/Infrastructure/Claude/ClaudeUsageProbe.swift 90.82% 10 Missing ⚠️
Sources/Infrastructure/Gemini/GeminiAPIProbe.swift 86.95% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            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     
Files with missing lines Coverage Δ
Sources/Infrastructure/Gemini/GeminiAPIProbe.swift 90.50% <86.95%> (-0.74%) ⬇️
...urces/Infrastructure/Claude/ClaudeUsageProbe.swift 81.59% <90.82%> (+1.99%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…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.
@zenibako zenibako changed the title fix: parse absolute timestamps in Claude CLI reset times fix: parse reset timestamps for Claude CLI absolute times and Gemini API Feb 11, 2026
@zenibako zenibako changed the title fix: parse reset timestamps for Claude CLI absolute times and Gemini API fix: parse reset timestamps for Claude CLI & Gemini API absolute times Feb 11, 2026
@zenibako zenibako marked this pull request as ready for review February 11, 2026 18:02
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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: extractTimeZone always 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 .backwards option on the range(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 extractReset behavior. The components(separatedBy:).count technique is a clean way to assert single-occurrence.

Minor: Line 885 force-unwraps result! after a non-nil assertion on line 884. Using let 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.

formatResetText is implemented identically in CodexAPIUsageProbe.swift (lines 329–345) and GeminiAPIProbe.swift (lines 248–264), with a functionally equivalent variant in ClaudeAPIUsageProbe.swift (lines 341–357). Similarly, parseResetTime exists in both GeminiAPIProbe.swift (lines 238–246) and AntigravityUsageProbe.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 ResetTimeFormatter struct or an extension on Date, 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
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 by m. 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+m substring. 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>
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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.

Comment on lines +744 to +806
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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 : now

This 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.

Suggested change
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.

@hanrw hanrw merged commit 9ea4bce into tddworks:main Feb 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants