mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 12:16:59 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user