import Chartsimport CodexBarCoreimport SwiftUI @MainActorstruct CreditsHistoryChartMenuView: View { private struct Point: Identifiable { let id: String let date: Date let creditsUsed: Double init(date: Date, creditsUsed: Double) { self.date = date self.creditsUsed = creditsUsed self.id = "\(Int(date.timeIntervalSince1970))-\(creditsUsed)" } } private let breakdown: [OpenAIDashboardDailyBreakdown] private let width: CGFloat @State private var selectedDayKey: String? init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) { self.breakdown = breakdown self.width = width } var body: some View { let model = Self.makeModel(from: self.breakdown) VStack(alignment: .leading, spacing: 10) { if model.points.isEmpty { Text("No credits history data.") .font(.footnote) .foregroundStyle(.secondary) } else { Chart { ForEach(model.points) { point in BarMark( x: .value("Day", point.date, unit: .day), y: .value("Credits used", point.creditsUsed)) .foregroundStyle(Self.barColor) } if let peak = Self.peakPoint(model: model) { let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0) BarMark( x: .value("Day", peak.date, unit: .day), yStart: .value("Cap start", capStart), yEnd: .value("Cap end", peak.creditsUsed)) .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) .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.detailLines(model: model) VStack(alignment: .leading, spacing: 0) { Text(detail.primary) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) Text(detail.secondary ?? " ") .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) .frame(height: 16, alignment: .leading) .opacity(detail.secondary == nil ? 0 : 1) } if let total = model.totalCreditsUsed { Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits") .font(.caption) .foregroundStyle(.secondary) } } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] let pointsByDayKey: [String: Point] let dayDates: [(dayKey: String, date: Date)] let selectableDayDates: [(dayKey: String, date: Date)] let axisDates: [Date] let peakKey: String? let totalCreditsUsed: Double? let maxCreditsUsed: Double } private static let barColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255) private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = breakdown.sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] points.reserveCapacity(sorted.count) var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:] breakdownByDayKey.reserveCapacity(sorted.count) var pointsByDayKey: [String: Point] = [:] pointsByDayKey.reserveCapacity(sorted.count) var dayDates: [(dayKey: String, date: Date)] = [] dayDates.reserveCapacity(sorted.count) var selectableDayDates: [(dayKey: String, date: Date)] = [] selectableDayDates.reserveCapacity(sorted.count) var totalCreditsUsed: Double = 0 var peak: (key: String, creditsUsed: Double)? var maxCreditsUsed: Double = 0 for day in sorted { guard let date = self.dateFromDayKey(day.day) else { continue } breakdownByDayKey[day.day] = day dayDates.append((dayKey: day.day, date: date)) totalCreditsUsed += day.totalCreditsUsed if day.totalCreditsUsed > 0 { let point = Point(date: date, creditsUsed: day.totalCreditsUsed) points.append(point) pointsByDayKey[day.day] = point selectableDayDates.append((dayKey: day.day, date: date)) if let cur = peak { if day.totalCreditsUsed > cur.creditsUsed { peak = (day.day, day.totalCreditsUsed) } } else { peak = (day.day, day.totalCreditsUsed) } maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed) } } let axisDates: [Date] = { guard let first = dayDates.first?.date, let last = dayDates.last?.date else { return [] } if Calendar.current.isDate(first, inSameDayAs: last) { return [first] } return [first, last] }() return Model( points: points, breakdownByDayKey: breakdownByDayKey, pointsByDayKey: pointsByDayKey, dayDates: dayDates, selectableDayDates: selectableDayDates, axisDates: axisDates, peakKey: peak?.key, totalCreditsUsed: totalCreditsUsed > 0 ? totalCreditsUsed : nil, maxCreditsUsed: maxCreditsUsed) } 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.pointsByDayKey[key] } private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? { guard let key = self.selectedDayKey else { return nil } guard let plotAnchor = proxy.plotFrame else { return nil } let plotFrame = geo[plotAnchor] guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil } let date = model.dayDates[index].date guard let x = proxy.position(forX: date) else { return nil } func xForIndex(_ idx: Int) -> CGFloat? { guard idx >= 0, idx < model.dayDates.count else { return nil } return proxy.position(forX: model.dayDates[idx].date) } let xPrev = xForIndex(index - 1) let xNext = xForIndex(index + 1) if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, y: plotFrame.origin.y, width: plotFrame.width, height: plotFrame.height) } 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) { guard let location else { if self.selectedDayKey != nil { self.selectedDayKey = nil } 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.nearestDayKey(to: date, model: model) else { return } if self.selectedDayKey != nearest { self.selectedDayKey = nearest } } private func nearestDayKey(to date: Date, model: Model) -> String? { guard !model.selectableDayDates.isEmpty else { return nil } var best: (key: String, distance: TimeInterval)? for entry in model.selectableDayDates { let dist = abs(entry.date.timeIntervalSince(date)) if let cur = best { if dist < cur.distance { best = (entry.dayKey, dist) } } else { best = (entry.dayKey, dist) } } return best?.key } private func detailLines(model: Model) -> (primary: String, secondary: String?) { guard let key = self.selectedDayKey, let day = model.breakdownByDayKey[key], let date = Self.dateFromDayKey(key) else { return ("Hover a bar for details", nil) } let dayLabel = date.formatted(.dateTime.month(.abbreviated).day()) let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2))) if day.services.isEmpty { return ("\(dayLabel): \(total) credits", nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) return ("\(dayLabel): \(used) credits", first.service) } let services = day.services .sorted { lhs, rhs in if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service } return lhs.creditsUsed > rhs.creditsUsed } .prefix(3) .map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" } .joined(separator: " · ") return ("\(dayLabel): \(total) credits", services) }}
import Charts
import CodexBarCore
import SwiftUI
@MainActor
struct CreditsHistoryChartMenuView: View {
private struct Point: Identifiable {
let id: String
let date: Date
let creditsUsed: Double
init(date: Date, creditsUsed: Double) {
self.date = date
self.creditsUsed = creditsUsed
self.id = "\(Int(date.timeIntervalSince1970))-\(creditsUsed)"
}
}
private let breakdown: [OpenAIDashboardDailyBreakdown]
private let width: CGFloat
@State private var selectedDayKey: String?
init(breakdown: [OpenAIDashboardDailyBreakdown], width: CGFloat) {
self.breakdown = breakdown
self.width = width
}
var body: some View {
let model = Self.makeModel(from: self.breakdown)
VStack(alignment: .leading, spacing: 10) {
if model.points.isEmpty {
Text("No credits history data.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
Chart {
ForEach(model.points) { point in
BarMark(
x: .value("Day", point.date, unit: .day),
y: .value("Credits used", point.creditsUsed))
.foregroundStyle(Self.barColor)
}
if let peak = Self.peakPoint(model: model) {
let capStart = max(peak.creditsUsed - Self.capHeight(maxValue: model.maxCreditsUsed), 0)
BarMark(
x: .value("Day", peak.date, unit: .day),
yStart: .value("Cap start", capStart),
yEnd: .value("Cap end", peak.creditsUsed))
.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)
.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.detailLines(model: model)
VStack(alignment: .leading, spacing: 0) {
Text(detail.primary)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: 16, alignment: .leading)
Text(detail.secondary ?? " ")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: 16, alignment: .leading)
.opacity(detail.secondary == nil ? 0 : 1)
}
if let total = model.totalCreditsUsed {
Text("Total (30d): \(total.formatted(.number.precision(.fractionLength(0...2)))) credits")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading)
}
private struct Model {
let points: [Point]
let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown]
let pointsByDayKey: [String: Point]
let dayDates: [(dayKey: String, date: Date)]
let selectableDayDates: [(dayKey: String, date: Date)]
let axisDates: [Date]
let peakKey: String?
let totalCreditsUsed: Double?
let maxCreditsUsed: Double
}
private static let barColor = Color(red: 73 / 255, green: 163 / 255, blue: 176 / 255)
private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1)
private static func capHeight(maxValue: Double) -> Double {
maxValue * 0.05
}
private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model {
let sorted = breakdown.sorted { lhs, rhs in lhs.day < rhs.day }
var points: [Point] = []
points.reserveCapacity(sorted.count)
var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:]
breakdownByDayKey.reserveCapacity(sorted.count)
var pointsByDayKey: [String: Point] = [:]
pointsByDayKey.reserveCapacity(sorted.count)
var dayDates: [(dayKey: String, date: Date)] = []
dayDates.reserveCapacity(sorted.count)
var selectableDayDates: [(dayKey: String, date: Date)] = []
selectableDayDates.reserveCapacity(sorted.count)
var totalCreditsUsed: Double = 0
var peak: (key: String, creditsUsed: Double)?
var maxCreditsUsed: Double = 0
for day in sorted {
guard let date = self.dateFromDayKey(day.day) else { continue }
breakdownByDayKey[day.day] = day
dayDates.append((dayKey: day.day, date: date))
totalCreditsUsed += day.totalCreditsUsed
if day.totalCreditsUsed > 0 {
let point = Point(date: date, creditsUsed: day.totalCreditsUsed)
points.append(point)
pointsByDayKey[day.day] = point
selectableDayDates.append((dayKey: day.day, date: date))
if let cur = peak {
if day.totalCreditsUsed > cur.creditsUsed { peak = (day.day, day.totalCreditsUsed) }
} else {
peak = (day.day, day.totalCreditsUsed)
}
maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed)
}
}
let axisDates: [Date] = {
guard let first = dayDates.first?.date, let last = dayDates.last?.date else { return [] }
if Calendar.current.isDate(first, inSameDayAs: last) { return [first] }
return [first, last]
}()
return Model(
points: points,
breakdownByDayKey: breakdownByDayKey,
pointsByDayKey: pointsByDayKey,
dayDates: dayDates,
selectableDayDates: selectableDayDates,
axisDates: axisDates,
peakKey: peak?.key,
totalCreditsUsed: totalCreditsUsed > 0 ? totalCreditsUsed : nil,
maxCreditsUsed: maxCreditsUsed)
}
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.pointsByDayKey[key]
}
private func selectionBandRect(model: Model, proxy: ChartProxy, geo: GeometryProxy) -> CGRect? {
guard let key = self.selectedDayKey else { return nil }
guard let plotAnchor = proxy.plotFrame else { return nil }
let plotFrame = geo[plotAnchor]
guard let index = model.dayDates.firstIndex(where: { $0.dayKey == key }) else { return nil }
let date = model.dayDates[index].date
guard let x = proxy.position(forX: date) else { return nil }
func xForIndex(_ idx: Int) -> CGFloat? {
guard idx >= 0, idx < model.dayDates.count else { return nil }
return proxy.position(forX: model.dayDates[idx].date)
}
let xPrev = xForIndex(index - 1)
let xNext = xForIndex(index + 1)
if model.dayDates.count <= 1 {
return CGRect(
x: plotFrame.origin.x,
y: plotFrame.origin.y,
width: plotFrame.width,
height: plotFrame.height)
}
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)
{
guard let location else {
if self.selectedDayKey != nil { self.selectedDayKey = nil }
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.nearestDayKey(to: date, model: model) else { return }
if self.selectedDayKey != nearest {
self.selectedDayKey = nearest
}
}
private func nearestDayKey(to date: Date, model: Model) -> String? {
guard !model.selectableDayDates.isEmpty else { return nil }
var best: (key: String, distance: TimeInterval)?
for entry in model.selectableDayDates {
let dist = abs(entry.date.timeIntervalSince(date))
if let cur = best {
if dist < cur.distance { best = (entry.dayKey, dist) }
} else {
best = (entry.dayKey, dist)
}
}
return best?.key
}
private func detailLines(model: Model) -> (primary: String, secondary: String?) {
guard let key = self.selectedDayKey,
let day = model.breakdownByDayKey[key],
let date = Self.dateFromDayKey(key)
else {
return ("Hover a bar for details", nil)
}
let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())
let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2)))
if day.services.isEmpty {
return ("\(dayLabel): \(total) credits", nil)
}
if day.services.count <= 1, let first = day.services.first {
let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2)))
return ("\(dayLabel): \(used) credits", first.service)
}
let services = day.services
.sorted { lhs, rhs in
if lhs.creditsUsed == rhs.creditsUsed { return lhs.service < rhs.service }
return lhs.creditsUsed > rhs.creditsUsed
}
.prefix(3)
.map { "\($0.service) \($0.creditsUsed.formatted(.number.precision(.fractionLength(0...2))))" }
.joined(separator: " · ")
return ("\(dayLabel): \(total) credits", services)
}
}