1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-10-18 11:14:57 +02:00

feat: Add batching functionality to new flags in production (#10756)

Adds the same batching functionality that was added to the
archived:created chart to the new flags in production chart.

In doing so, I've extracted the batching algorithm and the batched
tooltip title creation, as well as the ChartDataResult type (though
naming suggestions are still welcome on that front).

Locale 'ja':
<img width="1143" height="370" alt="image"
src="https://github.com/user-attachments/assets/827b41c6-0e67-46f4-8f82-4ba12e2120bb"
/>

Locale 'no':
<img width="1475" height="554" alt="image"
src="https://github.com/user-attachments/assets/6125c318-25fb-42bd-a520-44e6a7f7ece7"
/>
This commit is contained in:
Thomas Heartman 2025-10-08 14:40:11 +02:00 committed by GitHub
parent cf018020df
commit 7044cd4b1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 298 additions and 163 deletions

View File

@ -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<IChartTooltipProps> = ({
</Box>
);
export const ChartTooltip: VFC<IChartTooltipProps> = ({ tooltip }) => (
export const ChartTooltip: FC<IChartTooltipProps> = ({ tooltip }) => (
<ChartTooltipContainer tooltip={tooltip}>
<Paper
elevation={3}
sx={(theme) => ({
width: 220,
width: 'max-content',
padding: theme.spacing(1.5, 2),
})}
>

View File

@ -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<ICreationArchiveChartProps> = ({
creationArchiveTrends,
isLoading,
@ -111,14 +108,14 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
}))
.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<ICreationArchiveChartProps> = ({
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 batchedTooltipTitle = useBatchedTooltipDate();
const options = useMemo(
() => ({

View File

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

View File

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

View File

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

View File

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

View File

@ -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<IProjectHealthChartProps> = ({
@ -34,7 +70,7 @@ export const NewProductionFlagsChart: FC<IProjectHealthChartProps> = ({
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<IProjectHealthChartProps> = ({
);
})
.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 (
<LineChart
key={isAggregate ? 'aggregate' : 'project'}
data={data}
overrideOptions={
notEnoughData
? {}
: {
parsing: {
yAxisKey: 'newProductionFlags',
xAxisKey: 'date',
},
}
}
overrideOptions={overrideOptions}
cover={notEnoughData ? <NotEnoughData /> : isLoading}
/>
);

View File

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

View File

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

View File

@ -0,0 +1,9 @@
export type WeekData = {
newProductionFlags: number;
week: string;
date: string;
};
export type BatchedWeekData = Omit<WeekData, 'week'> & {
endDate: string;
};

View File

@ -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<number, number>({ merge: (x, y) => x + y, map: (x) => x })(
input,
),
).toStrictEqual([50, 23]);
});

View File

@ -0,0 +1,26 @@
const defaultBatchSize = 4;
export type BatchDataOptions<T, TBatched> = {
merge: (accumulated: TBatched, next: T) => TBatched;
map: (item: T) => TBatched;
batchSize?: number;
};
export const batchData =
<T, TBatched>({
merge,
map,
batchSize = defaultBatchSize,
}: BatchDataOptions<T, TBatched>) =>
(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[]);

View File

@ -0,0 +1 @@
type ChartDataResult = 'Not Enough Data' | 'Batched' | 'Weekly';

View File

@ -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 = <T extends keyof ChartTypeRegistry>(
fallback: string = 'Unknown date range',
) => {
const { locationSettings } = useLocationSettings();
const locale = getDateFnsLocale(locationSettings.locale);
return (datapoints: TooltipItem<T>[]) => {
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;
};
};