import Chartsimport CodexBarCoreimport SwiftUI @MainActorstruct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry enum AxisLabelPlacement: Equatable { case hidden case centered case edges } 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 projects: [CostUsageProjectBreakdown] private let width: CGFloat private let onHeightChange: ((CGFloat) -> Void)? @State private var selectedDateKey: String? init( provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], onHeightChange: ((CGFloat) -> Void)? = 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.projects = projects self.onHeightChange = onHeightChange self.width = width } var body: some View { let model = Self.makeModel(provider: self.provider, daily: self.daily) let selectedDateKey = self.selectedDateKey ?? Self.defaultSelectedDateKey(model: model) VStack(alignment: .leading, spacing: Self.outerSpacing) { 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 { AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(centered: false) { if let raw = value.as(Double.self) { Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode)) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .padding(.leading, 4) } } } } .chartXAxis { AxisMarks(values: model.axisDates) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) if let date = value.as(Date.self) { AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { Text(date, format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } } } .chartLegend(.hidden) .frame(height: Self.chartHeight) .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(selectedDateKey: selectedDateKey, 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 !detail.rows.isEmpty { 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) } } } .scrollIndicators( Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden) .frame( height: Self.detailRowsViewportHeight(rows: detail.rows), alignment: .topLeading) .id(selectedDateKey) } } .frame( height: Self.detailBlockHeight(rows: detail.rows), alignment: .topLeading) } if let total = self.totalCostUSD { Text(String( format: L("Est. total (%@): %@"), self.windowLabel ?? Self.windowLabel(days: self.historyDays), self.costString(total))) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) } if !self.projects.isEmpty { VStack(alignment: .leading, spacing: Self.projectRowSpacing) { Text("Projects") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in let visibleSources = Self.visibleProjectSources(project) VStack(alignment: .leading, spacing: Self.projectSourceSpacing) { self.projectParentRow(project) if !visibleSources.isEmpty { ForEach( Array(visibleSources.prefix(Self.maxVisibleProjectSourceRows)), id: \.sourceRowID) { source in self.projectSourceRow(source) } let hiddenSourceCount = visibleSources.count - Self.maxVisibleProjectSourceRows if hiddenSourceCount > 0 { Text("+ \(hiddenSourceCount) more") .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .padding(.leading, Self.projectSourceIndent) .frame(height: Self.projectMoreRowHeight, alignment: .leading) } } } .frame(height: Self.projectEntryHeight(project), alignment: .topLeading) } } .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .top) } private struct Model { let points: [Point] let pointsByDateKey: [String: Point] let entriesByDateKey: [String: DailyEntry] let dateKeys: [(key: String, date: Date)] let axisDates: [Date] let barColor: Color let peakKey: String? let maxCostUSD: Double } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) static let maxVisibleDetailLines = 4 private static let detailPrimaryLineHeight: CGFloat = 16 private static let detailTitleLineHeight: CGFloat = 16 private static let detailSubtitleLineHeight: CGFloat = 13 private static let compactDetailRowHeight: CGFloat = 36 private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 private static let chartHeight: CGFloat = 130 private static let outerSpacing: CGFloat = 10 private static let projectRowHeight: CGFloat = 31 private static let projectRowSpacing: CGFloat = 5 private static let maxVisibleProjectRows = 5 private static let projectSourceRowHeight: CGFloat = 29 private static let projectSourceSpacing: CGFloat = 3 private static let projectSourceIndent: CGFloat = 10 private static let projectMoreRowHeight: CGFloat = 16 private static let maxVisibleProjectSourceRows = 2 static let verticalPadding: CGFloat = 10 /// Deterministic total height of the rendered card for a given selection. NSMenu's modal /// tracking run loop never delivers SwiftUI `onPreferenceChange`, so the live height can't be /// measured via a GeometryReader while the menu is open. Every component height is fixed, so /// we compute the total directly and resize from the hover handler instead. private static func totalCardHeight( rows: [DetailRow], hasTotal: Bool, projects: [CostUsageProjectBreakdown] = []) -> CGFloat { var height = self.verticalPadding * 2 height += self.chartHeight height += self.outerSpacing height += self.detailBlockHeight(rows: rows) if hasTotal { height += self.outerSpacing height += self.detailPrimaryLineHeight } if !projects.isEmpty { height += self.outerSpacing height += self.projectBlockHeight(projects: projects) } return height } private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool, projectCount: Int) -> CGFloat { let projects = (0..<projectCount).map { index in CostUsageProjectBreakdown( name: "Project \(index)", path: "/tmp/project-\(index)", totalTokens: nil, totalCostUSD: nil, daily: [], modelBreakdowns: nil) } return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projects: projects) } static func windowLabel(days: Int) -> 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 } /// Y-axis tick values for the cost chart: 0, mid, max when the range is at /// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the /// axis renders no labels. private static func yAxisTickValues(maxCostUSD: Double) -> [Double] { guard maxCostUSD > 0 else { return [] } if maxCostUSD < 1.0 { return [0, maxCostUSD] } return [0, maxCostUSD / 2, maxCostUSD] } 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 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)) 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) return Model( points: points, pointsByDateKey: pointsByKey, entriesByDateKey: entriesByKey, dateKeys: dateKeys, axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD) } private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { switch dates.count { case 0: .hidden case 1: .centered default: .edges } } private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { switch self.axisLabelPlacement(for: axisDates) { case .hidden, .centered: .top case .edges: if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { .topLeading } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { .topTrailing } else { .top } } } 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 hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { item.standardCostUSD != nil || item.priorityCostUSD != nil } private static func detailBlockHeight(rows: [DetailRow]) -> CGFloat { guard !rows.isEmpty else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + self.detailRowsViewportHeight(rows: rows) + self.detailSpacing } private static func detailRowsViewportHeight(rows: [DetailRow]) -> CGFloat { let visibleRows = Array(rows.prefix(self.maxVisibleDetailLines)) guard !visibleRows.isEmpty else { return 0 } let rowHeights = visibleRows.reduce(CGFloat(0)) { total, row in total + self.detailRowHeight(for: row) } let spacing = CGFloat(max(visibleRows.count - 1, 0)) * self.detailSpacing return rowHeights + spacing } private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat {Lines 1–500 of 896. Load more
import Charts import CodexBarCore import SwiftUI @MainActor struct CostHistoryChartMenuView: View { typealias DailyEntry = CostUsageDailyReport.Entry enum AxisLabelPlacement: Equatable { case hidden case centered case edges } 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 projects: [CostUsageProjectBreakdown] private let width: CGFloat private let onHeightChange: ((CGFloat) -> Void)? @State private var selectedDateKey: String? init( provider: UsageProvider, daily: [DailyEntry], totalCostUSD: Double?, currencyCode: String = "USD", historyDays: Int = 30, windowLabel: String? = nil, projects: [CostUsageProjectBreakdown] = [], onHeightChange: ((CGFloat) -> Void)? = 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.projects = projects self.onHeightChange = onHeightChange self.width = width } var body: some View { let model = Self.makeModel(provider: self.provider, daily: self.daily) let selectedDateKey = self.selectedDateKey ?? Self.defaultSelectedDateKey(model: model) VStack(alignment: .leading, spacing: Self.outerSpacing) { 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 { AxisMarks(position: .leading, values: Self.yAxisTickValues(maxCostUSD: model.maxCostUSD)) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) AxisValueLabel(centered: false) { if let raw = value.as(Double.self) { Text(Self.yAxisCostString(raw, currencyCode: self.currencyCode)) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .padding(.leading, 4) } } } } .chartXAxis { AxisMarks(values: model.axisDates) { value in AxisGridLine().foregroundStyle(Color.clear) AxisTick().foregroundStyle(Color.clear) if let date = value.as(Date.self) { AxisValueLabel(anchor: Self.xAxisLabelAnchor(for: date, axisDates: model.axisDates)) { Text(date, format: .dateTime.month(.abbreviated).day()) .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) } } } } .chartLegend(.hidden) .frame(height: Self.chartHeight) .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(selectedDateKey: selectedDateKey, 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 !detail.rows.isEmpty { 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) } } } .scrollIndicators( Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden) .frame( height: Self.detailRowsViewportHeight(rows: detail.rows), alignment: .topLeading) .id(selectedDateKey) } } .frame( height: Self.detailBlockHeight(rows: detail.rows), alignment: .topLeading) } if let total = self.totalCostUSD { Text(String( format: L("Est. total (%@): %@"), self.windowLabel ?? Self.windowLabel(days: self.historyDays), self.costString(total))) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) } if !self.projects.isEmpty { VStack(alignment: .leading, spacing: Self.projectRowSpacing) { Text("Projects") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) ForEach(Array(self.projects.prefix(Self.maxVisibleProjectRows)), id: \.projectRowID) { project in let visibleSources = Self.visibleProjectSources(project) VStack(alignment: .leading, spacing: Self.projectSourceSpacing) { self.projectParentRow(project) if !visibleSources.isEmpty { ForEach( Array(visibleSources.prefix(Self.maxVisibleProjectSourceRows)), id: \.sourceRowID) { source in self.projectSourceRow(source) } let hiddenSourceCount = visibleSources.count - Self.maxVisibleProjectSourceRows if hiddenSourceCount > 0 { Text("+ \(hiddenSourceCount) more") .font(.caption2) .foregroundStyle(Color(nsColor: .tertiaryLabelColor)) .lineLimit(1) .padding(.leading, Self.projectSourceIndent) .frame(height: Self.projectMoreRowHeight, alignment: .leading) } } } .frame(height: Self.projectEntryHeight(project), alignment: .topLeading) } } .frame(height: Self.projectBlockHeight(projects: self.projects), alignment: .topLeading) } } .padding(.horizontal, 16) .padding(.vertical, Self.verticalPadding) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .top) } private struct Model { let points: [Point] let pointsByDateKey: [String: Point] let entriesByDateKey: [String: DailyEntry] let dateKeys: [(key: String, date: Date)] let axisDates: [Date] let barColor: Color let peakKey: String? let maxCostUSD: Double } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) static let maxVisibleDetailLines = 4 private static let detailPrimaryLineHeight: CGFloat = 16 private static let detailTitleLineHeight: CGFloat = 16 private static let detailSubtitleLineHeight: CGFloat = 13 private static let compactDetailRowHeight: CGFloat = 36 private static let expandedDetailRowHeight: CGFloat = 44 private static let detailSpacing: CGFloat = 6 private static let chartHeight: CGFloat = 130 private static let outerSpacing: CGFloat = 10 private static let projectRowHeight: CGFloat = 31 private static let projectRowSpacing: CGFloat = 5 private static let maxVisibleProjectRows = 5 private static let projectSourceRowHeight: CGFloat = 29 private static let projectSourceSpacing: CGFloat = 3 private static let projectSourceIndent: CGFloat = 10 private static let projectMoreRowHeight: CGFloat = 16 private static let maxVisibleProjectSourceRows = 2 static let verticalPadding: CGFloat = 10 /// Deterministic total height of the rendered card for a given selection. NSMenu's modal /// tracking run loop never delivers SwiftUI `onPreferenceChange`, so the live height can't be /// measured via a GeometryReader while the menu is open. Every component height is fixed, so /// we compute the total directly and resize from the hover handler instead. private static func totalCardHeight( rows: [DetailRow], hasTotal: Bool, projects: [CostUsageProjectBreakdown] = []) -> CGFloat { var height = self.verticalPadding * 2 height += self.chartHeight height += self.outerSpacing height += self.detailBlockHeight(rows: rows) if hasTotal { height += self.outerSpacing height += self.detailPrimaryLineHeight } if !projects.isEmpty { height += self.outerSpacing height += self.projectBlockHeight(projects: projects) } return height } private static func totalCardHeight(rows: [DetailRow], hasTotal: Bool, projectCount: Int) -> CGFloat { let projects = (0..<projectCount).map { index in CostUsageProjectBreakdown( name: "Project \(index)", path: "/tmp/project-\(index)", totalTokens: nil, totalCostUSD: nil, daily: [], modelBreakdowns: nil) } return self.totalCardHeight(rows: rows, hasTotal: hasTotal, projects: projects) } static func windowLabel(days: Int) -> 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 } /// Y-axis tick values for the cost chart: 0, mid, max when the range is at /// $1 or more; 0 and max for smaller ranges; empty for flat/no data so the /// axis renders no labels. private static func yAxisTickValues(maxCostUSD: Double) -> [Double] { guard maxCostUSD > 0 else { return [] } if maxCostUSD < 1.0 { return [0, maxCostUSD] } return [0, maxCostUSD / 2, maxCostUSD] } 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 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)) 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) return Model( points: points, pointsByDateKey: pointsByKey, entriesByDateKey: entriesByKey, dateKeys: dateKeys, axisDates: axisDates, barColor: barColor, peakKey: maxCostUSD > 0 ? peak?.key : nil, maxCostUSD: maxCostUSD) } private static func axisLabelPlacement(for dates: [Date]) -> AxisLabelPlacement { switch dates.count { case 0: .hidden case 1: .centered default: .edges } } private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { switch self.axisLabelPlacement(for: axisDates) { case .hidden, .centered: .top case .edges: if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { .topLeading } else if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { .topTrailing } else { .top } } } 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 hasModeSubtitle(_ item: CostUsageDailyReport.ModelBreakdown) -> Bool { item.standardCostUSD != nil || item.priorityCostUSD != nil } private static func detailBlockHeight(rows: [DetailRow]) -> CGFloat { guard !rows.isEmpty else { return self.detailPrimaryLineHeight } return self.detailPrimaryLineHeight + self.detailRowsViewportHeight(rows: rows) + self.detailSpacing } private static func detailRowsViewportHeight(rows: [DetailRow]) -> CGFloat { let visibleRows = Array(rows.prefix(self.maxVisibleDetailLines)) guard !visibleRows.isEmpty else { return 0 } let rowHeights = visibleRows.reduce(CGFloat(0)) { total, row in total + self.detailRowHeight(for: row) } let spacing = CGFloat(max(visibleRows.count - 1, 0)) * self.detailSpacing return rowHeights + spacing } private static func projectBlockHeight(projects: [CostUsageProjectBreakdown]) -> CGFloat {