mirror of
https://github.com/Unleash/unleash.git
synced 2025-10-27 11:02:16 +01:00
feat: display median calculation (#10763)
Adds the median calculation to the new flags in production widget and allows the median to take batched data into account: <img width="1288" height="530" alt="image" src="https://github.com/user-attachments/assets/5052dad1-03fa-4ce6-8626-ff4f6a5d818f" /> To achieve this, it was necessary to extract the data aggregation method from the NewProductionFlagsChart component, so that we could use the data (to calculate the median) at a higher level in the tree. Because the data is now passed around outside of the chart component, I've also updated the `ChartDataResult` type to a proper union type that contains the state and the data.
This commit is contained in:
parent
b18b128e52
commit
ca4ec203c6
@ -30,6 +30,8 @@ import { GraphCover } from 'component/insights/GraphCover.tsx';
|
||||
import { batchCreationArchiveData } from './batchCreationArchiveData.ts';
|
||||
import { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts';
|
||||
import { aggregateCreationArchiveData } from './aggregateCreationArchiveData.ts';
|
||||
import type { Theme } from '@mui/material/styles/createTheme';
|
||||
import type { ChartData } from '../chartData.ts';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@ -51,6 +53,42 @@ interface ICreationArchiveChartProps {
|
||||
labels: { week: string; date: string }[];
|
||||
}
|
||||
|
||||
const batchSize = 4;
|
||||
|
||||
const sharedDatasetOptions = (lineColor: string) => ({
|
||||
backgroundColor: lineColor,
|
||||
borderColor: lineColor,
|
||||
hoverBackgroundColor: lineColor,
|
||||
hoverBorderColor: lineColor,
|
||||
});
|
||||
const makeChartData = (
|
||||
data: FinalizedWeekData[] | BatchedWeekData[],
|
||||
theme: Theme,
|
||||
) => ({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Flags archived',
|
||||
data: data,
|
||||
order: 1,
|
||||
parsing: {
|
||||
yAxisKey: 'archivedFlags',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
...sharedDatasetOptions(theme.palette.charts.A2),
|
||||
},
|
||||
{
|
||||
label: 'Flags created',
|
||||
data: data,
|
||||
order: 2,
|
||||
parsing: {
|
||||
yAxisKey: 'totalCreatedFlags',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
...sharedDatasetOptions(theme.palette.charts.A1),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
creationArchiveTrends,
|
||||
isLoading,
|
||||
@ -61,62 +99,42 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
const { locationSettings } = useLocationSettings();
|
||||
const [tooltip, setTooltip] = useState<null | TooltipState>(null);
|
||||
|
||||
const { dataResult, aggregateOrProjectData } = useMemo(() => {
|
||||
const chartData: ChartData<FinalizedWeekData | BatchedWeekData> =
|
||||
useMemo(() => {
|
||||
if (isLoading) {
|
||||
return { state: 'Loading', value: placeholderData };
|
||||
}
|
||||
|
||||
const weeklyData = aggregateCreationArchiveData(
|
||||
labels,
|
||||
creationVsArchivedChart.datasets,
|
||||
);
|
||||
|
||||
let dataResult: ChartDataResult = 'Weekly';
|
||||
let displayData: FinalizedWeekData[] | (BatchedWeekData | null)[] =
|
||||
weeklyData;
|
||||
|
||||
if (weeklyData.length < 2) {
|
||||
dataResult = 'Not Enough Data';
|
||||
} else if (weeklyData.length >= 12) {
|
||||
dataResult = 'Batched';
|
||||
displayData = batchCreationArchiveData(weeklyData);
|
||||
return { state: 'Not Enough Data', value: placeholderData };
|
||||
}
|
||||
|
||||
if (weeklyData.length >= 12) {
|
||||
return {
|
||||
state: 'Batched',
|
||||
batchSize,
|
||||
value: makeChartData(
|
||||
batchCreationArchiveData(weeklyData, batchSize),
|
||||
theme,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
dataResult,
|
||||
aggregateOrProjectData: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Flags archived',
|
||||
data: displayData,
|
||||
backgroundColor: theme.palette.charts.A2,
|
||||
borderColor: theme.palette.charts.A2,
|
||||
hoverBackgroundColor: theme.palette.charts.A2,
|
||||
hoverBorderColor: theme.palette.charts.A2,
|
||||
parsing: {
|
||||
yAxisKey: 'archivedFlags',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: 'Flags created',
|
||||
data: displayData,
|
||||
backgroundColor: theme.palette.charts.A1,
|
||||
borderColor: theme.palette.charts.A1,
|
||||
hoverBackgroundColor: theme.palette.charts.A1,
|
||||
hoverBorderColor: theme.palette.charts.A1,
|
||||
parsing: {
|
||||
yAxisKey: 'totalCreatedFlags',
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
state: 'Weekly',
|
||||
value: makeChartData(weeklyData, theme),
|
||||
};
|
||||
}, [creationVsArchivedChart, theme]);
|
||||
|
||||
const useGraphCover = dataResult === 'Not Enough Data' || isLoading;
|
||||
const showNotEnoughDataText =
|
||||
dataResult === 'Not Enough Data' && !isLoading;
|
||||
const data = useGraphCover ? placeholderData : aggregateOrProjectData;
|
||||
const useGraphCover = ['Loading', 'Not Enough Data'].includes(
|
||||
chartData.state,
|
||||
);
|
||||
const showNotEnoughDataText = chartData.state === 'Not Enough Data';
|
||||
|
||||
const locale = getDateFnsLocale(locationSettings.locale);
|
||||
const batchedTooltipTitle = useBatchedTooltipDate();
|
||||
@ -153,7 +171,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
external: createTooltip(setTooltip),
|
||||
callbacks: {
|
||||
title:
|
||||
dataResult === 'Batched'
|
||||
chartData.state === 'Batched'
|
||||
? batchedTooltipTitle
|
||||
: undefined,
|
||||
},
|
||||
@ -171,7 +189,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
display: true,
|
||||
time: {
|
||||
unit:
|
||||
dataResult === 'Batched'
|
||||
chartData.state === 'Batched'
|
||||
? ('month' as const)
|
||||
: ('week' as const),
|
||||
tooltipFormat: 'P',
|
||||
@ -181,7 +199,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
},
|
||||
ticks: {
|
||||
source:
|
||||
dataResult === 'Batched'
|
||||
chartData.state === 'Batched'
|
||||
? ('auto' as const)
|
||||
: ('data' as const),
|
||||
display: !useGraphCover,
|
||||
@ -208,7 +226,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||
return (
|
||||
<>
|
||||
<Bar
|
||||
data={data}
|
||||
data={chartData.value}
|
||||
options={options}
|
||||
height={100}
|
||||
width={250}
|
||||
|
||||
@ -6,7 +6,7 @@ import type {
|
||||
BatchedWeekDataWithRatio,
|
||||
} from './types.ts';
|
||||
|
||||
export const batchCreationArchiveData = batchData({
|
||||
const batchArgs = (batchSize?: number) => ({
|
||||
merge: (accumulated: BatchedWeekData, next: FinalizedWeekData) => {
|
||||
if (next.state === 'empty') {
|
||||
return {
|
||||
@ -53,4 +53,10 @@ export const batchCreationArchiveData = batchData({
|
||||
endDate: item.date,
|
||||
};
|
||||
},
|
||||
batchSize,
|
||||
});
|
||||
|
||||
export const batchCreationArchiveData = (
|
||||
data: FinalizedWeekData[],
|
||||
batchSize?: number,
|
||||
) => batchData(batchArgs(batchSize))(data);
|
||||
|
||||
@ -1,29 +1,18 @@
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { type FC, useMemo } from 'react';
|
||||
import type { InstanceInsightsSchema } from 'openapi';
|
||||
import { useProjectChartData } from 'component/insights/hooks/useProjectChartData';
|
||||
import type { FC } from 'react';
|
||||
import {
|
||||
fillGradientPrimary,
|
||||
LineChart,
|
||||
NotEnoughData,
|
||||
} from 'component/insights/components/LineChart/LineChart';
|
||||
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';
|
||||
import type { WeekData } from './types.ts';
|
||||
import type { ChartData } from '../chartData.ts';
|
||||
|
||||
interface IProjectHealthChartProps {
|
||||
lifecycleTrends: GroupedDataByProject<
|
||||
InstanceInsightsSchema['lifecycleTrends']
|
||||
>;
|
||||
isAggregate?: boolean;
|
||||
isLoading?: boolean;
|
||||
labels: { week: string; date: string }[];
|
||||
interface INewProductionFlagsChartProps {
|
||||
chartData: ChartData<WeekData, number>;
|
||||
}
|
||||
|
||||
const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
||||
const useOverrideOptions = (chartData: ChartData<WeekData, number>) => {
|
||||
const batchedTooltipTitle = useBatchedTooltipDate();
|
||||
const sharedOptions = {
|
||||
parsing: {
|
||||
@ -31,7 +20,7 @@ const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
||||
xAxisKey: 'date',
|
||||
},
|
||||
};
|
||||
switch (chartDataResult) {
|
||||
switch (chartData.state) {
|
||||
case 'Batched': {
|
||||
return {
|
||||
...sharedOptions,
|
||||
@ -58,112 +47,27 @@ const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
||||
case 'Weekly':
|
||||
return sharedOptions;
|
||||
case 'Not Enough Data':
|
||||
case 'Loading':
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const NewProductionFlagsChart: FC<IProjectHealthChartProps> = ({
|
||||
labels,
|
||||
lifecycleTrends,
|
||||
isAggregate,
|
||||
isLoading,
|
||||
export const NewProductionFlagsChart: FC<INewProductionFlagsChartProps> = ({
|
||||
chartData,
|
||||
}) => {
|
||||
const lifecycleData = useProjectChartData(lifecycleTrends);
|
||||
const theme = useTheme();
|
||||
const placeholderData = usePlaceholderData();
|
||||
|
||||
const shouldBatchData = labels.length >= 12;
|
||||
|
||||
const { aggregateProductionFlagsData, chartDataResult } = useMemo(() => {
|
||||
const weeks: WeekData[] = labels.map(({ week, date }) => {
|
||||
return lifecycleData.datasets
|
||||
.map((d) => d.data.find((item) => item.week === week))
|
||||
.reduce(
|
||||
(acc: WeekData, item: WeekData) => {
|
||||
if (item) {
|
||||
acc.newProductionFlags =
|
||||
(acc.newProductionFlags ?? 0) +
|
||||
(item.newProductionFlags ?? 0);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ date, week },
|
||||
);
|
||||
});
|
||||
|
||||
let chartDataResult: ChartDataResult = 'Weekly';
|
||||
let displayData: WeekData[] | (BatchedWeekData | null)[] = weeks;
|
||||
|
||||
if (!isLoading && labels.length < 2) {
|
||||
chartDataResult = 'Not Enough Data';
|
||||
} else if (shouldBatchData) {
|
||||
chartDataResult = 'Batched';
|
||||
displayData = batchProductionFlagsData(weeks);
|
||||
}
|
||||
|
||||
return {
|
||||
chartDataResult,
|
||||
aggregateProductionFlagsData: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'Number of new flags',
|
||||
data: displayData,
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}, [lifecycleData, theme, isLoading]);
|
||||
|
||||
const padProjectData = () => {
|
||||
if (lifecycleData.datasets.length === 0) {
|
||||
// fallback for when there's no data in the selected time period for the selected projects
|
||||
return [
|
||||
{
|
||||
label: 'No data',
|
||||
data: labels,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const padData = (data: WeekData[]) => {
|
||||
const padded = labels.map(
|
||||
({ date, week }) =>
|
||||
data.find((item) => item?.week === week) ?? {
|
||||
date,
|
||||
week,
|
||||
},
|
||||
);
|
||||
return shouldBatchData ? batchProductionFlagsData(padded) : padded;
|
||||
};
|
||||
|
||||
return lifecycleData.datasets.map(({ data, ...dataset }) => ({
|
||||
...dataset,
|
||||
data: padData(data),
|
||||
}));
|
||||
};
|
||||
|
||||
const aggregateOrProjectData = isAggregate
|
||||
? aggregateProductionFlagsData
|
||||
: {
|
||||
datasets: padProjectData(),
|
||||
};
|
||||
|
||||
const notEnoughData = chartDataResult === 'Not Enough Data';
|
||||
const data =
|
||||
notEnoughData || isLoading ? placeholderData : aggregateOrProjectData;
|
||||
|
||||
const overrideOptions = useOverrideOptions(chartDataResult);
|
||||
const overrideOptions = useOverrideOptions(chartData);
|
||||
|
||||
return (
|
||||
<LineChart
|
||||
key={isAggregate ? 'aggregate' : 'project'}
|
||||
data={data}
|
||||
data={chartData.value}
|
||||
overrideOptions={overrideOptions}
|
||||
cover={notEnoughData ? <NotEnoughData /> : isLoading}
|
||||
cover={
|
||||
chartData.state === 'Not Enough Data' ? (
|
||||
<NotEnoughData />
|
||||
) : (
|
||||
chartData.state === 'Loading'
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -11,6 +11,7 @@ it('handles a single data point', () => {
|
||||
newProductionFlags: 5,
|
||||
date: input.date,
|
||||
endDate: input.date,
|
||||
week: '50',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -33,6 +34,7 @@ it('adds data in the expected way', () => {
|
||||
newProductionFlags: 11,
|
||||
date: '2022-01-01',
|
||||
endDate: '2022-02-01',
|
||||
week: '50',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { batchData } from '../batchData.ts';
|
||||
import type { BatchedWeekData, WeekData } from './types.ts';
|
||||
|
||||
export const batchProductionFlagsData = batchData({
|
||||
const batchArgs = (batchSize?: number) => ({
|
||||
merge: (accumulated: BatchedWeekData, next: WeekData) => {
|
||||
if (next.newProductionFlags)
|
||||
accumulated.newProductionFlags =
|
||||
@ -16,11 +16,15 @@ export const batchProductionFlagsData = batchData({
|
||||
return accumulated;
|
||||
},
|
||||
map: (item: WeekData) => {
|
||||
const { week: _, ...shared } = item;
|
||||
|
||||
return {
|
||||
...shared,
|
||||
...item,
|
||||
endDate: item.date,
|
||||
};
|
||||
},
|
||||
batchSize,
|
||||
});
|
||||
|
||||
export const batchProductionFlagsData = (
|
||||
data: WeekData[],
|
||||
batchSize?: number,
|
||||
): BatchedWeekData[] => batchData(batchArgs(batchSize))(data);
|
||||
|
||||
@ -4,6 +4,6 @@ export type WeekData = {
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type BatchedWeekData = Omit<WeekData, 'week'> & {
|
||||
export type BatchedWeekData = WeekData & {
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
@ -0,0 +1,169 @@
|
||||
import { useTheme } from '@mui/material';
|
||||
import type { GroupedDataByProject } from 'component/insights/hooks/useGroupedProjectTrends';
|
||||
import { usePlaceholderData } from 'component/insights/hooks/usePlaceholderData';
|
||||
import { useProjectChartData } from 'component/insights/hooks/useProjectChartData';
|
||||
import type { InstanceInsightsSchema } from 'openapi';
|
||||
import type { BatchedWeekData, WeekData } from './types.ts';
|
||||
import { useMemo } from 'react';
|
||||
import { batchProductionFlagsData } from './batchProductionFlagsData.ts';
|
||||
import { fillGradientPrimary } from 'component/insights/components/LineChart/LineChart';
|
||||
import { calculateMedian } from 'component/insights/componentsStat/NewProductionFlagsStats/calculateMedian.ts';
|
||||
import { hasRealData, type ChartData } from '../chartData.ts';
|
||||
import type { ChartData as ChartJsData } from 'chart.js';
|
||||
|
||||
const batchSize = 4;
|
||||
|
||||
type UseProductionFlagsDataProps = {
|
||||
groupedLifecycleData: GroupedDataByProject<
|
||||
InstanceInsightsSchema['lifecycleTrends']
|
||||
>;
|
||||
showAllProjects?: boolean;
|
||||
loading?: boolean;
|
||||
labels: { week: string; date: string }[];
|
||||
};
|
||||
|
||||
type UseProductionFlagsDataResult = {
|
||||
chartData: ChartData<WeekData, number>;
|
||||
median: string | number;
|
||||
};
|
||||
|
||||
const padProjectData = ({
|
||||
labels,
|
||||
lifecycleData,
|
||||
shouldBatchData,
|
||||
}: {
|
||||
labels: UseProductionFlagsDataProps['labels'];
|
||||
lifecycleData: ChartJsData<'line', WeekData[]>;
|
||||
shouldBatchData: boolean;
|
||||
}) => {
|
||||
if (lifecycleData.datasets.length === 0) {
|
||||
// fallback for when there's no data in the selected time period for the selected projects
|
||||
return [
|
||||
{
|
||||
label: 'No data',
|
||||
data: labels,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const padData = (data: WeekData[]) => {
|
||||
const padded = labels.map(
|
||||
({ date, week }) =>
|
||||
data.find((item) => item?.week === week) ?? {
|
||||
date,
|
||||
week,
|
||||
},
|
||||
);
|
||||
return shouldBatchData
|
||||
? batchProductionFlagsData(padded, batchSize)
|
||||
: padded;
|
||||
};
|
||||
|
||||
return lifecycleData.datasets.map(({ data, ...dataset }) => ({
|
||||
...dataset,
|
||||
data: padData(data),
|
||||
}));
|
||||
};
|
||||
|
||||
export const useProductionFlagsData = ({
|
||||
groupedLifecycleData,
|
||||
showAllProjects,
|
||||
loading,
|
||||
labels,
|
||||
}: UseProductionFlagsDataProps): UseProductionFlagsDataResult => {
|
||||
const lifecycleData = useProjectChartData(groupedLifecycleData);
|
||||
const theme = useTheme();
|
||||
const placeholderData = usePlaceholderData();
|
||||
|
||||
const shouldBatchData = labels.length >= 12;
|
||||
|
||||
const chartData = useMemo((): ChartData<
|
||||
WeekData | BatchedWeekData,
|
||||
number
|
||||
> => {
|
||||
if (loading) {
|
||||
return {
|
||||
state: 'Loading',
|
||||
value: placeholderData,
|
||||
};
|
||||
}
|
||||
|
||||
if (labels.length < 2) {
|
||||
return {
|
||||
state: 'Not Enough Data',
|
||||
value: placeholderData,
|
||||
};
|
||||
}
|
||||
|
||||
const weeks: WeekData[] = labels.map(({ week, date }) => {
|
||||
return lifecycleData.datasets
|
||||
.map((d) => d.data.find((item: WeekData) => item.week === week))
|
||||
.reduce(
|
||||
(acc: WeekData, item: WeekData) => {
|
||||
if (item && item.newProductionFlags !== undefined) {
|
||||
acc.newProductionFlags =
|
||||
(acc.newProductionFlags ?? 0) +
|
||||
item.newProductionFlags;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ date, week },
|
||||
);
|
||||
});
|
||||
|
||||
const chartArgs = (data: WeekData[] | BatchedWeekData[]) => ({
|
||||
datasets: [
|
||||
{
|
||||
label: 'Number of new flags',
|
||||
data,
|
||||
borderColor: theme.palette.primary.light,
|
||||
backgroundColor: fillGradientPrimary,
|
||||
fill: true,
|
||||
order: 3,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (shouldBatchData) {
|
||||
return {
|
||||
state: 'Batched',
|
||||
value: chartArgs(batchProductionFlagsData(weeks, batchSize)),
|
||||
batchSize: batchSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: 'Weekly',
|
||||
value: chartArgs(weeks),
|
||||
};
|
||||
}, [lifecycleData, theme, loading]);
|
||||
|
||||
if (!hasRealData(chartData)) {
|
||||
return {
|
||||
chartData,
|
||||
median: 'N/A',
|
||||
};
|
||||
}
|
||||
|
||||
const { value: allProjectsData, ...metadata } = chartData;
|
||||
|
||||
const value = showAllProjects
|
||||
? allProjectsData
|
||||
: {
|
||||
datasets: padProjectData({
|
||||
labels,
|
||||
lifecycleData,
|
||||
shouldBatchData,
|
||||
}),
|
||||
};
|
||||
|
||||
const median = calculateMedian(value.datasets);
|
||||
|
||||
return {
|
||||
chartData: {
|
||||
...metadata,
|
||||
value,
|
||||
},
|
||||
median,
|
||||
};
|
||||
};
|
||||
@ -13,38 +13,3 @@ it('batches by 4, starting from the first entry', () => {
|
||||
),
|
||||
).toStrictEqual([50, 23]);
|
||||
});
|
||||
|
||||
describe('null entry handling', () => {
|
||||
it('creates a new entry from the next item, if the accumulated is null', () => {
|
||||
const input = [null, 9];
|
||||
const result = batchData<number, number>({
|
||||
merge: (x, y) => x * y,
|
||||
map: Math.sqrt,
|
||||
})(input);
|
||||
expect(result).toStrictEqual([3]);
|
||||
});
|
||||
it('merges the accumulated entry with the next item if they are both present', () => {
|
||||
const input = [4, 9];
|
||||
const result = batchData<number, number>({
|
||||
merge: (x, y) => x + y,
|
||||
map: (x) => x,
|
||||
})(input);
|
||||
expect(result).toStrictEqual([13]);
|
||||
});
|
||||
it('it returns null if both the accumulated and the next item are null', () => {
|
||||
const input = [null, null];
|
||||
const result = batchData<number, number>({
|
||||
merge: (x, y) => x + y,
|
||||
map: (x) => x,
|
||||
})(input);
|
||||
expect(result).toStrictEqual([null]);
|
||||
});
|
||||
it('it returns the accumulated entry if the next item is null', () => {
|
||||
const input = [7, null];
|
||||
const result = batchData<number, number>({
|
||||
merge: (x, y) => x * y,
|
||||
map: (x) => x,
|
||||
})(input);
|
||||
expect(result).toStrictEqual([7]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,33 +6,22 @@ export type BatchDataOptions<T, TBatched> = {
|
||||
batchSize?: number;
|
||||
};
|
||||
|
||||
const nullOrUndefined = (value: any): value is null | undefined =>
|
||||
value === null || value === undefined;
|
||||
|
||||
export const batchData =
|
||||
<T, TBatched>({
|
||||
merge,
|
||||
map,
|
||||
batchSize = defaultBatchSize,
|
||||
}: BatchDataOptions<T, TBatched>) =>
|
||||
(xs: (T | null)[]): (TBatched | null)[] =>
|
||||
xs.reduce(
|
||||
(acc, curr, index) => {
|
||||
(xs: T[]): TBatched[] =>
|
||||
xs.reduce((acc, curr, index) => {
|
||||
const currentAggregatedIndex = Math.floor(index / batchSize);
|
||||
const data = acc[currentAggregatedIndex];
|
||||
|
||||
const hasData = !nullOrUndefined(data);
|
||||
const hasCurr = curr !== null;
|
||||
|
||||
if (!hasData && !hasCurr) {
|
||||
acc[currentAggregatedIndex] = null;
|
||||
} else if (hasData && hasCurr) {
|
||||
if (data) {
|
||||
acc[currentAggregatedIndex] = merge(data, curr);
|
||||
} else if (!hasData && hasCurr) {
|
||||
} else {
|
||||
acc[currentAggregatedIndex] = map(curr);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as (TBatched | null)[],
|
||||
);
|
||||
}, [] as TBatched[]);
|
||||
|
||||
33
frontend/src/component/insights/componentsChart/chartData.ts
Normal file
33
frontend/src/component/insights/componentsChart/chartData.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import type { ChartTypeRegistry, ChartData as ChartJsData } from 'chart.js';
|
||||
|
||||
export type ChartData<
|
||||
T,
|
||||
TPlaceholder = T,
|
||||
TChartType extends keyof ChartTypeRegistry = any,
|
||||
> =
|
||||
| { state: 'Loading'; value: ChartJsData<TChartType, TPlaceholder[]> }
|
||||
| {
|
||||
state: 'Not Enough Data';
|
||||
value: ChartJsData<TChartType, TPlaceholder[]>;
|
||||
}
|
||||
| { state: 'Weekly'; value: ChartJsData<TChartType, T[]> }
|
||||
| {
|
||||
state: 'Batched';
|
||||
batchSize: number;
|
||||
value: ChartJsData<TChartType, T[]>;
|
||||
};
|
||||
|
||||
export const hasRealData = <
|
||||
T,
|
||||
TPlaceholder = T,
|
||||
TChartType extends keyof ChartTypeRegistry = any,
|
||||
>(
|
||||
chartDataStatus: ChartData<T, TPlaceholder, TChartType>,
|
||||
): chartDataStatus is
|
||||
| { state: 'Weekly'; value: ChartJsData<TChartType, T[]> }
|
||||
| {
|
||||
state: 'Batched';
|
||||
batchSize: number;
|
||||
value: ChartJsData<TChartType, T[]>;
|
||||
} =>
|
||||
chartDataStatus.state === 'Weekly' || chartDataStatus.state === 'Batched';
|
||||
@ -1 +0,0 @@
|
||||
type ChartDataResult = 'Not Enough Data' | 'Batched' | 'Weekly';
|
||||
@ -0,0 +1,28 @@
|
||||
import type { FC } from 'react';
|
||||
import { StatCard } from '../shared/StatCard.tsx';
|
||||
import type { ChartData } from 'component/insights/componentsChart/chartData.ts';
|
||||
|
||||
interface NewProductionFlagsStatsProps {
|
||||
median: number | string;
|
||||
chartData: ChartData<unknown, unknown>;
|
||||
}
|
||||
|
||||
export const NewProductionFlagsStats: FC<NewProductionFlagsStatsProps> = ({
|
||||
median,
|
||||
chartData,
|
||||
}) => {
|
||||
const label =
|
||||
chartData.state === 'Batched'
|
||||
? `Median per ${chartData.batchSize} weeks`
|
||||
: 'Median per week';
|
||||
|
||||
return (
|
||||
<StatCard
|
||||
value={median}
|
||||
label={label}
|
||||
tooltip='The median number of flags that have entered production for the selected time range.'
|
||||
explanation='How often do flags go live in production?'
|
||||
isLoading={chartData.state === 'Loading'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import { useTheme } from '@mui/material';
|
||||
import { useMemo } from 'react';
|
||||
import { fillGradientPrimary } from '../components/LineChart/LineChart.jsx';
|
||||
import type { ChartData } from 'chart.js';
|
||||
|
||||
type PlaceholderDataOptions = {
|
||||
fill?: boolean;
|
||||
@ -9,7 +10,7 @@ type PlaceholderDataOptions = {
|
||||
|
||||
export const usePlaceholderData = (
|
||||
placeholderDataOptions?: PlaceholderDataOptions,
|
||||
) => {
|
||||
): ChartData<'line', number[]> => {
|
||||
const { fill = false, type = 'constant' } = placeholderDataOptions || {};
|
||||
const theme = useTheme();
|
||||
|
||||
|
||||
@ -22,13 +22,42 @@ import {
|
||||
StyledWidget,
|
||||
StyledWidgetContent,
|
||||
StyledWidgetStats,
|
||||
StatsExplanation,
|
||||
} from '../InsightsCharts.styles';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import { NewProductionFlagsChart } from '../componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx';
|
||||
import Lightbulb from '@mui/icons-material/LightbulbOutlined';
|
||||
import { CreationArchiveChart } from '../componentsChart/CreationArchiveChart/CreationArchiveChart.tsx';
|
||||
import { CreationArchiveStats } from '../componentsStat/CreationArchiveStats/CreationArchiveStats.tsx';
|
||||
import { NewProductionFlagsStats } from '../componentsStat/NewProductionFlagsStats/NewProductionFlagsStats.tsx';
|
||||
import { useProductionFlagsData } from '../componentsChart/NewProductionFlagsChart/useNewProductionFlagsData.ts';
|
||||
|
||||
const NewProductionFlagsWidget = ({
|
||||
groupedLifecycleData,
|
||||
loading,
|
||||
showAllProjects,
|
||||
labels,
|
||||
}) => {
|
||||
const { median, chartData } = useProductionFlagsData({
|
||||
groupedLifecycleData,
|
||||
labels,
|
||||
loading,
|
||||
showAllProjects,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledWidget>
|
||||
<StyledWidgetStats width={275}>
|
||||
<WidgetTitle title='New flags in production' />
|
||||
<NewProductionFlagsStats
|
||||
chartData={chartData}
|
||||
median={median}
|
||||
/>
|
||||
</StyledWidgetStats>
|
||||
<StyledChartContainer>
|
||||
<NewProductionFlagsChart chartData={chartData} />
|
||||
</StyledChartContainer>
|
||||
</StyledWidget>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerformanceInsights: FC = () => {
|
||||
const statePrefix = 'performance-';
|
||||
@ -85,23 +114,12 @@ export const PerformanceInsights: FC = () => {
|
||||
}
|
||||
>
|
||||
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
||||
<StyledWidget>
|
||||
<StyledWidgetStats width={275}>
|
||||
<WidgetTitle title='New flags in production' />
|
||||
<StatsExplanation>
|
||||
<Lightbulb color='primary' />
|
||||
How often do flags go live in production?
|
||||
</StatsExplanation>
|
||||
</StyledWidgetStats>
|
||||
<StyledChartContainer>
|
||||
<NewProductionFlagsChart
|
||||
<NewProductionFlagsWidget
|
||||
groupedLifecycleData={groupedLifecycleData}
|
||||
loading={loading}
|
||||
showAllProjects={showAllProjects}
|
||||
labels={insights.labels}
|
||||
lifecycleTrends={groupedLifecycleData}
|
||||
isAggregate={showAllProjects}
|
||||
isLoading={loading}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</StyledWidget>
|
||||
) : null}
|
||||
|
||||
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user