From b9705638e969278c5bb9d456d177288b9f7c20cc Mon Sep 17 00:00:00 2001 From: Brenda Sukh Date: Mon, 23 Nov 2020 18:46:20 -0500 Subject: [PATCH] Add top panel breakdown view --- .../CostOverviewByProduct.tsx | 211 ++++++++++++++++++ .../CostOverviewCard/CostOverviewChart.tsx | 2 +- .../CostOverviewCard/CostOverviewHeader.tsx | 4 +- .../cost-insights/src/utils/change.test.ts | 2 +- 4 files changed, 216 insertions(+), 3 deletions(-) create mode 100644 plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProduct.tsx diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProduct.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProduct.tsx new file mode 100644 index 0000000000..1496fcdaf8 --- /dev/null +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewByProduct.tsx @@ -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(); + 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>); + + const chartData: any[] = Object.keys(productsByDate).map(date => { + return { + ...productsByDate[date], + date: Date.parse(date), + }; + }); + + const tooltipRenderer: ContentRenderer = ({ + 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 ( + + {items.reverse().map((item, index) => ( + + ))} + + ); + }; + + 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) => ( + + )); + }; + + return ( + + + + + {currencyFormatter.format(previousPeriodTotal)} + + + + + {currencyFormatter.format(currentPeriodTotal)} + + + + + + + + 0, 'dataMax']} + tick={{ fill: styles.axis.fill }} + tickFormatter={formatGraphValue} + width={styles.yAxis.width} + /> + {renderAreas()} + + + + + + ); +}; diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx index 1910013581..4311081f20 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewChart.tsx @@ -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}`] } diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx index e46b0c3a78..b23dd2041d 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -27,7 +27,9 @@ export const CostOverviewHeader = ({ children, }: PropsWithChildren) => ( { const exclusiveEndDate = '2020-09-30'; expect( getPreviousPeriodTotalCost( - mockGroupDailyCost, + mockGroupDailyCost.aggregation, Duration.P1M, exclusiveEndDate, ),