Merge pull request #5439 from backstage/cost-insights-optional-ratio
cost insights: make change.ratio optional
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': minor
|
||||
---
|
||||
|
||||
make change ratio optional
|
||||
@@ -46,6 +46,7 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${`~1 ${engineers.unit}`}
|
||||
${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~1 ${engineers.unit}`}
|
||||
${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~1 ${engineers.unit}`}
|
||||
${200_000} | ${-0.8} | ${10_000} | ${`80% or ~1 ${engineers.unit}`}
|
||||
@@ -65,6 +66,9 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${-1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${'~$10,000'}
|
||||
${200_000} | ${0.000000001} | ${8_334} | ${'0% or ~$8,334'}
|
||||
${200_000} | ${-0.000000001} | ${10_000} | ${'0% or ~$10,000'}
|
||||
${200_000} | ${-0.8} | ${10_000} | ${'80% or ~$10,000'}
|
||||
@@ -84,6 +88,8 @@ describe.each`
|
||||
engineerCost | ratio | amount | expected
|
||||
${200_000} | ${0} | ${0} | ${'Negligible'}
|
||||
${200_000} | ${0} | ${8_333} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${1_000} | ${'Negligible'}
|
||||
${200_000} | ${undefined} | ${10_000} | ${`~2,857 ${carbon.unit}s`}
|
||||
${200_000} | ${0.000000001} | ${8_334} | ${`0% or ~2,381 ${carbon.unit}s`}
|
||||
${200_000} | ${-0.000000001} | ${10_000} | ${`0% or ~2,857 ${carbon.unit}s`}
|
||||
${200_000} | ${-0.8} | ${10_000} | ${`80% or ~2,857 ${carbon.unit}s`}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useCostGrowthStyles as useStyles } from '../../utils/styles';
|
||||
import { formatPercent, formatCurrency } from '../../utils/formatters';
|
||||
import { indefiniteArticleOf } from '../../utils/grammar';
|
||||
import { useConfig, useCurrency } from '../../hooks';
|
||||
import { notEmpty } from '../../utils/assert';
|
||||
|
||||
export type CostGrowthProps = {
|
||||
change: ChangeStatistic;
|
||||
@@ -42,31 +43,65 @@ export const CostGrowth = ({ change, duration }: CostGrowthProps) => {
|
||||
|
||||
// Only display costs in absolute values
|
||||
const amount = Math.abs(change.amount);
|
||||
const ratio = Math.abs(change.ratio);
|
||||
const ratio = Math.abs(change.ratio ?? NaN);
|
||||
|
||||
const rate = rateOf(engineerCost, duration);
|
||||
const engineers = amount / rate;
|
||||
const converted = amount / (currency.rate ?? rate);
|
||||
|
||||
// If a ratio cannot be calculated, don't format.
|
||||
const growth = notEmpty(change.ratio)
|
||||
? growthOf({ ratio: change.ratio, amount: engineers })
|
||||
: null;
|
||||
// Determine if growth is significant enough to highlight
|
||||
const growth = growthOf(change.ratio, engineers);
|
||||
const classes = classnames({
|
||||
[styles.excess]: growth === GrowthType.Excess,
|
||||
[styles.savings]: growth === GrowthType.Savings,
|
||||
});
|
||||
|
||||
const percent = formatPercent(ratio);
|
||||
|
||||
let cost = `${percent} or ~${formatCurrency(converted, currency.unit)}`;
|
||||
// Always display the converted value but use the cost in engineers
|
||||
// to determine negligibility, as costs should be time-period aware
|
||||
if (engineers < EngineerThreshold) {
|
||||
cost = 'Negligible';
|
||||
} else if (currency.kind === CurrencyType.USD) {
|
||||
cost = `${percent} or ~${currency.prefix}${formatCurrency(converted)}`;
|
||||
} else if (amount < 1) {
|
||||
cost = `less than ${indefiniteArticleOf(['a', 'an'], currency.unit)}`;
|
||||
return <span className={classes}>Negligible</span>;
|
||||
}
|
||||
|
||||
return <span className={classes}>{cost}</span>;
|
||||
if (currency.kind === CurrencyType.USD) {
|
||||
// Do not display percentage if ratio cannot be calculated
|
||||
if (isNaN(ratio)) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
~{currency.prefix}
|
||||
{formatCurrency(converted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{formatPercent(ratio)} or ~{currency.prefix}
|
||||
{formatCurrency(converted)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (amount < 1) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
less than {indefiniteArticleOf(['a', 'an'], currency.unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Do not display percentage if ratio cannot be calculated
|
||||
if (isNaN(ratio)) {
|
||||
return (
|
||||
<span className={classes}>
|
||||
~{formatCurrency(converted, currency.unit)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
{formatPercent(ratio)} or ~{formatCurrency(converted, currency.unit)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,8 +21,6 @@ import { ChangeThreshold, EngineerThreshold } from '../../types';
|
||||
|
||||
describe.each`
|
||||
ratio | amount | ariaLabel
|
||||
${-0.1} | ${undefined} | ${'savings'}
|
||||
${0.01} | ${undefined} | ${'excess'}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${'savings'}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${'savings'}
|
||||
@@ -32,7 +30,7 @@ describe.each`
|
||||
`('growthOf', ({ ratio, amount, ariaLabel }) => {
|
||||
it(`should display the correct indicator for ${ariaLabel}`, async () => {
|
||||
const { getByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
<CostGrowthIndicator change={{ ratio, amount }} />,
|
||||
);
|
||||
expect(getByLabelText(ariaLabel)).toBeInTheDocument();
|
||||
});
|
||||
@@ -40,7 +38,8 @@ describe.each`
|
||||
|
||||
describe.each`
|
||||
ratio | amount
|
||||
${0} | ${undefined}
|
||||
${undefined} | ${0}
|
||||
${0} | ${0}
|
||||
${ChangeThreshold.lower} | ${0}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1}
|
||||
@@ -49,7 +48,7 @@ describe.each`
|
||||
`('growthOf', ({ ratio, amount }) => {
|
||||
it('should display the correct indicator for negligible growth', async () => {
|
||||
const { queryByLabelText } = await renderInTestApp(
|
||||
<CostGrowthIndicator ratio={ratio} amount={amount} />,
|
||||
<CostGrowthIndicator change={{ ratio, amount }} />,
|
||||
);
|
||||
expect(queryByLabelText('savings')).not.toBeInTheDocument();
|
||||
expect(queryByLabelText('excess')).not.toBeInTheDocument();
|
||||
|
||||
@@ -20,49 +20,33 @@ import { Typography, TypographyProps } from '@material-ui/core';
|
||||
import { default as ArrowDropUp } from '@material-ui/icons/ArrowDropUp';
|
||||
import { default as ArrowDropDown } from '@material-ui/icons/ArrowDropDown';
|
||||
import { growthOf } from '../../utils/change';
|
||||
import { GrowthType } from '../../types';
|
||||
import { ChangeStatistic, GrowthType, Maybe } from '../../types';
|
||||
import { useCostGrowthStyles as useStyles } from '../../utils/styles';
|
||||
|
||||
export type CostGrowthIndicatorProps = TypographyProps & {
|
||||
ratio: number;
|
||||
amount?: number;
|
||||
formatter?: (amount: number) => string;
|
||||
change: ChangeStatistic;
|
||||
formatter?: (change: ChangeStatistic) => Maybe<string>;
|
||||
};
|
||||
|
||||
export const CostGrowthIndicator = ({
|
||||
ratio,
|
||||
amount,
|
||||
change,
|
||||
formatter,
|
||||
className,
|
||||
...props
|
||||
}: CostGrowthIndicatorProps) => {
|
||||
const classes = useStyles();
|
||||
const growth = growthOf(ratio, amount);
|
||||
const growth = growthOf(change);
|
||||
|
||||
const classNames = classnames(classes.indicator, className, {
|
||||
[classes.savings]: growth === GrowthType.Savings,
|
||||
[classes.excess]: growth === GrowthType.Excess,
|
||||
[classes.savings]: growth === GrowthType.Savings,
|
||||
});
|
||||
|
||||
// Display cost as a factor of engineer cost growth and percentage growth
|
||||
if (typeof amount === 'number') {
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(amount) : amount}
|
||||
{growth === GrowthType.Savings && (
|
||||
<ArrowDropDown aria-label="savings" />
|
||||
)}
|
||||
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
// Display cost as a factor of percent change
|
||||
return (
|
||||
<Typography className={classNames} component="span" {...props}>
|
||||
{formatter ? formatter(ratio) : ratio}
|
||||
{ratio < 0 && <ArrowDropDown aria-label="savings" />}
|
||||
{ratio > 0 && <ArrowDropUp aria-label="excess" />}
|
||||
{formatter ? formatter(change) : change.ratio}
|
||||
{growth === GrowthType.Excess && <ArrowDropUp aria-label="excess" />}
|
||||
{growth === GrowthType.Savings && <ArrowDropDown aria-label="savings" />}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { CostOverviewLegend } from './CostOverviewLegend';
|
||||
import {
|
||||
MockBillingDateProvider,
|
||||
MockConfigProvider,
|
||||
MockFilterProvider,
|
||||
MockCurrencyProvider,
|
||||
} from '../../testUtils';
|
||||
|
||||
function renderInTestApp(children: JSX.Element) {
|
||||
return render(
|
||||
wrapInTestApp(
|
||||
<MockConfigProvider>
|
||||
<MockCurrencyProvider>
|
||||
<MockBillingDateProvider>
|
||||
<MockFilterProvider>{children}</MockFilterProvider>
|
||||
</MockBillingDateProvider>
|
||||
</MockCurrencyProvider>
|
||||
</MockConfigProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('<CostOverviewLegend />', () => {
|
||||
it('displays the legend without exploding', async () => {
|
||||
const { findByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={{
|
||||
id: 'msc',
|
||||
format: 'number',
|
||||
aggregation: [],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
aggregation: [],
|
||||
change: {
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(await findByText('MSC Trend')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display metric legend if metric data is not provided', async () => {
|
||||
const { findByText, queryByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={null}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
aggregation: [],
|
||||
change: {
|
||||
amount: 0,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(queryByText('MSC Trend')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each`
|
||||
ratio | amount | title | expected
|
||||
${undefined} | ${1_000} | ${'∞'} | ${'Your Excess'}
|
||||
${undefined} | ${-1_000} | ${'-∞'} | ${'Your Savings'}
|
||||
`('<CostOverviewLegend />', ({ ratio, amount, title, expected }) => {
|
||||
it('displays the correct legend if ratio cannot be calculated and costs are within time period', async () => {
|
||||
const { findByText, findAllByText } = renderInTestApp(
|
||||
<CostOverviewLegend
|
||||
metric={{
|
||||
kind: 'msc',
|
||||
name: 'MSC',
|
||||
default: false,
|
||||
}}
|
||||
metricData={{
|
||||
id: 'msc',
|
||||
format: 'number',
|
||||
change: {
|
||||
ratio: ratio,
|
||||
amount: amount,
|
||||
},
|
||||
aggregation: [
|
||||
{
|
||||
date: '2020-01-01',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
date: '2020-07-01', // within default P90D period
|
||||
amount: amount,
|
||||
},
|
||||
],
|
||||
}}
|
||||
dailyCostData={{
|
||||
id: 'mock-id',
|
||||
change: {
|
||||
ratio,
|
||||
amount,
|
||||
},
|
||||
aggregation: [
|
||||
{
|
||||
date: '2020-01-01',
|
||||
amount: 0,
|
||||
},
|
||||
{
|
||||
date: '2020-07-01', // within default P90D period
|
||||
amount: amount,
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await findByText('Cost Trend')).toBeInTheDocument();
|
||||
expect(await findByText('MSC Trend')).toBeInTheDocument();
|
||||
expect(await findAllByText(title).then(res => res.length)).toBe(2);
|
||||
expect(await findByText(expected)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
Metric,
|
||||
} from '../../types';
|
||||
import { useLastCompleteBillingDate, useFilters } from '../../hooks';
|
||||
import { getComparedChange } from '../../utils/change';
|
||||
import { getComparedChange, choose } from '../../utils/change';
|
||||
import { mapFiltersToProps } from './selector';
|
||||
import { formatPercent } from '../../utils/formatters';
|
||||
import { formatChange } from '../../utils/formatters';
|
||||
import { CostGrowth } from '../CostGrowth';
|
||||
|
||||
type CostOverviewLegendProps = {
|
||||
@@ -42,9 +42,8 @@ export const CostOverviewLegend = ({
|
||||
metricData,
|
||||
}: PropsWithChildren<CostOverviewLegendProps>) => {
|
||||
const theme = useTheme<CostInsightsTheme>();
|
||||
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
const { duration } = useFilters(mapFiltersToProps);
|
||||
const lastCompleteBillingDate = useLastCompleteBillingDate();
|
||||
|
||||
const comparedChange = metricData
|
||||
? getComparedChange(
|
||||
@@ -57,23 +56,25 @@ export const CostOverviewLegend = ({
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="row">
|
||||
<Box mr={2}>
|
||||
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
|
||||
{formatPercent(dailyCostData.change!.ratio)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
{metric && metricData && comparedChange && (
|
||||
{dailyCostData.change && (
|
||||
<Box mr={2}>
|
||||
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
|
||||
{formatChange(dailyCostData.change)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
)}
|
||||
{metricData && metric && comparedChange && (
|
||||
<>
|
||||
<Box mr={2}>
|
||||
<LegendItem
|
||||
title={`${metric.name} Trend`}
|
||||
markerColor={theme.palette.magenta}
|
||||
>
|
||||
{formatPercent(metricData.change.ratio)}
|
||||
{formatChange(metricData.change)}
|
||||
</LegendItem>
|
||||
</Box>
|
||||
<LegendItem
|
||||
title={comparedChange.ratio <= 0 ? 'Your Savings' : 'Your Excess'}
|
||||
title={choose(['Your Savings', 'Your Excess'], comparedChange)}
|
||||
>
|
||||
<CostGrowth change={comparedChange} duration={duration} />
|
||||
</LegendItem>
|
||||
|
||||
@@ -18,10 +18,10 @@ import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Table, TableColumn } from '@backstage/core';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { costFormatter, formatPercent } from '../../utils/formatters';
|
||||
import { costFormatter, formatChange } from '../../utils/formatters';
|
||||
import { useEntityDialogStyles as useStyles } from '../../utils/styles';
|
||||
import { CostGrowthIndicator } from '../CostGrowth';
|
||||
import { BarChartOptions, Entity } from '../../types';
|
||||
import { BarChartOptions, ChangeStatistic, Entity } from '../../types';
|
||||
|
||||
export type ProductEntityTableOptions = Partial<
|
||||
Pick<BarChartOptions, 'previousName' | 'currentName'>
|
||||
@@ -32,7 +32,7 @@ type RowData = {
|
||||
label: string;
|
||||
previous: number;
|
||||
current: number;
|
||||
ratio: number;
|
||||
change: ChangeStatistic;
|
||||
};
|
||||
|
||||
function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
@@ -41,7 +41,7 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
const rowStyles = classnames(classes.row, {
|
||||
[classes.rowTotal]: row.id === 'total',
|
||||
[classes.colFirst]: col === 'label',
|
||||
[classes.colLast]: col === 'ratio',
|
||||
[classes.colLast]: col === 'change',
|
||||
});
|
||||
|
||||
switch (col) {
|
||||
@@ -52,12 +52,12 @@ function createRenderer(col: keyof RowData, classes: Record<string, string>) {
|
||||
{costFormatter.format(row[col])}
|
||||
</Typography>
|
||||
);
|
||||
case 'ratio':
|
||||
case 'change':
|
||||
return (
|
||||
<CostGrowthIndicator
|
||||
className={rowStyles}
|
||||
ratio={row.ratio}
|
||||
formatter={amount => formatPercent(Math.abs(amount))}
|
||||
change={row.change}
|
||||
formatter={formatChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
@@ -75,10 +75,15 @@ function createSorter(field?: keyof Omit<RowData, 'id'>) {
|
||||
if (a.id === 'total') return 1;
|
||||
if (b.id === 'total') return 1;
|
||||
if (field === 'label') return a.label.localeCompare(b.label);
|
||||
if (field === 'change') {
|
||||
if (formatChange(a[field]) === '∞' || formatChange(b[field]) === '-∞')
|
||||
return 1;
|
||||
if (formatChange(a[field]) === '-∞' || formatChange(b[field]) === '∞')
|
||||
return -1;
|
||||
return a[field].ratio! - b[field].ratio!;
|
||||
}
|
||||
|
||||
return field
|
||||
? a[field] - b[field]
|
||||
: b.previous + b.current - (a.previous + a.current);
|
||||
return b.previous + b.current - (a.previous + a.current);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,11 +139,11 @@ export const ProductEntityTable = ({
|
||||
customSort: createSorter('current'),
|
||||
},
|
||||
{
|
||||
field: 'ratio',
|
||||
field: 'change',
|
||||
title: <Typography className={lastColClasses}>Change</Typography>,
|
||||
align: 'right',
|
||||
render: createRenderer('ratio', classes),
|
||||
customSort: createSorter('ratio'),
|
||||
render: createRenderer('change', classes),
|
||||
customSort: createSorter('change'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -148,14 +153,14 @@ export const ProductEntityTable = ({
|
||||
label: e.id || 'Unknown',
|
||||
previous: e.aggregation[0],
|
||||
current: e.aggregation[1],
|
||||
ratio: e.change.ratio,
|
||||
change: e.change,
|
||||
}))
|
||||
.concat({
|
||||
id: 'total',
|
||||
label: 'Total',
|
||||
previous: entity.aggregation[0],
|
||||
current: entity.aggregation[1],
|
||||
ratio: entity.change.ratio,
|
||||
change: entity.change,
|
||||
})
|
||||
.sort(createSorter());
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
findAnyKey,
|
||||
assertAlways,
|
||||
} from '../../utils/assert';
|
||||
import { formatPeriod, formatPercent } from '../../utils/formatters';
|
||||
import { formatPeriod, formatChange } from '../../utils/formatters';
|
||||
import {
|
||||
titleOf,
|
||||
tooltipItemOf,
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
useBarChartLayoutStyles as useLayoutStyles,
|
||||
} from '../../utils/styles';
|
||||
import { Duration, Entity, Maybe } from '../../types';
|
||||
import { choose } from '../../utils/change';
|
||||
|
||||
export type ProductInsightsChartProps = {
|
||||
billingDate: string;
|
||||
@@ -86,7 +87,6 @@ export const ProductInsightsChart = ({
|
||||
return breakdowns.length > 0;
|
||||
}, [entities, activeLabel]);
|
||||
|
||||
const legendTitle = `Cost ${entity.change.ratio <= 0 ? 'Savings' : 'Growth'}`;
|
||||
const costStart = entity.aggregation[0];
|
||||
const costEnd = entity.aggregation[1];
|
||||
const resources = entities.map(resourceOf);
|
||||
@@ -136,7 +136,6 @@ export const ProductInsightsChart = ({
|
||||
const items = payload.map(tooltipItemOf).filter(notEmpty);
|
||||
|
||||
const activeEntity = findAlways(entities, e => e.id === id);
|
||||
const ratio = activeEntity.change.ratio;
|
||||
const breakdowns = Object.keys(activeEntity.entities);
|
||||
|
||||
if (breakdowns.length) {
|
||||
@@ -148,11 +147,13 @@ export const ProductInsightsChart = ({
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
!!activeEntity.change.ratio && (
|
||||
<CostGrowthIndicator
|
||||
formatter={formatChange}
|
||||
change={activeEntity.change}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
actions={
|
||||
<Box className={classes.actions}>
|
||||
@@ -173,11 +174,13 @@ export const ProductInsightsChart = ({
|
||||
<BarChartTooltip
|
||||
title={title}
|
||||
topRight={
|
||||
<CostGrowthIndicator
|
||||
className={classes.indicator}
|
||||
ratio={ratio}
|
||||
formatter={formatPercent}
|
||||
/>
|
||||
!!activeEntity.change.ratio && (
|
||||
<CostGrowthIndicator
|
||||
formatter={formatChange}
|
||||
change={activeEntity.change}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
content={
|
||||
id
|
||||
@@ -197,7 +200,9 @@ export const ProductInsightsChart = ({
|
||||
return (
|
||||
<Box className={layoutClasses.wrapper}>
|
||||
<BarChartLegend costStart={costStart} costEnd={costEnd} options={options}>
|
||||
<LegendItem title={legendTitle}>
|
||||
<LegendItem
|
||||
title={choose(['Cost Savings', 'Cost Excess'], entity.change)}
|
||||
>
|
||||
<CostGrowth change={entity.change} duration={duration} />
|
||||
</LegendItem>
|
||||
</BarChartLegend>
|
||||
|
||||
@@ -294,7 +294,6 @@ export const MockBigQueryInsights: Entity = {
|
||||
id: 'dataset-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -415,7 +414,6 @@ export const MockCloudDataflowInsights: Entity = {
|
||||
id: 'pipeline-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -503,7 +501,6 @@ export const MockCloudStorageInsights: Entity = {
|
||||
id: 'Mock SKU C',
|
||||
aggregation: [2_000, 0],
|
||||
change: {
|
||||
ratio: -1,
|
||||
amount: -2000,
|
||||
},
|
||||
entities: {},
|
||||
@@ -515,7 +512,6 @@ export const MockCloudStorageInsights: Entity = {
|
||||
id: 'bucket-c',
|
||||
aggregation: [0, 0],
|
||||
change: {
|
||||
ratio: 0,
|
||||
amount: 0,
|
||||
},
|
||||
entities: {},
|
||||
@@ -655,7 +651,6 @@ export const MockComputeEngineInsights: Entity = {
|
||||
id: 'service-c',
|
||||
aggregation: [0, 10_000],
|
||||
change: {
|
||||
ratio: 10_000,
|
||||
amount: 10_000,
|
||||
},
|
||||
entities: {},
|
||||
|
||||
@@ -93,10 +93,16 @@ export function changeOf(aggregation: DateAggregation[]): ChangeStatistic {
|
||||
const lastAmount = aggregation.length
|
||||
? aggregation[aggregation.length - 1].amount
|
||||
: 0;
|
||||
const ratio =
|
||||
firstAmount !== 0 ? (lastAmount - firstAmount) / firstAmount : 0;
|
||||
|
||||
// if either the first or last amounts are zero, the rate of increase/decrease is infinite
|
||||
if (!firstAmount || !lastAmount) {
|
||||
return {
|
||||
amount: lastAmount - firstAmount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ratio: ratio,
|
||||
ratio: (lastAmount - firstAmount) / firstAmount,
|
||||
amount: lastAmount - firstAmount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
|
||||
export interface ChangeStatistic {
|
||||
// The ratio of change from one duration to another, expressed as: (newSum - oldSum) / oldSum
|
||||
ratio: number;
|
||||
// If a ratio cannot be calculated - such as when a new or old sum is zero,
|
||||
// the ratio can be omitted and where applicable, ∞ or -∞ will display based on amount.
|
||||
ratio?: number;
|
||||
// The actual USD change between time periods (can be negative if costs decreased)
|
||||
amount: number;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function notEmpty<TValue>(
|
||||
return !isNull(value) && !isUndefined(value);
|
||||
}
|
||||
|
||||
export function isUndefined(value: any): boolean {
|
||||
export function isUndefined(value: any): value is undefined {
|
||||
return value === undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,23 +32,23 @@ const GrowthMap = {
|
||||
|
||||
describe.each`
|
||||
ratio | amount | expected
|
||||
${0.0} | ${undefined} | ${GrowthType.Negligible}
|
||||
${undefined} | ${0} | ${GrowthType.Negligible}
|
||||
${0.0} | ${0} | ${GrowthType.Negligible}
|
||||
${0.0} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper - 0.01} | ${undefined} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower - 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper - 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold - 0.1} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.upper + 0.01} | ${0} | ${GrowthType.Negligible}
|
||||
${ChangeThreshold.lower} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${undefined} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.lower - 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Savings}
|
||||
${ChangeThreshold.upper} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${undefined} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold} | ${GrowthType.Excess}
|
||||
${ChangeThreshold.upper + 0.01} | ${EngineerThreshold + 0.1} | ${GrowthType.Excess}
|
||||
`(
|
||||
@@ -63,7 +63,7 @@ describe.each`
|
||||
expected: GrowthType;
|
||||
}) => {
|
||||
it(`should display ${GrowthMap[expected]}`, () => {
|
||||
expect(growthOf(ratio, amount)).toBe(expected);
|
||||
expect(growthOf({ ratio, amount })).toBe(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,21 +27,26 @@ import {
|
||||
import dayjs, { OpUnitType } from 'dayjs';
|
||||
import durationPlugin from 'dayjs/plugin/duration';
|
||||
import { inclusiveStartDateOf } from './duration';
|
||||
import { notEmpty } from './assert';
|
||||
|
||||
dayjs.extend(durationPlugin);
|
||||
|
||||
// Used for displaying status colors
|
||||
export function growthOf(ratio: number, amount?: number) {
|
||||
if (typeof amount === 'number') {
|
||||
if (amount >= EngineerThreshold && ratio >= ChangeThreshold.upper) {
|
||||
export function growthOf(change: ChangeStatistic): GrowthType {
|
||||
const exceedsEngineerThreshold = Math.abs(change.amount) >= EngineerThreshold;
|
||||
|
||||
if (notEmpty(change.ratio)) {
|
||||
if (exceedsEngineerThreshold && change.ratio >= ChangeThreshold.upper) {
|
||||
return GrowthType.Excess;
|
||||
}
|
||||
if (amount >= EngineerThreshold && ratio <= ChangeThreshold.lower) {
|
||||
|
||||
if (exceedsEngineerThreshold && change.ratio <= ChangeThreshold.lower) {
|
||||
return GrowthType.Savings;
|
||||
}
|
||||
} else {
|
||||
if (ratio >= ChangeThreshold.upper) return GrowthType.Excess;
|
||||
if (ratio <= ChangeThreshold.lower) return GrowthType.Savings;
|
||||
if (exceedsEngineerThreshold && change.amount > 0) return GrowthType.Excess;
|
||||
if (exceedsEngineerThreshold && change.amount < 0)
|
||||
return GrowthType.Savings;
|
||||
}
|
||||
|
||||
return GrowthType.Negligible;
|
||||
@@ -54,15 +59,24 @@ export function getComparedChange(
|
||||
duration: Duration,
|
||||
lastCompleteBillingDate: string, // YYYY-MM-DD,
|
||||
): ChangeStatistic {
|
||||
const ratio = dailyCost.change!.ratio - metricData.change.ratio;
|
||||
const dailyCostRatio = dailyCost.change?.ratio;
|
||||
const metricDataRatio = metricData.change?.ratio;
|
||||
const previousPeriodTotal = getPreviousPeriodTotalCost(
|
||||
dailyCost.aggregation,
|
||||
duration,
|
||||
lastCompleteBillingDate,
|
||||
);
|
||||
|
||||
// if either ratio cannot be calculated, no compared ratio can be calculated
|
||||
if (!notEmpty(dailyCostRatio) || !notEmpty(metricDataRatio)) {
|
||||
return {
|
||||
amount: previousPeriodTotal,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ratio: ratio,
|
||||
amount: previousPeriodTotal * ratio,
|
||||
ratio: dailyCostRatio - metricDataRatio,
|
||||
amount: previousPeriodTotal * (dailyCostRatio - metricDataRatio),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +92,6 @@ export function getPreviousPeriodTotalCost(
|
||||
? [dayjsDuration.days(), 'day']
|
||||
: [dayjsDuration.months(), 'month'];
|
||||
const nextPeriodStart = dayjs(startDate).add(amount, type);
|
||||
|
||||
// Add up costs that incurred before the start of the next period.
|
||||
return aggregation.reduce((acc, costByDate) => {
|
||||
return dayjs(costByDate.date).isBefore(nextPeriodStart)
|
||||
@@ -86,3 +99,11 @@ export function getPreviousPeriodTotalCost(
|
||||
: acc;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function choose<T>(
|
||||
[savings, excess]: [T, T],
|
||||
change: ChangeStatistic,
|
||||
): T {
|
||||
const isSavings = (change.ratio ?? change.amount) <= 0;
|
||||
return isSavings ? savings : excess;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
|
||||
import moment from 'moment';
|
||||
import pluralize from 'pluralize';
|
||||
import { Duration } from '../types';
|
||||
import { ChangeStatistic, Duration } from '../types';
|
||||
import { inclusiveEndDateOf, inclusiveStartDateOf } from '../utils/duration';
|
||||
import { notEmpty } from './assert';
|
||||
|
||||
export type Period = {
|
||||
periodStart: string;
|
||||
@@ -78,6 +79,13 @@ export function formatCurrency(amount: number, currency?: string): string {
|
||||
return currency ? `${numString} ${pluralize(currency, n)}` : numString;
|
||||
}
|
||||
|
||||
export function formatChange(change: ChangeStatistic): string {
|
||||
if (notEmpty(change.ratio)) {
|
||||
return formatPercent(Math.abs(change.ratio));
|
||||
}
|
||||
return change.amount >= 0 ? '∞' : '-∞';
|
||||
}
|
||||
|
||||
export function formatPercent(n: number): string {
|
||||
// Number.toFixed shows scientific notation for extreme numbers
|
||||
if (isNaN(n) || Math.abs(n) < 0.01) {
|
||||
|
||||
Reference in New Issue
Block a user