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 { batchCreationArchiveData } from './batchCreationArchiveData.ts';
|
||||||
import { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts';
|
import { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts';
|
||||||
import { aggregateCreationArchiveData } from './aggregateCreationArchiveData.ts';
|
import { aggregateCreationArchiveData } from './aggregateCreationArchiveData.ts';
|
||||||
|
import type { Theme } from '@mui/material/styles/createTheme';
|
||||||
|
import type { ChartData } from '../chartData.ts';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@ -51,6 +53,42 @@ interface ICreationArchiveChartProps {
|
|||||||
labels: { week: string; date: string }[];
|
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> = ({
|
export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
||||||
creationArchiveTrends,
|
creationArchiveTrends,
|
||||||
isLoading,
|
isLoading,
|
||||||
@ -61,62 +99,42 @@ 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 { dataResult, aggregateOrProjectData } = useMemo(() => {
|
const chartData: ChartData<FinalizedWeekData | BatchedWeekData> =
|
||||||
const weeklyData = aggregateCreationArchiveData(
|
useMemo(() => {
|
||||||
labels,
|
if (isLoading) {
|
||||||
creationVsArchivedChart.datasets,
|
return { state: 'Loading', value: placeholderData };
|
||||||
);
|
}
|
||||||
|
|
||||||
let dataResult: ChartDataResult = 'Weekly';
|
const weeklyData = aggregateCreationArchiveData(
|
||||||
let displayData: FinalizedWeekData[] | (BatchedWeekData | null)[] =
|
labels,
|
||||||
weeklyData;
|
creationVsArchivedChart.datasets,
|
||||||
|
);
|
||||||
|
|
||||||
if (weeklyData.length < 2) {
|
if (weeklyData.length < 2) {
|
||||||
dataResult = 'Not Enough Data';
|
return { state: 'Not Enough Data', value: placeholderData };
|
||||||
} else if (weeklyData.length >= 12) {
|
}
|
||||||
dataResult = 'Batched';
|
|
||||||
displayData = batchCreationArchiveData(weeklyData);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
if (weeklyData.length >= 12) {
|
||||||
dataResult,
|
return {
|
||||||
aggregateOrProjectData: {
|
state: 'Batched',
|
||||||
datasets: [
|
batchSize,
|
||||||
{
|
value: makeChartData(
|
||||||
label: 'Flags archived',
|
batchCreationArchiveData(weeklyData, batchSize),
|
||||||
data: displayData,
|
theme,
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [creationVsArchivedChart, theme]);
|
|
||||||
|
|
||||||
const useGraphCover = dataResult === 'Not Enough Data' || isLoading;
|
return {
|
||||||
const showNotEnoughDataText =
|
state: 'Weekly',
|
||||||
dataResult === 'Not Enough Data' && !isLoading;
|
value: makeChartData(weeklyData, theme),
|
||||||
const data = useGraphCover ? placeholderData : aggregateOrProjectData;
|
};
|
||||||
|
}, [creationVsArchivedChart, theme]);
|
||||||
|
|
||||||
|
const useGraphCover = ['Loading', 'Not Enough Data'].includes(
|
||||||
|
chartData.state,
|
||||||
|
);
|
||||||
|
const showNotEnoughDataText = chartData.state === 'Not Enough Data';
|
||||||
|
|
||||||
const locale = getDateFnsLocale(locationSettings.locale);
|
const locale = getDateFnsLocale(locationSettings.locale);
|
||||||
const batchedTooltipTitle = useBatchedTooltipDate();
|
const batchedTooltipTitle = useBatchedTooltipDate();
|
||||||
@ -153,7 +171,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
|||||||
external: createTooltip(setTooltip),
|
external: createTooltip(setTooltip),
|
||||||
callbacks: {
|
callbacks: {
|
||||||
title:
|
title:
|
||||||
dataResult === 'Batched'
|
chartData.state === 'Batched'
|
||||||
? batchedTooltipTitle
|
? batchedTooltipTitle
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
@ -171,7 +189,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
|||||||
display: true,
|
display: true,
|
||||||
time: {
|
time: {
|
||||||
unit:
|
unit:
|
||||||
dataResult === 'Batched'
|
chartData.state === 'Batched'
|
||||||
? ('month' as const)
|
? ('month' as const)
|
||||||
: ('week' as const),
|
: ('week' as const),
|
||||||
tooltipFormat: 'P',
|
tooltipFormat: 'P',
|
||||||
@ -181,7 +199,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
|||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
source:
|
source:
|
||||||
dataResult === 'Batched'
|
chartData.state === 'Batched'
|
||||||
? ('auto' as const)
|
? ('auto' as const)
|
||||||
: ('data' as const),
|
: ('data' as const),
|
||||||
display: !useGraphCover,
|
display: !useGraphCover,
|
||||||
@ -208,7 +226,7 @@ export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Bar
|
<Bar
|
||||||
data={data}
|
data={chartData.value}
|
||||||
options={options}
|
options={options}
|
||||||
height={100}
|
height={100}
|
||||||
width={250}
|
width={250}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type {
|
|||||||
BatchedWeekDataWithRatio,
|
BatchedWeekDataWithRatio,
|
||||||
} from './types.ts';
|
} from './types.ts';
|
||||||
|
|
||||||
export const batchCreationArchiveData = batchData({
|
const batchArgs = (batchSize?: number) => ({
|
||||||
merge: (accumulated: BatchedWeekData, next: FinalizedWeekData) => {
|
merge: (accumulated: BatchedWeekData, next: FinalizedWeekData) => {
|
||||||
if (next.state === 'empty') {
|
if (next.state === 'empty') {
|
||||||
return {
|
return {
|
||||||
@ -53,4 +53,10 @@ export const batchCreationArchiveData = batchData({
|
|||||||
endDate: item.date,
|
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 'chartjs-adapter-date-fns';
|
||||||
import { type FC, useMemo } from 'react';
|
import type { FC } from 'react';
|
||||||
import type { InstanceInsightsSchema } from 'openapi';
|
|
||||||
import { useProjectChartData } from 'component/insights/hooks/useProjectChartData';
|
|
||||||
import {
|
import {
|
||||||
fillGradientPrimary,
|
|
||||||
LineChart,
|
LineChart,
|
||||||
NotEnoughData,
|
NotEnoughData,
|
||||||
} from 'component/insights/components/LineChart/LineChart';
|
} 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 { useBatchedTooltipDate } from '../useBatchedTooltipDate.ts';
|
||||||
|
import type { WeekData } from './types.ts';
|
||||||
|
import type { ChartData } from '../chartData.ts';
|
||||||
|
|
||||||
interface IProjectHealthChartProps {
|
interface INewProductionFlagsChartProps {
|
||||||
lifecycleTrends: GroupedDataByProject<
|
chartData: ChartData<WeekData, number>;
|
||||||
InstanceInsightsSchema['lifecycleTrends']
|
|
||||||
>;
|
|
||||||
isAggregate?: boolean;
|
|
||||||
isLoading?: boolean;
|
|
||||||
labels: { week: string; date: string }[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
const useOverrideOptions = (chartData: ChartData<WeekData, number>) => {
|
||||||
const batchedTooltipTitle = useBatchedTooltipDate();
|
const batchedTooltipTitle = useBatchedTooltipDate();
|
||||||
const sharedOptions = {
|
const sharedOptions = {
|
||||||
parsing: {
|
parsing: {
|
||||||
@ -31,7 +20,7 @@ const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
|||||||
xAxisKey: 'date',
|
xAxisKey: 'date',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
switch (chartDataResult) {
|
switch (chartData.state) {
|
||||||
case 'Batched': {
|
case 'Batched': {
|
||||||
return {
|
return {
|
||||||
...sharedOptions,
|
...sharedOptions,
|
||||||
@ -58,112 +47,27 @@ const useOverrideOptions = (chartDataResult: ChartDataResult) => {
|
|||||||
case 'Weekly':
|
case 'Weekly':
|
||||||
return sharedOptions;
|
return sharedOptions;
|
||||||
case 'Not Enough Data':
|
case 'Not Enough Data':
|
||||||
|
case 'Loading':
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NewProductionFlagsChart: FC<IProjectHealthChartProps> = ({
|
export const NewProductionFlagsChart: FC<INewProductionFlagsChartProps> = ({
|
||||||
labels,
|
chartData,
|
||||||
lifecycleTrends,
|
|
||||||
isAggregate,
|
|
||||||
isLoading,
|
|
||||||
}) => {
|
}) => {
|
||||||
const lifecycleData = useProjectChartData(lifecycleTrends);
|
const overrideOptions = useOverrideOptions(chartData);
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LineChart
|
<LineChart
|
||||||
key={isAggregate ? 'aggregate' : 'project'}
|
data={chartData.value}
|
||||||
data={data}
|
|
||||||
overrideOptions={overrideOptions}
|
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,
|
newProductionFlags: 5,
|
||||||
date: input.date,
|
date: input.date,
|
||||||
endDate: input.date,
|
endDate: input.date,
|
||||||
|
week: '50',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -33,6 +34,7 @@ it('adds data in the expected way', () => {
|
|||||||
newProductionFlags: 11,
|
newProductionFlags: 11,
|
||||||
date: '2022-01-01',
|
date: '2022-01-01',
|
||||||
endDate: '2022-02-01',
|
endDate: '2022-02-01',
|
||||||
|
week: '50',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { batchData } from '../batchData.ts';
|
import { batchData } from '../batchData.ts';
|
||||||
import type { BatchedWeekData, WeekData } from './types.ts';
|
import type { BatchedWeekData, WeekData } from './types.ts';
|
||||||
|
|
||||||
export const batchProductionFlagsData = batchData({
|
const batchArgs = (batchSize?: number) => ({
|
||||||
merge: (accumulated: BatchedWeekData, next: WeekData) => {
|
merge: (accumulated: BatchedWeekData, next: WeekData) => {
|
||||||
if (next.newProductionFlags)
|
if (next.newProductionFlags)
|
||||||
accumulated.newProductionFlags =
|
accumulated.newProductionFlags =
|
||||||
@ -16,11 +16,15 @@ export const batchProductionFlagsData = batchData({
|
|||||||
return accumulated;
|
return accumulated;
|
||||||
},
|
},
|
||||||
map: (item: WeekData) => {
|
map: (item: WeekData) => {
|
||||||
const { week: _, ...shared } = item;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...shared,
|
...item,
|
||||||
endDate: item.date,
|
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;
|
date: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BatchedWeekData = Omit<WeekData, 'week'> & {
|
export type BatchedWeekData = WeekData & {
|
||||||
endDate: string;
|
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]);
|
).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;
|
batchSize?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const nullOrUndefined = (value: any): value is null | undefined =>
|
|
||||||
value === null || value === undefined;
|
|
||||||
|
|
||||||
export const batchData =
|
export const batchData =
|
||||||
<T, TBatched>({
|
<T, TBatched>({
|
||||||
merge,
|
merge,
|
||||||
map,
|
map,
|
||||||
batchSize = defaultBatchSize,
|
batchSize = defaultBatchSize,
|
||||||
}: BatchDataOptions<T, TBatched>) =>
|
}: BatchDataOptions<T, TBatched>) =>
|
||||||
(xs: (T | null)[]): (TBatched | null)[] =>
|
(xs: T[]): TBatched[] =>
|
||||||
xs.reduce(
|
xs.reduce((acc, curr, index) => {
|
||||||
(acc, curr, index) => {
|
const currentAggregatedIndex = Math.floor(index / batchSize);
|
||||||
const currentAggregatedIndex = Math.floor(index / batchSize);
|
const data = acc[currentAggregatedIndex];
|
||||||
const data = acc[currentAggregatedIndex];
|
|
||||||
|
|
||||||
const hasData = !nullOrUndefined(data);
|
if (data) {
|
||||||
const hasCurr = curr !== null;
|
acc[currentAggregatedIndex] = merge(data, curr);
|
||||||
|
} else {
|
||||||
|
acc[currentAggregatedIndex] = map(curr);
|
||||||
|
}
|
||||||
|
|
||||||
if (!hasData && !hasCurr) {
|
return acc;
|
||||||
acc[currentAggregatedIndex] = null;
|
}, [] as TBatched[]);
|
||||||
} else if (hasData && hasCurr) {
|
|
||||||
acc[currentAggregatedIndex] = merge(data, curr);
|
|
||||||
} else if (!hasData && hasCurr) {
|
|
||||||
acc[currentAggregatedIndex] = map(curr);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
[] as (TBatched | null)[],
|
|
||||||
);
|
|
||||||
|
|||||||
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 { useTheme } from '@mui/material';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { fillGradientPrimary } from '../components/LineChart/LineChart.jsx';
|
import { fillGradientPrimary } from '../components/LineChart/LineChart.jsx';
|
||||||
|
import type { ChartData } from 'chart.js';
|
||||||
|
|
||||||
type PlaceholderDataOptions = {
|
type PlaceholderDataOptions = {
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
@ -9,7 +10,7 @@ type PlaceholderDataOptions = {
|
|||||||
|
|
||||||
export const usePlaceholderData = (
|
export const usePlaceholderData = (
|
||||||
placeholderDataOptions?: PlaceholderDataOptions,
|
placeholderDataOptions?: PlaceholderDataOptions,
|
||||||
) => {
|
): ChartData<'line', number[]> => {
|
||||||
const { fill = false, type = 'constant' } = placeholderDataOptions || {};
|
const { fill = false, type = 'constant' } = placeholderDataOptions || {};
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
|
|||||||
@ -22,13 +22,42 @@ import {
|
|||||||
StyledWidget,
|
StyledWidget,
|
||||||
StyledWidgetContent,
|
StyledWidgetContent,
|
||||||
StyledWidgetStats,
|
StyledWidgetStats,
|
||||||
StatsExplanation,
|
|
||||||
} from '../InsightsCharts.styles';
|
} from '../InsightsCharts.styles';
|
||||||
import { useUiFlag } from 'hooks/useUiFlag';
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
import { NewProductionFlagsChart } from '../componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx';
|
import { NewProductionFlagsChart } from '../componentsChart/NewProductionFlagsChart/NewProductionFlagsChart.tsx';
|
||||||
import Lightbulb from '@mui/icons-material/LightbulbOutlined';
|
|
||||||
import { CreationArchiveChart } from '../componentsChart/CreationArchiveChart/CreationArchiveChart.tsx';
|
import { CreationArchiveChart } from '../componentsChart/CreationArchiveChart/CreationArchiveChart.tsx';
|
||||||
import { CreationArchiveStats } from '../componentsStat/CreationArchiveStats/CreationArchiveStats.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 = () => {
|
export const PerformanceInsights: FC = () => {
|
||||||
const statePrefix = 'performance-';
|
const statePrefix = 'performance-';
|
||||||
@ -85,23 +114,12 @@ export const PerformanceInsights: FC = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
||||||
<StyledWidget>
|
<NewProductionFlagsWidget
|
||||||
<StyledWidgetStats width={275}>
|
groupedLifecycleData={groupedLifecycleData}
|
||||||
<WidgetTitle title='New flags in production' />
|
loading={loading}
|
||||||
<StatsExplanation>
|
showAllProjects={showAllProjects}
|
||||||
<Lightbulb color='primary' />
|
labels={insights.labels}
|
||||||
How often do flags go live in production?
|
/>
|
||||||
</StatsExplanation>
|
|
||||||
</StyledWidgetStats>
|
|
||||||
<StyledChartContainer>
|
|
||||||
<NewProductionFlagsChart
|
|
||||||
labels={insights.labels}
|
|
||||||
lifecycleTrends={groupedLifecycleData}
|
|
||||||
isAggregate={showAllProjects}
|
|
||||||
isLoading={loading}
|
|
||||||
/>
|
|
||||||
</StyledChartContainer>
|
|
||||||
</StyledWidget>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
{isLifecycleGraphsEnabled && isEnterprise() ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user