diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..8553f0ab7 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -669,6 +669,7 @@ extension UsageMenuCardView.Model { let sourceLabel: String? let kiloAutoMode: Bool let hidePersonalInfo: Bool + let claudePeakHoursEnabled: Bool let weeklyPace: UsagePace? let now: Date @@ -692,6 +693,7 @@ extension UsageMenuCardView.Model { sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, + claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, now: Date) { @@ -714,6 +716,7 @@ extension UsageMenuCardView.Model { self.sourceLabel = sourceLabel self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo + self.claudePeakHoursEnabled = claudePeakHoursEnabled self.weeklyPace = weeklyPace self.now = now } @@ -785,6 +788,11 @@ extension UsageMenuCardView.Model { return notes } + if input.provider == .claude, input.claudePeakHoursEnabled { + let peakStatus = ClaudePeakHours.status(at: input.now) + return [peakStatus.label] + } + guard input.provider == .openrouter, let openRouter = input.snapshot?.openRouterUsage else { diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index f3d5bc112..8d138c00f 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -372,6 +372,7 @@ struct ProvidersPane: View { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index de1bdffc7..9a882ee4b 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -26,6 +26,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { _ = settings.claudeOAuthKeychainPromptMode _ = settings.claudeOAuthKeychainReadStrategy _ = settings.claudeWebExtrasEnabled + _ = settings.claudePeakHoursEnabled } @MainActor @@ -77,6 +78,10 @@ struct ClaudeProviderImplementation: ProviderImplementation { context.settings.claudeOAuthPromptFreeCredentialsEnabled = enabled }) + let peakHoursBinding = Binding( + get: { context.settings.claudePeakHoursEnabled }, + set: { context.settings.claudePeakHoursEnabled = $0 }) + return [ ProviderSettingsToggleDescriptor( id: "claude-oauth-prompt-free-credentials", @@ -89,6 +94,17 @@ struct ClaudeProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "claude-peak-hours", + title: "Show peak hours indicator", + subtitle: "Show whether Claude is in peak usage hours.", + binding: peakHoursBinding, + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ] } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 44d83a023..5c6191d48 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -245,6 +245,14 @@ extension SettingsStore { } } + var claudePeakHoursEnabled: Bool { + get { self.defaultsState.claudePeakHoursEnabled } + set { + self.defaultsState.claudePeakHoursEnabled = newValue + self.userDefaults.set(newValue, forKey: "claudePeakHoursEnabled") + } + } + var showOptionalCreditsAndExtraUsage: Bool { get { self.defaultsState.showOptionalCreditsAndExtraUsage } set { diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index 23872508c..5106a426d 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -215,6 +215,7 @@ extension SettingsStore { let claudeOAuthKeychainPromptModeRaw = userDefaults.string(forKey: "claudeOAuthKeychainPromptMode") let claudeOAuthKeychainReadStrategyRaw = userDefaults.string(forKey: "claudeOAuthKeychainReadStrategy") let claudeWebExtrasEnabledRaw = userDefaults.object(forKey: "claudeWebExtrasEnabled") as? Bool ?? false + let claudePeakHoursEnabled = userDefaults.object(forKey: "claudePeakHoursEnabled") as? Bool ?? true let creditsExtrasDefault = userDefaults.object(forKey: "showOptionalCreditsAndExtraUsage") as? Bool let showOptionalCreditsAndExtraUsage = creditsExtrasDefault ?? true if creditsExtrasDefault == nil { userDefaults.set(true, forKey: "showOptionalCreditsAndExtraUsage") } @@ -256,6 +257,7 @@ extension SettingsStore { claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw, claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw, claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw, + claudePeakHoursEnabled: claudePeakHoursEnabled, showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage, openAIWebAccessEnabled: openAIWebAccessEnabled, jetbrainsIDEBasePath: jetbrainsIDEBasePath, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..6501ddf27 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -25,6 +25,7 @@ struct SettingsDefaultsState { var claudeOAuthKeychainPromptModeRaw: String? var claudeOAuthKeychainReadStrategyRaw: String? var claudeWebExtrasEnabledRaw: Bool + var claudePeakHoursEnabled: Bool var showOptionalCreditsAndExtraUsage: Bool var openAIWebAccessEnabled: Bool var jetbrainsIDEBasePath: String diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 484be310a..67ca3e25d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1482,6 +1482,7 @@ extension StatusItemController { sourceLabel: sourceLabel, kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, + claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled, weeklyPace: weeklyPace, now: now) return UsageMenuCardView.Model.make(input) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift new file mode 100644 index 000000000..8885fef62 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift @@ -0,0 +1,87 @@ +import Foundation + +public enum ClaudePeakHours: Sendable { + private static let peakTimeZone = TimeZone(identifier: "America/New_York")! + private static let peakStartHour = 8 + private static let peakEndHour = 14 + + public struct Status: Sendable, Equatable { + public let isPeak: Bool + public let label: String + } + + public static func status(at date: Date) -> Status { + let calendar = self.calendar() + let components = calendar.dateComponents([.hour, .minute, .weekday], from: date) + + guard let hour = components.hour, + let minute = components.minute, + let weekday = components.weekday + else { + return Status(isPeak: false, label: "Off-peak") + } + + let isWeekday = weekday >= 2 && weekday <= 6 + let nowMinutes = hour * 60 + minute + let peakStartMinutes = self.peakStartHour * 60 + let peakEndMinutes = self.peakEndHour * 60 + let isInPeakWindow = nowMinutes >= peakStartMinutes && nowMinutes < peakEndMinutes + + if isWeekday && isInPeakWindow { + let remaining = peakEndMinutes - nowMinutes + return Status( + isPeak: true, + label: "Peak · ends in \(self.formatDuration(minutes: remaining))") + } + + if isWeekday { + if nowMinutes < peakStartMinutes { + let until = peakStartMinutes - nowMinutes + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: until))") + } else { + let minutesLeftToday = 24 * 60 - nowMinutes + let nextPeakMinutes: Int + if weekday == 6 { + nextPeakMinutes = minutesLeftToday + 2 * 24 * 60 + peakStartMinutes + } else { + nextPeakMinutes = minutesLeftToday + peakStartMinutes + } + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: nextPeakMinutes))") + } + } + + let daysUntilMonday: Int + if weekday == 7 { + daysUntilMonday = 2 + } else { + daysUntilMonday = 1 + } + let minutesLeftToday = 24 * 60 - nowMinutes + let totalMinutes = minutesLeftToday + (daysUntilMonday - 1) * 24 * 60 + peakStartMinutes + return Status( + isPeak: false, + label: "Off-peak · peak in \(self.formatDuration(minutes: totalMinutes))") + } + + private static func formatDuration(minutes: Int) -> String { + let h = minutes / 60 + let m = minutes % 60 + if h == 0 { + return "\(m)m" + } + if m == 0 { + return "\(h)h" + } + return "\(h)h \(m)m" + } + + private static func calendar() -> Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = self.peakTimeZone + return cal + } +} diff --git a/Tests/CodexBarTests/ClaudePeakHoursTests.swift b/Tests/CodexBarTests/ClaudePeakHoursTests.swift new file mode 100644 index 000000000..1a0fe42ba --- /dev/null +++ b/Tests/CodexBarTests/ClaudePeakHoursTests.swift @@ -0,0 +1,141 @@ +import CodexBarCore +import Foundation +import Testing + +struct ClaudePeakHoursTests { + private static let eastern = TimeZone(identifier: "America/New_York")! + + private func date( + year: Int = 2026, + month: Int = 3, + day: Int, + hour: Int, + minute: Int = 0 + ) -> Date { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = Self.eastern + return cal.date(from: DateComponents( + year: year, month: month, day: day, + hour: hour, minute: minute))! + } + + // MARK: - Weekday peak hours + + @Test + func weekdayMorningBeforePeak() { + // Wednesday 2026-03-25 at 7:00 AM ET → off-peak, 1h until peak + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 1h") + } + + @Test + func weekdayJustBeforePeak() { + // Wednesday 2026-03-25 at 7:45 AM ET → off-peak, 15m until peak + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 15m") + } + + @Test + func weekdayPeakStart() { + // Wednesday 2026-03-25 at 8:00 AM ET → peak, 6h remaining + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 6h") + } + + @Test + func weekdayMidPeak() { + // Wednesday 2026-03-25 at 11:30 AM ET → peak, 2h 30m remaining + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 11, minute: 30)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h 30m") + } + + @Test + func weekdayPeakEndBoundary() { + // Wednesday 2026-03-25 at 1:59 PM ET → peak, 1m remaining + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 13, minute: 59)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1m") + } + + @Test + func weekdayAfterPeak() { + // Wednesday 2026-03-25 at 2:00 PM ET → off-peak, next peak tomorrow 8 AM (18h) + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 14)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 18h") + } + + @Test + func weekdayLateEvening() { + // Thursday 2026-03-26 at 11 PM ET → off-peak, 9h until next peak + let status = ClaudePeakHours.status(at: self.date(day: 26, hour: 23)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 9h") + } + + // MARK: - Weekend + + @Test + func saturdayMorning() { + // Saturday 2026-03-28 at 10 AM ET → off-peak, ~46h until Monday 8 AM + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 46h") + } + + @Test + func sundayEvening() { + // Sunday 2026-03-22 at 9 PM ET → off-peak, 11h until Monday 8 AM + let status = ClaudePeakHours.status(at: self.date(day: 22, hour: 21)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 11h") + } + + // MARK: - Friday → Monday transition + + @Test + func fridayAfterPeak() { + // Friday 2026-03-27 at 3 PM ET → off-peak, next peak Monday 8 AM (65h) + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 15)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 65h") + } + + @Test + func fridayPeak() { + // Friday 2026-03-27 at 12 PM ET → peak, 2h remaining + let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 12)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 2h") + } + + // MARK: - Edge cases + + @Test + func mondayMidnight() { + // Monday 2026-03-23 at 12:00 AM ET → off-peak, 8h until peak + let status = ClaudePeakHours.status(at: self.date(day: 23, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 8h") + } + + @Test + func peakWithMinuteGranularity() { + // Wednesday 2026-03-25 at 12:15 PM ET → peak, 1h 45m remaining + let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 12, minute: 15)) + #expect(status.isPeak) + #expect(status.label == "Peak · ends in 1h 45m") + } + + @Test + func saturdayMidnight() { + // Saturday 2026-03-28 at 12:00 AM ET → off-peak, 56h until Monday 8 AM + let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 0)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 56h") + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 60eddeb6a..3a529754e 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -362,6 +362,87 @@ struct MenuCardModelTests { #expect(model.creditsText == nil) } + @Test + func `claude model shows peak hours note when enabled`() throws { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "America/New_York")! + let now = cal.date(from: DateComponents(year: 2026, month: 3, day: 25, hour: 10))! + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: true, + now: now)) + + #expect(model.usageNotes.count == 1) + #expect(model.usageNotes.first?.contains("Peak") == true) + } + + @Test + func `claude model hides peak hours note when disabled`() throws { + let now = Date() + let identity = ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "claude@example.com", + accountOrganization: nil, + loginMethod: nil) + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 30, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: nil, + updatedAt: now, + identity: identity) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + claudePeakHoursEnabled: false, + now: now)) + + #expect(model.usageNotes.isEmpty) + } + @Test func `hides claude extra usage when disabled`() throws { let now = Date()