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,
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<ICreationArchiveChartProps> = ({
creationArchiveTrends,
isLoading,
@ -56,7 +61,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
const { locationSettings } = useLocationSettings();
const [tooltip, setTooltip] = useState<null | TooltipState>(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<ICreationArchiveChartProps> = ({
),
);
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<ICreationArchiveChartProps> = ({
totalCreatedFlags: 0,
archivePercentage: 0,
week: label,
date: '',
});
const weeks: WeekData[] = labels
@ -103,13 +111,23 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
}))
.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<ICreationArchiveChartProps> = ({
},
{
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<ICreationArchiveChartProps> = ({
};
}, [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<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(
() => ({
responsive: true,
@ -172,6 +206,12 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
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<ICreationArchiveChartProps> = ({
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,
},
},

View File

@ -44,6 +44,7 @@ interface CreationArchiveRatioTooltipProps {
}
const Timestamp = styled('span')(({ theme }) => ({
whiteSpace: 'nowrap',
fontSize: theme.typography.body2.fontSize,
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;
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<WeekData, 'week'> & {
endDate: string;
};

View File

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

View File

@ -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 (