chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user