@@ -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 ? {
0 commit comments