Add top panel breakdown view

This commit is contained in:
Brenda Sukh
2020-11-23 18:46:20 -05:00
parent f127264419
commit b9705638e9
4 changed files with 216 additions and 3 deletions
@@ -0,0 +1,211 @@
/*
* Copyright 2020 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 dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import { useTheme, Box } from '@material-ui/core';
import {
AreaChart,
ContentRenderer,
TooltipProps,
XAxis,
YAxis,
Tooltip as RechartsTooltip,
Area,
ResponsiveContainer,
CartesianGrid,
} from 'recharts';
import { Cost, DEFAULT_DATE_FORMAT, CostInsightsTheme } from '../../types';
import {
BarChartTooltip as Tooltip,
BarChartTooltipItem as TooltipItem,
} from '../BarChart';
import {
overviewGraphTickFormatter,
formatGraphValue,
isInvalid,
} from '../../utils/graphs';
import {
useCostOverviewStyles as useStyles,
DataVizColors,
} from '../../utils/styles';
import { useFilters, useLastCompleteBillingDate } from '../../hooks';
import { mapFiltersToProps } from './selector';
import { getPreviousPeriodTotalCost } from '../../utils/change';
import { LegendItem } from '../LegendItem';
import { currencyFormatter } from '../../utils/formatters';
import { aggregationSum } from '../../utils/sum';
dayjs.extend(utc);
export type CostOverviewByProductProps = {
dailyCostData: Cost;
};
const LOW_COST_THRESHOLD = 0.01;
export const CostOverviewByProduct = ({
dailyCostData,
}: CostOverviewByProductProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useStyles(theme);
const lastCompleteBillingDate = useLastCompleteBillingDate();
const { duration } = useFilters(mapFiltersToProps);
const groupedCosts = dailyCostData.groupedCosts;
if (!groupedCosts) {
return null;
}
const flattenedAggregation = groupedCosts
.map(cost => cost.aggregation)
.flat();
const totalCost = flattenedAggregation.reduce(
(acc, agg) => acc + agg.amount,
0,
);
const productsByDate = groupedCosts.reduce((prodByDate, group) => {
const product = group.id;
group.aggregation.forEach(curAggregation => {
const productCostsForDate = prodByDate[curAggregation.date] || {};
// Group products with less than 10% of the total cost into "Other" category
if (aggregationSum(group.aggregation) < totalCost * LOW_COST_THRESHOLD) {
prodByDate[curAggregation.date] = {
...productCostsForDate,
Other:
(prodByDate[curAggregation.date].Other || 0) +
curAggregation.amount,
};
} else {
prodByDate[curAggregation.date] = {
...productCostsForDate,
[product]: curAggregation.amount,
};
}
});
return prodByDate;
}, {} as Record<string, Record<string, number>>);
const chartData: any[] = Object.keys(productsByDate).map(date => {
return {
...productsByDate[date],
date: Date.parse(date),
};
});
const tooltipRenderer: ContentRenderer<TooltipProps> = ({
label,
payload = [],
}) => {
if (isInvalid({ label, payload })) return null;
const title = dayjs(label).utc().format(DEFAULT_DATE_FORMAT);
const items = payload.map(p => ({
label: p.dataKey as string,
value: formatGraphValue(p.value as number),
fill: p.fill!,
}));
return (
<Tooltip title={title}>
{items.reverse().map((item, index) => (
<TooltipItem key={`${item.label}-${index}`} item={item} />
))}
</Tooltip>
);
};
const previousPeriodTotal = getPreviousPeriodTotalCost(
flattenedAggregation,
duration,
lastCompleteBillingDate,
);
const currentPeriodTotal = totalCost - previousPeriodTotal;
const renderAreas = () => {
const highCostGroups = groupedCosts.filter(
group =>
aggregationSum(group.aggregation) >= totalCost * LOW_COST_THRESHOLD,
);
const sortedCostGroups = highCostGroups
.sort(
(a, b) => aggregationSum(a.aggregation) - aggregationSum(b.aggregation),
)
.map(group => group.id);
// Keep 'Other' category at the bottom of the stack
return ['Other', ...sortedCostGroups].map((group, i) => (
<Area
dataKey={group}
stackId="1"
fillOpacity="1"
stroke={DataVizColors[i]}
fill={DataVizColors[i]}
/>
));
};
return (
<Box display="flex" flexDirection="column">
<Box display="flex" flexDirection="row">
<Box mr={3}>
<LegendItem title="Previous Period">
{currencyFormatter.format(previousPeriodTotal)}
</LegendItem>
</Box>
<Box>
<LegendItem title="Current Period">
{currencyFormatter.format(currentPeriodTotal)}
</LegendItem>
</Box>
</Box>
<ResponsiveContainer
width={styles.container.width}
height={styles.container.height}
className="cost-overview-chart"
>
<AreaChart
data={chartData}
margin={{
top: 16,
right: 30,
bottom: 40,
}}
>
<CartesianGrid stroke={styles.cartesianGrid.stroke} />
<XAxis
dataKey="date"
domain={['dataMin', 'dataMax']}
tickFormatter={overviewGraphTickFormatter}
tickCount={6}
type="number"
stroke={styles.axis.fill}
/>
<YAxis
domain={[() => 0, 'dataMax']}
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
/>
{renderAreas()}
<RechartsTooltip content={tooltipRenderer} animationDuration={100} />
<RechartsTooltip />
</AreaChart>
</ResponsiveContainer>
</Box>
);
};
@@ -93,7 +93,7 @@ export const CostOverviewChart = ({
.sort(aggregationSort)
.map(entry => ({
date: Date.parse(entry.date),
trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)),
trend: trendFrom(data.dailyCost.data.trendline!, Date.parse(entry.date)),
dailyCost: entry.amount,
...(metric && data.metric.data
? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] }
@@ -27,7 +27,9 @@ export const CostOverviewHeader = ({
children,
}: PropsWithChildren<CostOverviewHeaderProps>) => (
<Box
marginY={1}
mt={2}
ml={1}
mb={1}
display="flex"
flexDirection="row"
justifyContent="space-between"
@@ -79,7 +79,7 @@ describe('getPreviousPeriodTotalCost', () => {
const exclusiveEndDate = '2020-09-30';
expect(
getPreviousPeriodTotalCost(
mockGroupDailyCost,
mockGroupDailyCost.aggregation,
Duration.P1M,
exclusiveEndDate,
),