469 lines · 18.7 KB · Swift
RawDownload
import Chartsimport CodexBarCoreimport SwiftUI @MainActorstruct 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)    }}