import Charts import CodexBarCore import SwiftUI @MainActor struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry private struct Point: Identifiable { let id: String let date: Date let costUSD: Double let totalTokens: Int? let requestCount: Int? init(date: Date, costUSD: Double, totalTokens: Int?, requestCount: Int?) { self.date = date self.costUSD = costUSD self.totalTokens = totalTokens self.requestCount = requestCount self.id = "\(Int(date.timeIntervalSince1970))-\(costUSD)" } } private struct DetailRow: Identifiable { let id: String let title: String let subtitle: String? let modeSubtitle: String? let accentColor: Color } private struct DetailContent { let primary: String let rows: [DetailRow] } private let provider: UsageProvider private let daily: [DailyEntry] private let totalCostUSD: Double? private let currencyCode: String private let historyDays: Int private let windowLabel: String? private let width: CGFloat @State private var selectedDateKey: String? init( provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, width: CGFloat) { self.provider = provider self.daily = daily self.totalCostUSD = totalCostUSD self.currencyCode = currencyCode self.historyDays = max(1, min(365, historyDays)) self.windowLabel = windowLabel self.width = width } var body: some View { let model = Self.makeModel(provider: self.provider, daily: self.daily) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { Text(L("No cost history data.")) .font(.footnote) .foregroundStyle(.secondary) .accessibilityLabel(L("No cost history data.")) } else { Chart { ForEach(model.points) { point in BarMark( x: .value(L("Day"), point.date, unit: .day), y: .value(L("Cost"), point.costUSD)) .foregroundStyle(model.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.costUSD - Self.capHeight(maxValue: model.maxCostUSD), 0) BarMark( x: .value(L("Day"), peak.date, unit: .day), yStart: .value(L("Cap start"), capStart), yEnd: .value(L("Cap end"), peak.costUSD)) .foregroundStyle(Color(nsColor: .systemYellow)) } } .chartYAxis(.hidden) .chartXAxis { AxisMarks(values: model.axisDates) { _ in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } .chartLegend(.hidden) .frame(height: 130) .accessibilityLabel(L("Cost history chart")) .accessibilityValue( model.points.isEmpty ? L("No data") : String(format: L("%d days of cost data"), model.points.count)) .chartOverlay { proxy in GeometryReader { geo in ZStack(alignment: .topLeading) { if let rect = self.selectionBandRect(model: model, proxy: proxy, geo: geo) { Rectangle() .fill(Self.selectionBandColor) .frame(width: rect.width, height: rect.height) .position(x: rect.midX, y: rect.midY) .allowsHitTesting(false) } MouseLocationReader { location in self.updateSelection(location: location, model: model, proxy: proxy, geo: geo) } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) } } } let detail = self.detailContent(model: model) VStack(alignment: .leading, spacing: Self.detailSpacing) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) if model.maxRenderedBreakdownRows > 0 { ScrollView(.vertical) { VStack(alignment: .leading, spacing: Self.detailSpacing) { ForEach(detail.rows) { row in HStack(alignment: .top, spacing: 8) { Rectangle() .fill(row.accentColor) .frame( width: 2, height: Self.accentHeight(for: row)) .padding(.top, 1) VStack(alignment: .leading, spacing: 1) { Text(row.title) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: Self.detailTitleLineHeight, alignment: .leading) if let subtitle = row.subtitle { Text(subtitle) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .truncationMode(.tail) .frame( height: Self.detailSubtitleLineHeight, alignment: .leading) } if let modeSubtitle = row.modeSubtitle { Text(modeSubtitle) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .truncationMode(.tail) .frame( height: Self.detailSubtitleLineHeight, alignment: .leading) } } } .frame(height: Self.detailRowHeight(for: row), alignment: .leading) } ForEach( 0.. String { if days == 1 { return L("Today") } return String(format: L("Last %d days"), days) } private static func detailRowHeight(for row: DetailRow) -> CGFloat { self.detailRowHeight(hasModeSubtitle: row.modeSubtitle != nil) } private static func detailRowHeight(hasModeSubtitle: Bool) -> CGFloat { hasModeSubtitle ? self.expandedDetailRowHeight : self.compactDetailRowHeight } private static func accentHeight(for row: DetailRow) -> CGFloat { row.subtitle == nil && row.modeSubtitle == nil ? 14 : self.detailRowHeight(for: row) } private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func makeModel(provider: UsageProvider, daily: [DailyEntry]) -> Model { let sorted = daily.sorted { lhs, rhs in lhs.date < rhs.date } var points: [Point] = [] points.reserveCapacity(sorted.count) var pointsByKey: [String: Point] = [:] pointsByKey.reserveCapacity(sorted.count) var entriesByKey: [String: DailyEntry] = [:] entriesByKey.reserveCapacity(sorted.count) var dateKeys: [(key: String, date: Date)] = [] dateKeys.reserveCapacity(sorted.count) var peak: (key: String, costUSD: Double)? var maxCostUSD: Double = 0 var maxRenderedBreakdownRows = 0 var detailRowMetrics: [(count: Int, height: CGFloat)] = [] for entry in sorted { guard let costUSD = entry.costUSD, costUSD >= 0 else { continue } guard let date = self.dateFromDayKey(entry.date) else { continue } let point = Point( date: date, costUSD: costUSD, totalTokens: entry.totalTokens, requestCount: entry.requestCount) points.append(point) pointsByKey[entry.date] = point entriesByKey[entry.date] = entry dateKeys.append((entry.date, date)) let rowMetric = Self.renderedBreakdownRowsMetric(for: entry) detailRowMetrics.append(rowMetric) maxRenderedBreakdownRows = max(maxRenderedBreakdownRows, rowMetric.count) if let cur = peak { if costUSD > cur.costUSD { peak = (entry.date, costUSD) } } else { peak = (entry.date, costUSD) } maxCostUSD = max(maxCostUSD, costUSD) } let axisDates: [Date] = { guard let first = dateKeys.first?.date, let last = dateKeys.last?.date else { return [] } if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } return [first, last] }() let barColor = Self.barColor(for: provider) let maxDetailRowsHeight = detailRowMetrics.reduce(CGFloat(0)) { currentMax, metric in let fillerRows = max(maxRenderedBreakdownRows - metric.count, 0) let filledHeight = metric.height + (CGFloat(fillerRows) * Self.compactDetailRowHeight) return max(currentMax, filledHeight) } return Model( points: points, pointsByDateKey: pointsByKey, entriesByDateKey: entriesByKey, dateKeys: dateKeys, axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD, maxRenderedBreakdownRows: maxRenderedBreakdownRows, maxDetailRowsHeight: maxDetailRowsHeight) } private static func barColor(for provider: UsageProvider) -> Color { let color = ProviderDescriptorRegistry.descriptor(for: provider).branding.color return Color(red: color.red, green: color.green, blue: color.blue) } private static func dateFromDayKey(_ key: String) -> Date? { let parts = key.split(separator: "-") guard parts.count == 3, let year = Int(parts[0]), let month = Int(parts[1]), let day = Int(parts[2]) else { return nil } var comps = DateComponents() comps.calendar = Calendar.current comps.timeZone = TimeZone.current comps.year = year comps.month = month comps.day = day comps.hour = 12 return comps.date } private static func peakPoint(model: Model) -> Point? { guard let key = model.peakKey else { return nil } return model.pointsByDateKey[key] } private static func renderedBreakdownRowsMetric(for entry: DailyEntry) -> (count: Int, height: CGFloat) { guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return (0, 0) } let renderedRows = Array( self.orderedBreakdownItems(breakdown) .prefix(self.detailViewportRowCount(itemCount: breakdown.count))) let height = renderedRows.reduce(CGFloat(0)) { total, item in total + self.detailRowHeight(hasModeSubtitle: Self.hasModeSubtitle(item)) } return (renderedRows.count, height) } private static func hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { item.standardCostUSD != nil || item.priorityCostUSD != nil } private static func detailBlockHeight(maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { guard maxBreakdownRows > 0 else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + self.detailRowsViewportHeight( maxBreakdownRows: maxBreakdownRows, maxRowsHeight: maxRowsHeight) + self.detailSpacing } private static func detailRowsViewportHeight( maxBreakdownRows: Int, maxRowsHeight: CGFloat) -> CGFloat { guard maxBreakdownRows > 0 else { return 0 } return maxRowsHeight + (CGFloat(maxBreakdownRows - 1) * self.detailSpacing) } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDateKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] guard let index = model.dateKeys.firstIndex(where: { $0.key == key }) else { return nil } let date = model.dateKeys[index].date guard let x = proxy.position(forX: date) else { return nil } func xForIndex(_ idx: Int) -> CGFloat? { guard idx >= 0, idx < model.dateKeys.count else { return nil } return proxy.position(forX: model.dateKeys[idx].date) } let xPrev = xForIndex(index - 1) let xNext = xForIndex(index + 1) let leftInPlot: CGFloat = if let xPrev { (xPrev + x) / 2 } else if let xNext { x - (xNext - x) / 2 } else { x - 8 } let rightInPlot: CGFloat = if let xNext { (xNext + x) / 2 } else if let xPrev { x + (x - xPrev) / 2 } else { x + 8 } let left = plotFrame.origin.x + min(leftInPlot, rightInPlot) let right = plotFrame.origin.x + max(leftInPlot, rightInPlot) return CGRect(x: left, y: plotFrame.origin.y, width: right - left, height: plotFrame.height) } private func updateSelection( location: CGPoint?, model: Model, proxy: ChartProxy, geo: GeometryProxy) { // Keep the last hovered day selected when the pointer leaves the chart so the adjacent // model-breakdown scroller remains interactive. The selection resets with the menu view. guard let location else { return } guard let plotAnchor = proxy.plotFrame else { return } let plotFrame = geo[plotAnchor] guard plotFrame.contains(location) else { return } let xInPlot = location.x - plotFrame.origin.x guard let date: Date = proxy.value(atX: xInPlot) else { return } guard let nearest = self.nearestDateKey(to: date, model: model) else { return } if self.selectedDateKey != nearest { self.selectedDateKey = nearest } } private func nearestDateKey(to date: Date, model: Model) -> String? { guard !model.dateKeys.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.dateKeys { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { if dist < cur.distance { best = (entry.key, dist) } } else { best = (entry.key, dist) } } return best?.key } private func detailContent(model: Model) -> DetailContent { guard let key = self.selectedDateKey, let point = model.pointsByDateKey[key], let date = Self.dateFromDayKey(key) else { return DetailContent(primary: L("Hover a bar for details"), rows: []) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let cost = self.costString(point.costUSD) var parts = [cost] if let tokens = point.totalTokens { parts.append("\(UsageFormatter.tokenCountString(tokens)) tokens") } if let requests = point.requestCount { parts.append("\(UsageFormatter.tokenCountString(requests)) requests") } let primary = "\(dayLabel): \(parts.joined(separator: " · "))" return DetailContent(primary: primary, rows: self.breakdownRows(key: key, model: model)) } private func breakdownRows(key: String, model: Model) -> [DetailRow] { guard let entry = model.entriesByDateKey[key] else { return [] } guard let breakdown = entry.modelBreakdowns, !breakdown.isEmpty else { return [] } return Self.orderedBreakdownItems(breakdown) .enumerated() .map { index, item in DetailRow( id: "\(item.modelName)-\(index)", title: UsageFormatter.modelDisplayName(item.modelName), subtitle: self.modelBreakdownTotalSubtitle(item), modeSubtitle: self.modelBreakdownModeSubtitle(item), accentColor: model.barColor.opacity(Self.breakdownAccentOpacity(for: index))) } } static func orderedBreakdownItems( _ breakdown: [CostUsageDailyReport.ModelBreakdown]) -> [CostUsageDailyReport.ModelBreakdown] { breakdown.sorted { lhs, rhs in let lCost = lhs.costUSD ?? -1 let rCost = rhs.costUSD ?? -1 if lCost != rCost { return lCost > rCost } let lTokens = lhs.totalTokens ?? -1 let rTokens = rhs.totalTokens ?? -1 if lTokens != rTokens { return lTokens > rTokens } return lhs.modelName > rhs.modelName } } static func detailViewportRowCount(itemCount: Int) -> Int { min(max(itemCount, 0), self.maxVisibleDetailLines) } static func detailRowsNeedScrolling(itemCount: Int) -> Bool { itemCount > self.maxVisibleDetailLines } private func modelBreakdownTotalSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { UsageFormatter.modelCostDetail( item.modelName, costUSD: item.costUSD, totalTokens: item.totalTokens, currencyCode: self.currencyCode) } private func modelBreakdownModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> String? { var parts: [String] = [] if let standardCost = item.standardCostUSD { var standardPart = "Std \(self.costString(standardCost))" if let standardTokens = item.standardTokens { standardPart += " · \(UsageFormatter.tokenCountString(standardTokens))" } parts.append(standardPart) } if let priorityCost = item.priorityCostUSD { var priorityPart = "Fast \(self.costString(priorityCost))" if let priorityTokens = item.priorityTokens { priorityPart += " · \(UsageFormatter.tokenCountString(priorityTokens))" } parts.append(priorityPart) } guard !parts.isEmpty else { return nil } return parts.joined(separator: " / ") } private func costString(_ value: Double) -> String { UsageFormatter.currencyString(value, currencyCode: self.currencyCode) } private static func breakdownAccentOpacity(for index: Int) -> Double { let opacity = 0.75 - (Double(index) * 0.12) return max(0.3, opacity) } }