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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
11 changes: 8 additions & 3 deletions mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var forceRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task<Void, Never>?
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
Expand Down Expand Up @@ -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)
Expand Down
226 changes: 226 additions & 0 deletions mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift
Original file line number Diff line number Diff line change
@@ -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<String>,
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<String>,
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<String> = []

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)
}
}
}
}
}
47 changes: 47 additions & 0 deletions mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
Loading
Loading