From 4437e356fc228ec03e35a48bd8395d47b6d92b53 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Feb 2026 18:28:25 +0800 Subject: [PATCH 01/32] Add subscription utilization history for Codex and Claude --- .../PlanUtilizationHistoryChartMenuView.swift | 405 ++++++++++++++++++ .../PlanUtilizationHistoryStore.swift | 66 +++ .../CodexBar/StatusItemController+Menu.swift | 24 +- ...tatusItemController+UsageHistoryMenu.swift | 52 +++ .../CodexBar/UsageStore+PlanUtilization.swift | 156 +++++++ Sources/CodexBar/UsageStore+Refresh.swift | 4 + Sources/CodexBar/UsageStore.swift | 70 ++- Tests/CodexBarTests/StatusMenuTests.swift | 106 +++++ .../UsageStorePlanUtilizationTests.swift | 240 +++++++++++ 9 files changed, 1112 insertions(+), 11 deletions(-) create mode 100644 Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift create mode 100644 Sources/CodexBar/PlanUtilizationHistoryStore.swift create mode 100644 Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift create mode 100644 Sources/CodexBar/UsageStore+PlanUtilization.swift create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift new file mode 100644 index 000000000..0a752f882 --- /dev/null +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -0,0 +1,405 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct PlanUtilizationHistoryChartMenuView: View { + private enum Layout { + static let chartHeight: CGFloat = 130 + static let detailHeight: CGFloat = 32 + static let emptyStateHeight: CGFloat = chartHeight + detailHeight + } + + private enum Period: String, CaseIterable, Identifiable { + case daily + case weekly + case monthly + + var id: String { + self.rawValue + } + + var title: String { + switch self { + case .daily: + "Daily" + case .weekly: + "Weekly" + case .monthly: + "Monthly" + } + } + + var emptyStateText: String { + switch self { + case .daily: + "No daily utilization data yet." + case .weekly: + "No weekly utilization data yet." + case .monthly: + "No monthly utilization data yet." + } + } + + var maxPoints: Int { + switch self { + case .daily: + 30 + case .weekly: + 16 + case .monthly: + 12 + } + } + } + + private struct Point: Identifiable { + let id: String + let index: Int + let date: Date + let usedPercent: Double + } + + private let provider: UsageProvider + private let samples: [PlanUtilizationHistorySample] + private let width: CGFloat + + @State private var selectedPeriod: Period = .weekly + @State private var selectedPointID: String? + + init(provider: UsageProvider, samples: [PlanUtilizationHistorySample], width: CGFloat) { + self.provider = provider + self.samples = samples + self.width = width + } + + var body: some View { + let model = Self.makeModel(period: self.selectedPeriod, samples: self.samples, provider: self.provider) + + VStack(alignment: .leading, spacing: 10) { + Picker("Period", selection: self.$selectedPeriod) { + ForEach(Period.allCases) { period in + Text(period.title).tag(period) + } + } + .pickerStyle(.segmented) + .onChange(of: self.selectedPeriod) { _, _ in + self.selectedPointID = nil + } + + if model.points.isEmpty { + ZStack { + Text(self.selectedPeriod.emptyStateText) + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .frame(height: Layout.emptyStateHeight) + } else { + self.utilizationChart(model: model) + .chartYAxis(.hidden) + .chartYScale(domain: 0...100) + .chartXAxis { + AxisMarks(values: model.axisIndexes) { value in + AxisGridLine().foregroundStyle(Color.clear) + AxisTick().foregroundStyle(Color.clear) + AxisValueLabel { + if let raw = value.as(Double.self) { + let index = Int(raw.rounded()) + if let point = model.pointsByIndex[index] { + Text(point.date.formatted(self.axisFormat(for: self.selectedPeriod))) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + } + } + } + } + } + .chartLegend(.hidden) + .frame(height: Layout.chartHeight) + .chartOverlay { proxy in + GeometryReader { geo in + MouseLocationReader { location in + self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + } + } + + let detail = self.detailLines(model: model) + VStack(alignment: .leading, spacing: 0) { + Text(detail.primary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: 16, alignment: .leading) + Text(detail.secondary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: 16, alignment: .leading) + } + .frame(height: Layout.detailHeight, alignment: .top) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) + } + + private struct Model { + let points: [Point] + let axisIndexes: [Double] + let xDomain: ClosedRange? + let pointsByID: [String: Point] + let pointsByIndex: [Int: Point] + let barColor: Color + } + + private static func makeModel( + period: Period, + samples: [PlanUtilizationHistorySample], + provider: UsageProvider) -> Model + { + var buckets: [Date: Double] = [:] + let calendar = Calendar.current + + let shouldDeriveCodexMonthlyFromWeekly = provider == .codex && + !samples.contains(where: { $0.monthlyUsedPercent != nil }) + let shouldDeriveMonthlyFromWeekly = period == .monthly && + (provider == .claude || shouldDeriveCodexMonthlyFromWeekly) + + if shouldDeriveMonthlyFromWeekly { + // Subscription utilization is approximated from weekly windows when no suitable monthly source exists. + // For Claude, this intentionally ignores pay-as-you-go extra usage spend. + // Approximate monthly utilization as the average weekly used % observed in that month. + var monthToWeekUsage: [Date: [Date: Double]] = [:] + for sample in samples { + guard let used = sample.weeklyUsedPercent else { continue } + let clamped = max(0, min(100, used)) + guard + let monthDate = Self.bucketDate(for: sample.capturedAt, period: .monthly, calendar: calendar), + let weekDate = Self.bucketDate(for: sample.capturedAt, period: .weekly, calendar: calendar) + else { + continue + } + var weekUsage = monthToWeekUsage[monthDate] ?? [:] + weekUsage[weekDate] = max(weekUsage[weekDate] ?? 0, clamped) + monthToWeekUsage[monthDate] = weekUsage + } + + for (monthDate, weekUsage) in monthToWeekUsage { + guard !weekUsage.isEmpty else { continue } + let totalUsed = weekUsage.values.reduce(0, +) + buckets[monthDate] = totalUsed / Double(weekUsage.count) + } + } else { + for sample in samples { + guard let used = Self.usedPercent(for: sample, period: period) else { continue } + let clamped = max(0, min(100, used)) + guard + let bucketDate = Self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) + else { + continue + } + let current = buckets[bucketDate] ?? 0 + buckets[bucketDate] = max(current, clamped) + } + } + + var points = buckets + .map { date, used in + Point( + id: Self.pointID(date: date, period: period), + index: 0, + date: date, + usedPercent: used) + } + .sorted { $0.date < $1.date } + + if points.count > period.maxPoints { + points = Array(points.suffix(period.maxPoints)) + } + + points = points.enumerated().map { index, point in + Point( + id: point.id, + index: index, + date: point.date, + usedPercent: point.usedPercent) + } + + let axisIndexes: [Double] = { + guard let first = points.first?.index, let last = points.last?.index else { return [] } + if first == last { return [Double(first)] } + return [Double(first), Double(last)] + }() + let xDomain = Self.xDomain(points: points) + + let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) }) + let pointsByIndex = Dictionary(uniqueKeysWithValues: points.map { ($0.index, $0) }) + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + let barColor = Color(red: color.red, green: color.green, blue: color.blue) + + return Model( + points: points, + axisIndexes: axisIndexes, + xDomain: xDomain, + pointsByID: pointsByID, + pointsByIndex: pointsByIndex, + barColor: barColor) + } + + private static func xDomain(points: [Point]) -> ClosedRange? { + guard points.count == 1 else { return nil } + return 0...13 + } + + private static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { + switch period { + case .daily: + sample.dailyUsedPercent + case .weekly: + sample.weeklyUsedPercent + case .monthly: + sample.monthlyUsedPercent + } + } + + private static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { + switch period { + case .daily: + calendar.startOfDay(for: date) + case .weekly: + calendar.dateInterval(of: .weekOfYear, for: date)?.start + case .monthly: + calendar.dateInterval(of: .month, for: date)?.start + } + } + + private static func pointID(date: Date, period: Period) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = switch period { + case .daily: + "yyyy-MM-dd" + case .weekly: + "yyyy-'W'ww" + case .monthly: + "yyyy-MM" + } + return formatter.string(from: date) + } + + private func xValue(for index: Int) -> PlottableValue { + .value("Period", Double(index)) + } + + @ViewBuilder + private func utilizationChart(model: Model) -> some View { + if let xDomain = model.xDomain { + Chart { + self.utilizationChartContent(model: model) + } + .chartXScale(domain: xDomain) + } else { + Chart { + self.utilizationChartContent(model: model) + } + } + } + + @ChartContentBuilder + private func utilizationChartContent(model: Model) -> some ChartContent { + ForEach(model.points) { point in + BarMark( + x: self.xValue(for: point.index), + y: .value("Utilization", point.usedPercent)) + .foregroundStyle(model.barColor) + } + if let selected = self.selectedPoint(model: model) { + RuleMark(x: self.xValue(for: selected.index)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + } + + private func axisFormat(for period: Period) -> Date.FormatStyle { + switch period { + case .daily, .weekly: + .dateTime.month(.abbreviated).day() + case .monthly: + .dateTime.month(.abbreviated).year(.defaultDigits) + } + } + + private func selectedPoint(model: Model) -> Point? { + guard let selectedPointID else { return nil } + return model.pointsByID[selectedPointID] + } + + private func detailLines(model: Model) -> (primary: String, secondary: String) { + let activePoint = self.selectedPoint(model: model) ?? model.points.last + guard let point = activePoint else { + return ("No data", "") + } + + let dateLabel: String = switch self.selectedPeriod { + case .daily, .weekly: + point.date.formatted(.dateTime.month(.abbreviated).day()) + case .monthly: + point.date.formatted(.dateTime.month(.abbreviated).year(.defaultDigits)) + } + + let used = max(0, min(100, point.usedPercent)) + let wasted = max(0, 100 - used) + let usedText = used.formatted(.number.precision(.fractionLength(0...1))) + let wastedText = wasted.formatted(.number.precision(.fractionLength(0...1))) + + return ( + "\(dateLabel): \(usedText)% used", + "\(wastedText)% wasted") + } + + private func updateSelection( + location: CGPoint?, + model: Model, + proxy: ChartProxy, + geo: GeometryProxy) + { + guard let location else { + if self.selectedPointID != nil { self.selectedPointID = nil } + return + } + + guard let plotAnchor = proxy.plotFrame else { return } + let plotFrame = geo[plotAnchor] + guard plotFrame.contains(location) else { + if self.selectedPointID != nil { self.selectedPointID = nil } + return + } + + let xInPlot = location.x - plotFrame.origin.x + guard let xValue: Double = proxy.value(atX: xInPlot) else { return } + + var best: (id: String, distance: Double)? + for point in model.points { + let distance = abs(Double(point.index) - xValue) + if let current = best { + if distance < current.distance { + best = (point.id, distance) + } + } else { + best = (point.id, distance) + } + } + + if self.selectedPointID != best?.id { + self.selectedPointID = best?.id + } + } +} diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift new file mode 100644 index 000000000..1e91e04b2 --- /dev/null +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -0,0 +1,66 @@ +import CodexBarCore +import Foundation + +struct PlanUtilizationHistorySample: Codable, Sendable, Equatable { + let capturedAt: Date + let dailyUsedPercent: Double? + let weeklyUsedPercent: Double? + let monthlyUsedPercent: Double? +} + +private struct PlanUtilizationHistoryFile: Codable, Sendable { + let providers: [String: [PlanUtilizationHistorySample]] +} + +enum PlanUtilizationHistoryStore { + static func load(fileManager: FileManager = .default) -> [UsageProvider: [PlanUtilizationHistorySample]] { + guard let url = self.fileURL(fileManager: fileManager) else { return [:] } + guard let data = try? Data(contentsOf: url) else { return [:] } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + guard let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) else { + return [:] + } + + var output: [UsageProvider: [PlanUtilizationHistorySample]] = [:] + for (rawProvider, samples) in decoded.providers { + guard let provider = UsageProvider(rawValue: rawProvider) else { continue } + output[provider] = samples.sorted { $0.capturedAt < $1.capturedAt } + } + return output + } + + static func save( + _ providers: [UsageProvider: [PlanUtilizationHistorySample]], + fileManager: FileManager = .default) + { + guard let url = self.fileURL(fileManager: fileManager) else { return } + + let payload = PlanUtilizationHistoryFile( + providers: Dictionary( + uniqueKeysWithValues: providers.map { provider, samples in + (provider.rawValue, samples) + })) + + do { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(payload) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + try data.write(to: url, options: [.atomic]) + } catch { + // Best-effort persistence only. + } + } + + private static func fileURL(fileManager: FileManager) -> URL? { + guard let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true) + return dir.appendingPathComponent("plan-utilization-history.json") + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..cf63ed3bd 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -168,6 +168,9 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + menu.addItem(.separator()) + } self.addActionableSections(descriptor.sections, to: menu) } @@ -216,6 +219,9 @@ extension StatusItemController { currentProvider: currentProvider, context: openAIContext, addedOpenAIWebItems: addedOpenAIWebItems) + if self.addUsageHistoryMenuItemIfNeeded(to: menu, provider: currentProvider) { + menu.addItem(.separator()) + } self.addActionableSections(descriptor.sections, to: menu) } @@ -1015,22 +1021,23 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] let width = Self.menuCardBaseWidth - guard !breakdown.isEmpty else { return nil } + let submenu = NSMenu() + submenu.delegate = self + return self.appendUsageBreakdownChartItem(to: submenu, width: width) ? submenu : nil + } + private func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] + guard !breakdown.isEmpty else { return false } if !Self.menuCardRenderingEnabled { - let submenu = NSMenu() - submenu.delegate = self let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) - return submenu + return true } - let submenu = NSMenu() - submenu.delegate = self let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes @@ -1043,7 +1050,7 @@ extension StatusItemController { chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) - return submenu + return true } private func makeCreditsHistorySubmenu() -> NSMenu? { @@ -1120,6 +1127,7 @@ extension StatusItemController { "usageBreakdownChart", "creditsHistoryChart", "costHistoryChart", + "usageHistoryChart", ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift new file mode 100644 index 000000000..e189c4fe9 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -0,0 +1,52 @@ +import AppKit +import CodexBarCore +import SwiftUI + +extension StatusItemController { + @discardableResult + func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { + guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } + let item = NSMenuItem(title: "Subscription Utilization", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) + return true + } + + private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { + guard provider == .codex || provider == .claude else { return nil } + let width: CGFloat = 310 + let submenu = NSMenu() + submenu.delegate = self + return self.appendUsageHistoryChartItem(to: submenu, provider: provider, width: width) ? submenu : nil + } + + private func appendUsageHistoryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + let samples = self.store.planUtilizationHistory(for: provider) + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = "usageHistoryChart" + submenu.addItem(chartItem) + return true + } + + let chartView = PlanUtilizationHistoryChartMenuView(provider: provider, samples: samples, width: width) + let hosting = NSHostingView(rootView: chartView) + let controller = NSHostingController(rootView: chartView) + let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + + let chartItem = NSMenuItem() + chartItem.view = hosting + chartItem.isEnabled = false + chartItem.representedObject = "usageHistoryChart" + submenu.addItem(chartItem) + return true + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift new file mode 100644 index 000000000..a17273519 --- /dev/null +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -0,0 +1,156 @@ +import CodexBarCore +import Foundation + +extension UsageStore { + private nonisolated static let codexCreditsMonthlyCapTokens: Double = 1000 + private nonisolated static let persistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator() + private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 + + func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { + self.planUtilizationHistory[provider] ?? [] + } + + func recordPlanUtilizationHistorySample( + provider: UsageProvider, + snapshot: UsageSnapshot, + credits: CreditsSnapshot? = nil, + now: Date = Date()) + async + { + guard provider == .codex || provider == .claude else { return } + + var snapshotToPersist: [UsageProvider: [PlanUtilizationHistorySample]]? + await MainActor.run { + var history = self.planUtilizationHistory[provider] ?? [] + let resolvedCredits = provider == .codex ? credits : nil + let sample = PlanUtilizationHistorySample( + capturedAt: now, + dailyUsedPercent: Self.clampedPercent(snapshot.primary?.usedPercent), + weeklyUsedPercent: Self.clampedPercent(snapshot.secondary?.usedPercent), + monthlyUsedPercent: Self.planHistoryMonthlyUsedPercent( + provider: provider, + snapshot: snapshot, + credits: resolvedCredits)) + + if let last = history.last, + now.timeIntervalSince(last.capturedAt) < Self.planUtilizationMinSampleIntervalSeconds, + Self.nearlyEqual(last.dailyUsedPercent, sample.dailyUsedPercent), + Self.nearlyEqual(last.weeklyUsedPercent, sample.weeklyUsedPercent) + { + if Self.nearlyEqual(last.monthlyUsedPercent, sample.monthlyUsedPercent) { + return + } + + if provider == .codex { + if last.monthlyUsedPercent != nil, sample.monthlyUsedPercent == nil { + return + } + if last.monthlyUsedPercent == nil, sample.monthlyUsedPercent != nil { + history[history.index(before: history.endIndex)] = sample + self.planUtilizationHistory[provider] = history + snapshotToPersist = self.planUtilizationHistory + return + } + } + } + + history.append(sample) + + // Keep at least ~13 months of hourly points per provider. + let maxSamples = 24 * 400 + if history.count > maxSamples { + history.removeFirst(history.count - maxSamples) + } + + self.planUtilizationHistory[provider] = history + snapshotToPersist = self.planUtilizationHistory + } + + guard let snapshotToPersist else { return } + await Self.persistenceCoordinator.enqueue(snapshotToPersist) + } + + nonisolated static func planHistoryMonthlyUsedPercent( + provider: UsageProvider, + snapshot: UsageSnapshot, + credits: CreditsSnapshot?) -> Double? + { + if provider == .codex, + let providerCostPercent = self.monthlyUsedPercent(from: snapshot.providerCost) + { + return providerCostPercent + } + guard provider == .codex else { return nil } + guard self.codexSupportsCreditBasedMonthly(snapshot: snapshot) else { return nil } + return self.codexMonthlyUsedPercent(from: credits) + } + + private nonisolated static func monthlyUsedPercent(from providerCost: ProviderCostSnapshot?) -> Double? { + guard let providerCost, providerCost.limit > 0 else { return nil } + let usedPercent = (providerCost.used / providerCost.limit) * 100 + return self.clampedPercent(usedPercent) + } + + private nonisolated static func codexMonthlyUsedPercent(from credits: CreditsSnapshot?) -> Double? { + guard let remaining = credits?.remaining, remaining.isFinite else { return nil } + let cap = self.codexCreditsMonthlyCapTokens + guard cap > 0 else { return nil } + let used = max(0, min(cap, cap - remaining)) + let usedPercent = (used / cap) * 100 + return self.clampedPercent(usedPercent) + } + + private nonisolated static func codexSupportsCreditBasedMonthly(snapshot: UsageSnapshot) -> Bool { + let rawPlan = snapshot.loginMethod(for: .codex)? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !rawPlan.isEmpty else { return false } + return rawPlan == "guest" || rawPlan == "free" || rawPlan == "free_workspace" + } + + private nonisolated static func clampedPercent(_ value: Double?) -> Double? { + guard let value else { return nil } + return max(0, min(100, value)) + } + + private nonisolated static func nearlyEqual(_ lhs: Double?, _ rhs: Double?, tolerance: Double = 0.1) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + true + case let (l?, r?): + abs(l - r) <= tolerance + default: + false + } + } +} + +private actor PlanUtilizationHistoryPersistenceCoordinator { + private var pendingSnapshot: [UsageProvider: [PlanUtilizationHistorySample]]? + private var isPersisting: Bool = false + + func enqueue(_ snapshot: [UsageProvider: [PlanUtilizationHistorySample]]) { + self.pendingSnapshot = snapshot + guard !self.isPersisting else { return } + self.isPersisting = true + + Task(priority: .utility) { + await self.persistLoop() + } + } + + private func persistLoop() async { + while let nextSnapshot = self.pendingSnapshot { + self.pendingSnapshot = nil + await Self.saveAsync(nextSnapshot) + } + + self.isPersisting = false + } + + private nonisolated static func saveAsync(_ snapshot: [UsageProvider: [PlanUtilizationHistorySample]]) async { + await Task.detached(priority: .utility) { + PlanUtilizationHistoryStore.save(snapshot) + }.value + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0963b8a63..ed1f59539 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -73,6 +73,10 @@ extension UsageStore { self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } + await self.recordPlanUtilizationHistorySample( + provider: provider, + snapshot: scoped, + credits: result.credits) if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index b42856c71..2c2d2acb1 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -8,6 +8,9 @@ import SweetCookieKit @MainActor extension UsageStore { + private nonisolated static let codexSnapshotWaitTimeoutSeconds: TimeInterval = 6 + private nonisolated static let codexSnapshotPollIntervalNanoseconds: UInt64 = 100_000_000 + var menuObservationToken: Int { _ = self.snapshots _ = self.errors @@ -191,8 +194,10 @@ final class UsageStore { @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var pathDebugRefreshTask: Task? + @ObservationIgnored private var codexPlanHistoryBackfillTask: Task? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @ObservationIgnored var planUtilizationHistory: [UsageProvider: [PlanUtilizationHistorySample]] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @@ -230,6 +235,7 @@ final class UsageStore { self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) + self.planUtilizationHistory = PlanUtilizationHistoryStore.load() self.logStartupState() self.bindSettings() self.detectVersions() @@ -434,6 +440,7 @@ final class UsageStore { func refresh(forceTokenUsage: Bool = false) async { guard !self.isRefreshing else { return } let refreshPhase: ProviderRefreshPhase = self.hasCompletedInitialRefresh ? .regular : .startup + let refreshStartedAt = Date() await ProviderRefreshContext.$current.withValue(refreshPhase) { self.isRefreshing = true @@ -447,7 +454,7 @@ final class UsageStore { group.addTask { await self.refreshProvider(provider) } group.addTask { await self.refreshStatus(provider) } } - group.addTask { await self.refreshCreditsIfNeeded() } + group.addTask { await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } } // Token-cost usage can be slow; run it outside the refresh group so we don't block menu updates. @@ -459,7 +466,7 @@ final class UsageStore { if self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) - await self.refreshCreditsIfNeeded() + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") @@ -621,7 +628,7 @@ final class UsageStore { } } - private func refreshCreditsIfNeeded() async { + private func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { guard self.isEnabled(.codex) else { return } do { let credits = try await self.codexFetcher.loadLatestCredits( @@ -632,6 +639,24 @@ final class UsageStore { self.lastCreditsSnapshot = credits self.creditsFailureStreak = 0 } + let codexSnapshot = await MainActor.run { + self.snapshots[.codex] + } + if let minimumSnapshotUpdatedAt, + codexSnapshot == nil || codexSnapshot?.updatedAt ?? .distantPast < minimumSnapshotUpdatedAt + { + self.scheduleCodexPlanHistoryBackfill( + minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt, + credits: credits) + return + } + + self.cancelCodexPlanHistoryBackfill() + guard let codexSnapshot else { return } + await self.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: codexSnapshot, + credits: credits) } catch { let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { @@ -864,6 +889,45 @@ extension UsageStore { } } + private func waitForCodexSnapshot(minimumUpdatedAt: Date) async -> UsageSnapshot? { + let deadline = Date().addingTimeInterval(Self.codexSnapshotWaitTimeoutSeconds) + + while Date() < deadline { + if Task.isCancelled { return nil } + if let snapshot = await MainActor.run(body: { self.snapshots[.codex] }), + snapshot.updatedAt >= minimumUpdatedAt + { + return snapshot + } + try? await Task.sleep(nanoseconds: Self.codexSnapshotPollIntervalNanoseconds) + } + + return nil + } + + private func scheduleCodexPlanHistoryBackfill( + minimumSnapshotUpdatedAt: Date, + credits: CreditsSnapshot) + { + self.cancelCodexPlanHistoryBackfill() + self.codexPlanHistoryBackfillTask = Task { @MainActor [weak self] in + guard let self else { return } + guard let snapshot = await self.waitForCodexSnapshot(minimumUpdatedAt: minimumSnapshotUpdatedAt) else { + return + } + await self.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + credits: credits) + self.codexPlanHistoryBackfillTask = nil + } + } + + private func cancelCodexPlanHistoryBackfill() { + self.codexPlanHistoryBackfillTask?.cancel() + self.codexPlanHistoryBackfillTask = nil + } + // MARK: - OpenAI web account switching /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user. diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 55fc217c6..cdfabbee2 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -334,6 +334,112 @@ struct StatusMenuTests { .contains { ($0.representedObject as? String) == "creditsHistoryChart" } == true) } + @Test + func showsUsageHistoryMenuItemForCodex() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .codex + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .codex) + store.planUtilizationHistory[.codex] = [ + PlanUtilizationHistorySample( + capturedAt: Date(), + dailyUsedPercent: 20, + weeklyUsedPercent: 40, + monthlyUsedPercent: 30), + ] + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let usageHistoryItem = menu.items.first { $0.title == "Subscription Utilization" } + #expect( + usageHistoryItem?.submenu?.items + .contains { ($0.representedObject as? String) == "usageHistoryChart" } == true) + } + + @Test + func showsUsageHistoryMenuItemForClaude() { + self.disableMenuCardsForTesting() + let settings = self.makeSettings() + settings.statusChecksEnabled = false + settings.refreshFrequency = .manual + settings.mergeIcons = true + settings.selectedMenuProvider = .claude + + let registry = ProviderRegistry.shared + if let codexMeta = registry.metadata[.codex] { + settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: false) + } + if let claudeMeta = registry.metadata[.claude] { + settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: true) + } + if let geminiMeta = registry.metadata[.gemini] { + settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) + } + + let fetcher = UsageFetcher() + let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow(usedPercent: 25, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date()), + provider: .claude) + store.planUtilizationHistory[.claude] = [ + PlanUtilizationHistorySample( + capturedAt: Date(), + dailyUsedPercent: 25, + weeklyUsedPercent: 55, + monthlyUsedPercent: 15), + ] + + let controller = StatusItemController( + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updater: DisabledUpdaterController(), + preferencesSelection: PreferencesSelection(), + statusBar: self.makeStatusBarForTesting()) + + let menu = controller.makeMenu() + controller.menuWillOpen(menu) + + let usageHistoryItem = menu.items.first { $0.title == "Subscription Utilization" } + #expect( + usageHistoryItem?.submenu?.items + .contains { ($0.representedObject as? String) == "usageHistoryChart" } == true) + } + @Test func showsCreditsBeforeCostInCodexMenuCardSections() throws { self.disableMenuCardsForTesting() diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift new file mode 100644 index 000000000..26e67b3a9 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -0,0 +1,240 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationTests { + @Test + func codexUsesProviderCostWhenAvailable() throws { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 25, + limit: 100, + currencyCode: "USD", + period: "Monthly", + resetsAt: nil, + updatedAt: Date()), + updatedAt: Date()) + let credits = CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: credits) + + #expect(try abs(#require(percent) - 25) < 0.001) + } + + @Test + func claudeIgnoresProviderCostForMonthlyHistory() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: ProviderCostSnapshot( + used: 40, + limit: 100, + currencyCode: "USD", + period: "Monthly", + resetsAt: nil, + updatedAt: Date()), + updatedAt: Date()) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .claude, + snapshot: snapshot, + credits: nil) + + #expect(percent == nil) + } + + @Test + func codexFallsBackToCredits() throws { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "free") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + let credits = CreditsSnapshot(remaining: 640, events: [], updatedAt: Date()) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: credits) + + #expect(try abs(#require(percent) - 36) < 0.001) + } + + @Test + func codexFreePlanWithoutFreshCreditsReturnsNil() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "free") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: nil) + + #expect(percent == nil) + } + + @Test + func codexPaidPlanDoesNotUseCreditsFallback() { + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "plus") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + let credits = CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: credits) + + #expect(percent == nil) + } + + @Test + func claudeWithoutProviderCostReturnsNil() { + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date()) + let credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: Date()) + + let percent = UsageStore.planHistoryMonthlyUsedPercent( + provider: .claude, + snapshot: snapshot, + credits: credits) + + #expect(percent == nil) + } + + @Test + @MainActor + func codexWithinWindowPromotesMonthlyFromNilWithoutAppending() async { + let store = self.makeUsageStore(suite: "UsageStorePlanUtilizationTests-promoteMonthly") + store.planUtilizationHistory[.codex] = [] + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "free") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + let now = Date() + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + credits: nil, + now: now) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now), + now: now.addingTimeInterval(300)) + + let history = store.planUtilizationHistory(for: .codex) + #expect(history.count == 1) + let monthly = history.last?.monthlyUsedPercent + #expect(monthly != nil) + #expect(abs((monthly ?? 0) - 36) < 0.001) + } + + @Test + @MainActor + func codexWithinWindowIgnoresNilMonthlyAfterKnownValue() async { + let store = self.makeUsageStore(suite: "UsageStorePlanUtilizationTests-ignoreNilMonthly") + store.planUtilizationHistory[.codex] = [] + + let identity = ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: nil, + accountOrganization: nil, + loginMethod: "free") + let snapshot = UsageSnapshot( + primary: nil, + secondary: nil, + providerCost: nil, + updatedAt: Date(), + identity: identity) + let now = Date() + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now), + now: now) + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + credits: nil, + now: now.addingTimeInterval(300)) + + let history = store.planUtilizationHistory(for: .codex) + #expect(history.count == 1) + let monthly = history.last?.monthlyUsedPercent + #expect(monthly != nil) + #expect(abs((monthly ?? 0) - 36) < 0.001) + } + + @MainActor + private func makeUsageStore(suite: String) -> UsageStore { + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let configStore = testConfigStore(suiteName: suite) + let settings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore(), + codexCookieStore: InMemoryCookieHeaderStore(), + claudeCookieStore: InMemoryCookieHeaderStore(), + cursorCookieStore: InMemoryCookieHeaderStore(), + opencodeCookieStore: InMemoryCookieHeaderStore(), + factoryCookieStore: InMemoryCookieHeaderStore(), + minimaxCookieStore: InMemoryMiniMaxCookieStore(), + minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), + kimiTokenStore: InMemoryKimiTokenStore(), + kimiK2TokenStore: InMemoryKimiK2TokenStore(), + augmentCookieStore: InMemoryCookieHeaderStore(), + ampCookieStore: InMemoryCookieHeaderStore(), + copilotTokenStore: InMemoryCopilotTokenStore(), + tokenAccountStore: InMemoryTokenAccountStore()) + return UsageStore( + fetcher: UsageFetcher(environment: [:]), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings) + } +} From 2534670b27103152188dbe852c4caa58385bf6a2 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Feb 2026 11:49:47 +0800 Subject: [PATCH 02/32] Align utilization bars to the left --- .../PlanUtilizationHistoryChartMenuView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 0a752f882..c27bfaacb 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -224,10 +224,10 @@ struct PlanUtilizationHistoryChartMenuView: View { points = Array(points.suffix(period.maxPoints)) } - points = points.enumerated().map { index, point in + points = points.enumerated().map { offset, point in Point( id: point.id, - index: index, + index: offset, date: point.date, usedPercent: point.usedPercent) } @@ -237,7 +237,7 @@ struct PlanUtilizationHistoryChartMenuView: View { if first == last { return [Double(first)] } return [Double(first), Double(last)] }() - let xDomain = Self.xDomain(points: points) + let xDomain = Self.xDomain(points: points, period: period) let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) }) let pointsByIndex = Dictionary(uniqueKeysWithValues: points.map { ($0.index, $0) }) @@ -253,9 +253,9 @@ struct PlanUtilizationHistoryChartMenuView: View { barColor: barColor) } - private static func xDomain(points: [Point]) -> ClosedRange? { - guard points.count == 1 else { return nil } - return 0...13 + private static func xDomain(points: [Point], period: Period) -> ClosedRange? { + guard points.count < period.maxPoints else { return nil } + return 0...Double(period.maxPoints - 1) } private static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { From 0733d2ec9382e04829288c078255331597435bad Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Feb 2026 11:57:40 +0800 Subject: [PATCH 03/32] Isolate plan-utilization tests from disk persistence --- .../CodexBar/UsageStore+PlanUtilization.swift | 90 ++++++++----- .../UsageStorePlanUtilizationTests.swift | 127 +++++++++--------- 2 files changed, 121 insertions(+), 96 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a17273519..814957958 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -5,6 +5,7 @@ extension UsageStore { private nonisolated static let codexCreditsMonthlyCapTokens: Double = 1000 private nonisolated static let persistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator() private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 + private nonisolated static let planUtilizationMaxSamples: Int = 24 * 400 func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { self.planUtilizationHistory[provider] ?? [] @@ -21,7 +22,7 @@ extension UsageStore { var snapshotToPersist: [UsageProvider: [PlanUtilizationHistorySample]]? await MainActor.run { - var history = self.planUtilizationHistory[provider] ?? [] + let history = self.planUtilizationHistory[provider] ?? [] let resolvedCredits = provider == .codex ? credits : nil let sample = PlanUtilizationHistorySample( capturedAt: now, @@ -32,37 +33,16 @@ extension UsageStore { snapshot: snapshot, credits: resolvedCredits)) - if let last = history.last, - now.timeIntervalSince(last.capturedAt) < Self.planUtilizationMinSampleIntervalSeconds, - Self.nearlyEqual(last.dailyUsedPercent, sample.dailyUsedPercent), - Self.nearlyEqual(last.weeklyUsedPercent, sample.weeklyUsedPercent) - { - if Self.nearlyEqual(last.monthlyUsedPercent, sample.monthlyUsedPercent) { - return - } - - if provider == .codex { - if last.monthlyUsedPercent != nil, sample.monthlyUsedPercent == nil { - return - } - if last.monthlyUsedPercent == nil, sample.monthlyUsedPercent != nil { - history[history.index(before: history.endIndex)] = sample - self.planUtilizationHistory[provider] = history - snapshotToPersist = self.planUtilizationHistory - return - } - } - } - - history.append(sample) - - // Keep at least ~13 months of hourly points per provider. - let maxSamples = 24 * 400 - if history.count > maxSamples { - history.removeFirst(history.count - maxSamples) + guard let updatedHistory = Self.updatedPlanUtilizationHistory( + provider: provider, + existingHistory: history, + sample: sample, + now: now) + else { + return } - self.planUtilizationHistory[provider] = history + self.planUtilizationHistory[provider] = updatedHistory snapshotToPersist = self.planUtilizationHistory } @@ -70,6 +50,56 @@ extension UsageStore { await Self.persistenceCoordinator.enqueue(snapshotToPersist) } + private nonisolated static func updatedPlanUtilizationHistory( + provider: UsageProvider, + existingHistory: [PlanUtilizationHistorySample], + sample: PlanUtilizationHistorySample, + now: Date) -> [PlanUtilizationHistorySample]? + { + var history = existingHistory + + if let last = history.last, + now.timeIntervalSince(last.capturedAt) < self.planUtilizationMinSampleIntervalSeconds, + self.nearlyEqual(last.dailyUsedPercent, sample.dailyUsedPercent), + self.nearlyEqual(last.weeklyUsedPercent, sample.weeklyUsedPercent) + { + if self.nearlyEqual(last.monthlyUsedPercent, sample.monthlyUsedPercent) { + return nil + } + + if provider == .codex { + if last.monthlyUsedPercent != nil, sample.monthlyUsedPercent == nil { + return nil + } + if last.monthlyUsedPercent == nil, sample.monthlyUsedPercent != nil { + history[history.index(before: history.endIndex)] = sample + return history + } + } + } + + history.append(sample) + if history.count > self.planUtilizationMaxSamples { + history.removeFirst(history.count - self.planUtilizationMaxSamples) + } + return history + } + + #if DEBUG + nonisolated static func _updatedPlanUtilizationHistoryForTesting( + provider: UsageProvider, + existingHistory: [PlanUtilizationHistorySample], + sample: PlanUtilizationHistorySample, + now: Date) -> [PlanUtilizationHistorySample]? + { + self.updatedPlanUtilizationHistory( + provider: provider, + existingHistory: existingHistory, + sample: sample, + now: now) + } + #endif + nonisolated static func planHistoryMonthlyUsedPercent( provider: UsageProvider, snapshot: UsageSnapshot, diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 26e67b3a9..dd6f28a55 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -136,11 +136,7 @@ struct UsageStorePlanUtilizationTests { } @Test - @MainActor - func codexWithinWindowPromotesMonthlyFromNilWithoutAppending() async { - let store = self.makeUsageStore(suite: "UsageStorePlanUtilizationTests-promoteMonthly") - store.planUtilizationHistory[.codex] = [] - + func codexWithinWindowPromotesMonthlyFromNilWithoutAppending() throws { let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: nil, @@ -153,31 +149,43 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(), identity: identity) let now = Date() - - await store.recordPlanUtilizationHistorySample( - provider: .codex, - snapshot: snapshot, - credits: nil, - now: now) - await store.recordPlanUtilizationHistorySample( - provider: .codex, - snapshot: snapshot, - credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now), - now: now.addingTimeInterval(300)) - - let history = store.planUtilizationHistory(for: .codex) - #expect(history.count == 1) - let monthly = history.last?.monthlyUsedPercent + let nilMonthly = PlanUtilizationHistorySample( + capturedAt: now, + dailyUsedPercent: nil, + weeklyUsedPercent: nil, + monthlyUsedPercent: nil) + let monthlyValue = try #require( + UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now))) + let promotedMonthly = PlanUtilizationHistorySample( + capturedAt: now.addingTimeInterval(300), + dailyUsedPercent: nil, + weeklyUsedPercent: nil, + monthlyUsedPercent: monthlyValue) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: nilMonthly, + now: now)) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: promotedMonthly, + now: now.addingTimeInterval(300))) + + #expect(updated.count == 1) + let monthly = updated.last?.monthlyUsedPercent #expect(monthly != nil) #expect(abs((monthly ?? 0) - 36) < 0.001) } @Test - @MainActor - func codexWithinWindowIgnoresNilMonthlyAfterKnownValue() async { - let store = self.makeUsageStore(suite: "UsageStorePlanUtilizationTests-ignoreNilMonthly") - store.planUtilizationHistory[.codex] = [] - + func codexWithinWindowIgnoresNilMonthlyAfterKnownValue() throws { let identity = ProviderIdentitySnapshot( providerID: .codex, accountEmail: nil, @@ -190,51 +198,38 @@ struct UsageStorePlanUtilizationTests { updatedAt: Date(), identity: identity) let now = Date() - - await store.recordPlanUtilizationHistorySample( - provider: .codex, - snapshot: snapshot, - credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now), - now: now) - await store.recordPlanUtilizationHistorySample( + let monthlyValue = try #require( + UsageStore.planHistoryMonthlyUsedPercent( + provider: .codex, + snapshot: snapshot, + credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now))) + let knownMonthly = PlanUtilizationHistorySample( + capturedAt: now, + dailyUsedPercent: nil, + weeklyUsedPercent: nil, + monthlyUsedPercent: monthlyValue) + let nilMonthly = PlanUtilizationHistorySample( + capturedAt: now.addingTimeInterval(300), + dailyUsedPercent: nil, + weeklyUsedPercent: nil, + monthlyUsedPercent: nil) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: knownMonthly, + now: now)) + let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, - snapshot: snapshot, - credits: nil, + existingHistory: initial, + sample: nilMonthly, now: now.addingTimeInterval(300)) - let history = store.planUtilizationHistory(for: .codex) - #expect(history.count == 1) - let monthly = history.last?.monthlyUsedPercent + #expect(updated == nil) + #expect(initial.count == 1) + let monthly = initial.last?.monthlyUsedPercent #expect(monthly != nil) #expect(abs((monthly ?? 0) - 36) < 0.001) } - - @MainActor - private func makeUsageStore(suite: String) -> UsageStore { - let defaults = UserDefaults(suiteName: suite)! - defaults.removePersistentDomain(forName: suite) - let configStore = testConfigStore(suiteName: suite) - let settings = SettingsStore( - userDefaults: defaults, - configStore: configStore, - zaiTokenStore: NoopZaiTokenStore(), - syntheticTokenStore: NoopSyntheticTokenStore(), - codexCookieStore: InMemoryCookieHeaderStore(), - claudeCookieStore: InMemoryCookieHeaderStore(), - cursorCookieStore: InMemoryCookieHeaderStore(), - opencodeCookieStore: InMemoryCookieHeaderStore(), - factoryCookieStore: InMemoryCookieHeaderStore(), - minimaxCookieStore: InMemoryMiniMaxCookieStore(), - minimaxAPITokenStore: InMemoryMiniMaxAPITokenStore(), - kimiTokenStore: InMemoryKimiTokenStore(), - kimiK2TokenStore: InMemoryKimiK2TokenStore(), - augmentCookieStore: InMemoryCookieHeaderStore(), - ampCookieStore: InMemoryCookieHeaderStore(), - copilotTokenStore: InMemoryCopilotTokenStore(), - tokenAccountStore: InMemoryTokenAccountStore()) - return UsageStore( - fetcher: UsageFetcher(environment: [:]), - browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings) - } } From 9535f1eea729ba69f24bf20da7d03eb651df4f9a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 19 Feb 2026 13:28:43 +0800 Subject: [PATCH 04/32] Default utilization chart to daily view --- Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index c27bfaacb..769903feb 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -64,7 +64,7 @@ struct PlanUtilizationHistoryChartMenuView: View { private let samples: [PlanUtilizationHistorySample] private let width: CGFloat - @State private var selectedPeriod: Period = .weekly + @State private var selectedPeriod: Period = .daily @State private var selectedPointID: String? init(provider: UsageProvider, samples: [PlanUtilizationHistorySample], width: CGFloat) { From 3d1166ad725bc40fb48643d30e11b5663198a89c Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 6 Mar 2026 16:18:53 +0800 Subject: [PATCH 05/32] Improve subscription utilization charts --- .../PlanUtilizationHistoryChartMenuView.swift | 56 ++++++-- .../CodexBar/StatusItemController+Menu.swift | 2 +- ...tatusItemController+UsageHistoryMenu.swift | 2 +- .../CodexBar/UsageStore+PlanUtilization.swift | 6 +- .../UsageStorePlanUtilizationTests.swift | 127 ++++++++++++++++++ 5 files changed, 176 insertions(+), 17 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 769903feb..933ff9b93 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -46,9 +46,9 @@ struct PlanUtilizationHistoryChartMenuView: View { case .daily: 30 case .weekly: - 16 + 24 case .monthly: - 12 + 24 } } } @@ -159,7 +159,7 @@ struct PlanUtilizationHistoryChartMenuView: View { let barColor: Color } - private static func makeModel( + private nonisolated static func makeModel( period: Period, samples: [PlanUtilizationHistorySample], provider: UsageProvider) -> Model @@ -232,11 +232,7 @@ struct PlanUtilizationHistoryChartMenuView: View { usedPercent: point.usedPercent) } - let axisIndexes: [Double] = { - guard let first = points.first?.index, let last = points.last?.index else { return [] } - if first == last { return [Double(first)] } - return [Double(first), Double(last)] - }() + let axisIndexes = Self.axisIndexes(points: points, period: period) let xDomain = Self.xDomain(points: points, period: period) let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) }) @@ -253,12 +249,44 @@ struct PlanUtilizationHistoryChartMenuView: View { barColor: barColor) } - private static func xDomain(points: [Point], period: Period) -> ClosedRange? { - guard points.count < period.maxPoints else { return nil } - return 0...Double(period.maxPoints - 1) + private nonisolated static func xDomain(points: [Point], period: Period) -> ClosedRange? { + guard !points.isEmpty else { return nil } + return -0.5...(Double(period.maxPoints) - 0.5) + } + + private nonisolated static func axisIndexes(points: [Point], period: Period) -> [Double] { + guard let first = points.first?.index, let last = points.last?.index else { return [] } + if first == last { return [Double(first)] } + switch period { + case .daily: + return [Double(first), Double(last)] + case .weekly, .monthly: + return [Double(last)] + } + } + + #if DEBUG + struct ModelSnapshot: Equatable { + let pointCount: Int + let axisIndexes: [Double] + let xDomain: ClosedRange? + } + + nonisolated static func _modelSnapshotForTesting( + periodRawValue: String, + samples: [PlanUtilizationHistorySample], + provider: UsageProvider) -> ModelSnapshot? + { + guard let period = Period(rawValue: periodRawValue) else { return nil } + let model = self.makeModel(period: period, samples: samples, provider: provider) + return ModelSnapshot( + pointCount: model.points.count, + axisIndexes: model.axisIndexes, + xDomain: model.xDomain) } + #endif - private static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { + private nonisolated static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { switch period { case .daily: sample.dailyUsedPercent @@ -269,7 +297,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { + private nonisolated static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { switch period { case .daily: calendar.startOfDay(for: date) @@ -280,7 +308,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private static func pointID(date: Date, period: Period) -> String { + private nonisolated static func pointID(date: Date, period: Period) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1c4939358..e13156359 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1030,7 +1030,7 @@ extension StatusItemController { var isHighlighted = false } - private final class MenuHostingView: NSHostingView { + final class MenuHostingView: NSHostingView { override var allowsVibrancy: Bool { true } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index e189c4fe9..d315fb66b 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -37,7 +37,7 @@ extension StatusItemController { } let chartView = PlanUtilizationHistoryChartMenuView(provider: provider, samples: samples, width: width) - let hosting = NSHostingView(rootView: chartView) + let hosting = MenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 814957958..fbe7772f5 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -5,7 +5,7 @@ extension UsageStore { private nonisolated static let codexCreditsMonthlyCapTokens: Double = 1000 private nonisolated static let persistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator() private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 - private nonisolated static let planUtilizationMaxSamples: Int = 24 * 400 + private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { self.planUtilizationHistory[provider] ?? [] @@ -98,6 +98,10 @@ extension UsageStore { sample: sample, now: now) } + + nonisolated static var _planUtilizationMaxSamplesForTesting: Int { + self.planUtilizationMaxSamples + } #endif nonisolated static func planHistoryMonthlyUsedPercent( diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index dd6f28a55..272c70741 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -232,4 +232,131 @@ struct UsageStorePlanUtilizationTests { #expect(monthly != nil) #expect(abs((monthly ?? 0) - 36) < 0.001) } + + @Test + func trimsHistoryToExpandedRetentionLimit() throws { + let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting + let base = Date(timeIntervalSince1970: 1_700_000_000) + var history: [PlanUtilizationHistorySample] = [] + + for offset in 0.. Date: Tue, 10 Mar 2026 10:46:25 +0800 Subject: [PATCH 06/32] Scope plan utilization history by account identity - Store plan utilization history in per-account buckets plus unscoped fallback - Hash provider identity fields for stable account keys when selecting history - Add v2 persistence with legacy file migration and round-trip coverage in tests --- .../PlanUtilizationHistoryStore.swift | 107 +++++++++-- .../CodexBar/UsageStore+PlanUtilization.swift | 62 ++++++- Sources/CodexBar/UsageStore.swift | 2 +- Tests/CodexBarTests/StatusMenuTests.swift | 30 ++-- .../UsageStorePlanUtilizationTests.swift | 168 ++++++++++++++++++ 5 files changed, 332 insertions(+), 37 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 1e91e04b2..48904ab66 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -8,40 +8,87 @@ struct PlanUtilizationHistorySample: Codable, Sendable, Equatable { let monthlyUsedPercent: Double? } +struct PlanUtilizationHistoryBuckets: Sendable, Equatable { + var unscoped: [PlanUtilizationHistorySample] = [] + var accounts: [String: [PlanUtilizationHistorySample]] = [:] + + func samples(for accountKey: String?) -> [PlanUtilizationHistorySample] { + guard let accountKey, !accountKey.isEmpty else { return self.unscoped } + return self.accounts[accountKey] ?? [] + } + + mutating func setSamples(_ samples: [PlanUtilizationHistorySample], for accountKey: String?) { + let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + guard let accountKey, !accountKey.isEmpty else { + self.unscoped = sorted + return + } + if sorted.isEmpty { + self.accounts.removeValue(forKey: accountKey) + } else { + self.accounts[accountKey] = sorted + } + } + + var isEmpty: Bool { + self.unscoped.isEmpty && self.accounts.values.allSatisfy(\.isEmpty) + } +} + private struct PlanUtilizationHistoryFile: Codable, Sendable { + let version: Int + let providers: [String: ProviderHistoryFile] +} + +private struct ProviderHistoryFile: Codable, Sendable { + let unscoped: [PlanUtilizationHistorySample] + let accounts: [String: [PlanUtilizationHistorySample]] +} + +private struct LegacyPlanUtilizationHistoryFile: Codable, Sendable { let providers: [String: [PlanUtilizationHistorySample]] } enum PlanUtilizationHistoryStore { - static func load(fileManager: FileManager = .default) -> [UsageProvider: [PlanUtilizationHistorySample]] { + private static let schemaVersion = 2 + + static func load(fileManager: FileManager = .default) -> [UsageProvider: PlanUtilizationHistoryBuckets] { guard let url = self.fileURL(fileManager: fileManager) else { return [:] } guard let data = try? Data(contentsOf: url) else { return [:] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - guard let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) else { - return [:] + if let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) { + return self.decodeProviders(decoded.providers) } - - var output: [UsageProvider: [PlanUtilizationHistorySample]] = [:] - for (rawProvider, samples) in decoded.providers { - guard let provider = UsageProvider(rawValue: rawProvider) else { continue } - output[provider] = samples.sorted { $0.capturedAt < $1.capturedAt } + guard let legacy = try? decoder.decode(LegacyPlanUtilizationHistoryFile.self, from: data) else { + return [:] } - return output + return self.decodeLegacyProviders(legacy.providers) } static func save( - _ providers: [UsageProvider: [PlanUtilizationHistorySample]], + _ providers: [UsageProvider: PlanUtilizationHistoryBuckets], fileManager: FileManager = .default) { guard let url = self.fileURL(fileManager: fileManager) else { return } + let persistedProviders = providers.reduce(into: [String: ProviderHistoryFile]()) { output, entry in + let (provider, buckets) = entry + guard !buckets.isEmpty else { return } + let accounts: [String: [PlanUtilizationHistorySample]] = Dictionary( + uniqueKeysWithValues: buckets.accounts.compactMap { accountKey, samples in + let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + guard !sorted.isEmpty else { return nil } + return (accountKey, sorted) + }) + output[provider.rawValue] = ProviderHistoryFile( + unscoped: buckets.unscoped.sorted { $0.capturedAt < $1.capturedAt }, + accounts: accounts) + } let payload = PlanUtilizationHistoryFile( - providers: Dictionary( - uniqueKeysWithValues: providers.map { provider, samples in - (provider.rawValue, samples) - })) + version: self.schemaVersion, + providers: persistedProviders) do { let encoder = JSONEncoder() @@ -50,12 +97,42 @@ enum PlanUtilizationHistoryStore { try fileManager.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) + try data.write(to: url, options: Data.WritingOptions.atomic) } catch { // Best-effort persistence only. } } + private static func decodeProviders( + _ providers: [String: ProviderHistoryFile]) -> [UsageProvider: PlanUtilizationHistoryBuckets] + { + var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] + for (rawProvider, providerHistory) in providers { + guard let provider = UsageProvider(rawValue: rawProvider) else { continue } + output[provider] = PlanUtilizationHistoryBuckets( + unscoped: providerHistory.unscoped.sorted { $0.capturedAt < $1.capturedAt }, + accounts: Dictionary( + uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, samples in + let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + guard !sorted.isEmpty else { return nil } + return (accountKey, sorted) + })) + } + return output + } + + private static func decodeLegacyProviders( + _ providers: [String: [PlanUtilizationHistorySample]]) -> [UsageProvider: PlanUtilizationHistoryBuckets] + { + var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] + for (rawProvider, samples) in providers { + guard let provider = UsageProvider(rawValue: rawProvider) else { continue } + output[provider] = PlanUtilizationHistoryBuckets( + unscoped: samples.sorted { $0.capturedAt < $1.capturedAt }) + } + return output + } + private static func fileURL(fileManager: FileManager) -> URL? { guard let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index fbe7772f5..cebe73fc6 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -1,4 +1,5 @@ import CodexBarCore +import CryptoKit import Foundation extension UsageStore { @@ -8,7 +9,8 @@ extension UsageStore { private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { - self.planUtilizationHistory[provider] ?? [] + let accountKey = self.planUtilizationAccountKey(for: provider) + return self.planUtilizationHistory[provider]?.samples(for: accountKey) ?? [] } func recordPlanUtilizationHistorySample( @@ -20,9 +22,11 @@ extension UsageStore { { guard provider == .codex || provider == .claude else { return } - var snapshotToPersist: [UsageProvider: [PlanUtilizationHistorySample]]? + let accountKey = Self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { - let history = self.planUtilizationHistory[provider] ?? [] + let providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + let history = providerBuckets.samples(for: accountKey) let resolvedCredits = provider == .codex ? credits : nil let sample = PlanUtilizationHistorySample( capturedAt: now, @@ -42,7 +46,9 @@ extension UsageStore { return } - self.planUtilizationHistory[provider] = updatedHistory + var updatedBuckets = providerBuckets + updatedBuckets.setSamples(updatedHistory, for: accountKey) + self.planUtilizationHistory[provider] = updatedBuckets snapshotToPersist = self.planUtilizationHistory } @@ -157,13 +163,55 @@ extension UsageStore { false } } + + private func planUtilizationAccountKey(for provider: UsageProvider) -> String? { + guard let snapshot = self.snapshots[provider] else { return nil } + return Self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + } + + private nonisolated static func planUtilizationAccountKey( + provider: UsageProvider, + snapshot: UsageSnapshot) -> String? + { + guard let identity = snapshot.identity(for: provider) else { return nil } + + let normalizedEmail = identity.accountEmail? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if let normalizedEmail, !normalizedEmail.isEmpty { + return self.sha256Hex("\(provider.rawValue):email:\(normalizedEmail)") + } + + let normalizedOrganization = identity.accountOrganization? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if let normalizedOrganization, !normalizedOrganization.isEmpty { + return self.sha256Hex("\(provider.rawValue):organization:\(normalizedOrganization)") + } + + return nil + } + + private nonisolated static func sha256Hex(_ input: String) -> String { + let digest = SHA256.hash(data: Data(input.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + #if DEBUG + nonisolated static func _planUtilizationAccountKeyForTesting( + provider: UsageProvider, + snapshot: UsageSnapshot) -> String? + { + self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + } + #endif } private actor PlanUtilizationHistoryPersistenceCoordinator { - private var pendingSnapshot: [UsageProvider: [PlanUtilizationHistorySample]]? + private var pendingSnapshot: [UsageProvider: PlanUtilizationHistoryBuckets]? private var isPersisting: Bool = false - func enqueue(_ snapshot: [UsageProvider: [PlanUtilizationHistorySample]]) { + func enqueue(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) { self.pendingSnapshot = snapshot guard !self.isPersisting else { return } self.isPersisting = true @@ -182,7 +230,7 @@ private actor PlanUtilizationHistoryPersistenceCoordinator { self.isPersisting = false } - private nonisolated static func saveAsync(_ snapshot: [UsageProvider: [PlanUtilizationHistorySample]]) async { + private nonisolated static func saveAsync(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) async { await Task.detached(priority: .utility) { PlanUtilizationHistoryStore.save(snapshot) }.value diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index e9bff7f92..7408278c3 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -158,7 +158,7 @@ final class UsageStore { @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] - @ObservationIgnored var planUtilizationHistory: [UsageProvider: [PlanUtilizationHistorySample]] = [:] + @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index 070003c80..b7529b982 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -595,13 +595,14 @@ struct StatusMenuTests { secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), updatedAt: Date()), provider: .codex) - store.planUtilizationHistory[.codex] = [ - PlanUtilizationHistorySample( - capturedAt: Date(), - dailyUsedPercent: 20, - weeklyUsedPercent: 40, - monthlyUsedPercent: 30), - ] + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [ + PlanUtilizationHistorySample( + capturedAt: Date(), + dailyUsedPercent: 20, + weeklyUsedPercent: 40, + monthlyUsedPercent: 30), + ]) let controller = StatusItemController( store: store, @@ -648,13 +649,14 @@ struct StatusMenuTests { secondary: RateWindow(usedPercent: 55, windowMinutes: nil, resetsAt: nil, resetDescription: nil), updatedAt: Date()), provider: .claude) - store.planUtilizationHistory[.claude] = [ - PlanUtilizationHistorySample( - capturedAt: Date(), - dailyUsedPercent: 25, - weeklyUsedPercent: 55, - monthlyUsedPercent: 15), - ] + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + unscoped: [ + PlanUtilizationHistorySample( + capturedAt: Date(), + dailyUsedPercent: 25, + weeklyUsedPercent: 55, + monthlyUsedPercent: 15), + ]) let controller = StatusItemController( store: store, diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 272c70741..3308f5927 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -359,4 +359,172 @@ struct UsageStorePlanUtilizationTests { #expect(model.axisIndexes == [23]) #expect(model.xDomain == -0.5...23.5) } + + @MainActor + @Test + func planHistorySelectsCurrentAccountBucket() throws { + let store = Self.makeStore() + let aliceSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + let bobSnapshot = Self.makeSnapshot(provider: .codex, email: "bob@example.com") + let aliceKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: aliceSnapshot)) + let bobKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: bobSnapshot)) + + let aliceSample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: 30) + let bobSample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_086_400), + dailyUsedPercent: 40, + weeklyUsedPercent: 50, + monthlyUsedPercent: 60) + + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + unscoped: [ + PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_699_913_600), + dailyUsedPercent: 90, + weeklyUsedPercent: 90, + monthlyUsedPercent: 90), + ], + accounts: [ + aliceKey: [aliceSample], + bobKey: [bobSample], + ]) + + store._setSnapshotForTesting(aliceSnapshot, provider: .codex) + #expect(store.planUtilizationHistory(for: .codex) == [aliceSample]) + + store._setSnapshotForTesting(bobSnapshot, provider: .codex) + #expect(store.planUtilizationHistory(for: .codex) == [bobSample]) + } + + @MainActor + @Test + func planHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { + let store = Self.makeStore() + let sample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 20, + weeklyUsedPercent: 30, + monthlyUsedPercent: 40) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) + store._setSnapshotForTesting( + UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: Date()), + provider: .claude) + + #expect(store.planUtilizationHistory(for: .claude) == [sample]) + } + + @Test + func storeLoadsLegacyProviderHistoryIntoUnscopedBucket() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileManager = PlanHistoryFileManager(applicationSupportURL: root) + let url = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("plan-utilization-history.json") + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + + let sample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: 30) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(LegacyPlanUtilizationHistoryFileFixture( + providers: ["codex": [sample]])) + try data.write(to: url, options: [.atomic]) + + let loaded = PlanUtilizationHistoryStore.load(fileManager: fileManager) + + #expect(loaded[.codex]?.unscoped == [sample]) + #expect(loaded[.codex]?.accounts.isEmpty == true) + } + + @Test + func storeRoundTripsAccountBuckets() { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + let fileManager = PlanHistoryFileManager(applicationSupportURL: root) + let aliceSample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: 30) + let legacySample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_699_913_600), + dailyUsedPercent: 50, + weeklyUsedPercent: 60, + monthlyUsedPercent: 70) + let buckets = PlanUtilizationHistoryBuckets( + unscoped: [legacySample], + accounts: ["alice": [aliceSample]]) + + PlanUtilizationHistoryStore.save([.codex: buckets], fileManager: fileManager) + let loaded = PlanUtilizationHistoryStore.load(fileManager: fileManager) + + #expect(loaded == [.codex: buckets]) + } +} + +extension UsageStorePlanUtilizationTests { + @MainActor + private static func makeStore() -> UsageStore { + let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName) ?? .standard + defaults.removePersistentDomain(forName: suiteName) + let settings = SettingsStore(userDefaults: defaults) + return UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + } + + private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: provider, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} + +private struct LegacyPlanUtilizationHistoryFileFixture: Codable { + let providers: [String: [PlanUtilizationHistorySample]] +} + +private final class PlanHistoryFileManager: FileManager { + private let applicationSupportURL: URL + + init(applicationSupportURL: URL) { + self.applicationSupportURL = applicationSupportURL + super.init() + } + + override func urls(for directory: SearchPathDirectory, in domainMask: SearchPathDomainMask) -> [URL] { + if directory == .applicationSupportDirectory, domainMask == .userDomainMask { + return [self.applicationSupportURL] + } + return super.urls(for: directory, in: domainMask) + } } From 3655d076325005576a57391c5e96deebde1d2a4d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Sun, 15 Mar 2026 13:33:25 +0800 Subject: [PATCH 07/32] Scope plan utilization history to selected token accounts - Use token-account-derived bucket keys for plan history when available - Fall back to identity keys for legacy/snapshot-only samples - Add tests for bucket selection, legacy isolation, and recording behavior --- .../CodexBar/UsageStore+PlanUtilization.swift | 45 ++++++-- .../UsageStorePlanUtilizationTests.swift | 103 +++++++++++++++++- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index cebe73fc6..993f1ddee 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -16,16 +16,18 @@ extension UsageStore { func recordPlanUtilizationHistorySample( provider: UsageProvider, snapshot: UsageSnapshot, + account: ProviderTokenAccount? = nil, credits: CreditsSnapshot? = nil, now: Date = Date()) async { guard provider == .codex || provider == .claude else { return } - let accountKey = Self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + let accountKey = Self.planUtilizationAccountKey(provider: provider, account: account) + ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { - let providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let history = providerBuckets.samples(for: accountKey) let resolvedCredits = provider == .codex ? credits : nil let sample = PlanUtilizationHistorySample( @@ -46,9 +48,8 @@ extension UsageStore { return } - var updatedBuckets = providerBuckets - updatedBuckets.setSamples(updatedHistory, for: accountKey) - self.planUtilizationHistory[provider] = updatedBuckets + providerBuckets.setSamples(updatedHistory, for: accountKey) + self.planUtilizationHistory[provider] = providerBuckets snapshotToPersist = self.planUtilizationHistory } @@ -165,11 +166,32 @@ extension UsageStore { } private func planUtilizationAccountKey(for provider: UsageProvider) -> String? { - guard let snapshot = self.snapshots[provider] else { return nil } - return Self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + self.planUtilizationAccountKey(for: provider, snapshot: nil, preferredAccount: nil) + } + + private func planUtilizationAccountKey( + for provider: UsageProvider, + snapshot: UsageSnapshot? = nil, + preferredAccount: ProviderTokenAccount? = nil) -> String? + { + let account = preferredAccount ?? self.settings.selectedTokenAccount(for: provider) + let accountKey = Self.planUtilizationAccountKey(provider: provider, account: account) + if let accountKey { + return accountKey + } + let resolvedSnapshot = snapshot ?? self.snapshots[provider] + return resolvedSnapshot.flatMap { Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: $0) } } private nonisolated static func planUtilizationAccountKey( + provider: UsageProvider, + account: ProviderTokenAccount?) -> String? + { + guard let account else { return nil } + return self.sha256Hex("\(provider.rawValue):token-account:\(account.id.uuidString.lowercased())") + } + + private nonisolated static func planUtilizationIdentityAccountKey( provider: UsageProvider, snapshot: UsageSnapshot) -> String? { @@ -202,7 +224,14 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot) -> String? { - self.planUtilizationAccountKey(provider: provider, snapshot: snapshot) + self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) + } + + nonisolated static func _planUtilizationTokenAccountKeyForTesting( + provider: UsageProvider, + account: ProviderTokenAccount) -> String? + { + self.planUtilizationAccountKey(provider: provider, account: account) } #endif } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 3308f5927..1b3d49ddd 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -406,6 +406,77 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .codex) == [bobSample]) } + @MainActor + @Test + func planHistorySelectsConfiguredTokenAccountBucket() throws { + let store = Self.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .claude, + account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .claude, + account: bob)) + + let aliceSample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 15, + weeklyUsedPercent: 25, + monthlyUsedPercent: 35) + let bobSample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_086_400), + dailyUsedPercent: 45, + weeklyUsedPercent: 55, + monthlyUsedPercent: 65) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [ + aliceKey: [aliceSample], + bobKey: [bobSample], + ]) + + store.settings.setActiveTokenAccountIndex(0, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [aliceSample]) + + store.settings.setActiveTokenAccountIndex(1, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) + } + + @MainActor + @Test + func recordPlanHistoryWithoutExplicitAccountPrefersSnapshotIdentityOverSelectedTokenAccount() async throws { + let store = Self.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(1, for: .claude) + + let aliceSnapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") + let aliceIdentityKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: aliceSnapshot)) + let selectedTokenKey = try #require( + store.settings.selectedTokenAccount(for: .claude).flatMap { + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: $0) + }) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: aliceSnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[aliceIdentityKey]?.count == 1) + #expect(buckets.accounts[selectedTokenKey] == nil) + } + @MainActor @Test func planHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { @@ -427,6 +498,30 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .claude) == [sample]) } + @MainActor + @Test + func planHistoryDoesNotReadLegacyIdentityBucketForTokenAccounts() throws { + let store = Self.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + + let snapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") + let legacyKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: snapshot)) + let legacySample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: nil) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [legacyKey: [legacySample]]) + store._setSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.planUtilizationHistory(for: .claude).isEmpty) + } + @Test func storeLoadsLegacyProviderHistoryIntoUnscopedBucket() throws { let root = FileManager.default.temporaryDirectory @@ -488,11 +583,15 @@ extension UsageStorePlanUtilizationTests { let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName) ?? .standard defaults.removePersistentDomain(forName: suiteName) - let settings = SettingsStore(userDefaults: defaults) + let configStore = testConfigStore(suiteName: suiteName) + let isolatedSettings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + tokenAccountStore: InMemoryTokenAccountStore()) return UsageStore( fetcher: UsageFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), - settings: settings, + settings: isolatedSettings, startupBehavior: .testing) } From d9e5353064b2fa9e148849338612f7e826bb91eb Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 16 Mar 2026 22:41:20 +0800 Subject: [PATCH 08/32] Use selected token account for implicit plan history buckets - Prefer the active token account when recording plan utilization without an explicit account - Update Claude plan-utilization test to assert writes land in the selected token bucket --- Sources/CodexBar/UsageStore+PlanUtilization.swift | 3 ++- Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 993f1ddee..a3659941d 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -23,7 +23,8 @@ extension UsageStore { { guard provider == .codex || provider == .claude else { return } - let accountKey = Self.planUtilizationAccountKey(provider: provider, account: account) + let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) + let accountKey = Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 1b3d49ddd..1b00513d9 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -451,17 +451,13 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func recordPlanHistoryWithoutExplicitAccountPrefersSnapshotIdentityOverSelectedTokenAccount() async throws { + func recordPlanHistoryWithoutExplicitAccountUsesSelectedTokenAccountBucket() async throws { let store = Self.makeStore() store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") store.settings.setActiveTokenAccountIndex(1, for: .claude) let aliceSnapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") - let aliceIdentityKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: aliceSnapshot)) let selectedTokenKey = try #require( store.settings.selectedTokenAccount(for: .claude).flatMap { UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: $0) @@ -473,8 +469,7 @@ struct UsageStorePlanUtilizationTests { now: Date(timeIntervalSince1970: 1_700_000_000)) let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[aliceIdentityKey]?.count == 1) - #expect(buckets.accounts[selectedTokenKey] == nil) + #expect(buckets.accounts[selectedTokenKey]?.count == 1) } @MainActor From 89d4d1549600e9a38be9a2fb8491437b9352d792 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 16 Mar 2026 22:51:25 +0800 Subject: [PATCH 09/32] Refactor StatusMenuTests naming and remove obsolete history test - Convert test method names to backticked descriptive phrases - Remove redundant `@Suite` annotation from `StatusMenuTests` - Delete outdated Codex usage history submenu test --- Tests/CodexBarTests/StatusMenuTests.swift | 93 +++++------------------ 1 file changed, 19 insertions(+), 74 deletions(-) diff --git a/Tests/CodexBarTests/StatusMenuTests.swift b/Tests/CodexBarTests/StatusMenuTests.swift index e18fb6966..017e591d0 100644 --- a/Tests/CodexBarTests/StatusMenuTests.swift +++ b/Tests/CodexBarTests/StatusMenuTests.swift @@ -4,7 +4,6 @@ import Testing @testable import CodexBar @MainActor -@Suite struct StatusMenuTests { private func disableMenuCardsForTesting() { StatusItemController.menuCardRenderingEnabled = false @@ -43,7 +42,7 @@ struct StatusMenuTests { } @Test - func remembersProviderWhenMenuOpens() { + func `remembers provider when menu opens`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -87,7 +86,7 @@ struct StatusMenuTests { } @Test - func mergedMenuOpenDoesNotPersistResolvedProviderWhenSelectionIsNil() { + func `merged menu open does not persist resolved provider when selection is nil`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -128,7 +127,7 @@ struct StatusMenuTests { } @Test - func mergedMenuRefreshUsesResolvedEnabledProviderWhenPersistedSelectionIsDisabled() { + func `merged menu refresh uses resolved enabled provider when persisted selection is disabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -195,7 +194,7 @@ struct StatusMenuTests { } @Test - func openMergedMenuRebuildsSwitcherWhenUsageBarsModeChanges() { + func `open merged menu rebuilds switcher when usage bars mode changes`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -242,7 +241,7 @@ struct StatusMenuTests { } @Test - func mergedSwitcherIncludesOverviewTabWhenMultipleProvidersEnabled() { + func `merged switcher includes overview tab when multiple providers enabled`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -278,7 +277,7 @@ struct StatusMenuTests { } @Test - func mergedSwitcherOverviewSelectionPersistsWithoutOverwritingProviderSelection() { + func `merged switcher overview selection persists without overwriting provider selection`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -323,7 +322,7 @@ struct StatusMenuTests { } @Test - func openMenuRebuildsSwitcherWhenOverviewAvailabilityChanges() { + func `open menu rebuilds switcher when overview availability changes`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -377,7 +376,7 @@ struct StatusMenuTests { } @Test - func overviewTabOmitsContextualProviderActions() { + func `overview tab omits contextual provider actions`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -417,7 +416,7 @@ struct StatusMenuTests { } @Test - func providerToggleUpdatesStatusItemVisibility() { + func `provider toggle updates status item visibility`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -456,7 +455,7 @@ struct StatusMenuTests { } @Test - func hidesOpenAIWebSubmenusWhenNoHistory() { + func `hides open AI web submenus when no history`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -502,7 +501,7 @@ struct StatusMenuTests { } @Test - func showsOpenAIWebSubmenusWhenHistoryExists() throws { + func `shows open AI web submenus when history exists`() throws { self.disableMenuCardsForTesting() let settings = SettingsStore( configStore: testConfigStore(suiteName: "StatusMenuTests-history"), @@ -568,61 +567,7 @@ struct StatusMenuTests { } @Test - func showsUsageHistoryMenuItemForCodex() { - self.disableMenuCardsForTesting() - let settings = self.makeSettings() - settings.statusChecksEnabled = false - settings.refreshFrequency = .manual - settings.mergeIcons = true - settings.selectedMenuProvider = .codex - - let registry = ProviderRegistry.shared - if let codexMeta = registry.metadata[.codex] { - settings.setProviderEnabled(provider: .codex, metadata: codexMeta, enabled: true) - } - if let claudeMeta = registry.metadata[.claude] { - settings.setProviderEnabled(provider: .claude, metadata: claudeMeta, enabled: false) - } - if let geminiMeta = registry.metadata[.gemini] { - settings.setProviderEnabled(provider: .gemini, metadata: geminiMeta, enabled: false) - } - - let fetcher = UsageFetcher() - let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - store._setSnapshotForTesting( - UsageSnapshot( - primary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date()), - provider: .codex) - store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( - unscoped: [ - PlanUtilizationHistorySample( - capturedAt: Date(), - dailyUsedPercent: 20, - weeklyUsedPercent: 40, - monthlyUsedPercent: 30), - ]) - - let controller = StatusItemController( - store: store, - settings: settings, - account: fetcher.loadAccountInfo(), - updater: DisabledUpdaterController(), - preferencesSelection: PreferencesSelection(), - statusBar: self.makeStatusBarForTesting()) - - let menu = controller.makeMenu() - controller.menuWillOpen(menu) - - let usageHistoryItem = menu.items.first { $0.title == "Subscription Utilization" } - #expect( - usageHistoryItem?.submenu?.items - .contains { ($0.representedObject as? String) == "usageHistoryChart" } == true) - } - - @Test - func showsCreditsBeforeCostInCodexMenuCardSections() throws { + func `shows credits before cost in codex menu card sections`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -689,7 +634,7 @@ struct StatusMenuTests { } @Test - func showsExtraUsageForClaudeWhenUsingMenuCardSections() { + func `shows extra usage for claude when using menu card sections`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -763,7 +708,7 @@ struct StatusMenuTests { } @Test - func showsVertexCostWhenUsageErrorPresent() { + func `shows vertex cost when usage error present`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -820,7 +765,7 @@ struct StatusMenuTests { extension StatusMenuTests { @Test - func overviewTabRendersOverviewRowsForAllActiveProvidersWhenThreeOrFewer() { + func `overview tab renders overview rows for all active providers when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -859,7 +804,7 @@ extension StatusMenuTests { } @Test - func overviewTabHonorsStoredSubsetWhenThreeOrFewer() { + func `overview tab honors stored subset when three or fewer`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -902,7 +847,7 @@ extension StatusMenuTests { } @Test - func overviewTabWithExplicitEmptySelectionIsHiddenAndShowsProviderDetail() { + func `overview tab with explicit empty selection is hidden and shows provider detail`() { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false @@ -947,7 +892,7 @@ extension StatusMenuTests { } @Test - func overviewRowsKeepMenuItemActionInRenderedMode() throws { + func `overview rows keep menu item action in rendered mode`() throws { StatusItemController.menuCardRenderingEnabled = true StatusItemController.menuRefreshEnabled = false defer { self.disableMenuCardsForTesting() } @@ -987,7 +932,7 @@ extension StatusMenuTests { } @Test - func selectingOverviewRowSwitchesToProviderDetail() throws { + func `selecting overview row switches to provider detail`() throws { self.disableMenuCardsForTesting() let settings = self.makeSettings() settings.statusChecksEnabled = false From 20b01ef9be2184baea3fe02454ab7f637df51ef0 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 16 Mar 2026 23:40:15 +0800 Subject: [PATCH 10/32] Show refreshing state and skip unscoped Claude history - Display "Refreshing..." in usage history chart when a provider refresh is in progress and no samples exist - Prevent Claude plan utilization reads/writes when identity/account key is unavailable - Add tests for empty-state messaging and Claude/Codex history fallback behavior --- .../PlanUtilizationHistoryChartMenuView.swift | 18 +++++- ...tatusItemController+UsageHistoryMenu.swift | 7 ++- .../CodexBar/UsageStore+PlanUtilization.swift | 10 ++- .../UsageStorePlanUtilizationTests.swift | 63 +++++++++++++++++-- 4 files changed, 88 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 933ff9b93..dfb0892b0 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -63,14 +63,16 @@ struct PlanUtilizationHistoryChartMenuView: View { private let provider: UsageProvider private let samples: [PlanUtilizationHistorySample] private let width: CGFloat + private let isRefreshing: Bool @State private var selectedPeriod: Period = .daily @State private var selectedPointID: String? - init(provider: UsageProvider, samples: [PlanUtilizationHistorySample], width: CGFloat) { + init(provider: UsageProvider, samples: [PlanUtilizationHistorySample], width: CGFloat, isRefreshing: Bool = false) { self.provider = provider self.samples = samples self.width = width + self.isRefreshing = isRefreshing } var body: some View { @@ -89,7 +91,7 @@ struct PlanUtilizationHistoryChartMenuView: View { if model.points.isEmpty { ZStack { - Text(self.selectedPeriod.emptyStateText) + Text(Self.emptyStateText(period: self.selectedPeriod, isRefreshing: self.isRefreshing)) .font(.footnote) .foregroundStyle(.secondary) } @@ -284,8 +286,20 @@ struct PlanUtilizationHistoryChartMenuView: View { axisIndexes: model.axisIndexes, xDomain: model.xDomain) } + + nonisolated static func _emptyStateTextForTesting(periodRawValue: String, isRefreshing: Bool) -> String? { + guard let period = Period(rawValue: periodRawValue) else { return nil } + return self.emptyStateText(period: period, isRefreshing: isRefreshing) + } #endif + private nonisolated static func emptyStateText(period: Period, isRefreshing: Bool) -> String { + if isRefreshing { + return "Refreshing..." + } + return period.emptyStateText + } + private nonisolated static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { switch period { case .daily: diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index d315fb66b..7e384e0dc 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -27,6 +27,7 @@ extension StatusItemController { width: CGFloat) -> Bool { let samples = self.store.planUtilizationHistory(for: provider) + let isRefreshing = self.store.refreshingProviders.contains(provider) && samples.isEmpty if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() @@ -36,7 +37,11 @@ extension StatusItemController { return true } - let chartView = PlanUtilizationHistoryChartMenuView(provider: provider, samples: samples, width: width) + let chartView = PlanUtilizationHistoryChartMenuView( + provider: provider, + samples: samples, + width: width, + isRefreshing: isRefreshing) let hosting = MenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a3659941d..ffd174154 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -10,6 +10,7 @@ extension UsageStore { func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { let accountKey = self.planUtilizationAccountKey(for: provider) + if provider == .claude, accountKey == nil { return [] } return self.planUtilizationHistory[provider]?.samples(for: accountKey) ?? [] } @@ -23,12 +24,15 @@ extension UsageStore { { guard provider == .codex || provider == .claude else { return } - let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) - let accountKey = Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) - ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) + let accountKey = Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) + ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) + if provider == .claude, accountKey == nil { + return + } let history = providerBuckets.samples(for: accountKey) let resolvedCredits = provider == .codex ? credits : nil let sample = PlanUtilizationHistorySample( diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 1b00513d9..259b2b73f 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -360,6 +360,26 @@ struct UsageStorePlanUtilizationTests { #expect(model.xDomain == -0.5...23.5) } + @Test + func chartEmptyStateShowsRefreshingWhileLoading() throws { + let text = try #require( + PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting( + periodRawValue: "daily", + isRefreshing: true)) + + #expect(text == "Refreshing...") + } + + @Test + func chartEmptyStateShowsPeriodSpecificMessageWhenNotRefreshing() throws { + let text = try #require( + PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting( + periodRawValue: "weekly", + isRefreshing: false)) + + #expect(text == "No weekly utilization data yet.") + } + @MainActor @Test func planHistorySelectsCurrentAccountBucket() throws { @@ -474,7 +494,7 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func planHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { + func codexPlanHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { let store = Self.makeStore() let sample = PlanUtilizationHistorySample( capturedAt: Date(timeIntervalSince1970: 1_700_000_000), @@ -482,15 +502,32 @@ struct UsageStorePlanUtilizationTests { weeklyUsedPercent: 30, monthlyUsedPercent: 40) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(unscoped: [sample]) store._setSnapshotForTesting( UsageSnapshot( primary: nil, secondary: nil, updatedAt: Date()), - provider: .claude) + provider: .codex) - #expect(store.planUtilizationHistory(for: .claude) == [sample]) + #expect(store.planUtilizationHistory(for: .codex) == [sample]) + } + + @MainActor + @Test + func claudePlanHistoryReturnsEmptyWhenIdentityIsUnavailable() { + let store = Self.makeStore() + let sample = PlanUtilizationHistorySample( + capturedAt: Date(timeIntervalSince1970: 1_700_000_000), + dailyUsedPercent: 20, + weeklyUsedPercent: 30, + monthlyUsedPercent: nil) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + unscoped: [sample], + accounts: ["claude-account-key": [sample]]) + + #expect(store.planUtilizationHistory(for: .claude).isEmpty) } @MainActor @@ -517,6 +554,24 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .claude).isEmpty) } + @MainActor + @Test + func recordPlanHistoryWithoutIdentitySkipsClaudeWrite() async { + let store = Self.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 11, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 21, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + let existingHistory = store.planUtilizationHistory[.claude] + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_003_600)) + + #expect(store.planUtilizationHistory[.claude] == existingHistory) + } + @Test func storeLoadsLegacyProviderHistoryIntoUnscopedBucket() throws { let root = FileManager.default.temporaryDirectory From 0bf95f0a517656ce343e88733c3098223d885aef Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 00:01:34 +0800 Subject: [PATCH 11/32] Record plan history for selected token account - Save a plan-utilization history sample after successful selected-account fetches - Add coverage to ensure selected Claude token accounts write to scoped history buckets --- .../CodexBar/UsageStore+TokenAccounts.swift | 5 +++ .../UsageStorePlanUtilizationTests.swift | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index f8cfd2f87..ccca3031a 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -162,6 +162,11 @@ extension UsageStore { self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } + await self.recordPlanUtilizationHistorySample( + provider: provider, + snapshot: labeled, + account: account, + credits: result.credits) case let .failure(error): await MainActor.run { let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 259b2b73f..bedf82ac0 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -492,6 +492,39 @@ struct UsageStorePlanUtilizationTests { #expect(buckets.accounts[selectedTokenKey]?.count == 1) } + @MainActor + @Test + func applySelectedOutcomeRecordsPlanHistoryForSelectedTokenAccount() async throws { + let store = Self.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(1, for: .claude) + + let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) + let selectedTokenKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) + let snapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") + let outcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + + await store.applySelectedOutcome( + outcome, + provider: .claude, + account: selectedAccount, + fallbackSnapshot: snapshot) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[selectedTokenKey]?.count == 1) + } + @MainActor @Test func codexPlanHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { From aeb50d694fb4640825f30f1409c2f9d2c83ec25e Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 09:39:22 +0800 Subject: [PATCH 12/32] Coalesce plan utilization samples by hour bucket - Merge same-hour plan utilization updates instead of appending duplicates - Preserve newer values when stale concurrent writes arrive - Add tests for hour-bucket coalescing, stale-write handling, and concurrent writes --- .../CodexBar/UsageStore+PlanUtilization.swift | 91 ++++++---- .../UsageStorePlanUtilizationTests.swift | 168 ++++++++++++++++-- 2 files changed, 216 insertions(+), 43 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index ffd174154..1bf3b808c 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -26,6 +26,8 @@ extension UsageStore { var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { + // History mutation stays serialized on MainActor so overlapping refresh tasks cannot race each other + // into duplicate writes for the same provider/account bucket. var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) let accountKey = Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) @@ -47,8 +49,7 @@ extension UsageStore { guard let updatedHistory = Self.updatedPlanUtilizationHistory( provider: provider, existingHistory: history, - sample: sample, - now: now) + sample: sample) else { return } @@ -65,32 +66,30 @@ extension UsageStore { private nonisolated static func updatedPlanUtilizationHistory( provider: UsageProvider, existingHistory: [PlanUtilizationHistorySample], - sample: PlanUtilizationHistorySample, - now: Date) -> [PlanUtilizationHistorySample]? + sample: PlanUtilizationHistorySample) -> [PlanUtilizationHistorySample]? { var history = existingHistory - - if let last = history.last, - now.timeIntervalSince(last.capturedAt) < self.planUtilizationMinSampleIntervalSeconds, - self.nearlyEqual(last.dailyUsedPercent, sample.dailyUsedPercent), - self.nearlyEqual(last.weeklyUsedPercent, sample.weeklyUsedPercent) - { - if self.nearlyEqual(last.monthlyUsedPercent, sample.monthlyUsedPercent) { + let sampleHourBucket = self.planUtilizationHourBucket(for: sample.capturedAt) + + if let matchingIndex = history.lastIndex(where: { + self.planUtilizationHourBucket(for: $0.capturedAt) == sampleHourBucket + }) { + let merged = self.mergedPlanUtilizationHistorySample( + existing: history[matchingIndex], + incoming: sample) + if merged == history[matchingIndex] { return nil } + history[matchingIndex] = merged + return history + } - if provider == .codex { - if last.monthlyUsedPercent != nil, sample.monthlyUsedPercent == nil { - return nil - } - if last.monthlyUsedPercent == nil, sample.monthlyUsedPercent != nil { - history[history.index(before: history.endIndex)] = sample - return history - } - } + if let insertionIndex = history.firstIndex(where: { $0.capturedAt > sample.capturedAt }) { + history.insert(sample, at: insertionIndex) + } else { + history.append(sample) } - history.append(sample) if history.count > self.planUtilizationMaxSamples { history.removeFirst(history.count - self.planUtilizationMaxSamples) } @@ -101,14 +100,12 @@ extension UsageStore { nonisolated static func _updatedPlanUtilizationHistoryForTesting( provider: UsageProvider, existingHistory: [PlanUtilizationHistorySample], - sample: PlanUtilizationHistorySample, - now: Date) -> [PlanUtilizationHistorySample]? + sample: PlanUtilizationHistorySample) -> [PlanUtilizationHistorySample]? { self.updatedPlanUtilizationHistory( provider: provider, existingHistory: existingHistory, - sample: sample, - now: now) + sample: sample) } nonisolated static var _planUtilizationMaxSamplesForTesting: Int { @@ -159,14 +156,42 @@ extension UsageStore { return max(0, min(100, value)) } - private nonisolated static func nearlyEqual(_ lhs: Double?, _ rhs: Double?, tolerance: Double = 0.1) -> Bool { - switch (lhs, rhs) { - case (nil, nil): - true - case let (l?, r?): - abs(l - r) <= tolerance - default: - false + private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { + Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) + } + + private nonisolated static func mergedPlanUtilizationHistorySample( + existing: PlanUtilizationHistorySample, + incoming: PlanUtilizationHistorySample) -> PlanUtilizationHistorySample + { + let preferIncoming = incoming.capturedAt >= existing.capturedAt + let capturedAt = preferIncoming ? incoming.capturedAt : existing.capturedAt + + return PlanUtilizationHistorySample( + capturedAt: capturedAt, + dailyUsedPercent: self.mergedPlanUtilizationPercent( + existing: existing.dailyUsedPercent, + incoming: incoming.dailyUsedPercent, + preferIncoming: preferIncoming), + weeklyUsedPercent: self.mergedPlanUtilizationPercent( + existing: existing.weeklyUsedPercent, + incoming: incoming.weeklyUsedPercent, + preferIncoming: preferIncoming), + monthlyUsedPercent: self.mergedPlanUtilizationPercent( + existing: existing.monthlyUsedPercent, + incoming: incoming.monthlyUsedPercent, + preferIncoming: preferIncoming)) + } + + private nonisolated static func mergedPlanUtilizationPercent( + existing: Double?, + incoming: Double?, + preferIncoming: Bool) -> Double? + { + if preferIncoming { + incoming ?? existing + } else { + existing ?? incoming } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index bedf82ac0..8a4c7cf07 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -169,14 +169,12 @@ struct UsageStorePlanUtilizationTests { UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, existingHistory: [], - sample: nilMonthly, - now: now)) + sample: nilMonthly)) let updated = try #require( UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, existingHistory: initial, - sample: promotedMonthly, - now: now.addingTimeInterval(300))) + sample: promotedMonthly)) #expect(updated.count == 1) let monthly = updated.last?.monthlyUsedPercent @@ -218,13 +216,11 @@ struct UsageStorePlanUtilizationTests { UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, existingHistory: [], - sample: knownMonthly, - now: now)) + sample: knownMonthly)) let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, existingHistory: initial, - sample: nilMonthly, - now: now.addingTimeInterval(300)) + sample: nilMonthly) #expect(updated == nil) #expect(initial.count == 1) @@ -233,6 +229,123 @@ struct UsageStorePlanUtilizationTests { #expect(abs((monthly ?? 0) - 36) < 0.001) } + @Test + func coalescesChangedUsageWithinHourIntoSingleSample() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = PlanUtilizationHistorySample( + capturedAt: hourStart, + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: 30) + let second = PlanUtilizationHistorySample( + capturedAt: hourStart.addingTimeInterval(25 * 60), + dailyUsedPercent: 35, + weeklyUsedPercent: 45, + monthlyUsedPercent: 55) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: second)) + + #expect(updated.count == 1) + #expect(updated.last == second) + } + + @Test + func appendsNewSampleAfterCrossingIntoNextHourBucket() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = PlanUtilizationHistorySample( + capturedAt: hourStart, + dailyUsedPercent: 10, + weeklyUsedPercent: 20, + monthlyUsedPercent: 30) + let second = PlanUtilizationHistorySample( + capturedAt: hourStart.addingTimeInterval(50 * 60), + dailyUsedPercent: 35, + weeklyUsedPercent: 45, + monthlyUsedPercent: 55) + let nextHour = PlanUtilizationHistorySample( + capturedAt: hourStart.addingTimeInterval(65 * 60), + dailyUsedPercent: 60, + weeklyUsedPercent: 70, + monthlyUsedPercent: 80) + + let oneHour = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: first)) + let coalesced = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: oneHour, + sample: second)) + let twoHours = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: coalesced, + sample: nextHour)) + + #expect(coalesced.count == 1) + #expect(twoHours.count == 2) + #expect(twoHours.first == second) + #expect(twoHours.last == nextHour) + } + + @Test + func staleWriteInSameHourDoesNotOverrideNewerValues() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let newer = PlanUtilizationHistorySample( + capturedAt: hourStart.addingTimeInterval(45 * 60), + dailyUsedPercent: 70, + weeklyUsedPercent: 80, + monthlyUsedPercent: 90) + let stale = PlanUtilizationHistorySample( + capturedAt: hourStart.addingTimeInterval(5 * 60), + dailyUsedPercent: 15, + weeklyUsedPercent: 25, + monthlyUsedPercent: nil) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: newer)) + let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: stale) + + #expect(updated == nil) + #expect(initial.count == 1) + #expect(initial.last == newer) + } + @Test func trimsHistoryToExpandedRetentionLimit() throws { let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting @@ -257,8 +370,7 @@ struct UsageStorePlanUtilizationTests { UsageStore._updatedPlanUtilizationHistoryForTesting( provider: .codex, existingHistory: history, - sample: appended, - now: appended.capturedAt)) + sample: appended)) #expect(updated.count == maxSamples) #expect(updated.first?.capturedAt == history[1].capturedAt) @@ -492,6 +604,42 @@ struct UsageStorePlanUtilizationTests { #expect(buckets.accounts[selectedTokenKey]?.count == 1) } + @MainActor + @Test + func concurrentPlanHistoryWritesCoalesceWithinSingleHourBucket() async throws { + let store = Self.makeStore() + let snapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + store._setSnapshotForTesting(snapshot, provider: .codex) + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let writeTimes = [ + hourStart.addingTimeInterval(5 * 60), + hourStart.addingTimeInterval(25 * 60), + hourStart.addingTimeInterval(45 * 60), + ] + + await withTaskGroup(of: Void.self) { group in + for writeTime in writeTimes { + group.addTask { + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + now: writeTime) + } + } + } + + let history = try #require(store.planUtilizationHistory[.codex]?.accounts.values.first) + #expect(history.count == 1) + let recordedAt = try #require(history.last?.capturedAt) + #expect(writeTimes.contains(recordedAt)) + } + @MainActor @Test func applySelectedOutcomeRecordsPlanHistoryForSelectedTokenAccount() async throws { From e3d853a90ee818ca588af573b0015b5ebf3baf94 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 12:40:15 +0800 Subject: [PATCH 13/32] Isolate plan history storage in tests --- .../PlanUtilizationHistoryStore.swift | 35 +++++++++++-------- .../CodexBar/UsageStore+PlanUtilization.swift | 17 +++++---- Sources/CodexBar/UsageStore.swift | 8 ++++- .../HistoricalUsagePaceTestSupport.swift | 5 ++- .../HistoricalUsagePaceTests.swift | 5 ++- Tests/CodexBarTests/TestStores.swift | 12 +++++++ .../UsageStorePlanUtilizationTests.swift | 35 +++++++------------ 7 files changed, 72 insertions(+), 45 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 48904ab66..2e4302ee6 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -49,29 +49,36 @@ private struct LegacyPlanUtilizationHistoryFile: Codable, Sendable { let providers: [String: [PlanUtilizationHistorySample]] } -enum PlanUtilizationHistoryStore { +struct PlanUtilizationHistoryStore: Sendable { private static let schemaVersion = 2 - static func load(fileManager: FileManager = .default) -> [UsageProvider: PlanUtilizationHistoryBuckets] { - guard let url = self.fileURL(fileManager: fileManager) else { return [:] } + let fileURL: URL? + + init(fileURL: URL? = Self.defaultFileURL()) { + self.fileURL = fileURL + } + + static func defaultAppSupport() -> Self { + Self() + } + + func load() -> [UsageProvider: PlanUtilizationHistoryBuckets] { + guard let url = self.fileURL else { return [:] } guard let data = try? Data(contentsOf: url) else { return [:] } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 if let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) { - return self.decodeProviders(decoded.providers) + return Self.decodeProviders(decoded.providers) } guard let legacy = try? decoder.decode(LegacyPlanUtilizationHistoryFile.self, from: data) else { return [:] } - return self.decodeLegacyProviders(legacy.providers) + return Self.decodeLegacyProviders(legacy.providers) } - static func save( - _ providers: [UsageProvider: PlanUtilizationHistoryBuckets], - fileManager: FileManager = .default) - { - guard let url = self.fileURL(fileManager: fileManager) else { return } + func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) { + guard let url = self.fileURL else { return } let persistedProviders = providers.reduce(into: [String: ProviderHistoryFile]()) { output, entry in let (provider, buckets) = entry guard !buckets.isEmpty else { return } @@ -87,14 +94,14 @@ enum PlanUtilizationHistoryStore { } let payload = PlanUtilizationHistoryFile( - version: self.schemaVersion, + version: Self.schemaVersion, providers: persistedProviders) do { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let data = try encoder.encode(payload) - try fileManager.createDirectory( + try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: Data.WritingOptions.atomic) @@ -133,8 +140,8 @@ enum PlanUtilizationHistoryStore { return output } - private static func fileURL(fileManager: FileManager) -> URL? { - guard let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + private static func defaultFileURL() -> URL? { + guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 1bf3b808c..c6c4bd1c9 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -4,7 +4,6 @@ import Foundation extension UsageStore { private nonisolated static let codexCreditsMonthlyCapTokens: Double = 1000 - private nonisolated static let persistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator() private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 @@ -60,7 +59,7 @@ extension UsageStore { } guard let snapshotToPersist else { return } - await Self.persistenceCoordinator.enqueue(snapshotToPersist) + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } private nonisolated static func updatedPlanUtilizationHistory( @@ -266,10 +265,15 @@ extension UsageStore { #endif } -private actor PlanUtilizationHistoryPersistenceCoordinator { +actor PlanUtilizationHistoryPersistenceCoordinator { + private let store: PlanUtilizationHistoryStore private var pendingSnapshot: [UsageProvider: PlanUtilizationHistoryBuckets]? private var isPersisting: Bool = false + init(store: PlanUtilizationHistoryStore) { + self.store = store + } + func enqueue(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) { self.pendingSnapshot = snapshot guard !self.isPersisting else { return } @@ -283,15 +287,16 @@ private actor PlanUtilizationHistoryPersistenceCoordinator { private func persistLoop() async { while let nextSnapshot = self.pendingSnapshot { self.pendingSnapshot = nil - await Self.saveAsync(nextSnapshot) + await self.saveAsync(nextSnapshot) } self.isPersisting = false } - private nonisolated static func saveAsync(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) async { + private func saveAsync(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) async { + let store = self.store await Task.detached(priority: .utility) { - PlanUtilizationHistoryStore.save(snapshot) + store.save(snapshot) }.value } } diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 2f0a0a650..5591ffdba 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -153,6 +153,7 @@ final class UsageStore { @ObservationIgnored private var pathDebugRefreshTask: Task? @ObservationIgnored private var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore + @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @@ -163,6 +164,7 @@ final class UsageStore { @ObservationIgnored private let tokenFetchTTL: TimeInterval = 60 * 60 @ObservationIgnored private let tokenFetchTimeout: TimeInterval = 10 * 60 @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( fetcher: UsageFetcher, @@ -172,6 +174,7 @@ final class UsageStore { settings: SettingsStore, registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), + planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic) { @@ -182,8 +185,11 @@ final class UsageStore { self.settings = settings self.registry = registry self.historicalUsageHistoryStore = historicalUsageHistoryStore + self.planUtilizationHistoryStore = planUtilizationHistoryStore self.sessionQuotaNotifier = sessionQuotaNotifier self.startupBehavior = startupBehavior.resolved(isRunningTests: Self.isRunningTestsProcess()) + self.planUtilizationPersistenceCoordinator = PlanUtilizationHistoryPersistenceCoordinator( + store: planUtilizationHistoryStore) self.providerMetadata = registry.metadata self .failureGates = Dictionary( @@ -201,7 +207,7 @@ final class UsageStore { self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) - self.planUtilizationHistory = PlanUtilizationHistoryStore.load() + self.planUtilizationHistory = planUtilizationHistoryStore.load() self.logStartupState() self.bindSettings() self.pathDebugInfo = PathDebugSnapshot( diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift index e3b2af377..1d82311ab 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTestSupport.swift @@ -162,10 +162,13 @@ extension HistoricalUsagePaceTests { copilotTokenStore: InMemoryCopilotTokenStore(), tokenAccountStore: InMemoryTokenAccountStore()) settings.historicalTrackingEnabled = true + let planHistoryStore = testPlanUtilizationHistoryStore( + suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") return UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, - historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: historyFileURL)) + historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: historyFileURL), + planUtilizationHistoryStore: planHistoryStore) } } diff --git a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift index 6c1ffeb2a..180e050bf 100644 --- a/Tests/CodexBarTests/HistoricalUsagePaceTests.swift +++ b/Tests/CodexBarTests/HistoricalUsagePaceTests.swift @@ -811,11 +811,14 @@ struct HistoricalUsagePaceTests { tokenAccountStore: InMemoryTokenAccountStore()) settings.historicalTrackingEnabled = true + let planHistoryStore = testPlanUtilizationHistoryStore( + suiteName: "HistoricalUsagePaceTests-\(UUID().uuidString)") let store = UsageStore( fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings, - historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: Self.makeTempURL())) + historicalUsageHistoryStore: HistoricalUsageHistoryStore(fileURL: Self.makeTempURL()), + planUtilizationHistoryStore: planHistoryStore) let now = Date(timeIntervalSince1970: 0) let window = RateWindow( diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e27491..cf0ee980e 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -132,3 +132,15 @@ func testConfigStore(suiteName: String, reset: Bool = true) -> CodexBarConfigSto } return CodexBarConfigStore(fileURL: url) } + +func testPlanUtilizationHistoryStore(suiteName: String, reset: Bool = true) -> PlanUtilizationHistoryStore { + let sanitized = suiteName.replacingOccurrences(of: "/", with: "-") + let base = FileManager.default.temporaryDirectory + .appendingPathComponent("codexbar-tests", isDirectory: true) + .appendingPathComponent(sanitized, isDirectory: true) + let url = base.appendingPathComponent("plan-utilization-history.json") + if reset { + try? FileManager.default.removeItem(at: url) + } + return PlanUtilizationHistoryStore(fileURL: url) +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 8a4c7cf07..7e5a1189d 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -757,10 +757,10 @@ struct UsageStorePlanUtilizationTests { func storeLoadsLegacyProviderHistoryIntoUnscopedBucket() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - let fileManager = PlanHistoryFileManager(applicationSupportURL: root) let url = root .appendingPathComponent("com.steipete.codexbar", isDirectory: true) .appendingPathComponent("plan-utilization-history.json") + let store = PlanUtilizationHistoryStore(fileURL: url) try FileManager.default.createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) @@ -776,7 +776,7 @@ struct UsageStorePlanUtilizationTests { providers: ["codex": [sample]])) try data.write(to: url, options: [.atomic]) - let loaded = PlanUtilizationHistoryStore.load(fileManager: fileManager) + let loaded = store.load() #expect(loaded[.codex]?.unscoped == [sample]) #expect(loaded[.codex]?.accounts.isEmpty == true) @@ -786,7 +786,10 @@ struct UsageStorePlanUtilizationTests { func storeRoundTripsAccountBuckets() { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - let fileManager = PlanHistoryFileManager(applicationSupportURL: root) + let url = root + .appendingPathComponent("com.steipete.codexbar", isDirectory: true) + .appendingPathComponent("plan-utilization-history.json") + let store = PlanUtilizationHistoryStore(fileURL: url) let aliceSample = PlanUtilizationHistorySample( capturedAt: Date(timeIntervalSince1970: 1_700_000_000), dailyUsedPercent: 10, @@ -801,8 +804,8 @@ struct UsageStorePlanUtilizationTests { unscoped: [legacySample], accounts: ["alice": [aliceSample]]) - PlanUtilizationHistoryStore.save([.codex: buckets], fileManager: fileManager) - let loaded = PlanUtilizationHistoryStore.load(fileManager: fileManager) + store.save([.codex: buckets]) + let loaded = store.load() #expect(loaded == [.codex: buckets]) } @@ -815,15 +818,19 @@ extension UsageStorePlanUtilizationTests { let defaults = UserDefaults(suiteName: suiteName) ?? .standard defaults.removePersistentDomain(forName: suiteName) let configStore = testConfigStore(suiteName: suiteName) + let planHistoryStore = testPlanUtilizationHistoryStore(suiteName: suiteName) let isolatedSettings = SettingsStore( userDefaults: defaults, configStore: configStore, tokenAccountStore: InMemoryTokenAccountStore()) - return UsageStore( + let store = UsageStore( fetcher: UsageFetcher(), browserDetection: BrowserDetection(cacheTTL: 0), settings: isolatedSettings, + planUtilizationHistoryStore: planHistoryStore, startupBehavior: .testing) + store.planUtilizationHistory = [:] + return store } private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { @@ -842,19 +849,3 @@ extension UsageStorePlanUtilizationTests { private struct LegacyPlanUtilizationHistoryFileFixture: Codable { let providers: [String: [PlanUtilizationHistorySample]] } - -private final class PlanHistoryFileManager: FileManager { - private let applicationSupportURL: URL - - init(applicationSupportURL: URL) { - self.applicationSupportURL = applicationSupportURL - super.init() - } - - override func urls(for directory: SearchPathDirectory, in domainMask: SearchPathDomainMask) -> [URL] { - if directory == .applicationSupportDirectory, domainMask == .userDomainMask { - return [self.applicationSupportURL] - } - return super.urls(for: directory, in: domainMask) - } -} From e99da379456c035b7b9ce03884e27a1ce46b795a Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 16:43:47 +0800 Subject: [PATCH 14/32] Derive utilization history charts from windowed provider snapshots - Store plan utilization as primary/secondary windowed samples with reset timestamps - Select visible chart periods from available window data and derive buckets when exact-fit data is missing - Parse and propagate reset dates/window lengths from Claude and Codex status snapshots --- .../PlanUtilizationHistoryChartMenuView.swift | 368 ++++++-- .../PlanUtilizationHistoryStore.swift | 49 +- .../CodexBar/UsageStore+PlanUtilization.swift | 92 +- Sources/CodexBar/UsageStore+Refresh.swift | 3 +- .../CodexBar/UsageStore+TokenAccounts.swift | 3 +- Sources/CodexBar/UsageStore.swift | 12 +- .../Providers/Claude/ClaudeUsageFetcher.swift | 35 +- .../Providers/Codex/CodexStatusProbe.swift | 67 +- Sources/CodexBarCore/UsageFetcher.swift | 4 +- Tests/CodexBarTests/ClaudeUsageTests.swift | 3 + Tests/CodexBarTests/StatusProbeTests.swift | 24 +- ...torePlanUtilizationDerivedChartTests.swift | 101 +++ .../UsageStorePlanUtilizationTests.swift | 783 +++++++++--------- 13 files changed, 995 insertions(+), 549 deletions(-) create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index dfb0892b0..e2e4c1888 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -51,6 +51,39 @@ struct PlanUtilizationHistoryChartMenuView: View { 24 } } + + var chartWindowMinutes: Int { + switch self { + case .daily: + 1440 + case .weekly: + 10080 + case .monthly: + 44640 + } + } + } + + private enum WindowSlot: Int, CaseIterable { + case primary + case secondary + } + + private struct WindowSourceSelection: Hashable { + let slot: WindowSlot + let windowMinutes: Int + } + + private struct DerivedGroupAccumulator { + let chartDate: Date + let boundaryDate: Date + let usesResetBoundary: Bool + var maxUsedPercent: Double + } + + private enum AggregationMode { + case exactFit + case derived } private struct Point: Identifiable { @@ -76,22 +109,29 @@ struct PlanUtilizationHistoryChartMenuView: View { } var body: some View { - let model = Self.makeModel(period: self.selectedPeriod, samples: self.samples, provider: self.provider) + let availablePeriods = Self.availablePeriods(samples: self.samples) + let visiblePeriods = availablePeriods.isEmpty ? Period.allCases : availablePeriods + let effectiveSelectedPeriod = visiblePeriods.contains(self.selectedPeriod) + ? self.selectedPeriod + : (visiblePeriods.first ?? .daily) + let model = Self.makeModel(period: effectiveSelectedPeriod, samples: self.samples, provider: self.provider) VStack(alignment: .leading, spacing: 10) { - Picker("Period", selection: self.$selectedPeriod) { - ForEach(Period.allCases) { period in - Text(period.title).tag(period) + if visiblePeriods.count > 1 { + Picker("Period", selection: self.$selectedPeriod) { + ForEach(visiblePeriods) { period in + Text(period.title).tag(period) + } + } + .pickerStyle(.segmented) + .onChange(of: self.selectedPeriod) { _, _ in + self.selectedPointID = nil } - } - .pickerStyle(.segmented) - .onChange(of: self.selectedPeriod) { _, _ in - self.selectedPointID = nil } if model.points.isEmpty { ZStack { - Text(Self.emptyStateText(period: self.selectedPeriod, isRefreshing: self.isRefreshing)) + Text(Self.emptyStateText(period: effectiveSelectedPeriod, isRefreshing: self.isRefreshing)) .font(.footnote) .foregroundStyle(.secondary) } @@ -109,7 +149,7 @@ struct PlanUtilizationHistoryChartMenuView: View { if let raw = value.as(Double.self) { let index = Int(raw.rounded()) if let point = model.pointsByIndex[index] { - Text(point.date.formatted(self.axisFormat(for: self.selectedPeriod))) + Text(point.date.formatted(self.axisFormat(for: effectiveSelectedPeriod))) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } @@ -129,7 +169,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model) + let detail = self.detailLines(model: model, period: effectiveSelectedPeriod) VStack(alignment: .leading, spacing: 0) { Text(detail.primary) .font(.caption) @@ -150,6 +190,12 @@ struct PlanUtilizationHistoryChartMenuView: View { .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) + .task(id: visiblePeriods.map(\.rawValue).joined(separator: ",")) { + guard let firstVisiblePeriod = visiblePeriods.first else { return } + guard !visiblePeriods.contains(self.selectedPeriod) else { return } + self.selectedPeriod = firstVisiblePeriod + self.selectedPointID = nil + } } private struct Model { @@ -166,51 +212,17 @@ struct PlanUtilizationHistoryChartMenuView: View { samples: [PlanUtilizationHistorySample], provider: UsageProvider) -> Model { - var buckets: [Date: Double] = [:] let calendar = Calendar.current - - let shouldDeriveCodexMonthlyFromWeekly = provider == .codex && - !samples.contains(where: { $0.monthlyUsedPercent != nil }) - let shouldDeriveMonthlyFromWeekly = period == .monthly && - (provider == .claude || shouldDeriveCodexMonthlyFromWeekly) - - if shouldDeriveMonthlyFromWeekly { - // Subscription utilization is approximated from weekly windows when no suitable monthly source exists. - // For Claude, this intentionally ignores pay-as-you-go extra usage spend. - // Approximate monthly utilization as the average weekly used % observed in that month. - var monthToWeekUsage: [Date: [Date: Double]] = [:] - for sample in samples { - guard let used = sample.weeklyUsedPercent else { continue } - let clamped = max(0, min(100, used)) - guard - let monthDate = Self.bucketDate(for: sample.capturedAt, period: .monthly, calendar: calendar), - let weekDate = Self.bucketDate(for: sample.capturedAt, period: .weekly, calendar: calendar) - else { - continue - } - var weekUsage = monthToWeekUsage[monthDate] ?? [:] - weekUsage[weekDate] = max(weekUsage[weekDate] ?? 0, clamped) - monthToWeekUsage[monthDate] = weekUsage - } - - for (monthDate, weekUsage) in monthToWeekUsage { - guard !weekUsage.isEmpty else { continue } - let totalUsed = weekUsage.values.reduce(0, +) - buckets[monthDate] = totalUsed / Double(weekUsage.count) - } - } else { - for sample in samples { - guard let used = Self.usedPercent(for: sample, period: period) else { continue } - let clamped = max(0, min(100, used)) - guard - let bucketDate = Self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) - else { - continue - } - let current = buckets[bucketDate] ?? 0 - buckets[bucketDate] = max(current, clamped) - } + guard let selectedSource = Self.selectedSource(for: period, samples: samples) else { + return Self.emptyModel(provider: provider, period: period) } + let aggregationMode = Self.aggregationMode(period: period, source: selectedSource) + let buckets = Self.chartBuckets( + period: period, + samples: samples, + source: selectedSource, + mode: aggregationMode, + calendar: calendar) var points = buckets .map { date, used in @@ -251,6 +263,18 @@ struct PlanUtilizationHistoryChartMenuView: View { barColor: barColor) } + private nonisolated static func emptyModel(provider: UsageProvider, period: Period) -> Model { + let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color + let barColor = Color(red: color.red, green: color.green, blue: color.blue) + return Model( + points: [], + axisIndexes: [], + xDomain: self.xDomain(points: [], period: period), + pointsByID: [:], + pointsByIndex: [:], + barColor: barColor) + } + private nonisolated static func xDomain(points: [Point], period: Period) -> ClosedRange? { guard !points.isEmpty else { return nil } return -0.5...(Double(period.maxPoints) - 0.5) @@ -272,6 +296,8 @@ struct PlanUtilizationHistoryChartMenuView: View { let pointCount: Int let axisIndexes: [Double] let xDomain: ClosedRange? + let selectedSource: String? + let usedPercents: [Double] } nonisolated static func _modelSnapshotForTesting( @@ -284,13 +310,21 @@ struct PlanUtilizationHistoryChartMenuView: View { return ModelSnapshot( pointCount: model.points.count, axisIndexes: model.axisIndexes, - xDomain: model.xDomain) + xDomain: model.xDomain, + selectedSource: self.selectedSource(for: period, samples: samples).map { + "\($0.slot == .primary ? "primary" : "secondary"):\($0.windowMinutes)" + }, + usedPercents: model.points.map(\.usedPercent)) } nonisolated static func _emptyStateTextForTesting(periodRawValue: String, isRefreshing: Bool) -> String? { guard let period = Period(rawValue: periodRawValue) else { return nil } return self.emptyStateText(period: period, isRefreshing: isRefreshing) } + + nonisolated static func _visiblePeriodsForTesting(samples: [PlanUtilizationHistorySample]) -> [String] { + self.availablePeriods(samples: samples).map(\.rawValue) + } #endif private nonisolated static func emptyStateText(period: Period, isRefreshing: Bool) -> String { @@ -300,15 +334,219 @@ struct PlanUtilizationHistoryChartMenuView: View { return period.emptyStateText } - private nonisolated static func usedPercent(for sample: PlanUtilizationHistorySample, period: Period) -> Double? { - switch period { - case .daily: - sample.dailyUsedPercent - case .weekly: - sample.weeklyUsedPercent - case .monthly: - sample.monthlyUsedPercent + private nonisolated static func selectedSource( + for period: Period, + samples: [PlanUtilizationHistorySample]) -> WindowSourceSelection? + { + var counts: [WindowSourceSelection: Int] = [:] + + for sample in samples { + for slot in WindowSlot.allCases { + guard let windowMinutes = self.windowMinutes(for: sample, slot: slot) else { continue } + guard windowMinutes <= period.chartWindowMinutes else { continue } + let selection = WindowSourceSelection(slot: slot, windowMinutes: windowMinutes) + counts[selection, default: 0] += 1 + } + } + + return counts.max { lhs, rhs in + if lhs.key.windowMinutes != rhs.key.windowMinutes { + return lhs.key.windowMinutes < rhs.key.windowMinutes + } + if lhs.value != rhs.value { + return lhs.value < rhs.value + } + return lhs.key.slot.rawValue > rhs.key.slot.rawValue + }?.key + } + + private nonisolated static func availablePeriods(samples: [PlanUtilizationHistorySample]) -> [Period] { + Period.allCases.filter { self.selectedSource(for: $0, samples: samples) != nil } + } + + private nonisolated static func usedPercent( + for sample: PlanUtilizationHistorySample, + source: WindowSourceSelection) -> Double? + { + guard self.windowMinutes(for: sample, slot: source.slot) == source.windowMinutes else { return nil } + switch source.slot { + case .primary: + return sample.primaryUsedPercent + case .secondary: + return sample.secondaryUsedPercent + } + } + + private nonisolated static func windowMinutes( + for sample: PlanUtilizationHistorySample, + slot: WindowSlot) -> Int? + { + switch slot { + case .primary: + sample.primaryWindowMinutes + case .secondary: + sample.secondaryWindowMinutes + } + } + + private nonisolated static func aggregationMode( + period: Period, + source: WindowSourceSelection) -> AggregationMode + { + source.windowMinutes == period.chartWindowMinutes ? .exactFit : .derived + } + + private nonisolated static func chartBuckets( + period: Period, + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection, + mode: AggregationMode, + calendar: Calendar) -> [Date: Double] + { + switch mode { + case .exactFit: + self.exactFitChartBuckets(period: period, samples: samples, source: source, calendar: calendar) + case .derived: + self.derivedChartBuckets(period: period, samples: samples, source: source, calendar: calendar) + } + } + + private nonisolated static func exactFitChartBuckets( + period: Period, + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection, + calendar: Calendar) -> [Date: Double] + { + var buckets: [Date: Double] = [:] + + for sample in samples { + guard let used = self.usedPercent(for: sample, source: source) else { continue } + guard let chartDate = self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) else { + continue + } + let clamped = max(0, min(100, used)) + buckets[chartDate] = max(buckets[chartDate] ?? 0, clamped) } + + return buckets + } + + private nonisolated static func derivedChartBuckets( + period: Period, + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection, + calendar: Calendar) -> [Date: Double] + { + let groups = self.derivedGroups(period: period, samples: samples, source: source, calendar: calendar) + guard !groups.isEmpty else { return [:] } + + let sortedGroups = groups.values.sorted { lhs, rhs in + if lhs.boundaryDate != rhs.boundaryDate { + return lhs.boundaryDate < rhs.boundaryDate + } + return lhs.chartDate < rhs.chartDate + } + + var previousResetBoundary: Date? + var weightedSums: [Date: Double] = [:] + var totalWeights: [Date: Double] = [:] + let nominalWindowMinutes = Double(source.windowMinutes) + + for group in sortedGroups { + var weightMinutes = nominalWindowMinutes + if group.usesResetBoundary, let previousResetBoundary { + let factualWindowMinutes = group.boundaryDate.timeIntervalSince(previousResetBoundary) / 60 + if factualWindowMinutes > 0, factualWindowMinutes < nominalWindowMinutes { + weightMinutes = factualWindowMinutes + } + } + + weightedSums[group.chartDate, default: 0] += group.maxUsedPercent * weightMinutes + totalWeights[group.chartDate, default: 0] += weightMinutes + + if group.usesResetBoundary { + previousResetBoundary = group.boundaryDate + } + } + + return weightedSums.reduce(into: [Date: Double]()) { output, entry in + let (chartDate, weightedSum) = entry + let totalWeight = totalWeights[chartDate] ?? 0 + guard totalWeight > 0 else { return } + output[chartDate] = weightedSum / totalWeight + } + } + + private nonisolated static func derivedGroups( + period: Period, + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection, + calendar: Calendar) -> [Date: DerivedGroupAccumulator] + { + var groups: [Date: DerivedGroupAccumulator] = [:] + + for sample in samples { + guard let used = self.usedPercent(for: sample, source: source) else { continue } + guard let groupBoundary = self.derivedBoundaryDate(for: sample, source: source) else { continue } + guard let chartDate = self.bucketDate(for: groupBoundary, period: period, calendar: calendar) else { + continue + } + + let clamped = max(0, min(100, used)) + let usesResetBoundary = self.resetsAt(for: sample, source: source) != nil + + if var existing = groups[groupBoundary] { + existing.maxUsedPercent = max(existing.maxUsedPercent, clamped) + groups[groupBoundary] = existing + } else { + groups[groupBoundary] = DerivedGroupAccumulator( + chartDate: chartDate, + boundaryDate: groupBoundary, + usesResetBoundary: usesResetBoundary, + maxUsedPercent: clamped) + } + } + + return groups + } + + private nonisolated static func derivedBoundaryDate( + for sample: PlanUtilizationHistorySample, + source: WindowSourceSelection) -> Date? + { + if let resetsAt = self.resetsAt(for: sample, source: source) { + return self.normalizedBoundaryDate(resetsAt) + } + return self.syntheticResetBoundaryDate( + for: sample.capturedAt, + windowMinutes: source.windowMinutes) + } + + private nonisolated static func resetsAt( + for sample: PlanUtilizationHistorySample, + source: WindowSourceSelection) -> Date? + { + guard self.windowMinutes(for: sample, slot: source.slot) == source.windowMinutes else { return nil } + switch source.slot { + case .primary: + return sample.primaryResetsAt + case .secondary: + return sample.secondaryResetsAt + } + } + + private nonisolated static func normalizedBoundaryDate(_ date: Date) -> Date { + Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) + } + + private nonisolated static func syntheticResetBoundaryDate( + for date: Date, + windowMinutes: Int) -> Date? + { + guard windowMinutes > 0 else { return nil } + let bucketSeconds = Double(windowMinutes) * 60 + let bucketIndex = floor(date.timeIntervalSince1970 / bucketSeconds) + return Date(timeIntervalSince1970: (bucketIndex + 1) * bucketSeconds) } private nonisolated static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { @@ -384,13 +622,13 @@ struct PlanUtilizationHistoryChartMenuView: View { return model.pointsByID[selectedPointID] } - private func detailLines(model: Model) -> (primary: String, secondary: String) { + private func detailLines(model: Model, period: Period) -> (primary: String, secondary: String) { let activePoint = self.selectedPoint(model: model) ?? model.points.last guard let point = activePoint else { return ("No data", "") } - let dateLabel: String = switch self.selectedPeriod { + let dateLabel: String = switch period { case .daily, .weekly: point.date.formatted(.dateTime.month(.abbreviated).day()) case .monthly: diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 2e4302ee6..875efdfb5 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -3,9 +3,12 @@ import Foundation struct PlanUtilizationHistorySample: Codable, Sendable, Equatable { let capturedAt: Date - let dailyUsedPercent: Double? - let weeklyUsedPercent: Double? - let monthlyUsedPercent: Double? + let primaryUsedPercent: Double? + let primaryWindowMinutes: Int? + let primaryResetsAt: Date? + let secondaryUsedPercent: Double? + let secondaryWindowMinutes: Int? + let secondaryResetsAt: Date? } struct PlanUtilizationHistoryBuckets: Sendable, Equatable { @@ -45,12 +48,8 @@ private struct ProviderHistoryFile: Codable, Sendable { let accounts: [String: [PlanUtilizationHistorySample]] } -private struct LegacyPlanUtilizationHistoryFile: Codable, Sendable { - let providers: [String: [PlanUtilizationHistorySample]] -} - struct PlanUtilizationHistoryStore: Sendable { - private static let schemaVersion = 2 + private static let schemaVersion = 3 let fileURL: URL? @@ -68,13 +67,10 @@ struct PlanUtilizationHistoryStore: Sendable { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - if let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) { - return Self.decodeProviders(decoded.providers) - } - guard let legacy = try? decoder.decode(LegacyPlanUtilizationHistoryFile.self, from: data) else { + guard let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) else { return [:] } - return Self.decodeLegacyProviders(legacy.providers) + return Self.decodeProviders(decoded.providers) } func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) { @@ -128,18 +124,6 @@ struct PlanUtilizationHistoryStore: Sendable { return output } - private static func decodeLegacyProviders( - _ providers: [String: [PlanUtilizationHistorySample]]) -> [UsageProvider: PlanUtilizationHistoryBuckets] - { - var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] - for (rawProvider, samples) in providers { - guard let provider = UsageProvider(rawValue: rawProvider) else { continue } - output[provider] = PlanUtilizationHistoryBuckets( - unscoped: samples.sorted { $0.capturedAt < $1.capturedAt }) - } - return output - } - private static func defaultFileURL() -> URL? { guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil @@ -148,3 +132,18 @@ struct PlanUtilizationHistoryStore: Sendable { return dir.appendingPathComponent("plan-utilization-history.json") } } + +extension PlanUtilizationHistoryFile { + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(Int.self, forKey: .version) + guard version == 3 else { + throw DecodingError.dataCorruptedError( + forKey: .version, + in: container, + debugDescription: "Unsupported plan utilization history schema version \(version)") + } + self.version = version + self.providers = try container.decode([String: ProviderHistoryFile].self, forKey: .providers) + } +} diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index c6c4bd1c9..a2719489c 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -3,7 +3,6 @@ import CryptoKit import Foundation extension UsageStore { - private nonisolated static let codexCreditsMonthlyCapTokens: Double = 1000 private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 @@ -17,7 +16,6 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot, account: ProviderTokenAccount? = nil, - credits: CreditsSnapshot? = nil, now: Date = Date()) async { @@ -35,15 +33,14 @@ extension UsageStore { return } let history = providerBuckets.samples(for: accountKey) - let resolvedCredits = provider == .codex ? credits : nil let sample = PlanUtilizationHistorySample( capturedAt: now, - dailyUsedPercent: Self.clampedPercent(snapshot.primary?.usedPercent), - weeklyUsedPercent: Self.clampedPercent(snapshot.secondary?.usedPercent), - monthlyUsedPercent: Self.planHistoryMonthlyUsedPercent( - provider: provider, - snapshot: snapshot, - credits: resolvedCredits)) + primaryUsedPercent: Self.clampedPercent(snapshot.primary?.usedPercent), + primaryWindowMinutes: snapshot.primary?.windowMinutes, + primaryResetsAt: snapshot.primary?.resetsAt, + secondaryUsedPercent: Self.clampedPercent(snapshot.secondary?.usedPercent), + secondaryWindowMinutes: snapshot.secondary?.windowMinutes, + secondaryResetsAt: snapshot.secondary?.resetsAt) guard let updatedHistory = Self.updatedPlanUtilizationHistory( provider: provider, @@ -110,45 +107,8 @@ extension UsageStore { nonisolated static var _planUtilizationMaxSamplesForTesting: Int { self.planUtilizationMaxSamples } - #endif - - nonisolated static func planHistoryMonthlyUsedPercent( - provider: UsageProvider, - snapshot: UsageSnapshot, - credits: CreditsSnapshot?) -> Double? - { - if provider == .codex, - let providerCostPercent = self.monthlyUsedPercent(from: snapshot.providerCost) - { - return providerCostPercent - } - guard provider == .codex else { return nil } - guard self.codexSupportsCreditBasedMonthly(snapshot: snapshot) else { return nil } - return self.codexMonthlyUsedPercent(from: credits) - } - - private nonisolated static func monthlyUsedPercent(from providerCost: ProviderCostSnapshot?) -> Double? { - guard let providerCost, providerCost.limit > 0 else { return nil } - let usedPercent = (providerCost.used / providerCost.limit) * 100 - return self.clampedPercent(usedPercent) - } - private nonisolated static func codexMonthlyUsedPercent(from credits: CreditsSnapshot?) -> Double? { - guard let remaining = credits?.remaining, remaining.isFinite else { return nil } - let cap = self.codexCreditsMonthlyCapTokens - guard cap > 0 else { return nil } - let used = max(0, min(cap, cap - remaining)) - let usedPercent = (used / cap) * 100 - return self.clampedPercent(usedPercent) - } - - private nonisolated static func codexSupportsCreditBasedMonthly(snapshot: UsageSnapshot) -> Bool { - let rawPlan = snapshot.loginMethod(for: .codex)? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() ?? "" - guard !rawPlan.isEmpty else { return false } - return rawPlan == "guest" || rawPlan == "free" || rawPlan == "free_workspace" - } + #endif private nonisolated static func clampedPercent(_ value: Double?) -> Double? { guard let value else { return nil } @@ -168,24 +128,36 @@ extension UsageStore { return PlanUtilizationHistorySample( capturedAt: capturedAt, - dailyUsedPercent: self.mergedPlanUtilizationPercent( - existing: existing.dailyUsedPercent, - incoming: incoming.dailyUsedPercent, + primaryUsedPercent: self.mergedPlanUtilizationValue( + existing: existing.primaryUsedPercent, + incoming: incoming.primaryUsedPercent, + preferIncoming: preferIncoming), + primaryWindowMinutes: self.mergedPlanUtilizationValue( + existing: existing.primaryWindowMinutes, + incoming: incoming.primaryWindowMinutes, + preferIncoming: preferIncoming), + primaryResetsAt: self.mergedPlanUtilizationValue( + existing: existing.primaryResetsAt, + incoming: incoming.primaryResetsAt, + preferIncoming: preferIncoming), + secondaryUsedPercent: self.mergedPlanUtilizationValue( + existing: existing.secondaryUsedPercent, + incoming: incoming.secondaryUsedPercent, preferIncoming: preferIncoming), - weeklyUsedPercent: self.mergedPlanUtilizationPercent( - existing: existing.weeklyUsedPercent, - incoming: incoming.weeklyUsedPercent, + secondaryWindowMinutes: self.mergedPlanUtilizationValue( + existing: existing.secondaryWindowMinutes, + incoming: incoming.secondaryWindowMinutes, preferIncoming: preferIncoming), - monthlyUsedPercent: self.mergedPlanUtilizationPercent( - existing: existing.monthlyUsedPercent, - incoming: incoming.monthlyUsedPercent, + secondaryResetsAt: self.mergedPlanUtilizationValue( + existing: existing.secondaryResetsAt, + incoming: incoming.secondaryResetsAt, preferIncoming: preferIncoming)) } - private nonisolated static func mergedPlanUtilizationPercent( - existing: Double?, - incoming: Double?, - preferIncoming: Bool) -> Double? + private nonisolated static func mergedPlanUtilizationValue( + existing: T?, + incoming: T?, + preferIncoming: Bool) -> T? { if preferIncoming { incoming ?? existing diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 652e103e5..06fccc774 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -87,8 +87,7 @@ extension UsageStore { } await self.recordPlanUtilizationHistorySample( provider: provider, - snapshot: scoped, - credits: result.credits) + snapshot: scoped) if let runtime = self.providerRuntimes[provider] { let context = ProviderRuntimeContext( provider: provider, settings: self.settings, store: self) diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index ccca3031a..4c2eead5b 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -165,8 +165,7 @@ extension UsageStore { await self.recordPlanUtilizationHistorySample( provider: provider, snapshot: labeled, - account: account, - credits: result.credits) + account: account) case let .failure(error): await MainActor.run { let hadPriorData = self.snapshots[provider] != nil || fallbackSnapshot != nil diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 5591ffdba..c8b0c044e 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -654,8 +654,7 @@ final class UsageStore { codexSnapshot == nil || codexSnapshot?.updatedAt ?? .distantPast < minimumSnapshotUpdatedAt { self.scheduleCodexPlanHistoryBackfill( - minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt, - credits: credits) + minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) return } @@ -663,8 +662,7 @@ final class UsageStore { guard let codexSnapshot else { return } await self.recordPlanUtilizationHistorySample( provider: .codex, - snapshot: codexSnapshot, - credits: credits) + snapshot: codexSnapshot) } catch { let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { @@ -921,8 +919,7 @@ extension UsageStore { } private func scheduleCodexPlanHistoryBackfill( - minimumSnapshotUpdatedAt: Date, - credits: CreditsSnapshot) + minimumSnapshotUpdatedAt: Date) { self.cancelCodexPlanHistoryBackfill() self.codexPlanHistoryBackfillTask = Task { @MainActor [weak self] in @@ -932,8 +929,7 @@ extension UsageStore { } await self.recordPlanUtilizationHistorySample( provider: .codex, - snapshot: snapshot, - credits: credits) + snapshot: snapshot) self.codexPlanHistoryBackfillTask = nil } } diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index ce48d24c1..42bcad7d4 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -58,6 +58,8 @@ public enum ClaudeUsageError: LocalizedError, Sendable { } public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { + private static let sessionWindowMinutes = 5 * 60 + private static let weeklyWindowMinutes = 7 * 24 * 60 private let environment: [String: String] private let dataSource: ClaudeUsageDataSource private let oauthKeychainPromptCooldownEnabled: Bool @@ -199,21 +201,26 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { return nil } - func makeWindow(_ dict: [String: Any]?) -> RateWindow? { + func makeWindow(_ dict: [String: Any]?, windowMinutes: Int) -> RateWindow? { guard let dict else { return nil } let pct = (dict["pct_used"] as? NSNumber)?.doubleValue ?? 0 let resetText = dict["resets"] as? String return RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: windowMinutes, resetsAt: Self.parseReset(text: resetText), resetDescription: resetText) } - guard let session = makeWindow(firstWindowDict(["session_5h"])) else { + guard let session = makeWindow( + firstWindowDict(["session_5h"]), + windowMinutes: Self.sessionWindowMinutes) + else { throw ClaudeUsageError.parseFailed("missing session data") } - let weekAll = makeWindow(firstWindowDict(["week_all_models", "week_all"])) + let weekAll = makeWindow( + firstWindowDict(["week_all_models", "week_all"]), + windowMinutes: Self.weeklyWindowMinutes) let rawEmail = (obj["account_email"] as? String)?.trimmingCharacters( in: .whitespacesAndNewlines) @@ -234,7 +241,7 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { let resets = opus["resets"] as? String return RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: Self.weeklyWindowMinutes, resetsAt: Self.parseReset(text: resets), resetDescription: resets) }() @@ -786,21 +793,29 @@ public struct ClaudeUsageFetcher: ClaudeUsageFetching, Sendable { throw ClaudeUsageError.parseFailed("missing session data") } - func makeWindow(pctLeft: Int?, reset: String?) -> RateWindow? { + func makeWindow(pctLeft: Int?, reset: String?, windowMinutes: Int) -> RateWindow? { guard let left = pctLeft else { return nil } let used = max(0, min(100, 100 - Double(left))) let resetClean = reset?.trimmingCharacters(in: .whitespacesAndNewlines) return RateWindow( usedPercent: used, - windowMinutes: nil, + windowMinutes: windowMinutes, resetsAt: ClaudeStatusProbe.parseResetDate(from: resetClean), resetDescription: resetClean) } - let primary = makeWindow(pctLeft: sessionPctLeft, reset: snap.primaryResetDescription)! + let primary = makeWindow( + pctLeft: sessionPctLeft, + reset: snap.primaryResetDescription, + windowMinutes: Self.sessionWindowMinutes)! let weekly = makeWindow( - pctLeft: snap.weeklyPercentLeft, reset: snap.secondaryResetDescription) - let opus = makeWindow(pctLeft: snap.opusPercentLeft, reset: snap.opusResetDescription) + pctLeft: snap.weeklyPercentLeft, + reset: snap.secondaryResetDescription, + windowMinutes: Self.weeklyWindowMinutes) + let opus = makeWindow( + pctLeft: snap.opusPercentLeft, + reset: snap.opusResetDescription, + windowMinutes: Self.weeklyWindowMinutes) return ClaudeUsageSnapshot( primary: primary, diff --git a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift index a04aee558..6e8667036 100644 --- a/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift +++ b/Sources/CodexBarCore/Providers/Codex/CodexStatusProbe.swift @@ -6,6 +6,8 @@ public struct CodexStatusSnapshot: Sendable { public let weeklyPercentLeft: Int? public let fiveHourResetDescription: String? public let weeklyResetDescription: String? + public let fiveHourResetsAt: Date? + public let weeklyResetsAt: Date? public let rawText: String public init( @@ -14,6 +16,8 @@ public struct CodexStatusSnapshot: Sendable { weeklyPercentLeft: Int?, fiveHourResetDescription: String?, weeklyResetDescription: String?, + fiveHourResetsAt: Date?, + weeklyResetsAt: Date?, rawText: String) { self.credits = credits @@ -21,6 +25,8 @@ public struct CodexStatusSnapshot: Sendable { self.weeklyPercentLeft = weeklyPercentLeft self.fiveHourResetDescription = fiveHourResetDescription self.weeklyResetDescription = weeklyResetDescription + self.fiveHourResetsAt = fiveHourResetsAt + self.weeklyResetsAt = weeklyResetsAt self.rawText = rawText } } @@ -92,7 +98,7 @@ public struct CodexStatusProbe { // MARK: - Parsing - public static func parse(text: String) throws -> CodexStatusSnapshot { + public static func parse(text: String, now: Date = .init()) throws -> CodexStatusSnapshot { let clean = TextParsing.stripANSICodes(text) guard !clean.isEmpty else { throw CodexStatusProbeError.timedOut } if clean.localizedCaseInsensitiveContains("data not available yet") { @@ -119,9 +125,68 @@ public struct CodexStatusProbe { weeklyPercentLeft: weekPct, fiveHourResetDescription: fiveReset, weeklyResetDescription: weekReset, + fiveHourResetsAt: self.parseResetDate(from: fiveReset, now: now), + weeklyResetsAt: self.parseResetDate(from: weekReset, now: now), rawText: clean) } + private static func parseResetDate(from text: String?, now: Date) -> Date? { + guard var raw = text?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + raw = raw.trimmingCharacters(in: CharacterSet(charactersIn: "()")) + raw = raw.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + + let calendar = Calendar(identifier: .gregorian) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.defaultDate = now + + if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([0-9]{1,2} [A-Za-z]{3})$/) { + raw = "\(match.output.2) \(match.output.1)" + formatter.dateFormat = "d MMM HH:mm" + if let date = formatter.date(from: raw) { + return self.bumpYearIfNeeded(date, now: now, calendar: calendar) + } + } + + if let match = raw.firstMatch(of: /^([0-9]{1,2}:[0-9]{2}) on ([A-Za-z]{3} [0-9]{1,2})$/) { + raw = "\(match.output.2) \(match.output.1)" + formatter.dateFormat = "MMM d HH:mm" + if let date = formatter.date(from: raw) { + return self.bumpYearIfNeeded(date, now: now, calendar: calendar) + } + } + + for format in ["HH:mm", "H:mm"] { + formatter.dateFormat = format + if let time = formatter.date(from: raw) { + let components = calendar.dateComponents([.hour, .minute], from: time) + guard let anchored = calendar.date( + bySettingHour: components.hour ?? 0, + minute: components.minute ?? 0, + second: 0, + of: now) + else { + return nil + } + if anchored >= now { + return anchored + } + return calendar.date(byAdding: .day, value: 1, to: anchored) + } + } + + return nil + } + + private static func bumpYearIfNeeded(_ date: Date, now: Date, calendar: Calendar) -> Date? { + if date >= now { + return date + } + return calendar.date(byAdding: .year, value: 1, to: date) + } + private func runAndParse( binary: String, rows: UInt16, diff --git a/Sources/CodexBarCore/UsageFetcher.swift b/Sources/CodexBarCore/UsageFetcher.swift index f2370e9ca..fefe6d3ed 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -569,12 +569,12 @@ public struct UsageFetcher: Sendable { let primary = RateWindow( usedPercent: max(0, 100 - Double(fiveLeft)), windowMinutes: 300, - resetsAt: nil, + resetsAt: status.fiveHourResetsAt, resetDescription: status.fiveHourResetDescription) let secondary = RateWindow( usedPercent: max(0, 100 - Double(weekLeft)), windowMinutes: 10080, - resetsAt: nil, + resetsAt: status.weeklyResetsAt, resetDescription: status.weeklyResetDescription) return UsageSnapshot( diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 3b3a5659c..b176d3bb6 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -41,7 +41,9 @@ struct ClaudeUsageTests { let snap = ClaudeUsageFetcher.parse(json: data) #expect(snap != nil) #expect(snap?.primary.usedPercent == 1) + #expect(snap?.primary.windowMinutes == 300) #expect(snap?.secondary?.usedPercent == 8) + #expect(snap?.secondary?.windowMinutes == 10080) #expect(snap?.primary.resetDescription == "11am (Europe/Vienna)") } @@ -510,6 +512,7 @@ struct ClaudeUsageTests { let data = Data(json.utf8) let snap = ClaudeUsageFetcher.parse(json: data) #expect(snap?.opus?.usedPercent == 0) + #expect(snap?.opus?.windowMinutes == 10080) #expect(snap?.opus?.resetDescription?.isEmpty == true) #expect(snap?.accountEmail == "steipete@gmail.com") #expect(snap?.accountOrganization == nil) diff --git a/Tests/CodexBarTests/StatusProbeTests.swift b/Tests/CodexBarTests/StatusProbeTests.swift index b516782e0..24b63ce7a 100644 --- a/Tests/CodexBarTests/StatusProbeTests.swift +++ b/Tests/CodexBarTests/StatusProbeTests.swift @@ -20,15 +20,37 @@ struct StatusProbeTests { @Test func `parse codex status with ansi and resets`() throws { + let now = try #require( + Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 11, + day: 26, + hour: 8, + minute: 0))) let sample = """ \u{001B}[38;5;245mCredits:\u{001B}[0m 557 credits 5h limit: [█████ ] 50% left (resets 09:01) Weekly limit: [███████ ] 85% left (resets 04:01 on 27 Nov) """ - let snap = try CodexStatusProbe.parse(text: sample) + let snap = try CodexStatusProbe.parse(text: sample, now: now) #expect(snap.credits == 557) #expect(snap.fiveHourPercentLeft == 50) #expect(snap.weeklyPercentLeft == 85) + #expect(snap.fiveHourResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 27, + hour: 9, + minute: 1))) + #expect(snap.weeklyResetsAt == Calendar(identifier: .gregorian).date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 11, + day: 27, + hour: 4, + minute: 1))) } @Test diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift new file mode 100644 index 000000000..8348c27a3 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -0,0 +1,101 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationDerivedChartTests { + @MainActor + @Test + func dailyModelDerivesFromResetBoundariesInsteadOfSyntheticEpochBuckets() throws { + let calendar = Calendar(identifier: .gregorian) + let boundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 1, + minute: 30))) + let samples = [ + makeDerivedChartPlanSample( + at: boundary.addingTimeInterval(-80 * 60), + primary: 20, + primaryWindowMinutes: 300, + primaryResetsAt: boundary), + makeDerivedChartPlanSample( + at: boundary.addingTimeInterval(-10 * 60), + primary: 40, + primaryWindowMinutes: 300, + primaryResetsAt: boundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "daily", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:300") + #expect(model.usedPercents == [40]) + } + + @MainActor + @Test + func dailyModelWeightsEarlyResetPeriodsByActualDuration() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 2, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 5, + minute: 0))) + let samples = [ + makeDerivedChartPlanSample( + at: firstBoundary.addingTimeInterval(-90 * 60), + primary: 30, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + makeDerivedChartPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + primary: 90, + primaryWindowMinutes: 300, + primaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "daily", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:300") + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - 52.5) < 0.000_1) + } +} + +private func makeDerivedChartPlanSample( + at capturedAt: Date, + primary: Double?, + primaryWindowMinutes: Int? = nil, + primaryResetsAt: Date? = nil) -> PlanUtilizationHistorySample +{ + PlanUtilizationHistorySample( + capturedAt: capturedAt, + primaryUsedPercent: primary, + primaryWindowMinutes: primaryWindowMinutes, + primaryResetsAt: primaryResetsAt, + secondaryUsedPercent: nil, + secondaryWindowMinutes: nil, + secondaryResetsAt: nil) +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 7e5a1189d..9af40abcd 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -5,230 +5,6 @@ import Testing @Suite struct UsageStorePlanUtilizationTests { - @Test - func codexUsesProviderCostWhenAvailable() throws { - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: ProviderCostSnapshot( - used: 25, - limit: 100, - currencyCode: "USD", - period: "Monthly", - resetsAt: nil, - updatedAt: Date()), - updatedAt: Date()) - let credits = CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: credits) - - #expect(try abs(#require(percent) - 25) < 0.001) - } - - @Test - func claudeIgnoresProviderCostForMonthlyHistory() { - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: ProviderCostSnapshot( - used: 40, - limit: 100, - currencyCode: "USD", - period: "Monthly", - resetsAt: nil, - updatedAt: Date()), - updatedAt: Date()) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .claude, - snapshot: snapshot, - credits: nil) - - #expect(percent == nil) - } - - @Test - func codexFallsBackToCredits() throws { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "free") - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - let credits = CreditsSnapshot(remaining: 640, events: [], updatedAt: Date()) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: credits) - - #expect(try abs(#require(percent) - 36) < 0.001) - } - - @Test - func codexFreePlanWithoutFreshCreditsReturnsNil() { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "free") - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: nil) - - #expect(percent == nil) - } - - @Test - func codexPaidPlanDoesNotUseCreditsFallback() { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "plus") - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - let credits = CreditsSnapshot(remaining: 0, events: [], updatedAt: Date()) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: credits) - - #expect(percent == nil) - } - - @Test - func claudeWithoutProviderCostReturnsNil() { - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date()) - let credits = CreditsSnapshot(remaining: 100, events: [], updatedAt: Date()) - - let percent = UsageStore.planHistoryMonthlyUsedPercent( - provider: .claude, - snapshot: snapshot, - credits: credits) - - #expect(percent == nil) - } - - @Test - func codexWithinWindowPromotesMonthlyFromNilWithoutAppending() throws { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "free") - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - let now = Date() - let nilMonthly = PlanUtilizationHistorySample( - capturedAt: now, - dailyUsedPercent: nil, - weeklyUsedPercent: nil, - monthlyUsedPercent: nil) - let monthlyValue = try #require( - UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now))) - let promotedMonthly = PlanUtilizationHistorySample( - capturedAt: now.addingTimeInterval(300), - dailyUsedPercent: nil, - weeklyUsedPercent: nil, - monthlyUsedPercent: monthlyValue) - - let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: nilMonthly)) - let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: promotedMonthly)) - - #expect(updated.count == 1) - let monthly = updated.last?.monthlyUsedPercent - #expect(monthly != nil) - #expect(abs((monthly ?? 0) - 36) < 0.001) - } - - @Test - func codexWithinWindowIgnoresNilMonthlyAfterKnownValue() throws { - let identity = ProviderIdentitySnapshot( - providerID: .codex, - accountEmail: nil, - accountOrganization: nil, - loginMethod: "free") - let snapshot = UsageSnapshot( - primary: nil, - secondary: nil, - providerCost: nil, - updatedAt: Date(), - identity: identity) - let now = Date() - let monthlyValue = try #require( - UsageStore.planHistoryMonthlyUsedPercent( - provider: .codex, - snapshot: snapshot, - credits: CreditsSnapshot(remaining: 640, events: [], updatedAt: now))) - let knownMonthly = PlanUtilizationHistorySample( - capturedAt: now, - dailyUsedPercent: nil, - weeklyUsedPercent: nil, - monthlyUsedPercent: monthlyValue) - let nilMonthly = PlanUtilizationHistorySample( - capturedAt: now.addingTimeInterval(300), - dailyUsedPercent: nil, - weeklyUsedPercent: nil, - monthlyUsedPercent: nil) - - let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: knownMonthly)) - let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: nilMonthly) - - #expect(updated == nil) - #expect(initial.count == 1) - let monthly = initial.last?.monthlyUsedPercent - #expect(monthly != nil) - #expect(abs((monthly ?? 0) - 36) < 0.001) - } - @Test func coalescesChangedUsageWithinHourIntoSingleSample() throws { let calendar = Calendar(identifier: .gregorian) @@ -238,16 +14,13 @@ struct UsageStorePlanUtilizationTests { month: 3, day: 17, hour: 10))) - let first = PlanUtilizationHistorySample( - capturedAt: hourStart, - dailyUsedPercent: 10, - weeklyUsedPercent: 20, - monthlyUsedPercent: 30) - let second = PlanUtilizationHistorySample( - capturedAt: hourStart.addingTimeInterval(25 * 60), - dailyUsedPercent: 35, - weeklyUsedPercent: 45, - monthlyUsedPercent: 55) + let first = makePlanSample(at: hourStart, primary: 10, secondary: 20) + let second = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 35, + secondary: 45, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10080) let initial = try #require( UsageStore._updatedPlanUtilizationHistoryForTesting( @@ -273,21 +46,9 @@ struct UsageStorePlanUtilizationTests { month: 3, day: 17, hour: 10))) - let first = PlanUtilizationHistorySample( - capturedAt: hourStart, - dailyUsedPercent: 10, - weeklyUsedPercent: 20, - monthlyUsedPercent: 30) - let second = PlanUtilizationHistorySample( - capturedAt: hourStart.addingTimeInterval(50 * 60), - dailyUsedPercent: 35, - weeklyUsedPercent: 45, - monthlyUsedPercent: 55) - let nextHour = PlanUtilizationHistorySample( - capturedAt: hourStart.addingTimeInterval(65 * 60), - dailyUsedPercent: 60, - weeklyUsedPercent: 70, - monthlyUsedPercent: 80) + let first = makePlanSample(at: hourStart, primary: 10, secondary: 20) + let second = makePlanSample(at: hourStart.addingTimeInterval(50 * 60), primary: 35, secondary: 45) + let nextHour = makePlanSample(at: hourStart.addingTimeInterval(65 * 60), primary: 60, secondary: 70) let oneHour = try #require( UsageStore._updatedPlanUtilizationHistoryForTesting( @@ -320,16 +81,18 @@ struct UsageStorePlanUtilizationTests { month: 3, day: 17, hour: 10))) - let newer = PlanUtilizationHistorySample( - capturedAt: hourStart.addingTimeInterval(45 * 60), - dailyUsedPercent: 70, - weeklyUsedPercent: 80, - monthlyUsedPercent: 90) - let stale = PlanUtilizationHistorySample( - capturedAt: hourStart.addingTimeInterval(5 * 60), - dailyUsedPercent: 15, - weeklyUsedPercent: 25, - monthlyUsedPercent: nil) + let newer = makePlanSample( + at: hourStart.addingTimeInterval(45 * 60), + primary: 70, + secondary: 80, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10080) + let stale = makePlanSample( + at: hourStart.addingTimeInterval(5 * 60), + primary: 15, + secondary: 25, + primaryWindowMinutes: 60, + secondaryWindowMinutes: 1440) let initial = try #require( UsageStore._updatedPlanUtilizationHistoryForTesting( @@ -346,6 +109,82 @@ struct UsageStorePlanUtilizationTests { #expect(initial.last == newer) } + @Test + func newerSameHourSampleKeepsNewerMetadataAndBackfillsMissingValues() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let existing = makePlanSample( + at: hourStart.addingTimeInterval(10 * 60), + primary: 20, + secondary: nil, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10080) + let incomingReset = Date(timeIntervalSince1970: 1_710_000_000) + let incoming = makePlanSample( + at: hourStart.addingTimeInterval(50 * 60), + primary: nil, + secondary: 45, + primaryResetsAt: incomingReset) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [existing], + sample: incoming)) + + let merged = try #require(updated.last) + #expect(merged.capturedAt == incoming.capturedAt) + #expect(merged.primaryUsedPercent == 20) + #expect(merged.primaryWindowMinutes == 300) + #expect(merged.primaryResetsAt == incomingReset) + #expect(merged.secondaryUsedPercent == 45) + #expect(merged.secondaryWindowMinutes == 10080) + } + + @Test + func staleSameHourSampleOnlyFillsMissingMetadata() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let newer = makePlanSample( + at: hourStart.addingTimeInterval(50 * 60), + primary: 40, + secondary: 80, + primaryWindowMinutes: nil, + secondaryWindowMinutes: 10080) + let staleReset = Date(timeIntervalSince1970: 1_710_123_456) + let stale = makePlanSample( + at: hourStart.addingTimeInterval(5 * 60), + primary: 10, + secondary: 20, + primaryWindowMinutes: 300, + primaryResetsAt: staleReset, + secondaryWindowMinutes: nil) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [newer], + sample: stale)) + + let merged = try #require(updated.last) + #expect(merged.capturedAt == newer.capturedAt) + #expect(merged.primaryUsedPercent == 40) + #expect(merged.secondaryUsedPercent == 80) + #expect(merged.primaryWindowMinutes == 300) + #expect(merged.primaryResetsAt == staleReset) + #expect(merged.secondaryWindowMinutes == 10080) + } + @Test func trimsHistoryToExpandedRetentionLimit() throws { let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting @@ -353,18 +192,16 @@ struct UsageStorePlanUtilizationTests { var history: [PlanUtilizationHistorySample] = [] for offset in 0.. PlanUtilizationHistorySample +{ + PlanUtilizationHistorySample( + capturedAt: capturedAt, + primaryUsedPercent: primary, + primaryWindowMinutes: primaryWindowMinutes, + primaryResetsAt: primaryResetsAt, + secondaryUsedPercent: secondary, + secondaryWindowMinutes: secondaryWindowMinutes, + secondaryResetsAt: secondaryResetsAt) } From be53ddeea026d5bf343199426f2fe6bc34690301 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 18:43:18 +0800 Subject: [PATCH 15/32] Normalize derived utilization by chart-period duration - Split derived history into time intervals and weight bucket contributions by overlap - Normalize daily/weekly/monthly derived values against full bucket duration - Update utilization chart tests to assert the new normalization behavior --- .../PlanUtilizationHistoryChartMenuView.swift | 92 +++++++++-- ...torePlanUtilizationDerivedChartTests.swift | 149 +++++++++++++++++- .../UsageStorePlanUtilizationTests.swift | 111 ++++++++----- 3 files changed, 291 insertions(+), 61 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index e2e4c1888..7e60d7305 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -81,6 +81,13 @@ struct PlanUtilizationHistoryChartMenuView: View { var maxUsedPercent: Double } + private struct DerivedInterval { + let chartDate: Date + let startDate: Date + let endDate: Date + let maxUsedPercent: Double + } + private enum AggregationMode { case exactFit case derived @@ -447,12 +454,20 @@ struct PlanUtilizationHistoryChartMenuView: View { return lhs.chartDate < rhs.chartDate } + let intervals = self.derivedIntervals(from: sortedGroups, nominalWindowMinutes: Double(source.windowMinutes)) + guard !intervals.isEmpty else { return [:] } + + return self.derivedPeriodChartBuckets(period: period, intervals: intervals, calendar: calendar) + } + + private nonisolated static func derivedIntervals( + from groups: [DerivedGroupAccumulator], + nominalWindowMinutes: Double) -> [DerivedInterval] + { var previousResetBoundary: Date? - var weightedSums: [Date: Double] = [:] - var totalWeights: [Date: Double] = [:] - let nominalWindowMinutes = Double(source.windowMinutes) + var intervals: [DerivedInterval] = [] - for group in sortedGroups { + for group in groups { var weightMinutes = nominalWindowMinutes if group.usesResetBoundary, let previousResetBoundary { let factualWindowMinutes = group.boundaryDate.timeIntervalSince(previousResetBoundary) / 60 @@ -461,19 +476,60 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - weightedSums[group.chartDate, default: 0] += group.maxUsedPercent * weightMinutes - totalWeights[group.chartDate, default: 0] += weightMinutes + let endDate = group.boundaryDate + let startDate = endDate.addingTimeInterval(-(weightMinutes * 60)) + intervals.append(DerivedInterval( + chartDate: group.chartDate, + startDate: startDate, + endDate: endDate, + maxUsedPercent: group.maxUsedPercent)) if group.usesResetBoundary { previousResetBoundary = group.boundaryDate } } + return intervals + } + + private nonisolated static func derivedPeriodChartBuckets( + period: Period, + intervals: [DerivedInterval], + calendar: Calendar) -> [Date: Double] + { + var weightedSums: [Date: Double] = [:] + + for interval in intervals { + var cursor = interval.startDate + while cursor < interval.endDate { + guard let chartInterval = self.chartDateInterval( + for: cursor, + period: period, + calendar: calendar) + else { + break + } + + let overlapStart = max(interval.startDate, chartInterval.start) + let overlapEnd = min(interval.endDate, chartInterval.end) + let overlapMinutes = overlapEnd.timeIntervalSince(overlapStart) / 60 + + if overlapMinutes > 0 { + weightedSums[chartInterval.start, default: 0] += interval.maxUsedPercent * overlapMinutes + } + + cursor = chartInterval.end + } + } + return weightedSums.reduce(into: [Date: Double]()) { output, entry in - let (chartDate, weightedSum) = entry - let totalWeight = totalWeights[chartDate] ?? 0 - guard totalWeight > 0 else { return } - output[chartDate] = weightedSum / totalWeight + let (chartStart, weightedSum) = entry + guard let chartInterval = self.chartDateInterval(for: chartStart, period: period, calendar: calendar) else { + return + } + let chartMinutes = chartInterval.duration / 60 + guard chartMinutes > 0 else { return } + output[chartStart] = weightedSum / chartMinutes } } @@ -550,13 +606,23 @@ struct PlanUtilizationHistoryChartMenuView: View { } private nonisolated static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { + self.chartDateInterval(for: date, period: period, calendar: calendar)?.start + } + + private nonisolated static func chartDateInterval( + for date: Date, + period: Period, + calendar: Calendar) -> DateInterval? + { switch period { case .daily: - calendar.startOfDay(for: date) + let start = calendar.startOfDay(for: date) + guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { return nil } + return DateInterval(start: start, end: end) case .weekly: - calendar.dateInterval(of: .weekOfYear, for: date)?.start + return calendar.dateInterval(of: .weekOfYear, for: date) case .monthly: - calendar.dateInterval(of: .month, for: date)?.start + return calendar.dateInterval(of: .month, for: date) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift index 8348c27a3..c4903238d 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -14,8 +14,8 @@ struct UsageStorePlanUtilizationDerivedChartTests { year: 2026, month: 3, day: 7, - hour: 1, - minute: 30))) + hour: 5, + minute: 0))) let samples = [ makeDerivedChartPlanSample( at: boundary.addingTimeInterval(-80 * 60), @@ -37,7 +37,8 @@ struct UsageStorePlanUtilizationDerivedChartTests { #expect(model.pointCount == 1) #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents == [40]) + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - (40.0 * 5.0 / 24.0)) < 0.000_1) } @MainActor @@ -49,14 +50,14 @@ struct UsageStorePlanUtilizationDerivedChartTests { year: 2026, month: 3, day: 7, - hour: 2, + hour: 10, minute: 0))) let secondBoundary = try #require(calendar.date(from: DateComponents( timeZone: TimeZone.current, year: 2026, month: 3, day: 7, - hour: 5, + hour: 13, minute: 0))) let samples = [ makeDerivedChartPlanSample( @@ -80,7 +81,143 @@ struct UsageStorePlanUtilizationDerivedChartTests { #expect(model.pointCount == 1) #expect(model.selectedSource == "primary:300") #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - 52.5) < 0.000_1) + #expect(abs(model.usedPercents[0] - 17.5) < 0.000_1) + } + + @MainActor + @Test + func weeklyModelNormalizesFiveHourHistoryAgainstFullWeekDuration() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 9, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 9, + hour: 10, + minute: 0))) + let samples = [ + makeDerivedChartPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + primary: 20, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + makeDerivedChartPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + primary: 40, + primaryWindowMinutes: 300, + primaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + let expected = (20.0 * 5.0 + 40.0 * 5.0) / (7.0 * 24.0) + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:300") + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - expected) < 0.000_1) + } + + @MainActor + @Test + func monthlyModelNormalizesWeeklyHistoryAgainstFullMonthDuration() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 0, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 0, + minute: 0))) + let samples = [ + makeDerivedChartPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + primary: 75, + primaryWindowMinutes: 10080, + primaryResetsAt: firstBoundary), + makeDerivedChartPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + primary: 75, + primaryWindowMinutes: 10080, + primaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "monthly", + samples: samples, + provider: .codex)) + + let expected = 75.0 * 14.0 / 31.0 + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:10080") + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - expected) < 0.000_1) + } + + @MainActor + @Test + func dailyModelNormalizesFiveHourHistoryAgainstFullDayDuration() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 10, + minute: 0))) + let samples = [ + makeDerivedChartPlanSample( + at: firstBoundary.addingTimeInterval(-60 * 60), + primary: 20, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + makeDerivedChartPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + primary: 10, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + makeDerivedChartPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + primary: 40, + primaryWindowMinutes: 300, + primaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "daily", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:300") + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - 12.5) < 0.000_1) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 9af40abcd..c0b433b72 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -218,25 +218,48 @@ struct UsageStorePlanUtilizationTests { @Test func dailyModelLeftAlignsSparseHistory() throws { let calendar = Calendar(identifier: .gregorian) - let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 6))) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 4, + hour: 10, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 5, + hour: 10, + minute: 0))) + let thirdBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 6, + hour: 10, + minute: 0))) let samples = [ makePlanSample( - at: now, + at: thirdBoundary.addingTimeInterval(-30 * 60), primary: 20, secondary: 35, primaryWindowMinutes: 300, + primaryResetsAt: thirdBoundary, secondaryWindowMinutes: 10080), makePlanSample( - at: now.addingTimeInterval(-24 * 3600), + at: secondBoundary.addingTimeInterval(-30 * 60), primary: 48, secondary: 48, primaryWindowMinutes: 300, + primaryResetsAt: secondBoundary, secondaryWindowMinutes: 10080), makePlanSample( - at: now.addingTimeInterval(-2 * 24 * 3600), + at: firstBoundary.addingTimeInterval(-30 * 60), primary: 62, secondary: 62, primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary, secondaryWindowMinutes: 10080), ] @@ -250,7 +273,10 @@ struct UsageStorePlanUtilizationTests { #expect(model.axisIndexes == [0, 2]) #expect(model.xDomain == -0.5...29.5) #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents == [62, 48, 20]) + #expect(model.usedPercents.count == 3) + #expect(abs(model.usedPercents[0] - (62.0 * 5.0 / 24.0)) < 0.000_1) + #expect(abs(model.usedPercents[1] - 10.0) < 0.000_1) + #expect(abs(model.usedPercents[2] - (20.0 * 5.0 / 24.0)) < 0.000_1) } @MainActor @@ -364,34 +390,6 @@ struct UsageStorePlanUtilizationTests { #expect(visiblePeriods == ["weekly", "monthly"]) } - @MainActor - @Test - func dailyModelDerivesFromPrimaryFiveHourHistory() throws { - let calendar = Calendar(identifier: .gregorian) - let base = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 0, - minute: 0))) - let samples = [ - makePlanSample(at: base.addingTimeInterval(60), primary: 20, secondary: nil, primaryWindowMinutes: 300), - makePlanSample(at: base.addingTimeInterval(3600), primary: 10, secondary: nil, primaryWindowMinutes: 300), - makePlanSample(at: base.addingTimeInterval(18060), primary: 40, secondary: nil, primaryWindowMinutes: 300), - ] - - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "daily", - samples: samples, - provider: .codex)) - - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents == [30]) - } - @Test func fiveHourOnlyHistoryShowsAllTabs() { let base = Date(timeIntervalSince1970: Double(18000 * 100_000)) @@ -410,19 +408,46 @@ struct UsageStorePlanUtilizationTests { @Test func weeklyAndMonthlyModelsUsePrimaryWhenItIsTheBestEligibleWindow() throws { let calendar = Calendar(identifier: .gregorian) - let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 20))) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 0, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 0, + minute: 0))) + let thirdBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 22, + hour: 0, + minute: 0))) let samples = [ - makePlanSample(at: now, primary: 20, secondary: nil, primaryWindowMinutes: 10080), makePlanSample( - at: now.addingTimeInterval(-7 * 24 * 3600), + at: firstBoundary.addingTimeInterval(-30 * 60), + primary: 62, + secondary: nil, + primaryWindowMinutes: 10080, + primaryResetsAt: firstBoundary), + makePlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), primary: 48, secondary: nil, - primaryWindowMinutes: 10080), + primaryWindowMinutes: 10080, + primaryResetsAt: secondBoundary), makePlanSample( - at: now.addingTimeInterval(-14 * 24 * 3600), - primary: 62, + at: thirdBoundary.addingTimeInterval(-30 * 60), + primary: 20, secondary: nil, - primaryWindowMinutes: 10080), + primaryWindowMinutes: 10080, + primaryResetsAt: thirdBoundary), ] let weeklyModel = try #require( @@ -441,7 +466,8 @@ struct UsageStorePlanUtilizationTests { #expect(monthlyModel.pointCount == 1) #expect(monthlyModel.selectedSource == "primary:10080") #expect(weeklyModel.usedPercents == [62, 48, 20]) - #expect(monthlyModel.usedPercents == [43.333333333333336]) + #expect(monthlyModel.usedPercents.count == 1) + #expect(abs(monthlyModel.usedPercents[0] - ((62.0 + 48.0 + 20.0) * 7.0 / 31.0)) < 0.000_1) } @MainActor @@ -462,7 +488,8 @@ struct UsageStorePlanUtilizationTests { #expect(model.pointCount == 1) #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents == [30]) + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - ((20.0 * 5.0 + 40.0 * 5.0) / (7.0 * 24.0))) < 0.000_1) } @Test From ce3814b08dc3c48f68996747ca42e1697a5a2813 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 19:14:31 +0800 Subject: [PATCH 16/32] Fill missing utilization periods with zero-value history bars - Backfill daily/weekly/monthly chart buckets through the current reference period - Add trailing zero bars up to the reference day for sparse daily history - Update utilization history tests to cover gap-filling behavior --- .../PlanUtilizationHistoryChartMenuView.swift | 69 ++++++++++++++- .../UsageStorePlanUtilizationTests.swift | 85 ++++++++++++++----- 2 files changed, 129 insertions(+), 25 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 7e60d7305..eec5b9edf 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -121,7 +121,11 @@ struct PlanUtilizationHistoryChartMenuView: View { let effectiveSelectedPeriod = visiblePeriods.contains(self.selectedPeriod) ? self.selectedPeriod : (visiblePeriods.first ?? .daily) - let model = Self.makeModel(period: effectiveSelectedPeriod, samples: self.samples, provider: self.provider) + let model = Self.makeModel( + period: effectiveSelectedPeriod, + samples: self.samples, + provider: self.provider, + referenceDate: Date()) VStack(alignment: .leading, spacing: 10) { if visiblePeriods.count > 1 { @@ -217,7 +221,8 @@ struct PlanUtilizationHistoryChartMenuView: View { private nonisolated static func makeModel( period: Period, samples: [PlanUtilizationHistorySample], - provider: UsageProvider) -> Model + provider: UsageProvider, + referenceDate: Date) -> Model { let calendar = Calendar.current guard let selectedSource = Self.selectedSource(for: period, samples: samples) else { @@ -241,6 +246,9 @@ struct PlanUtilizationHistoryChartMenuView: View { } .sorted { $0.date < $1.date } + let currentBucketDate = self.bucketDate(for: referenceDate, period: period, calendar: calendar) + points = self.filledPoints(points: points, period: period, calendar: calendar, through: currentBucketDate) + if points.count > period.maxPoints { points = Array(points.suffix(period.maxPoints)) } @@ -270,6 +278,38 @@ struct PlanUtilizationHistoryChartMenuView: View { barColor: barColor) } + private nonisolated static func filledPoints( + points: [Point], + period: Period, + calendar: Calendar, + through endDate: Date?) -> [Point] + { + guard let firstDate = points.first?.date, let lastDate = points.last?.date else { return points } + let effectiveEndDate = max(lastDate, endDate ?? lastDate) + let pointsByDate = Dictionary(uniqueKeysWithValues: points.map { ($0.date, $0) }) + + var filled: [Point] = [] + var cursor = firstDate + while cursor <= effectiveEndDate { + if let existing = pointsByDate[cursor] { + filled.append(existing) + } else { + filled.append(Point( + id: self.pointID(date: cursor, period: period), + index: 0, + date: cursor, + usedPercent: 0)) + } + + guard let nextDate = self.nextBucketDate(after: cursor, period: period, calendar: calendar) else { + break + } + cursor = nextDate + } + + return filled + } + private nonisolated static func emptyModel(provider: UsageProvider, period: Period) -> Model { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color let barColor = Color(red: color.red, green: color.green, blue: color.blue) @@ -310,10 +350,16 @@ struct PlanUtilizationHistoryChartMenuView: View { nonisolated static func _modelSnapshotForTesting( periodRawValue: String, samples: [PlanUtilizationHistorySample], - provider: UsageProvider) -> ModelSnapshot? + provider: UsageProvider, + referenceDate: Date? = nil) -> ModelSnapshot? { guard let period = Period(rawValue: periodRawValue) else { return nil } - let model = self.makeModel(period: period, samples: samples, provider: provider) + let effectiveReferenceDate = referenceDate ?? samples.map(\.capturedAt).max() ?? Date() + let model = self.makeModel( + period: period, + samples: samples, + provider: provider, + referenceDate: effectiveReferenceDate) return ModelSnapshot( pointCount: model.points.count, axisIndexes: model.axisIndexes, @@ -641,6 +687,21 @@ struct PlanUtilizationHistoryChartMenuView: View { return formatter.string(from: date) } + private nonisolated static func nextBucketDate( + after date: Date, + period: Period, + calendar: Calendar) -> Date? + { + switch period { + case .daily: + calendar.date(byAdding: .day, value: 1, to: date) + case .weekly: + calendar.date(byAdding: .weekOfYear, value: 1, to: date) + case .monthly: + calendar.date(byAdding: .month, value: 1, to: date) + } + } + private func xValue(for index: Int) -> PlottableValue { .value("Period", Double(index)) } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index c0b433b72..bd892c9ff 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -216,7 +216,7 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func dailyModelLeftAlignsSparseHistory() throws { + func dailyModelShowsZeroBarsForMissingDays() throws { let calendar = Calendar(identifier: .gregorian) let firstBoundary = try #require(calendar.date(from: DateComponents( timeZone: TimeZone.current, @@ -225,13 +225,6 @@ struct UsageStorePlanUtilizationTests { day: 4, hour: 10, minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 5, - hour: 10, - minute: 0))) let thirdBoundary = try #require(calendar.date(from: DateComponents( timeZone: TimeZone.current, year: 2026, @@ -247,13 +240,6 @@ struct UsageStorePlanUtilizationTests { primaryWindowMinutes: 300, primaryResetsAt: thirdBoundary, secondaryWindowMinutes: 10080), - makePlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - primary: 48, - secondary: 48, - primaryWindowMinutes: 300, - primaryResetsAt: secondBoundary, - secondaryWindowMinutes: 10080), makePlanSample( at: firstBoundary.addingTimeInterval(-30 * 60), primary: 62, @@ -275,13 +261,70 @@ struct UsageStorePlanUtilizationTests { #expect(model.selectedSource == "primary:300") #expect(model.usedPercents.count == 3) #expect(abs(model.usedPercents[0] - (62.0 * 5.0 / 24.0)) < 0.000_1) - #expect(abs(model.usedPercents[1] - 10.0) < 0.000_1) + #expect(model.usedPercents[1] == 0) #expect(abs(model.usedPercents[2] - (20.0 * 5.0 / 24.0)) < 0.000_1) } @MainActor @Test - func weeklyModelPacksExistingPeriodsWithoutPlaceholderGaps() throws { + func dailyModelShowsTrailingZeroBarsUpToReferenceDay() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 4, + hour: 10, + minute: 0))) + let lastBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 6, + hour: 10, + minute: 0))) + let referenceDate = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 12, + minute: 0))) + let samples = [ + makePlanSample( + at: lastBoundary.addingTimeInterval(-30 * 60), + primary: 20, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: lastBoundary), + makePlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + primary: 62, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "daily", + samples: samples, + provider: .codex, + referenceDate: referenceDate)) + + #expect(model.pointCount == 5) + #expect(model.axisIndexes == [0, 4]) + #expect(model.usedPercents.count == 5) + #expect(abs(model.usedPercents[0] - (62.0 * 5.0 / 24.0)) < 0.000_1) + #expect(model.usedPercents[1] == 0) + #expect(abs(model.usedPercents[2] - (20.0 * 5.0 / 24.0)) < 0.000_1) + #expect(model.usedPercents[3] == 0) + #expect(model.usedPercents[4] == 0) + } + + @MainActor + @Test + func weeklyModelShowsZeroBarsForMissingWeeks() throws { let calendar = Calendar(identifier: .gregorian) let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 6))) let samples = [ @@ -298,7 +341,7 @@ struct UsageStorePlanUtilizationTests { primaryWindowMinutes: 300, secondaryWindowMinutes: 10080), makePlanSample( - at: now.addingTimeInterval(-14 * 24 * 3600), + at: now.addingTimeInterval(-21 * 24 * 3600), primary: 30, secondary: 62, primaryWindowMinutes: 300, @@ -311,11 +354,11 @@ struct UsageStorePlanUtilizationTests { samples: samples, provider: .codex)) - #expect(model.pointCount == 3) - #expect(model.axisIndexes == [2]) + #expect(model.pointCount == 4) + #expect(model.axisIndexes == [3]) #expect(model.xDomain == -0.5...23.5) #expect(model.selectedSource == "secondary:10080") - #expect(model.usedPercents == [62, 48, 35]) + #expect(model.usedPercents == [62, 0, 48, 35]) } @MainActor From 5126af070b36ccc54225d7f65c4e0c0a83e73a22 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Tue, 17 Mar 2026 20:29:44 +0800 Subject: [PATCH 17/32] Align weekly exact-fit bars to reset boundaries - Use reset timestamps as exact-fit bar dates when reset boundaries exist - Fill missing and trailing weekly reset periods with zero-value bars - Add focused tests for same-day shifts, cross-day resets, and gap handling --- .../PlanUtilizationHistoryChartMenuView.swift | 196 ++++++++++-- ...orePlanUtilizationExactFitResetTests.swift | 280 ++++++++++++++++++ 2 files changed, 449 insertions(+), 27 deletions(-) create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index eec5b9edf..97699bfec 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -88,6 +88,13 @@ struct PlanUtilizationHistoryChartMenuView: View { let maxUsedPercent: Double } + private struct ExactFitPointAccumulator { + let keyDate: Date + let displayDate: Date + let observedAt: Date + let usedPercent: Double + } + private enum AggregationMode { case exactFit case derived @@ -229,25 +236,39 @@ struct PlanUtilizationHistoryChartMenuView: View { return Self.emptyModel(provider: provider, period: period) } let aggregationMode = Self.aggregationMode(period: period, source: selectedSource) - let buckets = Self.chartBuckets( - period: period, - samples: samples, - source: selectedSource, - mode: aggregationMode, - calendar: calendar) - - var points = buckets - .map { date, used in - Point( - id: Self.pointID(date: date, period: period), - index: 0, - date: date, - usedPercent: used) - } - .sorted { $0.date < $1.date } + let usesResetAlignedExactFit = aggregationMode == .exactFit + && self.hasAnyResetBoundary(samples: samples, source: selectedSource) + + var points: [Point] + if usesResetAlignedExactFit { + points = self.exactFitPoints(samples: samples, source: selectedSource, period: period, calendar: calendar) + points = self.filledExactFitPoints( + points: points, + period: period, + windowMinutes: selectedSource.windowMinutes, + referenceDate: referenceDate, + calendar: calendar) + } else { + let buckets = Self.chartBuckets( + period: period, + samples: samples, + source: selectedSource, + mode: aggregationMode, + calendar: calendar) + + points = buckets + .map { date, used in + Point( + id: Self.pointID(date: date, period: period, usesResetAlignedExactFit: false), + index: 0, + date: date, + usedPercent: used) + } + .sorted { $0.date < $1.date } - let currentBucketDate = self.bucketDate(for: referenceDate, period: period, calendar: calendar) - points = self.filledPoints(points: points, period: period, calendar: calendar, through: currentBucketDate) + let currentBucketDate = self.bucketDate(for: referenceDate, period: period, calendar: calendar) + points = self.filledPoints(points: points, period: period, calendar: calendar, through: currentBucketDate) + } if points.count > period.maxPoints { points = Array(points.suffix(period.maxPoints)) @@ -295,7 +316,7 @@ struct PlanUtilizationHistoryChartMenuView: View { filled.append(existing) } else { filled.append(Point( - id: self.pointID(date: cursor, period: period), + id: self.pointID(date: cursor, period: period, usesResetAlignedExactFit: false), index: 0, date: cursor, usedPercent: 0)) @@ -345,6 +366,7 @@ struct PlanUtilizationHistoryChartMenuView: View { let xDomain: ClosedRange? let selectedSource: String? let usedPercents: [Double] + let pointDates: [String] } nonisolated static func _modelSnapshotForTesting( @@ -367,7 +389,14 @@ struct PlanUtilizationHistoryChartMenuView: View { selectedSource: self.selectedSource(for: period, samples: samples).map { "\($0.slot == .primary ? "primary" : "secondary"):\($0.windowMinutes)" }, - usedPercents: model.points.map(\.usedPercent)) + usedPercents: model.points.map(\.usedPercent), + pointDates: model.points.map { point in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: point.date) + }) } nonisolated static func _emptyStateTextForTesting(periodRawValue: String, isRefreshing: Bool) -> String? { @@ -672,17 +701,21 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func pointID(date: Date, period: Period) -> String { + private nonisolated static func pointID(date: Date, period: Period, usesResetAlignedExactFit: Bool) -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone.current - formatter.dateFormat = switch period { - case .daily: + formatter.dateFormat = if usesResetAlignedExactFit { "yyyy-MM-dd" - case .weekly: - "yyyy-'W'ww" - case .monthly: - "yyyy-MM" + } else { + switch period { + case .daily: + "yyyy-MM-dd" + case .weekly: + "yyyy-'W'ww" + case .monthly: + "yyyy-MM" + } } return formatter.string(from: date) } @@ -810,3 +843,112 @@ struct PlanUtilizationHistoryChartMenuView: View { } } } + +extension PlanUtilizationHistoryChartMenuView { + private nonisolated static func exactFitPoints( + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection, + period: Period, + calendar: Calendar) -> [Point] + { + var buckets: [Date: ExactFitPointAccumulator] = [:] + + for sample in samples { + guard let used = self.usedPercent(for: sample, source: source) else { continue } + guard let displayDate = self.exactFitDisplayDate( + for: sample, + source: source, + period: period, + calendar: calendar) + else { + continue + } + + let keyDate = calendar.startOfDay(for: displayDate) + let candidate = ExactFitPointAccumulator( + keyDate: keyDate, + displayDate: displayDate, + observedAt: sample.capturedAt, + usedPercent: max(0, min(100, used))) + + if let existing = buckets[keyDate], candidate.observedAt < existing.observedAt { + continue + } + buckets[keyDate] = candidate + } + + return buckets.values + .sorted { lhs, rhs in + if lhs.keyDate != rhs.keyDate { return lhs.keyDate < rhs.keyDate } + return lhs.observedAt < rhs.observedAt + } + .map { point in + Point( + id: self.pointID(date: point.keyDate, period: period, usesResetAlignedExactFit: true), + index: 0, + date: point.displayDate, + usedPercent: point.usedPercent) + } + } + + private nonisolated static func filledExactFitPoints( + points: [Point], + period: Period, + windowMinutes: Int, + referenceDate: Date, + calendar: Calendar) -> [Point] + { + guard windowMinutes > 0 else { return points } + guard var previous = points.first else { return points } + + let windowInterval = Double(windowMinutes) * 60 + var filled: [Point] = [previous] + + for point in points.dropFirst() { + var cursor = previous.date.addingTimeInterval(windowInterval) + while calendar.startOfDay(for: cursor) < calendar.startOfDay(for: point.date) { + filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) + cursor = cursor.addingTimeInterval(windowInterval) + } + filled.append(point) + previous = point + } + + if previous.date <= referenceDate { + var cursor = previous.date.addingTimeInterval(windowInterval) + while cursor < referenceDate { + filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) + cursor = cursor.addingTimeInterval(windowInterval) + } + filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) + } + + return filled + } + + private nonisolated static func emptyExactFitPoint(date: Date, period: Period, calendar: Calendar) -> Point { + let keyDate = calendar.startOfDay(for: date) + return Point( + id: self.pointID(date: keyDate, period: period, usesResetAlignedExactFit: true), + index: 0, + date: date, + usedPercent: 0) + } + + private nonisolated static func hasAnyResetBoundary( + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection) -> Bool + { + samples.contains { self.resetsAt(for: $0, source: source) != nil } + } + + private nonisolated static func exactFitDisplayDate( + for sample: PlanUtilizationHistorySample, + source: WindowSourceSelection, + period: Period, + calendar: Calendar) -> Date? + { + if let resetsAt = self.resetsAt(for: sample, source: source) { return self.normalizedBoundaryDate(resetsAt) } + return self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift new file mode 100644 index 000000000..5b5589c4d --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -0,0 +1,280 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationExactFitResetTests { + @MainActor + @Test + func weeklyExactFitUsesResetDateAsBarDate() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let thirdBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 22, + hour: 5, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: secondBoundary), + makeExactFitResetPlanSample( + at: thirdBoundary.addingTimeInterval(-30 * 60), + secondary: 20, + secondaryResetsAt: thirdBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 3) + #expect(model.selectedSource == "secondary:10080") + #expect(model.usedPercents == [62, 48, 20]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-22 05:00"]) + } + + @MainActor + @Test + func weeklyExactFitCoalescesSameDayResetShiftIntoSingleBar() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let originalSecondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let shiftedSecondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 7, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: originalSecondBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: originalSecondBoundary), + makeExactFitResetPlanSample( + at: shiftedSecondBoundary.addingTimeInterval(-10 * 60), + secondary: 12, + secondaryResetsAt: shiftedSecondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 2) + #expect(model.usedPercents == [62, 12]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 07:00"]) + } + + @MainActor + @Test + func weeklyExactFitCreatesNewBarWhenEarlyResetMovesToDifferentDay() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let earlyResetBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 16, + hour: 2, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: secondBoundary), + makeExactFitResetPlanSample( + at: earlyResetBoundary.addingTimeInterval(-10 * 60), + secondary: 12, + secondaryResetsAt: earlyResetBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 3) + #expect(model.usedPercents == [62, 48, 12]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-16 02:00"]) + } + + @MainActor + @Test + func weeklyExactFitShowsZeroBarsForMissingResetPeriods() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let fourthBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 29, + hour: 5, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: secondBoundary), + makeExactFitResetPlanSample( + at: fourthBoundary.addingTimeInterval(-30 * 60), + secondary: 20, + secondaryResetsAt: fourthBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 4) + #expect(model.usedPercents == [62, 48, 0, 20]) + #expect(model.pointDates == [ + "2026-03-08 05:00", + "2026-03-15 05:00", + "2026-03-22 05:00", + "2026-03-29 05:00", + ]) + } + + @MainActor + @Test + func weeklyExactFitShowsTrailingZeroBarForCurrentExpectedReset() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let referenceDate = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 20, + hour: 12, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex, + referenceDate: referenceDate)) + + #expect(model.pointCount == 3) + #expect(model.usedPercents == [62, 48, 0]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-22 05:00"]) + } +} + +private func makeExactFitResetPlanSample( + at capturedAt: Date, + secondary: Double, + secondaryResetsAt: Date) -> PlanUtilizationHistorySample +{ + PlanUtilizationHistorySample( + capturedAt: capturedAt, + primaryUsedPercent: nil, + primaryWindowMinutes: nil, + primaryResetsAt: nil, + secondaryUsedPercent: secondary, + secondaryWindowMinutes: 10080, + secondaryResetsAt: secondaryResetsAt) +} From c13945f4875a7e9addece45063c3359f65bb4ac4 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 10:20:57 +0800 Subject: [PATCH 18/32] Stabilize Claude plan utilization history account resolution - Add Claude-specific account-key resolution with bootstrap anonymous bucket migration - Persist preferred account key in plan history schema v4 for sticky fallback behavior - Hide Claude history only during true initial refresh and expand test coverage for identity/account flows --- .../PlanUtilizationHistoryStore.swift | 8 +- ...tatusItemController+UsageHistoryMenu.swift | 2 +- .../CodexBar/UsageStore+PlanUtilization.swift | 176 +++++++++- ...rePlanUtilizationClaudeIdentityTests.swift | 302 ++++++++++++++++++ .../UsageStorePlanUtilizationTests.swift | 172 ++-------- 5 files changed, 503 insertions(+), 157 deletions(-) create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 875efdfb5..eab24c2c2 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -12,6 +12,7 @@ struct PlanUtilizationHistorySample: Codable, Sendable, Equatable { } struct PlanUtilizationHistoryBuckets: Sendable, Equatable { + var preferredAccountKey: String? var unscoped: [PlanUtilizationHistorySample] = [] var accounts: [String: [PlanUtilizationHistorySample]] = [:] @@ -44,12 +45,13 @@ private struct PlanUtilizationHistoryFile: Codable, Sendable { } private struct ProviderHistoryFile: Codable, Sendable { + let preferredAccountKey: String? let unscoped: [PlanUtilizationHistorySample] let accounts: [String: [PlanUtilizationHistorySample]] } struct PlanUtilizationHistoryStore: Sendable { - private static let schemaVersion = 3 + fileprivate static let schemaVersion = 4 let fileURL: URL? @@ -85,6 +87,7 @@ struct PlanUtilizationHistoryStore: Sendable { return (accountKey, sorted) }) output[provider.rawValue] = ProviderHistoryFile( + preferredAccountKey: buckets.preferredAccountKey, unscoped: buckets.unscoped.sorted { $0.capturedAt < $1.capturedAt }, accounts: accounts) } @@ -113,6 +116,7 @@ struct PlanUtilizationHistoryStore: Sendable { for (rawProvider, providerHistory) in providers { guard let provider = UsageProvider(rawValue: rawProvider) else { continue } output[provider] = PlanUtilizationHistoryBuckets( + preferredAccountKey: providerHistory.preferredAccountKey, unscoped: providerHistory.unscoped.sorted { $0.capturedAt < $1.capturedAt }, accounts: Dictionary( uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, samples in @@ -137,7 +141,7 @@ extension PlanUtilizationHistoryFile { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let version = try container.decode(Int.self, forKey: .version) - guard version == 3 else { + guard version == PlanUtilizationHistoryStore.schemaVersion else { throw DecodingError.dataCorruptedError( forKey: .version, in: container, diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 7e384e0dc..673d5d2eb 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -27,7 +27,7 @@ extension StatusItemController { width: CGFloat) -> Bool { let samples = self.store.planUtilizationHistory(for: provider) - let isRefreshing = self.store.refreshingProviders.contains(provider) && samples.isEmpty + let isRefreshing = self.store.shouldShowPlanUtilizationRefreshingState(for: provider) && samples.isEmpty if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a2719489c..92b391bed 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -3,13 +3,34 @@ import CryptoKit import Foundation extension UsageStore { + private nonisolated static let claudeBootstrapAnonymousAccountKey = "__claude_bootstrap_anonymous__" private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { - let accountKey = self.planUtilizationAccountKey(for: provider) - if provider == .claude, accountKey == nil { return [] } - return self.planUtilizationHistory[provider]?.samples(for: accountKey) ?? [] + if self.shouldDeferClaudePlanUtilizationHistory(provider: provider) { + return [] + } + + guard provider == .claude else { + let accountKey = self.planUtilizationAccountKey(for: provider) + return self.planUtilizationHistory[provider]?.samples(for: accountKey) ?? [] + } + + var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + let originalProviderBuckets = providerBuckets + let accountKey = self.resolveClaudePlanUtilizationAccountKey( + snapshot: self.snapshots[provider], + preferredAccount: nil, + providerBuckets: &providerBuckets) + self.planUtilizationHistory[provider] = providerBuckets + if providerBuckets != originalProviderBuckets { + let snapshotToPersist = self.planUtilizationHistory + Task { + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) + } + } + return providerBuckets.samples(for: accountKey) } func recordPlanUtilizationHistorySample( @@ -20,6 +41,7 @@ extension UsageStore { async { guard provider == .codex || provider == .claude else { return } + guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { @@ -27,11 +49,16 @@ extension UsageStore { // into duplicate writes for the same provider/account bucket. var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) - let accountKey = Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) - ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) - if provider == .claude, accountKey == nil { - return - } + let accountKey = + if provider == .claude { + self.resolveClaudePlanUtilizationAccountKey( + snapshot: snapshot, + preferredAccount: preferredAccount, + providerBuckets: &providerBuckets) + } else { + Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) + ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) + } let history = providerBuckets.samples(for: accountKey) let sample = PlanUtilizationHistorySample( capturedAt: now, @@ -205,6 +232,10 @@ extension UsageStore { return self.sha256Hex("\(provider.rawValue):email:\(normalizedEmail)") } + if provider == .claude { + return nil + } + let normalizedOrganization = identity.accountOrganization? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() @@ -220,6 +251,131 @@ extension UsageStore { return digest.map { String(format: "%02x", $0) }.joined() } + private func shouldDeferClaudePlanUtilizationHistory(provider: UsageProvider) -> Bool { + provider == .claude && self.shouldShowPlanUtilizationRefreshingState(for: .claude) + } + + func shouldShowPlanUtilizationRefreshingState(for provider: UsageProvider) -> Bool { + guard self.refreshingProviders.contains(provider) else { return false } + + if provider != .claude { + return true + } + + return self.snapshots[.claude] == nil && self.error(for: .claude) == nil + } + + private func resolveClaudePlanUtilizationAccountKey( + snapshot: UsageSnapshot?, + preferredAccount: ProviderTokenAccount?, + providerBuckets: inout PlanUtilizationHistoryBuckets) -> String? + { + let resolvedAccount = preferredAccount ?? self.settings.selectedTokenAccount(for: .claude) + if let tokenAccountKey = Self.planUtilizationAccountKey(provider: .claude, account: resolvedAccount) { + providerBuckets.preferredAccountKey = tokenAccountKey + self.adoptClaudeBootstrapHistoryIfNeeded(into: tokenAccountKey, providerBuckets: &providerBuckets) + return tokenAccountKey + } + + if let snapshot, + let identityAccountKey = Self.planUtilizationIdentityAccountKey(provider: .claude, snapshot: snapshot) + { + providerBuckets.preferredAccountKey = identityAccountKey + self.adoptClaudeBootstrapHistoryIfNeeded(into: identityAccountKey, providerBuckets: &providerBuckets) + return identityAccountKey + } + + if let stickyAccountKey = self.stickyClaudePlanUtilizationAccountKey(providerBuckets: providerBuckets) { + return stickyAccountKey + } + + let hasKnownAccounts = self.hasKnownClaudePlanUtilizationAccounts(providerBuckets: providerBuckets) + if hasKnownAccounts { + return nil + } + + return Self.claudeBootstrapAnonymousAccountKey + } + + private func adoptClaudeBootstrapHistoryIfNeeded( + into accountKey: String, + providerBuckets: inout PlanUtilizationHistoryBuckets) + { + guard accountKey != Self.claudeBootstrapAnonymousAccountKey else { return } + let anonymousHistory = providerBuckets.accounts[Self.claudeBootstrapAnonymousAccountKey] ?? [] + guard !anonymousHistory.isEmpty else { return } + + let existingHistory = providerBuckets.accounts[accountKey] ?? [] + let mergedHistory = Self.mergedPlanUtilizationHistories(provider: .claude, histories: [ + existingHistory, + anonymousHistory, + ]) + providerBuckets.setSamples(mergedHistory, for: accountKey) + providerBuckets.setSamples([], for: Self.claudeBootstrapAnonymousAccountKey) + } + + private func stickyClaudePlanUtilizationAccountKey( + providerBuckets: PlanUtilizationHistoryBuckets) -> String? + { + let knownAccountKeys = self.knownClaudePlanUtilizationAccountKeys(providerBuckets: providerBuckets) + guard !knownAccountKeys.isEmpty else { return nil } + + if let preferredAccountKey = providerBuckets.preferredAccountKey, + knownAccountKeys.contains(preferredAccountKey) + { + return preferredAccountKey + } + + if knownAccountKeys.count == 1 { + return knownAccountKeys[0] + } + + return knownAccountKeys.max { lhs, rhs in + let lhsDate = providerBuckets.accounts[lhs]?.last?.capturedAt ?? .distantPast + let rhsDate = providerBuckets.accounts[rhs]?.last?.capturedAt ?? .distantPast + if lhsDate == rhsDate { + return lhs > rhs + } + return lhsDate < rhsDate + } + } + + private func hasKnownClaudePlanUtilizationAccounts(providerBuckets: PlanUtilizationHistoryBuckets) -> Bool { + !self.knownClaudePlanUtilizationAccountKeys(providerBuckets: providerBuckets).isEmpty + } + + private func knownClaudePlanUtilizationAccountKeys(providerBuckets: PlanUtilizationHistoryBuckets) -> [String] { + providerBuckets.accounts.keys + .filter { $0 != Self.claudeBootstrapAnonymousAccountKey } + .sorted() + } + + private nonisolated static func mergedPlanUtilizationHistories( + provider: UsageProvider, + histories: [[PlanUtilizationHistorySample]]) -> [PlanUtilizationHistorySample] + { + let orderedSamples = histories + .flatMap(\.self) + .sorted { lhs, rhs in + if lhs.capturedAt == rhs.capturedAt { + return (lhs.primaryUsedPercent ?? -1) < (rhs.primaryUsedPercent ?? -1) + } + return lhs.capturedAt < rhs.capturedAt + } + + var mergedHistory: [PlanUtilizationHistorySample] = [] + for sample in orderedSamples { + if let updated = self.updatedPlanUtilizationHistory( + provider: provider, + existingHistory: mergedHistory, + sample: sample) + { + mergedHistory = updated + } + } + return mergedHistory + } + #if DEBUG nonisolated static func _planUtilizationAccountKeyForTesting( provider: UsageProvider, @@ -234,6 +390,10 @@ extension UsageStore { { self.planUtilizationAccountKey(provider: provider, account: account) } + + nonisolated static var _claudeBootstrapAnonymousAccountKeyForTesting: String { + self.claudeBootstrapAnonymousAccountKey + } #endif } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift new file mode 100644 index 000000000..71d0a999a --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -0,0 +1,302 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationClaudeIdentityTests { + @MainActor + @Test + func planHistorySelectsConfiguredTokenAccountBucket() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .claude, + account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting( + provider: .claude, + account: bob)) + + let aliceSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 15, secondary: 25) + let bobSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_086_400), primary: 45, secondary: 55) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [ + aliceKey: [aliceSample], + bobKey: [bobSample], + ]) + + store.settings.setActiveTokenAccountIndex(0, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [aliceSample]) + + store.settings.setActiveTokenAccountIndex(1, for: .claude) + #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) + } + + @MainActor + @Test + func recordPlanHistoryWithoutExplicitAccountUsesSelectedTokenAccountBucket() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(1, for: .claude) + + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let selectedTokenKey = try #require( + store.settings.selectedTokenAccount(for: .claude).flatMap { + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: $0) + }) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: aliceSnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[selectedTokenKey]?.count == 1) + } + + @MainActor + @Test + func applySelectedOutcomeRecordsPlanHistoryForSelectedTokenAccount() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(1, for: .claude) + + let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) + let selectedTokenKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let outcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: snapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + + await store.applySelectedOutcome( + outcome, + provider: .claude, + account: selectedAccount, + fallbackSnapshot: snapshot) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[selectedTokenKey]?.count == 1) + } + + @MainActor + @Test + func claudePlanHistoryFallsBackToAnonymousBootstrapBucketBeforeFirstIdentity() { + let store = UsageStorePlanUtilizationTests.makeStore() + let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 20, secondary: 30) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [sample]]) + + #expect(store.planUtilizationHistory(for: .claude) == [sample]) + } + + @MainActor + @Test + func claudePlanHistoryIsHiddenWhileMainClaudeCardStillShowsRefreshing() { + let store = UsageStorePlanUtilizationTests.makeStore() + let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [sample]]) + store.refreshingProviders.insert(.claude) + + #expect(store.shouldShowPlanUtilizationRefreshingState(for: .claude)) + #expect(store.planUtilizationHistory(for: .claude).isEmpty) + } + + @MainActor + @Test + func claudePlanHistoryStaysVisibleWhileRefreshFinishesAfterSnapshotAlreadyResolved() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let accountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: snapshot)) + let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [accountKey: [sample]]) + store._setSnapshotForTesting(snapshot, provider: .claude) + store.refreshingProviders.insert(.claude) + + #expect(store.shouldShowPlanUtilizationRefreshingState(for: .claude) == false) + #expect(store.planUtilizationHistory(for: .claude) == [sample]) + } + + @MainActor + @Test + func planHistoryDoesNotReadLegacyIdentityBucketForTokenAccounts() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let legacyKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: snapshot)) + let legacySample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [legacyKey: [legacySample]]) + store._setSnapshotForTesting(snapshot, provider: .claude) + + #expect(store.planUtilizationHistory(for: .claude).isEmpty) + } + + @MainActor + @Test + func recordPlanHistoryWithoutIdentityUsesAnonymousBootstrapBucketBeforeFirstIdentity() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 11, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 21, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_003_600)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[UsageStore._claudeBootstrapAnonymousAccountKeyForTesting]?.count == 1) + } + + @MainActor + @Test + func recordPlanHistoryWhileMainClaudeCardStillShowsRefreshingSkipsWrite() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + store.refreshingProviders.insert(.claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + #expect(store.planUtilizationHistory[.claude] == nil) + } + + @MainActor + @Test + func recordPlanHistoryWhileRefreshFinishesAfterSnapshotAlreadyResolvedStillWrites() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let accountKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: snapshot)) + store._setSnapshotForTesting(snapshot, provider: .claude) + store.refreshingProviders.insert(.claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[accountKey]?.count == 1) + } + + @MainActor + @Test + func firstResolvedClaudeIdentityAdoptsAnonymousBootstrapHistory() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let anonymousSample = makePlanSample( + at: Date(timeIntervalSince1970: 1_700_000_000), + primary: 15, + secondary: 25) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [anonymousSample]]) + + let resolvedSnapshot = UsageStorePlanUtilizationTests.makeSnapshot( + provider: .claude, + email: "alice@example.com") + let resolvedKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: resolvedSnapshot)) + store._setSnapshotForTesting(resolvedSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + + #expect(history == [anonymousSample]) + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[UsageStore._claudeBootstrapAnonymousAccountKeyForTesting] == nil) + #expect(buckets.accounts[resolvedKey] == [anonymousSample]) + } + + @MainActor + @Test + func claudeHistoryWithoutIdentityFallsBackToLastResolvedAccount() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let resolvedSnapshot = UsageStorePlanUtilizationTests.makeSnapshot( + provider: .claude, + email: "alice@example.com") + store._setSnapshotForTesting(resolvedSnapshot, provider: .claude) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: resolvedSnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: resolvedSnapshot.primary, + secondary: resolvedSnapshot.secondary, + updatedAt: resolvedSnapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + + #expect(history.count == 1) + #expect(history.first?.primaryUsedPercent == 10) + #expect(history.first?.secondaryUsedPercent == 20) + } + + @MainActor + @Test + func claudeHistoryWithoutIdentityFallsBackToMostRecentKnownAccount() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let bobSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "bob@example.com") + let aliceKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: aliceSnapshot)) + let bobKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .claude, + snapshot: bobSnapshot)) + let aliceSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) + let bobSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_086_400), primary: 40, secondary: 50) + + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( + accounts: [ + aliceKey: [aliceSample], + bobKey: [bobSample], + ]) + store.planUtilizationHistory[.claude]?.preferredAccountKey = nil + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + provider: .claude) + + #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index bd892c9ff..acb25a7fd 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -555,6 +555,19 @@ struct UsageStorePlanUtilizationTests { #expect(text == "No weekly utilization data yet.") } + @MainActor + @Test + func makeStoreUsesIsolatedTemporaryStorage() throws { + let store = Self.makeStore() + let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path + let configURL = store.settings.configStore.fileURL.standardizedFileURL + let planHistoryURL = try #require(store.planUtilizationHistoryStore.fileURL?.standardizedFileURL) + + #expect(configURL.path.hasPrefix(temporaryRoot)) + #expect(configURL != CodexBarConfigStore.defaultURL().standardizedFileURL) + #expect(planHistoryURL.path.hasPrefix(temporaryRoot)) + } + @MainActor @Test func planHistorySelectsCurrentAccountBucket() throws { @@ -587,64 +600,6 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .codex) == [bobSample]) } - @MainActor - @Test - func planHistorySelectsConfiguredTokenAccountBucket() throws { - let store = Self.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - - let accounts = store.settings.tokenAccounts(for: .claude) - let alice = try #require(accounts.first) - let bob = try #require(accounts.last) - let aliceKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting( - provider: .claude, - account: alice)) - let bobKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting( - provider: .claude, - account: bob)) - - let aliceSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 15, secondary: 25) - let bobSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_086_400), primary: 45, secondary: 55) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [ - aliceKey: [aliceSample], - bobKey: [bobSample], - ]) - - store.settings.setActiveTokenAccountIndex(0, for: .claude) - #expect(store.planUtilizationHistory(for: .claude) == [aliceSample]) - - store.settings.setActiveTokenAccountIndex(1, for: .claude) - #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) - } - - @MainActor - @Test - func recordPlanHistoryWithoutExplicitAccountUsesSelectedTokenAccountBucket() async throws { - let store = Self.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(1, for: .claude) - - let aliceSnapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") - let selectedTokenKey = try #require( - store.settings.selectedTokenAccount(for: .claude).flatMap { - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: $0) - }) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: aliceSnapshot, - now: Date(timeIntervalSince1970: 1_700_000_000)) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[selectedTokenKey]?.count == 1) - } - @MainActor @Test func recordPlanHistoryPersistsWindowMetadataFromSnapshot() async throws { @@ -739,39 +694,6 @@ struct UsageStorePlanUtilizationTests { #expect(writeTimes.contains(recordedAt)) } - @MainActor - @Test - func applySelectedOutcomeRecordsPlanHistoryForSelectedTokenAccount() async throws { - let store = Self.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(1, for: .claude) - - let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) - let selectedTokenKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) - let snapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") - let outcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: snapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - - await store.applySelectedOutcome( - outcome, - provider: .claude, - account: selectedAccount, - fallbackSnapshot: snapshot) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[selectedTokenKey]?.count == 1) - } - @MainActor @Test func codexPlanHistoryFallsBackToUnscopedBucketWhenIdentityIsUnavailable() { @@ -789,57 +711,6 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .codex) == [sample]) } - @MainActor - @Test - func claudePlanHistoryReturnsEmptyWhenIdentityIsUnavailable() { - let store = Self.makeStore() - let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 20, secondary: 30) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - unscoped: [sample], - accounts: ["claude-account-key": [sample]]) - - #expect(store.planUtilizationHistory(for: .claude).isEmpty) - } - - @MainActor - @Test - func planHistoryDoesNotReadLegacyIdentityBucketForTokenAccounts() throws { - let store = Self.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - - let snapshot = Self.makeSnapshot(provider: .claude, email: "alice@example.com") - let legacyKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: snapshot)) - let legacySample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [legacyKey: [legacySample]]) - store._setSnapshotForTesting(snapshot, provider: .claude) - - #expect(store.planUtilizationHistory(for: .claude).isEmpty) - } - - @MainActor - @Test - func recordPlanHistoryWithoutIdentitySkipsClaudeWrite() async { - let store = Self.makeStore() - let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 11, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 21, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) - let existingHistory = store.planUtilizationHistory[.claude] - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: snapshot, - now: Date(timeIntervalSince1970: 1_700_003_600)) - - #expect(store.planUtilizationHistory[.claude] == existingHistory) - } - @Test func runtimeDoesNotLoadUnsupportedPlanHistoryFile() throws { let root = FileManager.default.temporaryDirectory @@ -893,6 +764,7 @@ struct UsageStorePlanUtilizationTests { primary: 50, secondary: 60) let buckets = PlanUtilizationHistoryBuckets( + preferredAccountKey: "alice", unscoped: [legacySample], accounts: ["alice": [aliceSample]]) @@ -905,12 +777,20 @@ struct UsageStorePlanUtilizationTests { extension UsageStorePlanUtilizationTests { @MainActor - private static func makeStore() -> UsageStore { + static func makeStore() -> UsageStore { let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName) ?? .standard + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create isolated UserDefaults suite for tests") + } defaults.removePersistentDomain(forName: suiteName) let configStore = testConfigStore(suiteName: suiteName) let planHistoryStore = testPlanUtilizationHistoryStore(suiteName: suiteName) + let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path + precondition(configStore.fileURL.standardizedFileURL.path.hasPrefix(temporaryRoot)) + precondition(configStore.fileURL.standardizedFileURL != CodexBarConfigStore.defaultURL().standardizedFileURL) + if let historyURL = planHistoryStore.fileURL?.standardizedFileURL { + precondition(historyURL.path.hasPrefix(temporaryRoot)) + } let isolatedSettings = SettingsStore( userDefaults: defaults, configStore: configStore, @@ -925,7 +805,7 @@ extension UsageStorePlanUtilizationTests { return store } - private static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { UsageSnapshot( primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), @@ -938,7 +818,7 @@ extension UsageStorePlanUtilizationTests { } } -private func makePlanSample( +func makePlanSample( at capturedAt: Date, primary: Double?, secondary: Double?, From 32125bdf01cba2bffcf0133360d0fac21e6155a1 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 10:48:42 +0800 Subject: [PATCH 19/32] Unify plan utilization account resolution across providers - Replace Claude-only bootstrap account handling with shared unscoped history adoption - Resolve plan history buckets via provider-agnostic account key logic - Add/adjust tests for Codex identity resolution and fallback to last resolved account --- .../CodexBar/UsageStore+PlanUtilization.swift | 84 +++++++------------ ...rePlanUtilizationClaudeIdentityTests.swift | 13 ++- .../UsageStorePlanUtilizationTests.swift | 55 +++++++++++- 3 files changed, 90 insertions(+), 62 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 92b391bed..613b73059 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -3,7 +3,6 @@ import CryptoKit import Foundation extension UsageStore { - private nonisolated static let claudeBootstrapAnonymousAccountKey = "__claude_bootstrap_anonymous__" private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 @@ -12,14 +11,10 @@ extension UsageStore { return [] } - guard provider == .claude else { - let accountKey = self.planUtilizationAccountKey(for: provider) - return self.planUtilizationHistory[provider]?.samples(for: accountKey) ?? [] - } - var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let originalProviderBuckets = providerBuckets - let accountKey = self.resolveClaudePlanUtilizationAccountKey( + let accountKey = self.resolvePlanUtilizationAccountKey( + provider: provider, snapshot: self.snapshots[provider], preferredAccount: nil, providerBuckets: &providerBuckets) @@ -49,16 +44,11 @@ extension UsageStore { // into duplicate writes for the same provider/account bucket. var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) - let accountKey = - if provider == .claude { - self.resolveClaudePlanUtilizationAccountKey( - snapshot: snapshot, - preferredAccount: preferredAccount, - providerBuckets: &providerBuckets) - } else { - Self.planUtilizationAccountKey(provider: provider, account: preferredAccount) - ?? Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) - } + let accountKey = self.resolvePlanUtilizationAccountKey( + provider: provider, + snapshot: snapshot, + preferredAccount: preferredAccount, + providerBuckets: &providerBuckets) let history = providerBuckets.samples(for: accountKey) let sample = PlanUtilizationHistorySample( capturedAt: now, @@ -193,10 +183,6 @@ extension UsageStore { } } - private func planUtilizationAccountKey(for provider: UsageProvider) -> String? { - self.planUtilizationAccountKey(for: provider, snapshot: nil, preferredAccount: nil) - } - private func planUtilizationAccountKey( for provider: UsageProvider, snapshot: UsageSnapshot? = nil, @@ -265,59 +251,60 @@ extension UsageStore { return self.snapshots[.claude] == nil && self.error(for: .claude) == nil } - private func resolveClaudePlanUtilizationAccountKey( + private func resolvePlanUtilizationAccountKey( + provider: UsageProvider, snapshot: UsageSnapshot?, preferredAccount: ProviderTokenAccount?, providerBuckets: inout PlanUtilizationHistoryBuckets) -> String? { - let resolvedAccount = preferredAccount ?? self.settings.selectedTokenAccount(for: .claude) - if let tokenAccountKey = Self.planUtilizationAccountKey(provider: .claude, account: resolvedAccount) { + let resolvedAccount = preferredAccount ?? self.settings.selectedTokenAccount(for: provider) + if let tokenAccountKey = Self.planUtilizationAccountKey(provider: provider, account: resolvedAccount) { providerBuckets.preferredAccountKey = tokenAccountKey - self.adoptClaudeBootstrapHistoryIfNeeded(into: tokenAccountKey, providerBuckets: &providerBuckets) + self.adoptPlanUtilizationUnscopedHistoryIfNeeded( + into: tokenAccountKey, + provider: provider, + providerBuckets: &providerBuckets) return tokenAccountKey } if let snapshot, - let identityAccountKey = Self.planUtilizationIdentityAccountKey(provider: .claude, snapshot: snapshot) + let identityAccountKey = Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) { providerBuckets.preferredAccountKey = identityAccountKey - self.adoptClaudeBootstrapHistoryIfNeeded(into: identityAccountKey, providerBuckets: &providerBuckets) + self.adoptPlanUtilizationUnscopedHistoryIfNeeded( + into: identityAccountKey, + provider: provider, + providerBuckets: &providerBuckets) return identityAccountKey } - if let stickyAccountKey = self.stickyClaudePlanUtilizationAccountKey(providerBuckets: providerBuckets) { + if let stickyAccountKey = self.stickyPlanUtilizationAccountKey(providerBuckets: providerBuckets) { return stickyAccountKey } - let hasKnownAccounts = self.hasKnownClaudePlanUtilizationAccounts(providerBuckets: providerBuckets) - if hasKnownAccounts { - return nil - } - - return Self.claudeBootstrapAnonymousAccountKey + return nil } - private func adoptClaudeBootstrapHistoryIfNeeded( + private func adoptPlanUtilizationUnscopedHistoryIfNeeded( into accountKey: String, + provider: UsageProvider, providerBuckets: inout PlanUtilizationHistoryBuckets) { - guard accountKey != Self.claudeBootstrapAnonymousAccountKey else { return } - let anonymousHistory = providerBuckets.accounts[Self.claudeBootstrapAnonymousAccountKey] ?? [] - guard !anonymousHistory.isEmpty else { return } + guard !providerBuckets.unscoped.isEmpty else { return } let existingHistory = providerBuckets.accounts[accountKey] ?? [] - let mergedHistory = Self.mergedPlanUtilizationHistories(provider: .claude, histories: [ + let mergedHistory = Self.mergedPlanUtilizationHistories(provider: provider, histories: [ existingHistory, - anonymousHistory, + providerBuckets.unscoped, ]) providerBuckets.setSamples(mergedHistory, for: accountKey) - providerBuckets.setSamples([], for: Self.claudeBootstrapAnonymousAccountKey) + providerBuckets.setSamples([], for: nil) } - private func stickyClaudePlanUtilizationAccountKey( + private func stickyPlanUtilizationAccountKey( providerBuckets: PlanUtilizationHistoryBuckets) -> String? { - let knownAccountKeys = self.knownClaudePlanUtilizationAccountKeys(providerBuckets: providerBuckets) + let knownAccountKeys = self.knownPlanUtilizationAccountKeys(providerBuckets: providerBuckets) guard !knownAccountKeys.isEmpty else { return nil } if let preferredAccountKey = providerBuckets.preferredAccountKey, @@ -340,13 +327,8 @@ extension UsageStore { } } - private func hasKnownClaudePlanUtilizationAccounts(providerBuckets: PlanUtilizationHistoryBuckets) -> Bool { - !self.knownClaudePlanUtilizationAccountKeys(providerBuckets: providerBuckets).isEmpty - } - - private func knownClaudePlanUtilizationAccountKeys(providerBuckets: PlanUtilizationHistoryBuckets) -> [String] { + private func knownPlanUtilizationAccountKeys(providerBuckets: PlanUtilizationHistoryBuckets) -> [String] { providerBuckets.accounts.keys - .filter { $0 != Self.claudeBootstrapAnonymousAccountKey } .sorted() } @@ -390,10 +372,6 @@ extension UsageStore { { self.planUtilizationAccountKey(provider: provider, account: account) } - - nonisolated static var _claudeBootstrapAnonymousAccountKeyForTesting: String { - self.claudeBootstrapAnonymousAccountKey - } #endif } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 71d0a999a..23357ae2a 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -102,8 +102,7 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { let store = UsageStorePlanUtilizationTests.makeStore() let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 20, secondary: 30) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [sample]]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) #expect(store.planUtilizationHistory(for: .claude) == [sample]) } @@ -114,8 +113,7 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { let store = UsageStorePlanUtilizationTests.makeStore() let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [sample]]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) store.refreshingProviders.insert(.claude) #expect(store.shouldShowPlanUtilizationRefreshingState(for: .claude)) @@ -176,7 +174,7 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { now: Date(timeIntervalSince1970: 1_700_003_600)) let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[UsageStore._claudeBootstrapAnonymousAccountKeyForTesting]?.count == 1) + #expect(buckets.unscoped.count == 1) } @MainActor @@ -223,8 +221,7 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { at: Date(timeIntervalSince1970: 1_700_000_000), primary: 15, secondary: 25) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [UsageStore._claudeBootstrapAnonymousAccountKeyForTesting: [anonymousSample]]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [anonymousSample]) let resolvedSnapshot = UsageStorePlanUtilizationTests.makeSnapshot( provider: .claude, @@ -239,7 +236,7 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(history == [anonymousSample]) let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[UsageStore._claudeBootstrapAnonymousAccountKeyForTesting] == nil) + #expect(buckets.unscoped.isEmpty) #expect(buckets.accounts[resolvedKey] == [anonymousSample]) } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index acb25a7fd..7ac977ffd 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -594,7 +594,10 @@ struct UsageStorePlanUtilizationTests { ]) store._setSnapshotForTesting(aliceSnapshot, provider: .codex) - #expect(store.planUtilizationHistory(for: .codex) == [aliceSample]) + #expect(store.planUtilizationHistory(for: .codex) == [ + makePlanSample(at: Date(timeIntervalSince1970: 1_699_913_600), primary: 90, secondary: 90), + aliceSample, + ]) store._setSnapshotForTesting(bobSnapshot, provider: .codex) #expect(store.planUtilizationHistory(for: .codex) == [bobSample]) @@ -711,6 +714,56 @@ struct UsageStorePlanUtilizationTests { #expect(store.planUtilizationHistory(for: .codex) == [sample]) } + @MainActor + @Test + func firstResolvedCodexIdentityAdoptsUnscopedHistory() throws { + let store = Self.makeStore() + let unscopedSample = makePlanSample( + at: Date(timeIntervalSince1970: 1_700_000_000), + primary: 15, + secondary: 25) + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets(unscoped: [unscopedSample]) + + let resolvedSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + let resolvedKey = try #require( + UsageStore._planUtilizationAccountKeyForTesting( + provider: .codex, + snapshot: resolvedSnapshot)) + store._setSnapshotForTesting(resolvedSnapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + + #expect(history == [unscopedSample]) + let buckets = try #require(store.planUtilizationHistory[.codex]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[resolvedKey] == [unscopedSample]) + } + + @MainActor + @Test + func codexHistoryWithoutIdentityFallsBackToLastResolvedAccount() async { + let store = Self.makeStore() + let resolvedSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + store._setSnapshotForTesting(resolvedSnapshot, provider: .codex) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: resolvedSnapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: resolvedSnapshot.primary, + secondary: resolvedSnapshot.secondary, + updatedAt: resolvedSnapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .codex) + + let history = store.planUtilizationHistory(for: .codex) + + #expect(history.count == 1) + #expect(history.first?.primaryUsedPercent == 10) + #expect(history.first?.secondaryUsedPercent == 20) + } + @Test func runtimeDoesNotLoadUnsupportedPlanHistoryFile() throws { let root = FileManager.default.temporaryDirectory From 6f8d41bccccd8bfa8ebf8427df8525db975cc47f Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 11:09:25 +0800 Subject: [PATCH 20/32] Clarify utilization detail copy and provenance messaging - Show used and wasted percentages on one detail line in the history chart - Add provenance text for exact-fit vs derived aggregation sources - Add tests for derived detail text and exact-fit provider-reported copy --- .../PlanUtilizationHistoryChartMenuView.swift | 107 ++++++++++++++---- .../UsageStorePlanUtilizationTests.swift | 59 ++++++++++ 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 97699bfec..3821f1d16 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -196,8 +196,8 @@ struct PlanUtilizationHistoryChartMenuView: View { .truncationMode(.tail) .frame(height: 16, alignment: .leading) Text(detail.secondary) - .font(.caption) - .foregroundStyle(.secondary) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) @@ -223,6 +223,7 @@ struct PlanUtilizationHistoryChartMenuView: View { let pointsByID: [String: Point] let pointsByIndex: [Int: Point] let barColor: Color + let provenanceText: String } private nonisolated static func makeModel( @@ -238,6 +239,10 @@ struct PlanUtilizationHistoryChartMenuView: View { let aggregationMode = Self.aggregationMode(period: period, source: selectedSource) let usesResetAlignedExactFit = aggregationMode == .exactFit && self.hasAnyResetBoundary(samples: samples, source: selectedSource) + let provenanceText = self.provenanceText( + period: period, + source: selectedSource, + aggregationMode: aggregationMode) var points: [Point] if usesResetAlignedExactFit { @@ -296,7 +301,8 @@ struct PlanUtilizationHistoryChartMenuView: View { xDomain: xDomain, pointsByID: pointsByID, pointsByIndex: pointsByIndex, - barColor: barColor) + barColor: barColor, + provenanceText: provenanceText) } private nonisolated static func filledPoints( @@ -340,7 +346,8 @@ struct PlanUtilizationHistoryChartMenuView: View { xDomain: self.xDomain(points: [], period: period), pointsByID: [:], pointsByIndex: [:], - barColor: barColor) + barColor: barColor, + provenanceText: "") } private nonisolated static func xDomain(points: [Point], period: Period) -> ClosedRange? { @@ -367,6 +374,7 @@ struct PlanUtilizationHistoryChartMenuView: View { let selectedSource: String? let usedPercents: [Double] let pointDates: [String] + let provenanceText: String } nonisolated static func _modelSnapshotForTesting( @@ -396,7 +404,24 @@ struct PlanUtilizationHistoryChartMenuView: View { formatter.timeZone = TimeZone.current formatter.dateFormat = "yyyy-MM-dd HH:mm" return formatter.string(from: point.date) - }) + }, + provenanceText: model.provenanceText) + } + + nonisolated static func _detailLinesForTesting( + periodRawValue: String, + samples: [PlanUtilizationHistorySample], + provider: UsageProvider, + referenceDate: Date? = nil) -> (primary: String, secondary: String)? + { + guard let period = Period(rawValue: periodRawValue) else { return nil } + let effectiveReferenceDate = referenceDate ?? samples.map(\.capturedAt).max() ?? Date() + let model = self.makeModel( + period: period, + samples: samples, + provider: provider, + referenceDate: effectiveReferenceDate) + return self.detailLines(point: model.points.last, period: period, provenanceText: model.provenanceText) } nonisolated static func _emptyStateTextForTesting(periodRawValue: String, isRefreshing: Bool) -> String? { @@ -478,6 +503,32 @@ struct PlanUtilizationHistoryChartMenuView: View { source.windowMinutes == period.chartWindowMinutes ? .exactFit : .derived } + private nonisolated static func provenanceText( + period: Period, + source: WindowSourceSelection, + aggregationMode: AggregationMode) -> String + { + switch aggregationMode { + case .exactFit: + "Provider-reported \(period.rawValue) usage." + case .derived: + "Estimated from provider-reported \(self.windowLabel(minutes: source.windowMinutes)) windows." + } + } + + private nonisolated static func windowLabel(minutes: Int) -> String { + guard minutes > 0 else { return "provider" } + if minutes.isMultiple(of: 1440) { + let days = minutes / 1440 + return "\(days)-day" + } + if minutes.isMultiple(of: 60) { + let hours = minutes / 60 + return "\(hours)-hour" + } + return "\(minutes)-minute" + } + private nonisolated static func chartBuckets( period: Period, samples: [PlanUtilizationHistorySample], @@ -784,25 +835,7 @@ struct PlanUtilizationHistoryChartMenuView: View { private func detailLines(model: Model, period: Period) -> (primary: String, secondary: String) { let activePoint = self.selectedPoint(model: model) ?? model.points.last - guard let point = activePoint else { - return ("No data", "") - } - - let dateLabel: String = switch period { - case .daily, .weekly: - point.date.formatted(.dateTime.month(.abbreviated).day()) - case .monthly: - point.date.formatted(.dateTime.month(.abbreviated).year(.defaultDigits)) - } - - let used = max(0, min(100, point.usedPercent)) - let wasted = max(0, 100 - used) - let usedText = used.formatted(.number.precision(.fractionLength(0...1))) - let wastedText = wasted.formatted(.number.precision(.fractionLength(0...1))) - - return ( - "\(dateLabel): \(usedText)% used", - "\(wastedText)% wasted") + return Self.detailLines(point: activePoint, period: period, provenanceText: model.provenanceText) } private func updateSelection( @@ -845,6 +878,32 @@ struct PlanUtilizationHistoryChartMenuView: View { } extension PlanUtilizationHistoryChartMenuView { + private nonisolated static func detailLines( + point: Point?, + period: Period, + provenanceText: String) -> (primary: String, secondary: String) + { + guard let point else { + return ("No data", provenanceText) + } + + let dateLabel: String = switch period { + case .daily, .weekly: + point.date.formatted(.dateTime.month(.abbreviated).day()) + case .monthly: + point.date.formatted(.dateTime.month(.abbreviated).year(.defaultDigits)) + } + + let used = max(0, min(100, point.usedPercent)) + let wasted = max(0, 100 - used) + let usedText = used.formatted(.number.precision(.fractionLength(0...1))) + let wastedText = wasted.formatted(.number.precision(.fractionLength(0...1))) + + return ( + "\(dateLabel): \(usedText)% used, \(wastedText)% wasted", + provenanceText) + } + private nonisolated static func exactFitPoints( samples: [PlanUtilizationHistorySample], source: WindowSourceSelection, diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 7ac977ffd..220ad334e 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -535,6 +535,65 @@ struct UsageStorePlanUtilizationTests { #expect(abs(model.usedPercents[0] - ((20.0 * 5.0 + 40.0 * 5.0) / (7.0 * 24.0))) < 0.000_1) } + @MainActor + @Test + func detailLinesShowUsedAndWastedOnSingleLineForDerivedData() throws { + let calendar = Calendar(identifier: .gregorian) + let boundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 7, + hour: 5, + minute: 0))) + let samples = [ + makePlanSample( + at: boundary.addingTimeInterval(-30 * 60), + primary: 48, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: boundary), + ] + + let detail = try #require( + PlanUtilizationHistoryChartMenuView._detailLinesForTesting( + periodRawValue: "daily", + samples: samples, + provider: .codex)) + + #expect(detail.primary == "Mar 7: 10% used, 90% wasted") + #expect(detail.secondary == "Estimated from provider-reported 5-hour windows.") + } + + @MainActor + @Test + func exactFitModelUsesDirectProviderReportedCopy() throws { + let calendar = Calendar(identifier: .gregorian) + let boundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let samples = [ + makePlanSample( + at: boundary.addingTimeInterval(-30 * 60), + primary: nil, + secondary: 35, + secondaryWindowMinutes: 10080, + secondaryResetsAt: boundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.provenanceText == "Provider-reported weekly usage.") + } + @Test func chartEmptyStateShowsRefreshingWhileLoading() throws { let text = try #require( From 017e62a0293af2c9f8d167b51ad50edb87e45931 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 12:04:25 +0800 Subject: [PATCH 21/32] Preserve same-hour utilization samples across reset boundaries - Merge plan utilization samples only when window/reset markers are compatible - Pick merge candidates around insertion point to handle late backfills deterministically - Add reset coalescing tests for primary/secondary boundary changes and chart behavior --- .../CodexBar/UsageStore+PlanUtilization.swift | 92 ++++- ...ePlanUtilizationResetCoalescingTests.swift | 330 ++++++++++++++++++ 2 files changed, 417 insertions(+), 5 deletions(-) create mode 100644 Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 613b73059..75dca6292 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -82,11 +82,13 @@ extension UsageStore { sample: PlanUtilizationHistorySample) -> [PlanUtilizationHistorySample]? { var history = existingHistory - let sampleHourBucket = self.planUtilizationHourBucket(for: sample.capturedAt) + let insertionIndex = history.firstIndex(where: { $0.capturedAt > sample.capturedAt }) ?? history.endIndex - if let matchingIndex = history.lastIndex(where: { - self.planUtilizationHourBucket(for: $0.capturedAt) == sampleHourBucket - }) { + if let matchingIndex = self.planUtilizationHistoryMergeIndex( + history: history, + insertionIndex: insertionIndex, + sample: sample) + { let merged = self.mergedPlanUtilizationHistorySample( existing: history[matchingIndex], incoming: sample) @@ -97,7 +99,7 @@ extension UsageStore { return history } - if let insertionIndex = history.firstIndex(where: { $0.capturedAt > sample.capturedAt }) { + if insertionIndex < history.endIndex { history.insert(sample, at: insertionIndex) } else { history.append(sample) @@ -136,6 +138,86 @@ extension UsageStore { Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) } + private nonisolated static func planUtilizationHistoryMergeIndex( + history: [PlanUtilizationHistorySample], + insertionIndex: Int, + sample: PlanUtilizationHistorySample) -> Int? + { + let sampleHourBucket = self.planUtilizationHourBucket(for: sample.capturedAt) + var candidateIndexes: [Int] = [] + + let previousIndex = insertionIndex - 1 + if previousIndex >= history.startIndex { + candidateIndexes.append(previousIndex) + } + + if insertionIndex < history.endIndex { + candidateIndexes.append(insertionIndex) + } + + let compatibleIndexes = candidateIndexes.filter { index in + let existing = history[index] + return self.planUtilizationHourBucket(for: existing.capturedAt) == sampleHourBucket + && self.canMergePlanUtilizationHistorySamples(existing: existing, incoming: sample) + } + + guard !compatibleIndexes.isEmpty else { return nil } + if compatibleIndexes.count == 1 { + return compatibleIndexes[0] + } + + return compatibleIndexes.min { lhs, rhs in + let lhsDistance = abs(history[lhs].capturedAt.timeIntervalSince(sample.capturedAt)) + let rhsDistance = abs(history[rhs].capturedAt.timeIntervalSince(sample.capturedAt)) + if lhsDistance == rhsDistance { + return history[lhs].capturedAt > history[rhs].capturedAt + } + return lhsDistance < rhsDistance + } + } + + private nonisolated static func canMergePlanUtilizationHistorySamples( + existing: PlanUtilizationHistorySample, + incoming: PlanUtilizationHistorySample) -> Bool + { + self.arePlanUtilizationWindowMarkersCompatible( + existingWindowMinutes: existing.primaryWindowMinutes, + existingResetsAt: existing.primaryResetsAt, + incomingWindowMinutes: incoming.primaryWindowMinutes, + incomingResetsAt: incoming.primaryResetsAt) + && self.arePlanUtilizationWindowMarkersCompatible( + existingWindowMinutes: existing.secondaryWindowMinutes, + existingResetsAt: existing.secondaryResetsAt, + incomingWindowMinutes: incoming.secondaryWindowMinutes, + incomingResetsAt: incoming.secondaryResetsAt) + } + + private nonisolated static func arePlanUtilizationWindowMarkersCompatible( + existingWindowMinutes: Int?, + existingResetsAt: Date?, + incomingWindowMinutes: Int?, + incomingResetsAt: Date?) -> Bool + { + if let existingWindowMinutes, let incomingWindowMinutes, existingWindowMinutes != incomingWindowMinutes { + return false + } + + let normalizedExistingReset = existingResetsAt.map(self.normalizedPlanUtilizationBoundaryDate) + let normalizedIncomingReset = incomingResetsAt.map(self.normalizedPlanUtilizationBoundaryDate) + if let normalizedExistingReset, + let normalizedIncomingReset, + normalizedExistingReset != normalizedIncomingReset + { + return false + } + + return true + } + + private nonisolated static func normalizedPlanUtilizationBoundaryDate(_ date: Date) -> Date { + Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) + } + private nonisolated static func mergedPlanUtilizationHistorySample( existing: PlanUtilizationHistorySample, incoming: PlanUtilizationHistorySample) -> PlanUtilizationHistorySample diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift new file mode 100644 index 000000000..eee7dc1c7 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -0,0 +1,330 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationResetCoalescingTests { + @Test + func sameHourSampleWithChangedPrimaryResetBoundaryAppendsNewSample() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let firstReset = hourStart.addingTimeInterval(30 * 60) + let secondReset = hourStart.addingTimeInterval(5 * 60 + 5 * 60 * 60) + let beforeReset = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 82, + secondary: 40, + primaryWindowMinutes: 300, + primaryResetsAt: firstReset, + secondaryWindowMinutes: 10080) + let afterReset = makePlanSample( + at: hourStart.addingTimeInterval(35 * 60), + primary: 4, + secondary: 41, + primaryWindowMinutes: 300, + primaryResetsAt: secondReset, + secondaryWindowMinutes: 10080) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: beforeReset)) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: afterReset)) + + #expect(updated.count == 2) + #expect(updated[0] == beforeReset) + #expect(updated[1] == afterReset) + } + + @Test + func sameHourSampleWithChangedSecondaryResetBoundaryAppendsNewSample() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let firstReset = hourStart.addingTimeInterval(30 * 60) + let secondReset = firstReset.addingTimeInterval(7 * 24 * 60 * 60) + let shiftedReset = secondReset.addingTimeInterval(7 * 24 * 60 * 60) + let beforeReset = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 40, + secondary: 77, + primaryWindowMinutes: 300, + primaryResetsAt: firstReset, + secondaryWindowMinutes: 10080, + secondaryResetsAt: secondReset) + let afterReset = makePlanSample( + at: hourStart.addingTimeInterval(35 * 60), + primary: 41, + secondary: 3, + primaryWindowMinutes: 300, + primaryResetsAt: firstReset, + secondaryWindowMinutes: 10080, + secondaryResetsAt: shiftedReset) + + let initial = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [], + sample: beforeReset)) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: afterReset)) + + #expect(updated.count == 2) + #expect(updated[0] == beforeReset) + #expect(updated[1] == afterReset) + } + + @Test + func sameHourSampleMergesWhenOnlyIncomingResetMetadataIsBackfilled() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let incomingReset = hourStart.addingTimeInterval(30 * 60) + let existing = makePlanSample( + at: hourStart.addingTimeInterval(10 * 60), + primary: 20, + secondary: 35, + primaryWindowMinutes: 300, + secondaryWindowMinutes: 10080) + let incoming = makePlanSample( + at: hourStart.addingTimeInterval(45 * 60), + primary: 30, + secondary: 40, + primaryWindowMinutes: 300, + primaryResetsAt: incomingReset, + secondaryWindowMinutes: 10080) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [existing], + sample: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].primaryUsedPercent == 30) + #expect(updated[0].secondaryUsedPercent == 40) + #expect(updated[0].primaryResetsAt == incomingReset) + } + + @MainActor + @Test + func lateSameHourBackfillBeforeResetMergesIntoEarlierWindow() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let resetBoundary = hourStart.addingTimeInterval(30 * 60) + let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) + let earlierWindow = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 82, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: resetBoundary) + let laterWindow = makePlanSample( + at: hourStart.addingTimeInterval(35 * 60), + primary: 4, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nextResetBoundary) + let lateBackfill = makePlanSample( + at: hourStart.addingTimeInterval(28 * 60), + primary: 95, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nil) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [earlierWindow, laterWindow], + sample: lateBackfill)) + + #expect(updated.count == 2) + #expect(updated[0].capturedAt == lateBackfill.capturedAt) + #expect(updated[0].primaryUsedPercent == 95) + #expect(updated[0].primaryResetsAt == resetBoundary) + #expect(updated[1] == laterWindow) + } + + @Test + func lateSameHourBackfillAfterResetMergesIntoLaterWindow() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let resetBoundary = hourStart.addingTimeInterval(30 * 60) + let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) + let earlierWindow = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 82, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: resetBoundary) + let laterWindow = makePlanSample( + at: hourStart.addingTimeInterval(35 * 60), + primary: 4, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nextResetBoundary) + let lateBackfill = makePlanSample( + at: hourStart.addingTimeInterval(32 * 60), + primary: 12, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nil) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [earlierWindow, laterWindow], + sample: lateBackfill)) + + #expect(updated.count == 2) + #expect(updated[0] == earlierWindow) + #expect(updated[1].capturedAt == lateBackfill.capturedAt) + #expect(updated[1].primaryUsedPercent == 12) + #expect(updated[1].primaryResetsAt == nextResetBoundary) + } + + @Test + func sameHourBackfillTiePrefersLaterWindow() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let resetBoundary = hourStart.addingTimeInterval(30 * 60) + let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) + let earlierWindow = makePlanSample( + at: hourStart.addingTimeInterval(25 * 60), + primary: 82, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: resetBoundary) + let laterWindow = makePlanSample( + at: hourStart.addingTimeInterval(35 * 60), + primary: 4, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nextResetBoundary) + let ambiguousBackfill = makePlanSample( + at: hourStart.addingTimeInterval(30 * 60), + primary: 12, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: nil) + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [earlierWindow, laterWindow], + sample: ambiguousBackfill)) + + #expect(updated.count == 2) + #expect(updated[0] == earlierWindow) + #expect(updated[1].capturedAt == ambiguousBackfill.capturedAt) + #expect(updated[1].primaryUsedPercent == 12) + #expect(updated[1].primaryResetsAt == nextResetBoundary) + } + + @MainActor + @Test + func dailyChartPreservesCompletedWindowAcrossResetWithinSameHour() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 17, + hour: 4, + minute: 30))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 17, + hour: 9, + minute: 30))) + let thirdBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 17, + hour: 14, + minute: 30))) + let samples = [ + makePlanSample( + at: firstBoundary.addingTimeInterval(-20 * 60), + primary: 20, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: firstBoundary), + makePlanSample( + at: secondBoundary.addingTimeInterval(-5 * 60), + primary: 82, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: secondBoundary), + makePlanSample( + at: secondBoundary.addingTimeInterval(5 * 60), + primary: 4, + secondary: nil, + primaryWindowMinutes: 300, + primaryResetsAt: thirdBoundary), + ] + + var history: [PlanUtilizationHistorySample] = [] + for sample in samples { + history = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: history, + sample: sample)) + } + + #expect(history.count == 3) + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "daily", + samples: history, + provider: .codex)) + + #expect(model.pointCount == 1) + #expect(model.selectedSource == "primary:300") + #expect(model.usedPercents.count == 1) + #expect(abs(model.usedPercents[0] - ((20.0 + 82.0 + 4.0) * 5.0 / 24.0)) < 0.000_1) + } +} From 3e05eb1a444eb08801836f4b615a91ee6cf49921 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 12:57:52 +0800 Subject: [PATCH 22/32] Preserve selected Claude history when recording secondary accounts - Record plan utilization history for non-selected token accounts after refresh - Add flags to avoid updating preferred account key from secondary samples - Prevent secondary Claude samples from consuming/adopting unscoped bootstrap history - Add Claude identity tests covering preferred-bucket and bootstrap edge cases --- .../CodexBar/UsageStore+PlanUtilization.swift | 34 ++- .../CodexBar/UsageStore+TokenAccounts.swift | 24 ++ ...rePlanUtilizationClaudeIdentityTests.swift | 217 ++++++++++++++++++ 3 files changed, 265 insertions(+), 10 deletions(-) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 75dca6292..a55bedd38 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -32,6 +32,8 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot, account: ProviderTokenAccount? = nil, + shouldUpdatePreferredAccountKey: Bool = true, + shouldAdoptUnscopedHistory: Bool = true, now: Date = Date()) async { @@ -48,6 +50,8 @@ extension UsageStore { provider: provider, snapshot: snapshot, preferredAccount: preferredAccount, + shouldUpdatePreferredAccountKey: shouldUpdatePreferredAccountKey, + shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, providerBuckets: &providerBuckets) let history = providerBuckets.samples(for: accountKey) let sample = PlanUtilizationHistorySample( @@ -337,26 +341,36 @@ extension UsageStore { provider: UsageProvider, snapshot: UsageSnapshot?, preferredAccount: ProviderTokenAccount?, + shouldUpdatePreferredAccountKey: Bool = true, + shouldAdoptUnscopedHistory: Bool = true, providerBuckets: inout PlanUtilizationHistoryBuckets) -> String? { let resolvedAccount = preferredAccount ?? self.settings.selectedTokenAccount(for: provider) if let tokenAccountKey = Self.planUtilizationAccountKey(provider: provider, account: resolvedAccount) { - providerBuckets.preferredAccountKey = tokenAccountKey - self.adoptPlanUtilizationUnscopedHistoryIfNeeded( - into: tokenAccountKey, - provider: provider, - providerBuckets: &providerBuckets) + if shouldUpdatePreferredAccountKey { + providerBuckets.preferredAccountKey = tokenAccountKey + } + if shouldAdoptUnscopedHistory { + self.adoptPlanUtilizationUnscopedHistoryIfNeeded( + into: tokenAccountKey, + provider: provider, + providerBuckets: &providerBuckets) + } return tokenAccountKey } if let snapshot, let identityAccountKey = Self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) { - providerBuckets.preferredAccountKey = identityAccountKey - self.adoptPlanUtilizationUnscopedHistoryIfNeeded( - into: identityAccountKey, - provider: provider, - providerBuckets: &providerBuckets) + if shouldUpdatePreferredAccountKey { + providerBuckets.preferredAccountKey = identityAccountKey + } + if shouldAdoptUnscopedHistory { + self.adoptPlanUtilizationUnscopedHistoryIfNeeded( + into: identityAccountKey, + provider: provider, + providerBuckets: &providerBuckets) + } return identityAccountKey } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 4c2eead5b..80a41b3d9 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -33,6 +33,7 @@ extension UsageStore { let limitedAccounts = self.limitedTokenAccounts(accounts, selected: selectedAccount) let effectiveSelected = selectedAccount ?? limitedAccounts.first var snapshots: [TokenAccountUsageSnapshot] = [] + var historySamples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)] = [] var selectedOutcome: ProviderFetchOutcome? var selectedSnapshot: UsageSnapshot? @@ -41,6 +42,9 @@ extension UsageStore { let outcome = await self.fetchOutcome(provider: provider, override: override) let resolved = self.resolveAccountOutcome(outcome, provider: provider, account: account) snapshots.append(resolved.snapshot) + if let usage = resolved.usage { + historySamples.append((account: account, snapshot: usage)) + } if account.id == effectiveSelected?.id { selectedOutcome = outcome selectedSnapshot = resolved.usage @@ -58,6 +62,11 @@ extension UsageStore { account: effectiveSelected, fallbackSnapshot: selectedSnapshot) } + + await self.recordFetchedTokenAccountPlanUtilizationHistory( + provider: provider, + samples: historySamples, + selectedAccount: effectiveSelected) } func limitedTokenAccounts( @@ -113,6 +122,21 @@ extension UsageStore { let usage: UsageSnapshot? } + func recordFetchedTokenAccountPlanUtilizationHistory( + provider: UsageProvider, + samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], + selectedAccount: ProviderTokenAccount?) async + { + for sample in samples where sample.account.id != selectedAccount?.id { + await self.recordPlanUtilizationHistorySample( + provider: provider, + snapshot: sample.snapshot, + account: sample.account, + shouldUpdatePreferredAccountKey: false, + shouldAdoptUnscopedHistory: false) + } + } + private func resolveAccountOutcome( _ outcome: ProviderFetchOutcome, provider: UsageProvider, diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 23357ae2a..028239e99 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -96,6 +96,223 @@ struct UsageStorePlanUtilizationClaudeIdentityTests { #expect(buckets.accounts[selectedTokenKey]?.count == 1) } + @MainActor + @Test + func refreshingOtherTokenAccountsRecordsPlanHistoryAfterSelectedClaudeSnapshotResolves() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let selectedOutcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: aliceSnapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + store.refreshingProviders.insert(.claude) + + await store.applySelectedOutcome( + selectedOutcome, + provider: .claude, + account: alice, + fallbackSnapshot: aliceSnapshot) + + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [ + (account: bob, snapshot: UsageStorePlanUtilizationTests.makeSnapshot( + provider: .claude, + email: "bob@example.com")), + ], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.accounts[bobKey]?.count == 1) + + store.settings.setActiveTokenAccountIndex(1, for: .claude) + #expect(store.planUtilizationHistory(for: .claude).count == 1) + } + + @MainActor + @Test + func selectedClaudeTokenAccountAdoptsBootstrapHistoryBeforeSecondaryAccountsRecord() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + let bootstrapSample = makePlanSample( + at: Date(timeIntervalSince1970: 1_700_000_000), + primary: 15, + secondary: 25) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrapSample]) + + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let selectedOutcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: aliceSnapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + store.refreshingProviders.insert(.claude) + + await store.applySelectedOutcome( + selectedOutcome, + provider: .claude, + account: alice, + fallbackSnapshot: aliceSnapshot) + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [ + (account: bob, snapshot: UsageStorePlanUtilizationTests.makeSnapshot( + provider: .claude, + email: "bob@example.com")), + ], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[aliceKey]?.contains(bootstrapSample) == true) + #expect(buckets.accounts[bobKey]?.contains(bootstrapSample) != true) + } + + @MainActor + @Test + func secondaryClaudeSamplesDoNotReplacePreferredStickyHistoryBucket() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let bobSnapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "bob@example.com", + accountOrganization: nil, + loginMethod: "plus")) + let selectedOutcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: aliceSnapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + store.refreshingProviders.insert(.claude) + + await store.applySelectedOutcome( + selectedOutcome, + provider: .claude, + account: alice, + fallbackSnapshot: aliceSnapshot) + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [(account: bob, snapshot: bobSnapshot)], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.preferredAccountKey == aliceKey) + + store.settings.removeTokenAccount(provider: .claude, accountID: alice.id) + store.settings.removeTokenAccount(provider: .claude, accountID: bob.id) + store._setSnapshotForTesting( + UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), + provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #expect(history.first?.primaryUsedPercent == 10) + #expect(history.first?.secondaryUsedPercent == 20) + } + + @MainActor + @Test + func secondaryClaudeSamplesDoNotConsumeAnonymousBootstrapHistoryWhenSelectedAccountFails() async throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") + store.settings.setActiveTokenAccountIndex(0, for: .claude) + + let accounts = store.settings.tokenAccounts(for: .claude) + let alice = try #require(accounts.first) + let bob = try #require(accounts.last) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + let bobKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) + let bootstrapSample = makePlanSample( + at: Date(timeIntervalSince1970: 1_700_000_000), + primary: 15, + secondary: 25) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrapSample]) + + let bobSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "bob@example.com") + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [(account: bob, snapshot: bobSnapshot)], + selectedAccount: alice) + + var buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.unscoped == [bootstrapSample]) + #expect(buckets.accounts[bobKey]?.contains(bootstrapSample) != true) + + let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") + let selectedOutcome = ProviderFetchOutcome( + result: .success( + ProviderFetchResult( + usage: aliceSnapshot, + credits: nil, + dashboard: nil, + sourceLabel: "test", + strategyID: "test", + strategyKind: .web)), + attempts: []) + store.refreshingProviders.insert(.claude) + + await store.applySelectedOutcome( + selectedOutcome, + provider: .claude, + account: alice, + fallbackSnapshot: aliceSnapshot) + + buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[aliceKey]?.contains(bootstrapSample) == true) + } + @MainActor @Test func claudePlanHistoryFallsBackToAnonymousBootstrapBucketBeforeFirstIdentity() { From 0005ee53f0e8c2473be1ac59ad4798904c0f7f8b Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 13:20:49 +0800 Subject: [PATCH 23/32] Extract Codex credits refresh/backfill into provider extension - Move Codex credit refresh and snapshot-backfill logic into `UsageStore+CodexRefresh` - Adjust member visibility so the new extension can reuse cached credits and backfill task state --- .../Codex/UsageStore+CodexRefresh.swift | 102 +++++++++++++++++ Sources/CodexBar/UsageStore.swift | 104 +----------------- 2 files changed, 105 insertions(+), 101 deletions(-) create mode 100644 Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift new file mode 100644 index 000000000..9ecc6213e --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -0,0 +1,102 @@ +import CodexBarCore +import Foundation + +@MainActor +extension UsageStore { + nonisolated static let codexSnapshotWaitTimeoutSeconds: TimeInterval = 6 + nonisolated static let codexSnapshotPollIntervalNanoseconds: UInt64 = 100_000_000 + + func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { + guard self.isEnabled(.codex) else { return } + do { + let credits = try await self.codexFetcher.loadLatestCredits( + keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) + await MainActor.run { + self.credits = credits + self.lastCreditsError = nil + self.lastCreditsSnapshot = credits + self.creditsFailureStreak = 0 + } + let codexSnapshot = await MainActor.run { + self.snapshots[.codex] + } + if let minimumSnapshotUpdatedAt, + codexSnapshot == nil || codexSnapshot?.updatedAt ?? .distantPast < minimumSnapshotUpdatedAt + { + self.scheduleCodexPlanHistoryBackfill( + minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) + return + } + + self.cancelCodexPlanHistoryBackfill() + guard let codexSnapshot else { return } + await self.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: codexSnapshot) + } catch { + let message = error.localizedDescription + if message.localizedCaseInsensitiveContains("data not available yet") { + await MainActor.run { + if let cached = self.lastCreditsSnapshot { + self.credits = cached + self.lastCreditsError = nil + } else { + self.credits = nil + self.lastCreditsError = "Codex credits are still loading; will retry shortly." + } + } + return + } + + await MainActor.run { + self.creditsFailureStreak += 1 + if let cached = self.lastCreditsSnapshot { + self.credits = cached + let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened) + self.lastCreditsError = + "Last Codex credits refresh failed: \(message). Cached values from \(stamp)." + } else { + self.lastCreditsError = message + self.credits = nil + } + } + } + } + + func waitForCodexSnapshot(minimumUpdatedAt: Date) async -> UsageSnapshot? { + let deadline = Date().addingTimeInterval(Self.codexSnapshotWaitTimeoutSeconds) + + while Date() < deadline { + if Task.isCancelled { return nil } + if let snapshot = await MainActor.run(body: { self.snapshots[.codex] }), + snapshot.updatedAt >= minimumUpdatedAt + { + return snapshot + } + try? await Task.sleep(nanoseconds: Self.codexSnapshotPollIntervalNanoseconds) + } + + return nil + } + + func scheduleCodexPlanHistoryBackfill( + minimumSnapshotUpdatedAt: Date) + { + self.cancelCodexPlanHistoryBackfill() + self.codexPlanHistoryBackfillTask = Task { @MainActor [weak self] in + guard let self else { return } + guard let snapshot = await self.waitForCodexSnapshot(minimumUpdatedAt: minimumSnapshotUpdatedAt) else { + return + } + await self.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot) + self.codexPlanHistoryBackfillTask = nil + } + } + + func cancelCodexPlanHistoryBackfill() { + self.codexPlanHistoryBackfillTask?.cancel() + self.codexPlanHistoryBackfillTask = nil + } +} diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 897be2d53..b34b5e3ea 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1,4 +1,3 @@ -// swiftlint:disable file_length import AppKit import CodexBarCore import Foundation @@ -9,9 +8,6 @@ import SweetCookieKit @MainActor extension UsageStore { - private nonisolated static let codexSnapshotWaitTimeoutSeconds: TimeInterval = 6 - private nonisolated static let codexSnapshotPollIntervalNanoseconds: UInt64 = 100_000_000 - var menuObservationToken: Int { _ = self.snapshots _ = self.errors @@ -122,8 +118,8 @@ final class UsageStore { var statuses: [UsageProvider: ProviderStatus] = [:] var probeLogs: [UsageProvider: String] = [:] var historicalPaceRevision: Int = 0 - @ObservationIgnored private var lastCreditsSnapshot: CreditsSnapshot? - @ObservationIgnored private var creditsFailureStreak: Int = 0 + @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? + @ObservationIgnored var creditsFailureStreak: Int = 0 @ObservationIgnored private var lastOpenAIDashboardSnapshot: OpenAIDashboardSnapshot? @ObservationIgnored private var lastOpenAIDashboardTargetEmail: String? @ObservationIgnored private var lastOpenAIDashboardCookieImportAttemptAt: Date? @@ -152,7 +148,7 @@ final class UsageStore { @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var pathDebugRefreshTask: Task? - @ObservationIgnored private var codexPlanHistoryBackfillTask: Task? + @ObservationIgnored var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @@ -632,63 +628,6 @@ final class UsageStore { } } } - - private func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async { - guard self.isEnabled(.codex) else { return } - do { - let credits = try await self.codexFetcher.loadLatestCredits( - keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive) - await MainActor.run { - self.credits = credits - self.lastCreditsError = nil - self.lastCreditsSnapshot = credits - self.creditsFailureStreak = 0 - } - let codexSnapshot = await MainActor.run { - self.snapshots[.codex] - } - if let minimumSnapshotUpdatedAt, - codexSnapshot == nil || codexSnapshot?.updatedAt ?? .distantPast < minimumSnapshotUpdatedAt - { - self.scheduleCodexPlanHistoryBackfill( - minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt) - return - } - - self.cancelCodexPlanHistoryBackfill() - guard let codexSnapshot else { return } - await self.recordPlanUtilizationHistorySample( - provider: .codex, - snapshot: codexSnapshot) - } catch { - let message = error.localizedDescription - if message.localizedCaseInsensitiveContains("data not available yet") { - await MainActor.run { - if let cached = self.lastCreditsSnapshot { - self.credits = cached - self.lastCreditsError = nil - } else { - self.credits = nil - self.lastCreditsError = "Codex credits are still loading; will retry shortly." - } - } - return - } - - await MainActor.run { - self.creditsFailureStreak += 1 - if let cached = self.lastCreditsSnapshot { - self.credits = cached - let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened) - self.lastCreditsError = - "Last Codex credits refresh failed: \(message). Cached values from \(stamp)." - } else { - self.lastCreditsError = message - self.credits = nil - } - } - } - } } extension UsageStore { @@ -899,43 +838,6 @@ extension UsageStore { } } - private func waitForCodexSnapshot(minimumUpdatedAt: Date) async -> UsageSnapshot? { - let deadline = Date().addingTimeInterval(Self.codexSnapshotWaitTimeoutSeconds) - - while Date() < deadline { - if Task.isCancelled { return nil } - if let snapshot = await MainActor.run(body: { self.snapshots[.codex] }), - snapshot.updatedAt >= minimumUpdatedAt - { - return snapshot - } - try? await Task.sleep(nanoseconds: Self.codexSnapshotPollIntervalNanoseconds) - } - - return nil - } - - private func scheduleCodexPlanHistoryBackfill( - minimumSnapshotUpdatedAt: Date) - { - self.cancelCodexPlanHistoryBackfill() - self.codexPlanHistoryBackfillTask = Task { @MainActor [weak self] in - guard let self else { return } - guard let snapshot = await self.waitForCodexSnapshot(minimumUpdatedAt: minimumSnapshotUpdatedAt) else { - return - } - await self.recordPlanUtilizationHistorySample( - provider: .codex, - snapshot: snapshot) - self.codexPlanHistoryBackfillTask = nil - } - } - - private func cancelCodexPlanHistoryBackfill() { - self.codexPlanHistoryBackfillTask?.cancel() - self.codexPlanHistoryBackfillTask = nil - } - // MARK: - OpenAI web account switching /// Detect Codex account email changes and clear stale OpenAI web state so the UI can't show the wrong user. From 267a2ab817a3c0400bc93877dc500c5202adb13d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 14:12:38 +0800 Subject: [PATCH 24/32] Update utilization tests for same-hour window retention - Expect same-hour backfills to be ignored when they would overwrite later-window values - Keep stale same-hour samples with different window markers as separate history entries - Update chart/detail assertions to match two-point aggregation and revised date formatting --- ...ePlanUtilizationResetCoalescingTests.swift | 41 ++++++++----------- .../UsageStorePlanUtilizationTests.swift | 19 +++++---- 2 files changed, 26 insertions(+), 34 deletions(-) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift index eee7dc1c7..2537c8389 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -174,7 +174,7 @@ struct UsageStorePlanUtilizationResetCoalescingTests { } @Test - func lateSameHourBackfillAfterResetMergesIntoLaterWindow() throws { + func lateSameHourBackfillAfterResetDoesNotOverrideLaterWindowValues() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -203,21 +203,16 @@ struct UsageStorePlanUtilizationResetCoalescingTests { primaryWindowMinutes: 300, primaryResetsAt: nil) - let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [earlierWindow, laterWindow], - sample: lateBackfill)) + let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [earlierWindow, laterWindow], + sample: lateBackfill) - #expect(updated.count == 2) - #expect(updated[0] == earlierWindow) - #expect(updated[1].capturedAt == lateBackfill.capturedAt) - #expect(updated[1].primaryUsedPercent == 12) - #expect(updated[1].primaryResetsAt == nextResetBoundary) + #expect(updated == nil) } @Test - func sameHourBackfillTiePrefersLaterWindow() throws { + func sameHourBackfillTiePrefersLaterWindowWithoutOverridingValues() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -246,17 +241,12 @@ struct UsageStorePlanUtilizationResetCoalescingTests { primaryWindowMinutes: 300, primaryResetsAt: nil) - let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [earlierWindow, laterWindow], - sample: ambiguousBackfill)) + let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: [earlierWindow, laterWindow], + sample: ambiguousBackfill) - #expect(updated.count == 2) - #expect(updated[0] == earlierWindow) - #expect(updated[1].capturedAt == ambiguousBackfill.capturedAt) - #expect(updated[1].primaryUsedPercent == 12) - #expect(updated[1].primaryResetsAt == nextResetBoundary) + #expect(updated == nil) } @MainActor @@ -322,9 +312,10 @@ struct UsageStorePlanUtilizationResetCoalescingTests { samples: history, provider: .codex)) - #expect(model.pointCount == 1) + #expect(model.pointCount == 2) #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - ((20.0 + 82.0 + 4.0) * 5.0 / 24.0)) < 0.000_1) + #expect(model.usedPercents.count == 2) + #expect(abs(model.usedPercents[0] - (20.0 * 0.5 / 24.0)) < 0.000_1) + #expect(abs(model.usedPercents[1] - ((20.0 * 4.5 + 82.0 * 5.0 + 4.0 * 5.0) / 24.0)) < 0.000_1) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 220ad334e..6b8f2ae02 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -73,7 +73,7 @@ struct UsageStorePlanUtilizationTests { } @Test - func staleWriteInSameHourDoesNotOverrideNewerValues() throws { + func staleWriteInSameHourWithDifferentWindowMarkersIsRetainedSeparately() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -99,14 +99,15 @@ struct UsageStorePlanUtilizationTests { provider: .codex, existingHistory: [], sample: newer)) - let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: stale) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoryForTesting( + provider: .codex, + existingHistory: initial, + sample: stale)) - #expect(updated == nil) - #expect(initial.count == 1) - #expect(initial.last == newer) + #expect(updated.count == 2) + #expect(updated.first == stale) + #expect(updated.last == newer) } @Test @@ -561,7 +562,7 @@ struct UsageStorePlanUtilizationTests { samples: samples, provider: .codex)) - #expect(detail.primary == "Mar 7: 10% used, 90% wasted") + #expect(detail.primary == "7 Mar: 10% used, 90% wasted") #expect(detail.secondary == "Estimated from provider-reported 5-hour windows.") } From 4c10a7589063a13101205b65680aa8577e6ba0d1 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Wed, 18 Mar 2026 15:31:28 +0800 Subject: [PATCH 25/32] Infer missing weekly reset anchors in exact-fit utilization chart - infer boundary dates for samples that omit `resetsAt` using observed reset cadence - prefer real upcoming reset anchors over projected cadence when both exist - add weekly exact-fit tests for missing reset, shifted anchor, and restored cadence scenarios --- .../PlanUtilizationHistoryChartMenuView.swift | 67 +++++++- ...orePlanUtilizationExactFitResetTests.swift | 152 +++++++++++++++++- 2 files changed, 213 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 3821f1d16..4ab70caac 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -910,6 +910,7 @@ extension PlanUtilizationHistoryChartMenuView { period: Period, calendar: Calendar) -> [Point] { + let boundaryAnchors = self.resetBoundaryAnchors(samples: samples, source: source) var buckets: [Date: ExactFitPointAccumulator] = [:] for sample in samples { @@ -917,8 +918,7 @@ extension PlanUtilizationHistoryChartMenuView { guard let displayDate = self.exactFitDisplayDate( for: sample, source: source, - period: period, - calendar: calendar) + boundaryAnchors: boundaryAnchors) else { continue } @@ -1001,13 +1001,70 @@ extension PlanUtilizationHistoryChartMenuView { samples.contains { self.resetsAt(for: $0, source: source) != nil } } + private nonisolated static func resetBoundaryAnchors( + samples: [PlanUtilizationHistorySample], + source: WindowSourceSelection) -> [Date] + { + let boundaries: [Date] = samples.compactMap { sample in + guard let resetsAt = self.resetsAt(for: sample, source: source) else { return nil } + return self.normalizedBoundaryDate(resetsAt) + } + return Array(Set(boundaries)).sorted() + } + private nonisolated static func exactFitDisplayDate( for sample: PlanUtilizationHistorySample, source: WindowSourceSelection, - period: Period, - calendar: Calendar) -> Date? + boundaryAnchors: [Date]) -> Date? { if let resetsAt = self.resetsAt(for: sample, source: source) { return self.normalizedBoundaryDate(resetsAt) } - return self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) + return self.estimatedResetBoundaryDate( + for: sample.capturedAt, + windowMinutes: source.windowMinutes, + anchors: boundaryAnchors) + } + + private nonisolated static func estimatedResetBoundaryDate( + for capturedAt: Date, + windowMinutes: Int, + anchors: [Date]) -> Date? + { + guard windowMinutes > 0 else { return nil } + guard !anchors.isEmpty else { return nil } + + let windowInterval = Double(windowMinutes) * 60 + if let nextAnchor = anchors.first(where: { $0 >= capturedAt }), + nextAnchor.timeIntervalSince(capturedAt) <= windowInterval + { + return nextAnchor + } + + if let previousAnchor = anchors.last(where: { $0 < capturedAt }) { + return self.projectedResetBoundaryDate( + for: capturedAt, + anchorBoundary: previousAnchor, + windowMinutes: windowMinutes) + } + + if let nextAnchor = anchors.first { + return self.projectedResetBoundaryDate( + for: capturedAt, + anchorBoundary: nextAnchor, + windowMinutes: windowMinutes) + } + + return nil + } + + private nonisolated static func projectedResetBoundaryDate( + for capturedAt: Date, + anchorBoundary: Date, + windowMinutes: Int) -> Date? + { + guard windowMinutes > 0 else { return nil } + let windowInterval = Double(windowMinutes) * 60 + let steps = ceil(capturedAt.timeIntervalSince(anchorBoundary) / windowInterval) + let boundary = anchorBoundary.addingTimeInterval(steps * windowInterval) + return self.normalizedBoundaryDate(boundary) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift index 5b5589c4d..77a1319fc 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -215,6 +215,156 @@ struct UsageStorePlanUtilizationExactFitResetTests { ]) } + @MainActor + @Test + func weeklyExactFitInfersMissingResetFromObservedCadence() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 5, + minute: 0))) + let secondBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 22, + hour: 5, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 48, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-(3 * 24 * 60 * 60)), + secondary: 52, + secondaryResetsAt: nil), + makeExactFitResetPlanSample( + at: secondBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: secondBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 2) + #expect(model.usedPercents == [48, 62]) + #expect(model.pointDates == ["2026-03-15 05:00", "2026-03-22 05:00"]) + } + + @MainActor + @Test + func weeklyExactFitKeepsShiftedAnchorWhenLaterSampleMissesReset() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let shiftedBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 15, + hour: 7, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: shiftedBoundary.addingTimeInterval(-10 * 60), + secondary: 48, + secondaryResetsAt: shiftedBoundary), + makeExactFitResetPlanSample( + at: shiftedBoundary.addingTimeInterval(-5 * 60), + secondary: 12, + secondaryResetsAt: nil), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 2) + #expect(model.usedPercents == [62, 12]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 07:00"]) + } + + @MainActor + @Test + func weeklyExactFitPrefersRealNextResetOverTemporaryShiftCadence() throws { + let calendar = Calendar(identifier: .gregorian) + let firstBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 8, + hour: 5, + minute: 0))) + let shiftedBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 16, + hour: 2, + minute: 0))) + let restoredBoundary = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 22, + hour: 5, + minute: 0))) + let missingResetSampleDate = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone.current, + year: 2026, + month: 3, + day: 19, + hour: 12, + minute: 0))) + let samples = [ + makeExactFitResetPlanSample( + at: firstBoundary.addingTimeInterval(-30 * 60), + secondary: 62, + secondaryResetsAt: firstBoundary), + makeExactFitResetPlanSample( + at: shiftedBoundary.addingTimeInterval(-10 * 60), + secondary: 48, + secondaryResetsAt: shiftedBoundary), + makeExactFitResetPlanSample( + at: missingResetSampleDate, + secondary: 54, + secondaryResetsAt: nil), + makeExactFitResetPlanSample( + at: restoredBoundary.addingTimeInterval(-10 * 60), + secondary: 20, + secondaryResetsAt: restoredBoundary), + ] + + let model = try #require( + PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + periodRawValue: "weekly", + samples: samples, + provider: .codex)) + + #expect(model.pointCount == 4) + #expect(model.usedPercents == [62, 0, 48, 20]) + #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-16 02:00", "2026-03-22 05:00"]) + } + @MainActor @Test func weeklyExactFitShowsTrailingZeroBarForCurrentExpectedReset() throws { @@ -267,7 +417,7 @@ struct UsageStorePlanUtilizationExactFitResetTests { private func makeExactFitResetPlanSample( at capturedAt: Date, secondary: Double, - secondaryResetsAt: Date) -> PlanUtilizationHistorySample + secondaryResetsAt: Date?) -> PlanUtilizationHistorySample { PlanUtilizationHistorySample( capturedAt: capturedAt, From 0e3a7c2b623e37b28bbca808d6d59c3673d09ca7 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Thu, 19 Mar 2026 18:16:34 +0800 Subject: [PATCH 26/32] Use Codex snapshot timestamps for history samples - Record Codex utilization history using each snapshot's `updatedAt` - Keep refresh and backfill entries aligned to actual sample time --- .../CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift index 9ecc6213e..35b184897 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -32,7 +32,8 @@ extension UsageStore { guard let codexSnapshot else { return } await self.recordPlanUtilizationHistorySample( provider: .codex, - snapshot: codexSnapshot) + snapshot: codexSnapshot, + now: codexSnapshot.updatedAt) } catch { let message = error.localizedDescription if message.localizedCaseInsensitiveContains("data not available yet") { @@ -90,7 +91,8 @@ extension UsageStore { } await self.recordPlanUtilizationHistorySample( provider: .codex, - snapshot: snapshot) + snapshot: snapshot, + now: snapshot.updatedAt) self.codexPlanHistoryBackfillTask = nil } } From c6c79989dc3e7643c36477de4457137acaef8bb5 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 12:00:19 +0800 Subject: [PATCH 27/32] Refactor usage history chart around provider series histories - Replace period-based chart selection with dynamic visible series filtering - Rework point generation to align on reset boundaries and fill missing windows - Simplify chart detail/axis rendering and update plan utilization tests --- .../PlanUtilizationHistoryChartMenuView.swift | 1271 +++++++---------- .../PlanUtilizationHistoryStore.swift | 100 +- .../CodexBar/StatusItemController+Menu.swift | 34 +- ...tatusItemController+UsageHistoryMenu.swift | 15 +- .../CodexBar/UsageStore+PlanUtilization.swift | 468 +++--- ...rePlanUtilizationClaudeIdentityTests.swift | 501 +------ ...torePlanUtilizationDerivedChartTests.swift | 252 +--- ...orePlanUtilizationExactFitResetTests.swift | 524 +++---- ...ePlanUtilizationResetCoalescingTests.swift | 370 ++--- .../UsageStorePlanUtilizationTests.swift | 1015 ++++++------- 10 files changed, 1684 insertions(+), 2866 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index 4ab70caac..c3da7e9fa 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -6,98 +6,43 @@ import SwiftUI struct PlanUtilizationHistoryChartMenuView: View { private enum Layout { static let chartHeight: CGFloat = 130 - static let detailHeight: CGFloat = 32 + static let detailHeight: CGFloat = 16 static let emptyStateHeight: CGFloat = chartHeight + detailHeight + static let maxPoints = 30 + static let maxAxisLabels = 4 + static let barWidth: CGFloat = 6 } - private enum Period: String, CaseIterable, Identifiable { - case daily - case weekly - case monthly + private struct SeriesSelection: Hashable { + let name: PlanUtilizationSeriesName + let windowMinutes: Int var id: String { - self.rawValue - } - - var title: String { - switch self { - case .daily: - "Daily" - case .weekly: - "Weekly" - case .monthly: - "Monthly" - } - } - - var emptyStateText: String { - switch self { - case .daily: - "No daily utilization data yet." - case .weekly: - "No weekly utilization data yet." - case .monthly: - "No monthly utilization data yet." - } - } - - var maxPoints: Int { - switch self { - case .daily: - 30 - case .weekly: - 24 - case .monthly: - 24 - } + "\(self.name.rawValue):\(self.windowMinutes)" } - - var chartWindowMinutes: Int { - switch self { - case .daily: - 1440 - case .weekly: - 10080 - case .monthly: - 44640 - } - } - } - - private enum WindowSlot: Int, CaseIterable { - case primary - case secondary } - private struct WindowSourceSelection: Hashable { - let slot: WindowSlot - let windowMinutes: Int - } + private struct VisibleSeries: Identifiable, Equatable { + let selection: SeriesSelection + let title: String + let history: PlanUtilizationSeriesHistory - private struct DerivedGroupAccumulator { - let chartDate: Date - let boundaryDate: Date - let usesResetBoundary: Bool - var maxUsedPercent: Double - } - - private struct DerivedInterval { - let chartDate: Date - let startDate: Date - let endDate: Date - let maxUsedPercent: Double + var id: String { + self.selection.id + } } - private struct ExactFitPointAccumulator { - let keyDate: Date - let displayDate: Date + private struct EntryPointAccumulator { + let effectiveBoundaryDate: Date + let displayBoundaryDate: Date let observedAt: Date let usedPercent: Double + let hasObservedResetBoundary: Bool } - private enum AggregationMode { - case exactFit - case derived + private struct ResetBoundaryLattice { + let referenceBoundaryDate: Date + let windowInterval: TimeInterval } private struct Point: Identifiable { @@ -105,51 +50,75 @@ struct PlanUtilizationHistoryChartMenuView: View { let index: Int let date: Date let usedPercent: Double + let isObserved: Bool + } + + private struct Model { + let points: [Point] + let axisIndexes: [Double] + let xDomain: ClosedRange? + let pointsByID: [String: Point] + let pointsByIndex: [Int: Point] + let barColor: Color + let trackColor: Color } private let provider: UsageProvider - private let samples: [PlanUtilizationHistorySample] + private let histories: [PlanUtilizationSeriesHistory] + private let snapshot: UsageSnapshot? private let width: CGFloat private let isRefreshing: Bool - @State private var selectedPeriod: Period = .daily + @State private var selectedSeriesID: String? @State private var selectedPointID: String? - init(provider: UsageProvider, samples: [PlanUtilizationHistorySample], width: CGFloat, isRefreshing: Bool = false) { + init( + provider: UsageProvider, + histories: [PlanUtilizationSeriesHistory], + snapshot: UsageSnapshot? = nil, + width: CGFloat, + isRefreshing: Bool = false) + { self.provider = provider - self.samples = samples + self.histories = histories + self.snapshot = snapshot self.width = width self.isRefreshing = isRefreshing } var body: some View { - let availablePeriods = Self.availablePeriods(samples: self.samples) - let visiblePeriods = availablePeriods.isEmpty ? Period.allCases : availablePeriods - let effectiveSelectedPeriod = visiblePeriods.contains(self.selectedPeriod) - ? self.selectedPeriod - : (visiblePeriods.first ?? .daily) + let visibleSeries = Self.visibleSeries( + histories: self.histories, + provider: self.provider, + snapshot: self.snapshot) + let effectiveSelectedSeries = visibleSeries.first(where: { $0.id == self.selectedSeriesID }) ?? visibleSeries + .first let model = Self.makeModel( - period: effectiveSelectedPeriod, - samples: self.samples, + history: effectiveSelectedSeries?.history, provider: self.provider, referenceDate: Date()) VStack(alignment: .leading, spacing: 10) { - if visiblePeriods.count > 1 { - Picker("Period", selection: self.$selectedPeriod) { - ForEach(visiblePeriods) { period in - Text(period.title).tag(period) + if visibleSeries.count > 1 { + Picker(selection: Binding( + get: { effectiveSelectedSeries?.id ?? "" }, + set: { newValue in + self.selectedSeriesID = newValue + self.selectedPointID = nil + })) { + ForEach(visibleSeries) { series in + Text(series.title).tag(series.id) + } + } label: { + EmptyView() } - } - .pickerStyle(.segmented) - .onChange(of: self.selectedPeriod) { _, _ in - self.selectedPointID = nil - } + .labelsHidden() + .pickerStyle(.segmented) } if model.points.isEmpty { ZStack { - Text(Self.emptyStateText(period: effectiveSelectedPeriod, isRefreshing: self.isRefreshing)) + Text(Self.emptyStateText(title: effectiveSelectedSeries?.title, isRefreshing: self.isRefreshing)) .font(.footnote) .foregroundStyle(.secondary) } @@ -167,9 +136,12 @@ struct PlanUtilizationHistoryChartMenuView: View { if let raw = value.as(Double.self) { let index = Int(raw.rounded()) if let point = model.pointsByIndex[index] { - Text(point.date.formatted(self.axisFormat(for: effectiveSelectedPeriod))) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + let isTrailingFullChartLabel = index == model.points.last?.index + && model.points.count == Layout.maxPoints + Self.axisLabel( + for: point, + windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0, + isTrailingFullChartLabel: isTrailingFullChartLabel) } } } @@ -187,96 +159,93 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - let detail = self.detailLines(model: model, period: effectiveSelectedPeriod) - VStack(alignment: .leading, spacing: 0) { - Text(detail.primary) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - Text(detail.secondary) - .font(.caption2) - .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) - .lineLimit(1) - .truncationMode(.tail) - .frame(height: 16, alignment: .leading) - } - .frame(height: Layout.detailHeight, alignment: .top) + Text(self.detailLine(model: model, windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .frame(height: Layout.detailHeight, alignment: .leading) } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .topLeading) - .task(id: visiblePeriods.map(\.rawValue).joined(separator: ",")) { - guard let firstVisiblePeriod = visiblePeriods.first else { return } - guard !visiblePeriods.contains(self.selectedPeriod) else { return } - self.selectedPeriod = firstVisiblePeriod + .task(id: visibleSeries.map(\.id).joined(separator: ",")) { + guard let firstVisibleSeries = visibleSeries.first else { return } + guard !visibleSeries.contains(where: { $0.id == self.selectedSeriesID }) else { return } + self.selectedSeriesID = firstVisibleSeries.id self.selectedPointID = nil } } - private struct Model { - let points: [Point] - let axisIndexes: [Double] - let xDomain: ClosedRange? - let pointsByID: [String: Point] - let pointsByIndex: [Int: Point] - let barColor: Color - let provenanceText: String + private nonisolated static func visibleSeries( + histories: [PlanUtilizationSeriesHistory], + provider: UsageProvider, + snapshot: UsageSnapshot?) -> [VisibleSeries] + { + let metadata = ProviderDescriptorRegistry.metadata[provider] + let allowedNames = self.visibleSeriesNames(provider: provider, snapshot: snapshot) + return histories + .filter { history in + guard !history.entries.isEmpty else { return false } + guard let allowedNames else { return true } + return allowedNames.contains(history.name) + } + .sorted { lhs, rhs in + let lhsOrder = self.seriesSortOrder(lhs.name) + let rhsOrder = self.seriesSortOrder(rhs.name) + if lhsOrder != rhsOrder { + return lhsOrder < rhsOrder + } + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue + } + .map { history in + VisibleSeries( + selection: SeriesSelection(name: history.name, windowMinutes: history.windowMinutes), + title: self.seriesTitle(name: history.name, metadata: metadata), + history: history) + } } - private nonisolated static func makeModel( - period: Period, - samples: [PlanUtilizationHistorySample], + private nonisolated static func visibleSeriesNames( provider: UsageProvider, - referenceDate: Date) -> Model + snapshot: UsageSnapshot?) -> Set? { - let calendar = Calendar.current - guard let selectedSource = Self.selectedSource(for: period, samples: samples) else { - return Self.emptyModel(provider: provider, period: period) + guard let snapshot else { return nil } + + var names: Set = [] + if snapshot.primary != nil { + names.insert(.session) } - let aggregationMode = Self.aggregationMode(period: period, source: selectedSource) - let usesResetAlignedExactFit = aggregationMode == .exactFit - && self.hasAnyResetBoundary(samples: samples, source: selectedSource) - let provenanceText = self.provenanceText( - period: period, - source: selectedSource, - aggregationMode: aggregationMode) - - var points: [Point] - if usesResetAlignedExactFit { - points = self.exactFitPoints(samples: samples, source: selectedSource, period: period, calendar: calendar) - points = self.filledExactFitPoints( - points: points, - period: period, - windowMinutes: selectedSource.windowMinutes, - referenceDate: referenceDate, - calendar: calendar) - } else { - let buckets = Self.chartBuckets( - period: period, - samples: samples, - source: selectedSource, - mode: aggregationMode, - calendar: calendar) - - points = buckets - .map { date, used in - Point( - id: Self.pointID(date: date, period: period, usesResetAlignedExactFit: false), - index: 0, - date: date, - usedPercent: used) - } - .sorted { $0.date < $1.date } + if snapshot.secondary != nil { + names.insert(.weekly) + } + + if provider == .claude, + snapshot.tertiary != nil, + ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true + { + names.insert(.opus) + } + + return names + } - let currentBucketDate = self.bucketDate(for: referenceDate, period: period, calendar: calendar) - points = self.filledPoints(points: points, period: period, calendar: calendar, through: currentBucketDate) + private nonisolated static func makeModel( + history: PlanUtilizationSeriesHistory?, + provider: UsageProvider, + referenceDate: Date) -> Model + { + guard let history else { + return self.emptyModel(provider: provider) } - if points.count > period.maxPoints { - points = Array(points.suffix(period.maxPoints)) + var points = self.seriesPoints(history: history, referenceDate: referenceDate) + if points.count > Layout.maxPoints { + points = Array(points.suffix(Layout.maxPoints)) } points = points.enumerated().map { offset, point in @@ -284,510 +253,448 @@ struct PlanUtilizationHistoryChartMenuView: View { id: point.id, index: offset, date: point.date, - usedPercent: point.usedPercent) + usedPercent: point.usedPercent, + isObserved: point.isObserved) } - let axisIndexes = Self.axisIndexes(points: points, period: period) - let xDomain = Self.xDomain(points: points, period: period) - let pointsByID = Dictionary(uniqueKeysWithValues: points.map { ($0.id, $0) }) let pointsByIndex = Dictionary(uniqueKeysWithValues: points.map { ($0.index, $0) }) let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color let barColor = Color(red: color.red, green: color.green, blue: color.blue) + let trackColor = MenuHighlightStyle.progressTrack(false) return Model( points: points, - axisIndexes: axisIndexes, - xDomain: xDomain, + axisIndexes: self.axisIndexes(points: points, windowMinutes: history.windowMinutes), + xDomain: self.xDomain(points: points), pointsByID: pointsByID, pointsByIndex: pointsByIndex, barColor: barColor, - provenanceText: provenanceText) - } - - private nonisolated static func filledPoints( - points: [Point], - period: Period, - calendar: Calendar, - through endDate: Date?) -> [Point] - { - guard let firstDate = points.first?.date, let lastDate = points.last?.date else { return points } - let effectiveEndDate = max(lastDate, endDate ?? lastDate) - let pointsByDate = Dictionary(uniqueKeysWithValues: points.map { ($0.date, $0) }) - - var filled: [Point] = [] - var cursor = firstDate - while cursor <= effectiveEndDate { - if let existing = pointsByDate[cursor] { - filled.append(existing) - } else { - filled.append(Point( - id: self.pointID(date: cursor, period: period, usesResetAlignedExactFit: false), - index: 0, - date: cursor, - usedPercent: 0)) - } - - guard let nextDate = self.nextBucketDate(after: cursor, period: period, calendar: calendar) else { - break - } - cursor = nextDate - } - - return filled + trackColor: trackColor) } - private nonisolated static func emptyModel(provider: UsageProvider, period: Period) -> Model { + private nonisolated static func emptyModel(provider: UsageProvider) -> Model { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color let barColor = Color(red: color.red, green: color.green, blue: color.blue) + let trackColor = MenuHighlightStyle.progressTrack(false) return Model( points: [], axisIndexes: [], - xDomain: self.xDomain(points: [], period: period), + xDomain: nil, pointsByID: [:], pointsByIndex: [:], barColor: barColor, - provenanceText: "") - } - - private nonisolated static func xDomain(points: [Point], period: Period) -> ClosedRange? { - guard !points.isEmpty else { return nil } - return -0.5...(Double(period.maxPoints) - 0.5) - } - - private nonisolated static func axisIndexes(points: [Point], period: Period) -> [Double] { - guard let first = points.first?.index, let last = points.last?.index else { return [] } - if first == last { return [Double(first)] } - switch period { - case .daily: - return [Double(first), Double(last)] - case .weekly, .monthly: - return [Double(last)] - } - } - - #if DEBUG - struct ModelSnapshot: Equatable { - let pointCount: Int - let axisIndexes: [Double] - let xDomain: ClosedRange? - let selectedSource: String? - let usedPercents: [Double] - let pointDates: [String] - let provenanceText: String - } - - nonisolated static func _modelSnapshotForTesting( - periodRawValue: String, - samples: [PlanUtilizationHistorySample], - provider: UsageProvider, - referenceDate: Date? = nil) -> ModelSnapshot? - { - guard let period = Period(rawValue: periodRawValue) else { return nil } - let effectiveReferenceDate = referenceDate ?? samples.map(\.capturedAt).max() ?? Date() - let model = self.makeModel( - period: period, - samples: samples, - provider: provider, - referenceDate: effectiveReferenceDate) - return ModelSnapshot( - pointCount: model.points.count, - axisIndexes: model.axisIndexes, - xDomain: model.xDomain, - selectedSource: self.selectedSource(for: period, samples: samples).map { - "\($0.slot == .primary ? "primary" : "secondary"):\($0.windowMinutes)" - }, - usedPercents: model.points.map(\.usedPercent), - pointDates: model.points.map { point in - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = "yyyy-MM-dd HH:mm" - return formatter.string(from: point.date) - }, - provenanceText: model.provenanceText) + trackColor: trackColor) } - nonisolated static func _detailLinesForTesting( - periodRawValue: String, - samples: [PlanUtilizationHistorySample], - provider: UsageProvider, - referenceDate: Date? = nil) -> (primary: String, secondary: String)? + private nonisolated static func seriesPoints( + history: PlanUtilizationSeriesHistory, + referenceDate: Date) -> [Point] { - guard let period = Period(rawValue: periodRawValue) else { return nil } - let effectiveReferenceDate = referenceDate ?? samples.map(\.capturedAt).max() ?? Date() - let model = self.makeModel( - period: period, - samples: samples, - provider: provider, - referenceDate: effectiveReferenceDate) - return self.detailLines(point: model.points.last, period: period, provenanceText: model.provenanceText) - } + guard history.windowMinutes > 0 else { return [] } + let windowInterval = Double(history.windowMinutes) * 60 + let resetBoundaryLattice = self.resetBoundaryLattice( + entries: history.entries, + windowMinutes: history.windowMinutes) + var strongestObservedPointByPeriod: [Date: EntryPointAccumulator] = [:] + + for entry in history.entries { + let candidate = self.observedPointCandidate( + for: entry, + windowMinutes: history.windowMinutes, + resetBoundaryLattice: resetBoundaryLattice) + + if let existing = strongestObservedPointByPeriod[candidate.effectiveBoundaryDate], + !self.shouldPreferObservedPoint(candidate, over: existing) + { + continue + } + strongestObservedPointByPeriod[candidate.effectiveBoundaryDate] = candidate + } - nonisolated static func _emptyStateTextForTesting(periodRawValue: String, isRefreshing: Bool) -> String? { - guard let period = Period(rawValue: periodRawValue) else { return nil } - return self.emptyStateText(period: period, isRefreshing: isRefreshing) - } + guard !strongestObservedPointByPeriod.isEmpty else { return [] } - nonisolated static func _visiblePeriodsForTesting(samples: [PlanUtilizationHistorySample]) -> [String] { - self.availablePeriods(samples: samples).map(\.rawValue) - } - #endif + let sortedPeriodBoundaryDates = strongestObservedPointByPeriod.keys.sorted() + var points: [Point] = [] + var previousPeriodBoundaryDate: Date? - private nonisolated static func emptyStateText(period: Period, isRefreshing: Bool) -> String { - if isRefreshing { - return "Refreshing..." - } - return period.emptyStateText - } + for periodBoundaryDate in sortedPeriodBoundaryDates { + if let previousPeriodBoundaryDate { + var cursor = previousPeriodBoundaryDate.addingTimeInterval(windowInterval) + while cursor < periodBoundaryDate { + points.append(Point( + id: self.pointID(date: cursor), + index: 0, + date: cursor, + usedPercent: 0, + isObserved: false)) + cursor = cursor.addingTimeInterval(windowInterval) + } + } - private nonisolated static func selectedSource( - for period: Period, - samples: [PlanUtilizationHistorySample]) -> WindowSourceSelection? - { - var counts: [WindowSourceSelection: Int] = [:] - - for sample in samples { - for slot in WindowSlot.allCases { - guard let windowMinutes = self.windowMinutes(for: sample, slot: slot) else { continue } - guard windowMinutes <= period.chartWindowMinutes else { continue } - let selection = WindowSourceSelection(slot: slot, windowMinutes: windowMinutes) - counts[selection, default: 0] += 1 + if let bucket = strongestObservedPointByPeriod[periodBoundaryDate] { + points.append(Point( + id: self.pointID(date: bucket.effectiveBoundaryDate), + index: 0, + date: bucket.displayBoundaryDate, + usedPercent: bucket.usedPercent, + isObserved: true)) } + previousPeriodBoundaryDate = periodBoundaryDate } - return counts.max { lhs, rhs in - if lhs.key.windowMinutes != rhs.key.windowMinutes { - return lhs.key.windowMinutes < rhs.key.windowMinutes - } - if lhs.value != rhs.value { - return lhs.value < rhs.value + if let lastObservedPeriodBoundaryDate = sortedPeriodBoundaryDates.last { + let currentPeriodBoundaryDate = self.currentPeriodBoundaryDate( + for: referenceDate, + windowMinutes: history.windowMinutes, + resetBoundaryLattice: resetBoundaryLattice) + + if currentPeriodBoundaryDate > lastObservedPeriodBoundaryDate { + var cursor = lastObservedPeriodBoundaryDate.addingTimeInterval(windowInterval) + while cursor <= currentPeriodBoundaryDate { + points.append(Point( + id: self.pointID(date: cursor), + index: 0, + date: cursor, + usedPercent: 0, + isObserved: false)) + cursor = cursor.addingTimeInterval(windowInterval) + } } - return lhs.key.slot.rawValue > rhs.key.slot.rawValue - }?.key - } + } - private nonisolated static func availablePeriods(samples: [PlanUtilizationHistorySample]) -> [Period] { - Period.allCases.filter { self.selectedSource(for: $0, samples: samples) != nil } + return points } - private nonisolated static func usedPercent( - for sample: PlanUtilizationHistorySample, - source: WindowSourceSelection) -> Double? + private nonisolated static func observedPointCandidate( + for entry: PlanUtilizationHistoryEntry, + windowMinutes: Int, + resetBoundaryLattice: ResetBoundaryLattice?) -> EntryPointAccumulator { - guard self.windowMinutes(for: sample, slot: source.slot) == source.windowMinutes else { return nil } - switch source.slot { - case .primary: - return sample.primaryUsedPercent - case .secondary: - return sample.secondaryUsedPercent - } - } - - private nonisolated static func windowMinutes( - for sample: PlanUtilizationHistorySample, - slot: WindowSlot) -> Int? + let rawResetBoundaryDate = entry.resetsAt.map(self.normalizedBoundaryDate) + let effectiveBoundaryDate = self.effectivePeriodBoundaryDate( + for: entry, + windowMinutes: windowMinutes, + rawResetBoundaryDate: rawResetBoundaryDate, + resetBoundaryLattice: resetBoundaryLattice) + return EntryPointAccumulator( + effectiveBoundaryDate: effectiveBoundaryDate, + displayBoundaryDate: rawResetBoundaryDate ?? effectiveBoundaryDate, + observedAt: entry.capturedAt, + usedPercent: max(0, min(100, entry.usedPercent)), + hasObservedResetBoundary: rawResetBoundaryDate != nil) + } + + private nonisolated static func resetBoundaryLattice( + entries: [PlanUtilizationHistoryEntry], + windowMinutes: Int) -> ResetBoundaryLattice? { - switch slot { - case .primary: - sample.primaryWindowMinutes - case .secondary: - sample.secondaryWindowMinutes + guard let latestObservedResetBoundaryDate = entries + .compactMap(\.resetsAt) + .map(self.normalizedBoundaryDate) + .max() + else { + return nil } + return ResetBoundaryLattice( + referenceBoundaryDate: latestObservedResetBoundaryDate, + windowInterval: Double(windowMinutes) * 60) } - private nonisolated static func aggregationMode( - period: Period, - source: WindowSourceSelection) -> AggregationMode - { - source.windowMinutes == period.chartWindowMinutes ? .exactFit : .derived + private nonisolated static func normalizedBoundaryDate(_ date: Date) -> Date { + Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) } - private nonisolated static func provenanceText( - period: Period, - source: WindowSourceSelection, - aggregationMode: AggregationMode) -> String + private nonisolated static func effectivePeriodBoundaryDate( + for entry: PlanUtilizationHistoryEntry, + windowMinutes: Int, + rawResetBoundaryDate: Date?, + resetBoundaryLattice: ResetBoundaryLattice?) -> Date { - switch aggregationMode { - case .exactFit: - "Provider-reported \(period.rawValue) usage." - case .derived: - "Estimated from provider-reported \(self.windowLabel(minutes: source.windowMinutes)) windows." + if let rawResetBoundaryDate { + if let resetBoundaryLattice { + return self.closestPeriodBoundaryDate( + to: rawResetBoundaryDate, + resetBoundaryLattice: resetBoundaryLattice) + } + return rawResetBoundaryDate } + if let resetBoundaryLattice { + return self.periodBoundaryDate( + containing: entry.capturedAt, + resetBoundaryLattice: resetBoundaryLattice) + } + return self.syntheticBoundaryDate(for: entry.capturedAt, windowMinutes: windowMinutes) } - private nonisolated static func windowLabel(minutes: Int) -> String { - guard minutes > 0 else { return "provider" } - if minutes.isMultiple(of: 1440) { - let days = minutes / 1440 - return "\(days)-day" + private nonisolated static func shouldPreferObservedPoint( + _ candidate: EntryPointAccumulator, + over existing: EntryPointAccumulator) -> Bool + { + if candidate.usedPercent != existing.usedPercent { + return candidate.usedPercent > existing.usedPercent } - if minutes.isMultiple(of: 60) { - let hours = minutes / 60 - return "\(hours)-hour" + if candidate.hasObservedResetBoundary != existing.hasObservedResetBoundary { + return candidate.hasObservedResetBoundary } - return "\(minutes)-minute" + if candidate.displayBoundaryDate != existing.displayBoundaryDate { + return candidate.displayBoundaryDate > existing.displayBoundaryDate + } + return candidate.observedAt >= existing.observedAt } - private nonisolated static func chartBuckets( - period: Period, - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection, - mode: AggregationMode, - calendar: Calendar) -> [Date: Double] + private nonisolated static func currentPeriodBoundaryDate( + for referenceDate: Date, + windowMinutes: Int, + resetBoundaryLattice: ResetBoundaryLattice?) -> Date { - switch mode { - case .exactFit: - self.exactFitChartBuckets(period: period, samples: samples, source: source, calendar: calendar) - case .derived: - self.derivedChartBuckets(period: period, samples: samples, source: source, calendar: calendar) + if let resetBoundaryLattice { + return self.periodBoundaryDate( + containing: referenceDate, + resetBoundaryLattice: resetBoundaryLattice) } + return self.syntheticBoundaryDate(for: referenceDate, windowMinutes: windowMinutes) } - private nonisolated static func exactFitChartBuckets( - period: Period, - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection, - calendar: Calendar) -> [Date: Double] + private nonisolated static func closestPeriodBoundaryDate( + to rawBoundaryDate: Date, + resetBoundaryLattice: ResetBoundaryLattice) -> Date { - var buckets: [Date: Double] = [:] - - for sample in samples { - guard let used = self.usedPercent(for: sample, source: source) else { continue } - guard let chartDate = self.bucketDate(for: sample.capturedAt, period: period, calendar: calendar) else { - continue - } - let clamped = max(0, min(100, used)) - buckets[chartDate] = max(buckets[chartDate] ?? 0, clamped) - } - - return buckets + let offset = rawBoundaryDate.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate) + let periodOffset = (offset / resetBoundaryLattice.windowInterval).rounded() + return resetBoundaryLattice.referenceBoundaryDate + .addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval) } - private nonisolated static func derivedChartBuckets( - period: Period, - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection, - calendar: Calendar) -> [Date: Double] + private nonisolated static func periodBoundaryDate( + containing capturedAt: Date, + resetBoundaryLattice: ResetBoundaryLattice) -> Date { - let groups = self.derivedGroups(period: period, samples: samples, source: source, calendar: calendar) - guard !groups.isEmpty else { return [:] } - - let sortedGroups = groups.values.sorted { lhs, rhs in - if lhs.boundaryDate != rhs.boundaryDate { - return lhs.boundaryDate < rhs.boundaryDate - } - return lhs.chartDate < rhs.chartDate - } + let offset = capturedAt.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate) + let periodOffset = ceil(offset / resetBoundaryLattice.windowInterval) + return resetBoundaryLattice.referenceBoundaryDate + .addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval) + } - let intervals = self.derivedIntervals(from: sortedGroups, nominalWindowMinutes: Double(source.windowMinutes)) - guard !intervals.isEmpty else { return [:] } + private nonisolated static func syntheticBoundaryDate(for date: Date, windowMinutes: Int) -> Date { + let bucketSeconds = Double(windowMinutes) * 60 + let bucketIndex = floor(date.timeIntervalSince1970 / bucketSeconds) + return Date(timeIntervalSince1970: (bucketIndex + 1) * bucketSeconds) + } - return self.derivedPeriodChartBuckets(period: period, intervals: intervals, calendar: calendar) + private nonisolated static func pointID(date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) } - private nonisolated static func derivedIntervals( - from groups: [DerivedGroupAccumulator], - nominalWindowMinutes: Double) -> [DerivedInterval] - { - var previousResetBoundary: Date? - var intervals: [DerivedInterval] = [] - - for group in groups { - var weightMinutes = nominalWindowMinutes - if group.usesResetBoundary, let previousResetBoundary { - let factualWindowMinutes = group.boundaryDate.timeIntervalSince(previousResetBoundary) / 60 - if factualWindowMinutes > 0, factualWindowMinutes < nominalWindowMinutes { - weightMinutes = factualWindowMinutes - } - } + private nonisolated static func xDomain(points: [Point]) -> ClosedRange? { + guard !points.isEmpty else { return nil } + return -0.5...(Double(Layout.maxPoints) - 0.5) + } - let endDate = group.boundaryDate - let startDate = endDate.addingTimeInterval(-(weightMinutes * 60)) - intervals.append(DerivedInterval( - chartDate: group.chartDate, - startDate: startDate, - endDate: endDate, - maxUsedPercent: group.maxUsedPercent)) + private nonisolated static func axisIndexes(points: [Point], windowMinutes: Int) -> [Double] { + let candidateIndexes = self.axisCandidateIndexes(points: points, windowMinutes: windowMinutes) + return self.proportionalAxisIndexes(points: points, candidateIndexes: candidateIndexes) + } - if group.usesResetBoundary { - previousResetBoundary = group.boundaryDate - } + private nonisolated static func axisCandidateIndexes(points: [Point], windowMinutes: Int) -> [Int] { + if windowMinutes <= 300 { + return self.sessionAxisCandidateIndexes(points: points) } - - return intervals + return points.map(\.index) } - private nonisolated static func derivedPeriodChartBuckets( - period: Period, - intervals: [DerivedInterval], - calendar: Calendar) -> [Date: Double] - { - var weightedSums: [Date: Double] = [:] - - for interval in intervals { - var cursor = interval.startDate - while cursor < interval.endDate { - guard let chartInterval = self.chartDateInterval( - for: cursor, - period: period, - calendar: calendar) - else { - break - } - - let overlapStart = max(interval.startDate, chartInterval.start) - let overlapEnd = min(interval.endDate, chartInterval.end) - let overlapMinutes = overlapEnd.timeIntervalSince(overlapStart) / 60 - - if overlapMinutes > 0 { - weightedSums[chartInterval.start, default: 0] += interval.maxUsedPercent * overlapMinutes - } + private nonisolated static func sessionAxisCandidateIndexes(points: [Point]) -> [Int] { + guard let firstPoint = points.first else { return [] } + let calendar = Calendar.current + var previousPoint = firstPoint + var rawIndexes: [Int] = [firstPoint.index] - cursor = chartInterval.end + for point in points.dropFirst() { + if !calendar.isDate(point.date, inSameDayAs: previousPoint.date) { + rawIndexes.append(point.index) } + previousPoint = point } - return weightedSums.reduce(into: [Date: Double]()) { output, entry in - let (chartStart, weightedSum) = entry - guard let chartInterval = self.chartDateInterval(for: chartStart, period: period, calendar: calendar) else { - return - } - let chartMinutes = chartInterval.duration / 60 - guard chartMinutes > 0 else { return } - output[chartStart] = weightedSum / chartMinutes - } + return rawIndexes } - private nonisolated static func derivedGroups( - period: Period, - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection, - calendar: Calendar) -> [Date: DerivedGroupAccumulator] - { - var groups: [Date: DerivedGroupAccumulator] = [:] + private nonisolated static func proportionalAxisIndexes(points: [Point], candidateIndexes: [Int]) -> [Double] { + guard !points.isEmpty, !candidateIndexes.isEmpty else { return [] } - for sample in samples { - guard let used = self.usedPercent(for: sample, source: source) else { continue } - guard let groupBoundary = self.derivedBoundaryDate(for: sample, source: source) else { continue } - guard let chartDate = self.bucketDate(for: groupBoundary, period: period, calendar: calendar) else { - continue - } + let occupiedFraction = Double(points.count) / Double(Layout.maxPoints) + let proportionalBudget = Int(ceil(Double(Layout.maxAxisLabels) * occupiedFraction)) + let labelBudget = max(1, min(Layout.maxAxisLabels, proportionalBudget, candidateIndexes.count)) - let clamped = max(0, min(100, used)) - let usesResetBoundary = self.resetsAt(for: sample, source: source) != nil + if labelBudget == 1 { + return [Double(candidateIndexes[0])] + } - if var existing = groups[groupBoundary] { - existing.maxUsedPercent = max(existing.maxUsedPercent, clamped) - groups[groupBoundary] = existing - } else { - groups[groupBoundary] = DerivedGroupAccumulator( - chartDate: chartDate, - boundaryDate: groupBoundary, - usesResetBoundary: usesResetBoundary, - maxUsedPercent: clamped) - } + let step = Double(candidateIndexes.count - 1) / Double(labelBudget - 1) + var selectedIndexes = (0.. 1, + let lastSelectedIndex = selectedIndexes.last, + lastSelectedIndex >= trailingLabelCutoff + { + selectedIndexes.removeLast() + } - private nonisolated static func derivedBoundaryDate( - for sample: PlanUtilizationHistorySample, - source: WindowSourceSelection) -> Date? - { - if let resetsAt = self.resetsAt(for: sample, source: source) { - return self.normalizedBoundaryDate(resetsAt) + if points.count == Layout.maxPoints, + let lastVisibleIndex = points.last?.index, + !selectedIndexes.contains(lastVisibleIndex) + { + selectedIndexes.append(lastVisibleIndex) } - return self.syntheticResetBoundaryDate( - for: sample.capturedAt, - windowMinutes: source.windowMinutes) + + let deduplicated = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes + return deduplicated.map(Double.init) } - private nonisolated static func resetsAt( - for sample: PlanUtilizationHistorySample, - source: WindowSourceSelection) -> Date? + @ViewBuilder + private static func axisLabel( + for point: Point, + windowMinutes: Int, + isTrailingFullChartLabel: Bool) -> some View { - guard self.windowMinutes(for: sample, slot: source.slot) == source.windowMinutes else { return nil } - switch source.slot { - case .primary: - return sample.primaryResetsAt - case .secondary: - return sample.secondaryResetsAt + let label = Text(point.date.formatted(self.axisFormat(windowMinutes: windowMinutes))) + .font(.caption2) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + + if isTrailingFullChartLabel { + label + .frame(width: 48, alignment: .trailing) + .offset(x: -24) + } else { + label } } - private nonisolated static func normalizedBoundaryDate(_ date: Date) -> Date { - Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) + private nonisolated static func axisFormat(windowMinutes: Int) -> Date.FormatStyle { + if windowMinutes <= 300 { + return .dateTime.month(.abbreviated).day() + } + return .dateTime.month(.abbreviated).day() } - private nonisolated static func syntheticResetBoundaryDate( - for date: Date, - windowMinutes: Int) -> Date? + private nonisolated static func seriesTitle( + name: PlanUtilizationSeriesName, + metadata: ProviderMetadata?) -> String { - guard windowMinutes > 0 else { return nil } - let bucketSeconds = Double(windowMinutes) * 60 - let bucketIndex = floor(date.timeIntervalSince1970 / bucketSeconds) - return Date(timeIntervalSince1970: (bucketIndex + 1) * bucketSeconds) + switch name { + case .session: + metadata?.sessionLabel ?? "Session" + case .weekly: + metadata?.weeklyLabel ?? "Weekly" + case .opus: + metadata?.opusLabel ?? "Opus" + default: + self.fallbackTitle(for: name.rawValue) + } } - private nonisolated static func bucketDate(for date: Date, period: Period, calendar: Calendar) -> Date? { - self.chartDateInterval(for: date, period: period, calendar: calendar)?.start + private nonisolated static func fallbackTitle(for rawValue: String) -> String { + let words = rawValue + .replacingOccurrences(of: "([a-z0-9])([A-Z])", with: "$1 $2", options: .regularExpression) + .split(separator: " ") + return words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: " ") } - private nonisolated static func chartDateInterval( - for date: Date, - period: Period, - calendar: Calendar) -> DateInterval? - { - switch period { - case .daily: - let start = calendar.startOfDay(for: date) - guard let end = calendar.date(byAdding: .day, value: 1, to: start) else { return nil } - return DateInterval(start: start, end: end) + private nonisolated static func seriesSortOrder(_ name: PlanUtilizationSeriesName) -> Int { + switch name { + case .session: + 0 case .weekly: - return calendar.dateInterval(of: .weekOfYear, for: date) - case .monthly: - return calendar.dateInterval(of: .month, for: date) + 1 + case .opus: + 2 + default: + 100 } } - private nonisolated static func pointID(date: Date, period: Period, usesResetAlignedExactFit: Bool) -> String { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone.current - formatter.dateFormat = if usesResetAlignedExactFit { - "yyyy-MM-dd" - } else { - switch period { - case .daily: - "yyyy-MM-dd" - case .weekly: - "yyyy-'W'ww" - case .monthly: - "yyyy-MM" - } + private nonisolated static func emptyStateText(title: String?, isRefreshing: Bool) -> String { + if isRefreshing { + return "Refreshing..." } - return formatter.string(from: date) + if let title { + return "No \(title.lowercased()) utilization data yet." + } + return "No utilization data yet." + } + + #if DEBUG + struct ModelSnapshot: Equatable { + let pointCount: Int + let axisIndexes: [Double] + let xDomain: ClosedRange? + let selectedSeries: String? + let visibleSeries: [String] + let usedPercents: [Double] + let pointDates: [String] } - private nonisolated static func nextBucketDate( - after date: Date, - period: Period, - calendar: Calendar) -> Date? + nonisolated static func _modelSnapshotForTesting( + selectedSeriesRawValue: String? = nil, + histories: [PlanUtilizationSeriesHistory], + provider: UsageProvider, + snapshot: UsageSnapshot? = nil, + referenceDate: Date? = nil) -> ModelSnapshot { - switch period { - case .daily: - calendar.date(byAdding: .day, value: 1, to: date) - case .weekly: - calendar.date(byAdding: .weekOfYear, value: 1, to: date) - case .monthly: - calendar.date(byAdding: .month, value: 1, to: date) - } + let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot) + let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first + let model = self.makeModel( + history: selectedSeries?.history, + provider: provider, + referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date()) + return ModelSnapshot( + pointCount: model.points.count, + axisIndexes: model.axisIndexes, + xDomain: model.xDomain, + selectedSeries: selectedSeries?.id, + visibleSeries: visibleSeries.map(\.id), + usedPercents: model.points.map(\.usedPercent), + pointDates: model.points.map { point in + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: point.date) + }) + } + + nonisolated static func _detailLineForTesting( + selectedSeriesRawValue: String? = nil, + histories: [PlanUtilizationSeriesHistory], + provider: UsageProvider, + snapshot: UsageSnapshot? = nil, + referenceDate: Date? = nil) -> String + { + let visibleSeries = self.visibleSeries(histories: histories, provider: provider, snapshot: snapshot) + let selectedSeries = visibleSeries.first(where: { $0.id == selectedSeriesRawValue }) ?? visibleSeries.first + let model = self.makeModel( + history: selectedSeries?.history, + provider: provider, + referenceDate: referenceDate ?? histories.flatMap(\.entries).map(\.capturedAt).max() ?? Date()) + return self.detailLine(point: model.points.last, windowMinutes: selectedSeries?.history.windowMinutes ?? 0) } + nonisolated static func _emptyStateTextForTesting(title: String?, isRefreshing: Bool) -> String { + self.emptyStateText(title: title, isRefreshing: isRefreshing) + } + #endif + private func xValue(for index: Int) -> PlottableValue { - .value("Period", Double(index)) + .value("Series", Double(index)) } @ViewBuilder @@ -809,7 +716,15 @@ struct PlanUtilizationHistoryChartMenuView: View { ForEach(model.points) { point in BarMark( x: self.xValue(for: point.index), - y: .value("Utilization", point.usedPercent)) + yStart: .value("Capacity Start", 0), + yEnd: .value("Capacity End", 100), + width: .fixed(Layout.barWidth)) + .foregroundStyle(model.trackColor) + BarMark( + x: self.xValue(for: point.index), + yStart: .value("Utilization Start", 0), + yEnd: .value("Utilization End", point.usedPercent), + width: .fixed(Layout.barWidth)) .foregroundStyle(model.barColor) } if let selected = self.selectedPoint(model: model) { @@ -819,23 +734,14 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private func axisFormat(for period: Period) -> Date.FormatStyle { - switch period { - case .daily, .weekly: - .dateTime.month(.abbreviated).day() - case .monthly: - .dateTime.month(.abbreviated).year(.defaultDigits) - } - } - private func selectedPoint(model: Model) -> Point? { guard let selectedPointID else { return nil } return model.pointsByID[selectedPointID] } - private func detailLines(model: Model, period: Period) -> (primary: String, secondary: String) { + private func detailLine(model: Model, windowMinutes: Int) -> String { let activePoint = self.selectedPoint(model: model) ?? model.points.last - return Self.detailLines(point: activePoint, period: period, provenanceText: model.provenanceText) + return Self.detailLine(point: activePoint, windowMinutes: windowMinutes) } private func updateSelection( @@ -878,193 +784,28 @@ struct PlanUtilizationHistoryChartMenuView: View { } extension PlanUtilizationHistoryChartMenuView { - private nonisolated static func detailLines( - point: Point?, - period: Period, - provenanceText: String) -> (primary: String, secondary: String) - { + private nonisolated static func detailLine(point: Point?, windowMinutes: Int) -> String { guard let point else { - return ("No data", provenanceText) + return "-" } - let dateLabel: String = switch period { - case .daily, .weekly: - point.date.formatted(.dateTime.month(.abbreviated).day()) - case .monthly: - point.date.formatted(.dateTime.month(.abbreviated).year(.defaultDigits)) - } + let dateLabel = self.detailDateLabel(for: point.date, windowMinutes: windowMinutes) let used = max(0, min(100, point.usedPercent)) - let wasted = max(0, 100 - used) - let usedText = used.formatted(.number.precision(.fractionLength(0...1))) - let wastedText = wasted.formatted(.number.precision(.fractionLength(0...1))) - - return ( - "\(dateLabel): \(usedText)% used, \(wastedText)% wasted", - provenanceText) - } - - private nonisolated static func exactFitPoints( - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection, - period: Period, - calendar: Calendar) -> [Point] - { - let boundaryAnchors = self.resetBoundaryAnchors(samples: samples, source: source) - var buckets: [Date: ExactFitPointAccumulator] = [:] - - for sample in samples { - guard let used = self.usedPercent(for: sample, source: source) else { continue } - guard let displayDate = self.exactFitDisplayDate( - for: sample, - source: source, - boundaryAnchors: boundaryAnchors) - else { - continue - } - - let keyDate = calendar.startOfDay(for: displayDate) - let candidate = ExactFitPointAccumulator( - keyDate: keyDate, - displayDate: displayDate, - observedAt: sample.capturedAt, - usedPercent: max(0, min(100, used))) - - if let existing = buckets[keyDate], candidate.observedAt < existing.observedAt { - continue - } - buckets[keyDate] = candidate - } - - return buckets.values - .sorted { lhs, rhs in - if lhs.keyDate != rhs.keyDate { return lhs.keyDate < rhs.keyDate } - return lhs.observedAt < rhs.observedAt - } - .map { point in - Point( - id: self.pointID(date: point.keyDate, period: period, usesResetAlignedExactFit: true), - index: 0, - date: point.displayDate, - usedPercent: point.usedPercent) - } - } - - private nonisolated static func filledExactFitPoints( - points: [Point], - period: Period, - windowMinutes: Int, - referenceDate: Date, - calendar: Calendar) -> [Point] - { - guard windowMinutes > 0 else { return points } - guard var previous = points.first else { return points } - - let windowInterval = Double(windowMinutes) * 60 - var filled: [Point] = [previous] - - for point in points.dropFirst() { - var cursor = previous.date.addingTimeInterval(windowInterval) - while calendar.startOfDay(for: cursor) < calendar.startOfDay(for: point.date) { - filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) - cursor = cursor.addingTimeInterval(windowInterval) - } - filled.append(point) - previous = point - } - - if previous.date <= referenceDate { - var cursor = previous.date.addingTimeInterval(windowInterval) - while cursor < referenceDate { - filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) - cursor = cursor.addingTimeInterval(windowInterval) - } - filled.append(self.emptyExactFitPoint(date: cursor, period: period, calendar: calendar)) - } - - return filled - } - - private nonisolated static func emptyExactFitPoint(date: Date, period: Period, calendar: Calendar) -> Point { - let keyDate = calendar.startOfDay(for: date) - return Point( - id: self.pointID(date: keyDate, period: period, usesResetAlignedExactFit: true), - index: 0, - date: date, - usedPercent: 0) - } - - private nonisolated static func hasAnyResetBoundary( - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection) -> Bool - { - samples.contains { self.resetsAt(for: $0, source: source) != nil } - } - - private nonisolated static func resetBoundaryAnchors( - samples: [PlanUtilizationHistorySample], - source: WindowSourceSelection) -> [Date] - { - let boundaries: [Date] = samples.compactMap { sample in - guard let resetsAt = self.resetsAt(for: sample, source: source) else { return nil } - return self.normalizedBoundaryDate(resetsAt) - } - return Array(Set(boundaries)).sorted() - } - - private nonisolated static func exactFitDisplayDate( - for sample: PlanUtilizationHistorySample, - source: WindowSourceSelection, - boundaryAnchors: [Date]) -> Date? - { - if let resetsAt = self.resetsAt(for: sample, source: source) { return self.normalizedBoundaryDate(resetsAt) } - return self.estimatedResetBoundaryDate( - for: sample.capturedAt, - windowMinutes: source.windowMinutes, - anchors: boundaryAnchors) - } - - private nonisolated static func estimatedResetBoundaryDate( - for capturedAt: Date, - windowMinutes: Int, - anchors: [Date]) -> Date? - { - guard windowMinutes > 0 else { return nil } - guard !anchors.isEmpty else { return nil } - - let windowInterval = Double(windowMinutes) * 60 - if let nextAnchor = anchors.first(where: { $0 >= capturedAt }), - nextAnchor.timeIntervalSince(capturedAt) <= windowInterval - { - return nextAnchor - } - - if let previousAnchor = anchors.last(where: { $0 < capturedAt }) { - return self.projectedResetBoundaryDate( - for: capturedAt, - anchorBoundary: previousAnchor, - windowMinutes: windowMinutes) - } - - if let nextAnchor = anchors.first { - return self.projectedResetBoundaryDate( - for: capturedAt, - anchorBoundary: nextAnchor, - windowMinutes: windowMinutes) + if !point.isObserved { + return "\(dateLabel): -" } - - return nil + let usedText = used.formatted(.number.precision(.fractionLength(0...1))) + return "\(dateLabel): \(usedText)% used" } - private nonisolated static func projectedResetBoundaryDate( - for capturedAt: Date, - anchorBoundary: Date, - windowMinutes: Int) -> Date? - { - guard windowMinutes > 0 else { return nil } - let windowInterval = Double(windowMinutes) * 60 - let steps = ceil(capturedAt.timeIntervalSince(anchorBoundary) / windowInterval) - let boundary = anchorBoundary.addingTimeInterval(steps * windowInterval) - return self.normalizedBoundaryDate(boundary) + private nonisolated static func detailDateLabel(for date: Date, windowMinutes: Int) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.amSymbol = "am" + formatter.pmSymbol = "pm" + formatter.dateFormat = "MMM d, h:mm a" + return formatter.string(from: date) } } diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index eab24c2c2..7054e782d 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -1,28 +1,66 @@ import CodexBarCore import Foundation -struct PlanUtilizationHistorySample: Codable, Sendable, Equatable { +struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral { + let rawValue: String + + init(rawValue: String) { + self.rawValue = rawValue + } + + init(stringLiteral value: StringLiteralType) { + self.rawValue = value + } + + static let session: Self = "session" + static let weekly: Self = "weekly" + static let opus: Self = "opus" +} + +struct PlanUtilizationHistoryEntry: Codable, Sendable, Equatable { let capturedAt: Date - let primaryUsedPercent: Double? - let primaryWindowMinutes: Int? - let primaryResetsAt: Date? - let secondaryUsedPercent: Double? - let secondaryWindowMinutes: Int? - let secondaryResetsAt: Date? + let usedPercent: Double + let resetsAt: Date? +} + +struct PlanUtilizationSeriesHistory: Codable, Sendable, Equatable { + let name: PlanUtilizationSeriesName + let windowMinutes: Int + let entries: [PlanUtilizationHistoryEntry] + + init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) { + self.name = name + self.windowMinutes = windowMinutes + self.entries = entries.sorted { lhs, rhs in + if lhs.capturedAt != rhs.capturedAt { + return lhs.capturedAt < rhs.capturedAt + } + if lhs.usedPercent != rhs.usedPercent { + return lhs.usedPercent < rhs.usedPercent + } + let lhsReset = lhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970 + let rhsReset = rhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970 + return lhsReset < rhsReset + } + } + + var latestCapturedAt: Date? { + self.entries.last?.capturedAt + } } struct PlanUtilizationHistoryBuckets: Sendable, Equatable { var preferredAccountKey: String? - var unscoped: [PlanUtilizationHistorySample] = [] - var accounts: [String: [PlanUtilizationHistorySample]] = [:] + var unscoped: [PlanUtilizationSeriesHistory] = [] + var accounts: [String: [PlanUtilizationSeriesHistory]] = [:] - func samples(for accountKey: String?) -> [PlanUtilizationHistorySample] { + func histories(for accountKey: String?) -> [PlanUtilizationSeriesHistory] { guard let accountKey, !accountKey.isEmpty else { return self.unscoped } return self.accounts[accountKey] ?? [] } - mutating func setSamples(_ samples: [PlanUtilizationHistorySample], for accountKey: String?) { - let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + mutating func setHistories(_ histories: [PlanUtilizationSeriesHistory], for accountKey: String?) { + let sorted = Self.sortedHistories(histories) guard let accountKey, !accountKey.isEmpty else { self.unscoped = sorted return @@ -37,6 +75,15 @@ struct PlanUtilizationHistoryBuckets: Sendable, Equatable { var isEmpty: Bool { self.unscoped.isEmpty && self.accounts.values.allSatisfy(\.isEmpty) } + + private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] { + histories.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue + } + } } private struct PlanUtilizationHistoryFile: Codable, Sendable { @@ -46,12 +93,12 @@ private struct PlanUtilizationHistoryFile: Codable, Sendable { private struct ProviderHistoryFile: Codable, Sendable { let preferredAccountKey: String? - let unscoped: [PlanUtilizationHistorySample] - let accounts: [String: [PlanUtilizationHistorySample]] + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] } struct PlanUtilizationHistoryStore: Sendable { - fileprivate static let schemaVersion = 4 + fileprivate static let schemaVersion = 6 let fileURL: URL? @@ -80,15 +127,15 @@ struct PlanUtilizationHistoryStore: Sendable { let persistedProviders = providers.reduce(into: [String: ProviderHistoryFile]()) { output, entry in let (provider, buckets) = entry guard !buckets.isEmpty else { return } - let accounts: [String: [PlanUtilizationHistorySample]] = Dictionary( - uniqueKeysWithValues: buckets.accounts.compactMap { accountKey, samples in - let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + let accounts: [String: [PlanUtilizationSeriesHistory]] = Dictionary( + uniqueKeysWithValues: buckets.accounts.compactMap { accountKey, histories in + let sorted = Self.sortedHistories(histories) guard !sorted.isEmpty else { return nil } return (accountKey, sorted) }) output[provider.rawValue] = ProviderHistoryFile( preferredAccountKey: buckets.preferredAccountKey, - unscoped: buckets.unscoped.sorted { $0.capturedAt < $1.capturedAt }, + unscoped: Self.sortedHistories(buckets.unscoped), accounts: accounts) } @@ -117,10 +164,10 @@ struct PlanUtilizationHistoryStore: Sendable { guard let provider = UsageProvider(rawValue: rawProvider) else { continue } output[provider] = PlanUtilizationHistoryBuckets( preferredAccountKey: providerHistory.preferredAccountKey, - unscoped: providerHistory.unscoped.sorted { $0.capturedAt < $1.capturedAt }, + unscoped: Self.sortedHistories(providerHistory.unscoped), accounts: Dictionary( - uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, samples in - let sorted = samples.sorted { $0.capturedAt < $1.capturedAt } + uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, histories in + let sorted = Self.sortedHistories(histories) guard !sorted.isEmpty else { return nil } return (accountKey, sorted) })) @@ -128,6 +175,15 @@ struct PlanUtilizationHistoryStore: Sendable { return output } + private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] { + histories.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue + } + } + private static func defaultFileURL() -> URL? { guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 266fd4341..de6d01177 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1190,33 +1190,43 @@ extension StatusItemController { return item } + func makeFixedWidthSubmenuItem(title: String, submenu: NSMenu, width: CGFloat) -> NSMenuItem { + self.makeMenuCardItem( + HStack(spacing: 0) { + Text(title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 8) + }, + id: "submenu-\(title)", + width: width, + submenu: submenu) + } + @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + menu.addItem(self.makeFixedWidthSubmenuItem(title: "Credits history", submenu: submenu, width: width)) return true } @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + menu.addItem(self.makeFixedWidthSubmenuItem(title: "Usage breakdown", submenu: submenu, width: width)) return true } @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) + menu.addItem(self.makeFixedWidthSubmenuItem(title: "Usage history (30 days)", submenu: submenu, width: width)) return true } diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 673d5d2eb..19c3c4c36 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -6,10 +6,8 @@ extension StatusItemController { @discardableResult func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } - let item = NSMenuItem(title: "Subscription Utilization", action: nil, keyEquivalent: "") - item.isEnabled = true - item.submenu = submenu - menu.addItem(item) + let width: CGFloat = 310 + menu.addItem(self.makeFixedWidthSubmenuItem(title: "Subscription Utilization", submenu: submenu, width: width)) return true } @@ -26,8 +24,10 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - let samples = self.store.planUtilizationHistory(for: provider) - let isRefreshing = self.store.shouldShowPlanUtilizationRefreshingState(for: provider) && samples.isEmpty + let presentation = self.store.planUtilizationHistoryPresentation(for: provider) + let histories = presentation.histories + let snapshot = self.store.snapshot(for: provider) + let isRefreshing = presentation.isRefreshing if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() @@ -39,7 +39,8 @@ extension StatusItemController { let chartView = PlanUtilizationHistoryChartMenuView( provider: provider, - samples: samples, + histories: histories, + snapshot: snapshot, width: width, isRefreshing: isRefreshing) let hosting = MenuHostingView(rootView: chartView) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index a55bedd38..26330ec2a 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -3,14 +3,27 @@ import CryptoKit import Foundation extension UsageStore { + struct PlanUtilizationHistoryPresentation: Equatable { + let histories: [PlanUtilizationSeriesHistory] + let isRefreshing: Bool + } + private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 + private nonisolated static let planUtilizationResetEquivalenceToleranceSeconds: TimeInterval = 2 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 - func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationHistorySample] { - if self.shouldDeferClaudePlanUtilizationHistory(provider: provider) { - return [] - } + private struct PlanUtilizationSeriesKey: Hashable { + let name: PlanUtilizationSeriesName + let windowMinutes: Int + } + + private struct PlanUtilizationSeriesSample { + let name: PlanUtilizationSeriesName + let windowMinutes: Int + let entry: PlanUtilizationHistoryEntry + } + func planUtilizationHistory(for provider: UsageProvider) -> [PlanUtilizationSeriesHistory] { var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let originalProviderBuckets = providerBuckets let accountKey = self.resolvePlanUtilizationAccountKey( @@ -25,7 +38,17 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } } - return providerBuckets.samples(for: accountKey) + return providerBuckets.histories(for: accountKey) + } + + func planUtilizationHistoryPresentation(for provider: UsageProvider) -> PlanUtilizationHistoryPresentation { + let isRefreshing = self.shouldShowPlanUtilizationRefreshingState(for: provider) + if isRefreshing { + return PlanUtilizationHistoryPresentation(histories: [], isRefreshing: true) + } + return PlanUtilizationHistoryPresentation( + histories: self.planUtilizationHistory(for: provider), + isRefreshing: false) } func recordPlanUtilizationHistorySample( @@ -42,8 +65,6 @@ extension UsageStore { var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? await MainActor.run { - // History mutation stays serialized on MainActor so overlapping refresh tasks cannot race each other - // into duplicate writes for the same provider/account bucket. var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) let accountKey = self.resolvePlanUtilizationAccountKey( @@ -53,25 +74,17 @@ extension UsageStore { shouldUpdatePreferredAccountKey: shouldUpdatePreferredAccountKey, shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, providerBuckets: &providerBuckets) - let history = providerBuckets.samples(for: accountKey) - let sample = PlanUtilizationHistorySample( - capturedAt: now, - primaryUsedPercent: Self.clampedPercent(snapshot.primary?.usedPercent), - primaryWindowMinutes: snapshot.primary?.windowMinutes, - primaryResetsAt: snapshot.primary?.resetsAt, - secondaryUsedPercent: Self.clampedPercent(snapshot.secondary?.usedPercent), - secondaryWindowMinutes: snapshot.secondary?.windowMinutes, - secondaryResetsAt: snapshot.secondary?.resetsAt) - - guard let updatedHistory = Self.updatedPlanUtilizationHistory( - provider: provider, - existingHistory: history, - sample: sample) + let histories = providerBuckets.histories(for: accountKey) + let samples = Self.planUtilizationSeriesSamples(provider: provider, snapshot: snapshot, capturedAt: now) + + guard let updatedHistories = Self.updatedPlanUtilizationHistories( + existingHistories: histories, + samples: samples) else { return } - providerBuckets.setSamples(updatedHistory, for: accountKey) + providerBuckets.setHistories(updatedHistories, for: accountKey) self.planUtilizationHistory[provider] = providerBuckets snapshotToPersist = self.planUtilizationHistory } @@ -80,57 +93,96 @@ extension UsageStore { await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) } - private nonisolated static func updatedPlanUtilizationHistory( - provider: UsageProvider, - existingHistory: [PlanUtilizationHistorySample], - sample: PlanUtilizationHistorySample) -> [PlanUtilizationHistorySample]? + private nonisolated static func updatedPlanUtilizationHistories( + existingHistories: [PlanUtilizationSeriesHistory], + samples: [PlanUtilizationSeriesSample]) -> [PlanUtilizationSeriesHistory]? { - var history = existingHistory - let insertionIndex = history.firstIndex(where: { $0.capturedAt > sample.capturedAt }) ?? history.endIndex - - if let matchingIndex = self.planUtilizationHistoryMergeIndex( - history: history, - insertionIndex: insertionIndex, - sample: sample) - { - let merged = self.mergedPlanUtilizationHistorySample( - existing: history[matchingIndex], - incoming: sample) - if merged == history[matchingIndex] { - return nil + guard !samples.isEmpty else { return nil } + + var historiesByKey = Dictionary(uniqueKeysWithValues: existingHistories.map { + (PlanUtilizationSeriesKey(name: $0.name, windowMinutes: $0.windowMinutes), $0) + }) + var didChange = false + + for sample in samples { + let key = PlanUtilizationSeriesKey(name: sample.name, windowMinutes: sample.windowMinutes) + if let existingHistory = historiesByKey[key] { + guard let updatedEntries = self.updatedPlanUtilizationEntries( + existingEntries: existingHistory.entries, + entry: sample.entry) + else { + continue + } + historiesByKey[key] = PlanUtilizationSeriesHistory( + name: sample.name, + windowMinutes: sample.windowMinutes, + entries: updatedEntries) + } else { + historiesByKey[key] = PlanUtilizationSeriesHistory( + name: sample.name, + windowMinutes: sample.windowMinutes, + entries: [sample.entry]) } - history[matchingIndex] = merged - return history + didChange = true } - if insertionIndex < history.endIndex { - history.insert(sample, at: insertionIndex) - } else { - history.append(sample) + guard didChange else { return nil } + return historiesByKey.values.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue } + } + + private nonisolated static func updatedPlanUtilizationEntries( + existingEntries: [PlanUtilizationHistoryEntry], + entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]? + { + var entries = existingEntries + let insertionIndex = entries.firstIndex(where: { $0.capturedAt > entry.capturedAt }) ?? entries.endIndex + let sampleHourBucket = self.planUtilizationHourBucket(for: entry.capturedAt) + let sameHourRange = self.planUtilizationHourRange( + entries: entries, + insertionIndex: insertionIndex, + hourBucket: sampleHourBucket) + let existingHourEntries = Array(entries[sameHourRange]) + let canonicalHourEntries = self.canonicalPlanUtilizationHourEntries( + existingHourEntries: existingHourEntries, + incomingEntry: entry) + + guard canonicalHourEntries != existingHourEntries else { return nil } + entries.replaceSubrange(sameHourRange, with: canonicalHourEntries) - if history.count > self.planUtilizationMaxSamples { - history.removeFirst(history.count - self.planUtilizationMaxSamples) + if entries.count > self.planUtilizationMaxSamples { + entries.removeFirst(entries.count - self.planUtilizationMaxSamples) } - return history + return entries } #if DEBUG - nonisolated static func _updatedPlanUtilizationHistoryForTesting( - provider: UsageProvider, - existingHistory: [PlanUtilizationHistorySample], - sample: PlanUtilizationHistorySample) -> [PlanUtilizationHistorySample]? + nonisolated static func _updatedPlanUtilizationEntriesForTesting( + existingEntries: [PlanUtilizationHistoryEntry], + entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]? { - self.updatedPlanUtilizationHistory( - provider: provider, - existingHistory: existingHistory, - sample: sample) + self.updatedPlanUtilizationEntries(existingEntries: existingEntries, entry: entry) + } + + nonisolated static func _updatedPlanUtilizationHistoriesForTesting( + existingHistories: [PlanUtilizationSeriesHistory], + samples: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory]? + { + let normalized = samples.flatMap { history in + history.entries.map { entry in + PlanUtilizationSeriesSample(name: history.name, windowMinutes: history.windowMinutes, entry: entry) + } + } + return self.updatedPlanUtilizationHistories(existingHistories: existingHistories, samples: normalized) } nonisolated static var _planUtilizationMaxSamplesForTesting: Int { self.planUtilizationMaxSamples } - #endif private nonisolated static func clampedPercent(_ value: Double?) -> Double? { @@ -138,135 +190,181 @@ extension UsageStore { return max(0, min(100, value)) } - private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { - Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) - } - - private nonisolated static func planUtilizationHistoryMergeIndex( - history: [PlanUtilizationHistorySample], - insertionIndex: Int, - sample: PlanUtilizationHistorySample) -> Int? + private nonisolated static func planUtilizationSeriesSamples( + provider: UsageProvider, + snapshot: UsageSnapshot, + capturedAt: Date) -> [PlanUtilizationSeriesSample] { - let sampleHourBucket = self.planUtilizationHourBucket(for: sample.capturedAt) - var candidateIndexes: [Int] = [] + var samplesByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesSample] = [:] - let previousIndex = insertionIndex - 1 - if previousIndex >= history.startIndex { - candidateIndexes.append(previousIndex) - } + func appendWindow(_ window: RateWindow?, name: PlanUtilizationSeriesName?) { + guard let name, + let window, + let windowMinutes = window.windowMinutes, + let usedPercent = self.clampedPercent(window.usedPercent) + else { + return + } - if insertionIndex < history.endIndex { - candidateIndexes.append(insertionIndex) + let key = PlanUtilizationSeriesKey(name: name, windowMinutes: windowMinutes) + samplesByKey[key] = PlanUtilizationSeriesSample( + name: name, + windowMinutes: windowMinutes, + entry: PlanUtilizationHistoryEntry( + capturedAt: capturedAt, + usedPercent: usedPercent, + resetsAt: window.resetsAt)) } - let compatibleIndexes = candidateIndexes.filter { index in - let existing = history[index] - return self.planUtilizationHourBucket(for: existing.capturedAt) == sampleHourBucket - && self.canMergePlanUtilizationHistorySamples(existing: existing, incoming: sample) + switch provider { + case .codex: + appendWindow(snapshot.primary, name: self.codexSeriesName(for: snapshot.primary?.windowMinutes)) + appendWindow(snapshot.secondary, name: self.codexSeriesName(for: snapshot.secondary?.windowMinutes)) + case .claude: + appendWindow(snapshot.primary, name: .session) + appendWindow(snapshot.secondary, name: .weekly) + appendWindow(snapshot.tertiary, name: .opus) + default: + break } - guard !compatibleIndexes.isEmpty else { return nil } - if compatibleIndexes.count == 1 { - return compatibleIndexes[0] + return samplesByKey.values.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue } + } - return compatibleIndexes.min { lhs, rhs in - let lhsDistance = abs(history[lhs].capturedAt.timeIntervalSince(sample.capturedAt)) - let rhsDistance = abs(history[rhs].capturedAt.timeIntervalSince(sample.capturedAt)) - if lhsDistance == rhsDistance { - return history[lhs].capturedAt > history[rhs].capturedAt - } - return lhsDistance < rhsDistance + private nonisolated static func codexSeriesName(for windowMinutes: Int?) -> PlanUtilizationSeriesName? { + switch windowMinutes { + case 300: + .session + case 10080: + .weekly + default: + nil } } - private nonisolated static func canMergePlanUtilizationHistorySamples( - existing: PlanUtilizationHistorySample, - incoming: PlanUtilizationHistorySample) -> Bool + private nonisolated static func planUtilizationHourBucket(for date: Date) -> Int64 { + Int64(floor(date.timeIntervalSince1970 / self.planUtilizationMinSampleIntervalSeconds)) + } + + private nonisolated static func planUtilizationHourRange( + entries: [PlanUtilizationHistoryEntry], + insertionIndex: Int, + hourBucket: Int64) -> Range { - self.arePlanUtilizationWindowMarkersCompatible( - existingWindowMinutes: existing.primaryWindowMinutes, - existingResetsAt: existing.primaryResetsAt, - incomingWindowMinutes: incoming.primaryWindowMinutes, - incomingResetsAt: incoming.primaryResetsAt) - && self.arePlanUtilizationWindowMarkersCompatible( - existingWindowMinutes: existing.secondaryWindowMinutes, - existingResetsAt: existing.secondaryResetsAt, - incomingWindowMinutes: incoming.secondaryWindowMinutes, - incomingResetsAt: incoming.secondaryResetsAt) - } - - private nonisolated static func arePlanUtilizationWindowMarkersCompatible( - existingWindowMinutes: Int?, - existingResetsAt: Date?, - incomingWindowMinutes: Int?, - incomingResetsAt: Date?) -> Bool + var lowerBound = insertionIndex + while lowerBound > entries.startIndex { + let previousIndex = lowerBound - 1 + let previousHourBucket = self.planUtilizationHourBucket(for: entries[previousIndex].capturedAt) + guard previousHourBucket == hourBucket else { break } + lowerBound = previousIndex + } + + var upperBound = insertionIndex + while upperBound < entries.endIndex { + let currentHourBucket = self.planUtilizationHourBucket(for: entries[upperBound].capturedAt) + guard currentHourBucket == hourBucket else { break } + upperBound += 1 + } + + return lowerBound.. [PlanUtilizationHistoryEntry] { - if let existingWindowMinutes, let incomingWindowMinutes, existingWindowMinutes != incomingWindowMinutes { - return false + let hourlyObservations = (existingHourEntries + [incomingEntry]).sorted { lhs, rhs in + if lhs.capturedAt != rhs.capturedAt { + return lhs.capturedAt < rhs.capturedAt + } + if lhs.usedPercent != rhs.usedPercent { + return lhs.usedPercent < rhs.usedPercent + } + let lhsReset = lhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970 + let rhsReset = rhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970 + return lhsReset < rhsReset } + guard var activeSegmentPeak = hourlyObservations.first else { return [] } - let normalizedExistingReset = existingResetsAt.map(self.normalizedPlanUtilizationBoundaryDate) - let normalizedIncomingReset = incomingResetsAt.map(self.normalizedPlanUtilizationBoundaryDate) - if let normalizedExistingReset, - let normalizedIncomingReset, - normalizedExistingReset != normalizedIncomingReset - { - return false + var peakBeforeLatestReset: PlanUtilizationHistoryEntry? + + for observation in hourlyObservations.dropFirst() { + if self.startsNewPlanUtilizationResetSegment( + activeSegmentPeak: activeSegmentPeak, + observation: observation) + { + if peakBeforeLatestReset == nil { + peakBeforeLatestReset = activeSegmentPeak + } + activeSegmentPeak = observation + continue + } + + activeSegmentPeak = self.segmentPeakEntry( + existingPeak: activeSegmentPeak, + observation: observation) } - return true + if let peakBeforeLatestReset { + return [peakBeforeLatestReset, activeSegmentPeak] + } + return [activeSegmentPeak] } - private nonisolated static func normalizedPlanUtilizationBoundaryDate(_ date: Date) -> Date { - Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) + private nonisolated static func startsNewPlanUtilizationResetSegment( + activeSegmentPeak: PlanUtilizationHistoryEntry, + observation: PlanUtilizationHistoryEntry) -> Bool + { + self.haveMeaningfullyDifferentResetBoundaries( + activeSegmentPeak.resetsAt, + observation.resetsAt) } - private nonisolated static func mergedPlanUtilizationHistorySample( - existing: PlanUtilizationHistorySample, - incoming: PlanUtilizationHistorySample) -> PlanUtilizationHistorySample + private nonisolated static func segmentPeakEntry( + existingPeak: PlanUtilizationHistoryEntry, + observation: PlanUtilizationHistoryEntry) -> PlanUtilizationHistoryEntry { - let preferIncoming = incoming.capturedAt >= existing.capturedAt - let capturedAt = preferIncoming ? incoming.capturedAt : existing.capturedAt - - return PlanUtilizationHistorySample( - capturedAt: capturedAt, - primaryUsedPercent: self.mergedPlanUtilizationValue( - existing: existing.primaryUsedPercent, - incoming: incoming.primaryUsedPercent, - preferIncoming: preferIncoming), - primaryWindowMinutes: self.mergedPlanUtilizationValue( - existing: existing.primaryWindowMinutes, - incoming: incoming.primaryWindowMinutes, - preferIncoming: preferIncoming), - primaryResetsAt: self.mergedPlanUtilizationValue( - existing: existing.primaryResetsAt, - incoming: incoming.primaryResetsAt, - preferIncoming: preferIncoming), - secondaryUsedPercent: self.mergedPlanUtilizationValue( - existing: existing.secondaryUsedPercent, - incoming: incoming.secondaryUsedPercent, - preferIncoming: preferIncoming), - secondaryWindowMinutes: self.mergedPlanUtilizationValue( - existing: existing.secondaryWindowMinutes, - incoming: incoming.secondaryWindowMinutes, - preferIncoming: preferIncoming), - secondaryResetsAt: self.mergedPlanUtilizationValue( - existing: existing.secondaryResetsAt, - incoming: incoming.secondaryResetsAt, - preferIncoming: preferIncoming)) - } - - private nonisolated static func mergedPlanUtilizationValue( - existing: T?, - incoming: T?, - preferIncoming: Bool) -> T? + let hasHigherUsage = observation.usedPercent > existingPeak.usedPercent + let tiesUsageAndIsMoreRecent = observation.usedPercent == existingPeak.usedPercent + && observation.capturedAt >= existingPeak.capturedAt + let observationShouldReplacePeak = hasHigherUsage || tiesUsageAndIsMoreRecent + let peakSource = observationShouldReplacePeak ? observation : existingPeak + let preferObservationMetadata = observation.capturedAt >= existingPeak.capturedAt + + return PlanUtilizationHistoryEntry( + capturedAt: peakSource.capturedAt, + usedPercent: peakSource.usedPercent, + resetsAt: self.preferredResetBoundary( + existing: existingPeak.resetsAt, + incoming: observation.resetsAt, + preferIncoming: preferObservationMetadata)) + } + + private nonisolated static func haveMeaningfullyDifferentResetBoundaries(_ lhs: Date?, _ rhs: Date?) -> Bool { + switch (lhs, rhs) { + case let (lhs?, rhs?): + abs(lhs.timeIntervalSince(rhs)) >= self.planUtilizationResetEquivalenceToleranceSeconds + case (.none, .none): + false + default: + false + } + } + + private nonisolated static func preferredResetBoundary( + existing: Date?, + incoming: Date?, + preferIncoming: Bool) -> Date? { if preferIncoming { - incoming ?? existing - } else { - existing ?? incoming + return incoming ?? existing } + return existing ?? incoming } private func planUtilizationAccountKey( @@ -393,8 +491,8 @@ extension UsageStore { existingHistory, providerBuckets.unscoped, ]) - providerBuckets.setSamples(mergedHistory, for: accountKey) - providerBuckets.setSamples([], for: nil) + providerBuckets.setHistories(mergedHistory, for: accountKey) + providerBuckets.setHistories([], for: nil) } private func stickyPlanUtilizationAccountKey( @@ -414,8 +512,8 @@ extension UsageStore { } return knownAccountKeys.max { lhs, rhs in - let lhsDate = providerBuckets.accounts[lhs]?.last?.capturedAt ?? .distantPast - let rhsDate = providerBuckets.accounts[rhs]?.last?.capturedAt ?? .distantPast + let lhsDate = providerBuckets.accounts[lhs]?.compactMap(\.latestCapturedAt).max() ?? .distantPast + let rhsDate = providerBuckets.accounts[rhs]?.compactMap(\.latestCapturedAt).max() ?? .distantPast if lhsDate == rhsDate { return lhs > rhs } @@ -429,29 +527,37 @@ extension UsageStore { } private nonisolated static func mergedPlanUtilizationHistories( - provider: UsageProvider, - histories: [[PlanUtilizationHistorySample]]) -> [PlanUtilizationHistorySample] + provider _: UsageProvider, + histories: [[PlanUtilizationSeriesHistory]]) -> [PlanUtilizationSeriesHistory] { - let orderedSamples = histories - .flatMap(\.self) - .sorted { lhs, rhs in - if lhs.capturedAt == rhs.capturedAt { - return (lhs.primaryUsedPercent ?? -1) < (rhs.primaryUsedPercent ?? -1) + var mergedByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesHistory] = [:] + + for historyGroup in histories { + for history in historyGroup { + let key = PlanUtilizationSeriesKey(name: history.name, windowMinutes: history.windowMinutes) + let existingEntries = mergedByKey[key]?.entries ?? [] + var mergedEntries = existingEntries + for entry in history.entries.sorted(by: { $0.capturedAt < $1.capturedAt }) { + if let updatedEntries = self.updatedPlanUtilizationEntries( + existingEntries: mergedEntries, + entry: entry) + { + mergedEntries = updatedEntries + } } - return lhs.capturedAt < rhs.capturedAt + mergedByKey[key] = PlanUtilizationSeriesHistory( + name: history.name, + windowMinutes: history.windowMinutes, + entries: mergedEntries) } + } - var mergedHistory: [PlanUtilizationHistorySample] = [] - for sample in orderedSamples { - if let updated = self.updatedPlanUtilizationHistory( - provider: provider, - existingHistory: mergedHistory, - sample: sample) - { - mergedHistory = updated + return mergedByKey.values.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes } + return lhs.name.rawValue < rhs.name.rawValue } - return mergedHistory } #if DEBUG diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift index 028239e99..48582a76e 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -7,510 +7,127 @@ import Testing struct UsageStorePlanUtilizationClaudeIdentityTests { @MainActor @Test - func planHistorySelectsConfiguredTokenAccountBucket() throws { + func selectedTokenAccountChoosesMatchingBucket() throws { let store = UsageStorePlanUtilizationTests.makeStore() store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - let accounts = store.settings.tokenAccounts(for: .claude) let alice = try #require(accounts.first) let bob = try #require(accounts.last) let aliceKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting( - provider: .claude, - account: alice)) - let bobKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting( - provider: .claude, - account: bob)) - - let aliceSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 15, secondary: 25) - let bobSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_086_400), primary: 45, secondary: 55) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [ - aliceKey: [aliceSample], - bobKey: [bobSample], - ]) - - store.settings.setActiveTokenAccountIndex(0, for: .claude) - #expect(store.planUtilizationHistory(for: .claude) == [aliceSample]) - - store.settings.setActiveTokenAccountIndex(1, for: .claude) - #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) - } - - @MainActor - @Test - func recordPlanHistoryWithoutExplicitAccountUsesSelectedTokenAccountBucket() async throws { - let store = UsageStorePlanUtilizationTests.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(1, for: .claude) - - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let selectedTokenKey = try #require( - store.settings.selectedTokenAccount(for: .claude).flatMap { - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: $0) - }) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: aliceSnapshot, - now: Date(timeIntervalSince1970: 1_700_000_000)) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[selectedTokenKey]?.count == 1) - } - - @MainActor - @Test - func applySelectedOutcomeRecordsPlanHistoryForSelectedTokenAccount() async throws { - let store = UsageStorePlanUtilizationTests.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(1, for: .claude) - - let selectedAccount = try #require(store.settings.selectedTokenAccount(for: .claude)) - let selectedTokenKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: selectedAccount)) - let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let outcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: snapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - - await store.applySelectedOutcome( - outcome, - provider: .claude, - account: selectedAccount, - fallbackSnapshot: snapshot) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[selectedTokenKey]?.count == 1) - } - - @MainActor - @Test - func refreshingOtherTokenAccountsRecordsPlanHistoryAfterSelectedClaudeSnapshotResolves() async throws { - let store = UsageStorePlanUtilizationTests.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(0, for: .claude) - - let accounts = store.settings.tokenAccounts(for: .claude) - let alice = try #require(accounts.first) - let bob = try #require(accounts.last) + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) let bobKey = try #require( UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let selectedOutcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: aliceSnapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - store.refreshingProviders.insert(.claude) - - await store.applySelectedOutcome( - selectedOutcome, - provider: .claude, - account: alice, - fallbackSnapshot: aliceSnapshot) - await store.recordFetchedTokenAccountPlanUtilizationHistory( - provider: .claude, - samples: [ - (account: bob, snapshot: UsageStorePlanUtilizationTests.makeSnapshot( - provider: .claude, - email: "bob@example.com")), - ], - selectedAccount: alice) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[bobKey]?.count == 1) + store.settings.setActiveTokenAccountIndex(0, for: .claude) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [ + aliceKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ])], + bobKey: [planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ])], + ]) + + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ]) store.settings.setActiveTokenAccountIndex(1, for: .claude) - #expect(store.planUtilizationHistory(for: .claude).count == 1) + #expect(store.planUtilizationHistory(for: .claude) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ]), + ]) } @MainActor @Test - func selectedClaudeTokenAccountAdoptsBootstrapHistoryBeforeSecondaryAccountsRecord() async throws { + func fetchedNonSelectedAccountsPersistIntoSeparateClaudeBuckets() async throws { let store = UsageStorePlanUtilizationTests.makeStore() store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(0, for: .claude) - let accounts = store.settings.tokenAccounts(for: .claude) let alice = try #require(accounts.first) let bob = try #require(accounts.last) - let aliceKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) let bobKey = try #require( UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) - let bootstrapSample = makePlanSample( - at: Date(timeIntervalSince1970: 1_700_000_000), - primary: 15, - secondary: 25) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrapSample]) - - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let selectedOutcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: aliceSnapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - store.refreshingProviders.insert(.claude) - - await store.applySelectedOutcome( - selectedOutcome, - provider: .claude, - account: alice, - fallbackSnapshot: aliceSnapshot) - await store.recordFetchedTokenAccountPlanUtilizationHistory( - provider: .claude, - samples: [ - (account: bob, snapshot: UsageStorePlanUtilizationTests.makeSnapshot( - provider: .claude, - email: "bob@example.com")), - ], - selectedAccount: alice) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.unscoped.isEmpty) - #expect(buckets.accounts[aliceKey]?.contains(bootstrapSample) == true) - #expect(buckets.accounts[bobKey]?.contains(bootstrapSample) != true) - } - - @MainActor - @Test - func secondaryClaudeSamplesDoNotReplacePreferredStickyHistoryBucket() async throws { - let store = UsageStorePlanUtilizationTests.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(0, for: .claude) - let accounts = store.settings.tokenAccounts(for: .claude) - let alice = try #require(accounts.first) - let bob = try #require(accounts.last) - let aliceKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) - - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let bobSnapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 50, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_003_600), + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + tertiary: RateWindow(usedPercent: 30, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), identity: ProviderIdentitySnapshot( providerID: .claude, accountEmail: "bob@example.com", accountOrganization: nil, - loginMethod: "plus")) - let selectedOutcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: aliceSnapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - store.refreshingProviders.insert(.claude) + loginMethod: "max")) - await store.applySelectedOutcome( - selectedOutcome, - provider: .claude, - account: alice, - fallbackSnapshot: aliceSnapshot) await store.recordFetchedTokenAccountPlanUtilizationHistory( provider: .claude, - samples: [(account: bob, snapshot: bobSnapshot)], + samples: [(account: bob, snapshot: snapshot)], selectedAccount: alice) let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.preferredAccountKey == aliceKey) - - store.settings.removeTokenAccount(provider: .claude, accountID: alice.id) - store.settings.removeTokenAccount(provider: .claude, accountID: bob.id) - store._setSnapshotForTesting( - UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), - provider: .claude) - - let history = store.planUtilizationHistory(for: .claude) - #expect(history.first?.primaryUsedPercent == 10) - #expect(history.first?.secondaryUsedPercent == 20) + let histories = try #require(buckets.accounts[bobKey]) + #expect(findSeries(histories, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(histories, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) + #expect(findSeries(histories, name: .opus, windowMinutes: 10080)?.entries.last?.usedPercent == 30) } @MainActor @Test - func secondaryClaudeSamplesDoNotConsumeAnonymousBootstrapHistoryWhenSelectedAccountFails() async throws { + func firstResolvedClaudeTokenAccountAdoptsUnscopedHistory() throws { let store = UsageStorePlanUtilizationTests.makeStore() store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - store.settings.addTokenAccount(provider: .claude, label: "Bob", token: "bob-token") - store.settings.setActiveTokenAccountIndex(0, for: .claude) - - let accounts = store.settings.tokenAccounts(for: .claude) - let alice = try #require(accounts.first) - let bob = try #require(accounts.last) + let alice = try #require(store.settings.tokenAccounts(for: .claude).first) let aliceKey = try #require( UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) - let bobKey = try #require( - UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: bob)) - let bootstrapSample = makePlanSample( - at: Date(timeIntervalSince1970: 1_700_000_000), - primary: 15, - secondary: 25) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrapSample]) - - let bobSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "bob@example.com") - await store.recordFetchedTokenAccountPlanUtilizationHistory( - provider: .claude, - samples: [(account: bob, snapshot: bobSnapshot)], - selectedAccount: alice) - - var buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.unscoped == [bootstrapSample]) - #expect(buckets.accounts[bobKey]?.contains(bootstrapSample) != true) - - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let selectedOutcome = ProviderFetchOutcome( - result: .success( - ProviderFetchResult( - usage: aliceSnapshot, - credits: nil, - dashboard: nil, - sourceLabel: "test", - strategyID: "test", - strategyKind: .web)), - attempts: []) - store.refreshingProviders.insert(.claude) + let bootstrap = planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 15), + ]) + store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [bootstrap]) + store.settings.setActiveTokenAccountIndex(0, for: .claude) - await store.applySelectedOutcome( - selectedOutcome, - provider: .claude, - account: alice, - fallbackSnapshot: aliceSnapshot) + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) - buckets = try #require(store.planUtilizationHistory[.claude]) + #expect(history == [bootstrap]) #expect(buckets.unscoped.isEmpty) - #expect(buckets.accounts[aliceKey]?.contains(bootstrapSample) == true) - } - - @MainActor - @Test - func claudePlanHistoryFallsBackToAnonymousBootstrapBucketBeforeFirstIdentity() { - let store = UsageStorePlanUtilizationTests.makeStore() - let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 20, secondary: 30) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) - - #expect(store.planUtilizationHistory(for: .claude) == [sample]) - } - - @MainActor - @Test - func claudePlanHistoryIsHiddenWhileMainClaudeCardStillShowsRefreshing() { - let store = UsageStorePlanUtilizationTests.makeStore() - let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [sample]) - store.refreshingProviders.insert(.claude) - - #expect(store.shouldShowPlanUtilizationRefreshingState(for: .claude)) - #expect(store.planUtilizationHistory(for: .claude).isEmpty) - } - - @MainActor - @Test - func claudePlanHistoryStaysVisibleWhileRefreshFinishesAfterSnapshotAlreadyResolved() throws { - let store = UsageStorePlanUtilizationTests.makeStore() - let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let accountKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: snapshot)) - let sample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(accounts: [accountKey: [sample]]) - store._setSnapshotForTesting(snapshot, provider: .claude) - store.refreshingProviders.insert(.claude) - - #expect(store.shouldShowPlanUtilizationRefreshingState(for: .claude) == false) - #expect(store.planUtilizationHistory(for: .claude) == [sample]) + #expect(buckets.accounts[aliceKey] == [bootstrap]) } @MainActor @Test - func planHistoryDoesNotReadLegacyIdentityBucketForTokenAccounts() throws { - let store = UsageStorePlanUtilizationTests.makeStore() - store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") - - let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let legacyKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: snapshot)) - let legacySample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [legacyKey: [legacySample]]) - store._setSnapshotForTesting(snapshot, provider: .claude) - - #expect(store.planUtilizationHistory(for: .claude).isEmpty) - } - - @MainActor - @Test - func recordPlanHistoryWithoutIdentityUsesAnonymousBootstrapBucketBeforeFirstIdentity() async throws { + func claudeHistoryWithoutIdentityFallsBackToLastResolvedAccount() async { let store = UsageStorePlanUtilizationTests.makeStore() let snapshot = UsageSnapshot( - primary: RateWindow(usedPercent: 11, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 21, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - updatedAt: Date(timeIntervalSince1970: 1_700_003_600)) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: snapshot, - now: Date(timeIntervalSince1970: 1_700_003_600)) - - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.unscoped.count == 1) - } - - @MainActor - @Test - func recordPlanHistoryWhileMainClaudeCardStillShowsRefreshingSkipsWrite() async { - let store = UsageStorePlanUtilizationTests.makeStore() - let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - store.refreshingProviders.insert(.claude) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: snapshot, - now: Date(timeIntervalSince1970: 1_700_000_000)) - - #expect(store.planUtilizationHistory[.claude] == nil) - } - - @MainActor - @Test - func recordPlanHistoryWhileRefreshFinishesAfterSnapshotAlreadyResolvedStillWrites() async throws { - let store = UsageStorePlanUtilizationTests.makeStore() - let snapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let accountKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: snapshot)) + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(), + identity: ProviderIdentitySnapshot( + providerID: .claude, + accountEmail: "alice@example.com", + accountOrganization: nil, + loginMethod: "max")) store._setSnapshotForTesting(snapshot, provider: .claude) - store.refreshingProviders.insert(.claude) await store.recordPlanUtilizationHistorySample( provider: .claude, snapshot: snapshot, now: Date(timeIntervalSince1970: 1_700_000_000)) - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.accounts[accountKey]?.count == 1) - } - - @MainActor - @Test - func firstResolvedClaudeIdentityAdoptsAnonymousBootstrapHistory() throws { - let store = UsageStorePlanUtilizationTests.makeStore() - let anonymousSample = makePlanSample( - at: Date(timeIntervalSince1970: 1_700_000_000), - primary: 15, - secondary: 25) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets(unscoped: [anonymousSample]) - - let resolvedSnapshot = UsageStorePlanUtilizationTests.makeSnapshot( - provider: .claude, - email: "alice@example.com") - let resolvedKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: resolvedSnapshot)) - store._setSnapshotForTesting(resolvedSnapshot, provider: .claude) - - let history = store.planUtilizationHistory(for: .claude) - - #expect(history == [anonymousSample]) - let buckets = try #require(store.planUtilizationHistory[.claude]) - #expect(buckets.unscoped.isEmpty) - #expect(buckets.accounts[resolvedKey] == [anonymousSample]) - } - - @MainActor - @Test - func claudeHistoryWithoutIdentityFallsBackToLastResolvedAccount() async { - let store = UsageStorePlanUtilizationTests.makeStore() - let resolvedSnapshot = UsageStorePlanUtilizationTests.makeSnapshot( - provider: .claude, - email: "alice@example.com") - store._setSnapshotForTesting(resolvedSnapshot, provider: .claude) - - await store.recordPlanUtilizationHistorySample( - provider: .claude, - snapshot: resolvedSnapshot, - now: Date(timeIntervalSince1970: 1_700_000_000)) - let identitylessSnapshot = UsageSnapshot( - primary: resolvedSnapshot.primary, - secondary: resolvedSnapshot.secondary, - updatedAt: resolvedSnapshot.updatedAt) + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) let history = store.planUtilizationHistory(for: .claude) - - #expect(history.count == 1) - #expect(history.first?.primaryUsedPercent == 10) - #expect(history.first?.secondaryUsedPercent == 20) - } - - @MainActor - @Test - func claudeHistoryWithoutIdentityFallsBackToMostRecentKnownAccount() throws { - let store = UsageStorePlanUtilizationTests.makeStore() - let aliceSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "alice@example.com") - let bobSnapshot = UsageStorePlanUtilizationTests.makeSnapshot(provider: .claude, email: "bob@example.com") - let aliceKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: aliceSnapshot)) - let bobKey = try #require( - UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: bobSnapshot)) - let aliceSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_000_000), primary: 10, secondary: 20) - let bobSample = makePlanSample(at: Date(timeIntervalSince1970: 1_700_086_400), primary: 40, secondary: 50) - - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - accounts: [ - aliceKey: [aliceSample], - bobKey: [bobSample], - ]) - store.planUtilizationHistory[.claude]?.preferredAccountKey = nil - store._setSnapshotForTesting( - UsageSnapshot(primary: nil, secondary: nil, updatedAt: Date()), - provider: .claude) - - #expect(store.planUtilizationHistory(for: .claude) == [bobSample]) + #expect(findSeries(history, name: .session, windowMinutes: 300)?.entries.last?.usedPercent == 10) + #expect(findSeries(history, name: .weekly, windowMinutes: 10080)?.entries.last?.usedPercent == 20) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift index c4903238d..05624dea2 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -7,232 +7,50 @@ import Testing struct UsageStorePlanUtilizationDerivedChartTests { @MainActor @Test - func dailyModelDerivesFromResetBoundariesInsteadOfSyntheticEpochBuckets() throws { - let calendar = Calendar(identifier: .gregorian) - let boundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 5, - minute: 0))) - let samples = [ - makeDerivedChartPlanSample( - at: boundary.addingTimeInterval(-80 * 60), - primary: 20, - primaryWindowMinutes: 300, - primaryResetsAt: boundary), - makeDerivedChartPlanSample( - at: boundary.addingTimeInterval(-10 * 60), - primary: 40, - primaryWindowMinutes: 300, - primaryResetsAt: boundary), + func chartUsesRequestedNativeSeriesWithoutCrossSeriesSelection() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: firstBoundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "daily", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - (40.0 * 5.0 / 24.0)) < 0.000_1) + #expect(model.selectedSeries == "weekly:10080") + #expect(model.usedPercents == [62, 48]) } @MainActor @Test - func dailyModelWeightsEarlyResetPeriodsByActualDuration() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 10, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 13, - minute: 0))) - let samples = [ - makeDerivedChartPlanSample( - at: firstBoundary.addingTimeInterval(-90 * 60), - primary: 30, - primaryWindowMinutes: 300, - primaryResetsAt: firstBoundary), - makeDerivedChartPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - primary: 90, - primaryWindowMinutes: 300, - primaryResetsAt: secondBoundary), + func chartExposesClaudeOpusAsSeparateNativeTab() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 10, resetsAt: boundary), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: boundary), + ]), + planSeries(name: .opus, windowMinutes: 10080, entries: [ + planEntry(at: boundary.addingTimeInterval(-30 * 60), usedPercent: 30, resetsAt: boundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "daily", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .claude, + referenceDate: boundary) - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - 17.5) < 0.000_1) + #expect(model.visibleSeries == ["session:300", "weekly:10080", "opus:10080"]) } - - @MainActor - @Test - func weeklyModelNormalizesFiveHourHistoryAgainstFullWeekDuration() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 9, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 9, - hour: 10, - minute: 0))) - let samples = [ - makeDerivedChartPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - primary: 20, - primaryWindowMinutes: 300, - primaryResetsAt: firstBoundary), - makeDerivedChartPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - primary: 40, - primaryWindowMinutes: 300, - primaryResetsAt: secondBoundary), - ] - - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) - - let expected = (20.0 * 5.0 + 40.0 * 5.0) / (7.0 * 24.0) - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - expected) < 0.000_1) - } - - @MainActor - @Test - func monthlyModelNormalizesWeeklyHistoryAgainstFullMonthDuration() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 0, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 0, - minute: 0))) - let samples = [ - makeDerivedChartPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - primary: 75, - primaryWindowMinutes: 10080, - primaryResetsAt: firstBoundary), - makeDerivedChartPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - primary: 75, - primaryWindowMinutes: 10080, - primaryResetsAt: secondBoundary), - ] - - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "monthly", - samples: samples, - provider: .codex)) - - let expected = 75.0 * 14.0 / 31.0 - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:10080") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - expected) < 0.000_1) - } - - @MainActor - @Test - func dailyModelNormalizesFiveHourHistoryAgainstFullDayDuration() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 7, - hour: 10, - minute: 0))) - let samples = [ - makeDerivedChartPlanSample( - at: firstBoundary.addingTimeInterval(-60 * 60), - primary: 20, - primaryWindowMinutes: 300, - primaryResetsAt: firstBoundary), - makeDerivedChartPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - primary: 10, - primaryWindowMinutes: 300, - primaryResetsAt: firstBoundary), - makeDerivedChartPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - primary: 40, - primaryWindowMinutes: 300, - primaryResetsAt: secondBoundary), - ] - - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "daily", - samples: samples, - provider: .codex)) - - #expect(model.pointCount == 1) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 1) - #expect(abs(model.usedPercents[0] - 12.5) < 0.000_1) - } -} - -private func makeDerivedChartPlanSample( - at capturedAt: Date, - primary: Double?, - primaryWindowMinutes: Int? = nil, - primaryResetsAt: Date? = nil) -> PlanUtilizationHistorySample -{ - PlanUtilizationHistorySample( - capturedAt: capturedAt, - primaryUsedPercent: primary, - primaryWindowMinutes: primaryWindowMinutes, - primaryResetsAt: primaryResetsAt, - secondaryUsedPercent: nil, - secondaryWindowMinutes: nil, - secondaryResetsAt: nil) } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift index 77a1319fc..a61f035ff 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -7,424 +7,222 @@ import Testing struct UsageStorePlanUtilizationExactFitResetTests { @MainActor @Test - func weeklyExactFitUsesResetDateAsBarDate() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let thirdBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 22, - hour: 5, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: secondBoundary), - makeExactFitResetPlanSample( - at: thirdBoundary.addingTimeInterval(-30 * 60), - secondary: 20, - secondaryResetsAt: thirdBoundary), + func weeklyChartUsesResetDateAsBarDate() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: thirdBoundary) - #expect(model.pointCount == 3) - #expect(model.selectedSource == "secondary:10080") #expect(model.usedPercents == [62, 48, 20]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-22 05:00"]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(secondBoundary), + formattedBoundary(thirdBoundary), + ]) } @MainActor @Test - func weeklyExactFitCoalescesSameDayResetShiftIntoSingleBar() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let originalSecondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let shiftedSecondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 7, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: originalSecondBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: originalSecondBoundary), - makeExactFitResetPlanSample( - at: shiftedSecondBoundary.addingTimeInterval(-10 * 60), - secondary: 12, - secondaryResetsAt: shiftedSecondBoundary), + func chartKeepsMaximumUsageForEachEffectivePeriod() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(5 * 60 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-50 * 60), usedPercent: 22, resetsAt: firstBoundary), + planEntry( + at: firstBoundary.addingTimeInterval(-20 * 60), + usedPercent: 61, + resetsAt: firstBoundary.addingTimeInterval(75)), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 18, resetsAt: secondBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) - #expect(model.pointCount == 2) - #expect(model.usedPercents == [62, 12]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 07:00"]) + #expect(model.usedPercents == [61, 18]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary.addingTimeInterval(75)), + formattedBoundary(secondBoundary), + ]) } @MainActor @Test - func weeklyExactFitCreatesNewBarWhenEarlyResetMovesToDifferentDay() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let earlyResetBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 16, - hour: 2, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: secondBoundary), - makeExactFitResetPlanSample( - at: earlyResetBoundary.addingTimeInterval(-10 * 60), - secondary: 12, - secondaryResetsAt: earlyResetBoundary), + func chartPrefersResetBackedEntryWhenUsageTiesWithinPeriod() { + let boundary = Date(timeIntervalSince1970: 1_710_000_000) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: boundary.addingTimeInterval(-55 * 60), usedPercent: 48), + planEntry(at: boundary.addingTimeInterval(-20 * 60), usedPercent: 48, resetsAt: boundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: boundary) - #expect(model.pointCount == 3) - #expect(model.usedPercents == [62, 48, 12]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-16 02:00"]) + #expect(model.usedPercents == [48]) + #expect(model.pointDates == [formattedBoundary(boundary)]) } @MainActor @Test - func weeklyExactFitShowsZeroBarsForMissingResetPeriods() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let fourthBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 29, - hour: 5, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: secondBoundary), - makeExactFitResetPlanSample( - at: fourthBoundary.addingTimeInterval(-30 * 60), - secondary: 20, - secondaryResetsAt: fourthBoundary), + func chartAddsSyntheticCurrentBarWhenCurrentPeriodHasNoObservation() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let currentBoundary = firstBoundary.addingTimeInterval(10 * 60 * 60) + let referenceDate = currentBoundary.addingTimeInterval(-30 * 60) + let histories = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: referenceDate) - #expect(model.pointCount == 4) - #expect(model.usedPercents == [62, 48, 0, 20]) + #expect(model.usedPercents == [62, 0, 0]) #expect(model.pointDates == [ - "2026-03-08 05:00", - "2026-03-15 05:00", - "2026-03-22 05:00", - "2026-03-29 05:00", + formattedBoundary(firstBoundary), + formattedBoundary(firstBoundary.addingTimeInterval(5 * 60 * 60)), + formattedBoundary(currentBoundary), ]) } @MainActor @Test - func weeklyExactFitInfersMissingResetFromObservedCadence() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 22, - hour: 5, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-(3 * 24 * 60 * 60)), - secondary: 52, - secondaryResetsAt: nil), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: secondBoundary), + func weeklyChartShowsZeroBarsForMissingResetPeriods() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = secondBoundary.addingTimeInterval(14 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: fourthBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) - #expect(model.pointCount == 2) - #expect(model.usedPercents == [48, 62]) - #expect(model.pointDates == ["2026-03-15 05:00", "2026-03-22 05:00"]) + #expect(model.usedPercents == [62, 48, 0, 20]) } @MainActor @Test - func weeklyExactFitKeepsShiftedAnchorWhenLaterSampleMissesReset() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let shiftedBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 7, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: shiftedBoundary.addingTimeInterval(-10 * 60), - secondary: 48, - secondaryResetsAt: shiftedBoundary), - makeExactFitResetPlanSample( - at: shiftedBoundary.addingTimeInterval(-5 * 60), - secondary: 12, - secondaryResetsAt: nil), + func weeklyChartStartsAxisLabelsFromFirstBar() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_000) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let thirdBoundary = secondBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let fourthBoundary = thirdBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 48, resetsAt: secondBoundary), + planEntry(at: thirdBoundary.addingTimeInterval(-30 * 60), usedPercent: 20, resetsAt: thirdBoundary), + planEntry(at: fourthBoundary.addingTimeInterval(-30 * 60), usedPercent: 15, resetsAt: fourthBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) - #expect(model.pointCount == 2) - #expect(model.usedPercents == [62, 12]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 07:00"]) + #expect(model.axisIndexes == [0]) } @MainActor @Test - func weeklyExactFitPrefersRealNextResetOverTemporaryShiftCadence() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let shiftedBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 16, - hour: 2, - minute: 0))) - let restoredBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 22, - hour: 5, - minute: 0))) - let missingResetSampleDate = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 19, - hour: 12, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: shiftedBoundary.addingTimeInterval(-10 * 60), - secondary: 48, - secondaryResetsAt: shiftedBoundary), - makeExactFitResetPlanSample( - at: missingResetSampleDate, - secondary: 54, - secondaryResetsAt: nil), - makeExactFitResetPlanSample( - at: restoredBoundary.addingTimeInterval(-10 * 60), - secondary: 20, - secondaryResetsAt: restoredBoundary), + func weeklyChartKeepsObservedCurrentBoundaryWhenResetTimesDriftSlightly() { + let firstBoundary = Date(timeIntervalSince1970: 1_710_000_055) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60 + 88) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstBoundary.addingTimeInterval(-30 * 60), usedPercent: 62, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-30 * 60), usedPercent: 33, resetsAt: secondBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) - #expect(model.pointCount == 4) - #expect(model.usedPercents == [62, 0, 48, 20]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-16 02:00", "2026-03-22 05:00"]) + #expect(model.usedPercents == [62, 33]) } @MainActor @Test - func weeklyExactFitShowsTrailingZeroBarForCurrentExpectedReset() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 8, - hour: 5, - minute: 0))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 15, - hour: 5, - minute: 0))) - let referenceDate = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 20, - hour: 12, - minute: 0))) - let samples = [ - makeExactFitResetPlanSample( - at: firstBoundary.addingTimeInterval(-30 * 60), - secondary: 62, - secondaryResetsAt: firstBoundary), - makeExactFitResetPlanSample( - at: secondBoundary.addingTimeInterval(-30 * 60), - secondary: 48, - secondaryResetsAt: secondBoundary), + func weeklyChartPrefersResetBackedHistoryOverLegacySyntheticPoints() { + let legacyCapturedAt = Date(timeIntervalSince1970: 1_742_100_000) + let firstBoundary = Date(timeIntervalSince1970: 1_742_356_855) // 2026-03-18T17:00:55Z + let secondBoundary = Date(timeIntervalSince1970: 1_742_961_343) // 2026-03-25T17:02:23Z + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: legacyCapturedAt, usedPercent: 57), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), ] - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "weekly", - samples: samples, - provider: .codex, - referenceDate: referenceDate)) + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) - #expect(model.pointCount == 3) - #expect(model.usedPercents == [62, 48, 0]) - #expect(model.pointDates == ["2026-03-08 05:00", "2026-03-15 05:00", "2026-03-22 05:00"]) + #expect(model.usedPercents == [73, 35]) } -} -private func makeExactFitResetPlanSample( - at capturedAt: Date, - secondary: Double, - secondaryResetsAt: Date?) -> PlanUtilizationHistorySample -{ - PlanUtilizationHistorySample( - capturedAt: capturedAt, - primaryUsedPercent: nil, - primaryWindowMinutes: nil, - primaryResetsAt: nil, - secondaryUsedPercent: secondary, - secondaryWindowMinutes: 10080, - secondaryResetsAt: secondaryResetsAt) + @MainActor + @Test + func chartKeepsLegacyHistoryBeforeFirstResetBackedBoundary() { + let firstLegacyCapturedAt = Date(timeIntervalSince1970: 1_739_692_800) // 2026-02-23T07:00:00Z + let secondLegacyCapturedAt = firstLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60) + let firstBoundary = secondLegacyCapturedAt.addingTimeInterval(7 * 24 * 60 * 60 + 55) + let secondBoundary = firstBoundary.addingTimeInterval(7 * 24 * 60 * 60) + let histories = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: firstLegacyCapturedAt, usedPercent: 20), + planEntry(at: secondLegacyCapturedAt, usedPercent: 40), + planEntry(at: firstBoundary.addingTimeInterval(-60 * 60), usedPercent: 73, resetsAt: firstBoundary), + planEntry(at: secondBoundary.addingTimeInterval(-60 * 60), usedPercent: 35, resetsAt: secondBoundary), + ]), + ] + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .claude, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [20, 40, 73, 35]) + } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift index 2537c8389..30601f0b7 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct UsageStorePlanUtilizationResetCoalescingTests { @Test - func sameHourSampleWithChangedPrimaryResetBoundaryAppendsNewSample() throws { + func sameHourEntryBackfillsMissingResetMetadata() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -14,308 +14,198 @@ struct UsageStorePlanUtilizationResetCoalescingTests { month: 3, day: 17, hour: 9))) - let firstReset = hourStart.addingTimeInterval(30 * 60) - let secondReset = hourStart.addingTimeInterval(5 * 60 + 5 * 60 * 60) - let beforeReset = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 82, - secondary: 40, - primaryWindowMinutes: 300, - primaryResetsAt: firstReset, - secondaryWindowMinutes: 10080) - let afterReset = makePlanSample( - at: hourStart.addingTimeInterval(35 * 60), - primary: 4, - secondary: 41, - primaryWindowMinutes: 300, - primaryResetsAt: secondReset, - secondaryWindowMinutes: 10080) + let existing = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 20) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 30, + resetsAt: hourStart.addingTimeInterval(30 * 60)) - let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: beforeReset)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: afterReset)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) - #expect(updated.count == 2) - #expect(updated[0] == beforeReset) - #expect(updated[1] == afterReset) + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 30) + #expect(updated[0].resetsAt == incoming.resetsAt) } @Test - func sameHourSampleWithChangedSecondaryResetBoundaryAppendsNewSample() throws { + func sameHourZeroUsageWithDriftingResetCoalescesToLatestEntry() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), year: 2026, month: 3, - day: 17, - hour: 9))) - let firstReset = hourStart.addingTimeInterval(30 * 60) - let secondReset = firstReset.addingTimeInterval(7 * 24 * 60 * 60) - let shiftedReset = secondReset.addingTimeInterval(7 * 24 * 60 * 60) - let beforeReset = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 40, - secondary: 77, - primaryWindowMinutes: 300, - primaryResetsAt: firstReset, - secondaryWindowMinutes: 10080, - secondaryResetsAt: secondReset) - let afterReset = makePlanSample( - at: hourStart.addingTimeInterval(35 * 60), - primary: 41, - secondary: 3, - primaryWindowMinutes: 300, - primaryResetsAt: firstReset, - secondaryWindowMinutes: 10080, - secondaryResetsAt: shiftedReset) + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(14 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 2)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(23 * 60), + usedPercent: 0, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60 + 14 * 60 + 3)) - let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: beforeReset)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: afterReset)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) - #expect(updated.count == 2) - #expect(updated[0] == beforeReset) - #expect(updated[1] == afterReset) + #expect(updated.count == 1) + #expect(updated[0] == incoming) } @Test - func sameHourSampleMergesWhenOnlyIncomingResetMetadataIsBackfilled() throws { + func sameHourResetTimesWithinTwoMinutesStillKeepSingleHourlyPeak() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), year: 2026, month: 3, - day: 17, - hour: 9))) - let incomingReset = hourStart.addingTimeInterval(30 * 60) - let existing = makePlanSample( - at: hourStart.addingTimeInterval(10 * 60), - primary: 20, - secondary: 35, - primaryWindowMinutes: 300, - secondaryWindowMinutes: 10080) - let incoming = makePlanSample( - at: hourStart.addingTimeInterval(45 * 60), - primary: 30, - secondary: 40, - primaryWindowMinutes: 300, - primaryResetsAt: incomingReset, - secondaryWindowMinutes: 10080) + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(21 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(55 * 60), + usedPercent: 10, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 1)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [existing], - sample: incoming)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) #expect(updated.count == 1) #expect(updated[0].capturedAt == incoming.capturedAt) - #expect(updated[0].primaryUsedPercent == 30) - #expect(updated[0].secondaryUsedPercent == 40) - #expect(updated[0].primaryResetsAt == incomingReset) + #expect(updated[0].usedPercent == 10) + #expect(updated[0].resetsAt == incoming.resetsAt) } - @MainActor @Test - func lateSameHourBackfillBeforeResetMergesIntoEarlierWindow() throws { + func sameHourUsageDropWithoutMeaningfulResetStillKeepsSingleHourlyPeak() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), year: 2026, month: 3, - day: 17, - hour: 9))) - let resetBoundary = hourStart.addingTimeInterval(30 * 60) - let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) - let earlierWindow = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 82, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: resetBoundary) - let laterWindow = makePlanSample( - at: hourStart.addingTimeInterval(35 * 60), - primary: 4, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nextResetBoundary) - let lateBackfill = makePlanSample( - at: hourStart.addingTimeInterval(28 * 60), - primary: 95, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nil) + day: 20, + hour: 0))) + let existing = planEntry( + at: hourStart.addingTimeInterval(15 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)) + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 5, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60 + 30)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [earlierWindow, laterWindow], - sample: lateBackfill)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) - #expect(updated.count == 2) - #expect(updated[0].capturedAt == lateBackfill.capturedAt) - #expect(updated[0].primaryUsedPercent == 95) - #expect(updated[0].primaryResetsAt == resetBoundary) - #expect(updated[1] == laterWindow) + #expect(updated.count == 1) + #expect(updated[0].capturedAt == existing.capturedAt) + #expect(updated[0].usedPercent == existing.usedPercent) + #expect(updated[0].resetsAt == incoming.resetsAt) } @Test - func lateSameHourBackfillAfterResetDoesNotOverrideLaterWindowValues() throws { + func sameHourResetKeepsPeakBeforeResetAndLatestPeakAfterReset() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), year: 2026, month: 3, - day: 17, - hour: 9))) - let resetBoundary = hourStart.addingTimeInterval(30 * 60) - let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) - let earlierWindow = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 82, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: resetBoundary) - let laterWindow = makePlanSample( - at: hourStart.addingTimeInterval(35 * 60), - primary: 4, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nextResetBoundary) - let lateBackfill = makePlanSample( - at: hourStart.addingTimeInterval(32 * 60), - primary: 12, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nil) + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(45 * 60), + usedPercent: 18, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)) - let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [earlierWindow, laterWindow], - sample: lateBackfill) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) - #expect(updated == nil) + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1].usedPercent == 18) + #expect(updated[1].resetsAt == incoming.resetsAt) } @Test - func sameHourBackfillTiePrefersLaterWindowWithoutOverridingValues() throws { + func newerResetWithinHourReplacesEarlierPostResetRecord() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), year: 2026, month: 3, - day: 17, - hour: 9))) - let resetBoundary = hourStart.addingTimeInterval(30 * 60) - let nextResetBoundary = resetBoundary.addingTimeInterval(5 * 60 * 60) - let earlierWindow = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 82, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: resetBoundary) - let laterWindow = makePlanSample( - at: hourStart.addingTimeInterval(35 * 60), - primary: 4, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nextResetBoundary) - let ambiguousBackfill = makePlanSample( - at: hourStart.addingTimeInterval(30 * 60), - primary: 12, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: nil) + day: 20, + hour: 0))) + let initial = [ + planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 40, + resetsAt: hourStart.addingTimeInterval(3 * 60 * 60)), + planEntry( + at: hourStart.addingTimeInterval(20 * 60), + usedPercent: 12, + resetsAt: hourStart.addingTimeInterval(8 * 60 * 60)), + ] + let incoming = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 3, + resetsAt: hourStart.addingTimeInterval(10 * 60 * 60)) - let updated = UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [earlierWindow, laterWindow], - sample: ambiguousBackfill) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) - #expect(updated == nil) + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1] == incoming) } - @MainActor @Test - func dailyChartPreservesCompletedWindowAcrossResetWithinSameHour() throws { - let calendar = Calendar(identifier: .gregorian) - let firstBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 17, - hour: 4, - minute: 30))) - let secondBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 17, - hour: 9, - minute: 30))) - let thirdBoundary = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone.current, - year: 2026, - month: 3, - day: 17, - hour: 14, - minute: 30))) - let samples = [ - makePlanSample( - at: firstBoundary.addingTimeInterval(-20 * 60), - primary: 20, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: firstBoundary), - makePlanSample( - at: secondBoundary.addingTimeInterval(-5 * 60), - primary: 82, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: secondBoundary), - makePlanSample( - at: secondBoundary.addingTimeInterval(5 * 60), - primary: 4, - secondary: nil, - primaryWindowMinutes: 300, - primaryResetsAt: thirdBoundary), + func mergedHistoriesKeepSeriesSeparatedByStableName() throws { + let existing = [ + planSeries(name: .session, windowMinutes: 300, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 20), + ]), + ] + let incoming = [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 40), + ]), ] - var history: [PlanUtilizationHistorySample] = [] - for sample in samples { - history = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: history, - sample: sample)) - } - - #expect(history.count == 3) - - let model = try #require( - PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( - periodRawValue: "daily", - samples: history, - provider: .codex)) + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) - #expect(model.pointCount == 2) - #expect(model.selectedSource == "primary:300") - #expect(model.usedPercents.count == 2) - #expect(abs(model.usedPercents[0] - (20.0 * 0.5 / 24.0)) < 0.000_1) - #expect(abs(model.usedPercents[1] - ((20.0 * 4.5 + 82.0 * 5.0 + 4.0 * 5.0) / 24.0)) < 0.000_1) + #expect(findSeries(updated, name: .session, windowMinutes: 300) != nil) + #expect(findSeries(updated, name: .weekly, windowMinutes: 10080) != nil) } } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 6b8f2ae02..21f29295d 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -6,7 +6,7 @@ import Testing @Suite struct UsageStorePlanUtilizationTests { @Test - func coalescesChangedUsageWithinHourIntoSingleSample() throws { + func coalescesChangedUsageWithinHourIntoSingleEntry() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -14,31 +14,24 @@ struct UsageStorePlanUtilizationTests { month: 3, day: 17, hour: 10))) - let first = makePlanSample(at: hourStart, primary: 10, secondary: 20) - let second = makePlanSample( - at: hourStart.addingTimeInterval(25 * 60), - primary: 35, - secondary: 45, - primaryWindowMinutes: 300, - secondaryWindowMinutes: 10080) + let first = planEntry(at: hourStart, usedPercent: 10) + let second = planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35) let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: first)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: second)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) #expect(updated.count == 1) #expect(updated.last == second) } @Test - func appendsNewSampleAfterCrossingIntoNextHourBucket() throws { + func changedResetBoundaryWithinHourAppendsNewEntry() throws { let calendar = Calendar(identifier: .gregorian) let hourStart = try #require(calendar.date(from: DateComponents( timeZone: TimeZone(secondsFromGMT: 0), @@ -46,586 +39,378 @@ struct UsageStorePlanUtilizationTests { month: 3, day: 17, hour: 10))) - let first = makePlanSample(at: hourStart, primary: 10, secondary: 20) - let second = makePlanSample(at: hourStart.addingTimeInterval(50 * 60), primary: 35, secondary: 45) - let nextHour = makePlanSample(at: hourStart.addingTimeInterval(65 * 60), primary: 60, secondary: 70) - - let oneHour = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: first)) - let coalesced = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: oneHour, - sample: second)) - let twoHours = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: coalesced, - sample: nextHour)) - - #expect(coalesced.count == 1) - #expect(twoHours.count == 2) - #expect(twoHours.first == second) - #expect(twoHours.last == nextHour) - } - - @Test - func staleWriteInSameHourWithDifferentWindowMarkersIsRetainedSeparately() throws { - let calendar = Calendar(identifier: .gregorian) - let hourStart = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone(secondsFromGMT: 0), - year: 2026, - month: 3, - day: 17, - hour: 10))) - let newer = makePlanSample( - at: hourStart.addingTimeInterval(45 * 60), - primary: 70, - secondary: 80, - primaryWindowMinutes: 300, - secondaryWindowMinutes: 10080) - let stale = makePlanSample( + let first = planEntry( at: hourStart.addingTimeInterval(5 * 60), - primary: 15, - secondary: 25, - primaryWindowMinutes: 60, - secondaryWindowMinutes: 1440) + usedPercent: 82, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) let initial = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [], - sample: newer)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: initial, - sample: stale)) + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) #expect(updated.count == 2) - #expect(updated.first == stale) - #expect(updated.last == newer) - } - - @Test - func newerSameHourSampleKeepsNewerMetadataAndBackfillsMissingValues() throws { - let calendar = Calendar(identifier: .gregorian) - let hourStart = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone(secondsFromGMT: 0), - year: 2026, - month: 3, - day: 17, - hour: 10))) - let existing = makePlanSample( - at: hourStart.addingTimeInterval(10 * 60), - primary: 20, - secondary: nil, - primaryWindowMinutes: 300, - secondaryWindowMinutes: 10080) - let incomingReset = Date(timeIntervalSince1970: 1_710_000_000) - let incoming = makePlanSample( - at: hourStart.addingTimeInterval(50 * 60), - primary: nil, - secondary: 45, - primaryResetsAt: incomingReset) - - let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [existing], - sample: incoming)) - - let merged = try #require(updated.last) - #expect(merged.capturedAt == incoming.capturedAt) - #expect(merged.primaryUsedPercent == 20) - #expect(merged.primaryWindowMinutes == 300) - #expect(merged.primaryResetsAt == incomingReset) - #expect(merged.secondaryUsedPercent == 45) - #expect(merged.secondaryWindowMinutes == 10080) + #expect(updated[0] == first) + #expect(updated[1] == second) } @Test - func staleSameHourSampleOnlyFillsMissingMetadata() throws { - let calendar = Calendar(identifier: .gregorian) - let hourStart = try #require(calendar.date(from: DateComponents( - timeZone: TimeZone(secondsFromGMT: 0), - year: 2026, - month: 3, - day: 17, - hour: 10))) - let newer = makePlanSample( - at: hourStart.addingTimeInterval(50 * 60), - primary: 40, - secondary: 80, - primaryWindowMinutes: nil, - secondaryWindowMinutes: 10080) - let staleReset = Date(timeIntervalSince1970: 1_710_123_456) - let stale = makePlanSample( - at: hourStart.addingTimeInterval(5 * 60), - primary: 10, - secondary: 20, - primaryWindowMinutes: 300, - primaryResetsAt: staleReset, - secondaryWindowMinutes: nil) - - let updated = try #require( - UsageStore._updatedPlanUtilizationHistoryForTesting( - provider: .codex, - existingHistory: [newer], - sample: stale)) - - let merged = try #require(updated.last) - #expect(merged.capturedAt == newer.capturedAt) - #expect(merged.primaryUsedPercent == 40) - #expect(merged.secondaryUsedPercent == 80) - #expect(merged.primaryWindowMinutes == 300) - #expect(merged.primaryResetsAt == staleReset) - #expect(merged.secondaryWindowMinutes == 10080) - } - - @Test - func trimsHistoryToExpandedRetentionLimit() throws { + func trimsEntryHistoryToRetentionLimit() throws { let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting let base = Date(timeIntervalSince1970: 1_700_000_000) - var history: [PlanUtilizationHistorySample] = [] + var entries: [PlanUtilizationHistoryEntry] = [] for offset in 0.. UsageSnapshot { UsageSnapshot( - primary: RateWindow(usedPercent: 10, windowMinutes: nil, resetsAt: nil, resetDescription: nil), - secondary: RateWindow(usedPercent: 20, windowMinutes: nil, resetsAt: nil, resetDescription: nil), + primary: RateWindow(usedPercent: 10, windowMinutes: 300, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 20, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), updatedAt: Date(), identity: ProviderIdentitySnapshot( providerID: provider, @@ -931,21 +703,30 @@ extension UsageStorePlanUtilizationTests { } } -func makePlanSample( - at capturedAt: Date, - primary: Double?, - secondary: Double?, - primaryWindowMinutes: Int? = nil, - primaryResetsAt: Date? = nil, - secondaryWindowMinutes: Int? = nil, - secondaryResetsAt: Date? = nil) -> PlanUtilizationHistorySample +func planEntry(at capturedAt: Date, usedPercent: Double, resetsAt: Date? = nil) -> PlanUtilizationHistoryEntry { + PlanUtilizationHistoryEntry(capturedAt: capturedAt, usedPercent: usedPercent, resetsAt: resetsAt) +} + +func planSeries( + name: PlanUtilizationSeriesName, + windowMinutes: Int, + entries: [PlanUtilizationHistoryEntry]) -> PlanUtilizationSeriesHistory { - PlanUtilizationHistorySample( - capturedAt: capturedAt, - primaryUsedPercent: primary, - primaryWindowMinutes: primaryWindowMinutes, - primaryResetsAt: primaryResetsAt, - secondaryUsedPercent: secondary, - secondaryWindowMinutes: secondaryWindowMinutes, - secondaryResetsAt: secondaryResetsAt) + PlanUtilizationSeriesHistory(name: name, windowMinutes: windowMinutes, entries: entries) +} + +func findSeries( + _ histories: [PlanUtilizationSeriesHistory], + name: PlanUtilizationSeriesName, + windowMinutes: Int) -> PlanUtilizationSeriesHistory? +{ + histories.first { $0.name == name && $0.windowMinutes == windowMinutes } +} + +func formattedBoundary(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: date) } From 7a320aae5e8f75b2edc3c3ad4f6c1f26fb4509bf Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 12:41:20 +0800 Subject: [PATCH 28/32] Split plan utilization history into provider files - Persist plan history as one JSON file per provider under `history/` - Add migration script to convert legacy `plan-utilization-history.json` - Update tests and test store wiring for directory-based history storage --- .../migrate_plan_utilization_history.swift | 205 ++++++++++++++++++ .../PlanUtilizationHistoryStore.swift | 154 ++++++++----- Tests/CodexBarTests/TestStores.swift | 4 +- .../UsageStorePlanUtilizationTests.swift | 27 +-- 4 files changed, 316 insertions(+), 74 deletions(-) create mode 100755 Scripts/migrate_plan_utilization_history.swift diff --git a/Scripts/migrate_plan_utilization_history.swift b/Scripts/migrate_plan_utilization_history.swift new file mode 100755 index 000000000..40ed366bc --- /dev/null +++ b/Scripts/migrate_plan_utilization_history.swift @@ -0,0 +1,205 @@ +#!/usr/bin/env swift + +import Darwin +import Foundation + +private struct ProviderHistoryFile: Codable { + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] +} + +private struct LegacyPlanUtilizationHistoryFile: Codable { + let version: Int + let providers: [String: ProviderHistoryFile] +} + +private struct ProviderHistoryDocument: Codable { + let version: Int + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] +} + +private struct PlanUtilizationSeriesHistory: Codable { + let name: String + let windowMinutes: Int + let entries: [PlanUtilizationHistoryEntry] +} + +private struct PlanUtilizationHistoryEntry: Codable { + let capturedAt: Date + let usedPercent: Double + let resetsAt: Date? +} + +private enum MigrationError: Error, CustomStringConvertible { + case invalidArguments(String) + case unsupportedLegacyVersion(Int) + case providerFilesAlreadyExist(URL) + + var description: String { + switch self { + case let .invalidArguments(message): + return message + case let .unsupportedLegacyVersion(version): + return "Unsupported legacy history schema version \(version)." + case let .providerFilesAlreadyExist(url): + return "Provider history files already exist in \(url.path). Re-run with --force to overwrite." + } + } +} + +private struct Arguments { + let rootURL: URL + let force: Bool + + static func parse() throws -> Arguments { + var rootURL = Self.defaultRootURL() + var force = false + var index = 1 + + while index < CommandLine.arguments.count { + let argument = CommandLine.arguments[index] + switch argument { + case "--root": + let nextIndex = index + 1 + guard nextIndex < CommandLine.arguments.count else { + throw MigrationError.invalidArguments("Missing path after --root.") + } + rootURL = URL(fileURLWithPath: CommandLine.arguments[nextIndex], isDirectory: true) + index += 2 + case "--force": + force = true + index += 1 + case "--help", "-h": + Self.printUsage() + exit(0) + default: + throw MigrationError.invalidArguments("Unknown argument: \(argument)") + } + } + + return Arguments(rootURL: rootURL, force: force) + } + + private static func defaultRootURL() -> URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support", isDirectory: true) + return base.appendingPathComponent("com.steipete.codexbar", isDirectory: true) + } + + private static func printUsage() { + let executable = URL(fileURLWithPath: CommandLine.arguments[0]).lastPathComponent + print("Usage: \(executable) [--root ] [--force]") + } +} + +private let legacySchemaVersion = 6 +private let providerSchemaVersion = 1 + +private func loadLegacyHistory(from legacyURL: URL) throws -> LegacyPlanUtilizationHistoryFile { + let data = try Data(contentsOf: legacyURL) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let decoded = try decoder.decode(LegacyPlanUtilizationHistoryFile.self, from: data) + guard decoded.version == legacySchemaVersion else { + throw MigrationError.unsupportedLegacyVersion(decoded.version) + } + return decoded +} + +private func providerHistoryURLs(in directoryURL: URL) -> [URL] { + guard let contents = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles]) + else { + return [] + } + + return contents.filter { $0.pathExtension == "json" } +} + +private func archiveURL(for legacyURL: URL) -> URL { + let directoryURL = legacyURL.deletingLastPathComponent() + let basename = legacyURL.deletingPathExtension().lastPathComponent + let archiveBaseURL = directoryURL.appendingPathComponent("\(basename).legacy", isDirectory: false) + let archiveURL = archiveBaseURL.appendingPathExtension("json") + if !FileManager.default.fileExists(atPath: archiveURL.path) { + return archiveURL + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyyMMdd-HHmmss" + let timestamp = formatter.string(from: Date()) + return directoryURL + .appendingPathComponent("\(basename).legacy-\(timestamp)", isDirectory: false) + .appendingPathExtension("json") +} + +private func migrate(arguments: Arguments) throws { + let rootURL = arguments.rootURL + let legacyURL = rootURL.appendingPathComponent("plan-utilization-history.json", isDirectory: false) + let historyDirectoryURL = rootURL.appendingPathComponent("history", isDirectory: true) + + guard FileManager.default.fileExists(atPath: legacyURL.path) else { + print("No legacy plan utilization history found at \(legacyURL.path).") + return + } + + let existingProviderFiles = providerHistoryURLs(in: historyDirectoryURL) + if !existingProviderFiles.isEmpty, !arguments.force { + throw MigrationError.providerFilesAlreadyExist(historyDirectoryURL) + } + + let legacy = try loadLegacyHistory(from: legacyURL) + try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true) + if arguments.force { + for url in existingProviderFiles { + try? FileManager.default.removeItem(at: url) + } + } + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + + var migratedProviders: [String] = [] + for (provider, history) in legacy.providers.sorted(by: { $0.key < $1.key }) { + let payload = ProviderHistoryDocument( + version: providerSchemaVersion, + preferredAccountKey: history.preferredAccountKey, + unscoped: history.unscoped, + accounts: history.accounts) + let data = try encoder.encode(payload) + let providerURL = historyDirectoryURL.appendingPathComponent("\(provider).json", isDirectory: false) + try data.write(to: providerURL, options: [.atomic]) + migratedProviders.append(provider) + } + + let archivedLegacyURL = archiveURL(for: legacyURL) + try FileManager.default.moveItem(at: legacyURL, to: archivedLegacyURL) + + if migratedProviders.isEmpty { + print("Migrated empty legacy history. Archived legacy file to \(archivedLegacyURL.path).") + } else { + print("Migrated providers: \(migratedProviders.joined(separator: ", ")).") + print("Archived legacy file to \(archivedLegacyURL.path).") + print("Provider history directory: \(historyDirectoryURL.path)") + } +} + +do { + let arguments = try Arguments.parse() + try migrate(arguments: arguments) +} catch let error as MigrationError { + fputs("ERROR: \(error.description)\n", stderr) + exit(1) +} catch { + fputs("ERROR: \(error)\n", stderr) + exit(1) +} diff --git a/Sources/CodexBar/PlanUtilizationHistoryStore.swift b/Sources/CodexBar/PlanUtilizationHistoryStore.swift index 7054e782d..1a3dfa2c8 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryStore.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -86,24 +86,26 @@ struct PlanUtilizationHistoryBuckets: Sendable, Equatable { } } -private struct PlanUtilizationHistoryFile: Codable, Sendable { - let version: Int - let providers: [String: ProviderHistoryFile] +private struct ProviderHistoryFile: Codable, Sendable { + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] } -private struct ProviderHistoryFile: Codable, Sendable { +private struct ProviderHistoryDocument: Codable, Sendable { + let version: Int let preferredAccountKey: String? let unscoped: [PlanUtilizationSeriesHistory] let accounts: [String: [PlanUtilizationSeriesHistory]] } struct PlanUtilizationHistoryStore: Sendable { - fileprivate static let schemaVersion = 6 + fileprivate static let providerSchemaVersion = 1 - let fileURL: URL? + let directoryURL: URL? - init(fileURL: URL? = Self.defaultFileURL()) { - self.fileURL = fileURL + init(directoryURL: URL? = Self.defaultDirectoryURL()) { + self.directoryURL = directoryURL } static func defaultAppSupport() -> Self { @@ -111,70 +113,101 @@ struct PlanUtilizationHistoryStore: Sendable { } func load() -> [UsageProvider: PlanUtilizationHistoryBuckets] { - guard let url = self.fileURL else { return [:] } - guard let data = try? Data(contentsOf: url) else { return [:] } - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - guard let decoded = try? decoder.decode(PlanUtilizationHistoryFile.self, from: data) else { - return [:] - } - return Self.decodeProviders(decoded.providers) + self.loadProviderFiles() } func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) { - guard let url = self.fileURL else { return } - let persistedProviders = providers.reduce(into: [String: ProviderHistoryFile]()) { output, entry in - let (provider, buckets) = entry - guard !buckets.isEmpty else { return } - let accounts: [String: [PlanUtilizationSeriesHistory]] = Dictionary( - uniqueKeysWithValues: buckets.accounts.compactMap { accountKey, histories in - let sorted = Self.sortedHistories(histories) - guard !sorted.isEmpty else { return nil } - return (accountKey, sorted) - }) - output[provider.rawValue] = ProviderHistoryFile( - preferredAccountKey: buckets.preferredAccountKey, - unscoped: Self.sortedHistories(buckets.unscoped), - accounts: accounts) - } - - let payload = PlanUtilizationHistoryFile( - version: Self.schemaVersion, - providers: persistedProviders) - + guard let directoryURL = self.directoryURL else { return } do { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let data = try encoder.encode(payload) try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), + at: directoryURL, withIntermediateDirectories: true) - try data.write(to: url, options: Data.WritingOptions.atomic) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + + for provider in UsageProvider.allCases { + let fileURL = self.providerFileURL(for: provider) + let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets() + guard !buckets.isEmpty else { + try? FileManager.default.removeItem(at: fileURL) + continue + } + + let payload = ProviderHistoryDocument( + version: Self.providerSchemaVersion, + preferredAccountKey: buckets.preferredAccountKey, + unscoped: Self.sortedHistories(buckets.unscoped), + accounts: Self.sortedAccounts(buckets.accounts)) + let data = try encoder.encode(payload) + try data.write(to: fileURL, options: Data.WritingOptions.atomic) + } } catch { // Best-effort persistence only. } } + private func loadProviderFiles() -> [UsageProvider: PlanUtilizationHistoryBuckets] { + guard self.directoryURL != nil else { return [:] } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] + + for provider in UsageProvider.allCases { + let fileURL = self.providerFileURL(for: provider) + guard FileManager.default.fileExists(atPath: fileURL.path) else { continue } + guard let data = try? Data(contentsOf: fileURL), + let decoded = try? decoder.decode(ProviderHistoryDocument.self, from: data) + else { + continue + } + + let history = ProviderHistoryFile( + preferredAccountKey: decoded.preferredAccountKey, + unscoped: decoded.unscoped, + accounts: decoded.accounts) + output[provider] = Self.decodeProvider(history) + } + + return output + } + private static func decodeProviders( _ providers: [String: ProviderHistoryFile]) -> [UsageProvider: PlanUtilizationHistoryBuckets] { var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] for (rawProvider, providerHistory) in providers { guard let provider = UsageProvider(rawValue: rawProvider) else { continue } - output[provider] = PlanUtilizationHistoryBuckets( - preferredAccountKey: providerHistory.preferredAccountKey, - unscoped: Self.sortedHistories(providerHistory.unscoped), - accounts: Dictionary( - uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, histories in - let sorted = Self.sortedHistories(histories) - guard !sorted.isEmpty else { return nil } - return (accountKey, sorted) - })) + output[provider] = Self.decodeProvider(providerHistory) } return output } + private static func decodeProvider(_ providerHistory: ProviderHistoryFile) -> PlanUtilizationHistoryBuckets { + PlanUtilizationHistoryBuckets( + preferredAccountKey: providerHistory.preferredAccountKey, + unscoped: self.sortedHistories(providerHistory.unscoped), + accounts: Dictionary( + uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, histories in + let sorted = Self.sortedHistories(histories) + guard !sorted.isEmpty else { return nil } + return (accountKey, sorted) + })) + } + + private static func sortedAccounts( + _ accounts: [String: [PlanUtilizationSeriesHistory]]) -> [String: [PlanUtilizationSeriesHistory]] + { + Dictionary( + uniqueKeysWithValues: accounts.compactMap { accountKey, histories in + let sorted = Self.sortedHistories(histories) + guard !sorted.isEmpty else { return nil } + return (accountKey, sorted) + }) + } + private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] { histories.sorted { lhs, rhs in if lhs.windowMinutes != rhs.windowMinutes { @@ -184,26 +217,33 @@ struct PlanUtilizationHistoryStore: Sendable { } } - private static func defaultFileURL() -> URL? { + private static func defaultDirectoryURL() -> URL? { guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true) - return dir.appendingPathComponent("plan-utilization-history.json") + return dir.appendingPathComponent("history", isDirectory: true) + } + + private func providerFileURL(for provider: UsageProvider) -> URL { + let directoryURL = self.directoryURL ?? URL(fileURLWithPath: "/dev/null", isDirectory: true) + return directoryURL.appendingPathComponent("\(provider.rawValue).json", isDirectory: false) } } -extension PlanUtilizationHistoryFile { +extension ProviderHistoryDocument { init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let version = try container.decode(Int.self, forKey: .version) - guard version == PlanUtilizationHistoryStore.schemaVersion else { + guard version == PlanUtilizationHistoryStore.providerSchemaVersion else { throw DecodingError.dataCorruptedError( forKey: .version, in: container, - debugDescription: "Unsupported plan utilization history schema version \(version)") + debugDescription: "Unsupported provider history schema version \(version)") } self.version = version - self.providers = try container.decode([String: ProviderHistoryFile].self, forKey: .providers) + self.preferredAccountKey = try container.decodeIfPresent(String.self, forKey: .preferredAccountKey) + self.unscoped = try container.decode([PlanUtilizationSeriesHistory].self, forKey: .unscoped) + self.accounts = try container.decode([String: [PlanUtilizationSeriesHistory]].self, forKey: .accounts) } } diff --git a/Tests/CodexBarTests/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index cf0ee980e..47d103d83 100644 --- a/Tests/CodexBarTests/TestStores.swift +++ b/Tests/CodexBarTests/TestStores.swift @@ -138,9 +138,9 @@ func testPlanUtilizationHistoryStore(suiteName: String, reset: Bool = true) -> P let base = FileManager.default.temporaryDirectory .appendingPathComponent("codexbar-tests", isDirectory: true) .appendingPathComponent(sanitized, isDirectory: true) - let url = base.appendingPathComponent("plan-utilization-history.json") + let url = base.appendingPathComponent("history", isDirectory: true) if reset { try? FileManager.default.removeItem(at: url) } - return PlanUtilizationHistoryStore(fileURL: url) + return PlanUtilizationHistoryStore(directoryURL: url) } diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 21f29295d..f6c9e1cf0 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -602,26 +602,23 @@ struct UsageStorePlanUtilizationTests { func runtimeDoesNotLoadUnsupportedPlanHistoryFile() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - let url = root + let directoryURL = root .appendingPathComponent("com.steipete.codexbar", isDirectory: true) - .appendingPathComponent("plan-utilization-history.json") - let store = PlanUtilizationHistoryStore(fileURL: url) + .appendingPathComponent("history", isDirectory: true) + let providerURL = directoryURL.appendingPathComponent("codex.json") + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) try FileManager.default.createDirectory( - at: url.deletingLastPathComponent(), + at: directoryURL, withIntermediateDirectories: true) let unsupportedJSON = """ { "version": 999, - "providers": { - "codex": { - "unscoped": [], - "accounts": {} - } - } + "unscoped": [], + "accounts": {} } """ - try Data(unsupportedJSON.utf8).write(to: url, options: Data.WritingOptions.atomic) + try Data(unsupportedJSON.utf8).write(to: providerURL, options: Data.WritingOptions.atomic) let loaded = store.load() #expect(loaded.isEmpty) @@ -631,10 +628,10 @@ struct UsageStorePlanUtilizationTests { func storeRoundTripsAccountBucketsWithSeriesEntries() { let root = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - let url = root + let directoryURL = root .appendingPathComponent("com.steipete.codexbar", isDirectory: true) - .appendingPathComponent("plan-utilization-history.json") - let store = PlanUtilizationHistoryStore(fileURL: url) + .appendingPathComponent("history", isDirectory: true) + let store = PlanUtilizationHistoryStore(directoryURL: directoryURL) let buckets = PlanUtilizationHistoryBuckets( preferredAccountKey: "alice", unscoped: [ @@ -673,7 +670,7 @@ extension UsageStorePlanUtilizationTests { let temporaryRoot = FileManager.default.temporaryDirectory.standardizedFileURL.path precondition(configStore.fileURL.standardizedFileURL.path.hasPrefix(temporaryRoot)) precondition(configStore.fileURL.standardizedFileURL != CodexBarConfigStore.defaultURL().standardizedFileURL) - if let historyURL = planHistoryStore.fileURL?.standardizedFileURL { + if let historyURL = planHistoryStore.directoryURL?.standardizedFileURL { precondition(historyURL.path.hasPrefix(temporaryRoot)) } let isolatedSettings = SettingsStore( From 18b5a49532986612a83772d95cec7adf9a7b4afa Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 14:38:05 +0800 Subject: [PATCH 29/32] Prefer reset-aware plan utilization entries during coalescing - Replace provisional hourly peak when a later sample adds first known `resetsAt` - Preserve promoted reset boundary when later same-hour samples update usage - Add regression tests for reset-boundary promotion and coalescing behavior --- .../CodexBar/UsageStore+PlanUtilization.swift | 4 ++ ...ePlanUtilizationResetCoalescingTests.swift | 39 +++++++++++++++++++ .../UsageStorePlanUtilizationTests.swift | 31 +++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 26330ec2a..9e8994c97 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -329,6 +329,10 @@ extension UsageStore { existingPeak: PlanUtilizationHistoryEntry, observation: PlanUtilizationHistoryEntry) -> PlanUtilizationHistoryEntry { + if existingPeak.resetsAt == nil, observation.resetsAt != nil { + return observation + } + let hasHigherUsage = observation.usedPercent > existingPeak.usedPercent let tiesUsageAndIsMoreRecent = observation.usedPercent == existingPeak.usedPercent && observation.capturedAt >= existingPeak.capturedAt diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift index 30601f0b7..155c9fbfb 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -33,6 +33,45 @@ struct UsageStorePlanUtilizationResetCoalescingTests { #expect(updated[0].resetsAt == incoming.resetsAt) } + @Test + func sameHourLaterHigherUsageWithoutResetMetadataKeepsPromotedResetBoundary() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 9))) + let first = planEntry( + at: hourStart.addingTimeInterval(10 * 60), + usedPercent: 40) + let second = planEntry( + at: hourStart.addingTimeInterval(25 * 60), + usedPercent: 8, + resetsAt: hourStart.addingTimeInterval(30 * 60)) + let third = planEntry( + at: hourStart.addingTimeInterval(50 * 60), + usedPercent: 22) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let promoted = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: promoted, + entry: third)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == third.capturedAt) + #expect(updated[0].usedPercent == third.usedPercent) + #expect(updated[0].resetsAt == second.resetsAt) + } + @Test func sameHourZeroUsageWithDriftingResetCoalescesToLatestEntry() throws { let calendar = Calendar(identifier: .gregorian) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index f6c9e1cf0..67abca53a 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -62,6 +62,37 @@ struct UsageStorePlanUtilizationTests { #expect(updated[1] == second) } + @Test + func firstKnownResetBoundaryWithinHourReplacesEarlierProvisionalPeakEvenWhenUsageDrops() throws { + let calendar = Calendar(identifier: .gregorian) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + day: 17, + hour: 10))) + let first = planEntry( + at: hourStart.addingTimeInterval(5 * 60), + usedPercent: 82, + resetsAt: nil) + let second = planEntry( + at: hourStart.addingTimeInterval(35 * 60), + usedPercent: 4, + resetsAt: hourStart.addingTimeInterval(5 * 60 * 60)) + + let initial = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 1) + #expect(updated[0] == second) + } + @Test func trimsEntryHistoryToRetentionLimit() throws { let maxSamples = UsageStore._planUtilizationMaxSamplesForTesting From b85a6242c1961f9e2b5ad74a9d83478a0ab2ea19 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 14:56:45 +0800 Subject: [PATCH 30/32] Remove history migration script --- .../migrate_plan_utilization_history.swift | 205 ------------------ 1 file changed, 205 deletions(-) delete mode 100755 Scripts/migrate_plan_utilization_history.swift diff --git a/Scripts/migrate_plan_utilization_history.swift b/Scripts/migrate_plan_utilization_history.swift deleted file mode 100755 index 40ed366bc..000000000 --- a/Scripts/migrate_plan_utilization_history.swift +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env swift - -import Darwin -import Foundation - -private struct ProviderHistoryFile: Codable { - let preferredAccountKey: String? - let unscoped: [PlanUtilizationSeriesHistory] - let accounts: [String: [PlanUtilizationSeriesHistory]] -} - -private struct LegacyPlanUtilizationHistoryFile: Codable { - let version: Int - let providers: [String: ProviderHistoryFile] -} - -private struct ProviderHistoryDocument: Codable { - let version: Int - let preferredAccountKey: String? - let unscoped: [PlanUtilizationSeriesHistory] - let accounts: [String: [PlanUtilizationSeriesHistory]] -} - -private struct PlanUtilizationSeriesHistory: Codable { - let name: String - let windowMinutes: Int - let entries: [PlanUtilizationHistoryEntry] -} - -private struct PlanUtilizationHistoryEntry: Codable { - let capturedAt: Date - let usedPercent: Double - let resetsAt: Date? -} - -private enum MigrationError: Error, CustomStringConvertible { - case invalidArguments(String) - case unsupportedLegacyVersion(Int) - case providerFilesAlreadyExist(URL) - - var description: String { - switch self { - case let .invalidArguments(message): - return message - case let .unsupportedLegacyVersion(version): - return "Unsupported legacy history schema version \(version)." - case let .providerFilesAlreadyExist(url): - return "Provider history files already exist in \(url.path). Re-run with --force to overwrite." - } - } -} - -private struct Arguments { - let rootURL: URL - let force: Bool - - static func parse() throws -> Arguments { - var rootURL = Self.defaultRootURL() - var force = false - var index = 1 - - while index < CommandLine.arguments.count { - let argument = CommandLine.arguments[index] - switch argument { - case "--root": - let nextIndex = index + 1 - guard nextIndex < CommandLine.arguments.count else { - throw MigrationError.invalidArguments("Missing path after --root.") - } - rootURL = URL(fileURLWithPath: CommandLine.arguments[nextIndex], isDirectory: true) - index += 2 - case "--force": - force = true - index += 1 - case "--help", "-h": - Self.printUsage() - exit(0) - default: - throw MigrationError.invalidArguments("Unknown argument: \(argument)") - } - } - - return Arguments(rootURL: rootURL, force: force) - } - - private static func defaultRootURL() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support", isDirectory: true) - return base.appendingPathComponent("com.steipete.codexbar", isDirectory: true) - } - - private static func printUsage() { - let executable = URL(fileURLWithPath: CommandLine.arguments[0]).lastPathComponent - print("Usage: \(executable) [--root ] [--force]") - } -} - -private let legacySchemaVersion = 6 -private let providerSchemaVersion = 1 - -private func loadLegacyHistory(from legacyURL: URL) throws -> LegacyPlanUtilizationHistoryFile { - let data = try Data(contentsOf: legacyURL) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - let decoded = try decoder.decode(LegacyPlanUtilizationHistoryFile.self, from: data) - guard decoded.version == legacySchemaVersion else { - throw MigrationError.unsupportedLegacyVersion(decoded.version) - } - return decoded -} - -private func providerHistoryURLs(in directoryURL: URL) -> [URL] { - guard let contents = try? FileManager.default.contentsOfDirectory( - at: directoryURL, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles]) - else { - return [] - } - - return contents.filter { $0.pathExtension == "json" } -} - -private func archiveURL(for legacyURL: URL) -> URL { - let directoryURL = legacyURL.deletingLastPathComponent() - let basename = legacyURL.deletingPathExtension().lastPathComponent - let archiveBaseURL = directoryURL.appendingPathComponent("\(basename).legacy", isDirectory: false) - let archiveURL = archiveBaseURL.appendingPathExtension("json") - if !FileManager.default.fileExists(atPath: archiveURL.path) { - return archiveURL - } - - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.timeZone = TimeZone(secondsFromGMT: 0) - formatter.dateFormat = "yyyyMMdd-HHmmss" - let timestamp = formatter.string(from: Date()) - return directoryURL - .appendingPathComponent("\(basename).legacy-\(timestamp)", isDirectory: false) - .appendingPathExtension("json") -} - -private func migrate(arguments: Arguments) throws { - let rootURL = arguments.rootURL - let legacyURL = rootURL.appendingPathComponent("plan-utilization-history.json", isDirectory: false) - let historyDirectoryURL = rootURL.appendingPathComponent("history", isDirectory: true) - - guard FileManager.default.fileExists(atPath: legacyURL.path) else { - print("No legacy plan utilization history found at \(legacyURL.path).") - return - } - - let existingProviderFiles = providerHistoryURLs(in: historyDirectoryURL) - if !existingProviderFiles.isEmpty, !arguments.force { - throw MigrationError.providerFilesAlreadyExist(historyDirectoryURL) - } - - let legacy = try loadLegacyHistory(from: legacyURL) - try FileManager.default.createDirectory(at: historyDirectoryURL, withIntermediateDirectories: true) - if arguments.force { - for url in existingProviderFiles { - try? FileManager.default.removeItem(at: url) - } - } - - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - - var migratedProviders: [String] = [] - for (provider, history) in legacy.providers.sorted(by: { $0.key < $1.key }) { - let payload = ProviderHistoryDocument( - version: providerSchemaVersion, - preferredAccountKey: history.preferredAccountKey, - unscoped: history.unscoped, - accounts: history.accounts) - let data = try encoder.encode(payload) - let providerURL = historyDirectoryURL.appendingPathComponent("\(provider).json", isDirectory: false) - try data.write(to: providerURL, options: [.atomic]) - migratedProviders.append(provider) - } - - let archivedLegacyURL = archiveURL(for: legacyURL) - try FileManager.default.moveItem(at: legacyURL, to: archivedLegacyURL) - - if migratedProviders.isEmpty { - print("Migrated empty legacy history. Archived legacy file to \(archivedLegacyURL.path).") - } else { - print("Migrated providers: \(migratedProviders.joined(separator: ", ")).") - print("Archived legacy file to \(archivedLegacyURL.path).") - print("Provider history directory: \(historyDirectoryURL.path)") - } -} - -do { - let arguments = try Arguments.parse() - try migrate(arguments: arguments) -} catch let error as MigrationError { - fputs("ERROR: \(error.description)\n", stderr) - exit(1) -} catch { - fputs("ERROR: \(error)\n", stderr) - exit(1) -} From 2216520bc28d9856a239d866e60c9e6f43a9a4db Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 15:26:55 +0800 Subject: [PATCH 31/32] Refine usage history submenu card and simplify submenu wiring - Reuse `makeMenuCardItem` for Subscription Utilization with configurable chevron alignment/padding - Switch Credits/Usage/Cost history entries to plain `NSMenuItem` submenu rows - Simplify usage breakdown submenu creation and add a dedicated hosting view for usage history --- .../CodexBar/StatusItemController+Menu.swift | 71 +++++++++---------- ...tatusItemController+UsageHistoryMenu.swift | 25 ++++++- 2 files changed, 58 insertions(+), 38 deletions(-) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index de6d01177..6fefd96c6 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -826,11 +826,13 @@ extension StatusItemController { } } - private func makeMenuCardItem( + func makeMenuCardItem( _ view: some View, id: String, width: CGFloat, submenu: NSMenu? = nil, + submenuIndicatorAlignment: Alignment = .topTrailing, + submenuIndicatorTopPadding: CGFloat = 8, onClick: (() -> Void)? = nil) -> NSMenuItem { if !Self.menuCardRenderingEnabled { @@ -848,7 +850,9 @@ extension StatusItemController { let highlightState = MenuCardHighlightState() let wrapped = MenuCardSectionContainerView( highlightState: highlightState, - showsSubmenuIndicator: submenu != nil) + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) { view } @@ -1076,7 +1080,7 @@ extension StatusItemController { var isHighlighted = false } - final class MenuHostingView: NSHostingView { + private final class MenuHostingView: NSHostingView { override var allowsVibrancy: Bool { true } @@ -1143,15 +1147,21 @@ extension StatusItemController { private struct MenuCardSectionContainerView: View { @Bindable var highlightState: MenuCardHighlightState let showsSubmenuIndicator: Bool + let submenuIndicatorAlignment: Alignment + let submenuIndicatorTopPadding: CGFloat let content: Content init( highlightState: MenuCardHighlightState, showsSubmenuIndicator: Bool, + submenuIndicatorAlignment: Alignment, + submenuIndicatorTopPadding: CGFloat, @ViewBuilder content: () -> Content) { self.highlightState = highlightState self.showsSubmenuIndicator = showsSubmenuIndicator + self.submenuIndicatorAlignment = submenuIndicatorAlignment + self.submenuIndicatorTopPadding = submenuIndicatorTopPadding self.content = content() } @@ -1167,12 +1177,12 @@ extension StatusItemController { .padding(.vertical, 2) } } - .overlay(alignment: .topTrailing) { + .overlay(alignment: self.submenuIndicatorAlignment) { if self.showsSubmenuIndicator { Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) .foregroundStyle(MenuHighlightStyle.secondary(self.highlightState.isHighlighted)) - .padding(.top, 8) + .padding(.top, self.submenuIndicatorTopPadding) .padding(.trailing, 10) } } @@ -1190,43 +1200,33 @@ extension StatusItemController { return item } - func makeFixedWidthSubmenuItem(title: String, submenu: NSMenu, width: CGFloat) -> NSMenuItem { - self.makeMenuCardItem( - HStack(spacing: 0) { - Text(title) - .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 14) - .padding(.trailing, 28) - .padding(.vertical, 8) - }, - id: "submenu-\(title)", - width: width, - submenu: submenu) - } - @discardableResult private func addCreditsHistorySubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeCreditsHistorySubmenu() else { return false } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) - menu.addItem(self.makeFixedWidthSubmenuItem(title: "Credits history", submenu: submenu, width: width)) + let item = NSMenuItem(title: "Credits history", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) return true } @discardableResult private func addUsageBreakdownSubmenu(to menu: NSMenu) -> Bool { guard let submenu = self.makeUsageBreakdownSubmenu() else { return false } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) - menu.addItem(self.makeFixedWidthSubmenuItem(title: "Usage breakdown", submenu: submenu, width: width)) + let item = NSMenuItem(title: "Usage breakdown", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) return true } @discardableResult private func addCostHistorySubmenu(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeCostHistorySubmenu(provider: provider) else { return false } - let width = self.menuCardWidth(for: self.store.enabledProvidersForDisplay(), menu: menu) - menu.addItem(self.makeFixedWidthSubmenuItem(title: "Usage history (30 days)", submenu: submenu, width: width)) + let item = NSMenuItem(title: "Usage history (30 days)", action: nil, keyEquivalent: "") + item.isEnabled = true + item.submenu = submenu + menu.addItem(item) return true } @@ -1281,23 +1281,22 @@ extension StatusItemController { } private func makeUsageBreakdownSubmenu() -> NSMenu? { + let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] let width = Self.menuCardBaseWidth - let submenu = NSMenu() - submenu.delegate = self - return self.appendUsageBreakdownChartItem(to: submenu, width: width) ? submenu : nil - } + guard !breakdown.isEmpty else { return nil } - private func appendUsageBreakdownChartItem(to submenu: NSMenu, width: CGFloat) -> Bool { - let breakdown = self.store.openAIDashboard?.usageBreakdown ?? [] - guard !breakdown.isEmpty else { return false } if !Self.menuCardRenderingEnabled { + let submenu = NSMenu() + submenu.delegate = self let chartItem = NSMenuItem() chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) - return true + return submenu } + let submenu = NSMenu() + submenu.delegate = self let chartView = UsageBreakdownChartMenuView(breakdown: breakdown, width: width) let hosting = MenuHostingView(rootView: chartView) // Use NSHostingController for efficient size calculation without multiple layout passes @@ -1310,7 +1309,7 @@ extension StatusItemController { chartItem.isEnabled = false chartItem.representedObject = "usageBreakdownChart" submenu.addItem(chartItem) - return true + return submenu } private func makeCreditsHistorySubmenu() -> NSMenu? { diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index 19c3c4c36..e6631b84f 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -2,12 +2,33 @@ import AppKit import CodexBarCore import SwiftUI +private final class UsageHistoryMenuHostingView: NSHostingView { + override var allowsVibrancy: Bool { + true + } +} + extension StatusItemController { @discardableResult func addUsageHistoryMenuItemIfNeeded(to menu: NSMenu, provider: UsageProvider) -> Bool { guard let submenu = self.makeUsageHistorySubmenu(provider: provider) else { return false } let width: CGFloat = 310 - menu.addItem(self.makeFixedWidthSubmenuItem(title: "Subscription Utilization", submenu: submenu, width: width)) + let item = self.makeMenuCardItem( + HStack(spacing: 0) { + Text("Subscription Utilization") + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 14) + .padding(.trailing, 28) + .padding(.vertical, 8) + }, + id: "usageHistorySubmenu", + width: width, + submenu: submenu, + submenuIndicatorAlignment: .trailing, + submenuIndicatorTopPadding: 0) + menu.addItem(item) return true } @@ -43,7 +64,7 @@ extension StatusItemController { snapshot: snapshot, width: width, isRefreshing: isRefreshing) - let hosting = MenuHostingView(rootView: chartView) + let hosting = UsageHistoryMenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) From c4b0ca08ebef971eb8eedd3975002cef6f3a1d05 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 20 Mar 2026 15:57:03 +0800 Subject: [PATCH 32/32] Hide usage history menu while provider data is still loading - Remove chart-level "Refreshing..." empty state in favor of series-specific no-data messaging - Hide plan utilization history submenu during refresh when no snapshot is available - Add tests for menu visibility and updated empty-state behavior --- .../PlanUtilizationHistoryChartMenuView.swift | 16 ++---- .../CodexBar/StatusItemController+Menu.swift | 2 +- ...tatusItemController+UsageHistoryMenu.swift | 8 ++- .../CodexBar/UsageStore+PlanUtilization.swift | 35 ++++--------- .../UsageStorePlanUtilizationTests.swift | 50 ++++++++++++------- 5 files changed, 51 insertions(+), 60 deletions(-) diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index c3da7e9fa..23eae432f 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -67,7 +67,6 @@ struct PlanUtilizationHistoryChartMenuView: View { private let histories: [PlanUtilizationSeriesHistory] private let snapshot: UsageSnapshot? private let width: CGFloat - private let isRefreshing: Bool @State private var selectedSeriesID: String? @State private var selectedPointID: String? @@ -76,14 +75,12 @@ struct PlanUtilizationHistoryChartMenuView: View { provider: UsageProvider, histories: [PlanUtilizationSeriesHistory], snapshot: UsageSnapshot? = nil, - width: CGFloat, - isRefreshing: Bool = false) + width: CGFloat) { self.provider = provider self.histories = histories self.snapshot = snapshot self.width = width - self.isRefreshing = isRefreshing } var body: some View { @@ -118,7 +115,7 @@ struct PlanUtilizationHistoryChartMenuView: View { if model.points.isEmpty { ZStack { - Text(Self.emptyStateText(title: effectiveSelectedSeries?.title, isRefreshing: self.isRefreshing)) + Text(Self.emptyStateText(title: effectiveSelectedSeries?.title)) .font(.footnote) .foregroundStyle(.secondary) } @@ -622,10 +619,7 @@ struct PlanUtilizationHistoryChartMenuView: View { } } - private nonisolated static func emptyStateText(title: String?, isRefreshing: Bool) -> String { - if isRefreshing { - return "Refreshing..." - } + private nonisolated static func emptyStateText(title: String?) -> String { if let title { return "No \(title.lowercased()) utilization data yet." } @@ -688,8 +682,8 @@ struct PlanUtilizationHistoryChartMenuView: View { return self.detailLine(point: model.points.last, windowMinutes: selectedSeries?.history.windowMinutes ?? 0) } - nonisolated static func _emptyStateTextForTesting(title: String?, isRefreshing: Bool) -> String { - self.emptyStateText(title: title, isRefreshing: isRefreshing) + nonisolated static func _emptyStateTextForTesting(title: String?) -> String { + self.emptyStateText(title: title) } #endif diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 6fefd96c6..484be310a 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1473,7 +1473,7 @@ extension StatusItemController { tokenSnapshot: tokenSnapshot, tokenError: tokenError, account: self.account, - isRefreshing: self.store.isRefreshing, + isRefreshing: self.store.shouldShowRefreshingMenuCard(for: target), lastError: errorOverride ?? self.store.error(for: target), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, diff --git a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift index e6631b84f..da0f0a5db 100644 --- a/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -34,6 +34,7 @@ extension StatusItemController { private func makeUsageHistorySubmenu(provider: UsageProvider) -> NSMenu? { guard provider == .codex || provider == .claude else { return nil } + guard !self.store.shouldHidePlanUtilizationMenuItem(for: provider) else { return nil } let width: CGFloat = 310 let submenu = NSMenu() submenu.delegate = self @@ -45,10 +46,8 @@ extension StatusItemController { provider: UsageProvider, width: CGFloat) -> Bool { - let presentation = self.store.planUtilizationHistoryPresentation(for: provider) - let histories = presentation.histories + let histories = self.store.planUtilizationHistory(for: provider) let snapshot = self.store.snapshot(for: provider) - let isRefreshing = presentation.isRefreshing if !Self.menuCardRenderingEnabled { let chartItem = NSMenuItem() @@ -62,8 +61,7 @@ extension StatusItemController { provider: provider, histories: histories, snapshot: snapshot, - width: width, - isRefreshing: isRefreshing) + width: width) let hosting = UsageHistoryMenuHostingView(rootView: chartView) let controller = NSHostingController(rootView: chartView) let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude)) diff --git a/Sources/CodexBar/UsageStore+PlanUtilization.swift b/Sources/CodexBar/UsageStore+PlanUtilization.swift index 9e8994c97..3a0cbc604 100644 --- a/Sources/CodexBar/UsageStore+PlanUtilization.swift +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -3,11 +3,6 @@ import CryptoKit import Foundation extension UsageStore { - struct PlanUtilizationHistoryPresentation: Equatable { - let histories: [PlanUtilizationSeriesHistory] - let isRefreshing: Bool - } - private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 private nonisolated static let planUtilizationResetEquivalenceToleranceSeconds: TimeInterval = 2 * 60 private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 @@ -41,14 +36,16 @@ extension UsageStore { return providerBuckets.histories(for: accountKey) } - func planUtilizationHistoryPresentation(for provider: UsageProvider) -> PlanUtilizationHistoryPresentation { - let isRefreshing = self.shouldShowPlanUtilizationRefreshingState(for: provider) - if isRefreshing { - return PlanUtilizationHistoryPresentation(histories: [], isRefreshing: true) - } - return PlanUtilizationHistoryPresentation( - histories: self.planUtilizationHistory(for: provider), - isRefreshing: false) + func shouldShowRefreshingMenuCard(for provider: UsageProvider) -> Bool { + let isRefreshing = self.isRefreshing || self.refreshingProviders.contains(provider) + return isRefreshing + && self.snapshots[provider] == nil + && self.error(for: provider) == nil + } + + func shouldHidePlanUtilizationMenuItem(for provider: UsageProvider) -> Bool { + guard provider == .codex || provider == .claude else { return true } + return self.shouldShowRefreshingMenuCard(for: provider) } func recordPlanUtilizationHistorySample( @@ -426,17 +423,7 @@ extension UsageStore { } private func shouldDeferClaudePlanUtilizationHistory(provider: UsageProvider) -> Bool { - provider == .claude && self.shouldShowPlanUtilizationRefreshingState(for: .claude) - } - - func shouldShowPlanUtilizationRefreshingState(for provider: UsageProvider) -> Bool { - guard self.refreshingProviders.contains(provider) else { return false } - - if provider != .claude { - return true - } - - return self.snapshots[.claude] == nil && self.error(for: .claude) == nil + provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) } private func resolvePlanUtilizationAccountKey( diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 67abca53a..130ec2cf6 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -433,14 +433,14 @@ struct UsageStorePlanUtilizationTests { } @Test - func chartEmptyStateShowsRefreshingWhileLoading() { - let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Session", isRefreshing: true) - #expect(text == "Refreshing...") + func chartEmptyStateShowsSeriesSpecificMessage() { + let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Session") + #expect(text == "No session utilization data yet.") } @Test func chartEmptyStateShowsSeriesSpecificMessageWhenNotRefreshing() { - let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Weekly", isRefreshing: false) + let text = PlanUtilizationHistoryChartMenuView._emptyStateTextForTesting(title: "Weekly") #expect(text == "No weekly utilization data yet.") } @@ -485,7 +485,7 @@ struct UsageStorePlanUtilizationTests { @MainActor @Test - func claudeHistoryPresentationShowsRefreshingWhileRefreshingWithoutCurrentSnapshot() throws { + func planUtilizationMenuHidesWhileRefreshingWithoutCurrentSnapshot() throws { let store = Self.makeStore() let claudeKey = try #require( UsageStore._planUtilizationAccountKeyForTesting( @@ -502,32 +502,44 @@ struct UsageStorePlanUtilizationTests { store.refreshingProviders.insert(.claude) store._setSnapshotForTesting(nil, provider: .claude) - let presentation = store.planUtilizationHistoryPresentation(for: .claude) - #expect(presentation.isRefreshing) - #expect(presentation.histories.isEmpty) + #expect(store.shouldShowRefreshingMenuCard(for: .claude)) + #expect(store.shouldHidePlanUtilizationMenuItem(for: .claude)) } @MainActor @Test - func claudeHistoryPresentationShowsStoredHistoryWhenNotRefreshing() throws { + func planUtilizationMenuStaysVisibleWithStoredSnapshotEvenDuringRefresh() throws { let store = Self.makeStore() - let claudeKey = try #require( + let codexSnapshot = Self.makeSnapshot(provider: .codex, email: "alice@example.com") + let codexKey = try #require( UsageStore._planUtilizationAccountKeyForTesting( - provider: .claude, - snapshot: Self.makeSnapshot(provider: .claude, email: "alice@example.com"))) + provider: .codex, + snapshot: codexSnapshot)) let weekly = planSeries(name: .weekly, windowMinutes: 10080, entries: [ planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 64), ]) - store.planUtilizationHistory[.claude] = PlanUtilizationHistoryBuckets( - preferredAccountKey: claudeKey, + store.planUtilizationHistory[.codex] = PlanUtilizationHistoryBuckets( + preferredAccountKey: codexKey, accounts: [ - claudeKey: [weekly], + codexKey: [weekly], ]) - store._setSnapshotForTesting(nil, provider: .claude) + store.refreshingProviders.insert(.codex) + store._setSnapshotForTesting(codexSnapshot, provider: .codex) + + #expect(!store.shouldShowRefreshingMenuCard(for: .codex)) + #expect(!store.shouldHidePlanUtilizationMenuItem(for: .codex)) + #expect(store.planUtilizationHistory(for: .codex) == [weekly]) + } + + @MainActor + @Test + func codexPlanUtilizationMenuHidesDuringProviderOnlyRefreshWithoutSnapshot() { + let store = Self.makeStore() + store.refreshingProviders.insert(.codex) + store._setSnapshotForTesting(nil, provider: .codex) - let presentation = store.planUtilizationHistoryPresentation(for: .claude) - #expect(!presentation.isRefreshing) - #expect(presentation.histories == [weekly]) + #expect(store.shouldShowRefreshingMenuCard(for: .codex)) + #expect(store.shouldHidePlanUtilizationMenuItem(for: .codex)) } @MainActor