import Charts import CodexBarCore import SwiftUI @MainActor struct UsageBreakdownChartMenuView: View { enum PresentationState: Equatable { case empty case totalsOnly case chart } private struct Point: Identifiable { let id: String let date: Date let service: String let creditsUsed: Double init(date: Date, service: String, creditsUsed: Double) { self.date = date self.service = service self.creditsUsed = creditsUsed self.id = "\(service)-\(Int(date.timeIntervalSince1970))- \(creditsUsed)" } } private let breakdown: [OpenAIDashboardDailyBreakdown] private let now: Date private let calendar: Calendar private let width: CGFloat @State private var selectedDayKey: String? init( breakdown: [OpenAIDashboardDailyBreakdown], now: Date = Date(), calendar: Calendar = .current, width: CGFloat) { self.breakdown = breakdown self.now = now self.calendar = calendar self.width = width } var body: some View { let summary = OpenAIDashboardDailyBreakdown.recentUsageSummary( from: self.breakdown, now: self.now, calendar: self.calendar) let model = Self.makeModel(from: summary.daily) let presentationState = Self.presentationState( hasSummary: !summary.daily.isEmpty, hasChartPoints: !model.points.isEmpty) VStack(alignment: .leading, spacing: 10) { if presentationState != .empty { HStack(alignment: .firstTextBaseline) { self.summaryMetric(title: L("Today"), credits: summary.todayCredits) Spacer(minLength: 12) self.summaryMetric( title: String(format: L("Last %d days"), summary.historyDays), credits: summary.totalCredits) } } if presentationState == .empty { Text(L("No usage breakdown data.")) .font(.footnote) .foregroundStyle(.secondary) .accessibilityLabel(L("No usage breakdown data available.")) } else if presentationState == .chart { Chart { ForEach(model.points) { point in BarMark( x: .value(L("Day"), point.date, unit: .day), y: .value(L("Credits used"), point.creditsUsed)) .foregroundStyle(by: .value(L("Service"), point.service)) } if let peak = model.peakPoint { 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)) } } .chartForegroundStyleScale(domain: model.services, range: model.serviceColors) .chartYAxis(.hidden) .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: 130) .accessibilityLabel(L("Usage breakdown chart")) .accessibilityValue( model.points.isEmpty ? L("No data") : String( format: L("%d days of usage data across %d services"), model.points.count, model.services.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) } LazyVGrid( columns: [GridItem(.adaptive(minimum: 110), alignment: .leading)], alignment: .leading, spacing: 6) { ForEach(model.services, id: \.self) { service in HStack(spacing: 6) { Circle() .fill(model.color(for: service)) .frame(width: 7, height: 7) Text(service) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) } } } } } .padding(.horizontal, 16) .padding(.vertical, 10) .frame(minWidth: self.width, maxWidth: .infinity, alignment: .leading) } static func presentationState(hasSummary: Bool, hasChartPoints: Bool) -> PresentationState { if hasChartPoints { return .chart } if hasSummary { return .totalsOnly } return .empty } private struct Model { let points: [Point] let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] let dayDates: [(dayKey: String, date: Date)] let selectableDayDates: [(dayKey: String, date: Date)] let peakPoint: (date: Date, creditsUsed: Double)? let services: [String] let serviceColors: [Color] let axisDates: [Date] let maxCreditsUsed: Double func color(for service: String) -> Color { guard let idx = self.services.firstIndex(of: service), idx < self.serviceColors.count else { return .secondary } return self.serviceColors[idx] } } private static let selectionBandColor = Color(nsColor: .labelColor).opacity(0.1) private func summaryMetric(title: String, credits: Double?) -> some View { VStack(alignment: .leading, spacing: 1) { Text(title) .font(.caption2) .foregroundStyle(.secondary) Text(Self.creditsString(credits)) .font(.subheadline) .fontWeight(.semibold) .monospacedDigit() } .accessibilityElement(children: .combine) } private static func creditsString(_ credits: Double?) -> String { guard let credits, credits.isFinite else { return "—" } let value = credits.formatted(.number.precision(.fractionLength(0...2))) return "\(value) \(L("credits"))" } private static func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model { let sorted = OpenAIDashboardDailyBreakdown.removingSkillUsageServices(from: breakdown) .sorted { lhs, rhs in lhs.day < rhs.day } var points: [Point] = [] points.reserveCapacity(sorted.count * 2) var breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown] = [:] breakdownByDayKey.reserveCapacity(sorted.count) var dayDates: [(dayKey: String, date: Date)] = [] dayDates.reserveCapacity(sorted.count) var selectableDayDates: [(dayKey: String, date: Date)] = [] selectableDayDates.reserveCapacity(sorted.count) var peak: (date: Date, 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)) if day.totalCreditsUsed > 0 { if let cur = peak { if day.totalCreditsUsed > cur.creditsUsed { peak = (date, day.totalCreditsUsed) } } else { peak = (date, day.totalCreditsUsed) } maxCreditsUsed = max(maxCreditsUsed, day.totalCreditsUsed) } var addedSelectable = false for service in day.services where service.creditsUsed > 0 { points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed)) if !addedSelectable { selectableDayDates.append((dayKey: day.day, date: date)) addedSelectable = true } } } let services = Self.serviceOrder(from: sorted) let colors = services.map { Self.colorForService($0) } let axisDates = Self.axisDates(fromSortedDays: sorted) return Model( points: points, breakdownByDayKey: breakdownByDayKey, dayDates: dayDates, selectableDayDates: selectableDayDates, peakPoint: peak, services: services, serviceColors: colors, axisDates: axisDates, maxCreditsUsed: maxCreditsUsed) } private static func capHeight(maxValue: Double) -> Double { maxValue * 0.05 } private static func serviceOrder(from breakdown: [OpenAIDashboardDailyBreakdown]) -> [String] { var totals: [String: Double] = [:] for day in breakdown { for service in day.services { totals[service.service, default: 0] += service.creditsUsed } } return totals .sorted { lhs, rhs in if lhs.value == rhs.value { return lhs.key < rhs.key } return lhs.value > rhs.value } .map(\.key) } private static func colorForService(_ service: String) -> Color { let lower = service.lowercased() if lower == "cli" { return Color(red: 0.26, green: 0.55, blue: 0.96) } if lower.contains("github"), lower.contains("review") { return Color(red: 0.94, green: 0.53, blue: 0.18) } let palette: [Color] = [ Color(red: 0.46, green: 0.75, blue: 0.36), Color(red: 0.80, green: 0.45, blue: 0.92), Color(red: 0.26, green: 0.78, blue: 0.86), Color(red: 0.94, green: 0.74, blue: 0.26), ] let idx = abs(service.hashValue) % palette.count return palette[idx] } private static func axisDates(fromSortedDays sortedDays: [OpenAIDashboardDailyBreakdown]) -> [Date] { guard let first = sortedDays.first, let last = sortedDays.last else { return [] } guard let firstDate = self.dateFromDayKey(first.day), let lastDate = self.dateFromDayKey(last.day) else { return [] } if Calendar.current.isDate(firstDate, inSameDayAs: lastDate) { return [firstDate] } return [firstDate, lastDate] } private static func xAxisLabelAnchor(for date: Date, axisDates: [Date]) -> UnitPoint { if let first = axisDates.first, Calendar.current.isDate(date, inSameDayAs: first) { return .topLeading } if let last = axisDates.last, Calendar.current.isDate(date, inSameDayAs: last) { return .topTrailing } return .top } 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 // Noon avoids off-by-one-day shifts if anything ends up interpreted in UTC. comps.hour = 12 return comps.date } 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 ("\(dayLabel): \(total)", nil) } if day.services.count <= 1, let first = day.services.first { let used = first.creditsUsed.formatted(.number.precision(.fractionLength(0...2))) return ("\(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 ("\(dayLabel): \(total)", services) } }