From b314126efda0b993cc2cb87c242eeebcdc812c93 Mon Sep 17 00:00:00 2001 From: Kejsaren Date: Fri, 27 Mar 2026 16:21:14 +1000 Subject: [PATCH 1/5] feat: add claudePeakHoursEnabled setting and integrate into UsageMenuCardView and ClaudeProviderImplementation - Introduced `claudePeakHoursEnabled` property in SettingsStore and SettingsStoreState. - Updated UsageMenuCardView to utilize the new setting for displaying peak hours status. - Added toggle for peak hours visibility in ClaudeProviderImplementation. - Ensured persistence of the setting in user defaults. --- Sources/CodexBar/MenuCardView.swift | 8 + .../Claude/ClaudeProviderImplementation.swift | 16 ++ Sources/CodexBar/SettingsStore+Defaults.swift | 8 + Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + .../CodexBar/StatusItemController+Menu.swift | 1 + .../Providers/Claude/ClaudePeakHours.swift | 87 +++++++++++ .../CodexBarTests/ClaudePeakHoursTests.swift | 141 ++++++++++++++++++ 8 files changed, 264 insertions(+) create mode 100644 Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift create mode 100644 Tests/CodexBarTests/ClaudePeakHoursTests.swift diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 5f685af23..ebdfdbb22 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 = false, 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/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index de1bdffc7..a63fa4d4a 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 (8 AM–2 PM ET, weekdays).", + 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") + } +} From 09a4420c39a6b061c58d4be36e95ab44f3330d72 Mon Sep 17 00:00:00 2001 From: Kejsaren Date: Fri, 27 Mar 2026 16:34:18 +1000 Subject: [PATCH 2/5] Resolved PR feedback: - Updated `claudePeakHoursEnabled` to default to true in the UsageMenuCardView model. - Added tests to verify peak hours note visibility when the setting is enabled and disabled. --- Sources/CodexBar/MenuCardView.swift | 2 +- Tests/CodexBarTests/MenuCardModelTests.swift | 81 ++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index ebdfdbb22..8553f0ab7 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -693,7 +693,7 @@ extension UsageMenuCardView.Model { sourceLabel: String? = nil, kiloAutoMode: Bool = false, hidePersonalInfo: Bool, - claudePeakHoursEnabled: Bool = false, + claudePeakHoursEnabled: Bool = true, weeklyPace: UsagePace? = nil, now: Date) { 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() From 702c0a3b110fa186d2b33edb93d7f1db8b530b45 Mon Sep 17 00:00:00 2001 From: Kejsaren Date: Fri, 27 Mar 2026 16:42:21 +1000 Subject: [PATCH 3/5] Update ProvidersPane to include claudePeakHoursEnabled in UsageMenuCardView model --- Sources/CodexBar/PreferencesProvidersPane.swift | 1 + 1 file changed, 1 insertion(+) 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) From ccf1c4695ef3669e99ffb7e6912eeb716e307021 Mon Sep 17 00:00:00 2001 From: Kejsaren Date: Fri, 27 Mar 2026 17:32:13 +1000 Subject: [PATCH 4/5] Refine subtitle for peak hours indicator in ClaudeProviderImplementation --- .../Providers/Claude/ClaudeProviderImplementation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift index a63fa4d4a..9a882ee4b 100644 --- a/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Claude/ClaudeProviderImplementation.swift @@ -97,7 +97,7 @@ struct ClaudeProviderImplementation: ProviderImplementation { ProviderSettingsToggleDescriptor( id: "claude-peak-hours", title: "Show peak hours indicator", - subtitle: "Show whether Claude is in peak usage hours (8 AM–2 PM ET, weekdays).", + subtitle: "Show whether Claude is in peak usage hours.", binding: peakHoursBinding, statusText: nil, actions: [], From 761febd3540b1092759056408234243553b30121 Mon Sep 17 00:00:00 2001 From: Kejsaren Date: Mon, 30 Mar 2026 10:33:52 +1000 Subject: [PATCH 5/5] Refactor peak hours calculation in ClaudePeakHours to improve accuracy and readability. Introduced a new method for determining the next peak start time and updated related tests for consistency. --- .../Providers/Claude/ClaudePeakHours.swift | 54 +++++++++---------- .../CodexBarTests/ClaudePeakHoursTests.swift | 29 +++------- 2 files changed, 32 insertions(+), 51 deletions(-) diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift index 8885fef62..9bed7550b 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift @@ -34,37 +34,33 @@ public enum ClaudePeakHours: Sendable { 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 + let nextPeak = self.nextPeakStart(after: date, calendar: calendar) + let seconds = nextPeak.timeIntervalSince(date) + let minutes = max(Int(seconds / 60), 0) return Status( isPeak: false, - label: "Off-peak · peak in \(self.formatDuration(minutes: totalMinutes))") + label: "Off-peak · peak in \(self.formatDuration(minutes: minutes))") + } + + private static func nextPeakStart(after date: Date, calendar: Calendar) -> Date { + guard let todayPeak = calendar.date( + bySettingHour: self.peakStartHour, + minute: 0, + second: 0, + of: date) else { return date } + + let anchor = todayPeak > date ? todayPeak : calendar.date(byAdding: .day, value: 1, to: todayPeak) ?? date + let weekday = calendar.component(.weekday, from: anchor) + + let skip: Int + switch weekday { + case 1: skip = 1 + case 7: skip = 2 + default: skip = 0 + } + + if skip == 0 { return anchor } + return calendar.date(byAdding: .day, value: skip, to: anchor) ?? anchor } private static func formatDuration(minutes: Int) -> String { diff --git a/Tests/CodexBarTests/ClaudePeakHoursTests.swift b/Tests/CodexBarTests/ClaudePeakHoursTests.swift index 1a0fe42ba..42b35f66a 100644 --- a/Tests/CodexBarTests/ClaudePeakHoursTests.swift +++ b/Tests/CodexBarTests/ClaudePeakHoursTests.swift @@ -19,11 +19,8 @@ struct ClaudePeakHoursTests { 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") @@ -31,7 +28,6 @@ struct ClaudePeakHoursTests { @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") @@ -39,7 +35,6 @@ struct ClaudePeakHoursTests { @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") @@ -47,7 +42,6 @@ struct ClaudePeakHoursTests { @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") @@ -55,7 +49,6 @@ struct ClaudePeakHoursTests { @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") @@ -63,7 +56,6 @@ struct ClaudePeakHoursTests { @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") @@ -71,17 +63,13 @@ struct ClaudePeakHoursTests { @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") @@ -89,17 +77,13 @@ struct ClaudePeakHoursTests { @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)) + let status = ClaudePeakHours.status(at: self.date(day: 29, 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") @@ -107,17 +91,20 @@ struct ClaudePeakHoursTests { @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 springForwardWeekend() { + let status = ClaudePeakHours.status(at: self.date(day: 7, hour: 10)) + #expect(!status.isPeak) + #expect(status.label == "Off-peak · peak in 45h") + } @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") @@ -125,7 +112,6 @@ struct ClaudePeakHoursTests { @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") @@ -133,7 +119,6 @@ struct ClaudePeakHoursTests { @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")