From b8ec3026b4d12dba524cf125c371c1fe0bc7920b Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 12 May 2026 00:59:44 +0300 Subject: [PATCH] Add menubar quota notifications --- CHANGELOG.md | 6 + mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 11 +- .../Data/QuotaNotificationService.swift | 226 ++++++++++++++++++ .../CodeBurnMenubar/Views/SettingsView.swift | 47 ++++ .../QuotaNotificationTests.swift | 141 +++++++++++ 5 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e7dd43d7..e062d52f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,12 @@ period-level `activities[]` rollup so a consumer can sum across days and reconcile. Closes #279. +### Added (macOS menubar) +- **Quota notifications.** Optional local notifications alert when connected + Claude or Codex quota windows cross 80% or 100%. Alerts are deduplicated in + `UserDefaults` by provider, window, threshold, and reset day, with an + in-memory pending set to avoid duplicate sends during rapid refreshes. + ### Fixed (CLI) - **Cursor sessions break down by project, not one row called "cursor".** Cursor's chat history sat under a single dashboard row labeled `cursor` diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index a58d0446..6b5101e7 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -42,6 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var forceRefreshGeneration: UInt64 = 0 private var manualRefreshTask: Task? private var manualRefreshGeneration: UInt64 = 0 + private let quotaNotifications = QuotaNotificationCoordinator() func applicationWillFinishLaunching(_ notification: Notification) { // Set accessory policy before the app's focus chain forms. On macOS Tahoe @@ -458,9 +459,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { DispatchQueue.main.async { guard let self else { return } self.pendingRefreshWork?.cancel() - let work = DispatchWorkItem { [weak self] in - self?.refreshStatusButton() - self?.observeStore() + let work = DispatchWorkItem { + Task { @MainActor [weak self] in + guard let self else { return } + self.refreshStatusButton() + self.quotaNotifications.evaluate(store: self.store) + self.observeStore() + } } self.pendingRefreshWork = work DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift new file mode 100644 index 00000000..7bffff42 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift @@ -0,0 +1,226 @@ +import Foundation +import UserNotifications + +struct QuotaNotificationEvent: Equatable { + let provider: String + let windowLabel: String + let percent: Int + let threshold: Int + let identifier: String + let keysToMark: [String] + + var title: String { + "\(provider) quota at \(percent)%" + } + + var body: String { + "\(windowLabel) usage has crossed \(threshold)%." + } +} + +enum QuotaNotificationDecider { + static let keyPrefix = "codeburn.quotaNotification" + // Keep ascending: event selection uses the highest crossed threshold and + // marks all lower thresholds when a refresh jumps straight to a higher band. + private static let thresholds = [80, 100] + + static func events( + for summaries: [QuotaSummary], + notifiedKeys: Set, + now: Date = Date(), + calendar: Calendar = .current + ) -> [QuotaNotificationEvent] { + summaries.compactMap { + event(for: $0, notifiedKeys: notifiedKeys, now: now, calendar: calendar) + } + } + + private static func event( + for summary: QuotaSummary, + notifiedKeys: Set, + now: Date, + calendar: Calendar + ) -> QuotaNotificationEvent? { + guard shouldNotify(connection: summary.connection) else { return nil } + guard let window = summary.details.max(by: { $0.percent < $1.percent }) else { return nil } + + let percent = Int((window.percent * 100).rounded()) + guard let threshold = thresholds.filter({ percent >= $0 }).max() else { return nil } + + // If a refresh jumps from below 80% directly to 100%+, send only the + // 100% notification but mark lower thresholds too so they do not follow. + let keysToMark = thresholds + .filter { $0 <= threshold } + .map { dedupeKey(provider: summary.providerFilter.cliArg, window: window, threshold: $0, now: now, calendar: calendar) } + + guard let highestKey = keysToMark.last, !notifiedKeys.contains(highestKey) else { return nil } + + return QuotaNotificationEvent( + provider: summary.providerFilter.rawValue, + windowLabel: window.label, + percent: percent, + threshold: threshold, + identifier: highestKey, + keysToMark: keysToMark + ) + } + + private static func shouldNotify(connection: QuotaSummary.Connection) -> Bool { + switch connection { + case .connected: return true + case .disconnected, .loading, .stale, .transientFailure, .terminalFailure: return false + } + } + + static func dedupeKey( + provider: String, + window: QuotaSummary.Window, + threshold: Int, + now: Date, + calendar: Calendar = .current + ) -> String { + let resetToken: String + if let resetsAt = window.resetsAt { + resetToken = dayToken(for: resetsAt, calendar: calendar) + } else { + resetToken = dayToken(for: now, calendar: calendar) + } + return [ + keyPrefix, + slug(provider), + slug(window.label), + String(threshold), + resetToken, + ].joined(separator: ".") + } + + private static func slug(_ raw: String) -> String { + let lower = raw.lowercased() + let scalars = lower.unicodeScalars.map { scalar -> Character in + CharacterSet.alphanumerics.contains(scalar) ? Character(scalar) : "-" + } + return String(scalars).split(separator: "-").joined(separator: "-") + } + + private static func dayToken(for date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } +} + +enum QuotaNotificationPreferences { + static let enabledKey = "CodeBurnQuotaNotificationsEnabled" + + @MainActor + static var isEnabled: Bool { + get { UserDefaults.standard.bool(forKey: enabledKey) } + set { UserDefaults.standard.set(newValue, forKey: enabledKey) } + } + + @MainActor + static func setEnabled(_ enabled: Bool) async -> Bool { + guard enabled else { + isEnabled = false + return false + } + + do { + let granted = try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) + isEnabled = granted + return granted + } catch { + isEnabled = false + return false + } + } +} + +@MainActor +final class QuotaNotificationCoordinator { + private static let retentionSeconds: TimeInterval = 45 * 24 * 60 * 60 + + private let defaults: UserDefaults + private let center: UNUserNotificationCenter + private var pendingKeys: Set = [] + + init( + defaults: UserDefaults = .standard, + center: UNUserNotificationCenter = .current() + ) { + self.defaults = defaults + self.center = center + } + + func evaluate(store: AppStore) { + guard QuotaNotificationPreferences.isEnabled else { return } + + pruneExpiredKeys(now: Date()) + let summaries = ProviderFilter.allCases.compactMap { store.quotaSummary(for: $0) } + let notified = Set(defaults.dictionaryRepresentation().keys.filter { + $0.hasPrefix(QuotaNotificationDecider.keyPrefix + ".") + }).union(pendingKeys) + let events = QuotaNotificationDecider.events(for: summaries, notifiedKeys: notified) + guard !events.isEmpty else { return } + events.forEach { pendingKeys.formUnion($0.keysToMark) } + + Task { @MainActor in + for event in events { + await schedule(event) + } + } + } + + private func schedule(_ event: QuotaNotificationEvent) async { + defer { + event.keysToMark.forEach { pendingKeys.remove($0) } + } + + let settings = await center.notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + break + case .notDetermined: + QuotaNotificationPreferences.isEnabled = false + return + case .denied: + QuotaNotificationPreferences.isEnabled = false + return + @unknown default: + return + } + + let content = UNMutableNotificationContent() + content.title = event.title + content.body = event.body + content.sound = .default + + let request = UNNotificationRequest(identifier: event.identifier, content: content, trigger: nil) + do { + try await center.add(request) + mark(event) + } catch { + NSLog("CodeBurn: failed to schedule quota notification: \(error)") + } + } + + private func mark(_ event: QuotaNotificationEvent) { + event.keysToMark.forEach { defaults.set(true, forKey: $0) } + } + + private func pruneExpiredKeys(now: Date) { + let cutoff = now.addingTimeInterval(-Self.retentionSeconds) + let prefix = QuotaNotificationDecider.keyPrefix + "." + for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(prefix) { + guard let token = key.split(separator: ".").last else { continue } + let parts = token.split(separator: "-").compactMap { Int($0) } + if parts.count == 3 { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + let date = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) + if let date, date < cutoff { + defaults.removeObject(forKey: key) + } + } + } + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index a4c35853..c408c6ec 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications /// macOS-standard tabbed Settings window. New per-provider sections (Codex, /// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own @@ -28,6 +29,8 @@ struct SettingsView: View { private struct GeneralSettingsTab: View { @Environment(AppStore.self) private var store + @State private var quotaAlertsEnabled = QuotaNotificationPreferences.isEnabled + @State private var quotaAlertsDenied = false var body: some View { Form { @@ -49,9 +52,53 @@ private struct GeneralSettingsTab: View { } } } + Section("Notifications") { + Toggle("Quota alerts", isOn: Binding( + get: { quotaAlertsEnabled }, + set: { enabled in + quotaAlertsEnabled = enabled + Task { + let applied = await QuotaNotificationPreferences.setEnabled(enabled) + quotaAlertsEnabled = applied + quotaAlertsDenied = enabled && !applied + } + } + )) + Text("Local alerts when connected quota windows reach 80% or 100%.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + if quotaAlertsDenied { + Text("Notifications are blocked in System Settings.") + .font(.system(size: 11)) + .foregroundStyle(.orange) + } + } } .formStyle(.grouped) .padding() + .onAppear { + Task { await syncQuotaNotificationSettings() } + } + } + + @MainActor + private func syncQuotaNotificationSettings() async { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + quotaAlertsEnabled = QuotaNotificationPreferences.isEnabled + quotaAlertsDenied = false + case .denied: + QuotaNotificationPreferences.isEnabled = false + quotaAlertsEnabled = false + quotaAlertsDenied = true + case .notDetermined: + quotaAlertsEnabled = QuotaNotificationPreferences.isEnabled + quotaAlertsDenied = false + @unknown default: + quotaAlertsEnabled = false + quotaAlertsDenied = false + } } private func applyCurrency(code: String) { diff --git a/mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift b/mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift new file mode 100644 index 00000000..ec23277a --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift @@ -0,0 +1,141 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +private let quotaNow = Date(timeIntervalSince1970: 1_800_000_000) +private let resetAt = Date(timeIntervalSince1970: 1_800_010_000) + +private func quota( + provider: ProviderFilter = .claude, + connection: QuotaSummary.Connection = .connected, + windows: [QuotaSummary.Window] +) -> QuotaSummary { + QuotaSummary( + providerFilter: provider, + connection: connection, + primary: windows.first, + details: windows, + planLabel: nil, + footerLines: [] + ) +} + +@Suite("QuotaNotificationDecider") +struct QuotaNotificationDeciderTests { + @Test("does not emit below 80 percent") + func noAlertBelowThreshold() { + let summary = quota(windows: [ + .init(label: "Weekly", percent: 0.79, resetsAt: resetAt), + ]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [], now: quotaNow) + + #expect(events.isEmpty) + } + + @Test("emits 80 percent event once per provider window reset") + func emitsEightyOnce() { + let summary = quota(windows: [ + .init(label: "Weekly", percent: 0.82, resetsAt: resetAt), + ]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [], now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].provider == "Claude") + #expect(events[0].threshold == 80) + #expect(events[0].percent == 82) + + let suppressed = QuotaNotificationDecider.events( + for: [summary], + notifiedKeys: Set(events[0].keysToMark), + now: quotaNow + ) + #expect(suppressed.isEmpty) + } + + @Test("jumps directly to 100 percent and marks lower thresholds too") + func jumpToHundredMarksLowerThresholds() { + let summary = quota(provider: .codex, windows: [ + .init(label: "5-hour", percent: 1.04, resetsAt: resetAt), + ]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [], now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].provider == "Codex") + #expect(events[0].threshold == 100) + #expect(events[0].keysToMark.count == 2) + + let suppressed = QuotaNotificationDecider.events( + for: [summary], + notifiedKeys: Set(events[0].keysToMark), + now: quotaNow + ) + #expect(suppressed.isEmpty) + } + + @Test("emits 100 percent after an earlier 80 percent alert") + func hundredAfterEighty() { + let weekly = QuotaSummary.Window(label: "Weekly", percent: 1.0, resetsAt: resetAt) + let eightyKey = QuotaNotificationDecider.dedupeKey( + provider: ProviderFilter.claude.cliArg, + window: weekly, + threshold: 80, + now: quotaNow + ) + let summary = quota(windows: [weekly]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [eightyKey], now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].threshold == 100) + } + + @Test("uses the worst quota window for a provider") + func worstWindowWins() { + let summary = quota(windows: [ + .init(label: "5-hour", percent: 0.81, resetsAt: resetAt), + .init(label: "Weekly", percent: 0.96, resetsAt: resetAt), + ]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [], now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].windowLabel == "Weekly") + #expect(events[0].threshold == 80) + } + + @Test("skips disconnected, loading, stale, and failure providers") + func skipsUnavailableStates() { + let window = QuotaSummary.Window(label: "Weekly", percent: 1.2, resetsAt: resetAt) + let summaries = [ + quota(connection: .disconnected, windows: [window]), + quota(connection: .loading, windows: [window]), + quota(connection: .stale, windows: [window]), + quota(connection: .transientFailure, windows: [window]), + quota(connection: .terminalFailure(reason: "Reconnect"), windows: [window]), + ] + + let events = QuotaNotificationDecider.events(for: summaries, notifiedKeys: [], now: quotaNow) + + #expect(events.isEmpty) + } + + @Test("uses local calendar day when no reset timestamp is available") + func nilResetUsesCalendarDay() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let window = QuotaSummary.Window(label: "Daily", percent: 0.8, resetsAt: nil) + + let key = QuotaNotificationDecider.dedupeKey( + provider: "codex", + window: window, + threshold: 80, + now: quotaNow, + calendar: calendar + ) + + #expect(key.hasSuffix(".2027-01-15")) + } +}