1049 lines · 45.2 KB · Swift
RawDownload
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 sessions: [CostUsageSessionBreakdown]    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,        projects: [CostUsageProjectBreakdown] = [],        sessions: [CostUsageSessionBreakdown] = [],        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.sessions = sessions        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 model.detailViewportRowCount > 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,                                                    rowHeight: model.detailRowHeight))                                            .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: model.detailRowHeight, alignment: .leading)                                }                            }                        }                        .scrollIndicators(                            Self.detailRowsNeedScrolling(itemCount: detail.rows.count) ? .visible : .hidden)                        .frame(                            height: Self.detailRowsViewportHeight(                                rowCount: model.detailViewportRowCount,                                rowHeight: model.detailRowHeight),                            alignment: .topLeading)                        .id(selectedDateKey)                         if model.hasDetailOverflow {                            Text(Self.detailOverflowHint(itemCount: detail.rows.count) ?? " ")                                .font(.caption2)                                .foregroundStyle(Color(nsColor: .tertiaryLabelColor))                                .frame(height: Self.detailHintHeight, alignment: .leading)                                .accessibilityHidden(!Self.detailRowsNeedScrolling(itemCount: detail.rows.count))                        }                    }                }                .frame(                    height: Self.detailBlockHeight(                        rowCount: model.detailViewportRowCount,                        hasOverflow: model.hasDetailOverflow,                        rowHeight: model.detailRowHeight),                    alignment: .topLeading)            }             if let total = self.totalCostUSD {                VStack(alignment: .leading, spacing: 2) {                    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 let disclaimer = Self.estimateDisclaimer(provider: self.provider) {                        Text(disclaimer)                            .font(.caption2)                            .foregroundStyle(Color(nsColor: .tertiaryLabelColor))                            .lineLimit(1)                            .truncationMode(.tail)                    }                }            }             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)            }             if !self.sessions.isEmpty {                self.sessionsBlock            }        }        .padding(.horizontal, 16)        .padding(.vertical, Self.verticalPadding)        .frame(minWidth: self.width, maxWidth: .infinity, alignment: .top)    }     static func estimateDisclaimer(provider: UsageProvider) -> String? {        provider == .codex ? L("codex_api_estimate_hint") : nil    }     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 detailViewportRowCount: Int        let hasDetailOverflow: Bool        let detailRowHeight: 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    private static let detailHintHeight: CGFloat = 13    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    private static let sessionRowHeight: CGFloat = 44    private static let sessionRowSpacing: CGFloat = 5    private static let maxVisibleSessionRows = 5    static let verticalPadding: CGFloat = 10     private var sessionsBlock: some View {        let visibleCount = min(self.sessions.count, Self.maxVisibleSessionRows)        return VStack(alignment: .leading, spacing: Self.sessionRowSpacing) {            HStack {                Text("Conversations (\(self.windowLabel ?? Self.windowLabel(days: self.historyDays)))")                    .font(.caption)                    .foregroundStyle(.secondary)                    .lineLimit(1)                Spacer()                Text("\(self.sessions.count)")                    .font(.caption2)                    .foregroundStyle(Color(nsColor: .tertiaryLabelColor))            }            .frame(height: Self.detailPrimaryLineHeight, alignment: .leading)             ScrollView(.vertical) {                LazyVStack(alignment: .leading, spacing: Self.sessionRowSpacing) {                    ForEach(self.sessions) { session in                        self.sessionRow(session)                    }                }            }            .scrollIndicators(self.sessions.count > visibleCount ? .visible : .hidden)            .frame(                height: CGFloat(visibleCount) * Self.sessionRowHeight                    + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing,                alignment: .topLeading)        }        .frame(            height: Self.detailPrimaryLineHeight + Self.sessionRowSpacing                + CGFloat(visibleCount) * Self.sessionRowHeight                + CGFloat(max(visibleCount - 1, 0)) * Self.sessionRowSpacing,            alignment: .topLeading)    }     private func sessionRow(_ session: CostUsageSessionBreakdown) -> some View {        HStack(alignment: .top, spacing: 8) {            VStack(alignment: .leading, spacing: 1) {                Text("Session \(Self.shortSessionID(session.sessionID))")                    .font(.caption)                    .foregroundStyle(.secondary)                    .lineLimit(1)                Text(Self.sessionUsageLine(session))                    .font(.caption2)                    .foregroundStyle(Color(nsColor: .tertiaryLabelColor))                    .lineLimit(1)                    .truncationMode(.tail)                Text(session.lastActivity, format: .dateTime.month(.abbreviated).day().hour().minute())                    .font(.caption2)                    .foregroundStyle(Color(nsColor: .tertiaryLabelColor))                    .lineLimit(1)            }            Spacer(minLength: 8)            Text(session.costUSD.map(self.costString) ?? "—")                .font(.caption)                .monospacedDigit()                .foregroundStyle(.secondary)                .lineLimit(1)        }        .frame(height: Self.sessionRowHeight, alignment: .topLeading)        .accessibilityElement(children: .combine)    }     static func shortSessionID(_ sessionID: String) -> String {        let trimmed = sessionID.trimmingCharacters(in: .whitespacesAndNewlines)        guard trimmed.count > 12 else { return trimmed }        return "\(trimmed.prefix(4))...\(trimmed.suffix(8))"    }     private static func sessionUsageLine(_ session: CostUsageSessionBreakdown) -> String {        let models = session.modelBreakdowns.map(\.modelName)        let modelLabel = if models.isEmpty {            "Unknown model"        } else if models.count == 1 {            models[0]        } else {            "\(models[0]) +\(models.count - 1)"        }        let input = session.inputTokens.map(UsageFormatter.tokenCountString) ?? "—"        let cached = session.cachedInputTokens.map(UsageFormatter.tokenCountString) ?? "—"        let output = session.outputTokens.map(UsageFormatter.tokenCountString) ?? "—"        return "\(modelLabel) · \(input) input · \(cached) cached · \(output) output"    }     static func windowLabel(days: Int) -> String {        if days == 1 {            return L("Today")        }        return String(format: L("Last %d days"), days)    }     private static func accentHeight(for row: DetailRow, rowHeight: CGFloat) -> CGFloat {        row.subtitle == nil && row.modeSubtitle == nil ? 14 : rowHeight    }     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        var maxDetailRows = 0        var hasModeDetails = false        for entry in sorted {            guard let (costUSD, date) = self.chartPointInput(for: entry) 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 modelBreakdowns = entry.modelBreakdowns ?? []            maxDetailRows = max(maxDetailRows, modelBreakdowns.count)            hasModeDetails = hasModeDetails || modelBreakdowns.contains { Self.hasModeSubtitle($0) }            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]            }

Lines 1–500 of 1049.