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
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
Comment on lines +63 to +64
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Derive peak countdown from calendar dates

This countdown is computed with fixed 24-hour day math, which is incorrect in America/New_York when a daylight-saving transition falls between now and the next peak window. For example, on Saturday, March 7, 2026 at 10:00 ET (spring-forward weekend), this path reports Off-peak · peak in 46h, but Monday, March 9, 2026 at 8:00 ET is only 45 hours away. The same off-by-one-hour error appears around the November fall-back weekend; please compute the next peak Date via Calendar and subtract actual timestamps instead of multiplying by 24 * 60.

Useful? React with 👍 / 👎.

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