Skip to content

Commit 9ea4bce

Browse files
authored
Merge pull request #104 from zenibako/fix-absolute-reset-date-parsing
2 parents 58cd669 + 9117400 commit 9ea4bce

File tree

6 files changed

+481
-7
lines changed

6 files changed

+481
-7
lines changed

Sources/Infrastructure/Claude/ClaudeUsageProbe.swift

Lines changed: 171 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -532,13 +532,39 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable {
532532
// Look for "resets" or time indicators like "2h" or "30m"
533533
if lower.contains("reset") ||
534534
(lower.contains("in") && (lower.contains("h") || lower.contains("m"))) {
535-
return candidate.trimmingCharacters(in: .whitespacesAndNewlines)
535+
let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
536+
return deduplicateResetText(trimmed)
536537
}
537538
}
538539
}
539540
return nil
540541
}
541542

543+
/// Removes duplicate "Resets..." text caused by terminal redraw artifacts.
544+
///
545+
/// The Claude CLI redraws the screen using cursor positioning. Wide Unicode characters
546+
/// (progress bar blocks) can cause column misalignment, resulting in the reset text
547+
/// being appended to itself on a single line, e.g.:
548+
/// `"Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York)"`
549+
///
550+
/// This method detects such duplication and returns only the last occurrence.
551+
internal func deduplicateResetText(_ text: String) -> String {
552+
// Find all positions where "Resets" (case-insensitive) starts in the original text
553+
var positions: [Range<String.Index>] = []
554+
var searchStart = text.startIndex
555+
while let range = text.range(of: "resets", options: .caseInsensitive, range: searchStart..<text.endIndex) {
556+
positions.append(range)
557+
searchStart = text.index(after: range.lowerBound)
558+
}
559+
560+
// If there's more than one "Resets", take the last occurrence
561+
if positions.count > 1, let lastRange = positions.last {
562+
return String(text[lastRange.lowerBound...]).trimmingCharacters(in: .whitespaces)
563+
}
564+
565+
return text
566+
}
567+
542568
internal func extractEmail(text: String) -> String? {
543569
// Try old format first: "Account: email" or "Email: email"
544570
let oldPattern = #"(?i)(?:Account|Email):\s*([^\s@]+@[^\s@]+)"#
@@ -602,6 +628,17 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable {
602628
internal func parseResetDate(_ text: String?) -> Date? {
603629
guard let text else { return nil }
604630

631+
// Try relative duration first: "2h 15m", "30m", "2d"
632+
if let relativeDate = parseRelativeDuration(text) {
633+
return relativeDate
634+
}
635+
636+
// Try absolute date/time: "4:59pm (America/New_York)", "Jan 15, 3:30pm (TZ)", etc.
637+
return parseAbsoluteDate(text)
638+
}
639+
640+
/// Parses relative duration strings like "2h 15m", "30m", "2d"
641+
private func parseRelativeDuration(_ text: String) -> Date? {
605642
var totalSeconds: TimeInterval = 0
606643

607644
// Extract days: "2d" or "2 d" or "2 days"
@@ -635,6 +672,139 @@ public final class ClaudeUsageProbe: UsageProbe, @unchecked Sendable {
635672
return nil
636673
}
637674

675+
/// Parses absolute date/time strings from Claude CLI output.
676+
///
677+
/// Handles these formats (all optionally followed by a timezone in parentheses):
678+
/// - Time-only: "4:59pm", "3pm", "9pm"
679+
/// - Month + day: "Dec 28"
680+
/// - Month + day + time: "Jan 15, 3:30pm" or "Dec 25 at 4:59am"
681+
/// - Month + day + year + time: "Jan 1, 2026 (America/New_York)"
682+
private func parseAbsoluteDate(_ text: String) -> Date? {
683+
// Extract timezone identifier from parentheses, e.g., "(America/New_York)"
684+
let timeZone = extractTimeZone(from: text)
685+
686+
// Strip everything up to and including the last "Resets" token (case-insensitive),
687+
// then remove any trailing timezone in parentheses.
688+
// Using the *last* occurrence handles both start-of-line "Resets Jan 1, 2026"
689+
// and mid-line "$5.41 ... · Resets Jan 1, 2026 (America/New_York)".
690+
var cleaned = text
691+
if let lastResets = cleaned.range(of: "resets", options: [.caseInsensitive, .backwards]) {
692+
cleaned = String(cleaned[lastResets.upperBound...])
693+
}
694+
cleaned = cleaned
695+
.replacingOccurrences(of: #"\s*\([^)]+\)\s*$"#, with: "", options: .regularExpression)
696+
.trimmingCharacters(in: .whitespaces)
697+
698+
// Normalize "at" separator: "Dec 25 at 4:59am" -> "Dec 25, 4:59am"
699+
cleaned = cleaned.replacingOccurrences(of: #"\s+at\s+"#, with: ", ", options: .regularExpression)
700+
701+
// Try date formats from most specific to least specific
702+
let formats: [String] = [
703+
"MMM d, yyyy, h:mma", // "Jan 1, 2026, 3:30pm" (with year and minutes)
704+
"MMM d, yyyy, ha", // "Jan 1, 2026, 3pm" (with year, no minutes)
705+
"MMM d, yyyy", // "Jan 1, 2026" (date with year only)
706+
"MMM d, h:mma", // "Jan 15, 3:30pm" (date with minutes)
707+
"MMM d, ha", // "Jan 15, 4pm" (date without minutes)
708+
"h:mma", // "4:59pm" (time-only with minutes)
709+
"ha", // "3pm" (time-only, no minutes)
710+
"MMM d", // "Dec 28" (date only)
711+
]
712+
713+
let formatter = DateFormatter()
714+
formatter.locale = Locale(identifier: "en_US_POSIX")
715+
formatter.timeZone = timeZone ?? .current
716+
717+
for format in formats {
718+
formatter.dateFormat = format
719+
if let date = formatter.date(from: cleaned) {
720+
return resolveToFutureDate(date, format: format, timeZone: formatter.timeZone)
721+
}
722+
}
723+
724+
return nil
725+
}
726+
727+
/// Extracts a timezone identifier from parenthesized text, e.g., "(America/New_York)"
728+
private func extractTimeZone(from text: String) -> TimeZone? {
729+
guard let match = text.range(of: #"\(([^)]+)\)"#, options: [.regularExpression, .backwards]) else {
730+
return nil
731+
}
732+
let content = String(text[match])
733+
.dropFirst() // remove "("
734+
.dropLast() // remove ")"
735+
let identifier = String(content).trimmingCharacters(in: .whitespaces)
736+
return TimeZone(identifier: identifier)
737+
}
738+
739+
/// Resolves a parsed date to the next future occurrence.
740+
///
741+
/// DateFormatter gives us a date with components that may be in the past
742+
/// (e.g., "3pm" today but it's already 5pm, or "Dec 25" but it's Dec 26).
743+
/// This method adjusts to the next occurrence.
744+
private func resolveToFutureDate(_ parsedDate: Date, format: String, timeZone: TimeZone) -> Date {
745+
var calendar = Calendar.current
746+
calendar.timeZone = timeZone
747+
let now = Date()
748+
749+
let hasYear = format.contains("yyyy")
750+
let hasMonth = format.contains("MMM")
751+
let hasTime = format.contains("h") || format.contains("H")
752+
753+
if hasYear {
754+
// Explicit year provided — use as-is (e.g., "Jan 1, 2026")
755+
return parsedDate
756+
}
757+
758+
if hasMonth && hasTime {
759+
// Has month, day, and time (e.g., "Jan 15, 3:30pm")
760+
// Set the year to current or next year
761+
var components = calendar.dateComponents([.month, .day, .hour, .minute, .second], from: parsedDate)
762+
components.year = calendar.component(.year, from: now)
763+
if let candidate = calendar.date(from: components), candidate > now {
764+
return candidate
765+
}
766+
// Already past this year — try next year
767+
components.year = calendar.component(.year, from: now) + 1
768+
return calendar.date(from: components) ?? parsedDate
769+
}
770+
771+
if hasMonth {
772+
// Date only, no time (e.g., "Dec 28") — assume start of day
773+
var components = calendar.dateComponents([.month, .day], from: parsedDate)
774+
components.hour = 0
775+
components.minute = 0
776+
components.second = 0
777+
components.year = calendar.component(.year, from: now)
778+
if let candidate = calendar.date(from: components), candidate > now {
779+
return candidate
780+
}
781+
components.year = calendar.component(.year, from: now) + 1
782+
return calendar.date(from: components) ?? parsedDate
783+
}
784+
785+
if hasTime {
786+
// Time-only (e.g., "3pm", "4:59pm") — resolve to today or tomorrow
787+
let parsedComponents = calendar.dateComponents([.hour, .minute, .second], from: parsedDate)
788+
var todayComponents = calendar.dateComponents([.year, .month, .day], from: now)
789+
todayComponents.hour = parsedComponents.hour
790+
todayComponents.minute = parsedComponents.minute
791+
todayComponents.second = parsedComponents.second
792+
if let candidate = calendar.date(from: todayComponents), candidate > now {
793+
return candidate
794+
}
795+
// Already past today — use tomorrow
796+
if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now) {
797+
todayComponents = calendar.dateComponents([.year, .month, .day], from: tomorrow)
798+
todayComponents.hour = parsedComponents.hour
799+
todayComponents.minute = parsedComponents.minute
800+
todayComponents.second = parsedComponents.second
801+
return calendar.date(from: todayComponents) ?? parsedDate
802+
}
803+
}
804+
805+
return parsedDate
806+
}
807+
638808
// MARK: - Error Detection
639809

640810
internal func extractUsageError(_ text: String) -> ProbeError? {

Sources/Infrastructure/Gemini/GeminiAPIProbe.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,13 @@ internal struct GeminiAPIProbe {
174174
let quotas: [UsageQuota] = modelQuotaMap
175175
.sorted { $0.key < $1.key }
176176
.map { modelId, data in
177-
UsageQuota(
177+
let resetsAt = data.resetTime.flatMap { parseResetTime($0) }
178+
return UsageQuota(
178179
percentRemaining: data.fraction * 100,
179180
quotaType: .modelSpecific(modelId),
180181
providerId: "gemini",
181-
resetText: data.resetTime.map { "Resets \($0)" }
182+
resetsAt: resetsAt,
183+
resetText: formatResetText(resetsAt)
182184
)
183185
}
184186

@@ -231,6 +233,36 @@ internal struct GeminiAPIProbe {
231233
)
232234
}
233235

236+
// MARK: - Reset Time Parsing
237+
238+
private func parseResetTime(_ value: String) -> Date? {
239+
let formatter = ISO8601DateFormatter()
240+
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
241+
if let date = formatter.date(from: value) { return date }
242+
243+
// Try without fractional seconds
244+
formatter.formatOptions = [.withInternetDateTime]
245+
return formatter.date(from: value)
246+
}
247+
248+
private func formatResetText(_ date: Date?) -> String? {
249+
guard let date else { return nil }
250+
251+
let seconds = date.timeIntervalSinceNow
252+
guard seconds > 0 else { return nil }
253+
254+
let hours = Int(seconds / 3600)
255+
let minutes = Int((seconds.truncatingRemainder(dividingBy: 3600)) / 60)
256+
257+
if hours > 0 {
258+
return "Resets in \(hours)h \(minutes)m"
259+
} else if minutes > 0 {
260+
return "Resets in \(minutes)m"
261+
} else {
262+
return "Resets soon"
263+
}
264+
}
265+
234266
private struct QuotaBucket: Decodable {
235267
let remainingFraction: Double?
236268
let resetTime: String?

Tests/DomainTests/Provider/UsageQuotaTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ struct UsageQuotaTests {
4646

4747
@Test
4848
func `quota reset timestamp shows days hours and minutes`() {
49-
// Given - 2 days, 5 hours, 30 minutes from now
50-
let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60)
49+
// Given - 2 days, 5 hours, 30 minutes from now (+ 30s buffer to avoid rounding down)
50+
let resetDate = Date().addingTimeInterval(2.0 * 86400 + 5.0 * 3600 + 30.0 * 60 + 30)
5151

5252
// When
5353
let quota = UsageQuota(
@@ -63,8 +63,8 @@ struct UsageQuotaTests {
6363

6464
@Test
6565
func `quota reset timestamp shows only hours and minutes when less than a day`() {
66-
// Given - 3 hours, 15 minutes from now
67-
let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60)
66+
// Given - 3 hours, 15 minutes from now (+ 30s buffer to avoid rounding down)
67+
let resetDate = Date().addingTimeInterval(3.0 * 3600 + 15.0 * 60 + 30)
6868

6969
// When
7070
let quota = UsageQuota(

0 commit comments

Comments
 (0)