diff --git a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts index a6ef15f3b9..42f010a645 100644 --- a/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts +++ b/packages/app/src/plugins/cost-insights/ExampleCostInsightsClient.ts @@ -23,6 +23,7 @@ import { Cost, CostInsightsApi, DateAggregation, + DEFAULT_DATE_FORMAT, Duration, exclusiveEndDateOf, Group, @@ -38,18 +39,32 @@ import { UnlabeledDataflowData, } from '@backstage/plugin-cost-insights'; -function durationOf(intervals: string): Duration { - const match = intervals.match(/\/(?P\d+[DM])\//); - const { duration } = match!.groups!; - return duration as Duration; +type IntervalFields = { + duration: Duration; + endDate: string; +}; + +function parseIntervals(intervals: string): IntervalFields { + const match = intervals.match( + /\/(?P\d+[DM])\/(?\d{4}-\d{2}-\d{2})/, + ); + if (Object.keys(match?.groups || {}).length !== 2) { + throw new Error(`Invalid intervals: ${intervals}`); + } + const { duration, date } = match!.groups!; + return { + duration: duration as Duration, + endDate: date, + }; } function aggregationFor( - duration: Duration, + intervals: string, baseline: number, ): DateAggregation[] { - const days = dayjs(exclusiveEndDateOf(duration)).diff( - inclusiveStartDateOf(duration), + const { duration, endDate } = parseIntervals(intervals); + const days = dayjs(exclusiveEndDateOf(duration, endDate)).diff( + inclusiveStartDateOf(duration, endDate), 'day', ); @@ -57,10 +72,10 @@ function aggregationFor( (values: DateAggregation[], i: number): DateAggregation[] => { const last = values.length ? values[values.length - 1].amount : baseline; values.push({ - date: dayjs(inclusiveStartDateOf(duration)) + date: dayjs(inclusiveStartDateOf(duration, endDate)) .add(i, 'day') - .format('YYYY-MM-DD'), - amount: last + (baseline / 20) * (Math.random() * 2 - 1), + .format(DEFAULT_DATE_FORMAT), + amount: Math.max(0, last + (baseline / 20) * (Math.random() * 2 - 1)), }); return values; }, @@ -99,6 +114,12 @@ export class ExampleCostInsightsClient implements CostInsightsApi { return new Promise(resolve => setTimeout(resolve, 0, res)); } + getLastCompleteBillingDate(): Promise { + return Promise.resolve( + dayjs().subtract(1, 'day').format(DEFAULT_DATE_FORMAT), + ); + } + async getUserGroups(userId: string): Promise { const groups: Group[] = await this.request({ userId }, [ { id: 'pied-piper' }, @@ -121,10 +142,10 @@ export class ExampleCostInsightsClient implements CostInsightsApi { metric: string, intervals: string, ): Promise { - const aggregation = aggregationFor( - durationOf(intervals), - 100_000, - ).map(entry => ({ ...entry, amount: Math.round(entry.amount) })); + const aggregation = aggregationFor(intervals, 100_000).map(entry => ({ + ...entry, + amount: Math.round(entry.amount), + })); const cost: MetricData = await this.request( { metric, intervals }, @@ -140,7 +161,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { } async getGroupDailyCost(group: string, intervals: string): Promise { - const aggregation = aggregationFor(durationOf(intervals), 8_000); + const aggregation = aggregationFor(intervals, 8_000); const groupDailyCost: Cost = await this.request( { group, intervals }, { @@ -154,7 +175,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi { } async getProjectDailyCost(project: string, intervals: string): Promise { - const aggregation = aggregationFor(durationOf(intervals), 1_500); + const aggregation = aggregationFor(intervals, 1_500); const projectDailyCost: Cost = await this.request( { project, intervals }, { diff --git a/plugins/cost-insights/src/api/CostInsightsApi.ts b/plugins/cost-insights/src/api/CostInsightsApi.ts index 028aead853..be5cd54fdb 100644 --- a/plugins/cost-insights/src/api/CostInsightsApi.ts +++ b/plugins/cost-insights/src/api/CostInsightsApi.ts @@ -27,6 +27,13 @@ import { } from '../types'; export type CostInsightsApi = { + /** + * Get the most current date for which billing data is complete, in YYYY-MM-DD format. This helps + * define the intervals used in other API methods to avoid showing incomplete cost. The costs for + * today, for example, will not be complete. This ideally comes from the cloud provider. + */ + getLastCompleteBillingDate(): Promise; + /** * Get a list of groups the given user belongs to. These may be LDAP groups or similar * organizational groups. Cost Insights is designed to show costs based on group membership; diff --git a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx index 82954baa86..e230e94130 100644 --- a/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx +++ b/plugins/cost-insights/src/components/AlertInstructionsLayout/AlertInstructionsLayout.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode } from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, Button, Container, makeStyles } from '@material-ui/core'; import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; import { Header, Page } from '@backstage/core'; @@ -30,13 +30,12 @@ const useStyles = makeStyles(theme => ({ type AlertInstructionsLayoutProps = { title: string; - children: ReactNode; }; const AlertInstructionsLayout = ({ title, children, -}: AlertInstructionsLayoutProps) => { +}: PropsWithChildren) => { const classes = useStyles(); return ( diff --git a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx index 3e12c18f90..9e49cb14dd 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartLabel.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box } from '@material-ui/core'; import { useBarChartLabelStyles } from '../../utils/styles'; @@ -23,10 +23,15 @@ type BarChartLabel = { y: number; height: number; width: number; - children?: React.ReactNode; }; -const BarChartLabel = ({ x, y, height, width, children }: BarChartLabel) => { +const BarChartLabel = ({ + x, + y, + height, + width, + children, +}: PropsWithChildren) => { const classes = useBarChartLabelStyles(); const translateX = width * -0.5; const childArray = React.Children.toArray(children); diff --git a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx index b668ffaa5c..66d1424576 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartStepperButton.tsx @@ -14,18 +14,21 @@ * limitations under the License. */ -import React, { forwardRef, Ref } from 'react'; +import React, { forwardRef, PropsWithChildren, Ref } from 'react'; import { ButtonBase, ButtonBaseProps } from '@material-ui/core'; import { useBarChartStepperButtonStyles as useStyles } from '../../utils/styles'; interface BarChartStepperButtonProps extends ButtonBaseProps { name: string; - children?: React.ReactNode; } const BarChartStepperButton = forwardRef( ( - { name, children, ...buttonBaseProps }: BarChartStepperButtonProps, + { + name, + children, + ...buttonBaseProps + }: PropsWithChildren, ref: Ref, ) => { const classes = useStyles(); diff --git a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx index e2ca0fca93..1e5a56f1ff 100644 --- a/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx +++ b/plugins/cost-insights/src/components/CostInsightsLayout/CostInsightsLayout.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { makeStyles } from '@material-ui/core'; import { Header, Page } from '@backstage/core'; import { Group } from '../../types'; @@ -34,10 +34,12 @@ const useStyles = makeStyles(theme => ({ type CostInsightsLayoutProps = { title?: string; groups: Group[]; - children?: React.ReactNode; }; -const CostInsightsLayout = ({ groups, children }: CostInsightsLayoutProps) => { +const CostInsightsLayout = ({ + groups, + children, +}: PropsWithChildren) => { const classes = useStyles(); return ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx index 77d9f5a05e..e28626c6d1 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPage.tsx @@ -15,8 +15,17 @@ */ import React, { useCallback, useEffect, useState } from 'react'; -import { Box, Container, Divider, Grid, Typography } from '@material-ui/core'; -import { Progress, useApi, featureFlagsApiRef } from '@backstage/core'; +import { + Box, + Container, + createStyles, + Divider, + Grid, + makeStyles, + Typography, +} from '@material-ui/core'; +import { featureFlagsApiRef, Progress, useApi } from '@backstage/core'; +import { BackstageTheme } from '@backstage/theme'; import { default as MaterialAlert } from '@material-ui/lab/Alert'; import { costInsightsApiRef } from '../../api'; import AlertActionCardList from '../AlertActionCardList'; @@ -33,11 +42,12 @@ import CostOverviewCard from '../CostOverviewCard'; import ProductInsights from '../ProductInsights'; import CostInsightsSupportButton from '../CostInsightsSupportButton'; import { - useLoading, + useConfig, + useCurrency, useFilters, useGroups, - useCurrency, - useConfig, + useLastCompleteBillingDate, + useLoading, } from '../../hooks'; import { Alert, @@ -50,13 +60,23 @@ import { import { mapLoadingToProps } from './selector'; import ProjectSelect from '../ProjectSelect'; +const useTextStyles = makeStyles((theme: BackstageTheme) => + createStyles({ + root: { + color: theme.palette.textSubtle, + }, + }), +); + const CostInsightsPage = () => { + const classes = useTextStyles(); const flags = useApi(featureFlagsApiRef).getFlags(); // There is not currently a UI to set feature flags // flags.set('cost-insights-currencies', FeatureFlagState.On); const client = useApi(costInsightsApiRef); const config = useConfig(); const groups = useGroups(); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const [currency, setCurrency] = useCurrency(); const [projects, setProjects] = useState>(null); const [dailyCost, setDailyCost] = useState>(null); @@ -101,7 +121,10 @@ const CostInsightsPage = () => { try { if (pageFilters.group) { dispatchLoadingInsights(true); - const intervals = intervalsOf(pageFilters.duration); + const intervals = intervalsOf( + pageFilters.duration, + lastCompleteBillingDate, + ); const [ fetchedProjects, fetchedAlerts, @@ -145,6 +168,7 @@ const CostInsightsPage = () => { dispatchLoadingInsights, dispatchLoadingInitial, dispatchLoadingNone, + lastCompleteBillingDate, ]); if (loadingInitial) { @@ -195,6 +219,9 @@ const CostInsightsPage = () => { > Cost Overview + + Billing data as of {lastCompleteBillingDate} + {!!flags.get('cost-insights-currencies') && ( diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx index b2276481b2..6dca7982db 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsPageRoot.tsx @@ -22,6 +22,7 @@ import { GroupsProvider } from '../../hooks/useGroups'; import { CurrencyProvider } from '../../hooks/useCurrency'; import { ScrollProvider } from '../../hooks/useScroll'; import { ConfigProvider } from '../../hooks/useConfig'; +import { BillingDateProvider } from '../../hooks/useLastCompleteBillingDate'; import { CostInsightsThemeProvider } from './CostInsightsThemeProvider'; const CostInsightsPageRoot = () => ( @@ -29,13 +30,15 @@ const CostInsightsPageRoot = () => ( - - - - - - - + + + + + + + + + diff --git a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx index cecb076d35..a96f0cb4d3 100644 --- a/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx +++ b/plugins/cost-insights/src/components/CostInsightsPage/CostInsightsThemeProvider.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { createMuiTheme, ThemeProvider } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { @@ -23,13 +23,9 @@ import { } from '../../utils/styles'; import { CostInsightsTheme } from '../../types'; -interface CostInsightsThemeProviderProps { - children?: React.ReactNode; -} - export const CostInsightsThemeProvider = ({ children, -}: CostInsightsThemeProviderProps) => { +}: PropsWithChildren<{}>) => { return ( diff --git a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx index 5de90d155a..76a0d944de 100644 --- a/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx +++ b/plugins/cost-insights/src/components/CostOverviewCard/CostOverviewHeader.tsx @@ -13,20 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { PropsWithChildren } from 'react'; import { Box, Typography } from '@material-ui/core'; type CostOverviewHeaderProps = { title: string; subtitle?: string; - children?: React.ReactNode; }; const CostOverviewHeader = ({ title, subtitle, children, -}: CostOverviewHeaderProps) => ( +}: PropsWithChildren) => ( { +}: PropsWithChildren) => { const classes = useCostGrowthLegendStyles(); return ( diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx index c632ebad34..b5381cdd84 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.test.tsx @@ -17,32 +17,41 @@ import React from 'react'; import { getByRole, waitFor } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; -import PeriodSelect, { DEFAULT_OPTIONS as options } from './PeriodSelect'; +import PeriodSelect, { getDefaultOptions } from './PeriodSelect'; import { Duration, getDefaultPageFilters, Group } from '../../types'; import { renderInTestApp } from '@backstage/test-utils'; +import { MockBillingDateProvider } from '../../utils/tests'; const DefaultPageFilters = getDefaultPageFilters([{ id: 'tools' }] as Group[]); - -Date.now = jest.fn(() => new Date(Date.parse('2020-05-01')).valueOf()); +const lastCompleteBillingDate = '2020-05-01'; +const options = getDefaultOptions(lastCompleteBillingDate); describe('', () => { it('Renders without exploding', async () => { const rendered = await renderInTestApp( - , + + + , ); expect(rendered.getByTestId('period-select')).toBeInTheDocument(); }); it('Should display all costGrowth period options', async () => { const rendered = await renderInTestApp( - , + + + , ); const periodSelectContainer = rendered.getByTestId('period-select'); const button = getByRole(periodSelectContainer, 'button'); @@ -70,7 +79,11 @@ describe('', () => { : DefaultPageFilters.duration; const rendered = await renderInTestApp( - , + + , + , ); const periodSelect = rendered.getByTestId('period-select'); const button = getByRole(periodSelect, 'button'); diff --git a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx index 688e22452e..08cf8a73f9 100644 --- a/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx +++ b/plugins/cost-insights/src/components/PeriodSelect/PeriodSelect.tsx @@ -22,35 +22,35 @@ import { } from '../../utils/formatters'; import { Duration, findAlways } from '../../types'; import { useSelectStyles as useStyles } from '../../utils/styles'; +import { useLastCompleteBillingDate } from '../../hooks'; export type PeriodOption = { value: Duration; label: string; }; -const LAST_6_MONTHS = 'Past 6 Months'; -const LAST_60_DAYS = 'Past 60 Days'; -const LAST_2_COMPLETED_MONTHS = formatLastTwoMonths(); -const LAST_2_LOOKAHEAD_QUARTERS = formatLastTwoLookaheadQuarters(); - -export const DEFAULT_OPTIONS: PeriodOption[] = [ - { - value: Duration.P90D, - label: LAST_6_MONTHS, - }, - { - value: Duration.P30D, - label: LAST_60_DAYS, - }, - { - value: Duration.P1M, - label: LAST_2_COMPLETED_MONTHS, - }, - { - value: Duration.P3M, - label: LAST_2_LOOKAHEAD_QUARTERS, - }, -]; +export function getDefaultOptions( + lastCompleteBillingDate: string, +): PeriodOption[] { + return [ + { + value: Duration.P90D, + label: 'Past 6 Months', + }, + { + value: Duration.P30D, + label: 'Past 60 Days', + }, + { + value: Duration.P1M, + label: formatLastTwoMonths(lastCompleteBillingDate), + }, + { + value: Duration.P3M, + label: formatLastTwoLookaheadQuarters(lastCompleteBillingDate), + }, + ]; +} type PeriodSelectProps = { duration: Duration; @@ -58,19 +58,18 @@ type PeriodSelectProps = { options?: PeriodOption[]; }; -const PeriodSelect = ({ - duration, - onSelect, - options = DEFAULT_OPTIONS, -}: PeriodSelectProps) => { +const PeriodSelect = ({ duration, onSelect, options }: PeriodSelectProps) => { const classes = useStyles(); + const lastCompleteBillingDate = useLastCompleteBillingDate(); + const optionsOrDefault = + options ?? getDefaultOptions(lastCompleteBillingDate); const handleOnChange: SelectProps['onChange'] = e => { onSelect(e.target.value as Duration); }; const renderValue: SelectProps['renderValue'] = value => { - const option = findAlways(DEFAULT_OPTIONS, o => o.value === value); + const option = findAlways(optionsOrDefault, o => o.value === value); return {option.label}; }; @@ -83,7 +82,7 @@ const PeriodSelect = ({ renderValue={renderValue} data-testid="period-select" > - {options.map(option => ( + {optionsOrDefault.map(option => ( ({ const renderProductInsightsCardInTestApp = async ( productCost: ProductCost, product: Product, + duration: Duration, ) => await renderInTestApp( @@ -102,19 +105,22 @@ const renderProductInsightsCardInTestApp = async ( }} > - - - - - - - + + + + + + + + + @@ -126,6 +132,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( mockProductCost, MockComputeEngine, + Duration.P1M, ); expect( rendered.queryByTestId(`scroll-test-compute-engine`), @@ -140,6 +147,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( productCost, MockComputeEngine, + Duration.P1M, ); const subheader = 'entities, sorted by cost'; const subheaderRgx = new RegExp( @@ -154,6 +162,7 @@ describe('', () => { const rendered = await renderProductInsightsCardInTestApp( productCost, MockComputeEngine, + Duration.P1M, ); const subheaderRgx = new RegExp(subheader); expect(rendered.getByText(subheaderRgx)).toBeInTheDocument(); @@ -164,4 +173,27 @@ describe('', () => { rendered.queryByTestId('.insights-bar-chart'), ).not.toBeInTheDocument(); }); + + describe.each` + duration | periodStartText | periodEndText + ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} + ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} + `( + 'Should display the correct relative time', + ({ duration, periodStartText, periodEndText }) => { + it(`Should display the correct relative time for ${duration}`, async () => { + const productCost = { + ...mockProductCost, + entities: [...Array(3)].map(createMockEntity), + }; + const rendered = await renderProductInsightsCardInTestApp( + productCost, + MockComputeEngine, + duration, + ); + expect(rendered.getByText(periodStartText)).toBeInTheDocument(); + expect(rendered.getByText(periodEndText)).toBeInTheDocument(); + }); + }, + ); }); diff --git a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx index 13bb990740..bf56eb23e8 100644 --- a/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx +++ b/plugins/cost-insights/src/components/ProductInsightsCard/ProductInsightsCard.tsx @@ -22,11 +22,17 @@ import { costInsightsApiRef } from '../../api'; import PeriodSelect from '../PeriodSelect'; import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; -import { useFilters, useLoading, useScroll } from '../../hooks'; +import { + useFilters, + useLastCompleteBillingDate, + useLoading, + useScroll, +} from '../../hooks'; import { useProductInsightsCardStyles as useStyles } from '../../utils/styles'; import { mapFiltersToProps, mapLoadingToProps } from './selector'; import { Duration, Maybe, Product, ProductCost } from '../../types'; import { pluralOf } from '../../utils/grammar'; +import { formatPeriod } from '../../utils/formatters'; type ProductInsightsCardProps = { product: Product; @@ -36,6 +42,7 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const client = useApi(costInsightsApiRef); const classes = useStyles(); const { ScrollAnchor } = useScroll(product.kind); + const lastCompleteBillingDate = useLastCompleteBillingDate(); const [resource, setResource] = useState>(null); const [error, setError] = useState>(null); @@ -53,6 +60,17 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { const amount = resource?.entities?.length || 0; const hasCostsWithinTimeframe = resource?.change && !!amount; + const previousName = formatPeriod( + productFilter.duration, + lastCompleteBillingDate, + false, + ); + const currentName = formatPeriod( + productFilter.duration, + lastCompleteBillingDate, + true, + ); + const subheader = amount ? `${amount} ${pluralOf(amount, 'entity', 'entities')}, sorted by cost` : `There are no ${product.name} costs within this timeframe for your team's projects.`; @@ -131,12 +149,15 @@ const ProductInsightsCard = ({ product }: ProductInsightsCardProps) => { diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx index b914b3885d..b3af1a49cd 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.test.tsx @@ -18,7 +18,11 @@ import React from 'react'; import { renderInTestApp } from '@backstage/test-utils'; import ProjectGrowthAlertCard from './ProjectGrowthAlertCard'; import { createMockProjectGrowthData } from '../../utils/mockData'; -import { MockCurrencyProvider, MockConfigProvider } from '../../utils/tests'; +import { + MockCurrencyProvider, + MockConfigProvider, + MockBillingDateProvider, +} from '../../utils/tests'; import { AlertCost, defaultCurrencies, findAlways } from '../../types'; const engineers = findAlways(defaultCurrencies, c => c.kind === null); @@ -49,9 +53,11 @@ describe('', () => { engineerCost={200_000} currencies={[]} > - - , - + + + , + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); @@ -69,14 +75,16 @@ describe('', () => { engineerCost={200_000} currencies={[]} > - - - + + + + + , ); expect(rendered.getByText(title)).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx index 8d1d1d29b1..e87f538a82 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthAlertCard/ProjectGrowthAlertCard.tsx @@ -21,6 +21,7 @@ import ResourceGrowthBarChart from '../ResourceGrowthBarChart'; import ResourceGrowthBarChartLegend from '../ResourceGrowthBarChartLegend'; import { Duration, ProjectGrowthData } from '../../types'; import { pluralOf } from '../../utils/grammar'; +import { formatPeriod } from '../../utils/formatters'; type ProjectGrowthAlertProps = { alert: ProjectGrowthData; @@ -33,6 +34,8 @@ const ProjectGrowthAlertCard = ({ alert }: ProjectGrowthAlertProps) => { ${alert.products.length} ${pluralOf(alert.products.length, 'product')}${ alert.products.length > 1 ? ', sorted by cost' : '' }`; + const previousName = formatPeriod(Duration.P3M, alert.periodStart, false); + const currentName = formatPeriod(Duration.P3M, alert.periodEnd, true); return ( { diff --git a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx index cde989c9dd..634514c1df 100644 --- a/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx +++ b/plugins/cost-insights/src/components/ProjectGrowthInstructionsPage/ProjectGrowthInstructionsPage.tsx @@ -160,14 +160,17 @@ const ProjectGrowthInstructionsPage = () => { diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx index 8355deab7d..9a3899f2ff 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.test.tsx @@ -16,7 +16,6 @@ import React from 'react'; import ResourceGrowthBarChart from './ResourceGrowthBarChart'; -import { Duration } from '../../types'; import { renderInTestApp } from '@backstage/test-utils'; import { createMockEntity } from '../../utils/mockData'; @@ -32,8 +31,9 @@ describe('', () => { it('Pre-renders without exploding', async () => { const rendered = await renderInTestApp( , ); expect(rendered.queryByTestId('bar-chart-wrapper')).toBeInTheDocument(); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx index 3ae35ba871..daa6d88683 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChart/ResourceGrowthBarChart.tsx @@ -16,41 +16,33 @@ import React from 'react'; import { TooltipPayload } from 'recharts'; +import { currencyFormatter } from '../../utils/formatters'; import { - currencyFormatter, - dateRegex, - formatDuration, -} from '../../utils/formatters'; -import { + AlertCost, BarChartData, CostInsightsTheme, DataKey, - Duration, Entity, - inclusiveEndDateOf, - inclusiveStartDateOf, Maybe, ResourceData, - AlertCost, } from '../../types'; import BarChart from '../BarChart'; import { TooltipItemProps } from '../Tooltip'; import { useTheme } from '@material-ui/core'; export type ResourceGrowthBarChartProps = { - duration: Duration; resources: Array; + previousName: string; + currentName: string; }; const ResourceGrowthBarChart = ({ - duration, resources, + previousName, + currentName, }: ResourceGrowthBarChartProps) => { const theme = useTheme(); const getTooltipItem = (payload: TooltipPayload): Maybe => { - const label = dateRegex.test(payload.name) - ? formatDuration(payload.name, duration) - : payload.name; const value = typeof payload.value === 'number' ? currencyFormatter.format(payload.value) @@ -61,7 +53,7 @@ const ResourceGrowthBarChart = ({ case DataKey.Current: case DataKey.Previous: return { - label: label, + label: payload.name, value: value, fill: fill, }; @@ -73,8 +65,8 @@ const ResourceGrowthBarChart = ({ const barChartData: BarChartData = { previousFill: theme.palette.lightBlue, currentFill: theme.palette.darkBlue, - previousName: inclusiveStartDateOf(duration), - currentName: inclusiveEndDateOf(duration), + previousName: previousName, + currentName: currentName, }; const resourceData: ResourceData[] = resources.map(resource => { diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx index 1e432ff3bc..ecc601a0b7 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.test.tsx @@ -56,6 +56,8 @@ describe('', () => { @@ -66,28 +68,4 @@ describe('', () => { }); }, ); - - describe.each` - duration | periodStartText | periodEndText - ${Duration.P30D} | ${'First 30 Days'} | ${'Last 30 Days'} - ${Duration.P90D} | ${'First 90 Days'} | ${'Last 90 Days'} - `( - 'Should display the correct relative time', - ({ duration, periodStartText, periodEndText }) => { - it(`Should display the correct relative time for ${duration}`, async () => { - const rendered = await renderInTestApp( - - - , - ); - expect(rendered.getByText(periodStartText)).toBeInTheDocument(); - expect(rendered.getByText(periodEndText)).toBeInTheDocument(); - }); - }, - ); }); diff --git a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx index 1466e18ee1..9b0f77ceda 100644 --- a/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx +++ b/plugins/cost-insights/src/components/ResourceGrowthBarChartLegend/ResourceGrowthBarChartLegend.tsx @@ -18,18 +18,14 @@ import React from 'react'; import { Box, useTheme } from '@material-ui/core'; import LegendItem from '../LegendItem'; import CostGrowth from '../CostGrowth'; -import { currencyFormatter, formatDuration } from '../../utils/formatters'; -import { - ChangeStatistic, - CostInsightsTheme, - Duration, - inclusiveEndDateOf, - inclusiveStartDateOf, -} from '../../types'; +import { currencyFormatter } from '../../utils/formatters'; +import { ChangeStatistic, CostInsightsTheme, Duration } from '../../types'; export type ResourceGrowthBarChartLegendProps = { change: ChangeStatistic; duration: Duration; + previousName: string; + currentName: string; costStart: number; costEnd: number; }; @@ -37,28 +33,22 @@ export type ResourceGrowthBarChartLegendProps = { const ResourceGrowthBarChartLegend = ({ change, duration, + previousName, + currentName, costStart, costEnd, }: ResourceGrowthBarChartLegendProps) => { const theme = useTheme(); - const startOf = inclusiveStartDateOf(duration); - const endOf = inclusiveEndDateOf(duration); - const periodStartTitle = formatDuration(startOf, duration); - const periodEndTitle = formatDuration(endOf, duration); - return ( - + {currencyFormatter.format(costStart)} - + {currencyFormatter.format(costEnd)} diff --git a/plugins/cost-insights/src/hooks/index.ts b/plugins/cost-insights/src/hooks/index.ts index 8998a10e2c..94c556763c 100644 --- a/plugins/cost-insights/src/hooks/index.ts +++ b/plugins/cost-insights/src/hooks/index.ts @@ -21,3 +21,4 @@ export * from './useCurrency'; export * from './useGroups'; export * from './useLoading'; export * from './useScroll'; +export * from './useLastCompleteBillingDate'; diff --git a/plugins/cost-insights/src/hooks/useConfig.tsx b/plugins/cost-insights/src/hooks/useConfig.tsx index 6cbdd1a2ef..f63b9228a9 100644 --- a/plugins/cost-insights/src/hooks/useConfig.tsx +++ b/plugins/cost-insights/src/hooks/useConfig.tsx @@ -15,15 +15,15 @@ */ import React, { - ReactNode, createContext, + PropsWithChildren, useContext, useEffect, useState, } from 'react'; -import { useApi, configApiRef } from '@backstage/core'; +import { configApiRef, useApi } from '@backstage/core'; import { Config as BackstageConfig } from '@backstage/config'; -import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types'; +import { Currency, defaultCurrencies, Icon, Metric, Product } from '../types'; import { getIcon } from '../utils/navigation'; import { validateMetrics } from '../utils/config'; @@ -67,7 +67,7 @@ const defaultState: ConfigContextProps = { currencies: defaultCurrencies, }; -export const ConfigProvider = ({ children }: { children: ReactNode }) => { +export const ConfigProvider = ({ children }: PropsWithChildren<{}>) => { const c: BackstageConfig = useApi(configApiRef); const [config, setConfig] = useState(defaultState); const [loading, setLoading] = useState(true); diff --git a/plugins/cost-insights/src/hooks/useCurrency.tsx b/plugins/cost-insights/src/hooks/useCurrency.tsx index f5d0df929a..34a4f54d5f 100644 --- a/plugins/cost-insights/src/hooks/useCurrency.tsx +++ b/plugins/cost-insights/src/hooks/useCurrency.tsx @@ -16,9 +16,9 @@ import React, { Dispatch, SetStateAction, - ReactNode, useState, useContext, + PropsWithChildren, } from 'react'; import { Currency, defaultCurrencies, findAlways } from '../types'; @@ -27,15 +27,11 @@ export type CurrencyContextProps = { setCurrency: Dispatch>; }; -export type CurrencyProviderProps = { - children: ReactNode; -}; - export const CurrencyContext = React.createContext< CurrencyContextProps | undefined >(undefined); -export const CurrencyProvider = ({ children }: CurrencyProviderProps) => { +export const CurrencyProvider = ({ children }: PropsWithChildren<{}>) => { const engineers = findAlways(defaultCurrencies, c => c.kind === null); const [currency, setCurrency] = useState(engineers); return ( diff --git a/plugins/cost-insights/src/hooks/useGroups.tsx b/plugins/cost-insights/src/hooks/useGroups.tsx index 8f11c88e60..d05920e561 100644 --- a/plugins/cost-insights/src/hooks/useGroups.tsx +++ b/plugins/cost-insights/src/hooks/useGroups.tsx @@ -30,7 +30,7 @@ type GroupsProviderLoadingProps = { dispatchLoadingGroups: (isLoading: boolean) => void; }; -export const mapLoadingToProps: MapLoadingToProps = ({ +const mapLoadingToProps: MapLoadingToProps = ({ dispatch, }) => ({ dispatchLoadingGroups: (isLoading: boolean) => diff --git a/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx new file mode 100644 index 0000000000..8df1483eaa --- /dev/null +++ b/plugins/cost-insights/src/hooks/useLastCompleteBillingDate.tsx @@ -0,0 +1,102 @@ +/* + * 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, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import { Alert } from '@material-ui/lab'; +import { useApi } from '@backstage/core'; +import { costInsightsApiRef } from '../api'; +import { MapLoadingToProps, useLoading } from './useLoading'; +import { DefaultLoadingAction, Maybe } from '../types'; + +type BillingDateProviderLoadingProps = { + dispatchLoadingBillingDate: (isLoading: boolean) => void; +}; + +const mapLoadingToProps: MapLoadingToProps = ({ + dispatch, +}) => ({ + dispatchLoadingBillingDate: (isLoading: boolean) => + dispatch({ [DefaultLoadingAction.LastCompleteBillingDate]: isLoading }), +}); + +export type BillingDateContextProps = { + lastCompleteBillingDate: string; // YYYY-MM-DD +}; + +export const BillingDateContext = React.createContext< + BillingDateContextProps | undefined +>(undefined); + +export const BillingDateProvider = ({ children }: PropsWithChildren<{}>) => { + const client = useApi(costInsightsApiRef); + const [error, setError] = useState>(null); + const { dispatchLoadingBillingDate } = useLoading(mapLoadingToProps); + + const [lastCompleteBillingDate, setlastCompleteBillingDate] = useState< + Maybe + >(null); + + useEffect(() => { + dispatchLoadingBillingDate(true); + + async function getLastCompleteBillingDate() { + try { + const d = await client.getLastCompleteBillingDate(); + setlastCompleteBillingDate(d); + } catch (e) { + setError(e); + } finally { + dispatchLoadingBillingDate(false); + } + } + + getLastCompleteBillingDate(); + }, [client]); // eslint-disable-line react-hooks/exhaustive-deps + + if (error) { + return {error.message}; + } + + if (!lastCompleteBillingDate) return null; + + return ( + + {children} + + ); +}; + +export function useLastCompleteBillingDate(): string { + const context = useContext(BillingDateContext); + return context ? context.lastCompleteBillingDate : assertNever(); +} + +function assertNever(): never { + throw Error( + 'Cannot use useLastCompleteBillingDate outside of BillingDateProvider', + ); +} diff --git a/plugins/cost-insights/src/hooks/useLoading.tsx b/plugins/cost-insights/src/hooks/useLoading.tsx index bc0df4bbc7..31402ba0d1 100644 --- a/plugins/cost-insights/src/hooks/useLoading.tsx +++ b/plugins/cost-insights/src/hooks/useLoading.tsx @@ -15,10 +15,10 @@ */ import React, { - Dispatch, - ReactNode, - SetStateAction, createContext, + Dispatch, + PropsWithChildren, + SetStateAction, useContext, useEffect, useMemo, @@ -27,10 +27,10 @@ import React, { } from 'react'; import { Backdrop, CircularProgress } from '@material-ui/core'; import { - Loading, - getDefaultState, DefaultLoadingAction, + getDefaultState, getLoadingActions, + Loading, } from '../types'; import { useBackdropStyles as useStyles } from '../utils/styles'; import { useConfig } from './useConfig'; @@ -41,10 +41,6 @@ export type LoadingContextProps = { actions: Array; }; -export type LoadingProviderProps = { - children: ReactNode; -}; - export type MapLoadingToProps = (props: LoadingContextProps) => T; export const LoadingContext = createContext( @@ -58,7 +54,7 @@ function reducer(prevState: Loading, action: Partial): Loading { } as Record; } -export const LoadingProvider = ({ children }: LoadingProviderProps) => { +export const LoadingProvider = ({ children }: PropsWithChildren<{}>) => { const classes = useStyles(); const { products } = useConfig(); const actions = useMemo(() => getLoadingActions(products.map(p => p.kind)), [ diff --git a/plugins/cost-insights/src/hooks/useScroll.tsx b/plugins/cost-insights/src/hooks/useScroll.tsx index 480495a257..0a1abbdad3 100644 --- a/plugins/cost-insights/src/hooks/useScroll.tsx +++ b/plugins/cost-insights/src/hooks/useScroll.tsx @@ -15,12 +15,12 @@ */ import React, { Dispatch, - ReactNode, SetStateAction, useState, useContext, useEffect, useRef, + PropsWithChildren, } from 'react'; import { CSSProperties } from '@material-ui/styles'; import { Maybe } from '../types'; @@ -32,10 +32,6 @@ export type ScrollContextProps = { setScrollTo: Dispatch>; }; -export type ScrollProviderProps = { - children: ReactNode; -}; - export type ScrollUtils = { ScrollAnchor: (props: Omit) => JSX.Element; scrollIntoView: () => void; @@ -96,7 +92,7 @@ export const ScrollAnchor = ({ return
; }; -export const ScrollProvider = ({ children }: ScrollProviderProps) => { +export const ScrollProvider = ({ children }: PropsWithChildren<{}>) => { const [scrollTo, setScrollTo] = useState(null); return ( diff --git a/plugins/cost-insights/src/types/Duration.test.ts b/plugins/cost-insights/src/types/Duration.test.ts index 5b9f7b4a9b..4ab84de974 100644 --- a/plugins/cost-insights/src/types/Duration.test.ts +++ b/plugins/cost-insights/src/types/Duration.test.ts @@ -16,7 +16,7 @@ import { Duration, inclusiveEndDateOf, inclusiveStartDateOf } from './Duration'; -Date.now = jest.fn(() => new Date(Date.parse('2020-06-05')).valueOf()); +const lastCompleteBillingDate = '2020-06-05'; describe.each` duration | startDate | endDate @@ -26,7 +26,9 @@ describe.each` ${Duration.P3M} | ${'2019-10-01'} | ${'2020-03-31'} `('Calculates interval dates correctly', ({ duration, startDate, endDate }) => { it(`Calculates dates correctly for ${duration}`, () => { - expect(inclusiveStartDateOf(duration)).toBe(startDate); - expect(inclusiveEndDateOf(duration)).toBe(endDate); + expect(inclusiveStartDateOf(duration, lastCompleteBillingDate)).toBe( + startDate, + ); + expect(inclusiveEndDateOf(duration, lastCompleteBillingDate)).toBe(endDate); }); }); diff --git a/plugins/cost-insights/src/types/Duration.ts b/plugins/cost-insights/src/types/Duration.ts index 6e6c5b1100..72501d46d5 100644 --- a/plugins/cost-insights/src/types/Duration.ts +++ b/plugins/cost-insights/src/types/Duration.ts @@ -32,23 +32,31 @@ export enum Duration { export const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD'; -// Derive the start date of a given period, assuming two repeating intervals -export function inclusiveStartDateOf(duration: Duration): string { +/** + * Derive the start date of a given period, assuming two repeating intervals. + * + * @param duration see comment on Duration enum + * @param endDate from CostInsightsApi.getLastCompleteBillingDate + */ +export function inclusiveStartDateOf( + duration: Duration, + endDate: string, +): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment() + return moment(endDate) .utc() .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P1M: - return moment() + return moment(endDate) .utc() .startOf('month') .subtract(moment.duration(duration).add(moment.duration(duration))) .format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment() + return moment(endDate) .utc() .startOf('quarter') .subtract(moment.duration(duration).add(moment.duration(duration))) @@ -58,28 +66,37 @@ export function inclusiveStartDateOf(duration: Duration): string { } } -export function exclusiveEndDateOf(duration: Duration): string { +export function exclusiveEndDateOf( + duration: Duration, + endDate: string, +): string { switch (duration) { case Duration.P30D: case Duration.P90D: - return moment().utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); + return moment(endDate).utc().add(1, 'day').format(DEFAULT_DATE_FORMAT); case Duration.P1M: - return moment().utc().startOf('month').format(DEFAULT_DATE_FORMAT); + return moment(endDate).utc().startOf('month').format(DEFAULT_DATE_FORMAT); case Duration.P3M: - return moment().utc().startOf('quarter').format(DEFAULT_DATE_FORMAT); + return moment(endDate) + .utc() + .startOf('quarter') + .format(DEFAULT_DATE_FORMAT); default: return assertNever(duration); } } -export function inclusiveEndDateOf(duration: Duration): string { - return moment(exclusiveEndDateOf(duration)) +export function inclusiveEndDateOf( + duration: Duration, + endDate: string, +): string { + return moment(exclusiveEndDateOf(duration, endDate)) .utc() .subtract(1, 'day') .format(DEFAULT_DATE_FORMAT); } // https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals -export function intervalsOf(duration: Duration) { - return `R2/${duration}/${exclusiveEndDateOf(duration)}`; +export function intervalsOf(duration: Duration, endDate: string) { + return `R2/${duration}/${exclusiveEndDateOf(duration, endDate)}`; } diff --git a/plugins/cost-insights/src/types/Loading.ts b/plugins/cost-insights/src/types/Loading.ts index f5da1d2b07..8bc0c4eaef 100644 --- a/plugins/cost-insights/src/types/Loading.ts +++ b/plugins/cost-insights/src/types/Loading.ts @@ -18,6 +18,7 @@ export type Loading = Record; export enum DefaultLoadingAction { UserGroups = 'user-groups', + LastCompleteBillingDate = 'billing-date', CostInsightsInitial = 'cost-insights-initial', CostInsightsPage = 'cost-insights-page', } diff --git a/plugins/cost-insights/src/utils/formatters.test.ts b/plugins/cost-insights/src/utils/formatters.test.ts index a76e838b53..69e9086be1 100644 --- a/plugins/cost-insights/src/utils/formatters.test.ts +++ b/plugins/cost-insights/src/utils/formatters.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ -import { lengthyCurrencyFormatter, quarterOf } from './formatters'; +import { + formatPeriod, + lengthyCurrencyFormatter, + quarterOf, +} from './formatters'; +import { Duration } from '../types'; Date.now = jest.fn(() => new Date(Date.parse('2019-12-07')).valueOf()); @@ -48,3 +53,19 @@ describe('date formatters', () => { ]); }); }); + +describe.each` + duration | date | isEndDate | output + ${Duration.P1M} | ${'2020-10-11'} | ${true} | ${'September 2020'} + ${Duration.P1M} | ${'2020-10-11'} | ${false} | ${'August 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${true} | ${'Q3 2020'} + ${Duration.P3M} | ${'2020-10-11'} | ${false} | ${'Q2 2020'} + ${Duration.P30D} | ${'2020-10-11'} | ${true} | ${'Last 30 Days'} + ${Duration.P30D} | ${'2020-10-11'} | ${false} | ${'First 30 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${true} | ${'Last 90 Days'} + ${Duration.P90D} | ${'2020-10-11'} | ${false} | ${'First 90 Days'} +`('formatPeriod', ({ duration, date, isEndDate, output }) => { + it(`Correctly formats ${duration} with date ${date}`, async () => { + expect(formatPeriod(duration, date, isEndDate)).toBe(output); + }); +}); diff --git a/plugins/cost-insights/src/utils/formatters.ts b/plugins/cost-insights/src/utils/formatters.ts index f3790f315e..44c7517351 100644 --- a/plugins/cost-insights/src/utils/formatters.ts +++ b/plugins/cost-insights/src/utils/formatters.ts @@ -87,26 +87,33 @@ export function formatPercent(n: number): string { return `${(n * 100).toFixed(0)}%`; } -export const formatLastTwoLookaheadQuarters = () => { - const start = moment(inclusiveStartDateOf(Duration.P3M)).format('[Q]Q YYYY'); - const end = moment(inclusiveEndDateOf(Duration.P3M)).format('[Q]Q YYYY'); +export function formatLastTwoLookaheadQuarters(endDate: string) { + const start = moment(inclusiveStartDateOf(Duration.P3M, endDate)).format( + '[Q]Q YYYY', + ); + const end = moment(inclusiveEndDateOf(Duration.P3M, endDate)).format( + '[Q]Q YYYY', + ); return `${start} vs ${end}`; -}; +} -export const formatLastTwoMonths = () => { - const start = moment(inclusiveStartDateOf(Duration.P1M)).utc().format('MMMM'); - const end = moment(inclusiveEndDateOf(Duration.P1M)).utc().format('MMMM'); +export function formatLastTwoMonths(endDate: string) { + const start = moment(inclusiveStartDateOf(Duration.P1M, endDate)) + .utc() + .format('MMMM'); + const end = moment(inclusiveEndDateOf(Duration.P1M, endDate)) + .utc() + .format('MMMM'); return `${start} vs ${end}`; -}; +} -export const dateRegex: RegExp = /^\d{4}-\d{2}-\d{2}$/; - -export const formatRelativeDuration = ( - date: string, +const formatRelativePeriod = ( duration: Duration, + date: string, + isEndDate: boolean, ): string => { - const periodStart = inclusiveStartDateOf(duration); - const periodEnd = inclusiveEndDateOf(duration); + const periodStart = isEndDate ? inclusiveStartDateOf(duration, date) : date; + const periodEnd = isEndDate ? date : inclusiveEndDateOf(duration, date); const days = moment.duration(duration).asDays(); if (![periodStart, periodEnd].includes(date)) { throw new Error(`Invalid relative date ${date} for duration ${duration}`); @@ -114,13 +121,25 @@ export const formatRelativeDuration = ( return date === periodStart ? `First ${days} Days` : `Last ${days} Days`; }; -export function formatDuration(date: string, duration: Duration) { +export function formatPeriod( + duration: Duration, + date: string, + isEndDate: boolean, +) { switch (duration) { case Duration.P1M: - return monthOf(date); + return monthOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); case Duration.P3M: - return quarterOf(date); + return quarterOf( + isEndDate + ? inclusiveEndDateOf(duration, date) + : inclusiveStartDateOf(duration, date), + ); default: - return formatRelativeDuration(date, duration); + return formatRelativePeriod(duration, date, isEndDate); } } diff --git a/plugins/cost-insights/src/utils/mockData.ts b/plugins/cost-insights/src/utils/mockData.ts index e1d715c573..83dcd0d77d 100644 --- a/plugins/cost-insights/src/utils/mockData.ts +++ b/plugins/cost-insights/src/utils/mockData.ts @@ -22,6 +22,7 @@ import { getDefaultState, Product, ProductCost, + ProductFilters, ProjectGrowthData, UnlabeledDataflowAlertProject, UnlabeledDataflowData, @@ -135,7 +136,7 @@ export const MockProductTypes: Record = { 'cloud-pub-sub': 'Cloud Pub/Sub', }; -export const MockProductFilters = Object.keys( +export const MockProductFilters: ProductFilters = Object.keys( MockProductTypes, ).map(productType => ({ duration: Duration.P1M, productType })); diff --git a/plugins/cost-insights/src/utils/tests.tsx b/plugins/cost-insights/src/utils/tests.tsx index 552c524afe..fbf665c131 100644 --- a/plugins/cost-insights/src/utils/tests.tsx +++ b/plugins/cost-insights/src/utils/tests.tsx @@ -13,18 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { Dispatch, ReactNode, SetStateAction } from 'react'; +import React, { Dispatch, PropsWithChildren, SetStateAction } from 'react'; import { + Duration, getDefaultPageFilters, + Group, Maybe, PageFilters, ProductFilters, - Group, + ProductPeriod, } from '../types'; import { FilterContext } from '../hooks/useFilters'; import { ConfigContext, ConfigContextProps } from '../hooks/useConfig'; import { CurrencyContext, CurrencyContextProps } from '../hooks/useCurrency'; -import { ScrollContext, ScrollProviderProps } from '../hooks/useScroll'; +import { ScrollContext } from '../hooks/useScroll'; +import { + BillingDateContext, + BillingDateContextProps, +} from '../hooks/useLastCompleteBillingDate'; import { MockProductFilters } from './mockData'; export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; @@ -32,20 +38,24 @@ export const MockGroups: Group[] = [{ id: 'tech' }, { id: 'mock-group' }]; type MockFilterProviderProps = { setPageFilters: Dispatch>>; setProductFilters: Dispatch>>; - children: ReactNode; + duration?: Duration; }; export const MockFilterProvider = ({ setPageFilters, setProductFilters, children, -}: MockFilterProviderProps) => { + duration = Duration.P1M, +}: PropsWithChildren) => { const pageFilters = getDefaultPageFilters(MockGroups); return ( ({ + ...period, + duration: duration, + })), setPageFilters: setPageFilters, setProductFilters: setProductFilters, }} @@ -62,7 +72,7 @@ export const MockConfigProvider = ({ engineerCost, currencies, children, -}: ConfigContextProps & { children: React.ReactNode }) => ( +}: PropsWithChildren) => ( @@ -74,13 +84,24 @@ export const MockCurrencyProvider = ({ currency, setCurrency, children, -}: CurrencyContextProps & { children: React.ReactNode }) => ( +}: PropsWithChildren) => ( {children} ); -export const MockScrollProvider = ({ children }: ScrollProviderProps) => ( +export const MockBillingDateProvider = ({ + lastCompleteBillingDate, + children, +}: PropsWithChildren) => ( + + {children} + +); + +export const MockScrollProvider = ({ children }: PropsWithChildren<{}>) => ( {children}