mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-18 06:04:36 +00:00
PR #221 unified the period logic but missed the TUI hotkey bar, GNOME indicator popup, and macOS menubar app. All surfaces now consistently show '6 Months' instead of 'All' or 'all time'.
383 lines
14 KiB
Swift
383 lines
14 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
private let cacheTTLSeconds: TimeInterval = 30
|
|
|
|
struct CachedPayload {
|
|
let payload: MenubarPayload
|
|
let fetchedAt: Date
|
|
var isFresh: Bool { Date().timeIntervalSince(fetchedAt) < cacheTTLSeconds }
|
|
}
|
|
|
|
struct PayloadCacheKey: Hashable {
|
|
let period: Period
|
|
let provider: ProviderFilter
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class AppStore {
|
|
var selectedProvider: ProviderFilter = .all
|
|
var selectedPeriod: Period = .today
|
|
var selectedInsight: InsightMode = .trend
|
|
var accentPreset: AccentPreset = ThemeState.shared.preset {
|
|
didSet { ThemeState.shared.preset = accentPreset }
|
|
}
|
|
var showingAccentPicker: Bool = false
|
|
var currency: String = "USD"
|
|
var isLoading: Bool { loadingCount > 0 }
|
|
private var loadingCount: Int = 0
|
|
var lastError: String?
|
|
var subscription: SubscriptionUsage?
|
|
var subscriptionError: String?
|
|
var subscriptionLoadState: SubscriptionLoadState = .idle
|
|
var capacityEstimates: [String: CapacityEstimate] = [:]
|
|
|
|
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
|
private var switchTask: Task<Void, Never>?
|
|
|
|
private var currentKey: PayloadCacheKey {
|
|
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
|
}
|
|
|
|
var payload: MenubarPayload {
|
|
cache[currentKey]?.payload ?? .empty
|
|
}
|
|
|
|
/// Today (across all providers) is pinned for the always-visible menubar icon, independent of
|
|
/// the popover's selected period or provider.
|
|
var todayPayload: MenubarPayload? {
|
|
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
|
|
}
|
|
|
|
/// All-provider payload for the selected period. Used by the tab strip to show
|
|
/// per-provider costs that match the active period, not just today.
|
|
var periodAllPayload: MenubarPayload? {
|
|
cache[PayloadCacheKey(period: selectedPeriod, provider: .all)]?.payload
|
|
}
|
|
|
|
var hasCachedData: Bool {
|
|
cache[currentKey] != nil
|
|
}
|
|
|
|
var findingsCount: Int {
|
|
payload.optimize.findingCount
|
|
}
|
|
|
|
/// Switch to a period. Cancels any in-flight switch and fetches provider-specific +
|
|
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
|
func switchTo(period: Period) {
|
|
selectedPeriod = period
|
|
switchTask?.cancel()
|
|
switchTask = Task {
|
|
if selectedProvider == .all {
|
|
await refresh(includeOptimize: true, force: true)
|
|
} else {
|
|
async let main: Void = refresh(includeOptimize: true, force: true)
|
|
async let all: Void = refreshQuietly(period: period)
|
|
_ = await (main, all)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
|
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
|
/// in parallel so the tab strip costs stay in sync with the hero.
|
|
func switchTo(provider: ProviderFilter) {
|
|
selectedProvider = provider
|
|
switchTask?.cancel()
|
|
switchTask = Task {
|
|
if provider == .all {
|
|
await refresh(includeOptimize: true, force: true)
|
|
} else {
|
|
async let main: Void = refresh(includeOptimize: true, force: true)
|
|
async let all: Void = refreshQuietly(period: selectedPeriod)
|
|
_ = await (main, all)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var inFlightKeys: Set<PayloadCacheKey> = []
|
|
|
|
func refresh(includeOptimize: Bool, force: Bool = false) async {
|
|
let key = currentKey
|
|
if !force, cache[key]?.isFresh == true { return }
|
|
if !force, inFlightKeys.contains(key) { return }
|
|
inFlightKeys.insert(key)
|
|
let showedLoading = cache[key] == nil
|
|
if showedLoading {
|
|
loadingCount += 1
|
|
}
|
|
defer {
|
|
inFlightKeys.remove(key)
|
|
if showedLoading { loadingCount = max(loadingCount - 1, 0) }
|
|
}
|
|
do {
|
|
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
|
guard !Task.isCancelled else { return }
|
|
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
|
lastError = nil
|
|
} catch {
|
|
if Task.isCancelled { return }
|
|
lastError = String(describing: error)
|
|
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
|
}
|
|
|
|
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
|
if key != allKey, cache[allKey]?.isFresh != true {
|
|
await refreshQuietly(period: selectedPeriod)
|
|
}
|
|
}
|
|
|
|
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
|
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
|
|
/// Always uses the .all provider since the menubar badge shows total spend.
|
|
func refreshQuietly(period: Period) async {
|
|
do {
|
|
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true)
|
|
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
|
|
} catch {
|
|
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
|
|
}
|
|
}
|
|
|
|
/// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
|
|
/// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
|
|
func refreshSubscription() async {
|
|
subscriptionLoadState = .loading
|
|
do {
|
|
let usage = try await SubscriptionClient.fetch()
|
|
subscription = usage
|
|
subscriptionError = nil
|
|
subscriptionLoadState = .loaded
|
|
await captureSnapshots(for: usage)
|
|
} catch SubscriptionError.noCredentials {
|
|
subscription = nil
|
|
subscriptionError = nil
|
|
subscriptionLoadState = .noCredentials
|
|
} catch {
|
|
subscription = nil
|
|
subscriptionError = String(describing: error)
|
|
subscriptionLoadState = .failed
|
|
NSLog("CodeBurn: subscription fetch failed: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
|
/// when the current window has just reset and projection from current data isn't meaningful.
|
|
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
|
/// which the CapacityEstimator uses to derive the absolute token capacity per tier.
|
|
private func captureSnapshots(for usage: SubscriptionUsage) async {
|
|
let now = Date()
|
|
let history = payload.history.daily
|
|
|
|
let captures: [(key: String, percent: Double?, resetsAt: Date?, effective: Double?)] = [
|
|
("five_hour", usage.fiveHourPercent, usage.fiveHourResetsAt, nil),
|
|
("seven_day", usage.sevenDayPercent, usage.sevenDayResetsAt,
|
|
effectiveTokensInLast7Days(history: history, asOf: now)),
|
|
("seven_day_opus", usage.sevenDayOpusPercent, usage.sevenDayOpusResetsAt, nil),
|
|
("seven_day_sonnet", usage.sevenDaySonnetPercent, usage.sevenDaySonnetResetsAt, nil),
|
|
]
|
|
for capture in captures {
|
|
guard let percent = capture.percent, let resetsAt = capture.resetsAt else { continue }
|
|
await SubscriptionSnapshotStore.record(SubscriptionSnapshot(
|
|
windowKey: capture.key,
|
|
percent: percent,
|
|
resetsAt: resetsAt,
|
|
capturedAt: now,
|
|
effectiveTokens: capture.effective
|
|
))
|
|
}
|
|
|
|
await refreshCapacityEstimates()
|
|
}
|
|
|
|
/// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the
|
|
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
|
|
/// with the API-reported percent for capacity estimation.
|
|
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
|
|
let f = DateFormatter()
|
|
f.dateFormat = "yyyy-MM-dd"
|
|
f.timeZone = .current
|
|
let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400))
|
|
return history
|
|
.filter { $0.date >= cutoff }
|
|
.reduce(0.0) { $0 + $1.effectiveTokens }
|
|
}
|
|
|
|
/// Run CapacityEstimator over each window's accumulated snapshots. Only snapshots with a
|
|
/// non-nil effectiveTokens contribute. Result lives in capacityEstimates dict for UI gating.
|
|
private func refreshCapacityEstimates() async {
|
|
var next: [String: CapacityEstimate] = [:]
|
|
for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] {
|
|
let snaps = await SubscriptionSnapshotStore.snapshots(for: key)
|
|
let capacitySnaps = snaps.compactMap { s -> CapacitySnapshot? in
|
|
guard let effective = s.effectiveTokens, effective > 0 else { return nil }
|
|
return CapacitySnapshot(percent: s.percent, effectiveTokens: effective, capturedAt: s.capturedAt)
|
|
}
|
|
if let estimate = CapacityEstimator.estimate(capacitySnaps) {
|
|
next[key] = estimate
|
|
}
|
|
}
|
|
capacityEstimates = next
|
|
}
|
|
}
|
|
|
|
enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|
case USD, GBP, EUR, AUD, CAD, NZD, JPY, CHF, INR, BRL, SEK, SGD, HKD, KRW, MXN, ZAR, DKK
|
|
var id: String { rawValue }
|
|
var displayName: String {
|
|
switch self {
|
|
case .USD: "US Dollar"
|
|
case .GBP: "British Pound"
|
|
case .EUR: "Euro"
|
|
case .AUD: "Australian Dollar"
|
|
case .CAD: "Canadian Dollar"
|
|
case .NZD: "New Zealand Dollar"
|
|
case .JPY: "Japanese Yen"
|
|
case .CHF: "Swiss Franc"
|
|
case .INR: "Indian Rupee"
|
|
case .BRL: "Brazilian Real"
|
|
case .SEK: "Swedish Krona"
|
|
case .SGD: "Singapore Dollar"
|
|
case .HKD: "Hong Kong Dollar"
|
|
case .KRW: "South Korean Won"
|
|
case .MXN: "Mexican Peso"
|
|
case .ZAR: "South African Rand"
|
|
case .DKK: "Danish Krone"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum ProviderFilter: String, CaseIterable, Identifiable {
|
|
case all = "All"
|
|
case claude = "Claude"
|
|
case codex = "Codex"
|
|
case cursor = "Cursor"
|
|
case copilot = "Copilot"
|
|
case droid = "Droid"
|
|
case gemini = "Gemini"
|
|
case kiro = "Kiro"
|
|
case kiloCode = "KiloCode"
|
|
case openclaw = "OpenClaw"
|
|
case opencode = "OpenCode"
|
|
case pi = "Pi"
|
|
case qwen = "Qwen"
|
|
case omp = "OMP"
|
|
case rooCode = "Roo Code"
|
|
|
|
var id: String { rawValue }
|
|
|
|
var providerKeys: [String] {
|
|
switch self {
|
|
case .cursor: ["cursor", "cursor agent"]
|
|
case .rooCode: ["roo-code", "roo code"]
|
|
case .kiloCode: ["kilo-code", "kilocode"]
|
|
case .openclaw: ["openclaw"]
|
|
default: [rawValue.lowercased()]
|
|
}
|
|
}
|
|
|
|
var cliArg: String {
|
|
switch self {
|
|
case .all: "all"
|
|
case .claude: "claude"
|
|
case .codex: "codex"
|
|
case .cursor: "cursor"
|
|
case .copilot: "copilot"
|
|
case .droid: "droid"
|
|
case .gemini: "gemini"
|
|
case .kiloCode: "kilo-code"
|
|
case .kiro: "kiro"
|
|
case .openclaw: "openclaw"
|
|
case .opencode: "opencode"
|
|
case .pi: "pi"
|
|
case .qwen: "qwen"
|
|
case .omp: "omp"
|
|
case .rooCode: "roo-code"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum SubscriptionLoadState: Sendable, Equatable {
|
|
case idle // never tried, awaiting user intent
|
|
case loading // fetch in progress
|
|
case loaded // success; subscription is populated
|
|
case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
|
|
case failed // tried; error occurred
|
|
}
|
|
|
|
enum InsightMode: String, CaseIterable, Identifiable {
|
|
case plan = "Plan"
|
|
case trend = "Trend"
|
|
case forecast = "Forecast"
|
|
case pulse = "Pulse"
|
|
case stats = "Stats"
|
|
var id: String { rawValue }
|
|
}
|
|
|
|
enum Period: String, CaseIterable, Identifiable {
|
|
case today = "Today"
|
|
case sevenDays = "7 Days"
|
|
case thirtyDays = "30 Days"
|
|
case month = "Month"
|
|
case all = "6 Months"
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// Maps to the CLI's `--period` argument values.
|
|
var cliArg: String {
|
|
switch self {
|
|
case .today: "today"
|
|
case .sevenDays: "week"
|
|
case .thirtyDays: "30days"
|
|
case .month: "month"
|
|
case .all: "all"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// NumberFormatter is expensive to instantiate (~microseconds each) and currency/token values
|
|
/// are formatted dozens of times per popover refresh. These shared instances avoid thousands of
|
|
/// allocations per frame while SwiftUI's Observation framework still triggers redraws when
|
|
/// CurrencyState.shared mutates.
|
|
private let groupedDecimalFormatter: NumberFormatter = {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
f.groupingSeparator = ","
|
|
f.decimalSeparator = "."
|
|
f.maximumFractionDigits = 2
|
|
f.minimumFractionDigits = 2
|
|
return f
|
|
}()
|
|
|
|
private let thousandsFormatter: NumberFormatter = {
|
|
let f = NumberFormatter()
|
|
f.numberStyle = .decimal
|
|
f.groupingSeparator = ","
|
|
return f
|
|
}()
|
|
|
|
@MainActor extension Double {
|
|
func asCurrency() -> String {
|
|
let state = CurrencyState.shared
|
|
let converted = self * state.rate
|
|
return state.symbol + (groupedDecimalFormatter.string(from: NSNumber(value: converted)) ?? "\(converted)")
|
|
}
|
|
|
|
func asCompactCurrency() -> String {
|
|
let state = CurrencyState.shared
|
|
return String(format: "\(state.symbol)%.2f", self * state.rate)
|
|
}
|
|
|
|
func asCompactCurrencyWhole() -> String {
|
|
let state = CurrencyState.shared
|
|
return "\(state.symbol)\(Int((self * state.rate).rounded()))"
|
|
}
|
|
}
|
|
|
|
extension Int {
|
|
func asThousandsSeparated() -> String {
|
|
thousandsFormatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
|
}
|
|
}
|