Skip to content
Merged
8 changes: 8 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ extension UsageMenuCardView.Model {
let sourceLabel: String?
let kiloAutoMode: Bool
let hidePersonalInfo: Bool
let claudePeakHoursEnabled: Bool
let weeklyPace: UsagePace?
let now: Date

Expand All @@ -694,6 +695,7 @@ extension UsageMenuCardView.Model {
sourceLabel: String? = nil,
kiloAutoMode: Bool = false,
hidePersonalInfo: Bool,
claudePeakHoursEnabled: Bool = true,
Comment thread
hello-amed marked this conversation as resolved.
weeklyPace: UsagePace? = nil,
now: Date)
{
Expand All @@ -717,6 +719,7 @@ extension UsageMenuCardView.Model {
self.sourceLabel = sourceLabel
self.kiloAutoMode = kiloAutoMode
self.hidePersonalInfo = hidePersonalInfo
self.claudePeakHoursEnabled = claudePeakHoursEnabled
self.weeklyPace = weeklyPace
self.now = now
}
Expand Down Expand Up @@ -788,6 +791,11 @@ extension UsageMenuCardView.Model {
return notes
}

if input.provider == .claude, input.claudePeakHoursEnabled {
let peakStatus = ClaudePeakHours.status(at: input.now)
return [peakStatus.label]
}
Comment thread
hello-amed marked this conversation as resolved.

guard input.provider == .openrouter,
let openRouter = input.snapshot?.openRouterUsage
else {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ClaudeProviderImplementation: ProviderImplementation {
_ = settings.claudeOAuthKeychainPromptMode
_ = settings.claudeOAuthKeychainReadStrategy
_ = settings.claudeWebExtrasEnabled
_ = settings.claudePeakHoursEnabled
}

@MainActor
Expand Down Expand Up @@ -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",
Expand All @@ -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),
]
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,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") }
Expand Down Expand Up @@ -308,6 +309,7 @@ extension SettingsStore {
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
claudePeakHoursEnabled: claudePeakHoursEnabled,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
openAIWebAccessEnabled: openAIWebAccessEnabled,
openAIWebBatterySaverEnabled: openAIWebBatterySaverEnabled,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct SettingsDefaultsState {
var claudeOAuthKeychainPromptModeRaw: String?
var claudeOAuthKeychainReadStrategyRaw: String?
var claudeWebExtrasEnabledRaw: Bool
var claudePeakHoursEnabled: Bool
var showOptionalCreditsAndExtraUsage: Bool
var openAIWebAccessEnabled: Bool
var openAIWebBatterySaverEnabled: Bool
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,7 @@ extension StatusItemController {
sourceLabel: sourceLabel,
kiloAutoMode: kiloAutoMode,
hidePersonalInfo: self.settings.hidePersonalInfo,
claudePeakHoursEnabled: self.settings.claudePeakHoursEnabled,
weeklyPace: weeklyPace,
now: now)
return UsageMenuCardView.Model.make(input)
Expand Down
83 changes: 83 additions & 0 deletions Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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 date = calendar.dateInterval(of: .minute, for: date)?.start ?? date
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))")
}

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: minutes))")
Comment thread
hello-amed marked this conversation as resolved.
}

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 = switch weekday {
case 1: 1
case 7: 2
default: 0
}

if skip == 0 { return anchor }
return calendar.date(byAdding: .day, value: skip, to: anchor) ?? anchor
}

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
}
}
159 changes: 159 additions & 0 deletions Tests/CodexBarTests/ClaudePeakHoursTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
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,
second: 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,
second: second))!
}

@Test
func weekdayMorningBeforePeak() {
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() {
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() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8))
#expect(status.isPeak)
#expect(status.label == "Peak · ends in 6h")
}

@Test
func weekdayMidPeak() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 11, minute: 30))
#expect(status.isPeak)
#expect(status.label == "Peak · ends in 2h 30m")
}
Comment thread
hello-amed marked this conversation as resolved.

@Test
func weekdayPeakEndBoundary() {
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() {
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() {
let status = ClaudePeakHours.status(at: self.date(day: 26, hour: 23))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 9h")
}

@Test
func saturdayMorning() {
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() {
let status = ClaudePeakHours.status(at: self.date(day: 29, hour: 21))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 11h")
}

@Test
func fridayAfterPeak() {
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() {
let status = ClaudePeakHours.status(at: self.date(day: 27, hour: 12))
#expect(status.isPeak)
#expect(status.label == "Peak · ends in 2h")
}

@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() {
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() {
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() {
let status = ClaudePeakHours.status(at: self.date(day: 28, hour: 0))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 56h")
}

@Test
func weekdayJustBeforePeakWithSeconds() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 45, second: 30))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 15m")
}

@Test
func weekdayOneMinuteBeforePeakWithSeconds() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 30))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 1m")
}

@Test
func weekdayLastSecondBeforePeak() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 7, minute: 59, second: 59))
#expect(!status.isPeak)
#expect(status.label == "Off-peak · peak in 1m")
}

@Test
func weekdayPeakStartWithSeconds() {
let status = ClaudePeakHours.status(at: self.date(day: 25, hour: 8, minute: 0, second: 30))
#expect(status.isPeak)
#expect(status.label == "Peak · ends in 6h")
}
}
Loading