From 81837103a6ee7c553b4defe8093b2a41855cbe5b Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Thu, 21 May 2026 22:40:01 +0200 Subject: [PATCH 1/2] refactor: add static chart legend items Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/components/DualAxisLineChart.tsx | 42 +++++++++++++++++++++------- src/views/CostManagementView.tsx | 16 +++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/components/DualAxisLineChart.tsx b/src/components/DualAxisLineChart.tsx index 681482d..86f7ddb 100644 --- a/src/components/DualAxisLineChart.tsx +++ b/src/components/DualAxisLineChart.tsx @@ -8,6 +8,7 @@ import { Tooltip, Legend, type ChartOptions, + type LegendItem, type ScriptableLineSegmentContext, } from 'chart.js' import { Line } from 'react-chartjs-2' @@ -27,12 +28,23 @@ export interface LineSeries { segmentColor?: (startValue: number, endValue: number) => string } +export interface ExtraLegendItem { + label: string + color: string + legendOrder?: number +} + export interface DualAxisLineChartProps { title: string labels: string[] series: [LineSeries, LineSeries, ...LineSeries[]] height?: number formatYAsCurrency?: boolean + extraLegendItems?: ExtraLegendItem[] +} + +type OrderedLegendItem = LegendItem & { + legendOrder?: number } function formatTick(value: number): string { @@ -52,6 +64,7 @@ export function DualAxisLineChart({ series, height = 320, formatYAsCurrency = false, + extraLegendItems = [], }: DualAxisLineChartProps) { const tickFormatter = formatYAsCurrency ? formatUsdTick : formatTick const usesSecondaryAxis = series.some((dataset) => dataset.yAxisID === 'y1') @@ -111,24 +124,33 @@ export function DualAxisLineChart({ font: { size: 11, weight: 500 }, generateLabels: (chart) => { const defaultLabels = ChartJS.defaults.plugins.legend.labels.generateLabels?.(chart) ?? [] - return defaultLabels.map((item) => { + const datasetLabels: OrderedLegendItem[] = defaultLabels.map((item) => { const dataset = typeof item.datasetIndex === 'number' ? chart.data.datasets[item.datasetIndex] as { legendLabel?: string, legendOrder?: number } : undefined - return dataset?.legendLabel ? { ...item, text: dataset.legendLabel } : item - }).sort((a, b) => { - const datasetA = typeof a.datasetIndex === 'number' - ? chart.data.datasets[a.datasetIndex] as { legendOrder?: number } - : undefined - const datasetB = typeof b.datasetIndex === 'number' - ? chart.data.datasets[b.datasetIndex] as { legendOrder?: number } - : undefined + return { + ...item, + text: dataset?.legendLabel ?? item.text, + legendOrder: dataset?.legendOrder, + } + }) + + const additionalLabels: OrderedLegendItem[] = extraLegendItems.map((item) => ({ + text: item.label, + fillStyle: item.color, + strokeStyle: item.color, + hidden: false, + lineWidth: 2, + legendOrder: item.legendOrder, + })) - return (datasetA?.legendOrder ?? a.datasetIndex ?? 0) - (datasetB?.legendOrder ?? b.datasetIndex ?? 0) + return [...datasetLabels, ...additionalLabels].sort((a, b) => { + return (a.legendOrder ?? a.datasetIndex ?? 0) - (b.legendOrder ?? b.datasetIndex ?? 0) }) }, }, + onClick: () => undefined, }, title: { display: true, diff --git a/src/views/CostManagementView.tsx b/src/views/CostManagementView.tsx index 95eab14..b69f2e1 100644 --- a/src/views/CostManagementView.tsx +++ b/src/views/CostManagementView.tsx @@ -441,15 +441,6 @@ export function CostManagementView({ : SIMULATED_ADDITIONAL_COLOR ), }, - { - label: 'Simulated - additional usage', - legendOrder: 2, - color: SIMULATED_ADDITIONAL_COLOR, - data: cumulativeSimulationSeries.labels.map(() => null), - yAxisID: 'y', - order: 4, - pointRadius: 0, - }, { label: 'Included AI Credits pool', legendOrder: 4, @@ -461,6 +452,13 @@ export function CostManagementView({ pointRadius: 0, }, ]} + extraLegendItems={[ + { + label: 'Simulated - additional usage', + color: SIMULATED_ADDITIONAL_COLOR, + legendOrder: 2, + }, + ]} formatYAsCurrency height={320} /> From 16f3574bbe13af9afce419f242db5df81f10e1c0 Mon Sep 17 00:00:00 2001 From: Anton Sizikov Date: Thu, 21 May 2026 22:40:15 +0200 Subject: [PATCH 2/2] feat: show cumulative AIC usage on overview Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/App.tsx | 1 + src/views/OverviewView.tsx | 120 ++++++++++++++++++++++++++----------- 2 files changed, 85 insertions(+), 36 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 740ab8b..4021204 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -729,6 +729,7 @@ function App() { rangeEnd={rangeEnd} licenseAmount={licenseAmount} licenseSeatCounts={licenseSeatCounts} + includedAicCredits={includedAicPoolSize} reportPlanScope={reportPlanScope} upgradeRecommendation={individualUpgradeRecommendation} onAdjustSeatCounts={reportPlanScope === 'organization' && !isIndividualReport ? () => setActiveView('users') : undefined} diff --git a/src/views/OverviewView.tsx b/src/views/OverviewView.tsx index 1a0b956..25522a0 100644 --- a/src/views/OverviewView.tsx +++ b/src/views/OverviewView.tsx @@ -3,6 +3,7 @@ import { BillingProjectionDisclaimer, BillingTotalsCards } from '../components/u import { appLinks } from '../config/links' import type { ReportPlanScope } from '../pipeline/aicIncludedCredits' import type { DailyUsageData } from '../pipeline/aggregators/dailyUsageAggregator' +import { AIC_UNIT_PRICE_USD } from '../utils/billingConstants' import { fillDataForRange } from '../utils/fillDataForRange' import { formatUsd } from '../utils/format' import type { IndividualPlanUpgradeRecommendation } from '../utils/individualPlanUpgrade' @@ -18,11 +19,16 @@ type OverviewViewProps = { business: number enterprise: number } + includedAicCredits: number reportPlanScope?: ReportPlanScope upgradeRecommendation?: IndividualPlanUpgradeRecommendation | null onAdjustSeatCounts?: () => void } +const CURRENT_AIC_COLOR = '#1a7f37' +const ADDITIONAL_AIC_COLOR = '#cf222e' +const INCLUDED_CREDITS_COLOR = '#0969da' + function createEmptyDailyUsage(date: string): DailyUsageData { return { date, @@ -44,6 +50,7 @@ export function OverviewView({ rangeEnd, licenseAmount, licenseSeatCounts, + includedAicCredits, reportPlanScope = 'organization', upgradeRecommendation = null, onAdjustSeatCounts, @@ -73,6 +80,23 @@ export function OverviewView({ const usageBasedBillingDocsUrl = reportPlanScope === 'individual' ? appLinks.usageBasedBillingForIndividualsDocs : appLinks.usageBasedBillingForOrganizationsDocs + const includedCreditsValue = includedAicCredits * AIC_UNIT_PRICE_USD + const includedCreditsLabel = 'Included value' + const includedCreditsLegendLabel = reportPlanScope === 'individual' ? 'Included AI Credits' : 'Included AI Credits pool' + const includedCreditsDescription = reportPlanScope === 'individual' + ? 'Your plan\'s included AI Credits are consumed first. Additional usage spend starts after cumulative AIC gross cost exceeds the included value.' + : 'The account-wide included AI Credits pool is consumed first. Additional usage spend starts after cumulative AIC gross cost exceeds the included value.' + const includedCreditsCardTitle = reportPlanScope === 'individual' ? 'Included credits are coming' : 'Pooled included credits are coming' + const includedCreditsCardBody = reportPlanScope === 'individual' + ? 'Under usage-based billing, your Copilot plan includes AI Credits each month. Usage consumes those included credits first; additional usage is billed only after they are used.' + : 'Under usage-based billing, included credits will be pooled across all licensed users in your account. No more unused capacity going to waste from idle users.' + const includedCreditsDocsUrl = reportPlanScope === 'individual' + ? appLinks.usageBasedBillingForIndividualsDocs + : appLinks.aiCreditsForOrganizationsDocs + const cumulativeAicGrossAmount = filledDailyUsageData.reduce((totals, day) => { + totals.push((totals[totals.length - 1] ?? 0) + day.aicGrossAmount) + return totals + }, []) return (
@@ -146,25 +170,51 @@ export function OverviewView({
- day.date)} - series={[ - { - label: 'Premium Requests', - color: '#6366f1', - data: filledDailyUsageData.map((day) => day.requests), - yAxisID: 'y', - }, - { - label: 'AI Credits', - color: '#22c55e', - data: filledDailyUsageData.map((day) => day.aicQuantity), - yAxisID: 'y1', - }, - ]} - height={320} - /> +
+ day.date)} + series={[ + { + label: 'AIC gross cost', + legendLabel: 'Usage - within included value', + legendOrder: 1, + color: CURRENT_AIC_COLOR, + data: cumulativeAicGrossAmount, + yAxisID: 'y', + order: 1, + segmentColor: (_startValue, endValue) => ( + endValue <= includedCreditsValue + ? CURRENT_AIC_COLOR + : ADDITIONAL_AIC_COLOR + ), + }, + { + label: includedCreditsLabel, + legendLabel: includedCreditsLegendLabel, + legendOrder: 3, + color: INCLUDED_CREDITS_COLOR, + data: filledDailyUsageData.map(() => includedCreditsValue), + yAxisID: 'y', + borderDash: [2, 4], + order: 2, + pointRadius: 0, + }, + ]} + extraLegendItems={[ + { + label: 'Usage - additional spend', + color: ADDITIONAL_AIC_COLOR, + legendOrder: 2, + }, + ]} + formatYAsCurrency + height={320} + /> +

+ {includedCreditsDescription} +

+
day.date)} @@ -213,24 +263,22 @@ export function OverviewView({ />
- {reportPlanScope === 'organization' && ( -
-
- Pooled included credits are coming -

- Under usage-based billing, included credits will be pooled across all licensed users in your account. No more unused capacity going to waste from idle users. -

-
- - Learn more → - +
+
+ {includedCreditsCardTitle} +

+ {includedCreditsCardBody} +

- )} + + Learn more → + +

Recommended next steps