From 28d7672a58256ef6afe39210c823f1f3f488d777 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Tue, 7 Oct 2025 13:51:45 +0200 Subject: [PATCH] feat: batch week data if the shown time span is greater than 12 weeks. (#10745) Implements batching of data points in the archived:created chart: when there's 12 or more weeks of data, batch data into batches of 4 weeks at a time. When we batch data, we also switch the labeling to be month-based and auto-generated (cf the inline comment with more details). image The current implementation batches into groups of 4 weeks, but this can easily be parameterized to support arbitrary batch sizes. Because of the batching, we also now need to adjust the tooltip title in those cases. This is handled by a callback. --- .../CreationArchiveChart.tsx | 84 ++++++++++++++----- .../CreationArchiveRatioTooltip.tsx | 1 + .../batchWeekData.test.ts | 79 +++++++++++++++++ .../CreationArchiveChart/batchWeekData.ts | 29 +++++++ .../CreationArchiveChart/types.ts | 6 +- .../insights/hooks/useInsightsData.ts | 19 ++--- .../insights/sections/PerformanceInsights.tsx | 7 -- 7 files changed, 188 insertions(+), 37 deletions(-) create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.ts diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx index c3332cdb80..ac8488dd01 100644 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx @@ -15,10 +15,11 @@ 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'; -import type { WeekData, RawWeekData } from './types.ts'; +import type { WeekData, RawWeekData, BatchedWeekData } from './types.ts'; import { createTooltip } from 'component/insights/components/LineChart/createTooltip.ts'; import { CreationArchiveRatioTooltip } from './CreationArchiveRatioTooltip.tsx'; import { getDateFnsLocale } from '../../getDateFnsLocale.ts'; @@ -27,6 +28,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'; ChartJS.register( CategoryScale, @@ -47,6 +50,8 @@ interface ICreationArchiveChartProps { isLoading?: boolean; } +type DataResult = 'Not Enough Data' | 'Batched' | 'Weekly'; + export const CreationArchiveChart: FC = ({ creationArchiveTrends, isLoading, @@ -56,7 +61,7 @@ export const CreationArchiveChart: FC = ({ const { locationSettings } = useLocationSettings(); const [tooltip, setTooltip] = useState(null); - const { notEnoughData, aggregateOrProjectData } = useMemo(() => { + const { dataResult, aggregateOrProjectData } = useMemo(() => { const labels: string[] = Array.from( new Set( creationVsArchivedChart.datasets.flatMap((d) => @@ -65,19 +70,21 @@ export const CreationArchiveChart: FC = ({ ), ); - const aggregateWeekData = (acc: WeekData, item: RawWeekData) => { - if (item) { - acc.archivedFlags += item.archivedFlags || 0; + const aggregateWeekData = (acc: WeekData, item?: RawWeekData) => { + if (!item) return acc; - if (item.createdFlags) { - Object.entries(item.createdFlags).forEach(([_, count]) => { - acc.totalCreatedFlags += count; - }); - } + acc.archivedFlags += item.archivedFlags || 0; + + if (item.createdFlags) { + Object.entries(item.createdFlags).forEach(([_, count]) => { + acc.totalCreatedFlags += count; + }); } + if (!acc.date) { - acc.date = item?.date; + acc.date = item.date; } + return acc; }; @@ -86,6 +93,7 @@ export const CreationArchiveChart: FC = ({ totalCreatedFlags: 0, archivePercentage: 0, week: label, + date: '', }); const weeks: WeekData[] = labels @@ -103,13 +111,23 @@ export const CreationArchiveChart: FC = ({ })) .sort((a, b) => (a.week > b.week ? 1 : -1)); + let dataResult: DataResult = 'Weekly'; + let displayData: WeekData[] | BatchedWeekData[] = weeks; + + if (weeks.length < 2) { + dataResult = 'Not Enough Data'; + } else if (weeks.length >= 12) { + dataResult = 'Batched'; + displayData = batchWeekData(weeks); + } + return { - notEnoughData: weeks.length < 2, + dataResult, aggregateOrProjectData: { datasets: [ { label: 'Flags archived', - data: weeks, + data: displayData, backgroundColor: theme.palette.charts.A2, borderColor: theme.palette.charts.A2, hoverBackgroundColor: theme.palette.charts.A2, @@ -122,7 +140,7 @@ export const CreationArchiveChart: FC = ({ }, { label: 'Flags created', - data: weeks, + data: displayData, backgroundColor: theme.palette.charts.A1, borderColor: theme.palette.charts.A1, hoverBackgroundColor: theme.palette.charts.A1, @@ -138,10 +156,26 @@ export const CreationArchiveChart: FC = ({ }; }, [creationVsArchivedChart, theme]); - const useGraphCover = notEnoughData || isLoading; - const showNotEnoughDataText = notEnoughData && !isLoading; + const useGraphCover = dataResult === 'Not Enough Data' || isLoading; + const showNotEnoughDataText = + dataResult === 'Not Enough Data' && !isLoading; 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 options = useMemo( () => ({ responsive: true, @@ -172,6 +206,12 @@ export const CreationArchiveChart: FC = ({ enabled: false, position: 'average' as const, external: createTooltip(setTooltip), + callbacks: { + title: + dataResult === 'Batched' + ? batchedTooltipTitle + : undefined, + }, }, }, locale: locationSettings.locale, @@ -179,20 +219,26 @@ export const CreationArchiveChart: FC = ({ x: { adapters: { date: { - locale: getDateFnsLocale(locationSettings.locale), + locale, }, }, type: 'time' as const, display: true, time: { - unit: 'week' as const, + unit: + dataResult === 'Batched' + ? ('month' as const) + : ('week' as const), tooltipFormat: 'P', }, grid: { display: false, }, ticks: { - source: 'data' as const, + source: + dataResult === 'Batched' + ? ('auto' as const) + : ('data' as const), display: !useGraphCover, }, }, diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx index 3a33da08e7..4250f72d7c 100644 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx @@ -44,6 +44,7 @@ interface CreationArchiveRatioTooltipProps { } const Timestamp = styled('span')(({ theme }) => ({ + whiteSpace: 'nowrap', fontSize: theme.typography.body2.fontSize, color: theme.palette.text.secondary, })); diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts new file mode 100644 index 0000000000..abd319f571 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.test.ts @@ -0,0 +1,79 @@ +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 new file mode 100644 index 0000000000..b063ac6e0c --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/batchWeekData.ts @@ -0,0 +1,29 @@ +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/CreationArchiveChart/types.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts index 3a3570aa95..2d368bd5a6 100644 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts @@ -3,7 +3,7 @@ export type WeekData = { totalCreatedFlags: number; archivePercentage: number; week: string; - date?: string; + date: string; }; export type RawWeekData = { @@ -12,3 +12,7 @@ export type RawWeekData = { week: string; date: string; }; + +export type BatchedWeekData = Omit & { + endDate: string; +}; diff --git a/frontend/src/component/insights/hooks/useInsightsData.ts b/frontend/src/component/insights/hooks/useInsightsData.ts index 6276396f85..081d373e73 100644 --- a/frontend/src/component/insights/hooks/useInsightsData.ts +++ b/frontend/src/component/insights/hooks/useInsightsData.ts @@ -12,21 +12,12 @@ export const useInsightsData = ( const allMetricsDatapoints = useAllDatapoints( instanceInsights.metricsSummaryTrends, ); + const projectsData = useFilteredTrends( instanceInsights.projectFlagTrends, projects, ); - const lifecycleData = useFilteredTrends( - instanceInsights.lifecycleTrends, - projects, - ); - - const creationArchiveData = useFilteredTrends( - instanceInsights.creationArchiveTrends, - projects, - ); - const groupedProjectsData = useGroupedProjectTrends(projectsData); const metricsData = useFilteredTrends( @@ -37,8 +28,16 @@ export const useInsightsData = ( const summary = useFilteredFlagsSummary(projectsData); + const lifecycleData = useFilteredTrends( + instanceInsights.lifecycleTrends, + projects, + ); const groupedLifecycleData = useGroupedProjectTrends(lifecycleData); + const creationArchiveData = useFilteredTrends( + instanceInsights.creationArchiveTrends, + projects, + ); const groupedCreationArchiveData = useGroupedProjectTrends(creationArchiveData); diff --git a/frontend/src/component/insights/sections/PerformanceInsights.tsx b/frontend/src/component/insights/sections/PerformanceInsights.tsx index 4e1caf9ba1..9596edd8b2 100644 --- a/frontend/src/component/insights/sections/PerformanceInsights.tsx +++ b/frontend/src/component/insights/sections/PerformanceInsights.tsx @@ -71,13 +71,6 @@ export const PerformanceInsights: FC = () => { const lastFlagTrend = flagTrends[flagTrends.length - 1]; const flagsTotal = lastFlagTrend?.total ?? 0; - function getFlagsPerUser(flagsTotal: number, usersTotal: number) { - const flagsPerUserCalculation = flagsTotal / usersTotal; - return Number.isNaN(flagsPerUserCalculation) - ? 'N/A' - : flagsPerUserCalculation.toFixed(2); - } - const isLifecycleGraphsEnabled = useUiFlag('lifecycleGraphs'); return (