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)

    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
    }

    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 }
        ]
        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) {
        for (var i = 0; i < options.length; i++) {
            if (Number(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
            }
        }

        Components.RefreshButton {
            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)
            Accessible.name: i18n("History range")
            onActivated: function(index) {
                view.applet.setCostHistoryDays(rangeCombo.valueAt(index))
                // 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)
                })
            }
        }
    }

    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 {
                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 {
                visible: view.dailyPoints.length > 0
                Layout.fillWidth: true
                spacing: Kirigami.Units.smallSpacing / 2

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

                // 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.
                Item {
                    Layout.fillWidth: true
                    Layout.preferredHeight: 7 * heatmapGrid.cellSize
                        + 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.dailyPoints.length / 7)))
                        readonly property real cellSize: Math.max(minimumCellSize, Math.min(
                            maximumCellSize,
                            (width - cellSpacing * (columnCount - 1)) / columnCount))
                        readonly property var cells: view.dailyPoints.slice(
                            Math.max(0, view.dailyPoints.length - columnCount * 7))

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

                        Repeater {
                            model: heatmapGrid.cells

                            delegate: Rectangle {
                                required property var modelData

                                readonly property real fraction: view.heatmapMaximum > 0
                                    ? Math.max(0, Math.min(1, Number(modelData.value) / view.heatmapMaximum))
                                    : 0

                                width: heatmapGrid.cellSize
                                height: heatmapGrid.cellSize
                                radius: Kirigami.Units.cornerRadius / 2
                                color: view.applet.withAlpha(
                                    Kirigami.Theme.highlightColor,
                                    0.1 + fraction * 0.8)
                                border.width: heatmapMouse.containsMouse ? 1 : 0
                                border.color: view.applet.withAlpha(Kirigami.Theme.textColor, 0.4)

                                Components.PlainToolTip {
                                    visible: heatmapMouse.containsMouse
                                    plainText: i18n("%1: %2", modelData.label, 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
                }

                Repeater {
                    id: spendProviderRepeater

                    model: view.presentedProviderCosts

                    delegate: RowLayout {
                        required property var modelData

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

                        Kirigami.Icon {
                            source: view.applet.providerIconSource(modelData.provider)
                            fallback: "view-statistics"
                            isMask: view.applet.providerIconIsMask(modelData.provider)
                            color: view.applet.providerReadableColor(
                                modelData.provider,
                                Kirigami.Theme.backgroundColor)
                            Layout.preferredWidth: Kirigami.Units.iconSizes.small
                            Layout.preferredHeight: Kirigami.Units.iconSizes.small
                        }

                        PlainPlasmaLabel {
                            text: view.applet.providerDisplayTitle(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: modelData.windowValueLine
                            opacity: view.applet.valueTextOpacity
                            horizontalAlignment: Text.AlignRight
                            elide: Text.ElideRight
                        }
                    }
                }
            }
        }
    }
}