Files
ccs/macos-bar/Sources/CCSBarCore/BarAnalytics.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

139 lines
5.3 KiB
Swift

import Foundation
/// Usage analytics for the menu bar, mirroring `GET /api/bar/analytics`.
///
/// Rolled up server-side from the persisted CLIProxy usage snapshot. All cost
/// values are USD.
/// Spend and request count for one usage surface (tool/origin), e.g. "Claude Code"
/// or "Codex". The server sends these ordered descending by cost for the same window
/// as topModels, so the array can be rendered as-is.
public struct BarAnalyticsSurface: Codable, Sendable, Equatable, Identifiable {
public let source: String
public let surface: String
public let cost: Double
public let requests: Int
/// Stable identity for SwiftUI lists: surface name is unique within a window.
public var id: String { surface }
public init(source: String, surface: String, cost: Double, requests: Int) {
self.source = source
self.surface = surface
self.cost = cost
self.requests = requests
}
}
public struct BarAnalytics: Codable, Sendable, Equatable {
public struct Window: Codable, Sendable, Equatable {
public let cost: Double
public let requests: Int
public init(cost: Double, requests: Int) {
self.cost = cost
self.requests = requests
}
}
public struct Day: Codable, Sendable, Equatable, Identifiable {
public let date: String
public let cost: Double
public let requests: Int
public var id: String { date }
public init(date: String, cost: Double, requests: Int) {
self.date = date
self.cost = cost
self.requests = requests
}
}
public struct Model: Codable, Sendable, Equatable, Identifiable {
public let model: String
public let cost: Double
public let requests: Int
public var id: String { model }
public init(model: String, cost: Double, requests: Int) {
self.model = model
self.cost = cost
self.requests = requests
}
}
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]
public let topModels: [Model]
/// "30d" when recent data exists, else "all".
public let topModelsWindow: String
/// ISO timestamp of the most recent non-failed usage record, null if none.
public let lastActivityAt: String?
/// Whole local-days since `lastActivityAt`, null if no usable records.
public let daysSinceLastActivity: Int?
/// True when the trailing 30 days carry any spend or requests. The UI pivots
/// its empty/stale presentation on this without re-deriving it.
public let hasRecentData: Bool
public let generatedAt: String
/// Spend/requests per usage surface (e.g. "Claude Code", "Codex"), ordered
/// descending by cost for the same window as topModels. Empty when the backend
/// has no surface breakdown; the UI omits the section in that case.
public let bySurface: [BarAnalyticsSurface]
public init(
today: Window,
last7d: Window,
last30d: Window,
monthToDate: Window = Window(cost: 0, requests: 0),
allTime: Window,
byDay: [Day],
topModels: [Model],
topModelsWindow: String,
lastActivityAt: String? = nil,
daysSinceLastActivity: Int? = nil,
hasRecentData: Bool = false,
generatedAt: String,
bySurface: [BarAnalyticsSurface] = []
) {
self.today = today
self.last7d = last7d
self.last30d = last30d
self.monthToDate = monthToDate
self.allTime = allTime
self.byDay = byDay
self.topModels = topModels
self.topModelsWindow = topModelsWindow
self.lastActivityAt = lastActivityAt
self.daysSinceLastActivity = daysSinceLastActivity
self.hasRecentData = hasRecentData
self.generatedAt = generatedAt
self.bySurface = bySurface
}
// Custom decoder: `bySurface` is a new field absent from older snapshots.
// Defaulting to [] when the key is missing keeps the app backward-compatible
// with any cached or older-backend analytics payload.
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
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)
topModelsWindow = try c.decode(String.self, forKey: .topModelsWindow)
lastActivityAt = try c.decodeIfPresent(String.self, forKey: .lastActivityAt)
daysSinceLastActivity = try c.decodeIfPresent(Int.self, forKey: .daysSinceLastActivity)
hasRecentData = try c.decode(Bool.self, forKey: .hasRecentData)
generatedAt = try c.decode(String.self, forKey: .generatedAt)
bySurface = (try c.decodeIfPresent([BarAnalyticsSurface].self, forKey: .bySurface)) ?? []
}
}