562 lines · 23.4 KB · Swift
RawDownload
import Chartsimport CodexBarCoreimport SwiftUI @MainActorstruct 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..<max(model.maxRenderedBreakdownRows - detail.rows.count, 0),                                    id: \.self)                                { _ in                                    Text(" ")                                        .font(.caption)                                        .frame(height: Self.compactDetailRowHeight, alignment: .leading)                                        .opacity(0)                                }                            }                        }                        .scrollIndicators(                            Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden)                        .frame(                            height: Self.detailRowsViewportHeight(                                maxBreakdownRows: model.maxRenderedBreakdownRows,                                maxRowsHeight: model.maxDetailRowsHeight),                            alignment: .topLeading)                        .id(self.selectedDateKey)                    }                }                .frame(                    height: Self.detailBlockHeight(                        maxBreakdownRows: model.maxRenderedBreakdownRows,                        maxRowsHeight: model.maxDetailRowsHeight),                    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)            }        }        .padding(.horizontal, 16)        .padding(.vertical, 10)        .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading)    }     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        let maxRenderedBreakdownRows: Int        let maxDetailRowsHeight: CGFloat    }     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     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    }     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)))            }    }

Lines 1–500 of 562.