diff --git a/frontend/src/component/insights/components/LineChart/ChartTooltip/ChartTooltip.tsx b/frontend/src/component/insights/components/LineChart/ChartTooltip/ChartTooltip.tsx index ff0b8891b2..f4344e46a0 100644 --- a/frontend/src/component/insights/components/LineChart/ChartTooltip/ChartTooltip.tsx +++ b/frontend/src/component/insights/components/LineChart/ChartTooltip/ChartTooltip.tsx @@ -2,7 +2,7 @@ import { Box, Paper, styled, Typography } from '@mui/material'; import type { TooltipItem } from 'chart.js'; import { Truncator } from 'component/common/Truncator/Truncator'; import type React from 'react'; -import type { FC, VFC } from 'react'; +import type { FC } from 'react'; import { objectId } from 'utils/objectId'; export type TooltipState = { @@ -91,12 +91,12 @@ export const ChartTooltipContainer: FC = ({ ); -export const ChartTooltip: VFC = ({ tooltip }) => ( +export const ChartTooltip: FC = ({ tooltip }) => ( ({ - width: 220, + width: 'max-content', padding: theme.spacing(1.5, 2), })} > diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx index ac8488dd01..b70a0c282a 100644 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx @@ -15,7 +15,6 @@ import { TimeScale, Chart as ChartJS, Filler, - type TooltipItem, } from 'chart.js'; import { useLocationSettings } from 'hooks/useLocationSettings'; import type { TooltipState } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip'; @@ -28,8 +27,8 @@ import { NotEnoughData } from 'component/insights/components/LineChart/LineChart import { placeholderData } from './placeholderData.ts'; import { Bar } from 'react-chartjs-2'; import { GraphCover } from 'component/insights/GraphCover.tsx'; -import { format, startOfWeek } from 'date-fns'; -import { batchWeekData } from './batchWeekData.ts'; +import { batchCreationArchiveData } from './batchCreationArchiveData.ts'; +import { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts'; ChartJS.register( CategoryScale, @@ -50,8 +49,6 @@ interface ICreationArchiveChartProps { isLoading?: boolean; } -type DataResult = 'Not Enough Data' | 'Batched' | 'Weekly'; - export const CreationArchiveChart: FC = ({ creationArchiveTrends, isLoading, @@ -111,14 +108,14 @@ export const CreationArchiveChart: FC = ({ })) .sort((a, b) => (a.week > b.week ? 1 : -1)); - let dataResult: DataResult = 'Weekly'; + let dataResult: ChartDataResult = 'Weekly'; let displayData: WeekData[] | BatchedWeekData[] = weeks; if (weeks.length < 2) { dataResult = 'Not Enough Data'; } else if (weeks.length >= 12) { dataResult = 'Batched'; - displayData = batchWeekData(weeks); + displayData = batchCreationArchiveData(weeks); } return { @@ -162,19 +159,7 @@ export const CreationArchiveChart: FC = ({ const data = useGraphCover ? placeholderData : aggregateOrProjectData; const locale = getDateFnsLocale(locationSettings.locale); - const batchedTooltipTitle = (datapoints: TooltipItem[]) => { - const rawData = datapoints[0].raw as BatchedWeekData; - const startDate = format( - startOfWeek(new Date(rawData.date), { - locale, - weekStartsOn: 1, - }), - `PP`, - { locale }, - ); - const endDate = format(new Date(rawData.endDate), `PP`, { locale }); - return `${startDate} – ${endDate}`; - }; + const batchedTooltipTitle = useBatchedTooltipDate(); const options = useMemo( () => ({ diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.test.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.test.ts new file mode 100644 index 0000000000..0a359d4b62 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.test.ts @@ -0,0 +1,48 @@ +import { batchCreationArchiveData } from './batchCreationArchiveData.ts'; + +it('handles a single data point', () => { + const input = { + archivedFlags: 5, + totalCreatedFlags: 1, + archivePercentage: 500, + week: '50', + date: '2022-01-01', + }; + expect(batchCreationArchiveData([input])).toStrictEqual([ + { + archivedFlags: 5, + totalCreatedFlags: 1, + archivePercentage: 500, + date: input.date, + endDate: input.date, + }, + ]); +}); + +it('adds data in the expected way', () => { + const input = [ + { + archivedFlags: 5, + totalCreatedFlags: 1, + archivePercentage: 500, + week: '50', + date: '2022-01-01', + }, + { + archivedFlags: 3, + totalCreatedFlags: 3, + archivePercentage: 150, + week: '51', + date: '2022-02-01', + }, + ]; + expect(batchCreationArchiveData(input)).toStrictEqual([ + { + archivedFlags: 8, + totalCreatedFlags: 4, + archivePercentage: 200, + date: '2022-01-01', + endDate: '2022-02-01', + }, + ]); +}); diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.ts new file mode 100644 index 0000000000..b72bf807d3 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchCreationArchiveData.ts @@ -0,0 +1,26 @@ +import { batchData } from '../batchData.ts'; +import type { BatchedWeekData, WeekData } from './types.ts'; + +export const batchCreationArchiveData = batchData({ + merge: (accumulated: BatchedWeekData, next: WeekData) => { + accumulated.totalCreatedFlags += next.totalCreatedFlags; + accumulated.archivedFlags += next.archivedFlags; + + accumulated.archivePercentage = + accumulated.totalCreatedFlags > 0 + ? (accumulated.archivedFlags / accumulated.totalCreatedFlags) * + 100 + : 0; + + accumulated.endDate = next.date; + return accumulated; + }, + map: (item: WeekData) => { + const { week: _, ...shared } = item; + + return { + ...shared, + endDate: item.date, + }; + }, +}); diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts deleted file mode 100644 index abd319f571..0000000000 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { batchWeekData } from './batchWeekData.ts'; - -it('handles empty input', () => { - expect(batchWeekData([])).toEqual([]); -}); - -it('handles a single data point', () => { - const input = { - archivedFlags: 5, - totalCreatedFlags: 1, - archivePercentage: 500, - week: '50', - date: '2022-01-01', - }; - expect(batchWeekData([input])).toStrictEqual([ - { - archivedFlags: 5, - totalCreatedFlags: 1, - archivePercentage: 500, - date: input.date, - endDate: input.date, - }, - ]); -}); -it('batches by 4, starting from the first entry', () => { - const input = [ - { - archivedFlags: 1, - totalCreatedFlags: 1, - archivePercentage: 100, - week: '50', - date: '2022-01-01', - }, - { - archivedFlags: 5, - totalCreatedFlags: 1, - archivePercentage: 500, - week: '50', - date: '2022-02-02', - }, - { - archivedFlags: 3, - totalCreatedFlags: 0, - archivePercentage: 0, - week: '50', - date: '2022-03-03', - }, - { - archivedFlags: 3, - totalCreatedFlags: 4, - archivePercentage: 75, - week: '50', - date: '2022-04-04', - }, - { - archivedFlags: 3, - totalCreatedFlags: 2, - archivePercentage: 150, - week: '50', - date: '2022-05-05', - }, - ]; - expect(batchWeekData(input)).toStrictEqual([ - { - archivedFlags: 12, - totalCreatedFlags: 6, - archivePercentage: 200, - date: '2022-01-01', - endDate: '2022-04-04', - }, - { - archivedFlags: 3, - totalCreatedFlags: 2, - archivePercentage: 150, - date: '2022-05-05', - endDate: '2022-05-05', - }, - ]); -}); diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.ts deleted file mode 100644 index b063ac6e0c..0000000000 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { BatchedWeekData, WeekData } from './types.ts'; - -const batchSize = 4; - -export const batchWeekData = (weeks: WeekData[]): BatchedWeekData[] => - weeks.reduce((acc, curr, index) => { - const currentAggregatedIndex = Math.floor(index / batchSize); - - const data = acc[currentAggregatedIndex]; - - if (data) { - data.totalCreatedFlags += curr.totalCreatedFlags; - data.archivedFlags += curr.archivedFlags; - - data.archivePercentage = - data.totalCreatedFlags > 0 - ? (data.archivedFlags / data.totalCreatedFlags) * 100 - : 0; - - data.endDate = curr.date; - } else { - const { week: _, ...shared } = curr; - acc[currentAggregatedIndex] = { - ...shared, - endDate: curr.date, - }; - } - return acc; - }, [] as BatchedWeekData[]); diff --git a/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx index 2a3d98879b..beefe9c00f 100644 --- a/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx +++ b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx @@ -10,6 +10,9 @@ import { import { useTheme } from '@mui/material'; import type { GroupedDataByProject } from 'component/insights/hooks/useGroupedProjectTrends'; import { usePlaceholderData } from 'component/insights/hooks/usePlaceholderData'; +import type { BatchedWeekData, WeekData } from './types.ts'; +import { batchProductionFlagsData } from './batchProductionFlagsData.ts'; +import { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts'; interface IProjectHealthChartProps { lifecycleTrends: GroupedDataByProject< @@ -19,10 +22,43 @@ interface IProjectHealthChartProps { isLoading?: boolean; } -type WeekData = { - newProductionFlags: number; - week: string; - date?: string; +const useOverrideOptions = (chartDataResult: ChartDataResult) => { + const batchedTooltipTitle = useBatchedTooltipDate(); + const sharedOptions = { + parsing: { + yAxisKey: 'newProductionFlags', + xAxisKey: 'date', + }, + }; + switch (chartDataResult) { + case 'Batched': { + return { + ...sharedOptions, + scales: { + x: { + time: { + unit: 'month' as const, + tooltipFormat: 'P', + }, + ticks: { + source: 'auto' as const, + }, + }, + }, + plugins: { + tooltip: { + callbacks: { + title: batchedTooltipTitle, + }, + }, + }, + }; + } + case 'Weekly': + return sharedOptions; + case 'Not Enough Data': + return {}; + } }; export const NewProductionFlagsChart: FC = ({ @@ -34,7 +70,7 @@ export const NewProductionFlagsChart: FC = ({ const theme = useTheme(); const placeholderData = usePlaceholderData(); - const aggregateHealthData = useMemo(() => { + const { aggregateHealthData, chartDataResult } = useMemo(() => { const labels: string[] = Array.from( new Set( lifecycleData.datasets.flatMap((d) => @@ -65,46 +101,51 @@ export const NewProductionFlagsChart: FC = ({ ); }) .sort((a, b) => (a.week > b.week ? 1 : -1)); + + let chartDataResult: ChartDataResult = 'Weekly'; + let displayData: WeekData[] | BatchedWeekData[] = weeks; + + if ( + !isLoading && + !lifecycleData.datasets.some((d) => d.data.length > 1) + ) { + chartDataResult = 'Not Enough Data'; + } else if (weeks.length >= 12) { + chartDataResult = 'Batched'; + displayData = batchProductionFlagsData(weeks); + } + return { - datasets: [ - { - label: 'Number of new flags', - data: weeks, - borderColor: theme.palette.primary.light, - backgroundColor: fillGradientPrimary, - fill: true, - order: 3, - }, - ], + chartDataResult, + aggregateHealthData: { + datasets: [ + { + label: 'Number of new flags', + data: displayData, + borderColor: theme.palette.primary.light, + backgroundColor: fillGradientPrimary, + fill: true, + order: 3, + }, + ], + }, }; - }, [lifecycleData, theme]); + }, [lifecycleData, theme, isLoading]); const aggregateOrProjectData = isAggregate ? aggregateHealthData : lifecycleData; - const notEnoughData = useMemo( - () => - !isLoading && - !lifecycleData.datasets.some((d) => d.data.length > 1), - [lifecycleData, isLoading], - ); + const notEnoughData = chartDataResult === 'Not Enough Data'; const data = notEnoughData || isLoading ? placeholderData : aggregateOrProjectData; + const overrideOptions = useOverrideOptions(chartDataResult); + return ( : isLoading} /> ); diff --git a/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.test.ts b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.test.ts new file mode 100644 index 0000000000..a2d72097d0 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.test.ts @@ -0,0 +1,38 @@ +import { batchProductionFlagsData } from './batchProductionFlagsData.ts'; + +it('handles a single data point', () => { + const input = { + newProductionFlags: 5, + week: '50', + date: '2022-01-01', + }; + expect(batchProductionFlagsData([input])).toStrictEqual([ + { + newProductionFlags: 5, + date: input.date, + endDate: input.date, + }, + ]); +}); + +it('adds data in the expected way', () => { + const input = [ + { + newProductionFlags: 5, + week: '50', + date: '2022-01-01', + }, + { + newProductionFlags: 6, + week: '51', + date: '2022-02-01', + }, + ]; + expect(batchProductionFlagsData(input)).toStrictEqual([ + { + newProductionFlags: 11, + date: '2022-01-01', + endDate: '2022-02-01', + }, + ]); +}); diff --git a/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.ts b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.ts new file mode 100644 index 0000000000..197d7f377d --- /dev/null +++ b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/batchProductionFlagsData.ts @@ -0,0 +1,19 @@ +import { batchData } from '../batchData.ts'; +import type { BatchedWeekData, WeekData } from './types.ts'; + +export const batchProductionFlagsData = batchData({ + merge: (accumulated: BatchedWeekData, next: WeekData) => { + accumulated.newProductionFlags += next.newProductionFlags; + accumulated.endDate = next.date; + + return accumulated; + }, + map: (item: WeekData) => { + const { week: _, ...shared } = item; + + return { + ...shared, + endDate: item.date, + }; + }, +}); diff --git a/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/types.ts b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/types.ts new file mode 100644 index 0000000000..a21ede8079 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/NewProductionFlagsChart/types.ts @@ -0,0 +1,9 @@ +export type WeekData = { + newProductionFlags: number; + week: string; + date: string; +}; + +export type BatchedWeekData = Omit & { + endDate: string; +}; diff --git a/frontend/src/component/insights/componentsChart/batchData.test.ts b/frontend/src/component/insights/componentsChart/batchData.test.ts new file mode 100644 index 0000000000..fe025bf2c8 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/batchData.test.ts @@ -0,0 +1,15 @@ +import { batchData } from './batchData.ts'; + +it('handles empty input', () => { + expect(batchData({ merge: (x, _) => x, map: (x) => x })([])).toEqual([]); +}); + +it('batches by 4, starting from the first entry', () => { + const input = [7, 11, 13, 19, 23]; + + expect( + batchData({ merge: (x, y) => x + y, map: (x) => x })( + input, + ), + ).toStrictEqual([50, 23]); +}); diff --git a/frontend/src/component/insights/componentsChart/batchData.ts b/frontend/src/component/insights/componentsChart/batchData.ts new file mode 100644 index 0000000000..71e259fc67 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/batchData.ts @@ -0,0 +1,26 @@ +const defaultBatchSize = 4; + +export type BatchDataOptions = { + merge: (accumulated: TBatched, next: T) => TBatched; + map: (item: T) => TBatched; + batchSize?: number; +}; + +export const batchData = + ({ + merge, + map, + batchSize = defaultBatchSize, + }: BatchDataOptions) => + (xs: T[]): TBatched[] => + xs.reduce((acc, curr, index) => { + const currentAggregatedIndex = Math.floor(index / batchSize); + const data = acc[currentAggregatedIndex]; + + if (data) { + acc[currentAggregatedIndex] = merge(data, curr); + } else { + acc[currentAggregatedIndex] = map(curr); + } + return acc; + }, [] as TBatched[]); diff --git a/frontend/src/component/insights/componentsChart/chartDataResult.ts b/frontend/src/component/insights/componentsChart/chartDataResult.ts new file mode 100644 index 0000000000..0f48f12474 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/chartDataResult.ts @@ -0,0 +1 @@ +type ChartDataResult = 'Not Enough Data' | 'Batched' | 'Weekly'; diff --git a/frontend/src/component/insights/componentsChart/useBatchedTooltipDate.ts b/frontend/src/component/insights/componentsChart/useBatchedTooltipDate.ts new file mode 100644 index 0000000000..2749e7a1d9 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/useBatchedTooltipDate.ts @@ -0,0 +1,35 @@ +import { useLocationSettings } from 'hooks/useLocationSettings'; +import { getDateFnsLocale } from '../getDateFnsLocale.ts'; +import type { ChartTypeRegistry, TooltipItem } from 'chart.js'; +import { format, startOfWeek } from 'date-fns'; + +export const useBatchedTooltipDate = ( + fallback: string = 'Unknown date range', +) => { + const { locationSettings } = useLocationSettings(); + const locale = getDateFnsLocale(locationSettings.locale); + return (datapoints: TooltipItem[]) => { + const dataPoint = datapoints[0].raw as any; + if ( + 'date' in dataPoint && + typeof dataPoint.date === 'string' && + 'endDate' in dataPoint && + typeof dataPoint.endDate === 'string' + ) { + const startDate = format( + startOfWeek(new Date(dataPoint.date), { + locale, + weekStartsOn: 1, + }), + `PP`, + { locale }, + ); + const endDate = format(new Date(dataPoint.endDate), `PP`, { + locale, + }); + return `${startDate} – ${endDate}`; + } + + return fallback; + }; +};