diff --git a/.changeset/cost-insights-five-baboons-attack.md b/.changeset/cost-insights-five-baboons-attack.md
new file mode 100644
index 0000000000..f2c070acb4
--- /dev/null
+++ b/.changeset/cost-insights-five-baboons-attack.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-cost-insights': minor
+---
+
+make change ratio optional
diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx
index f8c06845c9..7133ae81e3 100644
--- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx
+++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.test.tsx
@@ -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`}
diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx
index 01a0874636..196f8bec44 100644
--- a/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx
+++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowth.tsx
@@ -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 Negligible;
}
- return {cost};
+ if (currency.kind === CurrencyType.USD) {
+ // Do not display percentage if ratio cannot be calculated
+ if (isNaN(ratio)) {
+ return (
+
+ ~{currency.prefix}
+ {formatCurrency(converted)}
+
+ );
+ }
+
+ return (
+
+ {formatPercent(ratio)} or ~{currency.prefix}
+ {formatCurrency(converted)}
+
+ );
+ }
+
+ if (amount < 1) {
+ return (
+
+ less than {indefiniteArticleOf(['a', 'an'], currency.unit)}
+
+ );
+ }
+
+ // Do not display percentage if ratio cannot be calculated
+ if (isNaN(ratio)) {
+ return (
+
+ ~{formatCurrency(converted, currency.unit)}
+
+ );
+ }
+
+ return (
+
+ {formatPercent(ratio)} or ~{formatCurrency(converted, currency.unit)}
+
+ );
};
diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx
index 8be5e238cc..6dc6f2cc93 100644
--- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx
+++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.test.tsx
@@ -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(
- ,
+ ,
);
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(
- ,
+ ,
);
expect(queryByLabelText('savings')).not.toBeInTheDocument();
expect(queryByLabelText('excess')).not.toBeInTheDocument();
diff --git a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx
index e220fdb8e7..a1c200c6b3 100644
--- a/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx
+++ b/plugins/cost-insights/src/components/CostGrowth/CostGrowthIndicator.tsx
@@ -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;
};
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 (
-
- {formatter ? formatter(amount) : amount}
- {growth === GrowthType.Savings && (
-
- )}
- {growth === GrowthType.Excess && }
-
- );
- }
-
- // Display cost as a factor of percent change
return (
- {formatter ? formatter(ratio) : ratio}
- {ratio < 0 && }
- {ratio > 0 && }
+ {formatter ? formatter(change) : change.ratio}
+ {growth === GrowthType.Excess && }
+ {growth === GrowthType.Savings && }
);
};
diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx
new file mode 100644
index 0000000000..112081fed4
--- /dev/null
+++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.test.tsx
@@ -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(
+
+
+
+ {children}
+
+
+ ,
+ ),
+ );
+}
+
+describe('', () => {
+ it('displays the legend without exploding', async () => {
+ const { findByText } = renderInTestApp(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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'}
+`('', ({ 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(
+ ,
+ );
+
+ 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();
+ });
+});
diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx
index ebba5173a9..ad8a065a6e 100644
--- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx
+++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewLegend.tsx
@@ -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) => {
const theme = useTheme();
-
- const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
+ const lastCompleteBillingDate = useLastCompleteBillingDate();
const comparedChange = metricData
? getComparedChange(
@@ -57,23 +56,25 @@ export const CostOverviewLegend = ({
return (
-
-
- {formatPercent(dailyCostData.change!.ratio)}
-
-
- {metric && metricData && comparedChange && (
+ {dailyCostData.change && (
+
+
+ {formatChange(dailyCostData.change)}
+
+
+ )}
+ {metricData && metric && comparedChange && (
<>
- {formatPercent(metricData.change.ratio)}
+ {formatChange(metricData.change)}
diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx
index 45576bda65..f9bb44058b 100644
--- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx
+++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductEntityTable.tsx
@@ -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
@@ -32,7 +32,7 @@ type RowData = {
label: string;
previous: number;
current: number;
- ratio: number;
+ change: ChangeStatistic;
};
function createRenderer(col: keyof RowData, classes: Record) {
@@ -41,7 +41,7 @@ function createRenderer(col: keyof RowData, classes: Record) {
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) {
{costFormatter.format(row[col])}
);
- case 'ratio':
+ case 'change':
return (
formatPercent(Math.abs(amount))}
+ change={row.change}
+ formatter={formatChange}
/>
);
default:
@@ -75,10 +75,15 @@ function createSorter(field?: keyof Omit) {
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: Change,
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());
diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx
index ace2766277..07fea77d9d 100644
--- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx
+++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsChart.tsx
@@ -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={
-
+ !!activeEntity.change.ratio && (
+
+ )
}
actions={
@@ -173,11 +174,13 @@ export const ProductInsightsChart = ({
+ !!activeEntity.change.ratio && (
+
+ )
}
content={
id
@@ -197,7 +200,9 @@ export const ProductInsightsChart = ({
return (
-
+
diff --git a/plugins/cost-insights/src/testUtils/mockData.ts b/plugins/cost-insights/src/testUtils/mockData.ts
index 5149da278d..27bb07d0e1 100644
--- a/plugins/cost-insights/src/testUtils/mockData.ts
+++ b/plugins/cost-insights/src/testUtils/mockData.ts
@@ -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: {},
diff --git a/plugins/cost-insights/src/testUtils/testUtils.ts b/plugins/cost-insights/src/testUtils/testUtils.ts
index 37913f635e..5077e05b75 100644
--- a/plugins/cost-insights/src/testUtils/testUtils.ts
+++ b/plugins/cost-insights/src/testUtils/testUtils.ts
@@ -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,
};
}
diff --git a/plugins/cost-insights/src/types/ChangeStatistic.ts b/plugins/cost-insights/src/types/ChangeStatistic.ts
index a47640411a..70cd9fb9a2 100644
--- a/plugins/cost-insights/src/types/ChangeStatistic.ts
+++ b/plugins/cost-insights/src/types/ChangeStatistic.ts
@@ -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;
}
diff --git a/plugins/cost-insights/src/utils/assert.ts b/plugins/cost-insights/src/utils/assert.ts
index 05ce65197b..5f9f0d01e1 100644
--- a/plugins/cost-insights/src/utils/assert.ts
+++ b/plugins/cost-insights/src/utils/assert.ts
@@ -20,7 +20,7 @@ export function notEmpty(
return !isNull(value) && !isUndefined(value);
}
-export function isUndefined(value: any): boolean {
+export function isUndefined(value: any): value is undefined {
return value === undefined;
}
diff --git a/plugins/cost-insights/src/utils/change.test.ts b/plugins/cost-insights/src/utils/change.test.ts
index 7208e9bc4f..191ae589d6 100644
--- a/plugins/cost-insights/src/utils/change.test.ts
+++ b/plugins/cost-insights/src/utils/change.test.ts
@@ -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);
});
},
);
diff --git a/plugins/cost-insights/src/utils/change.ts b/plugins/cost-insights/src/utils/change.ts
index 950fbfeb82..479818bdae 100644
--- a/plugins/cost-insights/src/utils/change.ts
+++ b/plugins/cost-insights/src/utils/change.ts
@@ -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(
+ [savings, excess]: [T, T],
+ change: ChangeStatistic,
+): T {
+ const isSavings = (change.ratio ?? change.amount) <= 0;
+ return isSavings ? savings : excess;
+}
diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts
index 8f2b94f997..75306d7392 100644
--- a/plugins/cost-insights/src/utils/formatters.ts
+++ b/plugins/cost-insights/src/utils/formatters.ts
@@ -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) {