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.
This commit is contained in:
iamtoruk 2026-09-13 13:55:10 -07:00
parent 09ba2d1569
commit a496cf646d
8 changed files with 363 additions and 4 deletions

View file

@ -9,6 +9,7 @@
### Added (macOS)
- **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.
- **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.
- **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)
- **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)
- **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)

View file

@ -182,6 +182,7 @@ final class AppStore {
var earlyResetEvents: [String: EarlyQuotaResetEvent] = [:]
var earlyResetHistory: [EarlyQuotaResetHistory.Summary] = []
@ObservationIgnored var earlyQuotaResetMonitor = EarlyQuotaResetMonitor()
@ObservationIgnored var quotaCrossingMonitor = QuotaCrossingMonitor()
var codexUsage: CodexUsage?
var codexError: String?
@ -2172,10 +2173,15 @@ final class AppStore {
let warnings: [QuotaWarning] // sorted desc by percent
}
var aggregateQuotaStatus: AggregateQuotaStatus {
var providers: [QuotaWarning] = []
func include(_ name: String, _ windows: [QuotaWarning.Candidate]) {
if let worst = QuotaWarning.worst(name: name, windows: windows) { providers.append(worst) }
/// Every connected provider's quota windows, flattened. The warning banner
/// and the crossing notifier read the same list, so the two can never
/// disagree about what a provider is reporting.
var quotaWindows: [QuotaCrossingWindow] {
var windows: [QuotaCrossingWindow] = []
func include(_ name: String, _ candidates: [QuotaWarning.Candidate]) {
windows += candidates.map {
QuotaCrossingWindow(providerName: name, label: $0.label, percent: $0.percent, resetsAt: $0.resetsAt)
}
}
if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
// Labelled as `claudeQuotaSummary` labels them, so the warning row
@ -2217,6 +2223,19 @@ final class AppStore {
QuotaWarning.Candidate(label: $0.label, percent: $0.usedPercent, resetsAt: $0.resetsAt)
})
}
return windows
}
var aggregateQuotaStatus: AggregateQuotaStatus {
var order: [String] = []
var byProvider: [String: [QuotaWarning.Candidate]] = [:]
for window in quotaWindows {
if byProvider[window.providerName] == nil { order.append(window.providerName) }
byProvider[window.providerName, default: []].append(
.init(label: window.label, percent: window.percent, resetsAt: window.resetsAt)
)
}
let providers = order.compactMap { QuotaWarning.worst(name: $0, windows: byProvider[$0] ?? []) }
let result = QuotaWarningPresentation.aggregate(providers)
return AggregateQuotaStatus(severity: result.severity, warnings: result.warnings)
}

View file

@ -671,6 +671,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
case (false, false):
break
}
await store.quotaCrossingMonitor.record(windows: store.quotaWindows)
return true
}

View file

@ -0,0 +1,145 @@
import Foundation
/// User preference for quota-crossing notifications. Absent key is true,
/// matching `UpdateNotificationPreference`: existing installs get the alert
/// without visiting Settings first.
enum QuotaCrossingPreference {
static let defaultsKey = "codeburn.quota.crossingNotificationsEnabled"
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
defaults.object(forKey: defaultsKey) as? Bool ?? true
}
}
/// One provider window as `AppStore.quotaWindows` reports it: the same list the
/// warning banner and the menu-bar flame are built from.
struct QuotaCrossingWindow: Equatable, Sendable {
let providerName: String
/// The adapter's own label ("5-hour", "Weekly · Opus"), never translated.
let label: String
/// Share of the window used, 0...100; nil when the provider did not report it.
let percent: Double?
let resetsAt: Date?
/// One cycle of one window. A window that carries no reset instant cannot
/// tell one cycle from the next, so it notifies once and then stays quiet.
var cycleKey: String {
let stamp = resetsAt.map { String(Int($0.timeIntervalSince1970.rounded())) } ?? "-"
return "\(providerName)|\(label)|\(stamp)"
}
}
struct QuotaCrossingEvent: Equatable, Sendable {
enum Level: String, Sendable {
case warning
case limit
}
let providerName: String
let windowLabel: String
let level: Level
/// Fired-set entry and notification identifier: one per level per cycle.
let key: String
var notificationTitle: String {
switch level {
case .warning: L("%@ · %@ at 80%%", providerName, windowLabel)
case .limit: L("%@ · %@ limit reached", providerName, windowLabel)
}
}
}
/// Decides which windows crossed a notify-worthy threshold. Pure: the fired set
/// goes in and comes back out, so the caller owns persistence and nothing here
/// reads a clock or a default.
enum QuotaCrossingDetector {
static let warningPercent: Double = 80
static let limitPercent: Double = 100
static func evaluate(
windows: [QuotaCrossingWindow],
fired: Set<String>
) -> (events: [QuotaCrossingEvent], fired: Set<String>) {
var live = fired
for window in windows {
// A window now on a different cycle is re-armed; absence is not a
// reset, so a provider between fetches keeps what it has fired.
let prefix = "\(window.providerName)|\(window.label)|"
let current = window.cycleKey + "|"
live = live.filter { !$0.hasPrefix(prefix) || $0.hasPrefix(current) }
}
var events: [QuotaCrossingEvent] = []
for window in windows {
guard let percent = window.percent, percent.isFinite else { continue }
guard let level = level(for: percent) else { continue }
let key = "\(window.cycleKey)|\(level.rawValue)"
guard live.insert(key).inserted else { continue }
// Once the window is full the 80% notice is water under the bridge:
// marking it fired keeps a later reading from posting it after the
// louder one.
if level == .limit {
live.insert("\(window.cycleKey)|\(QuotaCrossingEvent.Level.warning.rawValue)")
}
events.append(QuotaCrossingEvent(
providerName: window.providerName,
windowLabel: window.label,
level: level,
key: key
))
}
return (events, live)
}
private static func level(for percent: Double) -> QuotaCrossingEvent.Level? {
if percent >= limitPercent { return .limit }
if percent >= warningPercent { return .warning }
return nil
}
}
/// Runs the detector over each quota refresh and posts through the same notifier
/// the update check uses. It adds no polling: the caller invokes it from the
/// existing refresh lifecycle. The fired set lives in `UserDefaults` so a
/// relaunch does not repeat a crossing already announced.
@MainActor
final class QuotaCrossingMonitor {
static let defaultsKey = "codeburn.quota.crossingsFired"
nonisolated static let notificationIdentifierPrefix = "QuotaCrossing."
private let defaults: UserDefaults
private let makeNotifier: () -> any UpdateNotifier
private var notifier: (any UpdateNotifier)?
init(
defaults: UserDefaults = .standard,
makeNotifier: @escaping () -> any UpdateNotifier = { SystemUpdateNotifier() }
) {
self.defaults = defaults
self.makeNotifier = makeNotifier
}
@discardableResult
func record(windows: [QuotaCrossingWindow]) async -> [QuotaCrossingEvent] {
let stored = Set(defaults.stringArray(forKey: Self.defaultsKey) ?? [])
let (events, fired) = QuotaCrossingDetector.evaluate(windows: windows, fired: stored)
// Persisted before delivery: a crossing is one-shot even when the user
// has notifications off or denied.
defaults.set(Array(fired), forKey: Self.defaultsKey)
guard !events.isEmpty, QuotaCrossingPreference.isEnabled(defaults: defaults) else { return [] }
let notifier = notifier ?? makeNotifier()
self.notifier = notifier
// Authorization is requested here and nowhere else, so a user who never
// crosses a threshold is never asked.
guard await notifier.requestAuthorizationIfNeeded() else { return [] }
for event in events {
notifier.post(
title: event.notificationTitle,
body: "",
identifier: Self.notificationIdentifierPrefix + event.key
)
}
return events
}
}

View file

@ -703,3 +703,8 @@
"%lldd ago" = "%lldd ago";
"in under a minute" = "in under a minute";
"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.";
/* MARK: Quota crossing notifications */
"%@ · %@ at 80%%" = "%@ · %@ at 80%%";
"%@ · %@ limit reached" = "%@ · %@ limit reached";
"Quota crossings (%lld%% and %lld%%)" = "Quota crossings (%lld%% and %lld%%)";

View file

@ -695,3 +695,8 @@
"%lldd ago" = "%lld 天前";
"in under a minute" = "不到一分钟后";
"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),也不是缓存写入,并且已按缓存读取费率计入已消耗金额。";
/* MARK: Quota crossing notifications */
"%@ · %@ at 80%%" = "%@ · %@ 已达 80%%";
"%@ · %@ limit reached" = "%@ · %@ 已达上限";
"Quota crossings (%lld%% and %lld%%)" = "配额跨越(%lld%% 与 %lld%%)";

View file

@ -399,6 +399,9 @@ private struct GeneralSettingsTab: View {
@AppStorage(EarlyQuotaResetPreference.defaultsKey)
private var notifyAboutEarlyResets: Bool = true
@AppStorage(QuotaCrossingPreference.defaultsKey)
private var notifyAboutQuotaCrossings: Bool = true
private let costPresets: Set<Double> = [25, 50, 100, 200, 500]
private let tokenPresets: Set<Double> = [1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000, 100_000_000]
@ -555,6 +558,7 @@ private struct GeneralSettingsTab: View {
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."))
.font(.system(size: 11))
.foregroundStyle(.secondary)
Toggle(L("Quota crossings (%lld%% and %lld%%)", 80, 100), isOn: $notifyAboutQuotaCrossings)
}
Section(L("Terminal")) {

View file

@ -0,0 +1,179 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
private let resetsAt = Date(timeIntervalSince1970: 1_800_000_000)
private func window(
_ percent: Double?,
provider: String = "Claude",
label: String = "Weekly",
resetsAt: Date? = resetsAt
) -> QuotaCrossingWindow {
QuotaCrossingWindow(providerName: provider, label: label, percent: percent, resetsAt: resetsAt)
}
@Suite("Quota crossing detection")
struct QuotaCrossingDetectorTests {
@Test("A window under 80% says nothing")
func belowThresholdIsSilent() {
let result = QuotaCrossingDetector.evaluate(windows: [window(79.9)], fired: [])
#expect(result.events.isEmpty)
#expect(result.fired.isEmpty)
}
@Test("Crossing 80% fires once, and not again on the next fetch")
func warningFiresOnce() {
let first = QuotaCrossingDetector.evaluate(windows: [window(81)], fired: [])
#expect(first.events.count == 1)
#expect(first.events[0].level == .warning)
#expect(first.events[0].notificationTitle == "Claude · Weekly at 80%")
let second = QuotaCrossingDetector.evaluate(windows: [window(85)], fired: first.fired)
#expect(second.events.isEmpty)
#expect(second.fired == first.fired)
}
@Test("100% fires even when 80% was never observed, and never fires twice")
func limitFiresWithoutAWarning() {
let first = QuotaCrossingDetector.evaluate(windows: [window(100)], fired: [])
#expect(first.events.count == 1)
#expect(first.events[0].level == .limit)
#expect(first.events[0].notificationTitle == "Claude · Weekly limit reached")
let second = QuotaCrossingDetector.evaluate(windows: [window(100)], fired: first.fired)
#expect(second.events.isEmpty)
}
@Test("A window that crossed 80% still announces the limit")
func warningThenLimit() {
let warning = QuotaCrossingDetector.evaluate(windows: [window(82)], fired: [])
let limit = QuotaCrossingDetector.evaluate(windows: [window(100)], fired: warning.fired)
#expect(limit.events.map(\.level) == [.limit])
}
@Test("A new reset instant re-arms both thresholds")
func resetReArms() {
let first = QuotaCrossingDetector.evaluate(windows: [window(90)], fired: [])
let nextCycle = window(90, resetsAt: resetsAt.addingTimeInterval(7 * 24 * 3600))
let second = QuotaCrossingDetector.evaluate(windows: [nextCycle], fired: first.fired)
#expect(second.events.map(\.level) == [.warning])
// The old cycle's entry is dropped rather than kept forever.
#expect(second.fired.count == 1)
}
@Test("A window missing from this fetch keeps what it has already fired")
func absenceIsNotAReset() {
let first = QuotaCrossingDetector.evaluate(windows: [window(90)], fired: [])
let elsewhere = QuotaCrossingDetector.evaluate(
windows: [window(10, provider: "Codex", label: "5-hour")],
fired: first.fired
)
let back = QuotaCrossingDetector.evaluate(windows: [window(90)], fired: elsewhere.fired)
#expect(back.events.isEmpty)
}
@Test("Each provider window is judged on its own")
func providersAreIndependent() {
let result = QuotaCrossingDetector.evaluate(
windows: [window(85), window(100, provider: "Codex", label: "5-hour"), window(12, label: "Weekly · Opus")],
fired: []
)
#expect(result.events.count == 2)
#expect(result.events.contains { $0.providerName == "Codex" && $0.level == .limit })
}
@Test("A window with no reported percent is no opinion")
func missingPercentIsSilent() {
let result = QuotaCrossingDetector.evaluate(
windows: [window(nil), window(.nan, label: "Weekly · Opus")],
fired: []
)
#expect(result.events.isEmpty)
}
}
@Suite("Quota crossing notifications")
@MainActor
struct QuotaCrossingMonitorTests {
@Test("A crossing posts once and asks for authorization only then")
func postsOnce() async throws {
try await withMonitor { monitor, notifier, _ in
await monitor.record(windows: [window(50)])
#expect(notifier.posts.isEmpty)
#expect(notifier.authorizationRequests == 0)
await monitor.record(windows: [window(82)])
#expect(notifier.posts.map(\.title) == ["Claude · Weekly at 80%"])
#expect(notifier.authorizationRequests == 1)
await monitor.record(windows: [window(84)])
#expect(notifier.posts.count == 1)
}
}
@Test("The fired set survives a relaunch")
func firedSetPersists() async throws {
try await withMonitor { monitor, notifier, defaults in
await monitor.record(windows: [window(100)])
#expect(notifier.posts.count == 1)
let relaunched = QuotaCrossingMonitor(defaults: defaults, makeNotifier: { notifier })
await relaunched.record(windows: [window(100)])
#expect(notifier.posts.count == 1)
}
}
@Test("With the toggle off nothing is posted")
func toggleOffIsSilent() async throws {
try await withMonitor { monitor, notifier, defaults in
defaults.set(false, forKey: QuotaCrossingPreference.defaultsKey)
await monitor.record(windows: [window(100)])
#expect(notifier.posts.isEmpty)
#expect(notifier.authorizationRequests == 0)
}
}
@Test("A denied authorization posts nothing and does not re-announce later")
func deniedAuthorizationStaysQuiet() async throws {
try await withMonitor { monitor, notifier, _ in
notifier.authorized = false
await monitor.record(windows: [window(100)])
#expect(notifier.posts.isEmpty)
notifier.authorized = true
await monitor.record(windows: [window(100)])
#expect(notifier.posts.isEmpty)
}
}
}
@MainActor
private func withMonitor(
_ body: @MainActor (QuotaCrossingMonitor, RecordingCrossingNotifier, UserDefaults) async throws -> Void
) async throws {
let suiteName = "codeburn.quota.crossing.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let notifier = RecordingCrossingNotifier()
let monitor = QuotaCrossingMonitor(defaults: defaults, makeNotifier: { notifier })
try await body(monitor, notifier, defaults)
}
@MainActor
private final class RecordingCrossingNotifier: UpdateNotifier {
var authorized = true
var authorizationRequests = 0
var posts: [(title: String, body: String, identifier: String)] = []
func requestAuthorizationIfNeeded() async -> Bool {
authorizationRequests += 1
return authorized
}
func post(title: String, body: String, identifier: String) {
posts.append((title, body, identifier))
}
}