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 weeklyData = aggregateCreationArchiveData(
 | 
			
		||||
            labels,
 | 
			
		||||
            creationVsArchivedChart.datasets,
 | 
			
		||||
        );
 | 
			
		||||
    const chartData: ChartData<FinalizedWeekData | BatchedWeekData> =
 | 
			
		||||
        useMemo(() => {
 | 
			
		||||
            if (isLoading) {
 | 
			
		||||
                return { state: 'Loading', value: placeholderData };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        let dataResult: ChartDataResult = 'Weekly';
 | 
			
		||||
        let displayData: FinalizedWeekData[] | (BatchedWeekData | null)[] =
 | 
			
		||||
            weeklyData;
 | 
			
		||||
            const weeklyData = aggregateCreationArchiveData(
 | 
			
		||||
                labels,
 | 
			
		||||
                creationVsArchivedChart.datasets,
 | 
			
		||||
            );
 | 
			
		||||
 | 
			
		||||
        if (weeklyData.length < 2) {
 | 
			
		||||
            dataResult = 'Not Enough Data';
 | 
			
		||||
        } else if (weeklyData.length >= 12) {
 | 
			
		||||
            dataResult = 'Batched';
 | 
			
		||||
            displayData = batchCreationArchiveData(weeklyData);
 | 
			
		||||
        }
 | 
			
		||||
            if (weeklyData.length < 2) {
 | 
			
		||||
                return { state: 'Not Enough Data', value: placeholderData };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        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,
 | 
			
		||||
                    },
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
    }, [creationVsArchivedChart, theme]);
 | 
			
		||||
            if (weeklyData.length >= 12) {
 | 
			
		||||
                return {
 | 
			
		||||
                    state: 'Batched',
 | 
			
		||||
                    batchSize,
 | 
			
		||||
                    value: makeChartData(
 | 
			
		||||
                        batchCreationArchiveData(weeklyData, batchSize),
 | 
			
		||||
                        theme,
 | 
			
		||||
                    ),
 | 
			
		||||
                };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
    const useGraphCover = dataResult === 'Not Enough Data' || isLoading;
 | 
			
		||||
    const showNotEnoughDataText =
 | 
			
		||||
        dataResult === 'Not Enough Data' && !isLoading;
 | 
			
		||||
    const data = useGraphCover ? placeholderData : aggregateOrProjectData;
 | 
			
		||||
            return {
 | 
			
		||||
                state: 'Weekly',
 | 
			
		||||
                value: makeChartData(weeklyData, theme),
 | 
			
		||||
            };
 | 
			
		||||
        }, [creationVsArchivedChart, theme]);
 | 
			
		||||
 | 
			
		||||
    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) => {
 | 
			
		||||
                const currentAggregatedIndex = Math.floor(index / batchSize);
 | 
			
		||||
                const data = acc[currentAggregatedIndex];
 | 
			
		||||
    (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 (data) {
 | 
			
		||||
                acc[currentAggregatedIndex] = merge(data, curr);
 | 
			
		||||
            } else {
 | 
			
		||||
                acc[currentAggregatedIndex] = map(curr);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
                if (!hasData && !hasCurr) {
 | 
			
		||||
                    acc[currentAggregatedIndex] = null;
 | 
			
		||||
                } else if (hasData && hasCurr) {
 | 
			
		||||
                    acc[currentAggregatedIndex] = merge(data, curr);
 | 
			
		||||
                } else if (!hasData && hasCurr) {
 | 
			
		||||
                    acc[currentAggregatedIndex] = map(curr);
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                return acc;
 | 
			
		||||
            },
 | 
			
		||||
            [] as (TBatched | null)[],
 | 
			
		||||
        );
 | 
			
		||||
            return acc;
 | 
			
		||||
        }, [] 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
 | 
			
		||||
                            labels={insights.labels}
 | 
			
		||||
                            lifecycleTrends={groupedLifecycleData}
 | 
			
		||||
                            isAggregate={showAllProjects}
 | 
			
		||||
                            isLoading={loading}
 | 
			
		||||
                        />
 | 
			
		||||
                    </StyledChartContainer>
 | 
			
		||||
                </StyledWidget>
 | 
			
		||||
                <NewProductionFlagsWidget
 | 
			
		||||
                    groupedLifecycleData={groupedLifecycleData}
 | 
			
		||||
                    loading={loading}
 | 
			
		||||
                    showAllProjects={showAllProjects}
 | 
			
		||||
                    labels={insights.labels}
 | 
			
		||||
                />
 | 
			
		||||
            ) : null}
 | 
			
		||||
 | 
			
		||||
            {isLifecycleGraphsEnabled && isEnterprise() ? (
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user