diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b06b305..a706e96ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Menu & Menu Bar - Menu: opening OpenAI web submenus triggers a refresh when the data is stale. - Menu: fix usage line labels to honor “Show usage as used”. +- Menu: tighten absolute reset timestamps and move the toggle into Display settings. - Debug: add a toggle to keep Codex/Claude CLI sessions alive between probes. - Debug: add a button to reset CLI probe sessions. - App icon: use the classic icon on macOS 15 and earlier while keeping Liquid Glass for macOS 26+ (#178). Thanks @zerone0x! diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5e92b4b84..156c8d73f 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -779,6 +779,7 @@ extension UsageMenuCardView.Model { let paceDetail = Self.weeklyPaceDetail( provider: input.provider, window: weekly, + resetStyle: input.resetTimeDisplayStyle, now: input.now, showUsed: input.usageBarsShowUsed) metrics.append(Metric( @@ -842,10 +843,15 @@ extension UsageMenuCardView.Model { private static func weeklyPaceDetail( provider: UsageProvider, window: RateWindow, + resetStyle: ResetTimeDisplayStyle, now: Date, showUsed: Bool) -> PaceDetail? { - guard let detail = UsagePaceText.weeklyDetail(provider: provider, window: window, now: now) else { return nil } + guard let detail = UsagePaceText.weeklyDetail( + provider: provider, + window: window, + resetStyle: resetStyle, + now: now) else { return nil } let expectedUsed = detail.expectedUsedPercent let actualUsed = window.usedPercent let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 6330782b7..11cb65dfa 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -129,7 +129,10 @@ struct MenuDescriptor { window: weekly, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) - if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) { + if let paceSummary = UsagePaceText.weeklySummary( + provider: provider, + window: weekly, + resetStyle: resetStyle) { entries.append(.text(paceSummary, .secondary)) } } diff --git a/Sources/CodexBar/PreferencesAdvancedPane.swift b/Sources/CodexBar/PreferencesAdvancedPane.swift index 1db4897f2..08415a41c 100644 --- a/Sources/CodexBar/PreferencesAdvancedPane.swift +++ b/Sources/CodexBar/PreferencesAdvancedPane.swift @@ -53,8 +53,6 @@ struct AdvancedPane: View { .foregroundStyle(.tertiary) } - Divider() - SettingsSection(contentSpacing: 10) { PreferenceToggleRow( title: "Show Debug Settings", diff --git a/Sources/CodexBar/PreferencesDisplayPane.swift b/Sources/CodexBar/PreferencesDisplayPane.swift index 003fa27ee..ddad0f52e 100644 --- a/Sources/CodexBar/PreferencesDisplayPane.swift +++ b/Sources/CodexBar/PreferencesDisplayPane.swift @@ -66,8 +66,8 @@ struct DisplayPane: View { subtitle: "Progress bars fill as you consume quota (instead of showing remaining).", binding: self.$settings.usageBarsShowUsed) PreferenceToggleRow( - title: "Show reset time as clock", - subtitle: "Display reset times as absolute clock values instead of countdowns.", + title: "Show reset date/time", + subtitle: "Display reset times as absolute date + time values instead of countdowns.", binding: self.$settings.resetTimesShowAbsolute) PreferenceToggleRow( title: "Show credits + extra usage", diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1b3d02bc7..d1f977463 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -972,7 +972,7 @@ extension StatusItemController { } if let resetTime = timeLimit.nextResetTime { let reset = self.settings.resetTimeDisplayStyle == .absolute - ? UsageFormatter.resetDescription(from: resetTime) + ? UsageFormatter.resetAbsoluteDescription(from: resetTime) : UsageFormatter.resetCountdownDescription(from: resetTime) let item = NSMenuItem(title: "Resets: \(reset)", action: nil, keyEquivalent: "") item.isEnabled = false diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 920e38ef9..131456a2e 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -11,19 +11,30 @@ enum UsagePaceText { private static let minimumExpectedPercent: Double = 3 - static func weeklySummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { - guard let detail = weeklyDetail(provider: provider, window: window, now: now) else { return nil } + static func weeklySummary( + provider: UsageProvider, + window: RateWindow, + resetStyle: ResetTimeDisplayStyle = .countdown, + now: Date = .init()) -> String? + { + guard let detail = weeklyDetail(provider: provider, window: window, resetStyle: resetStyle, now: now) + else { return nil } if let rightLabel = detail.rightLabel { return "Pace: \(detail.leftLabel) · \(rightLabel)" } return "Pace: \(detail.leftLabel)" } - static func weeklyDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { + static func weeklyDetail( + provider: UsageProvider, + window: RateWindow, + resetStyle: ResetTimeDisplayStyle = .countdown, + now: Date = .init()) -> WeeklyDetail? + { guard let pace = weeklyPace(provider: provider, window: window, now: now) else { return nil } return WeeklyDetail( leftLabel: Self.detailLeftLabel(for: pace), - rightLabel: Self.detailRightLabel(for: pace, now: now), + rightLabel: Self.detailRightLabel(for: pace, now: now, resetStyle: resetStyle), expectedUsedPercent: pace.expectedUsedPercent, stage: pace.stage) } @@ -40,16 +51,22 @@ enum UsagePaceText { } } - private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { + private static func detailRightLabel(for pace: UsagePace, now: Date, resetStyle: ResetTimeDisplayStyle) -> String? { if pace.willLastToReset { return "Lasts until reset" } guard let etaSeconds = pace.etaSeconds else { return nil } - let etaText = Self.durationText(seconds: etaSeconds, now: now) - if etaText == "now" { return "Runs out now" } - return "Runs out in \(etaText)" + let etaText = Self.durationText(seconds: etaSeconds, now: now, resetStyle: resetStyle) + if resetStyle == .countdown { + if etaText == "now" { return "Runs out now" } + return "Runs out in \(etaText)" + } + return "Runs out at \(etaText)" } - private static func durationText(seconds: TimeInterval, now: Date) -> String { + private static func durationText(seconds: TimeInterval, now: Date, resetStyle: ResetTimeDisplayStyle) -> String { let date = now.addingTimeInterval(seconds) + if resetStyle == .absolute { + return UsageFormatter.resetAbsoluteDescription(from: date, now: now) + } let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) if countdown == "now" { return "now" } if countdown.hasPrefix("in ") { return String(countdown.dropFirst(3)) } diff --git a/Sources/CodexBarCore/UsageFormatter.swift b/Sources/CodexBarCore/UsageFormatter.swift index 226d43569..f401a3d5a 100644 --- a/Sources/CodexBarCore/UsageFormatter.swift +++ b/Sources/CodexBarCore/UsageFormatter.swift @@ -47,6 +47,19 @@ public enum UsageFormatter { return date.formatted(date: .abbreviated, time: .shortened) } + public static func resetAbsoluteDescription(from date: Date, now: Date = .init()) -> String { + let calendar = Calendar.current + if calendar.isDate(date, inSameDayAs: now) { + return date.formatted(date: .omitted, time: .shortened) + } + if let weekAhead = calendar.date(byAdding: .day, value: 7, to: now), date < weekAhead { + let weekday = date.formatted(.dateTime.weekday(.abbreviated)) + let time = date.formatted(date: .omitted, time: .shortened) + return "\(weekday) \(time)" + } + return date.formatted(.dateTime.month(.abbreviated).day().hour().minute()) + } + public static func resetLine( for window: RateWindow, style: ResetTimeDisplayStyle, @@ -55,8 +68,9 @@ public enum UsageFormatter { if let date = window.resetsAt { let text = style == .countdown ? self.resetCountdownDescription(from: date, now: now) - : self.resetDescription(from: date, now: now) - return "Resets \(text)" + : self.resetAbsoluteDescription(from: date, now: now) + let prefix = style == .countdown ? "Resets " : "Resets at " + return "\(prefix)\(text)" } if let desc = window.resetDescription { diff --git a/Tests/CodexBarTests/UsageFormatterTests.swift b/Tests/CodexBarTests/UsageFormatterTests.swift index 072b299df..6b0c85a36 100644 --- a/Tests/CodexBarTests/UsageFormatterTests.swift +++ b/Tests/CodexBarTests/UsageFormatterTests.swift @@ -80,6 +80,26 @@ struct UsageFormatterTests { #expect(text == "Resets in 11m") } + @Test + func resetLineUsesAbsoluteWhenResetsAtIsAvailable() { + let now = Date(timeIntervalSince1970: 1_000_000) + let reset = now.addingTimeInterval(60 * 60) + let window = RateWindow(usedPercent: 0, windowMinutes: nil, resetsAt: reset, resetDescription: nil) + let text = UsageFormatter.resetLine(for: window, style: .absolute, now: now) + #expect(text?.hasPrefix("Resets at ") == true) + } + + @Test + func resetAbsoluteDescriptionOmitsYear() { + let now = Date(timeIntervalSince1970: 1_785_000_000) // Jan 2026-ish + let sameDay = now.addingTimeInterval(3 * 3600) + let later = now.addingTimeInterval(15 * 24 * 3600) + let sameDayText = UsageFormatter.resetAbsoluteDescription(from: sameDay, now: now) + let laterText = UsageFormatter.resetAbsoluteDescription(from: later, now: now) + #expect(!sameDayText.contains("2026")) + #expect(!laterText.contains("2026")) + } + @Test func resetLineFallsBackToProvidedDescription() { let window = RateWindow( diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 86c49a8ff..9f75e0ec4 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -49,6 +49,20 @@ struct UsagePaceTextTests { #expect(summary == "Pace: 7% in deficit · Runs out in 3d") } + @Test + func weeklyPaceDetail_usesAbsoluteResetStyle() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, resetStyle: .absolute, now: now) + + #expect(detail?.rightLabel?.hasPrefix("Runs out at ") == true) + } + @Test func weeklyPaceDetail_hidesWhenResetIsMissing() { let now = Date(timeIntervalSince1970: 0)