diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 14703fae9..84adbf809 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -104,6 +104,7 @@ struct UsageMenuCardView: View { let tokenUsage: TokenUsageSection? let placeholder: String? let progressColor: Color + let resetWarnings: [ResetWarning] } let model: Model @@ -140,6 +141,9 @@ struct UsageMenuCardView: View { let hasCost = self.model.tokenUsage != nil || hasProviderCost VStack(alignment: .leading, spacing: 12) { + if !self.model.resetWarnings.isEmpty { + ResetWarningBannerContent(warnings: self.model.resetWarnings) + } if hasUsage { VStack(alignment: .leading, spacing: 12) { ForEach(self.model.metrics, id: \.id) { metric in @@ -518,6 +522,45 @@ struct UsageMenuCardCreditsSectionView: View { } } +private struct ResetWarningBannerContent: View { + let warnings: [ResetWarning] + + @Environment(\.menuItemHighlighted) private var isHighlighted + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(self.warnings, id: \.windowKind) { warning in + Self.warningRow(warning: warning, isHighlighted: self.isHighlighted) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(.orange.opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(.orange.opacity(0.3), lineWidth: 1))) + } + + private static func warningRow(warning: ResetWarning, isHighlighted: Bool) -> some View { + let hoursLeft = String(format: "%.1f", warning.hoursUntilReset) + let percentLeft = String(format: "%.0f", warning.remainingPercent) + let windowLabel = warning.windowKind == .session ? "Session" : "Weekly" + let label = "\(windowLabel) resets in \(hoursLeft)h — \(percentLeft)% remaining" + let textColor: Color = isHighlighted ? .primary : .orange + return HStack(spacing: 6) { + Image(systemName: "clock.badge.exclamationmark") + .font(.caption) + .foregroundStyle(.orange) + Text(label) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(textColor) + } + } +} + private struct CreditsBarContent: View { private static let fullScaleTokens: Double = 1000 @@ -671,6 +714,7 @@ extension UsageMenuCardView.Model { let kiloAutoMode: Bool let hidePersonalInfo: Bool let weeklyPace: UsagePace? + let resetWarnings: [ResetWarning]? let now: Date init( @@ -695,6 +739,7 @@ extension UsageMenuCardView.Model { kiloAutoMode: Bool = false, hidePersonalInfo: Bool, weeklyPace: UsagePace? = nil, + resetWarnings: [ResetWarning]? = nil, now: Date) { self.provider = provider @@ -718,6 +763,7 @@ extension UsageMenuCardView.Model { self.kiloAutoMode = kiloAutoMode self.hidePersonalInfo = hidePersonalInfo self.weeklyPace = weeklyPace + self.resetWarnings = resetWarnings self.now = now } } @@ -770,7 +816,8 @@ extension UsageMenuCardView.Model { providerCost: providerCost, tokenUsage: tokenUsage, placeholder: placeholder, - progressColor: Self.progressColor(for: input.provider)) + progressColor: Self.progressColor(for: input.provider), + resetWarnings: input.resetWarnings ?? []) } private static func usageNotes(input: Input) -> [String] { diff --git a/Sources/CodexBar/PreferencesGeneralPane.swift b/Sources/CodexBar/PreferencesGeneralPane.swift index 39a95a55f..cb2a67daa 100644 --- a/Sources/CodexBar/PreferencesGeneralPane.swift +++ b/Sources/CodexBar/PreferencesGeneralPane.swift @@ -96,6 +96,31 @@ struct GeneralPane: View { subtitle: "Notifies when the 5-hour session quota hits 0% and when it becomes " + "available again.", binding: self.$settings.sessionQuotaNotificationsEnabled) + PreferenceToggleRow( + title: "Reset warnings", + subtitle: "Notifies when a usage window is about to reset and you still have " + + "significant capacity remaining.", + binding: self.$settings.resetWarningEnabled) + if self.settings.resetWarningEnabled { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Warning advance time") + .font(.body) + Text("How many hours before reset to trigger the warning.") + .font(.footnote) + .foregroundStyle(.tertiary) + } + Spacer() + Picker("Warning advance time", selection: self.$settings.resetWarningHours) { + ForEach([1, 2, 4, 8, 12, 24, 48], id: \.self) { hours in + Text(hours == 1 ? "1 hour" : "\(hours) hours").tag(hours) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(maxWidth: 200) + } + } } Divider() diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 327f51564..f58c5852a 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -561,6 +561,7 @@ struct ProvidersPane: View { showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, + resetWarnings: self.store.activeResetWarnings[provider], now: now) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBar/SessionQuotaNotifications.swift b/Sources/CodexBar/SessionQuotaNotifications.swift index 962b5a61f..b4a76f0ca 100644 --- a/Sources/CodexBar/SessionQuotaNotifications.swift +++ b/Sources/CodexBar/SessionQuotaNotifications.swift @@ -32,6 +32,7 @@ enum SessionQuotaNotificationLogic { @MainActor protocol SessionQuotaNotifying: AnyObject { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge: NSNumber?) + func postResetWarning(idPrefix: String, title: String, body: String) } @MainActor @@ -60,4 +61,9 @@ final class SessionQuotaNotifier: SessionQuotaNotifying { self.logger.info("enqueuing", metadata: ["prefix": idPrefix]) AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: badge) } + + func postResetWarning(idPrefix: String, title: String, body: String) { + self.logger.info("enqueuing reset warning", metadata: ["prefix": idPrefix]) + AppNotifications.shared.post(idPrefix: idPrefix, title: title, body: body, badge: nil) + } } diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 477529804..b1caeb13d 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -478,6 +478,23 @@ extension SettingsStore { } } + var resetWarningEnabled: Bool { + get { self.defaultsState.resetWarningEnabled } + set { + self.defaultsState.resetWarningEnabled = newValue + self.userDefaults.set(newValue, forKey: "resetWarningEnabled") + } + } + + var resetWarningHours: Int { + get { self.defaultsState.resetWarningHours } + set { + let clamped = max(1, min(48, newValue)) + self.defaultsState.resetWarningHours = clamped + self.userDefaults.set(clamped, forKey: "resetWarningHours") + } + } + var debugLoadingPattern: LoadingPattern? { get { self.debugLoadingPatternRaw.flatMap(LoadingPattern.init(rawValue:)) } set { self.debugLoadingPatternRaw = newValue?.rawValue } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index c3a59ba7a..10a1283ed 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -241,6 +241,9 @@ extension SettingsStore { forKey: "mergedOverviewSelectedProviders") as? [String] ?? [] let selectedMenuProviderRaw = userDefaults.string(forKey: "selectedMenuProvider") let providerDetectionCompleted = userDefaults.object(forKey: "providerDetectionCompleted") as? Bool ?? false + let resetWarningEnabled = userDefaults.object(forKey: "resetWarningEnabled") as? Bool ?? true + let resetWarningHours = userDefaults.object(forKey: "resetWarningHours") as? Int + ?? ResetWarningEvaluator.defaultWarningHours return SettingsDefaultsState( refreshFrequency: refreshFrequency, @@ -275,7 +278,9 @@ extension SettingsStore { mergedMenuLastSelectedWasOverview: mergedMenuLastSelectedWasOverview, mergedOverviewSelectedProvidersRaw: mergedOverviewSelectedProvidersRaw, selectedMenuProviderRaw: selectedMenuProviderRaw, - providerDetectionCompleted: providerDetectionCompleted) + providerDetectionCompleted: providerDetectionCompleted, + resetWarningEnabled: resetWarningEnabled, + resetWarningHours: resetWarningHours) } } diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index 98e01406d..c7acf37e4 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -34,4 +34,6 @@ struct SettingsDefaultsState { var mergedOverviewSelectedProvidersRaw: [String] var selectedMenuProviderRaw: String? var providerDetectionCompleted: Bool + var resetWarningEnabled: Bool + var resetWarningHours: Int } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab4..2d3318b93 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1441,6 +1441,7 @@ extension StatusItemController { kiloAutoMode: kiloAutoMode, hidePersonalInfo: self.settings.hidePersonalInfo, weeklyPace: weeklyPace, + resetWarnings: self.store.activeResetWarnings[target], now: now) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index a629d1d09..08efbe5e6 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -128,6 +128,7 @@ final class UsageStore { var pathDebugInfo: PathDebugSnapshot = .empty var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] + var activeResetWarnings: [UsageProvider: [ResetWarning]] = [:] var historicalPaceRevision: Int = 0 @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? @ObservationIgnored var lastCreditsSnapshotAccountKey: String? @@ -188,6 +189,7 @@ final class UsageStore { @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] + @ObservationIgnored var lastResetWarningDate: [String: Date] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @@ -467,6 +469,7 @@ final class UsageStore { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } + self.checkResetWarnings() self.persistWidgetSnapshot(reason: "refresh") } } @@ -639,6 +642,74 @@ final class UsageStore { self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) } + // MARK: - Reset warnings + + func checkResetWarnings() { + guard self.settings.resetWarningEnabled else { + self.activeResetWarnings = [:] + return + } + + let warningHours = self.settings.resetWarningHours + let now = Date() + var newWarnings: [UsageProvider: [ResetWarning]] = [:] + + for provider in self.enabledProviders() { + guard let snapshot = self.snapshots[provider] else { continue } + var providerWarnings: [ResetWarning] = [] + + if let primary = snapshot.primary, + let warning = ResetWarningEvaluator.evaluate( + provider: provider, + window: primary, + windowKind: .session, + warningHours: warningHours, + now: now) + { + providerWarnings.append(warning) + } + + if let secondary = snapshot.secondary, + let warning = ResetWarningEvaluator.evaluate( + provider: provider, + window: secondary, + windowKind: .weekly, + warningHours: warningHours, + now: now) + { + providerWarnings.append(warning) + } + + if !providerWarnings.isEmpty { + newWarnings[provider] = providerWarnings + for warning in providerWarnings { + let key = "reset-\(provider.rawValue)-\(warning.windowKind.rawValue)" + if ResetWarningEvaluator.shouldNotify( + warning: warning, + lastNotifiedAt: self.lastResetWarningDate[key], + now: now) + { + self.lastResetWarningDate[key] = now + self.postResetWarningNotification(warning: warning) + } + } + } + } + + self.activeResetWarnings = newWarnings + } + + private func postResetWarningNotification(warning: ResetWarning) { + let providerName = ProviderDescriptorRegistry.descriptor(for: warning.providerID).metadata.displayName + let windowLabel = warning.windowKind == .session ? "session" : "weekly" + let hoursLeft = String(format: "%.0f", warning.hoursUntilReset) + let percentLeft = String(format: "%.0f", warning.remainingPercent) + let title = "\(providerName) \(windowLabel) resets in \(hoursLeft)h" + let body = "You still have \(percentLeft)% remaining. Consider starting long-running tasks." + let idPrefix = "reset-warning-\(warning.providerID.rawValue)-\(warning.windowKind.rawValue)" + self.sessionQuotaNotifier.postResetWarning(idPrefix: idPrefix, title: title, body: body) + } + private func refreshStatus(_ provider: UsageProvider) async { guard self.settings.statusChecksEnabled else { return } guard let meta = self.providerMetadata[provider] else { return } diff --git a/Sources/CodexBarCore/ResetWarningState.swift b/Sources/CodexBarCore/ResetWarningState.swift new file mode 100644 index 000000000..e272d7166 --- /dev/null +++ b/Sources/CodexBarCore/ResetWarningState.swift @@ -0,0 +1,72 @@ +import Foundation + +public struct ResetWarning: Equatable, Sendable { + public let providerID: UsageProvider + public let windowKind: WindowKind + public let remainingPercent: Double + public let resetsAt: Date + public let hoursUntilReset: Double + + public enum WindowKind: String, Sendable { + case session + case weekly + } + + public init( + providerID: UsageProvider, + windowKind: WindowKind, + remainingPercent: Double, + resetsAt: Date, + hoursUntilReset: Double) + { + self.providerID = providerID + self.windowKind = windowKind + self.remainingPercent = remainingPercent + self.resetsAt = resetsAt + self.hoursUntilReset = hoursUntilReset + } +} + +public enum ResetWarningEvaluator { + public static let minimumRemainingPercent: Double = 20 + public static let defaultWarningHours: Int = 8 + public static let notificationCooldownHours: Double = 1 + + public static func evaluate( + provider: UsageProvider, + window: RateWindow, + windowKind: ResetWarning.WindowKind, + warningHours: Int, + minimumRemainingPercent: Double = ResetWarningEvaluator.minimumRemainingPercent, + now: Date = .init()) -> ResetWarning? + { + guard let resetsAt = window.resetsAt else { return nil } + let secondsUntilReset = resetsAt.timeIntervalSince(now) + guard secondsUntilReset > 0 else { return nil } + + let hoursUntilReset = secondsUntilReset / 3600 + let warningWindow = Double(warningHours) + + guard hoursUntilReset <= warningWindow else { return nil } + + let remaining = window.remainingPercent + guard remaining >= minimumRemainingPercent else { return nil } + + return ResetWarning( + providerID: provider, + windowKind: windowKind, + remainingPercent: remaining, + resetsAt: resetsAt, + hoursUntilReset: hoursUntilReset) + } + + public static func shouldNotify( + warning: ResetWarning, + lastNotifiedAt: Date?, + now: Date = .init()) -> Bool + { + guard let lastNotifiedAt else { return true } + let cooldownSeconds = Self.notificationCooldownHours * 3600 + return now.timeIntervalSince(lastNotifiedAt) >= cooldownSeconds + } +} diff --git a/Tests/CodexBarTests/ResetWarningTests.swift b/Tests/CodexBarTests/ResetWarningTests.swift new file mode 100644 index 000000000..1192e1cf3 --- /dev/null +++ b/Tests/CodexBarTests/ResetWarningTests.swift @@ -0,0 +1,149 @@ +import CodexBarCore +import Foundation +import Testing + +struct ResetWarningTests { + private static func makeWindow( + usedPercent: Double, + windowMinutes: Int? = 300, + resetsAt: Date?) -> RateWindow + { + RateWindow(usedPercent: usedPercent, windowMinutes: windowMinutes, resetsAt: resetsAt, resetDescription: nil) + } + + @Test("no warning when resetsAt is nil") + func noWarningWithoutResetTime() { + let window = Self.makeWindow(usedPercent: 10, resetsAt: nil) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8) + #expect(result == nil) + } + + @Test("no warning when reset is far in the future") + func noWarningWhenResetFarAway() { + let future = Date().addingTimeInterval(48 * 3600) + let window = Self.makeWindow(usedPercent: 10, resetsAt: future) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8) + #expect(result == nil) + } + + @Test("no warning when remaining is below threshold") + func noWarningWhenLowRemaining() { + let soon = Date().addingTimeInterval(2 * 3600) + let window = Self.makeWindow(usedPercent: 95, resetsAt: soon) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8) + #expect(result == nil) + } + + @Test("warning fires when within window and high remaining") + func warningWhenWithinWindowAndHighRemaining() { + let soon = Date().addingTimeInterval(3 * 3600) + let window = Self.makeWindow(usedPercent: 10, resetsAt: soon) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8) + #expect(result != nil) + #expect(result?.remainingPercent == 90) + #expect(result?.providerID == .claude) + #expect(result?.windowKind == .session) + } + + @Test("warning fires at boundary of warning hours") + func warningAtExactBoundary() { + let boundary = Date().addingTimeInterval(8 * 3600) + let window = Self.makeWindow(usedPercent: 30, resetsAt: boundary) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .weekly, + warningHours: 8) + #expect(result != nil) + #expect(result?.remainingPercent == 70) + } + + @Test("no warning when past reset time") + func noWarningWhenPastReset() { + let past = Date().addingTimeInterval(-1 * 3600) + let window = Self.makeWindow(usedPercent: 10, resetsAt: past) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8) + #expect(result == nil) + } + + @Test("shouldNotify returns true when no previous notification") + func shouldNotifyFirstTime() { + let warning = ResetWarning( + providerID: .claude, + windowKind: .session, + remainingPercent: 80, + resetsAt: Date().addingTimeInterval(3 * 3600), + hoursUntilReset: 3) + #expect(ResetWarningEvaluator.shouldNotify(warning: warning, lastNotifiedAt: nil)) + } + + @Test("shouldNotify returns false within cooldown") + func shouldNotNotifyWithinCooldown() { + let warning = ResetWarning( + providerID: .claude, + windowKind: .session, + remainingPercent: 80, + resetsAt: Date().addingTimeInterval(3 * 3600), + hoursUntilReset: 3) + let recent = Date().addingTimeInterval(-30 * 60) + #expect(!ResetWarningEvaluator.shouldNotify(warning: warning, lastNotifiedAt: recent)) + } + + @Test("shouldNotify returns true after cooldown expires") + func shouldNotifyAfterCooldown() { + let warning = ResetWarning( + providerID: .claude, + windowKind: .session, + remainingPercent: 80, + resetsAt: Date().addingTimeInterval(3 * 3600), + hoursUntilReset: 3) + let old = Date().addingTimeInterval(-2 * 3600) + #expect(ResetWarningEvaluator.shouldNotify(warning: warning, lastNotifiedAt: old)) + } + + @Test("minimum remaining percent boundary") + func exactlyAtMinimumRemaining() { + let soon = Date().addingTimeInterval(3 * 3600) + let window = Self.makeWindow(usedPercent: 80, resetsAt: soon) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8, + minimumRemainingPercent: 20) + #expect(result != nil) + } + + @Test("below minimum remaining percent") + func belowMinimumRemaining() { + let soon = Date().addingTimeInterval(3 * 3600) + let window = Self.makeWindow(usedPercent: 81, resetsAt: soon) + let result = ResetWarningEvaluator.evaluate( + provider: .claude, + window: window, + windowKind: .session, + warningHours: 8, + minimumRemainingPercent: 20) + #expect(result == nil) + } +} diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d9..a717f9390 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -12,6 +12,8 @@ struct UsageStoreSessionQuotaTransitionTests { func post(transition: SessionQuotaTransition, provider: UsageProvider, badge _: NSNumber?) { self.posts.append((transition: transition, provider: provider)) } + + func postResetWarning(idPrefix _: String, title _: String, body _: String) {} } @Test