Merge pull request #5439 from backstage/cost-insights-optional-ratio

cost insights: make change.ratio optional
This commit is contained in:
Ryan Vazquez
2021-04-29 16:39:53 -04:00
committed by GitHub
16 changed files with 338 additions and 113 deletions
@@ -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;
}
+1 -1
View File
@@ -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);
});
},
);
+31 -10
View File
@@ -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) {