mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge b8ec3026b4 into 151d24fb26
This commit is contained in:
commit
7fbeb3427c
5 changed files with 428 additions and 3 deletions
|
|
@ -63,6 +63,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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
226
mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift
Normal file
226
mac/Sources/CodeBurnMenubar/Data/QuotaNotificationService.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
141
mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift
Normal file
141
mac/Tests/CodeBurnMenubarTests/QuotaNotificationTests.swift
Normal file
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue