Files
ccs/macos-bar/Sources/CCSBarCore/BarAlertPrefsStore.swift
T
Tam Nhu Tran 559d4340e7 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.
2026-06-09 13:03:44 -04:00

106 lines
4.6 KiB
Swift

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,
]
}
}