Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 = false,
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
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 (8 AM–2 PM ET, weekdays).",
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
87 changes: 87 additions & 0 deletions Sources/CodexBarCore/Providers/Claude/ClaudePeakHours.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
141 changes: 141 additions & 0 deletions Tests/CodexBarTests/ClaudePeakHoursTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading