feat(bar-app): quota gauges, threshold alerts, configurable glance

Add real quota gauges with threshold bands and a reset countdown for
quota-capable accounts. Introduce a pure, deterministic alert rule engine
(CCSBarCore) covering quota-remaining, daily/monthly spend caps, reauth, and
cooldown, with per-rule dedupe keys that re-arm on period rollover or
clears-then-recurs so it never spams. Deliver via UNUserNotificationCenter
with an in-dropdown Alerts fallback when notifications are denied. Add a
preferences surface (per-rule toggles, caps, quota levels, glance mode)
persisted in UserDefaults, and a configurable menu-bar glance
(auto/today/month/lowest-quota/account-count) that still never shows a
lifetime dollar. Default spend caps raised to $500/day and $10000/month.
This commit is contained in:
Tam Nhu Tran
2026-06-09 13:03:44 -04:00
parent 68f0231f48
commit 559d4340e7
12 changed files with 1435 additions and 12 deletions
+131 -9
View File
@@ -7,6 +7,11 @@ import CCSBarCore
/// footer controls.
struct BarMenuView: View {
@ObservedObject var viewModel: BarViewModel
/// Drives the preferences sheet from the footer gear.
@State private var showingPrefs = false
/// The prefs adapter the sheet edits; shares the standard suite with the
/// view model so a write-through is visible on the next poll.
private let prefs = BarPreferences()
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -25,6 +30,17 @@ struct BarMenuView: View {
BarAnalyticsView(analytics: analytics)
}
// In-dropdown alert list: surfaces the conditions the engine flagged
// this poll so users who deny system notifications still see them.
if !viewModel.activeAlerts.isEmpty {
VStack(alignment: .leading, spacing: 6) {
SectionLabel("Alerts")
ForEach(viewModel.activeAlerts) { alert in
AlertRow(alert: alert)
}
}
}
VStack(alignment: .leading, spacing: 6) {
SectionLabel("Accounts")
if let error = viewModel.lastError {
@@ -55,6 +71,9 @@ struct BarMenuView: View {
}
.frame(width: 340)
.onAppear { viewModel.onOpen() }
.sheet(isPresented: $showingPrefs) {
BarPreferencesView(viewModel: viewModel, prefs: prefs)
}
}
private var header: some View {
@@ -101,6 +120,12 @@ struct BarMenuView: View {
)
}
.help("Toggle the menu-bar icon between color and monochrome")
Button {
showingPrefs = true
} label: {
Label("Alerts", systemImage: "bell.badge")
}
.help("Configure alerts and the menu-bar glance")
Spacer()
Button {
viewModel.onOpen()
@@ -165,9 +190,10 @@ struct BarRowView: View {
HStack(spacing: 6) {
Chip(row.provider, tint: BarTheme.accent)
if let tier = row.tier { Chip(tier, tint: .secondary) }
Text(BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus))
.font(.caption2)
.foregroundStyle(quotaColor)
QuotaGaugeView(
percentage: row.quotaPercentage,
status: row.quotaStatus,
nextReset: row.nextReset)
}
if let lastActive = BarFormatting.lastActiveLabel(
iso: row.lastActivityAt, daysSince: nil)
@@ -245,13 +271,62 @@ struct BarRowView: View {
default: return .green
}
}
}
/// Quota label color: muted for "no quota", warning-tinted for "quota ?".
private var quotaColor: Color {
switch row.quotaStatus {
case "unsupported": return .secondary
case "error": return .orange
default: return .secondary
/// Per-account quota gauge. When the row has a live "ok" quota with a percentage,
/// renders a thin colored bar (filled by the remaining fraction, tinted by the
/// severity band) plus a "resets in " caption. When there is no live quota it
/// falls back to the honest text label ("no quota" / "quota ?"). All branch,
/// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view
/// is a thin render.
struct QuotaGaugeView: View {
let percentage: Double?
let status: String
let nextReset: String?
var body: some View {
let band = BarQuotaGauge.band(percentage: percentage, status: status)
if band != .none, let fill = BarQuotaGauge.fillFraction(percentage: percentage, status: status) {
HStack(spacing: 5) {
gaugeBar(fill: fill, color: color(for: band))
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(color(for: band))
if let countdown = BarQuotaGauge.resetCountdown(nextReset: nextReset, now: Date()) {
Text(countdown)
.font(.caption2)
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
} else {
// No live quota: keep the existing honest text ("no quota" / "quota ?").
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func gaugeBar(fill: Double, color: Color) -> some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(Color.primary.opacity(0.12))
Capsule()
.fill(color)
.frame(width: max(2, geo.size.width * fill))
}
}
.frame(width: 44, height: 5)
}
private func color(for band: BarQuotaGauge.Band) -> Color {
switch band {
case .green: return .green
case .yellow: return .yellow
case .orange: return .orange
case .red: return .red
case .none: return .secondary
}
}
}
@@ -276,6 +351,53 @@ struct ErrorBanner: View {
}
}
/// One in-dropdown alert row. Mirrors a delivered notification so the conditions
/// are visible even when system notifications are denied. The icon is keyed off
/// the alert kind so each rule reads at a glance.
struct AlertRow: View {
let alert: BarNotification
var body: some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: icon)
.foregroundStyle(tint)
.font(.caption)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 1) {
Text(alert.title)
.font(.caption.weight(.medium))
Text(alert.body)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
}
private var icon: String {
switch alert.kind {
case .quotaRemainingBelow: return "gauge.with.dots.needle.bottom.0percent"
case .dailySpendAbove, .monthSpendAbove: return "dollarsign.circle"
case .reauthNeeded: return "key.slash"
case .accountCooldownOrPaused: return "pause.circle"
}
}
private var tint: Color {
switch alert.kind {
case .quotaRemainingBelow: return .orange
case .dailySpendAbove, .monthSpendAbove: return BarTheme.accent
case .reauthNeeded: return .red
case .accountCooldownOrPaused: return .secondary
}
}
}
/// Small pill label used in account sublines.
struct Chip: View {
let text: String
@@ -0,0 +1,84 @@
import Foundation
import UserNotifications
import CCSBarCore
/// Real notification delivery backed by `UNUserNotificationCenter`.
///
/// Authorization is requested LAZILY on the first non-empty deliver, not at
/// launch. Ad-hoc-signed menu-bar apps launched via `open` get a flaky / silently
/// dropped prompt when authorization is requested during startup, so we defer the
/// request until there is actually something to show the prompt then lands with
/// user-visible context.
///
/// When authorization is denied, `deliver` is a no-op, but the rule engine keeps
/// updating its fired-keys regardless (the App persists them independent of
/// delivery). That avoids a backlog of stale alerts replaying if the user later
/// grants permission only conditions still true at that later poll re-fire.
@MainActor
final class BarNotifier: NotificationDelivering {
enum AuthState {
case unknown
case authorized
case denied
}
private let center: UNUserNotificationCenter?
private var authState: AuthState = .unknown
private var didRequest = false
/// `UNUserNotificationCenter.current()` traps when there is no main bundle
/// identifier (e.g. a bare `swift run` with no .app wrapper). Guard it so the
/// wiring still compiles and runs headlessly; delivery is simply a no-op there.
init() {
if Bundle.main.bundleIdentifier != nil {
center = UNUserNotificationCenter.current()
} else {
center = nil
}
}
/// Deliver one notification. The first call with a notification triggers a
/// one-time authorization request; subsequent calls reuse the cached state.
nonisolated func deliver(_ notification: BarNotification) {
Task { @MainActor in
self.send(notification)
}
}
private func send(_ notification: BarNotification) {
guard let center else { return }
if !didRequest {
didRequest = true
// Request once, lazily. The completion updates cached state; this in-flight
// notification is enqueued after, so an accepted prompt still shows it.
center.requestAuthorization(options: [.alert, .sound]) { [weak self] granted, _ in
// Hop back to the main actor and re-read `self.center` there rather than
// capturing the non-Sendable center across the closure boundary.
Task { @MainActor in
guard let self else { return }
self.authState = granted ? .authorized : .denied
if granted, let c = self.center { self.post(notification, on: c) }
}
}
return
}
// Already requested: post only when authorized; a denied state is a no-op.
if authState == .authorized || authState == .unknown {
post(notification, on: center)
}
}
private func post(_ notification: BarNotification, on center: UNUserNotificationCenter) {
let content = UNMutableNotificationContent()
content.title = notification.title
content.body = notification.body
content.sound = .default
// Identifier == fired-key so the OS de-dupes at the delivery layer too: a
// re-posted same-key request replaces rather than stacks.
let request = UNNotificationRequest(
identifier: notification.id, content: content, trigger: nil)
center.add(request, withCompletionHandler: nil)
}
}
@@ -0,0 +1,62 @@
import Foundation
import CCSBarCore
/// Live UserDefaults adapter for the alert/glance preferences. The pure
/// key/default/parse contract lives in Core (`BarAlertPrefsStore`); this type is
/// the thin App-side bridge that reads/writes the real defaults suite.
///
/// `register(defaults:)` is called once at launch so that an absent Bool key does
/// NOT read back as `false` (which would silently disable every alert on a fresh
/// install). All reads go through `load()`; all writes go through `save(_:)`.
struct BarPreferences {
let defaults: UserDefaults
/// Default to the standard suite. A stable suite name is intentionally NOT used
/// here because the rest of the app (MenuBarIcon) already persists to
/// `.standard`; keeping one suite avoids split state across the two.
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
/// Seed the registration domain so missing keys resolve to their real defaults
/// rather than the type-zero value. Idempotent safe to call on every launch.
func registerDefaults() {
defaults.register(defaults: BarAlertPrefsStore.registrationDefaults)
}
/// Read the current preferences. Pulls each key into a plain dictionary and
/// defers to the pure Core decoder so parsing/clamping stays in one place.
func load() -> BarAlertPrefs {
var dict: [String: Any] = [:]
// Only forward keys that are actually present; the Core decoder fills the
// rest from canonical defaults. `object(forKey:)` returns the registered
// default when nothing was explicitly written, which is exactly what we want.
dict[BarAlertPrefsStore.Key.quotaEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaEnabled)
dict[BarAlertPrefsStore.Key.quotaLevels] = defaults.object(forKey: BarAlertPrefsStore.Key.quotaLevels)
dict[BarAlertPrefsStore.Key.dailyEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyEnabled)
dict[BarAlertPrefsStore.Key.dailyCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.dailyCapUSD)
dict[BarAlertPrefsStore.Key.monthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.monthEnabled)
dict[BarAlertPrefsStore.Key.monthCapUSD] = defaults.object(forKey: BarAlertPrefsStore.Key.monthCapUSD)
dict[BarAlertPrefsStore.Key.reauthEnabled] = defaults.object(forKey: BarAlertPrefsStore.Key.reauthEnabled)
dict[BarAlertPrefsStore.Key.cooldownPausedEnabled] =
defaults.object(forKey: BarAlertPrefsStore.Key.cooldownPausedEnabled)
dict[BarAlertPrefsStore.Key.glanceMode] = defaults.object(forKey: BarAlertPrefsStore.Key.glanceMode)
return BarAlertPrefsStore.decode(from: dict.compactMapValues { $0 })
}
/// Persist the preferences, encoding levels comma-joined and mode as raw value.
func save(_ prefs: BarAlertPrefs) {
for (key, value) in BarAlertPrefsStore.encode(prefs) {
defaults.set(value, forKey: key)
}
}
// MARK: Fired-key engine state (NOT user-editable)
/// The engine's persisted fired-key set. Overwritten verbatim each poll with
/// `BarAlertEvaluation.firedKeys` so the stored set stays bounded.
var firedKeys: Set<String> {
get { Set(defaults.stringArray(forKey: BarAlertPrefsStore.Key.firedKeys) ?? []) }
nonmutating set { defaults.set(Array(newValue), forKey: BarAlertPrefsStore.Key.firedKeys) }
}
}
@@ -0,0 +1,178 @@
import SwiftUI
import CCSBarCore
/// Preferences sheet reachable from the dropdown footer. Lets the user pick the
/// menu-bar glance mode and toggle / tune each alert rule. Writes through to
/// UserDefaults on every change and tells the view model to re-read prefs so the
/// title updates live and the next poll re-evaluates with the new settings.
struct BarPreferencesView: View {
@ObservedObject var viewModel: BarViewModel
let prefs: BarPreferences
@Environment(\.dismiss) private var dismiss
// Local editable mirror of the persisted prefs. Loaded on appear; each change
// is written through immediately so there is no separate "save" step.
@State private var draft = BarAlertPrefs()
// Quota levels are edited as free text and parsed on commit; keeping the raw
// string in @State avoids fighting the user's keystrokes mid-edit.
@State private var levelsText = ""
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
Form {
glanceSection
quotaSection
spendSection
accountSection
deliveryHint
}
.formStyle(.grouped)
Divider()
footer
}
.frame(width: 360, height: 460)
.onAppear(perform: hydrate)
}
private var header: some View {
HStack(spacing: 8) {
Image(systemName: "bell.badge")
.foregroundStyle(BarTheme.accent)
Text("Alerts & Glance").font(.headline)
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
private var glanceSection: some View {
Section("Menu-bar glance") {
Picker("Show in menu bar", selection: $draft.glanceMode) {
ForEach(BarGlanceMode.allCases, id: \.self) { mode in
Text(glanceLabel(mode)).tag(mode)
}
}
.onChange(of: draft.glanceMode) { _ in writeThrough() }
}
}
private var quotaSection: some View {
Section("Quota") {
Toggle("Alert on low quota", isOn: $draft.quotaEnabled)
.onChange(of: draft.quotaEnabled) { _ in writeThrough() }
HStack {
Text("Levels (%)")
Spacer()
TextField("20,10,0", text: $levelsText)
.multilineTextAlignment(.trailing)
.frame(width: 120)
.onSubmit { commitLevels() }
}
.disabled(!draft.quotaEnabled)
Text("Fires once per account at the most-severe level crossed, then again after the next quota reset.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var spendSection: some View {
Section("Spend") {
Toggle("Alert on daily spend", isOn: $draft.dailySpendEnabled)
.onChange(of: draft.dailySpendEnabled) { _ in writeThrough() }
capRow(label: "Daily cap", value: $draft.dailyCapUSD, enabled: draft.dailySpendEnabled)
Toggle("Alert on monthly spend", isOn: $draft.monthSpendEnabled)
.onChange(of: draft.monthSpendEnabled) { _ in writeThrough() }
capRow(label: "Month cap", value: $draft.monthCapUSD, enabled: draft.monthSpendEnabled)
Text("Monthly cap measures calendar month-to-date, so it resets at the start of each billing month.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private var accountSection: some View {
Section("Account state") {
Toggle("Alert when an account needs re-auth", isOn: $draft.reauthEnabled)
.onChange(of: draft.reauthEnabled) { _ in writeThrough() }
Toggle("Alert when an account is paused / cooling down", isOn: $draft.cooldownPausedEnabled)
.onChange(of: draft.cooldownPausedEnabled) { _ in writeThrough() }
}
}
/// Always-present delivery note. We don't synchronously read the live UN
/// authorization state here (it's async and ad-hoc signing makes the prompt
/// land late), so rather than a flickering "denied" branch we tell the user
/// where alerts surface either way: as system notifications when allowed, and
/// always in the in-menu alert list.
private var deliveryHint: some View {
Section {
HStack(spacing: 6) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Alerts show as system notifications when allowed (System Settings Notifications) and always appear in the menu's Alerts list.")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func capRow(label: String, value: Binding<Double>, enabled: Bool) -> some View {
HStack {
Text(label)
Spacer()
Text("$")
.foregroundStyle(.secondary)
TextField("0", value: value, format: .number)
.multilineTextAlignment(.trailing)
.frame(width: 90)
.onSubmit { writeThrough() }
.onChange(of: value.wrappedValue) { _ in writeThrough() }
}
.disabled(!enabled)
}
private var footer: some View {
HStack {
Spacer()
Button("Done") { commitLevels(); dismiss() }
.keyboardShortcut(.defaultAction)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
}
// MARK: State plumbing
private func hydrate() {
draft = prefs.load()
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
}
/// Parse the free-text levels field into normalized ints, then persist.
private func commitLevels() {
let parsed = BarAlertPrefsStore.parseQuotaLevels(levelsText)
if !parsed.isEmpty { draft.quotaLevels = parsed }
// Reflect the normalized form back into the field so the user sees what stuck.
levelsText = BarAlertPrefsStore.encodeQuotaLevels(draft.quotaLevels)
writeThrough()
}
/// Save the current draft and ask the view model to re-read it so the live
/// title + next evaluation pick up the change.
private func writeThrough() {
prefs.save(draft)
viewModel.reloadPrefs()
}
private func glanceLabel(_ mode: BarGlanceMode) -> String {
switch mode {
case .auto: return "Auto (smart)"
case .todaySpend: return "Today's spend"
case .monthSpend: return "This month's spend"
case .lowestQuota: return "Lowest quota"
case .accountCount: return "Active account count"
}
}
}
+55 -3
View File
@@ -15,25 +15,53 @@ final class BarViewModel: ObservableObject {
@Published var iconStyle: BarIconStyle {
didSet { MenuBarIcon.saveStyle(iconStyle) }
}
/// Which figure leads the always-on title. Persisted; a change re-derives
/// `statusTitle` live because it is @Published.
@Published var glanceMode: BarGlanceMode
/// The alerts the most recent evaluation wanted delivered, surfaced in the
/// dropdown so users who deny notifications still see the conditions.
@Published var activeAlerts: [BarNotification] = []
private let home: String
private var client: CCSBarClient?
private var debouncer = RefreshDebouncer(interval: 15)
private let prefs: BarPreferences
private let notifier: NotificationDelivering
init(home: String = NSHomeDirectory()) {
init(
home: String = NSHomeDirectory(),
prefs: BarPreferences = BarPreferences(),
notifier: NotificationDelivering? = nil
) {
self.home = home
self.prefs = prefs
// Seed registration defaults before the first pref read. Idempotent, and the
// App-level call is a redundant safety net for the @StateObject default-init
// ordering (stored-property defaults run before the App.init body).
prefs.registerDefaults()
// Default to the real UN-backed notifier; tests inject a recording one.
self.notifier = notifier ?? BarNotifier()
self.iconStyle = MenuBarIcon.loadStyle()
self.glanceMode = prefs.load().glanceMode
reconnect()
}
/// Re-read prefs after the preferences sheet writes through, so the next poll
/// (and the live title) reflects the change immediately.
func reloadPrefs() {
glanceMode = prefs.load().glanceMode
}
/// Toggle the menu-bar icon between the color mark and the mono template.
func toggleIconStyle() {
iconStyle = (iconStyle == .color) ? .mono : .color
}
/// Compact status-bar title.
/// Compact status-bar title, resolved through the user's chosen glance mode.
var statusTitle: String {
offline ? "CCS offline" : BarFormatting.statusTitle(rows: rows, analytics: analytics)
offline
? "CCS offline"
: BarFormatting.statusTitle(rows: rows, analytics: analytics, mode: glanceMode)
}
/// Resolve the discovery file and (re)build the client. Marks offline when
@@ -85,6 +113,30 @@ final class BarViewModel: ObservableObject {
if let fresh = try? await client.analytics() {
analytics = fresh
}
evaluateAlerts()
}
/// Run the pure rule engine once per poll against the freshly-loaded state,
/// deliver any new notifications, and overwrite the persisted fired-key set
/// verbatim. The engine's dedupe means repeated polls with unchanged state
/// deliver nothing, so this never spams. Delivery is best-effort and never
/// blocks the UI (the notifier hops to its own task).
private func evaluateAlerts() {
let current = prefs.load()
let eval = BarAlertEngine.evaluate(
rows: rows,
analytics: analytics,
prefs: current,
priorFiredKeys: prefs.firedKeys,
now: Date())
for notification in eval.toDeliver {
notifier.deliver(notification)
}
// Persist the COMPLETE next-state set verbatim (no merge) this is what keeps
// the stored set bounded and lets cleared conditions re-arm.
prefs.firedKeys = eval.firedKeys
activeAlerts = eval.toDeliver
}
// MARK: Account actions
@@ -7,6 +7,13 @@ import SwiftUI
struct CCSBarApp: App {
@StateObject private var viewModel = BarViewModel()
init() {
// Seed the registration domain BEFORE any pref read so absent Bool keys
// resolve to their real defaults (true) instead of reading back as false,
// which would silently disable every alert on a fresh install.
BarPreferences().registerDefaults()
}
var body: some Scene {
MenuBarExtra {
BarMenuView(viewModel: viewModel)
+417
View File
@@ -368,6 +368,423 @@ do {
check(false, "surface analytics decode threw: \(error)")
}
// MARK: monthToDate decode (calendar MTD field, resilient default)
do {
// Older payload WITHOUT monthToDate decodes to a zero window (back-compat).
let a = try JSONDecoder().decode(BarAnalytics.self, from: analyticsJSON)
check(a.monthToDate.cost == 0, "monthToDate defaults to 0 cost when absent from payload")
check(a.monthToDate.requests == 0, "monthToDate defaults to 0 requests when absent")
}
do {
// Newer payload WITH monthToDate decodes the real value, distinct from last30d.
let mtdJSON = """
{"today":{"cost":0,"requests":0},"last7d":{"cost":0,"requests":0},
"last30d":{"cost":35620.0,"requests":70973},"monthToDate":{"cost":1200.5,"requests":4096},
"allTime":{"cost":40844.0,"requests":91207},
"byDay":[\(buildByDayJSON(count: 30))],
"topModels":[],"topModelsWindow":"30d","hasRecentData":true,
"generatedAt":"2026-06-09T00:00:00.000Z"}
""".data(using: .utf8)!
let a = try JSONDecoder().decode(BarAnalytics.self, from: mtdJSON)
check(a.monthToDate.cost == 1200.5, "monthToDate decodes its own cost")
check(a.monthToDate.requests == 4096, "monthToDate decodes its own requests")
check(a.monthToDate.cost != a.last30d.cost, "monthToDate is distinct from rolling last30d")
}
// MARK: BarQuotaGauge band + fillFraction + countdown
check(BarQuotaGauge.band(percentage: 82, status: "ok") == .green, "band >50 -> green")
check(BarQuotaGauge.band(percentage: 51, status: "ok") == .green, "band 51 -> green (boundary)")
check(BarQuotaGauge.band(percentage: 50, status: "ok") == .yellow, "band 50 -> yellow (boundary)")
check(BarQuotaGauge.band(percentage: 21, status: "ok") == .yellow, "band 21 -> yellow")
check(BarQuotaGauge.band(percentage: 20, status: "ok") == .orange, "band 20 -> orange (boundary)")
check(BarQuotaGauge.band(percentage: 11, status: "ok") == .orange, "band 11 -> orange")
check(BarQuotaGauge.band(percentage: 10, status: "ok") == .red, "band 10 -> red (boundary)")
check(BarQuotaGauge.band(percentage: 0, status: "ok") == .red, "band 0 -> red")
check(BarQuotaGauge.band(percentage: nil, status: "ok") == .none, "band nil pct -> none")
check(BarQuotaGauge.band(percentage: 50, status: "unsupported") == .none, "band unsupported -> none")
check(BarQuotaGauge.band(percentage: 50, status: "error") == .none, "band error -> none")
check(BarQuotaGauge.fillFraction(percentage: 82, status: "ok") == 0.82, "fillFraction 82 -> 0.82")
check(BarQuotaGauge.fillFraction(percentage: 0, status: "ok") == 0.0, "fillFraction 0 -> 0")
check(BarQuotaGauge.fillFraction(percentage: 150, status: "ok") == 1.0, "fillFraction clamps >100 to 1")
check(BarQuotaGauge.fillFraction(percentage: nil, status: "ok") == nil, "fillFraction nil pct -> nil")
check(
BarQuotaGauge.fillFraction(percentage: 50, status: "unsupported") == nil,
"fillFraction unsupported -> nil")
do {
let now = Date(timeIntervalSince1970: 1_000_000)
let in3h12m = now.addingTimeInterval(3 * 3600 + 12 * 60)
let iso = ISO8601DateFormatter().string(from: in3h12m)
check(
BarQuotaGauge.resetCountdown(nextReset: iso, now: now) == "resets in 3h 12m",
"resetCountdown formats hours+minutes")
let in12m = now.addingTimeInterval(12 * 60)
let iso12 = ISO8601DateFormatter().string(from: in12m)
check(
BarQuotaGauge.resetCountdown(nextReset: iso12, now: now) == "resets in 12m",
"resetCountdown formats minutes-only")
let past = now.addingTimeInterval(-60)
let isoPast = ISO8601DateFormatter().string(from: past)
check(
BarQuotaGauge.resetCountdown(nextReset: isoPast, now: now) == "resets soon",
"resetCountdown past -> 'resets soon'")
check(BarQuotaGauge.resetCountdown(nextReset: nil, now: now) == nil, "resetCountdown nil -> nil")
check(
BarQuotaGauge.resetCountdown(nextReset: "not-a-date", now: now) == nil,
"resetCountdown unparseable -> nil")
}
// MARK: Glance-mode title resolution (lifetime dollar NEVER appears)
do {
let allTimeBig = mkAnalytics(allTimeCost: 40844, todayCost: 0) // allTime is the largest window
let lifetime = BarFormatting.money(allTimeBig.allTime.cost)
let rows = [
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported"),
]
// For EVERY mode, the title must never be the lifetime figure.
for mode in BarGlanceMode.allCases {
let t = BarFormatting.statusTitle(rows: rows, analytics: allTimeBig, mode: mode)
check(t != lifetime, "glance mode \(mode.rawValue): title never == lifetime dollar")
}
// .todaySpend leads with today when > 0, else falls back to .auto chain.
let todayA = mkAnalytics(allTimeCost: 40844, todayCost: 3.2)
check(
BarFormatting.statusTitle(rows: rows, analytics: todayA, mode: .todaySpend) == "$3.20",
"glance .todaySpend leads with today's spend")
check(
BarFormatting.statusTitle(rows: rows, analytics: allTimeBig, mode: .todaySpend) == "CCS 2",
"glance .todaySpend falls back to .auto when today==0")
// .monthSpend leads with monthToDate when > 0, else falls back.
func mkMonth(_ mtd: Double, today: Double = 0) -> BarAnalytics {
BarAnalytics(
today: .init(cost: today, requests: 0),
last7d: .init(cost: 0, requests: 0),
last30d: .init(cost: 35000, requests: 0),
monthToDate: .init(cost: mtd, requests: 0),
allTime: .init(cost: 40844, requests: 0),
byDay: [], topModels: [], topModelsWindow: "30d",
generatedAt: "2026-06-09T00:00:00Z")
}
check(
BarFormatting.statusTitle(rows: rows, analytics: mkMonth(1200.5), mode: .monthSpend)
== BarFormatting.money(1200.5),
"glance .monthSpend leads with calendar MTD")
check(
BarFormatting.statusTitle(rows: rows, analytics: mkMonth(0), mode: .monthSpend) == "CCS 2",
"glance .monthSpend falls back to .auto when MTD==0")
// MTD mode must NOT leak last30d (35000) even though it is far larger.
let mtdTitle = BarFormatting.statusTitle(rows: rows, analytics: mkMonth(1200.5), mode: .monthSpend)
check(!mtdTitle.contains("35"), "glance .monthSpend never shows last30d figure")
// .lowestQuota leads with the lowest ok-quota row, else falls back.
let quotaRows = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 90, quotaStatus: "ok"),
BarSummaryRow(accountId: "b", provider: "agy", quotaPercentage: 12, quotaStatus: "ok"),
]
check(
BarFormatting.statusTitle(rows: quotaRows, analytics: nil, mode: .lowestQuota) == "agy 12%",
"glance .lowestQuota leads with lowest remaining quota")
check(
BarFormatting.statusTitle(rows: rows, analytics: allTimeBig, mode: .lowestQuota) == "CCS 2",
"glance .lowestQuota falls back to .auto with no ok-quota rows")
// .accountCount = non-paused count, never appends "!".
let pausedMix = [
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "unsupported"),
BarSummaryRow(accountId: "b", provider: "kiro", paused: true, quotaStatus: "unsupported"),
]
check(
BarFormatting.statusTitle(rows: pausedMix, analytics: nil, mode: .accountCount) == "CCS 1",
"glance .accountCount = non-paused count")
let allPaused = [
BarSummaryRow(accountId: "a", provider: "ghcp", paused: true, quotaStatus: "unsupported"),
BarSummaryRow(accountId: "b", provider: "kiro", paused: true, quotaStatus: "unsupported"),
]
check(
BarFormatting.statusTitle(rows: allPaused, analytics: nil, mode: .accountCount) == "CCS 2",
"glance .accountCount falls back to total when all paused")
let reauthCount = [
BarSummaryRow(accountId: "a", provider: "ghcp", quotaStatus: "error", needsReauth: true)
]
check(
!BarFormatting.statusTitle(rows: reauthCount, analytics: nil, mode: .accountCount).contains("!"),
"glance .accountCount never appends attention '!'")
}
// MARK: BarAlertPrefsStore dictionary codec + level parsing
do {
// Empty dict -> all defaults.
let p = BarAlertPrefsStore.decode(from: [:])
check(p.quotaEnabled == true, "prefs default quotaEnabled true")
check(p.quotaLevels == [20, 10, 0], "prefs default quota levels 20,10,0")
check(p.dailyCapUSD == 500, "prefs default daily cap 500")
check(p.monthCapUSD == 10000, "prefs default month cap 10000")
check(p.glanceMode == .auto, "prefs default glance mode auto")
// Round-trip encode -> decode is identity.
let custom = BarAlertPrefs(
quotaEnabled: false, quotaLevels: [5, 25, 50], dailyCapUSD: 12.5, monthCapUSD: 2000,
glanceMode: .monthSpend)
let back = BarAlertPrefsStore.decode(from: BarAlertPrefsStore.encode(custom))
check(back.quotaEnabled == false, "prefs round-trip quotaEnabled")
check(back.quotaLevels == [50, 25, 5], "prefs round-trip levels sorted desc")
check(back.dailyCapUSD == 12.5, "prefs round-trip daily cap")
check(back.glanceMode == .monthSpend, "prefs round-trip glance mode")
// Level parsing: clamp, dedupe, sort desc; garbage tokens dropped.
check(
BarAlertPrefsStore.parseQuotaLevels("0, 10, 200, 10, -5, foo") == [100, 10, 0],
"parseQuotaLevels clamps/dedupes/sorts and drops garbage")
}
// MARK: BarAlertEngine pure rule engine
let engineNow = Date(timeIntervalSince1970: 1_700_000_000) // fixed clock
let utc = { () -> Calendar in
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()
func mkSpendAnalytics(today: Double, month: Double) -> BarAnalytics {
BarAnalytics(
today: .init(cost: today, requests: 0),
last7d: .init(cost: 0, requests: 0),
last30d: .init(cost: 99999, requests: 0), // intentionally huge to prove it's unused
monthToDate: .init(cost: month, requests: 0),
allTime: .init(cost: 999999, requests: 0), // intentionally huge to prove it's unused
byDay: [], topModels: [], topModelsWindow: "30d",
generatedAt: "2026-06-09T00:00:00Z")
}
// (A) Threshold cross: quota dropping below levels fires exactly ONE notif at the
// most-severe crossed level (L10 for remaining 5 with levels [20,10,0]).
do {
let rows = [
BarSummaryRow(
accountId: "a", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z")
]
let prefs = BarAlertPrefs(quotaLevels: [20, 10, 0])
let ev = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: prefs, priorFiredKeys: [], now: engineNow, calendar: utc)
let quotaNotifs = ev.toDeliver.filter { $0.kind == .quotaRemainingBelow }
check(quotaNotifs.count == 1, "quota cross fires exactly ONE notif (most-severe level)")
check(quotaNotifs.first?.id.hasSuffix("|L10") == true, "quota fires at most-severe crossed L10")
check(quotaNotifs.first?.body.contains("5%") == true, "quota body reports remaining %")
}
// (B) dailySpendAbove strict > ; silent at ==cap ; driven by today (not last30d/allTime).
do {
let prefs = BarAlertPrefs(dailyCapUSD: 50)
let over = BarAlertEngine.evaluate(
rows: [], analytics: mkSpendAnalytics(today: 50.01, month: 0), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(
over.toDeliver.contains { $0.kind == .dailySpendAbove }, "daily spend fires when today > cap")
let atCap = BarAlertEngine.evaluate(
rows: [], analytics: mkSpendAnalytics(today: 50, month: 0), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(
!atCap.toDeliver.contains { $0.kind == .dailySpendAbove },
"daily spend silent at == cap (strict >)")
}
// (C) monthSpendAbove driven by monthToDate ONLY huge last30d/allTime must not trigger.
do {
let prefs = BarAlertPrefs(monthCapUSD: 1000)
// monthToDate UNDER cap but last30d (99999) and allTime (999999) huge must NOT fire.
let under = BarAlertEngine.evaluate(
rows: [], analytics: mkSpendAnalytics(today: 0, month: 500), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(
!under.toDeliver.contains { $0.kind == .monthSpendAbove },
"month alert ignores last30d/allTime — silent when MTD under cap")
let over = BarAlertEngine.evaluate(
rows: [], analytics: mkSpendAnalytics(today: 0, month: 1500), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(
over.toDeliver.contains { $0.kind == .monthSpendAbove }, "month alert fires on MTD > cap")
}
// (D) Dedupe: a second identical evaluate with same now + same firedKeys empty toDeliver.
do {
let rows = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z")
]
let prefs = BarAlertPrefs(dailyCapUSD: 10)
let a1 = BarAlertEngine.evaluate(
rows: rows, analytics: mkSpendAnalytics(today: 100, month: 5000), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(!a1.toDeliver.isEmpty, "first poll delivers notifs")
let a2 = BarAlertEngine.evaluate(
rows: rows, analytics: mkSpendAnalytics(today: 100, month: 5000), prefs: prefs,
priorFiredKeys: a1.firedKeys, now: engineNow, calendar: utc)
check(a2.toDeliver.isEmpty, "second identical poll delivers nothing (dedupe)")
check(a2.firedKeys == a1.firedKeys, "fired keys stable across identical polls")
}
// (E) Re-arm period: +1 day re-fires daily; +1 month re-fires month; new nextReset re-fires quota.
do {
let prefs = BarAlertPrefs(dailyCapUSD: 10, monthCapUSD: 100)
let day0 = mkSpendAnalytics(today: 100, month: 500)
let p0 = BarAlertEngine.evaluate(
rows: [], analytics: day0, prefs: prefs, priorFiredKeys: [], now: engineNow, calendar: utc)
let nextDay = engineNow.addingTimeInterval(24 * 3600)
let p1 = BarAlertEngine.evaluate(
rows: [], analytics: day0, prefs: prefs, priorFiredKeys: p0.firedKeys, now: nextDay,
calendar: utc)
check(p1.toDeliver.contains { $0.kind == .dailySpendAbove }, "daily re-fires next calendar day")
let nextMonth = engineNow.addingTimeInterval(40 * 24 * 3600)
let p2 = BarAlertEngine.evaluate(
rows: [], analytics: day0, prefs: prefs, priorFiredKeys: p0.firedKeys, now: nextMonth,
calendar: utc)
check(p2.toDeliver.contains { $0.kind == .monthSpendAbove }, "month re-fires next calendar month")
// New nextReset string => new quota bucket => quota re-fires.
let q0rows = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z")
]
let q0 = BarAlertEngine.evaluate(
rows: q0rows, analytics: nil, prefs: BarAlertPrefs(quotaLevels: [10]), priorFiredKeys: [],
now: engineNow, calendar: utc)
let q1rows = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-08-01T00:00:00Z")
]
let q1 = BarAlertEngine.evaluate(
rows: q1rows, analytics: nil, prefs: BarAlertPrefs(quotaLevels: [10]),
priorFiredKeys: q0.firedKeys, now: engineNow, calendar: utc)
check(
q1.toDeliver.contains { $0.kind == .quotaRemainingBelow },
"quota re-fires on a new next_reset bucket")
}
// (F) Re-arm clears-then-recurs: reauth true->fires->key set; false->key dropped; true->re-fires.
do {
let onRows = [BarSummaryRow(accountId: "a", provider: "agy", needsReauth: true)]
let offRows = [BarSummaryRow(accountId: "a", provider: "agy", needsReauth: false)]
let s1 = BarAlertEngine.evaluate(
rows: onRows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: [], now: engineNow,
calendar: utc)
check(s1.toDeliver.contains { $0.kind == .reauthNeeded }, "reauth fires when needsReauth true")
check(
s1.firedKeys.contains { $0.hasPrefix("reauthNeeded|") }, "reauth key present after firing")
let s2 = BarAlertEngine.evaluate(
rows: offRows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: s1.firedKeys,
now: engineNow, calendar: utc)
check(
!s2.firedKeys.contains { $0.hasPrefix("reauthNeeded|") },
"reauth key dropped when condition clears")
let s3 = BarAlertEngine.evaluate(
rows: onRows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: s2.firedKeys,
now: engineNow, calendar: utc)
check(
s3.toDeliver.contains { $0.kind == .reauthNeeded }, "reauth re-fires after clear")
// Same clears-then-recurs behavior for accountCooldownOrPaused on `paused`.
let pOn = [BarSummaryRow(accountId: "a", provider: "agy", paused: true)]
let pOff = [BarSummaryRow(accountId: "a", provider: "agy", paused: false)]
let c1 = BarAlertEngine.evaluate(
rows: pOn, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: [], now: engineNow,
calendar: utc)
check(
c1.toDeliver.contains { $0.kind == .accountCooldownOrPaused }, "cooldown/paused fires on paused")
let c2 = BarAlertEngine.evaluate(
rows: pOff, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: c1.firedKeys, now: engineNow,
calendar: utc)
check(
!c2.firedKeys.contains { $0.hasPrefix("accountCooldownOrPaused|") },
"cooldown/paused key dropped when unpaused")
}
// (G) Prune / bounded: after day+month+reset rollover and account churn, fired set
// holds only current-bucket keys and no keys for absent accounts.
do {
let prefs = BarAlertPrefs(quotaLevels: [10], dailyCapUSD: 10, monthCapUSD: 100)
let rowsT0 = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 5, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z", needsReauth: true)
]
let t0 = BarAlertEngine.evaluate(
rows: rowsT0, analytics: mkSpendAnalytics(today: 100, month: 500), prefs: prefs,
priorFiredKeys: [], now: engineNow, calendar: utc)
check(t0.firedKeys.count >= 3, "t0 accumulates quota+daily+month+reauth keys")
// Roll a full month forward AND drop account "a", introduce account "b".
let later = engineNow.addingTimeInterval(40 * 24 * 3600)
let rowsT1 = [BarSummaryRow(accountId: "b", provider: "kiro", quotaStatus: "unsupported")]
let t1 = BarAlertEngine.evaluate(
rows: rowsT1, analytics: mkSpendAnalytics(today: 0, month: 0), prefs: prefs,
priorFiredKeys: t0.firedKeys, now: later, calendar: utc)
check(
!t1.firedKeys.contains { $0.contains("|agy:a|") || $0.hasSuffix("|agy:a|on") },
"prune drops keys for departed account a")
// Stale daily/month/quota buckets from t0 are gone (bucket rolled), so the set
// collapses to only what's currently true (account b has no alerts) empty.
check(t1.firedKeys.isEmpty, "prune collapses stale-bucket + absent-account keys (bounded)")
}
// (H) Deterministic order: shuffled rows produce notifs in stable id order.
do {
let rows = [
BarSummaryRow(accountId: "z", provider: "agy", needsReauth: true),
BarSummaryRow(accountId: "a", provider: "agy", needsReauth: true),
]
let ev = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: [], now: engineNow,
calendar: utc)
let reauthIds = ev.toDeliver.filter { $0.kind == .reauthNeeded }.map { $0.id }
check(
reauthIds == reauthIds.sorted(), "engine emits notifs in stable account-id order")
}
// (I) Delivery wrapper (structural): RecordingNotifier (Core protocol) gets exactly
// the toDeliver ids; a repeat poll delivers nothing.
final class RecordingNotifier: NotificationDelivering, @unchecked Sendable {
var delivered: [String] = []
func deliver(_ n: BarNotification) { delivered.append(n.id) }
}
do {
let notifier = RecordingNotifier()
let rows = [BarSummaryRow(accountId: "a", provider: "agy", needsReauth: true)]
let ev = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: [], now: engineNow,
calendar: utc)
for n in ev.toDeliver { notifier.deliver(n) }
check(notifier.delivered == ev.toDeliver.map { $0.id }, "notifier delivered ids == toDeliver ids")
let ev2 = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: BarAlertPrefs(), priorFiredKeys: ev.firedKeys, now: engineNow,
calendar: utc)
let before = notifier.delivered.count
for n in ev2.toDeliver { notifier.deliver(n) }
check(notifier.delivered.count == before, "repeat poll delivers nothing through notifier")
}
// (J) Disabled rule: quota disabled => no quota notif even on a deep cross.
do {
let rows = [
BarSummaryRow(accountId: "a", provider: "agy", quotaPercentage: 1, quotaStatus: "ok",
nextReset: "2026-07-01T00:00:00Z")
]
let ev = BarAlertEngine.evaluate(
rows: rows, analytics: nil, prefs: BarAlertPrefs(quotaEnabled: false), priorFiredKeys: [],
now: engineNow, calendar: utc)
check(
!ev.toDeliver.contains { $0.kind == .quotaRemainingBelow },
"disabled quota rule never fires")
}
// cleanup
try? FileManager.default.removeItem(atPath: tmp)
@@ -0,0 +1,291 @@
import Foundation
// MARK: Glance mode
/// Which figure leads the always-on menu-bar title. Persisted by raw value, so
/// the cases are a stable contract with `BarFormatting.statusTitle(...:mode:)`.
/// There is deliberately NO allTime/lifetime case a lifetime dollar figure in
/// the bar reads as live spend and creates false urgency.
public enum BarGlanceMode: String, Sendable, Equatable, CaseIterable {
case auto
case todaySpend
case monthSpend
case lowestQuota
case accountCount
}
// MARK: Alert kinds
/// The five alert conditions the engine evaluates. The raw values are STABLE:
/// they are embedded inside persisted fired-keys, so renaming a case orphans
/// any live key and would re-fire or silence alerts incorrectly.
public enum BarAlertKind: String, Sendable, Equatable, CaseIterable {
case quotaRemainingBelow
case dailySpendAbove
case monthSpendAbove
case reauthNeeded
case accountCooldownOrPaused
}
// MARK: Preferences (pure value type)
/// User alert preferences. A pure input to the engine the App hydrates this
/// from UserDefaults and passes it in; the engine never reads defaults itself.
public struct BarAlertPrefs: Sendable, Equatable {
public var quotaEnabled: Bool
public var quotaLevels: [Int]
public var dailySpendEnabled: Bool
public var dailyCapUSD: Double
public var monthSpendEnabled: Bool
public var monthCapUSD: Double
public var reauthEnabled: Bool
public var cooldownPausedEnabled: Bool
public var glanceMode: BarGlanceMode
public init(
quotaEnabled: Bool = true,
quotaLevels: [Int] = [20, 10, 0],
dailySpendEnabled: Bool = true,
dailyCapUSD: Double = 500,
monthSpendEnabled: Bool = true,
monthCapUSD: Double = 10000,
reauthEnabled: Bool = true,
cooldownPausedEnabled: Bool = true,
glanceMode: BarGlanceMode = .auto
) {
self.quotaEnabled = quotaEnabled
self.quotaLevels = quotaLevels
self.dailySpendEnabled = dailySpendEnabled
self.dailyCapUSD = dailyCapUSD
self.monthSpendEnabled = monthSpendEnabled
self.monthCapUSD = monthCapUSD
self.reauthEnabled = reauthEnabled
self.cooldownPausedEnabled = cooldownPausedEnabled
self.glanceMode = glanceMode
}
/// Quota levels defensively normalized: clamped to 0...100, de-duplicated, and
/// sorted descending so the engine can pick the single most-severe crossed
/// level deterministically regardless of how the prefs were entered.
public var normalizedQuotaLevels: [Int] {
Array(Set(quotaLevels.map { min(100, max(0, $0)) })).sorted(by: >)
}
}
// MARK: Notification value + delivery protocol
/// A single notification the engine wants delivered this poll. `id` is the
/// fired-key (also used as the UN request identifier so the OS de-dupes at the
/// delivery layer too).
public struct BarNotification: Sendable, Equatable, Identifiable {
public let id: String
public let title: String
public let body: String
public let kind: BarAlertKind
public init(id: String, title: String, body: String, kind: BarAlertKind) {
self.id = id
self.title = title
self.body = body
self.kind = kind
}
}
/// Result of one evaluation. `firedKeys` is the COMPLETE next-state set the
/// caller overwrites its stored set verbatim (no merge), which is what keeps the
/// persisted set bounded and lets cleared conditions re-arm.
public struct BarAlertEvaluation: Sendable, Equatable {
public let toDeliver: [BarNotification]
public let firedKeys: Set<String>
public init(toDeliver: [BarNotification], firedKeys: Set<String>) {
self.toDeliver = toDeliver
self.firedKeys = firedKeys
}
}
/// Real notification delivery. Declared in Core so the assert harness can supply
/// a recording implementation; the engine NEVER calls it the App orchestrates
/// delivery from the engine's `toDeliver` output.
public protocol NotificationDelivering: Sendable {
func deliver(_ notification: BarNotification)
}
// MARK: The pure rule engine
/// Namespace for the pure, deterministic alert rule engine. `evaluate` is
/// side-effect-free: no `Date()`, no IO, no UserDefaults. Everything time- or
/// state-dependent (`now`, `prefs`, `priorFiredKeys`, `calendar`) is injected.
public enum BarAlertEngine {
/// Evaluate all rules against the current rows + analytics.
///
/// Deterministic: accounts are iterated in stable id order so output order is
/// reproducible. Returns the notifications to deliver and the complete next
/// fired-key set (caller overwrites stored set verbatim).
///
/// Key format: pipe-joined `kindRaw|scope|bucket[|suffix]`. Pipe (not colon)
/// because `account.id` already contains a colon (`provider:accountId`).
/// `scope` is the account id or literal "global". `bucket` is the re-arm token
/// when it rolls (new day/month/reset) the key changes and the alert re-fires.
public static func evaluate(
rows: [BarSummaryRow],
analytics: BarAnalytics?,
prefs: BarAlertPrefs,
priorFiredKeys: Set<String>,
now: Date,
calendar: Calendar = .current
) -> BarAlertEvaluation {
var fired = priorFiredKeys
var out: [BarNotification] = []
let sortedRows = rows.sorted { $0.id < $1.id }
let dayBucket = localDayKey(now, calendar: calendar)
let monthBucket = localMonthKey(now, calendar: calendar)
let levels = prefs.normalizedQuotaLevels
// Per-account rules.
for row in sortedRows {
let scope = row.id
let name = row.displayName ?? row.provider
// (1) quotaRemainingBelow fire the SINGLE most-severe crossed level.
if prefs.quotaEnabled, row.quotaStatus == "ok", let pct = row.quotaPercentage {
let remaining = Int(pct.rounded())
// levels sorted desc; the most-severe crossed level is the smallest L
// with remaining <= L. One notif per account per poll, not one per level.
if let level = levels.filter({ remaining <= $0 }).min() {
let bucket = row.nextReset ?? "noreset"
let key = "\(BarAlertKind.quotaRemainingBelow.rawValue)|\(scope)|\(bucket)|L\(level)"
if !fired.contains(key) {
let resets = BarQuotaGauge.resetCountdown(nextReset: row.nextReset, now: now)
let resetSuffix = resets.map { "\($0)" } ?? ""
out.append(
BarNotification(
id: key,
title: "Quota low",
body: "\(name) quota at \(remaining)%\(resetSuffix)",
kind: .quotaRemainingBelow))
fired.insert(key)
}
}
}
// (4) reauthNeeded clears-then-recurs.
let reauthKey = "\(BarAlertKind.reauthNeeded.rawValue)|\(scope)|on"
if prefs.reauthEnabled, row.needsReauth {
if !fired.contains(reauthKey) {
out.append(
BarNotification(
id: reauthKey,
title: "Re-authentication needed",
body: "\(name) needs re-authentication",
kind: .reauthNeeded))
fired.insert(reauthKey)
}
} else {
// Condition cleared (or rule disabled): drop the key so it re-fires next
// time the condition becomes true again.
fired.remove(reauthKey)
}
// (5) accountCooldownOrPaused clears-then-recurs on `paused`.
let pausedKey = "\(BarAlertKind.accountCooldownOrPaused.rawValue)|\(scope)|on"
if prefs.cooldownPausedEnabled, row.paused {
if !fired.contains(pausedKey) {
out.append(
BarNotification(
id: pausedKey,
title: "Account paused",
body: "\(name) is paused / cooling down",
kind: .accountCooldownOrPaused))
fired.insert(pausedKey)
}
} else {
fired.remove(pausedKey)
}
}
// Global rules.
// (2) dailySpendAbove strict >, per calendar day.
if prefs.dailySpendEnabled {
let today = analytics?.today.cost ?? 0
if today > prefs.dailyCapUSD {
let key = "\(BarAlertKind.dailySpendAbove.rawValue)|global|\(dayBucket)"
if !fired.contains(key) {
out.append(
BarNotification(
id: key,
title: "Daily spend cap",
body:
"Daily spend \(BarFormatting.money(today)) is over your "
+ "\(BarFormatting.money(prefs.dailyCapUSD)) cap",
kind: .dailySpendAbove))
fired.insert(key)
}
}
}
// (3) monthSpendAbove strict >, driven by calendar MTD (NOT last30d/allTime).
if prefs.monthSpendEnabled {
let mtd = analytics?.monthToDate.cost ?? 0
if mtd > prefs.monthCapUSD {
let key = "\(BarAlertKind.monthSpendAbove.rawValue)|global|\(monthBucket)"
if !fired.contains(key) {
out.append(
BarNotification(
id: key,
title: "Monthly spend cap",
body:
"This month's spend \(BarFormatting.money(mtd)) is over your "
+ "\(BarFormatting.money(prefs.monthCapUSD)) cap",
kind: .monthSpendAbove))
fired.insert(key)
}
}
}
// PRUNE keep the fired set bounded so it can't grow without limit across
// day/month/reset rollovers or account churn.
let presentIds = Set(rows.map { $0.id })
let presentResetBuckets: [String: String] = Dictionary(
uniqueKeysWithValues: rows.map { ($0.id, $0.nextReset ?? "noreset") })
fired = fired.filter { key in
let parts = key.split(separator: "|", omittingEmptySubsequences: false).map(String.init)
guard let kind = parts.first else { return false }
switch kind {
case BarAlertKind.dailySpendAbove.rawValue:
// parts: [kind, "global", dayBucket]
return parts.count >= 3 && parts[2] == dayBucket
case BarAlertKind.monthSpendAbove.rawValue:
return parts.count >= 3 && parts[2] == monthBucket
case BarAlertKind.quotaRemainingBelow.rawValue:
// parts: [kind, accountId, bucket, "L<level>"]; bucket must equal the
// account's CURRENT nextReset and the account must still be present.
guard parts.count >= 3, presentIds.contains(parts[1]) else { return false }
return presentResetBuckets[parts[1]] == parts[2]
case BarAlertKind.reauthNeeded.rawValue, BarAlertKind.accountCooldownOrPaused.rawValue:
// parts: [kind, accountId, "on"]; keep only for still-present accounts.
return parts.count >= 2 && presentIds.contains(parts[1])
default:
return false
}
}
return BarAlertEvaluation(toDeliver: out, firedKeys: fired)
}
// MARK: Bucket helpers (local calendar, injected)
/// Local calendar day key `yyyy-MM-dd` from injected `calendar`. Local (not
/// UTC) to match the backend's local-day analytics semantics.
static func localDayKey(_ date: Date, calendar: Calendar) -> String {
let c = calendar.dateComponents([.year, .month, .day], from: date)
return String(
format: "%04d-%02d-%02d", c.year ?? 0, c.month ?? 0, c.day ?? 0)
}
/// Local calendar month key `yyyy-MM`.
static func localMonthKey(_ date: Date, calendar: Calendar) -> String {
let c = calendar.dateComponents([.year, .month], from: date)
return String(format: "%04d-%02d", c.year ?? 0, c.month ?? 0)
}
}
@@ -0,0 +1,105 @@
import Foundation
/// The persistence CONTRACT for alert preferences: the UserDefaults key strings,
/// their defaults, and a pure dictionary <-> prefs codec. The App layer owns the
/// live `UserDefaults` object; this type stays pure so the key/default/parse
/// logic is unit-testable on any toolchain without touching real defaults.
public enum BarAlertPrefsStore {
/// UserDefaults keys. Stable strings changing one silently drops a user's
/// saved preference back to its default.
public enum Key {
public static let quotaEnabled = "ccsbar.alert.quota.enabled"
public static let quotaLevels = "ccsbar.alert.quota.levels"
public static let dailyEnabled = "ccsbar.alert.daily.enabled"
public static let dailyCapUSD = "ccsbar.alert.daily.capUSD"
public static let monthEnabled = "ccsbar.alert.month.enabled"
public static let monthCapUSD = "ccsbar.alert.month.capUSD"
public static let reauthEnabled = "ccsbar.alert.reauth.enabled"
public static let cooldownPausedEnabled = "ccsbar.alert.cooldownPaused.enabled"
public static let glanceMode = "ccsbar.glance.mode"
/// Engine state, NOT user-editable.
public static let firedKeys = "ccsbar.alert.firedKeys"
}
/// Default values keyed by the UserDefaults key. Registered via
/// `UserDefaults.register(defaults:)` at App launch so absent Bool keys don't
/// read back as `false` (which would silently disable every alert on first run).
public static var registrationDefaults: [String: Any] {
[
Key.quotaEnabled: true,
Key.quotaLevels: "20,10,0",
Key.dailyEnabled: true,
Key.dailyCapUSD: 500.0,
Key.monthEnabled: true,
Key.monthCapUSD: 10000.0,
Key.reauthEnabled: true,
Key.cooldownPausedEnabled: true,
Key.glanceMode: BarGlanceMode.auto.rawValue,
]
}
/// Parse the comma-joined quota levels string into normalized `[Int]`:
/// clamped 0...100, de-duplicated, sorted descending. Bad tokens are dropped.
public static func parseQuotaLevels(_ raw: String) -> [Int] {
let parsed = raw.split(separator: ",")
.compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
.map { min(100, max(0, $0)) }
return Array(Set(parsed)).sorted(by: >)
}
/// Serialize quota levels back to the comma-joined storage form (sorted desc).
public static func encodeQuotaLevels(_ levels: [Int]) -> String {
levels.map { min(100, max(0, $0)) }.sorted(by: >).map(String.init).joined(separator: ",")
}
/// Decode a `BarAlertPrefs` from a plain `[key: value]` dictionary. Mirrors how
/// the App will read each key from `UserDefaults`, but pure so it is testable.
/// Missing keys fall back to the matching `BarAlertPrefs` default.
public static func decode(from dict: [String: Any]) -> BarAlertPrefs {
let d = BarAlertPrefs() // carries the canonical defaults
func bool(_ key: String, _ fallback: Bool) -> Bool { (dict[key] as? Bool) ?? fallback }
func double(_ key: String, _ fallback: Double) -> Double {
if let v = dict[key] as? Double { return v }
if let v = dict[key] as? Int { return Double(v) }
return fallback
}
let levels: [Int]
if let raw = dict[Key.quotaLevels] as? String {
let parsed = parseQuotaLevels(raw)
levels = parsed.isEmpty ? d.quotaLevels : parsed
} else {
levels = d.quotaLevels
}
let mode = (dict[Key.glanceMode] as? String).flatMap(BarGlanceMode.init(rawValue:)) ?? d.glanceMode
return BarAlertPrefs(
quotaEnabled: bool(Key.quotaEnabled, d.quotaEnabled),
quotaLevels: levels,
dailySpendEnabled: bool(Key.dailyEnabled, d.dailySpendEnabled),
dailyCapUSD: double(Key.dailyCapUSD, d.dailyCapUSD),
monthSpendEnabled: bool(Key.monthEnabled, d.monthSpendEnabled),
monthCapUSD: double(Key.monthCapUSD, d.monthCapUSD),
reauthEnabled: bool(Key.reauthEnabled, d.reauthEnabled),
cooldownPausedEnabled: bool(Key.cooldownPausedEnabled, d.cooldownPausedEnabled),
glanceMode: mode)
}
/// Encode a `BarAlertPrefs` to the dictionary form the App writes to
/// `UserDefaults` (levels comma-joined, mode as raw value).
public static func encode(_ prefs: BarAlertPrefs) -> [String: Any] {
[
Key.quotaEnabled: prefs.quotaEnabled,
Key.quotaLevels: encodeQuotaLevels(prefs.quotaLevels),
Key.dailyEnabled: prefs.dailySpendEnabled,
Key.dailyCapUSD: prefs.dailyCapUSD,
Key.monthEnabled: prefs.monthSpendEnabled,
Key.monthCapUSD: prefs.monthCapUSD,
Key.reauthEnabled: prefs.reauthEnabled,
Key.cooldownPausedEnabled: prefs.cooldownPausedEnabled,
Key.glanceMode: prefs.glanceMode.rawValue,
]
}
}
@@ -60,6 +60,10 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
public let today: Window
public let last7d: Window
public let last30d: Window
/// Honest calendar month-to-date (1st of the current local month now), NOT a
/// rolling 30 days. A fresh month resets this toward ~0 even when `last30d`
/// stays populated, so a monthly-cap alert measures the real billing month.
public let monthToDate: Window
public let allTime: Window
/// Oldest newest, exactly 30 zero-filled entries, for the sparkline.
public let byDay: [Day]
@@ -83,6 +87,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
today: Window,
last7d: Window,
last30d: Window,
monthToDate: Window = Window(cost: 0, requests: 0),
allTime: Window,
byDay: [Day],
topModels: [Model],
@@ -96,6 +101,7 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
self.today = today
self.last7d = last7d
self.last30d = last30d
self.monthToDate = monthToDate
self.allTime = allTime
self.byDay = byDay
self.topModels = topModels
@@ -115,6 +121,10 @@ public struct BarAnalytics: Codable, Sendable, Equatable {
today = try c.decode(Window.self, forKey: .today)
last7d = try c.decode(Window.self, forKey: .last7d)
last30d = try c.decode(Window.self, forKey: .last30d)
// `monthToDate` is a newer field; older cached payloads omit it. Default to a
// zero window so a stale cache decodes cleanly instead of throwing.
monthToDate =
(try c.decodeIfPresent(Window.self, forKey: .monthToDate)) ?? Window(cost: 0, requests: 0)
allTime = try c.decode(Window.self, forKey: .allTime)
byDay = try c.decode([Day].self, forKey: .byDay)
topModels = try c.decode([Model].self, forKey: .topModels)
@@ -94,6 +94,48 @@ public enum BarFormatting {
return "CCS \(activeCount > 0 ? activeCount : rows.count)"
}
/// Glance-mode title resolver. The user picks which figure leads the menu-bar
/// title; every mode degrades to the `.auto` fallback chain rather than show a
/// dead "$0.00" or a misleading lifetime dollar. A LIFETIME / allTime figure
/// NEVER appears in any mode that invariant is what keeps the always-on bar
/// from reading like live spend.
public static func statusTitle(
rows: [BarSummaryRow], analytics: BarAnalytics?, mode: BarGlanceMode
) -> String {
switch mode {
case .auto:
return statusTitle(rows: rows, analytics: analytics)
case .todaySpend:
// Avoid a dead "$0.00" sitting in the bar: only lead with today's spend
// when there is some; otherwise fall through to the auto chain.
if let c = analytics?.today.cost, c > 0 { return money(c) }
return statusTitle(rows: rows, analytics: analytics)
case .monthSpend:
// Calendar month-to-date (the new backend field), NOT last30d/allTime.
if let c = analytics?.monthToDate.cost, c > 0 { return money(c) }
return statusTitle(rows: rows, analytics: analytics)
case .lowestQuota:
// Step (1) of the auto chain only: lowest remaining "ok" quota.
let quotaRows = rows.filter { $0.quotaStatus == "ok" && $0.quotaPercentage != nil }
if let lead = quotaRows.min(by: { ($0.quotaPercentage ?? 0) < ($1.quotaPercentage ?? 0) }),
let token = quotaTitleToken(percentage: lead.quotaPercentage, status: lead.quotaStatus)
{
return "\(lead.provider) \(token)"
}
return statusTitle(rows: rows, analytics: analytics)
case .accountCount:
// Non-paused count, falling back to total when every account is paused.
// Never appends "!" that attention marker is an .auto-only signal.
if rows.isEmpty { return statusTitle(rows: rows, analytics: analytics) }
let active = rows.filter { !$0.paused }.count
return "CCS \(active > 0 ? active : rows.count)"
}
}
/// The "headline" account for the dropdown when no quota exists (which account
/// name leads). Deterministic: prefer the default row, else the sole active
/// row, else alphabetical by id never `rows.first` (arbitrary order).
@@ -0,0 +1,53 @@
import Foundation
/// Pure quota-gauge math: band selection, fill fraction, and reset-countdown
/// formatting. No SwiftUI dependency and no implicit clock `now` is injected
/// for `resetCountdown` so the gauge is fully deterministic and testable. The
/// App layer renders a ring/bar from these values; all branch/color/countdown
/// logic lives here so the view stays a thin render.
public enum BarQuotaGauge {
/// Severity band for the remaining-quota percentage. `.none` means the row
/// has no live quota (unsupported provider, fetch error, or a nil percentage)
/// and the gauge should not be drawn at all.
public enum Band: String, Sendable, Equatable, CaseIterable {
case green
case yellow
case orange
case red
case none
}
/// Map a remaining-quota percentage to a severity band. Only a status of "ok"
/// with a real percentage yields a colored band; everything else is `.none`.
/// Boundaries (remaining): >50 green, 21...50 yellow, 11...20 orange, <=10 red.
public static func band(percentage pct: Double?, status: String) -> Band {
guard status == "ok", let pct else { return .none }
if pct > 50 { return .green }
if pct > 20 { return .yellow }
if pct > 10 { return .orange }
return .red
}
/// Fraction of the ring/bar to fill: remaining/100 clamped to 0...1. Returns
/// nil when there is no live quota (so the view can fall back to a text label
/// instead of drawing an empty gauge).
public static func fillFraction(percentage pct: Double?, status: String) -> Double? {
guard status == "ok", let pct else { return nil }
return min(1, max(0, pct / 100))
}
/// Human countdown to the next quota reset, e.g. "resets in 3h 12m",
/// "resets in 12m", or "resets soon" when the reset time is at/in the past.
/// Returns nil for a nil or unparseable timestamp. `now` is injected so the
/// formatting is deterministic and unit-testable.
public static func resetCountdown(nextReset: String?, now: Date) -> String? {
guard let nextReset, let reset = BarFormatting.isoDate(nextReset) else { return nil }
let secs = reset.timeIntervalSince(now)
if secs <= 0 { return "resets soon" }
let totalMinutes = Int(secs / 60)
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 { return "resets in \(hours)h \(minutes)m" }
return "resets in \(minutes)m"
}
}