From 20885e6a39e1e20617ded8cb1fbb25c4a6572cf0 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 | 23 +- .../Data/QuotaNotificationService.swift | 274 ++++++++++++++++++ .../CodeBurnMenubar/Views/SettingsView.swift | 47 +++ .../QuotaNotificationTests.swift | 205 +++++++++++++ 5 files changed, 551 insertions(+), 4 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 8b74542..6502ccc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,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 6191575..9d9ecdb 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -1,6 +1,7 @@ import SwiftUI import AppKit import Observation +import UserNotifications private let refreshIntervalSeconds: UInt64 = 30 private let forceRefreshWatchdogSeconds: TimeInterval = 90 @@ -29,7 +30,7 @@ struct CodeBurnApp: App { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, UNUserNotificationCenterDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! fileprivate let store = AppStore() @@ -50,6 +51,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { private var codexQuotaRefreshTask: Task? private var refreshLoopHeartbeatAt: Date = .distantPast private var lastLaunchAgentHeartbeatAt: Date = .distantPast + private let quotaNotifications = QuotaNotificationCoordinator() func applicationWillFinishLaunching(_ notification: Notification) { // Set accessory policy before the app's focus chain forms. On macOS Tahoe @@ -81,6 +83,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { restorePersistedCurrency() setupStatusItem() setupPopover() + UNUserNotificationCenter.current().delegate = self observeStore() startRefreshLoop() setupWakeObservers() @@ -634,9 +637,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) @@ -913,4 +920,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { // popover was anchored. refreshStatusButton() } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift new file mode 100644 index 0000000..836f720 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift @@ -0,0 +1,274 @@ +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] { + events( + for: summaries, + isNotified: { notifiedKeys.contains($0) }, + now: now, + calendar: calendar + ) + } + + static func events( + for summaries: [QuotaSummary], + isNotified: (String) -> Bool, + now: Date = Date(), + calendar: Calendar = .current + ) -> [QuotaNotificationEvent] { + summaries.flatMap { + events(for: $0, isNotified: isNotified, now: now, calendar: calendar) + } + } + + private static func events( + for summary: QuotaSummary, + isNotified: (String) -> Bool, + now: Date, + calendar: Calendar + ) -> [QuotaNotificationEvent] { + guard shouldNotify(connection: summary.connection) else { return [] } + + return summary.details.compactMap { window in + event(for: summary, window: window, isNotified: isNotified, now: now, calendar: calendar) + } + } + + private static func event( + for summary: QuotaSummary, + window: QuotaSummary.Window, + isNotified: (String) -> Bool, + now: Date, + calendar: Calendar + ) -> QuotaNotificationEvent? { + guard window.percent.isFinite else { return nil } + + let percent = Int((max(0, 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, !isNotified(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 = resetToken(for: window.resetsAt, now: now, calendar: calendar) + return [ + keyPrefix, + slug(provider), + slug(window.label), + String(threshold), + resetToken, + ].joined(separator: ".") + } + + private static func resetToken(for resetsAt: Date?, now: Date, calendar: Calendar) -> String { + if let resetsAt { + return "r\(Int(resetsAt.timeIntervalSince1970.rounded()))" + } + return "d\(dayToken(for: now, calendar: calendar))" + } + + 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 static let pruneIntervalSeconds: TimeInterval = 24 * 60 * 60 + + private let defaults: UserDefaults + private let center: UNUserNotificationCenter + private var pendingKeys: Set = [] + private var lastPrunedAt: Date = .distantPast + + init( + defaults: UserDefaults = .standard, + center: UNUserNotificationCenter = .current() + ) { + self.defaults = defaults + self.center = center + } + + func evaluate(store: AppStore) { + guard QuotaNotificationPreferences.isEnabled else { return } + + let now = Date() + pruneExpiredKeysIfNeeded(now: now) + let summaries = ProviderFilter.allCases.compactMap { store.quotaSummary(for: $0) } + let events = QuotaNotificationDecider.events( + for: summaries, + isNotified: { [defaults, pendingKeys] key in + pendingKeys.contains(key) || defaults.bool(forKey: key) + }, + now: now + ) + 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 pruneExpiredKeysIfNeeded(now: Date) { + guard now.timeIntervalSince(lastPrunedAt) >= Self.pruneIntervalSeconds else { return } + lastPrunedAt = now + pruneExpiredKeys(now: now) + } + + 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 } + if let tokenDate = date(fromResetToken: String(token)), tokenDate < cutoff { + defaults.removeObject(forKey: key) + } + } + } + + private func date(fromResetToken token: String) -> Date? { + if token.hasPrefix("r"), let seconds = TimeInterval(token.dropFirst()) { + return Date(timeIntervalSince1970: seconds) + } + + let rawDay = token.hasPrefix("d") ? String(token.dropFirst()) : token + let parts = rawDay.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3 else { return nil } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .current + return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index dae7406..68c75e2 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 0000000..df2e92e --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift @@ -0,0 +1,205 @@ +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("emits each crossed quota window for a provider") + func eachCrossedWindowEmits() { + 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 == 2) + #expect(events.map(\.windowLabel) == ["5-hour", "Weekly"]) + #expect(events.map(\.threshold) == [80, 80]) + } + + @Test("notified worst window does not suppress newly crossed sibling windows") + func notifiedWorstWindowDoesNotSuppressSiblings() { + let fiveHour = QuotaSummary.Window(label: "5-hour", percent: 1.0, resetsAt: resetAt) + let weekly = QuotaSummary.Window(label: "Weekly", percent: 0.85, resetsAt: resetAt) + let notified = Set([80, 100].map { + QuotaNotificationDecider.dedupeKey( + provider: ProviderFilter.claude.cliArg, + window: fiveHour, + threshold: $0, + now: quotaNow + ) + }) + let summary = quota(windows: [fiveHour, weekly]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: notified, now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].windowLabel == "Weekly") + #expect(events[0].threshold == 80) + } + + @Test("skips non-finite quota percentages") + func skipsNonFinitePercentages() { + let summary = quota(windows: [ + .init(label: "Bad", percent: .nan, resetsAt: resetAt), + .init(label: "Also Bad", percent: .infinity, resetsAt: resetAt), + .init(label: "Good", percent: 0.82, resetsAt: resetAt), + ]) + + let events = QuotaNotificationDecider.events(for: [summary], notifiedKeys: [], now: quotaNow) + + #expect(events.count == 1) + #expect(events[0].windowLabel == "Good") + } + + @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(".d2027-01-15")) + } + + @Test("keeps same-day reset windows distinct") + func sameDayResetWindowsAreDistinct() { + let first = QuotaSummary.Window( + label: "5-hour", + percent: 0.8, + resetsAt: Date(timeIntervalSince1970: 1_800_010_000) + ) + let second = QuotaSummary.Window( + label: "5-hour", + percent: 0.8, + resetsAt: Date(timeIntervalSince1970: 1_800_020_000) + ) + + let firstKey = QuotaNotificationDecider.dedupeKey( + provider: "claude", + window: first, + threshold: 80, + now: quotaNow + ) + let secondKey = QuotaNotificationDecider.dedupeKey( + provider: "claude", + window: second, + threshold: 80, + now: quotaNow + ) + + #expect(firstKey != secondKey) + } +}