Skip to content

Commit a496cf6

Browse files
committed
Notify when a quota window crosses 80% and 100%
Every connected provider's windows are checked on the existing quota refresh. A crossing fires once per (provider, window, reset instant); the next cycle re-arms it. Authorization is requested on the first real crossing, not at launch. Settings gains a toggle, default on.
1 parent 09ba2d1 commit a496cf6

8 files changed

Lines changed: 363 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Added (macOS)
1111
- **Launching the menubar app replaces the copy already running instead of stacking a second Capacity Dock on the screen edge.** At launch it terminates only *strictly older* instances of its own bundle identifier, so two simultaneous launches cannot quit each other and leave none, and an older copy that ignores the polite quit is forced out a few seconds later.
12+
- **The menubar tells you when a quota window crosses 80% and again when it hits its limit.** Every connected provider's windows — the same list the menu-bar flame and the popover warning row are built from — are checked on the quota refresh that already runs, with no new polling: crossing 80% posts `Claude · Weekly at 80%`, reaching the limit posts `Claude · Weekly limit reached`. Each fires once per window per cycle, a window that hits its limit without ever having been seen at 80% posts only the limit notice, and the cycle's reset instant is part of the identity, so the next cycle re-arms both while an absent or failed fetch keeps what was already announced. The fired set is persisted, so a relaunch does not repeat a crossing. Notification authorization is requested on the first real crossing and never at launch. Settings → Notifications gains a switch, on by default.
1213
- **The macOS menubar app speaks Simplified Chinese, and follows your system language to decide.** Every user-facing string in the popover, the Capacity Dock, the status-item menu, the update alerts and all of Settings now resolves through a `Localizable.strings` catalog shipped for `en` and `zh-Hans` with no third-party library: 628 keys, whose key *is* the English copy, so an untranslated string degrades to correct English rather than a visible identifier. AppKit picks the table from the user's preferred languages; Settings > General > Language overrides it for CodeBurn alone by writing `AppleLanguages` into the app's own preferences domain, which is the same key `CFBundleLocalizations` makes System Settings > Language & Region > Applications write, so the two surfaces are one setting rather than two. Enum raw values that double as persistence or cache keys (`Period`, `MenubarScope`, `InsightMode`, `AccentPreset`, `ProviderFilter`) keep their raw value and gained a separate display label, so nothing a user has saved changes meaning. Three display-only date formatters that were pinned to `en_US_POSIX` with fixed patterns now follow the locale, and the calendar popover's weekday row comes from the locale's own short symbols clipped to two units, so a Chinese UI reads `2026年9月` and `周一 周二` while English keeps `Mo Tu We`. That locale move is the one place English output changes: `EEE MMM d` reads `Sat, Sep 12` in en_US and `Sat 12 Sep` in en_GB, and `MMM d` reads `12 Sep` in en_GB. Provider, model and plan names, units, currency codes, shell commands and anything the `codeburn` CLI itself produces stay verbatim in every locale. Adding a language is now one more `.lproj`; a test fails the build if the two tables disagree on keys, leave a value blank, or disagree on format specifiers, and a second test reads `mac/Sources` itself and fails when a user-facing literal never reaches the catalog at all — the drift a catalog-versus-catalog diff cannot see, because both tables stay in perfect agreement while a bare `Text("…")` ships English to a zh-Hans user. This covers the menubar half of #1219 only, not the CLI output or the web dashboard. (#1219)
1314
- **The menubar tells you when a vendor resets your quota early, and what that provider's early resets have looked like.** Vendors sometimes reset a usage window ahead of schedule as a goodwill gesture; CodeBurn showed the new percentage but never said it had happened, so the free capacity went unused. Two signals now catch it on the refresh lifecycle that already runs, with no new polling: a reset time that jumps to a new cycle while the stored one still had time to run, and usage emptying (a fall of at least 40 points landing at or under 10%) while the advertised reset time stands still. A system notification through the existing notifier names the provider, the window and the lead, worded for the signal that saw it ("Claude's weekly limit reset 18h early. You're back to 100%." when the cycle rolled over, "Claude cleared your weekly usage 18h before its reset. You're back to 100%." when the counter emptied but the reset time held), the Capacity Dock carries a band saying the same for twelve hours, and the quota hover card gains this Mac's own pattern from the 30 days of snapshots already on disk ("Last 3 weekly resets came ~18h early"), derived from the stored reset times with no network and no external feed. One goodwill reset is one notification per provider however many windows it empties, and a reset already announced is never announced again — after a relaunch, or when the vendor briefly serves the old cycle back. It stays silent on a normal scheduled reset, a plan change, clock or timestamp skew, a window appearing or disappearing between fetches, a window's first observation, a reconnect after a terminal failure or a fresh bootstrap, and a window whose length the adapter does not validate; a missing or malformed stored reading is no opinion rather than an event. Settings → General → Notifications turns the notification off, default on, and with it off the dock band still appears. Claude is the provider wired up today, because it is the one whose quota readings are persisted. (#725)
1415
- **The Capacity Dock shows today's cache-read tokens and tells you whether each quota window will last to its reset.** The Today section gains a provider-scoped cache-read figure beside input, output and calls; a known zero prints as `0` while missing or incomplete historical accounting stays unknown rather than becoming a fabricated zero, and because cache reads were already priced into the burned figure this adds visibility without changing any total. Each quota window then gets one line under it: `Lasts until reset`, `Runs out in 2d 8h`, or — on windows of six hours or less, where one burst would make a linear ETA cry wolf — the pace stage the Plan tab uses (`On pace`, `40% in deficit`, `30% in reserve`). The same line appears under each bar in the agent-tab quota hover card. The projection runs against the window length the provider adapter reports, never a length guessed from the display label, so a monthly cycle whose label happens to read `Weekly` is still paced against its month; it stays silent early in a window, on an exhausted window, without a reset time or a validated duration, and on stale, disconnected or older-than-ten-minute data. Four quota windows move to a two-column grid so scope labels, reset times and captions stay readable, and the dock reserves the caption's height whether or not a column has one so the bubble cannot resize under the pointer. Status snapshot revision 7 invalidates older cached payloads without purging daily history, and no extra polling is introduced. (#1267)

‎mac/Sources/CodeBurnMenubar/AppStore.swift‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ final class AppStore {
182182
var earlyResetEvents: [String: EarlyQuotaResetEvent] = [:]
183183
var earlyResetHistory: [EarlyQuotaResetHistory.Summary] = []
184184
@ObservationIgnored var earlyQuotaResetMonitor = EarlyQuotaResetMonitor()
185+
@ObservationIgnored var quotaCrossingMonitor = QuotaCrossingMonitor()
185186

186187
var codexUsage: CodexUsage?
187188
var codexError: String?
@@ -2172,10 +2173,15 @@ final class AppStore {
21722173
let warnings: [QuotaWarning] // sorted desc by percent
21732174
}
21742175

2175-
var aggregateQuotaStatus: AggregateQuotaStatus {
2176-
var providers: [QuotaWarning] = []
2177-
func include(_ name: String, _ windows: [QuotaWarning.Candidate]) {
2178-
if let worst = QuotaWarning.worst(name: name, windows: windows) { providers.append(worst) }
2176+
/// Every connected provider's quota windows, flattened. The warning banner
2177+
/// and the crossing notifier read the same list, so the two can never
2178+
/// disagree about what a provider is reporting.
2179+
var quotaWindows: [QuotaCrossingWindow] {
2180+
var windows: [QuotaCrossingWindow] = []
2181+
func include(_ name: String, _ candidates: [QuotaWarning.Candidate]) {
2182+
windows += candidates.map {
2183+
QuotaCrossingWindow(providerName: name, label: $0.label, percent: $0.percent, resetsAt: $0.resetsAt)
2184+
}
21792185
}
21802186
if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
21812187
// Labelled as `claudeQuotaSummary` labels them, so the warning row
@@ -2217,6 +2223,19 @@ final class AppStore {
22172223
QuotaWarning.Candidate(label: $0.label, percent: $0.usedPercent, resetsAt: $0.resetsAt)
22182224
})
22192225
}
2226+
return windows
2227+
}
2228+
2229+
var aggregateQuotaStatus: AggregateQuotaStatus {
2230+
var order: [String] = []
2231+
var byProvider: [String: [QuotaWarning.Candidate]] = [:]
2232+
for window in quotaWindows {
2233+
if byProvider[window.providerName] == nil { order.append(window.providerName) }
2234+
byProvider[window.providerName, default: []].append(
2235+
.init(label: window.label, percent: window.percent, resetsAt: window.resetsAt)
2236+
)
2237+
}
2238+
let providers = order.compactMap { QuotaWarning.worst(name: $0, windows: byProvider[$0] ?? []) }
22202239
let result = QuotaWarningPresentation.aggregate(providers)
22212240
return AggregateQuotaStatus(severity: result.severity, warnings: result.warnings)
22222241
}

‎mac/Sources/CodeBurnMenubar/CodeBurnApp.swift‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
671671
case (false, false):
672672
break
673673
}
674+
await store.quotaCrossingMonitor.record(windows: store.quotaWindows)
674675
return true
675676
}
676677

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import Foundation
2+
3+
/// User preference for quota-crossing notifications. Absent key is true,
4+
/// matching `UpdateNotificationPreference`: existing installs get the alert
5+
/// without visiting Settings first.
6+
enum QuotaCrossingPreference {
7+
static let defaultsKey = "codeburn.quota.crossingNotificationsEnabled"
8+
9+
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
10+
defaults.object(forKey: defaultsKey) as? Bool ?? true
11+
}
12+
}
13+
14+
/// One provider window as `AppStore.quotaWindows` reports it: the same list the
15+
/// warning banner and the menu-bar flame are built from.
16+
struct QuotaCrossingWindow: Equatable, Sendable {
17+
let providerName: String
18+
/// The adapter's own label ("5-hour", "Weekly · Opus"), never translated.
19+
let label: String
20+
/// Share of the window used, 0...100; nil when the provider did not report it.
21+
let percent: Double?
22+
let resetsAt: Date?
23+
24+
/// One cycle of one window. A window that carries no reset instant cannot
25+
/// tell one cycle from the next, so it notifies once and then stays quiet.
26+
var cycleKey: String {
27+
let stamp = resetsAt.map { String(Int($0.timeIntervalSince1970.rounded())) } ?? "-"
28+
return "\(providerName)|\(label)|\(stamp)"
29+
}
30+
}
31+
32+
struct QuotaCrossingEvent: Equatable, Sendable {
33+
enum Level: String, Sendable {
34+
case warning
35+
case limit
36+
}
37+
38+
let providerName: String
39+
let windowLabel: String
40+
let level: Level
41+
/// Fired-set entry and notification identifier: one per level per cycle.
42+
let key: String
43+
44+
var notificationTitle: String {
45+
switch level {
46+
case .warning: L("%@ · %@ at 80%%", providerName, windowLabel)
47+
case .limit: L("%@ · %@ limit reached", providerName, windowLabel)
48+
}
49+
}
50+
}
51+
52+
/// Decides which windows crossed a notify-worthy threshold. Pure: the fired set
53+
/// goes in and comes back out, so the caller owns persistence and nothing here
54+
/// reads a clock or a default.
55+
enum QuotaCrossingDetector {
56+
static let warningPercent: Double = 80
57+
static let limitPercent: Double = 100
58+
59+
static func evaluate(
60+
windows: [QuotaCrossingWindow],
61+
fired: Set<String>
62+
) -> (events: [QuotaCrossingEvent], fired: Set<String>) {
63+
var live = fired
64+
for window in windows {
65+
// A window now on a different cycle is re-armed; absence is not a
66+
// reset, so a provider between fetches keeps what it has fired.
67+
let prefix = "\(window.providerName)|\(window.label)|"
68+
let current = window.cycleKey + "|"
69+
live = live.filter { !$0.hasPrefix(prefix) || $0.hasPrefix(current) }
70+
}
71+
72+
var events: [QuotaCrossingEvent] = []
73+
for window in windows {
74+
guard let percent = window.percent, percent.isFinite else { continue }
75+
guard let level = level(for: percent) else { continue }
76+
let key = "\(window.cycleKey)|\(level.rawValue)"
77+
guard live.insert(key).inserted else { continue }
78+
// Once the window is full the 80% notice is water under the bridge:
79+
// marking it fired keeps a later reading from posting it after the
80+
// louder one.
81+
if level == .limit {
82+
live.insert("\(window.cycleKey)|\(QuotaCrossingEvent.Level.warning.rawValue)")
83+
}
84+
events.append(QuotaCrossingEvent(
85+
providerName: window.providerName,
86+
windowLabel: window.label,
87+
level: level,
88+
key: key
89+
))
90+
}
91+
return (events, live)
92+
}
93+
94+
private static func level(for percent: Double) -> QuotaCrossingEvent.Level? {
95+
if percent >= limitPercent { return .limit }
96+
if percent >= warningPercent { return .warning }
97+
return nil
98+
}
99+
}
100+
101+
/// Runs the detector over each quota refresh and posts through the same notifier
102+
/// the update check uses. It adds no polling: the caller invokes it from the
103+
/// existing refresh lifecycle. The fired set lives in `UserDefaults` so a
104+
/// relaunch does not repeat a crossing already announced.
105+
@MainActor
106+
final class QuotaCrossingMonitor {
107+
static let defaultsKey = "codeburn.quota.crossingsFired"
108+
nonisolated static let notificationIdentifierPrefix = "QuotaCrossing."
109+
110+
private let defaults: UserDefaults
111+
private let makeNotifier: () -> any UpdateNotifier
112+
private var notifier: (any UpdateNotifier)?
113+
114+
init(
115+
defaults: UserDefaults = .standard,
116+
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() }
117+
) {
118+
self.defaults = defaults
119+
self.makeNotifier = makeNotifier
120+
}
121+
122+
@discardableResult
123+
func record(windows: [QuotaCrossingWindow]) async -> [QuotaCrossingEvent] {
124+
let stored = Set(defaults.stringArray(forKey: Self.defaultsKey) ?? [])
125+
let (events, fired) = QuotaCrossingDetector.evaluate(windows: windows, fired: stored)
126+
// Persisted before delivery: a crossing is one-shot even when the user
127+
// has notifications off or denied.
128+
defaults.set(Array(fired), forKey: Self.defaultsKey)
129+
130+
guard !events.isEmpty, QuotaCrossingPreference.isEnabled(defaults: defaults) else { return [] }
131+
let notifier = notifier ?? makeNotifier()
132+
self.notifier = notifier
133+
// Authorization is requested here and nowhere else, so a user who never
134+
// crosses a threshold is never asked.
135+
guard await notifier.requestAuthorizationIfNeeded() else { return [] }
136+
for event in events {
137+
notifier.post(
138+
title: event.notificationTitle,
139+
body: "",
140+
identifier: Self.notificationIdentifierPrefix + event.key
141+
)
142+
}
143+
return events
144+
}
145+
}

‎mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,3 +703,8 @@
703703
"%lldd ago" = "%lldd ago";
704704
"in under a minute" = "in under a minute";
705705
"Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure." = "Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure.";
706+
707+
/* MARK: Quota crossing notifications */
708+
"%@ · %@ at 80%%" = "%@ · %@ at 80%%";
709+
"%@ · %@ limit reached" = "%@ · %@ limit reached";
710+
"Quota crossings (%lld%% and %lld%%)" = "Quota crossings (%lld%% and %lld%%)";

‎mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,8 @@
695695
"%lldd ago" = "%lld 天前";
696696
"in under a minute" = "不到一分钟后";
697697
"Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure." = "今天从该服务商提示缓存中复用的输入 Token。不是新的输入(arrow.down),也不是缓存写入,并且已按缓存读取费率计入已消耗金额。";
698+
699+
/* MARK: Quota crossing notifications */
700+
"%@ · %@ at 80%%" = "%@ · %@ 已达 80%%";
701+
"%@ · %@ limit reached" = "%@ · %@ 已达上限";
702+
"Quota crossings (%lld%% and %lld%%)" = "配额跨越(%lld%% 与 %lld%%)";

‎mac/Sources/CodeBurnMenubar/Views/SettingsView.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,9 @@ private struct GeneralSettingsTab: View {
399399
@AppStorage(EarlyQuotaResetPreference.defaultsKey)
400400
private var notifyAboutEarlyResets: Bool = true
401401

402+
@AppStorage(QuotaCrossingPreference.defaultsKey)
403+
private var notifyAboutQuotaCrossings: Bool = true
404+
402405
private let costPresets: Set<Double> = [25, 50, 100, 200, 500]
403406
private let tokenPresets: Set<Double> = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000]
404407

@@ -555,6 +558,7 @@ private struct GeneralSettingsTab: View {
555558
Text(L("Posts a notification when a provider resets a usage limit before its scheduled time, so you know the capacity is back. The Capacity Dock shows the same notice for 12 hours either way."))
556559
.font(.system(size: 11))
557560
.foregroundStyle(.secondary)
561+
Toggle(L("Quota crossings (%lld%% and %lld%%)", 80, 100), isOn: $notifyAboutQuotaCrossings)
558562
}
559563

560564
Section(L("Terminal")) {

0 commit comments

Comments
 (0)