contents/ui/components/SpendView.qml

import QtQuick
import QtQuick.Controls as Controls
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.plasma.components as PlasmaComponents
import "." as Components
import "../CostPresentation.js" as CostPresentation

ColumnLayout {
    id: view

    required property var applet
    readonly property var dailyPoints: applet.spendDailyPoints()
    readonly property var providerCosts: applet.spendProviderCosts()
    readonly property var presentedProviderCosts: applet.presentedSpendProviderCosts(providerCosts)
    readonly property var costTrustSummary: CostPresentation.costTrustSummary(providerCosts)
    readonly property string spendCurrency: applet.spendCurrency(providerCosts)
    readonly property bool hasMixedCostCurrencies: CostPresentation.spendHasMixedCostCurrencies(providerCosts)
    readonly property real heatmapMaximum: chartMaximum(dailyPoints)
    readonly property var heatmapDays: CostPresentation.spendHeatmapDays(dailyPoints, providerCosts)
    readonly property int heatmapFirstWeekday: Qt.locale().firstDayOfWeek
    readonly property var heatmapRowWeekdays: CostPresentation.spendHeatmapRowWeekdays(
        heatmapDays, heatmapFirstWeekday)
    readonly property int heatmapTrailingSlots: CostPresentation.spendHeatmapTrailingSlots(
        heatmapDays, heatmapFirstWeekday)

    Layout.fillWidth: true
    Layout.fillHeight: true
    spacing: Kirigami.Units.largeSpacing

    function chartMaximum(points) {
        var maximum = 0
        for (var i = 0; i < points.length; i++) {
            maximum = Math.max(maximum, Number(points[i].value) || 0)
        }
        return maximum
    }

    // Day counts are numbers; the calendar periods are the CLI's names.
    function rangeOptions(days) {
        var values = [7, 30, 90]
        var options = [
            { text: i18n("7 days"), value: 7 },
            { text: i18n("30 days"), value: 30 },
            { text: i18n("90 days"), value: 90 },
            { text: i18n("Month to date"), value: "month-to-date" },
            { text: i18n("All history"), value: "all" }
        ]
        var currentDays = Number(days)
        if (values.indexOf(currentDays) === -1) {
            options.unshift({
                text: i18np("%1 day", "%1 days", currentDays),
                value: currentDays
            })
        }
        return options
    }

    function rangeIndex(options, days, period) {
        for (var i = 0; i < options.length; i++) {
            if (period ? options[i].value === period : options[i].value === Number(days)) {
                return i
            }
        }
        return 0
    }

    function metricOptions() {
        return [
            { text: i18n("Cost"), value: "cost" },
            { text: i18n("Tokens"), value: "tokens" }
        ]
    }

    function metricIndex(options, metric) {
        for (var i = 0; i < options.length; i++) {
            if (String(options[i].value) === String(metric)) {
                return i
            }
        }
        return 0
    }

    RowLayout {
        id: spendHeaderRow

        Layout.fillWidth: true
        Layout.rightMargin: Kirigami.Units.smallSpacing
        spacing: Kirigami.Units.smallSpacing

        ColumnLayout {
            Layout.fillWidth: true
            spacing: Kirigami.Units.smallSpacing / 2

            PlainHeading {
                text: i18n("Usage & Spend")
                level: 2
                type: Kirigami.Heading.Type.Primary
                Layout.fillWidth: true
                elide: Text.ElideRight
            }

            PlainPlasmaLabel {
                text: view.applet.spendTotalLine()
                opacity: view.applet.secondaryTextOpacity
                Layout.fillWidth: true
                elide: Text.ElideRight
            }
        }

        PlasmaComponents.ToolButton {
            id: shareButton
            objectName: "shareUsageButton"
            Layout.alignment: Qt.AlignTop
            icon.name: "document-share"
            Accessible.name: i18n("Share AI usage")
            enabled: view.providerCosts.length > 0 && !view.applet.costLoading
            onClicked: view.applet.openShareUsage()
            PlainToolTip {
                parent: shareButton
                visible: shareButton.hovered
                plainText: i18n("Share AI usage")
            }
        }

        Components.RefreshButton {
            Layout.alignment: Qt.AlignTop
            busy: view.applet.costLoading
            label: i18n("Refresh local history")
            onRequested: view.applet.refreshCost(true)
        }
    }

    RowLayout {
        id: historyControlsRow

        Layout.fillWidth: true
        Layout.rightMargin: Kirigami.Units.smallSpacing
        spacing: Kirigami.Units.smallSpacing

        PlainPlasmaLabel {
            text: i18n("History")
            opacity: view.applet.secondaryTextOpacity
            Layout.fillWidth: true
            elide: Text.ElideRight
        }

        Controls.ComboBox {
            id: metricCombo

            textRole: "text"
            valueRole: "value"
            model: view.metricOptions()
            currentIndex: view.metricIndex(model, view.applet.costHistoryMetric)
            Accessible.name: i18n("History metric")
            onActivated: function(index) {
                view.applet.setCostHistoryMetric(metricCombo.valueAt(index))
                // The interactive pick severs the currentIndex binding; restore
                // it so the combo keeps tracking settings changes made outside
                // this view.
                metricCombo.currentIndex = Qt.binding(function() {
                    return view.metricIndex(metricCombo.model, view.applet.costHistoryMetric)
                })
            }
        }

        Controls.ComboBox {
            id: rangeCombo

            textRole: "text"
            valueRole: "value"
            model: view.rangeOptions(view.applet.costHistoryDays)
            currentIndex: view.rangeIndex(model, view.applet.costHistoryDays, view.applet.costHistoryPeriod)
            Accessible.name: i18n("History range")
            onActivated: function(index) {
                var value = rangeCombo.valueAt(index)
                if (typeof value === "string") {
                    view.applet.setCostHistoryPeriod(value)
                } else {
                    view.applet.setCostHistoryDays(value)
                }
                // Same restored binding as the metric combo: keep tracking
                // external settings changes after a pick severs this one.
                rangeCombo.currentIndex = Qt.binding(function() {
                    return view.rangeIndex(rangeCombo.model, view.applet.costHistoryDays,
                        view.applet.costHistoryPeriod)
                })
            }
        }
    }

    Components.PlainInlineMessage {
        visible: view.applet.costErrorText.length > 0
        plainText: view.providerCosts.length > 0
            ? i18n("Some local history is unavailable: %1", view.applet.privateErrorText(view.applet.costErrorText))
            : i18n("Local history is unavailable: %1", view.applet.privateErrorText(view.applet.costErrorText))
        type: Kirigami.MessageType.Warning
        Layout.fillWidth: true
    }

    Components.PlainInlineMessage {
        visible: view.hasMixedCostCurrencies
        plainText: i18n("The cost subtotal and charts use %1. Providers reporting another currency remain separate below. Token figures include every provider.", view.spendCurrency)
        type: Kirigami.MessageType.Information
        Layout.fillWidth: true
    }

    Components.PlainInlineMessage {
        // Distinguishes "you spent little" from "the local scan has not reached
        // that far back yet", which otherwise look identical on the chart.
        visible: view.providerCosts.length > 0 && view.applet.spendHistoryStillBuilding()
        plainText: i18n("Local history is still being collected, so early days may be incomplete.")
        type: Kirigami.MessageType.Information
        Layout.fillWidth: true
    }

    Components.CostTrustNotice {
        noticeScope: "spend"
        stateOwner: view.applet
        presentationVisible: view.visible
        summary: view.costTrustSummary
    }

    Components.PlainInlineMessage {
        visible: view.providerCosts.length > 0 && view.dailyPoints.length === 0
        plainText: view.applet.costHistoryShowsTokens
            ? i18n("No daily token history is available for this range.")
            : i18n("No daily cost history is available for this range. Try Tokens to check for token-only history.")
        type: Kirigami.MessageType.Information
        Layout.fillWidth: true
    }

    Item {
        visible: !view.applet.costLoading
            && view.providerCosts.length === 0
            && view.applet.costErrorText.length === 0
        implicitHeight: emptySpendPlaceholder.implicitHeight
        Layout.fillWidth: true
        Layout.fillHeight: true

        // Keep the native placeholder together while this item fills the view.
        PlainPlaceholderMessage {
            id: emptySpendPlaceholder

            anchors.centerIn: parent
            width: parent.width
            plainText: i18n("No local token or cost history.")
            plainExplanation: i18n("History appears for providers supported by the codexbar cost command.")
            icon.name: "view-statistics-symbolic"
            type: Kirigami.PlaceholderMessage.Type.Informational
        }
    }

    Item {
        visible: view.applet.costLoading && view.providerCosts.length === 0
        Layout.fillWidth: true
        Layout.fillHeight: true

        Controls.BusyIndicator {
            anchors.centerIn: parent
            running: parent.visible
        }
    }

    Item {
        visible: !view.applet.costLoading
            && view.providerCosts.length === 0
            && view.applet.costErrorText.length > 0
        Layout.fillWidth: true
        Layout.fillHeight: true
    }

    PlasmaComponents.ScrollView {
        objectName: "spendHistoryScroll"
        visible: view.providerCosts.length > 0
        Layout.fillWidth: true
        Layout.fillHeight: true
        contentWidth: availableWidth
        clip: true
        PlasmaComponents.ScrollBar.horizontal.policy: PlasmaComponents.ScrollBar.AlwaysOff

        ColumnLayout {
            width: Math.max(0, parent.width - Kirigami.Units.smallSpacing)
            spacing: Kirigami.Units.largeSpacing

            InteractiveChart {
                id: historyChart

                visible: view.dailyPoints.length > 0
                applet: view.applet
                points: view.dailyPoints
                accent: view.applet.readableAccentColor(
                    Kirigami.Theme.highlightColor,
                    Kirigami.Theme.backgroundColor)
                kind: "bar"
                // This chart plots the whole selected range, up to 90 bars, and
                // is the primary view here: it outranks the heatmap below it.
                plotHeight: Kirigami.Units.gridUnit * 6
                accessibleTitle: view.applet.costHistoryShowsTokens
                    ? i18n("Daily token history")
                    : i18n("Daily cost history")
            }

            Components.ProjectCostSection {
                applet: view.applet
                providerCosts: view.presentedProviderCosts
            }

            ColumnLayout {
                // One week column carries a single day per row, which repeats
                // the chart above instead of showing a weekday pattern.
                visible: view.heatmapDays.length > 7
                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing / 2

                PlainPlasmaLabel {
                    text: i18n("Activity heatmap")
                    font.weight: Font.DemiBold
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                }

                // The grid follows the selected range instead of a fixed
                // 42-day window, and derives the cell size from the available
                // width so the cells stay large enough to hover.
                RowLayout {
                    Layout.fillWidth: true
                    spacing: Kirigami.Units.smallSpacing

                    // Without weekday names the rows are an unlabelled stripe
                    // pattern; the labels are what make it a weekday read.
                    Column {
                        id: heatmapWeekdayLabels

                        visible: view.heatmapRowWeekdays.length === 7
                        spacing: heatmapGrid.rowSpacing
                        Layout.alignment: Qt.AlignTop
                        Accessible.ignored: true

                        Repeater {
                            model: view.heatmapRowWeekdays

                            delegate: PlainPlasmaLabel {
                                required property var modelData

                                height: heatmapGrid.cellHeight
                                text: Qt.locale().dayName(modelData, Locale.ShortFormat)
                                font: Kirigami.Theme.smallFont
                                opacity: view.applet.secondaryTextOpacity
                                verticalAlignment: Text.AlignVCenter
                            }
                        }
                    }

                    Item {
                        Layout.fillWidth: true
                        Layout.preferredHeight: 7 * heatmapGrid.cellHeight
                            + 6 * heatmapGrid.rowSpacing
                        clip: true

                        Grid {
                            id: heatmapGrid

                            readonly property real cellSpacing: Math.max(1, Math.round(Kirigami.Units.smallSpacing / 2))
                            readonly property real minimumCellSize: Kirigami.Units.gridUnit * 0.6
                            // Kept below the chart's weight: this is the secondary
                            // read, a weekday pattern, not the magnitude over time.
                            readonly property real maximumCellSize: Kirigami.Units.gridUnit * 1.15
                            readonly property int fittingColumns: Math.max(1, Math.floor(
                                (width + cellSpacing) / (minimumCellSize + cellSpacing)))
                            readonly property int columnCount: Math.max(1, Math.min(
                                fittingColumns, Math.ceil(
                                    (view.heatmapDays.length + view.heatmapTrailingSlots) / 7)))
                            readonly property real availableCellWidth: Math.max(minimumCellSize,
                                (width - cellSpacing * (columnCount - 1)) / columnCount)
                            readonly property real cellHeight: Math.max(minimumCellSize, Math.min(
                                maximumCellSize, availableCellWidth))
                            // Cells widen into spare width up to a 2:1 tile, so a
                            // long range fills the row while a short one keeps
                            // contribution-graph proportions instead of bars.
                            readonly property real cellWidth: Math.min(
                                availableCellWidth, cellHeight * 2)
                            readonly property var cells: CostPresentation.spendHeatmapCells(
                                view.heatmapDays, columnCount * 7, view.heatmapTrailingSlots)

                            width: parent.width
                            rows: 7
                            flow: Grid.TopToBottom
                            columnSpacing: cellSpacing
                            rowSpacing: cellSpacing

                            Repeater {
                                model: heatmapGrid.cells

                                delegate: Rectangle {
                                    id: heatmapCell

                                    required property var modelData

                                    // Padding slots carry no day: they fill the
                                    // oldest corner of the block and stay out of
                                    // hover, the readout, and the reading order.
                                    readonly property bool measured: !!heatmapCell.modelData
                                    readonly property real fraction: heatmapCell.measured && view.heatmapMaximum > 0
                                        ? Math.max(0, Math.min(1,
                                            Number(heatmapCell.modelData.value) / view.heatmapMaximum))
                                        : 0

                                    Accessible.role: Accessible.Graphic
                                    Accessible.ignored: !heatmapCell.measured
                                    // Days read like the chart's, in the locale's date format.
                                    readonly property string dayLabel: heatmapCell.measured
                                        ? historyChart.pointLabel(heatmapCell.modelData) : ""

                                    Accessible.name: heatmapCell.dayLabel
                                    Accessible.description: heatmapCell.measured ? heatmapCell.modelData.displayValue : ""

                                    width: heatmapGrid.cellWidth
                                    height: heatmapGrid.cellHeight
                                    radius: Kirigami.Units.cornerRadius / 2
                                    color: heatmapCell.measured
                                        ? view.applet.withAlpha(
                                            Kirigami.Theme.highlightColor,
                                            0.1 + heatmapCell.fraction * 0.8)
                                        : view.applet.withAlpha(Kirigami.Theme.textColor, 0.05)

                                    // The cell carries no border of its own and the
                                    // hover outline is a separate overlay. Fading a
                                    // border's own alpha does not work: Qt treats a
                                    // zero-alpha pen as invalid and paints the
                                    // rectangle with a zero-width border, so the fill
                                    // snaps out to the cell edge the moment the
                                    // highlight leaves. Grabbing the rendered pixels
                                    // confirmed it: between alpha 0 and 0.01 an edge
                                    // pixel jumped straight from the fill colour to
                                    // the background. Fading this overlay's opacity
                                    // blends that pixel instead, leaving the cell's
                                    // painted geometry untouched.
                                    Rectangle {
                                        anchors.fill: parent
                                        radius: parent.radius
                                        color: "transparent"
                                        border.width: 1
                                        border.color: view.applet.withAlpha(
                                            Kirigami.Theme.textColor, 0.4)
                                        visible: heatmapCell.measured
                                        opacity: heatmapMouse.containsMouse ? 1 : 0

                                        Behavior on opacity {
                                            NumberAnimation {
                                                duration: Kirigami.Units.shortDuration
                                            }
                                        }
                                    }

                                    Components.PlainToolTip {
                                        // A readout for the cell under the pointer,
                                        // not a label for a control: scanning the
                                        // grid must not wait out a hover delay.
                                        delay: 0
                                        visible: heatmapMouse.containsMouse && heatmapCell.measured
                                        plainText: heatmapCell.measured
                                            ? i18n("%1: %2", heatmapCell.dayLabel,
                                                heatmapCell.modelData.displayValue)
                                            : ""
                                    }

                                    MouseArea {
                                        id: heatmapMouse

                                        anchors.fill: parent
                                        hoverEnabled: true
                                        // A binding avoids Qt rejecting a bare zero enum literal.
                                        acceptedButtons: (0)
                                    }
                                }
                            }
                        }
                    }
                }
            }

            ColumnLayout {
                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing

                PlainPlasmaLabel {
                    text: i18n("Providers")
                    font.weight: Font.DemiBold
                    Layout.fillWidth: true
                    elide: Text.ElideRight
                }

                Repeater {
                    id: spendProviderRepeater

                    model: view.presentedProviderCosts

                    delegate: Rectangle {
                        id: spendProviderCard

                        required property var modelData

                        Layout.fillWidth: true
                        implicitHeight: spendProviderRow.implicitHeight + Kirigami.Units.smallSpacing * 1.5
                        radius: view.applet.nestedSurfaceRadius
                        // A passive display row: no hover state, because every
                        // other highlighted surface in the popup acts on click
                        // or reveals actions, and this one does neither.
                        color: view.applet.withAlpha(Kirigami.Theme.textColor, 0.035)

                        Accessible.role: Accessible.ListItem
                        Accessible.name: view.applet.providerDisplayTitle(spendProviderCard.modelData.provider)
                        Accessible.description: spendProviderCard.modelData.windowValueLine

                        RowLayout {
                            id: spendProviderRow

                            anchors.fill: parent
                            anchors.leftMargin: Kirigami.Units.smallSpacing
                            anchors.rightMargin: Kirigami.Units.smallSpacing
                            spacing: Kirigami.Units.smallSpacing

                            Rectangle {
                                Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium
                                Layout.preferredHeight: Layout.preferredWidth
                                // Same identity-tile treatment as the overview
                                // and header tiles, scaled down for this row.
                                radius: view.applet.nestedSurfaceRadius
                                color: view.applet.withAlpha(view.applet.providerReadableColor(
                                    spendProviderCard.modelData.provider,
                                    Kirigami.Theme.backgroundColor), 0.12)

                                Kirigami.Icon {
                                    anchors.centerIn: parent
                                    source: view.applet.providerIconSource(spendProviderCard.modelData.provider)
                                    fallback: "view-statistics"
                                    isMask: view.applet.providerIconIsMask(spendProviderCard.modelData.provider)
                                    color: view.applet.providerReadableColor(
                                        spendProviderCard.modelData.provider,
                                        Kirigami.Theme.backgroundColor)
                                    width: Kirigami.Units.iconSizes.small
                                    height: Kirigami.Units.iconSizes.small
                                }
                            }

                            PlainPlasmaLabel {
                                text: view.applet.providerDisplayTitle(spendProviderCard.modelData.provider)
                                font.weight: Font.DemiBold
                                Layout.fillWidth: true
                                elide: Text.ElideRight
                            }

                            PlainPlasmaLabel {
                                // The range selector above states the window once,
                                // so each row carries only its own figures.
                                text: spendProviderCard.modelData.windowValueLine
                                opacity: view.applet.valueTextOpacity
                                horizontalAlignment: Text.AlignRight
                                elide: Text.ElideRight
                            }
                        }
                    }
                }
            }
        }
    }
}