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(L("No credits history data.")) .font(.footnote) .foregroundStyle(.secondary) .accessibilityLabel(L("No credits history data available.")) } else { Chart { ForEach(model.points) { point in BarMark( x: .value(L("Day"), point.date, unit: .day), y: .value(L("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(L("Day"), peak.date, unit: .day), yStart: .value(L("Cap start"), capStart), yEnd: .value(L("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) .accessibilityLabel(L("Credits history chart")) .accessibilityValue( model.points.isEmpty ? L("No data") : String(format: L("%d days of credits 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.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(String( format: L("Total (30d): %@ credits"), total.formatted(.number.precision(.fractionLength(0...2))))) .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 } if model.dayDates.count <= 1 { return CGRect( x: plotFrame.origin.x, y: plotFrame.origin.y, width: plotFrame.width, height: plotFrame.height) } // Use the calendar day slot width (always 1 day on the time axis) so the band is the // same size for every bar regardless of gaps in the data. let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: date)) ?? (x + 20) let slotWidth = abs(nextDayX - x) let barHalfWidth = slotWidth * 0.25 + 2 let left = plotFrame.origin.x + x - barHalfWidth let right = plotFrame.origin.x + x + barHalfWidth 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 } // Stay on the last selected bar when cursor is in the gap between bars; only switch // selection when the cursor is over the bar's own visual body. // Skip this gate for single-day charts: no gap exists, and selectionBandRect // already covers the full plot width in that case. if model.selectableDayDates.count > 1, let nearestEntry = model.selectableDayDates.first(where: { $0.dayKey == nearest }), let barX = proxy.position(forX: nearestEntry.date) { let nextDayX = proxy.position(forX: ChartBarHoverSelection.nextCalendarDay(after: nearestEntry.date)) ?? (barX + 20) let slotWidth = abs(nextDayX - barX) guard ChartBarHoverSelection.accepts( distanceFromBarCenter: abs(location.x - (plotFrame.origin.x + barX)), barHalfWidth: slotWidth * 0.25 + 2, selectableCount: model.selectableDayDates.count) 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 (L("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 (String(format: L("%@: %@ credits"), dayLabel, total), nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) return (String(format: L("%@: %@ credits"), dayLabel, used), 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 (String(format: L("%@: %@ credits"), dayLabel, total), services) } }