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:
parent
cf018020df
commit
7044cd4b1a
@ -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),
|
||||
})}
|
||||
>
|
||||
|
@ -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(
|
||||
() => ({
|
||||
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
@ -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[]);
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
@ -0,0 +1,9 @@
|
||||
export type WeekData = {
|
||||
newProductionFlags: number;
|
||||
week: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type BatchedWeekData = Omit<WeekData, 'week'> & {
|
||||
endDate: string;
|
||||
};
|
@ -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]);
|
||||
});
|
26
frontend/src/component/insights/componentsChart/batchData.ts
Normal file
26
frontend/src/component/insights/componentsChart/batchData.ts
Normal 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[]);
|
@ -0,0 +1 @@
|
||||
type ChartDataResult = 'Not Enough Data' | 'Batched' | 'Weekly';
|
@ -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;
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue
Block a user