This commit is contained in:
ozymandiashh 2026-05-12 23:27:38 +09:00 committed by GitHub
commit 7fbeb3427c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 428 additions and 3 deletions

View file

@ -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`

View file

@ -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)

View 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)
}
}
}
}
}

View file

@ -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) {

View 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"))
}
}