diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 9b4bac8e..e31d3558 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarApp/BarNotifier.swift b/macos-bar/Sources/CCSBarApp/BarNotifier.swift new file mode 100644 index 00000000..f0487cdc --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarNotifier.swift @@ -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) + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarPreferences.swift b/macos-bar/Sources/CCSBarApp/BarPreferences.swift new file mode 100644 index 00000000..0e98dab5 --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarPreferences.swift @@ -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 { + get { Set(defaults.stringArray(forKey: BarAlertPrefsStore.Key.firedKeys) ?? []) } + nonmutating set { defaults.set(Array(newValue), forKey: BarAlertPrefsStore.Key.firedKeys) } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift new file mode 100644 index 00000000..2009887e --- /dev/null +++ b/macos-bar/Sources/CCSBarApp/BarPreferencesView.swift @@ -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, 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" + } + } +} diff --git a/macos-bar/Sources/CCSBarApp/BarViewModel.swift b/macos-bar/Sources/CCSBarApp/BarViewModel.swift index 72132f98..0dae3d7b 100644 --- a/macos-bar/Sources/CCSBarApp/BarViewModel.swift +++ b/macos-bar/Sources/CCSBarApp/BarViewModel.swift @@ -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 diff --git a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift index 3e2c6eac..b150705a 100644 --- a/macos-bar/Sources/CCSBarApp/CCSBarApp.swift +++ b/macos-bar/Sources/CCSBarApp/CCSBarApp.swift @@ -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) diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index ceafb2b8..7dec7c72 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -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) diff --git a/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift b/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift new file mode 100644 index 00000000..8c1182e3 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarAlertEngine.swift @@ -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 + public init(toDeliver: [BarNotification], firedKeys: Set) { + 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, + 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"]; 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) + } +} diff --git a/macos-bar/Sources/CCSBarCore/BarAlertPrefsStore.swift b/macos-bar/Sources/CCSBarCore/BarAlertPrefsStore.swift new file mode 100644 index 00000000..e2cf6645 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarAlertPrefsStore.swift @@ -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, + ] + } +} diff --git a/macos-bar/Sources/CCSBarCore/BarAnalytics.swift b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift index 9a265140..b1a4e304 100644 --- a/macos-bar/Sources/CCSBarCore/BarAnalytics.swift +++ b/macos-bar/Sources/CCSBarCore/BarAnalytics.swift @@ -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) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index 54dddd9d..fa0b6b03 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -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). diff --git a/macos-bar/Sources/CCSBarCore/BarQuotaGauge.swift b/macos-bar/Sources/CCSBarCore/BarQuotaGauge.swift new file mode 100644 index 00000000..a11b34a3 --- /dev/null +++ b/macos-bar/Sources/CCSBarCore/BarQuotaGauge.swift @@ -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" + } +}