diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift new file mode 100644 index 000000000..23eae432f --- /dev/null +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -0,0 +1,805 @@ +import Charts +import CodexBarCore +import SwiftUI + +@MainActor +struct PlanUtilizationHistoryChartMenuView: View { + private enum Layout { + static let chartHeight: CGFloat = 130 + 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 struct SeriesSelection: Hashable { + let name: PlanUtilizationSeriesName + let windowMinutes: Int + + var id: String { + "\(self.name.rawValue):\(self.windowMinutes)" + } + } + + private struct VisibleSeries: Identifiable, Equatable { + let selection: SeriesSelection + let title: String + let history: PlanUtilizationSeriesHistory + + var id: String { + self.selection.id + } + } + + private struct EntryPointAccumulator { + let effectiveBoundaryDate: Date + let displayBoundaryDate: Date + let observedAt: Date + let usedPercent: Double + let hasObservedResetBoundary: Bool + } + + private struct ResetBoundaryLattice { + let referenceBoundaryDate: Date + let windowInterval: TimeInterval + } + + private struct Point: Identifiable { + let id: String + 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 histories: [PlanUtilizationSeriesHistory] + private let snapshot: UsageSnapshot? + private let width: CGFloat + + @State private var selectedSeriesID: String? + @State private var selectedPointID: String? + + init( + provider: UsageProvider, + histories: [PlanUtilizationSeriesHistory], + snapshot: UsageSnapshot? = nil, + width: CGFloat) + { + self.provider = provider + self.histories = histories + self.snapshot = snapshot + self.width = width + } + + var body: some View { + 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( + history: effectiveSelectedSeries?.history, + provider: self.provider, + referenceDate: Date()) + + VStack(alignment: .leading, spacing: 10) { + 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() + } + .labelsHidden() + .pickerStyle(.segmented) + } + + if model.points.isEmpty { + ZStack { + Text(Self.emptyStateText(title: effectiveSelectedSeries?.title)) + .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] { + let isTrailingFullChartLabel = index == model.points.last?.index + && model.points.count == Layout.maxPoints + Self.axisLabel( + for: point, + windowMinutes: effectiveSelectedSeries?.history.windowMinutes ?? 0, + isTrailingFullChartLabel: isTrailingFullChartLabel) + } + } + } + } + } + .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()) + } + } + + 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: 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 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 visibleSeriesNames( + provider: UsageProvider, + snapshot: UsageSnapshot?) -> Set? + { + guard let snapshot else { return nil } + + var names: Set = [] + if snapshot.primary != nil { + names.insert(.session) + } + if snapshot.secondary != nil { + names.insert(.weekly) + } + + if provider == .claude, + snapshot.tertiary != nil, + ProviderDescriptorRegistry.metadata[provider]?.supportsOpus == true + { + names.insert(.opus) + } + + return names + } + + private nonisolated static func makeModel( + history: PlanUtilizationSeriesHistory?, + provider: UsageProvider, + referenceDate: Date) -> Model + { + guard let history else { + return self.emptyModel(provider: provider) + } + + 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 + Point( + id: point.id, + index: offset, + date: point.date, + usedPercent: point.usedPercent, + isObserved: point.isObserved) + } + + 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: self.axisIndexes(points: points, windowMinutes: history.windowMinutes), + xDomain: self.xDomain(points: points), + pointsByID: pointsByID, + pointsByIndex: pointsByIndex, + barColor: barColor, + trackColor: trackColor) + } + + 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: nil, + pointsByID: [:], + pointsByIndex: [:], + barColor: barColor, + trackColor: trackColor) + } + + private nonisolated static func seriesPoints( + history: PlanUtilizationSeriesHistory, + referenceDate: Date) -> [Point] + { + 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 + } + + guard !strongestObservedPointByPeriod.isEmpty else { return [] } + + let sortedPeriodBoundaryDates = strongestObservedPointByPeriod.keys.sorted() + var points: [Point] = [] + var previousPeriodBoundaryDate: Date? + + 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) + } + } + + 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 + } + + 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 points + } + + private nonisolated static func observedPointCandidate( + for entry: PlanUtilizationHistoryEntry, + windowMinutes: Int, + resetBoundaryLattice: ResetBoundaryLattice?) -> EntryPointAccumulator + { + 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? + { + 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 normalizedBoundaryDate(_ date: Date) -> Date { + Date(timeIntervalSince1970: floor(date.timeIntervalSince1970)) + } + + private nonisolated static func effectivePeriodBoundaryDate( + for entry: PlanUtilizationHistoryEntry, + windowMinutes: Int, + rawResetBoundaryDate: Date?, + resetBoundaryLattice: ResetBoundaryLattice?) -> Date + { + 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 shouldPreferObservedPoint( + _ candidate: EntryPointAccumulator, + over existing: EntryPointAccumulator) -> Bool + { + if candidate.usedPercent != existing.usedPercent { + return candidate.usedPercent > existing.usedPercent + } + if candidate.hasObservedResetBoundary != existing.hasObservedResetBoundary { + return candidate.hasObservedResetBoundary + } + if candidate.displayBoundaryDate != existing.displayBoundaryDate { + return candidate.displayBoundaryDate > existing.displayBoundaryDate + } + return candidate.observedAt >= existing.observedAt + } + + private nonisolated static func currentPeriodBoundaryDate( + for referenceDate: Date, + windowMinutes: Int, + resetBoundaryLattice: ResetBoundaryLattice?) -> Date + { + if let resetBoundaryLattice { + return self.periodBoundaryDate( + containing: referenceDate, + resetBoundaryLattice: resetBoundaryLattice) + } + return self.syntheticBoundaryDate(for: referenceDate, windowMinutes: windowMinutes) + } + + private nonisolated static func closestPeriodBoundaryDate( + to rawBoundaryDate: Date, + resetBoundaryLattice: ResetBoundaryLattice) -> Date + { + let offset = rawBoundaryDate.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate) + let periodOffset = (offset / resetBoundaryLattice.windowInterval).rounded() + return resetBoundaryLattice.referenceBoundaryDate + .addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval) + } + + private nonisolated static func periodBoundaryDate( + containing capturedAt: Date, + resetBoundaryLattice: ResetBoundaryLattice) -> Date + { + let offset = capturedAt.timeIntervalSince(resetBoundaryLattice.referenceBoundaryDate) + let periodOffset = ceil(offset / resetBoundaryLattice.windowInterval) + return resetBoundaryLattice.referenceBoundaryDate + .addingTimeInterval(periodOffset * resetBoundaryLattice.windowInterval) + } + + 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) + } + + 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 xDomain(points: [Point]) -> ClosedRange? { + guard !points.isEmpty else { return nil } + return -0.5...(Double(Layout.maxPoints) - 0.5) + } + + 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) + } + + private nonisolated static func axisCandidateIndexes(points: [Point], windowMinutes: Int) -> [Int] { + if windowMinutes <= 300 { + return self.sessionAxisCandidateIndexes(points: points) + } + return points.map(\.index) + } + + 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] + + for point in points.dropFirst() { + if !calendar.isDate(point.date, inSameDayAs: previousPoint.date) { + rawIndexes.append(point.index) + } + previousPoint = point + } + + return rawIndexes + } + + private nonisolated static func proportionalAxisIndexes(points: [Point], candidateIndexes: [Int]) -> [Double] { + guard !points.isEmpty, !candidateIndexes.isEmpty else { return [] } + + 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)) + + if labelBudget == 1 { + return [Double(candidateIndexes[0])] + } + + let step = Double(candidateIndexes.count - 1) / Double(labelBudget - 1) + var selectedIndexes = (0.. 1, + let lastSelectedIndex = selectedIndexes.last, + lastSelectedIndex >= trailingLabelCutoff + { + selectedIndexes.removeLast() + } + + if points.count == Layout.maxPoints, + let lastVisibleIndex = points.last?.index, + !selectedIndexes.contains(lastVisibleIndex) + { + selectedIndexes.append(lastVisibleIndex) + } + + let deduplicated = Array(NSOrderedSet(array: selectedIndexes)) as? [Int] ?? selectedIndexes + return deduplicated.map(Double.init) + } + + @ViewBuilder + private static func axisLabel( + for point: Point, + windowMinutes: Int, + isTrailingFullChartLabel: Bool) -> some View + { + 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 axisFormat(windowMinutes: Int) -> Date.FormatStyle { + if windowMinutes <= 300 { + return .dateTime.month(.abbreviated).day() + } + return .dateTime.month(.abbreviated).day() + } + + private nonisolated static func seriesTitle( + name: PlanUtilizationSeriesName, + metadata: ProviderMetadata?) -> String + { + 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 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 seriesSortOrder(_ name: PlanUtilizationSeriesName) -> Int { + switch name { + case .session: + 0 + case .weekly: + 1 + case .opus: + 2 + default: + 100 + } + } + + private nonisolated static func emptyStateText(title: String?) -> String { + 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] + } + + nonisolated static func _modelSnapshotForTesting( + selectedSeriesRawValue: String? = nil, + histories: [PlanUtilizationSeriesHistory], + provider: UsageProvider, + snapshot: UsageSnapshot? = nil, + referenceDate: Date? = nil) -> ModelSnapshot + { + 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?) -> String { + self.emptyStateText(title: title) + } + #endif + + private func xValue(for index: Int) -> PlottableValue { + .value("Series", 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), + 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) { + RuleMark(x: self.xValue(for: selected.index)) + .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + } + + private func selectedPoint(model: Model) -> Point? { + guard let selectedPointID else { return nil } + return model.pointsByID[selectedPointID] + } + + private func detailLine(model: Model, windowMinutes: Int) -> String { + let activePoint = self.selectedPoint(model: model) ?? model.points.last + return Self.detailLine(point: activePoint, windowMinutes: windowMinutes) + } + + 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 + } + } +} + +extension PlanUtilizationHistoryChartMenuView { + private nonisolated static func detailLine(point: Point?, windowMinutes: Int) -> String { + guard let point else { + return "-" + } + + let dateLabel = self.detailDateLabel(for: point.date, windowMinutes: windowMinutes) + + let used = max(0, min(100, point.usedPercent)) + if !point.isObserved { + return "\(dateLabel): -" + } + let usedText = used.formatted(.number.precision(.fractionLength(0...1))) + return "\(dateLabel): \(usedText)% used" + } + + 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 new file mode 100644 index 000000000..1a3dfa2c8 --- /dev/null +++ b/Sources/CodexBar/PlanUtilizationHistoryStore.swift @@ -0,0 +1,249 @@ +import CodexBarCore +import Foundation + +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 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: [PlanUtilizationSeriesHistory] = [] + var accounts: [String: [PlanUtilizationSeriesHistory]] = [:] + + func histories(for accountKey: String?) -> [PlanUtilizationSeriesHistory] { + guard let accountKey, !accountKey.isEmpty else { return self.unscoped } + return self.accounts[accountKey] ?? [] + } + + mutating func setHistories(_ histories: [PlanUtilizationSeriesHistory], for accountKey: String?) { + let sorted = Self.sortedHistories(histories) + 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 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 ProviderHistoryFile: Codable, Sendable { + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] +} + +private struct ProviderHistoryDocument: Codable, Sendable { + let version: Int + let preferredAccountKey: String? + let unscoped: [PlanUtilizationSeriesHistory] + let accounts: [String: [PlanUtilizationSeriesHistory]] +} + +struct PlanUtilizationHistoryStore: Sendable { + fileprivate static let providerSchemaVersion = 1 + + let directoryURL: URL? + + init(directoryURL: URL? = Self.defaultDirectoryURL()) { + self.directoryURL = directoryURL + } + + static func defaultAppSupport() -> Self { + Self() + } + + func load() -> [UsageProvider: PlanUtilizationHistoryBuckets] { + self.loadProviderFiles() + } + + func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) { + guard let directoryURL = self.directoryURL else { return } + do { + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true) + 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] = 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 { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue + } + } + + 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("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 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.providerSchemaVersion else { + throw DecodingError.dataCorruptedError( + forKey: .version, + in: container, + debugDescription: "Unsupported provider history schema version \(version)") + } + self.version = version + 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/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift new file mode 100644 index 000000000..35b184897 --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift @@ -0,0 +1,104 @@ +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, + now: codexSnapshot.updatedAt) + } 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, + now: snapshot.updatedAt) + self.codexPlanHistoryBackfillTask = nil + } + } + + func cancelCodexPlanHistoryBackfill() { + self.codexPlanHistoryBackfillTask?.cancel() + self.codexPlanHistoryBackfillTask = nil + } +} diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 1d7c6e35d..484be310a 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -242,6 +242,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, width: menuWidth) } @@ -291,6 +294,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, width: menuWidth) } @@ -820,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 { @@ -842,7 +850,9 @@ extension StatusItemController { let highlightState = MenuCardHighlightState() let wrapped = MenuCardSectionContainerView( highlightState: highlightState, - showsSubmenuIndicator: submenu != nil) + showsSubmenuIndicator: submenu != nil, + submenuIndicatorAlignment: submenuIndicatorAlignment, + submenuIndicatorTopPadding: submenuIndicatorTopPadding) { view } @@ -1137,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() } @@ -1161,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) } } @@ -1370,6 +1386,7 @@ extension StatusItemController { "usageBreakdownChart", "creditsHistoryChart", "costHistoryChart", + "usageHistoryChart", ] return menu.items.contains { item in guard let id = item.representedObject as? String else { return false } @@ -1456,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 new file mode 100644 index 000000000..da0f0a5db --- /dev/null +++ b/Sources/CodexBar/StatusItemController+UsageHistoryMenu.swift @@ -0,0 +1,77 @@ +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 + 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 + } + + 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 + return self.appendUsageHistoryChartItem(to: submenu, provider: provider, width: width) ? submenu : nil + } + + private func appendUsageHistoryChartItem( + to submenu: NSMenu, + provider: UsageProvider, + width: CGFloat) -> Bool + { + let histories = self.store.planUtilizationHistory(for: provider) + let snapshot = self.store.snapshot(for: provider) + + if !Self.menuCardRenderingEnabled { + let chartItem = NSMenuItem() + chartItem.isEnabled = false + chartItem.representedObject = "usageHistoryChart" + submenu.addItem(chartItem) + return true + } + + let chartView = PlanUtilizationHistoryChartMenuView( + provider: provider, + histories: histories, + snapshot: snapshot, + width: width) + 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)) + + 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..3a0cbc604 --- /dev/null +++ b/Sources/CodexBar/UsageStore+PlanUtilization.swift @@ -0,0 +1,605 @@ +import CodexBarCore +import CryptoKit +import Foundation + +extension UsageStore { + private nonisolated static let planUtilizationMinSampleIntervalSeconds: TimeInterval = 60 * 60 + private nonisolated static let planUtilizationResetEquivalenceToleranceSeconds: TimeInterval = 2 * 60 + private nonisolated static let planUtilizationMaxSamples: Int = 24 * 730 + + 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( + provider: provider, + 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.histories(for: accountKey) + } + + 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( + provider: UsageProvider, + snapshot: UsageSnapshot, + account: ProviderTokenAccount? = nil, + shouldUpdatePreferredAccountKey: Bool = true, + shouldAdoptUnscopedHistory: Bool = true, + now: Date = Date()) + async + { + guard provider == .codex || provider == .claude else { return } + guard !self.shouldDeferClaudePlanUtilizationHistory(provider: provider) else { return } + + var snapshotToPersist: [UsageProvider: PlanUtilizationHistoryBuckets]? + await MainActor.run { + var providerBuckets = self.planUtilizationHistory[provider] ?? PlanUtilizationHistoryBuckets() + let preferredAccount = account ?? self.settings.selectedTokenAccount(for: provider) + let accountKey = self.resolvePlanUtilizationAccountKey( + provider: provider, + snapshot: snapshot, + preferredAccount: preferredAccount, + shouldUpdatePreferredAccountKey: shouldUpdatePreferredAccountKey, + shouldAdoptUnscopedHistory: shouldAdoptUnscopedHistory, + providerBuckets: &providerBuckets) + 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.setHistories(updatedHistories, for: accountKey) + self.planUtilizationHistory[provider] = providerBuckets + snapshotToPersist = self.planUtilizationHistory + } + + guard let snapshotToPersist else { return } + await self.planUtilizationPersistenceCoordinator.enqueue(snapshotToPersist) + } + + private nonisolated static func updatedPlanUtilizationHistories( + existingHistories: [PlanUtilizationSeriesHistory], + samples: [PlanUtilizationSeriesSample]) -> [PlanUtilizationSeriesHistory]? + { + 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]) + } + didChange = true + } + + 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 entries.count > self.planUtilizationMaxSamples { + entries.removeFirst(entries.count - self.planUtilizationMaxSamples) + } + return entries + } + + #if DEBUG + nonisolated static func _updatedPlanUtilizationEntriesForTesting( + existingEntries: [PlanUtilizationHistoryEntry], + entry: PlanUtilizationHistoryEntry) -> [PlanUtilizationHistoryEntry]? + { + 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? { + guard let value else { return nil } + return max(0, min(100, value)) + } + + private nonisolated static func planUtilizationSeriesSamples( + provider: UsageProvider, + snapshot: UsageSnapshot, + capturedAt: Date) -> [PlanUtilizationSeriesSample] + { + var samplesByKey: [PlanUtilizationSeriesKey: PlanUtilizationSeriesSample] = [:] + + func appendWindow(_ window: RateWindow?, name: PlanUtilizationSeriesName?) { + guard let name, + let window, + let windowMinutes = window.windowMinutes, + let usedPercent = self.clampedPercent(window.usedPercent) + else { + return + } + + let key = PlanUtilizationSeriesKey(name: name, windowMinutes: windowMinutes) + samplesByKey[key] = PlanUtilizationSeriesSample( + name: name, + windowMinutes: windowMinutes, + entry: PlanUtilizationHistoryEntry( + capturedAt: capturedAt, + usedPercent: usedPercent, + resetsAt: window.resetsAt)) + } + + 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 + } + + return samplesByKey.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 codexSeriesName(for windowMinutes: Int?) -> PlanUtilizationSeriesName? { + switch windowMinutes { + case 300: + .session + case 10080: + .weekly + default: + nil + } + } + + 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 + { + 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] + { + 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 [] } + + 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) + } + + if let peakBeforeLatestReset { + return [peakBeforeLatestReset, activeSegmentPeak] + } + return [activeSegmentPeak] + } + + private nonisolated static func startsNewPlanUtilizationResetSegment( + activeSegmentPeak: PlanUtilizationHistoryEntry, + observation: PlanUtilizationHistoryEntry) -> Bool + { + self.haveMeaningfullyDifferentResetBoundaries( + activeSegmentPeak.resetsAt, + observation.resetsAt) + } + + private nonisolated static func segmentPeakEntry( + 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 + 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 { + return incoming ?? existing + } + return existing ?? incoming + } + + 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? + { + 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)") + } + + if provider == .claude { + return nil + } + + 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() + } + + private func shouldDeferClaudePlanUtilizationHistory(provider: UsageProvider) -> Bool { + provider == .claude && self.shouldHidePlanUtilizationMenuItem(for: .claude) + } + + private func resolvePlanUtilizationAccountKey( + 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) { + 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) + { + if shouldUpdatePreferredAccountKey { + providerBuckets.preferredAccountKey = identityAccountKey + } + if shouldAdoptUnscopedHistory { + self.adoptPlanUtilizationUnscopedHistoryIfNeeded( + into: identityAccountKey, + provider: provider, + providerBuckets: &providerBuckets) + } + return identityAccountKey + } + + if let stickyAccountKey = self.stickyPlanUtilizationAccountKey(providerBuckets: providerBuckets) { + return stickyAccountKey + } + + return nil + } + + private func adoptPlanUtilizationUnscopedHistoryIfNeeded( + into accountKey: String, + provider: UsageProvider, + providerBuckets: inout PlanUtilizationHistoryBuckets) + { + guard !providerBuckets.unscoped.isEmpty else { return } + + let existingHistory = providerBuckets.accounts[accountKey] ?? [] + let mergedHistory = Self.mergedPlanUtilizationHistories(provider: provider, histories: [ + existingHistory, + providerBuckets.unscoped, + ]) + providerBuckets.setHistories(mergedHistory, for: accountKey) + providerBuckets.setHistories([], for: nil) + } + + private func stickyPlanUtilizationAccountKey( + providerBuckets: PlanUtilizationHistoryBuckets) -> String? + { + let knownAccountKeys = self.knownPlanUtilizationAccountKeys(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]?.compactMap(\.latestCapturedAt).max() ?? .distantPast + let rhsDate = providerBuckets.accounts[rhs]?.compactMap(\.latestCapturedAt).max() ?? .distantPast + if lhsDate == rhsDate { + return lhs > rhs + } + return lhsDate < rhsDate + } + } + + private func knownPlanUtilizationAccountKeys(providerBuckets: PlanUtilizationHistoryBuckets) -> [String] { + providerBuckets.accounts.keys + .sorted() + } + + private nonisolated static func mergedPlanUtilizationHistories( + provider _: UsageProvider, + histories: [[PlanUtilizationSeriesHistory]]) -> [PlanUtilizationSeriesHistory] + { + 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 + } + } + mergedByKey[key] = PlanUtilizationSeriesHistory( + name: history.name, + windowMinutes: history.windowMinutes, + entries: mergedEntries) + } + } + + return mergedByKey.values.sorted { lhs, rhs in + if lhs.windowMinutes != rhs.windowMinutes { + return lhs.windowMinutes < rhs.windowMinutes + } + return lhs.name.rawValue < rhs.name.rawValue + } + } + + #if DEBUG + nonisolated static func _planUtilizationAccountKeyForTesting( + provider: UsageProvider, + snapshot: UsageSnapshot) -> String? + { + self.planUtilizationIdentityAccountKey(provider: provider, snapshot: snapshot) + } + + nonisolated static func _planUtilizationTokenAccountKeyForTesting( + provider: UsageProvider, + account: ProviderTokenAccount) -> String? + { + self.planUtilizationAccountKey(provider: provider, account: account) + } + #endif +} + +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 } + 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 func saveAsync(_ snapshot: [UsageProvider: PlanUtilizationHistoryBuckets]) async { + let store = self.store + await Task.detached(priority: .utility) { + store.save(snapshot) + }.value + } +} diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 1e60dfb37..06fccc774 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -85,6 +85,9 @@ extension UsageStore { self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } + await self.recordPlanUtilizationHistorySample( + provider: provider, + 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 f8cfd2f87..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, @@ -162,6 +186,10 @@ extension UsageStore { self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() } + await self.recordPlanUtilizationHistorySample( + provider: provider, + snapshot: labeled, + 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 35fdd234d..47efc5b63 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -118,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? @@ -148,16 +148,20 @@ final class UsageStore { @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var pathDebugRefreshTask: Task? + @ObservationIgnored var codexPlanHistoryBackfillTask: Task? @ObservationIgnored let historicalUsageHistoryStore: HistoricalUsageHistoryStore + @ObservationIgnored let planUtilizationHistoryStore: PlanUtilizationHistoryStore @ObservationIgnored var codexHistoricalDataset: CodexHistoricalDataset? @ObservationIgnored var codexHistoricalDatasetAccountKey: String? @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] + @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 @ObservationIgnored private let startupBehavior: StartupBehavior + @ObservationIgnored let planUtilizationPersistenceCoordinator: PlanUtilizationHistoryPersistenceCoordinator init( fetcher: UsageFetcher, @@ -167,6 +171,7 @@ final class UsageStore { settings: SettingsStore, registry: ProviderRegistry = .shared, historicalUsageHistoryStore: HistoricalUsageHistoryStore = HistoricalUsageHistoryStore(), + planUtilizationHistoryStore: PlanUtilizationHistoryStore = .defaultAppSupport(), sessionQuotaNotifier: any SessionQuotaNotifying = SessionQuotaNotifier(), startupBehavior: StartupBehavior = .automatic) { @@ -177,8 +182,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( @@ -196,6 +204,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.pathDebugInfo = PathDebugSnapshot( @@ -392,6 +401,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 @@ -405,7 +415,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. @@ -417,7 +427,7 @@ final class UsageStore { if self.openAIDashboardRequiresLogin { await self.refreshProvider(.codex) - await self.refreshCreditsIfNeeded() + await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) } self.persistWidgetSnapshot(reason: "refresh") @@ -494,6 +504,7 @@ final class UsageStore { self.timerTask?.cancel() self.tokenTimerTask?.cancel() self.tokenRefreshSequenceTask?.cancel() + self.codexPlanHistoryBackfillTask?.cancel() } enum SessionQuotaWindowSource: String { @@ -617,47 +628,6 @@ final class UsageStore { } } } - - private func refreshCreditsIfNeeded() 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 - } - } 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 { diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index 9351eeeab..48ad4f4f5 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 struct Configuration: Sendable { let environment: [String: String] let runtime: ProviderRuntime @@ -548,21 +550,26 @@ extension ClaudeUsageFetcher { 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) @@ -583,7 +590,7 @@ extension ClaudeUsageFetcher { let resets = opus["resets"] as? String return RateWindow( usedPercent: pct, - windowMinutes: nil, + windowMinutes: Self.weeklyWindowMinutes, resetsAt: Self.parseReset(text: resets), resetDescription: resets) }() @@ -943,21 +950,29 @@ extension ClaudeUsageFetcher { 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 e5b918e22..98859b18e 100644 --- a/Sources/CodexBarCore/UsageFetcher.swift +++ b/Sources/CodexBarCore/UsageFetcher.swift @@ -576,12 +576,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 94825cb78..3f3caa278 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)") } @@ -515,6 +517,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/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/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/TestStores.swift b/Tests/CodexBarTests/TestStores.swift index 7d8e27491..47d103d83 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("history", isDirectory: true) + if reset { + try? FileManager.default.removeItem(at: url) + } + return PlanUtilizationHistoryStore(directoryURL: url) +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift new file mode 100644 index 000000000..48582a76e --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationClaudeIdentityTests.swift @@ -0,0 +1,133 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationClaudeIdentityTests { + @MainActor + @Test + 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)) + + 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) == [ + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 50), + ]), + ]) + } + + @MainActor + @Test + 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") + 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 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: "max")) + + await store.recordFetchedTokenAccountPlanUtilizationHistory( + provider: .claude, + samples: [(account: bob, snapshot: snapshot)], + selectedAccount: alice) + + let buckets = try #require(store.planUtilizationHistory[.claude]) + 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 firstResolvedClaudeTokenAccountAdoptsUnscopedHistory() throws { + let store = UsageStorePlanUtilizationTests.makeStore() + store.settings.addTokenAccount(provider: .claude, label: "Alice", token: "alice-token") + let alice = try #require(store.settings.tokenAccounts(for: .claude).first) + let aliceKey = try #require( + UsageStore._planUtilizationTokenAccountKeyForTesting(provider: .claude, account: alice)) + 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) + + let history = store.planUtilizationHistory(for: .claude) + let buckets = try #require(store.planUtilizationHistory[.claude]) + + #expect(history == [bootstrap]) + #expect(buckets.unscoped.isEmpty) + #expect(buckets.accounts[aliceKey] == [bootstrap]) + } + + @MainActor + @Test + func claudeHistoryWithoutIdentityFallsBackToLastResolvedAccount() async { + let store = UsageStorePlanUtilizationTests.makeStore() + let snapshot = UsageSnapshot( + 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) + + await store.recordPlanUtilizationHistorySample( + provider: .claude, + snapshot: snapshot, + now: Date(timeIntervalSince1970: 1_700_000_000)) + + let identitylessSnapshot = UsageSnapshot( + primary: snapshot.primary, + secondary: snapshot.secondary, + updatedAt: snapshot.updatedAt) + store._setSnapshotForTesting(identitylessSnapshot, provider: .claude) + + let history = store.planUtilizationHistory(for: .claude) + #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 new file mode 100644 index 000000000..05624dea2 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationDerivedChartTests.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationDerivedChartTests { + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.selectedSeries == "weekly:10080") + #expect(model.usedPercents == [62, 48]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .claude, + referenceDate: boundary) + + #expect(model.visibleSeries == ["session:300", "weekly:10080", "opus:10080"]) + } +} diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift new file mode 100644 index 000000000..a61f035ff --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationExactFitResetTests.swift @@ -0,0 +1,228 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationExactFitResetTests { + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: thirdBoundary) + + #expect(model.usedPercents == [62, 48, 20]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(secondBoundary), + formattedBoundary(thirdBoundary), + ]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: secondBoundary) + + #expect(model.usedPercents == [61, 18]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary.addingTimeInterval(75)), + formattedBoundary(secondBoundary), + ]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: boundary) + + #expect(model.usedPercents == [48]) + #expect(model.pointDates == [formattedBoundary(boundary)]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "session:300", + histories: histories, + provider: .codex, + referenceDate: referenceDate) + + #expect(model.usedPercents == [62, 0, 0]) + #expect(model.pointDates == [ + formattedBoundary(firstBoundary), + formattedBoundary(firstBoundary.addingTimeInterval(5 * 60 * 60)), + formattedBoundary(currentBoundary), + ]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.usedPercents == [62, 48, 0, 20]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: fourthBoundary) + + #expect(model.axisIndexes == [0]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [62, 33]) + } + + @MainActor + @Test + 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 = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + selectedSeriesRawValue: "weekly:10080", + histories: histories, + provider: .codex, + referenceDate: secondBoundary.addingTimeInterval(-60)) + + #expect(model.usedPercents == [73, 35]) + } + + @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 new file mode 100644 index 000000000..155c9fbfb --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationResetCoalescingTests.swift @@ -0,0 +1,250 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationResetCoalescingTests { + @Test + func sameHourEntryBackfillsMissingResetMetadata() 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 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 updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 30) + #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) + let hourStart = try #require(calendar.date(from: DateComponents( + timeZone: TimeZone(secondsFromGMT: 0), + year: 2026, + month: 3, + 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 updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0] == incoming) + } + + @Test + 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: 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._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == incoming.capturedAt) + #expect(updated[0].usedPercent == 10) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + 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: 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._updatedPlanUtilizationEntriesForTesting( + existingEntries: [existing], + entry: incoming)) + + #expect(updated.count == 1) + #expect(updated[0].capturedAt == existing.capturedAt) + #expect(updated[0].usedPercent == existing.usedPercent) + #expect(updated[0].resetsAt == incoming.resetsAt) + } + + @Test + 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: 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 = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1].usedPercent == 18) + #expect(updated[1].resetsAt == incoming.resetsAt) + } + + @Test + 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: 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 = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: incoming)) + + #expect(updated.count == 2) + #expect(updated[0].usedPercent == 40) + #expect(updated[1] == incoming) + } + + @Test + 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), + ]), + ] + + let updated = try #require( + UsageStore._updatedPlanUtilizationHistoriesForTesting( + existingHistories: existing, + samples: incoming)) + + #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 new file mode 100644 index 000000000..130ec2cf6 --- /dev/null +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -0,0 +1,772 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsageStorePlanUtilizationTests { + @Test + func coalescesChangedUsageWithinHourIntoSingleEntry() 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, usedPercent: 10) + let second = planEntry(at: hourStart.addingTimeInterval(25 * 60), usedPercent: 35) + + 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.last == second) + } + + @Test + func changedResetBoundaryWithinHourAppendsNewEntry() 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: 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._updatedPlanUtilizationEntriesForTesting( + existingEntries: [], + entry: first)) + let updated = try #require( + UsageStore._updatedPlanUtilizationEntriesForTesting( + existingEntries: initial, + entry: second)) + + #expect(updated.count == 2) + #expect(updated[0] == first) + #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 + let base = Date(timeIntervalSince1970: 1_700_000_000) + var entries: [PlanUtilizationHistoryEntry] = [] + + for offset in 0.. UsageStore { + let suiteName = "UsageStorePlanUtilizationTests-\(UUID().uuidString)" + 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.directoryURL?.standardizedFileURL { + precondition(historyURL.path.hasPrefix(temporaryRoot)) + } + let isolatedSettings = SettingsStore( + userDefaults: defaults, + configStore: configStore, + tokenAccountStore: InMemoryTokenAccountStore()) + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: isolatedSettings, + planUtilizationHistoryStore: planHistoryStore, + startupBehavior: .testing) + store.planUtilizationHistory = [:] + return store + } + + static func makeSnapshot(provider: UsageProvider, email: String) -> UsageSnapshot { + UsageSnapshot( + 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, + accountEmail: email, + accountOrganization: nil, + loginMethod: "plus")) + } +} + +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 +{ + 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) +}