1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-27 11:02:16 +01:00

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).

<img width="798" height="317" alt="image"
src="https://github.com/user-attachments/assets/068ee528-a6d6-4aaf-ac81-c729c2c813d1"
/>


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.
This commit is contained in:
Thomas Heartman 2025-10-07 13:51:45 +02:00 committed by GitHub
parent 5e2d95e0be
commit 28d7672a58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 188 additions and 37 deletions

View File

@ -15,10 +15,11 @@ import {
TimeScale, TimeScale,
Chart as ChartJS, Chart as ChartJS,
Filler, Filler,
type TooltipItem,
} from 'chart.js'; } from 'chart.js';
import { useLocationSettings } from 'hooks/useLocationSettings'; import { useLocationSettings } from 'hooks/useLocationSettings';
import type { TooltipState } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip'; 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 { createTooltip } from 'component/insights/components/LineChart/createTooltip.ts';
import { CreationArchiveRatioTooltip } from './CreationArchiveRatioTooltip.tsx'; import { CreationArchiveRatioTooltip } from './CreationArchiveRatioTooltip.tsx';
import { getDateFnsLocale } from '../../getDateFnsLocale.ts'; import { getDateFnsLocale } from '../../getDateFnsLocale.ts';
@ -27,6 +28,8 @@ import { NotEnoughData } from 'component/insights/components/LineChart/LineChart
import { placeholderData } from './placeholderData.ts'; import { placeholderData } from './placeholderData.ts';
import { Bar } from 'react-chartjs-2'; import { Bar } from 'react-chartjs-2';
import { GraphCover } from 'component/insights/GraphCover.tsx'; import { GraphCover } from 'component/insights/GraphCover.tsx';
import { format, startOfWeek } from 'date-fns';
import { batchWeekData } from './batchWeekData.ts';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@ -47,6 +50,8 @@ interface ICreationArchiveChartProps {
isLoading?: boolean; isLoading?: boolean;
} }
type DataResult = 'Not Enough Data' | 'Batched' | 'Weekly';
export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
creationArchiveTrends, creationArchiveTrends,
isLoading, isLoading,
@ -56,7 +61,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();
const [tooltip, setTooltip] = useState<null | TooltipState>(null); const [tooltip, setTooltip] = useState<null | TooltipState>(null);
const { notEnoughData, aggregateOrProjectData } = useMemo(() => { const { dataResult, aggregateOrProjectData } = useMemo(() => {
const labels: string[] = Array.from( const labels: string[] = Array.from(
new Set( new Set(
creationVsArchivedChart.datasets.flatMap((d) => creationVsArchivedChart.datasets.flatMap((d) =>
@ -65,8 +70,9 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
), ),
); );
const aggregateWeekData = (acc: WeekData, item: RawWeekData) => { const aggregateWeekData = (acc: WeekData, item?: RawWeekData) => {
if (item) { if (!item) return acc;
acc.archivedFlags += item.archivedFlags || 0; acc.archivedFlags += item.archivedFlags || 0;
if (item.createdFlags) { if (item.createdFlags) {
@ -74,10 +80,11 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
acc.totalCreatedFlags += count; acc.totalCreatedFlags += count;
}); });
} }
}
if (!acc.date) { if (!acc.date) {
acc.date = item?.date; acc.date = item.date;
} }
return acc; return acc;
}; };
@ -86,6 +93,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
totalCreatedFlags: 0, totalCreatedFlags: 0,
archivePercentage: 0, archivePercentage: 0,
week: label, week: label,
date: '',
}); });
const weeks: WeekData[] = labels const weeks: WeekData[] = labels
@ -103,13 +111,23 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
})) }))
.sort((a, b) => (a.week > b.week ? 1 : -1)); .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 { return {
notEnoughData: weeks.length < 2, dataResult,
aggregateOrProjectData: { aggregateOrProjectData: {
datasets: [ datasets: [
{ {
label: 'Flags archived', label: 'Flags archived',
data: weeks, data: displayData,
backgroundColor: theme.palette.charts.A2, backgroundColor: theme.palette.charts.A2,
borderColor: theme.palette.charts.A2, borderColor: theme.palette.charts.A2,
hoverBackgroundColor: theme.palette.charts.A2, hoverBackgroundColor: theme.palette.charts.A2,
@ -122,7 +140,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
}, },
{ {
label: 'Flags created', label: 'Flags created',
data: weeks, data: displayData,
backgroundColor: theme.palette.charts.A1, backgroundColor: theme.palette.charts.A1,
borderColor: theme.palette.charts.A1, borderColor: theme.palette.charts.A1,
hoverBackgroundColor: theme.palette.charts.A1, hoverBackgroundColor: theme.palette.charts.A1,
@ -138,10 +156,26 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
}; };
}, [creationVsArchivedChart, theme]); }, [creationVsArchivedChart, theme]);
const useGraphCover = notEnoughData || isLoading; const useGraphCover = dataResult === 'Not Enough Data' || isLoading;
const showNotEnoughDataText = notEnoughData && !isLoading; const showNotEnoughDataText =
dataResult === 'Not Enough Data' && !isLoading;
const data = useGraphCover ? placeholderData : aggregateOrProjectData; const data = useGraphCover ? placeholderData : aggregateOrProjectData;
const locale = getDateFnsLocale(locationSettings.locale);
const batchedTooltipTitle = (datapoints: TooltipItem<any>[]) => {
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( const options = useMemo(
() => ({ () => ({
responsive: true, responsive: true,
@ -172,6 +206,12 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
enabled: false, enabled: false,
position: 'average' as const, position: 'average' as const,
external: createTooltip(setTooltip), external: createTooltip(setTooltip),
callbacks: {
title:
dataResult === 'Batched'
? batchedTooltipTitle
: undefined,
},
}, },
}, },
locale: locationSettings.locale, locale: locationSettings.locale,
@ -179,20 +219,26 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
x: { x: {
adapters: { adapters: {
date: { date: {
locale: getDateFnsLocale(locationSettings.locale), locale,
}, },
}, },
type: 'time' as const, type: 'time' as const,
display: true, display: true,
time: { time: {
unit: 'week' as const, unit:
dataResult === 'Batched'
? ('month' as const)
: ('week' as const),
tooltipFormat: 'P', tooltipFormat: 'P',
}, },
grid: { grid: {
display: false, display: false,
}, },
ticks: { ticks: {
source: 'data' as const, source:
dataResult === 'Batched'
? ('auto' as const)
: ('data' as const),
display: !useGraphCover, display: !useGraphCover,
}, },
}, },

View File

@ -44,6 +44,7 @@ interface CreationArchiveRatioTooltipProps {
} }
const Timestamp = styled('span')(({ theme }) => ({ const Timestamp = styled('span')(({ theme }) => ({
whiteSpace: 'nowrap',
fontSize: theme.typography.body2.fontSize, fontSize: theme.typography.body2.fontSize,
color: theme.palette.text.secondary, color: theme.palette.text.secondary,
})); }));

View File

@ -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',
},
]);
});

View File

@ -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[]);

View File

@ -3,7 +3,7 @@ export type WeekData = {
totalCreatedFlags: number; totalCreatedFlags: number;
archivePercentage: number; archivePercentage: number;
week: string; week: string;
date?: string; date: string;
}; };
export type RawWeekData = { export type RawWeekData = {
@ -12,3 +12,7 @@ export type RawWeekData = {
week: string; week: string;
date: string; date: string;
}; };
export type BatchedWeekData = Omit<WeekData, 'week'> & {
endDate: string;
};

View File

@ -12,21 +12,12 @@ export const useInsightsData = (
const allMetricsDatapoints = useAllDatapoints( const allMetricsDatapoints = useAllDatapoints(
instanceInsights.metricsSummaryTrends, instanceInsights.metricsSummaryTrends,
); );
const projectsData = useFilteredTrends( const projectsData = useFilteredTrends(
instanceInsights.projectFlagTrends, instanceInsights.projectFlagTrends,
projects, projects,
); );
const lifecycleData = useFilteredTrends(
instanceInsights.lifecycleTrends,
projects,
);
const creationArchiveData = useFilteredTrends(
instanceInsights.creationArchiveTrends,
projects,
);
const groupedProjectsData = useGroupedProjectTrends(projectsData); const groupedProjectsData = useGroupedProjectTrends(projectsData);
const metricsData = useFilteredTrends( const metricsData = useFilteredTrends(
@ -37,8 +28,16 @@ export const useInsightsData = (
const summary = useFilteredFlagsSummary(projectsData); const summary = useFilteredFlagsSummary(projectsData);
const lifecycleData = useFilteredTrends(
instanceInsights.lifecycleTrends,
projects,
);
const groupedLifecycleData = useGroupedProjectTrends(lifecycleData); const groupedLifecycleData = useGroupedProjectTrends(lifecycleData);
const creationArchiveData = useFilteredTrends(
instanceInsights.creationArchiveTrends,
projects,
);
const groupedCreationArchiveData = const groupedCreationArchiveData =
useGroupedProjectTrends(creationArchiveData); useGroupedProjectTrends(creationArchiveData);

View File

@ -71,13 +71,6 @@ export const PerformanceInsights: FC = () => {
const lastFlagTrend = flagTrends[flagTrends.length - 1]; const lastFlagTrend = flagTrends[flagTrends.length - 1];
const flagsTotal = lastFlagTrend?.total ?? 0; 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'); const isLifecycleGraphsEnabled = useUiFlag('lifecycleGraphs');
return ( return (