Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/CodexBar/MenuCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -692,6 +693,7 @@ extension UsageMenuCardView.Model {
sourceLabel: String? = nil,
kiloAutoMode: Bool = false,
hidePersonalInfo: Bool,
claudePeakHoursEnabled: Bool = true,
weeklyPace: UsagePace? = nil,
now: Date)
{
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/PreferencesProvidersPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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 @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down Expand Up @@ -256,6 +257,7 @@ extension SettingsStore {
claudeOAuthKeychainPromptModeRaw: claudeOAuthKeychainPromptModeRaw,
claudeOAuthKeychainReadStrategyRaw: claudeOAuthKeychainReadStrategyRaw,
claudeWebExtrasEnabledRaw: claudeWebExtrasEnabledRaw,
claudePeakHoursEnabled: claudePeakHoursEnabled,
showOptionalCreditsAndExtraUsage: showOptionalCreditsAndExtraUsage,
openAIWebAccessEnabled: openAIWebAccessEnabled,
jetbrainsIDEBasePath: jetbrainsIDEBasePath,
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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)
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 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))")
}

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

@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")
}

@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")
}
}
Loading