278 lines · 10.1 KB · Swift
RawDownload
import Chartsimport CodexBarCoreimport SwiftUI @MainActorstruct UsageBreakdownChartMenuView: View {    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]    @State private var selectedDayKey: String?     init(breakdown: [OpenAIDashboardDailyBreakdown]) {        self.breakdown = breakdown    }     var body: some View {        let model = Self.makeModel(from: self.breakdown)        VStack(alignment: .leading, spacing: 10) {            if model.points.isEmpty {                Text("No usage breakdown data.")                    .font(.footnote)                    .foregroundStyle(.secondary)            } else {                Chart(model.points) { point in                    BarMark(                        x: .value("Day", point.date, unit: .day),                        y: .value("Credits used", point.creditsUsed))                        .foregroundStyle(by: .value("Service", point.service))                }                .chartForegroundStyleScale(domain: model.services, range: model.serviceColors)                .chartYAxis(.hidden)                .chartXAxis {                    AxisMarks(values: model.axisDates) { _ in                        AxisGridLine().foregroundStyle(Color.clear)                        AxisTick().foregroundStyle(Color.clear)                        AxisValueLabel(format: .dateTime.month(.abbreviated).day())                            .foregroundStyle(.secondary)                    }                }                .chartLegend(.hidden)                .frame(height: 130)                .chartOverlay { proxy in                    GeometryReader { geo in                        MouseLocationReader { location in                            self.updateSelection(location: location, model: model, proxy: proxy, geo: geo)                        }                        .frame(maxWidth: .infinity, maxHeight: .infinity)                        .contentShape(Rectangle())                    }                }                 Text(self.detailText(model: model))                    .font(.caption)                    .foregroundStyle(.secondary)                    .lineLimit(1)                    .fixedSize(horizontal: false, vertical: true)                    .frame(height: 16, alignment: .leading)                 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: 300, maxWidth: 300, alignment: .leading)    }     private struct Model {        let points: [Point]        let breakdownByDayKey: [String: OpenAIDashboardDailyBreakdown]        let dayDates: [(dayKey: String, date: Date)]        let services: [String]        let serviceColors: [Color]        let axisDates: [Date]         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 func makeModel(from breakdown: [OpenAIDashboardDailyBreakdown]) -> Model {        let sorted = 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)         for day in sorted {            guard let date = self.dateFromDayKey(day.day) else { continue }            breakdownByDayKey[day.day] = day            dayDates.append((dayKey: day.day, date: date))            for service in day.services where service.creditsUsed > 0 {                points.append(Point(date: date, service: service.service, creditsUsed: service.creditsUsed))            }        }         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,            services: services,            serviceColors: colors,            axisDates: axisDates)    }     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 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 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.dayDates.isEmpty else { return nil }        var best: (key: String, distance: TimeInterval)?        for entry in model.dayDates {            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 detailText(model: Model) -> String {        guard let key = self.selectedDayKey,              let day = model.breakdownByDayKey[key],              let date = Self.dateFromDayKey(key)        else {            return "Hover a bar for details"        }         let dayLabel = date.formatted(.dateTime.month(.abbreviated).day())        let total = day.totalCreditsUsed.formatted(.number.precision(.fractionLength(0...2)))        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))"    }}