1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-04 13:48:56 +02:00

feat: create flags created vs archived chart (#10429)

This commit is contained in:
Jaanus Sellin 2025-07-31 10:54:37 +03:00 committed by GitHub
parent 2f1ca50bc4
commit 1d3aea47dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 576 additions and 76 deletions

View File

@ -1,127 +1,288 @@
import 'chartjs-adapter-date-fns';
import { type FC, useMemo } from 'react';
import { type FC, useMemo, useState } from 'react';
import type { InstanceInsightsSchema } from 'openapi';
import { useProjectChartData } from 'component/insights/hooks/useProjectChartData';
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 { Chart } from 'react-chartjs-2';
import {
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Tooltip,
Legend,
TimeScale,
Chart as ChartJS,
Filler,
} from 'chart.js';
import { useLocationSettings } from 'hooks/useLocationSettings';
import {
ChartTooltip,
type TooltipState,
} from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip';
import { createTooltip } from 'component/insights/components/LineChart/createTooltip';
import { CreationArchiveTooltip } from './CreationArchiveTooltip.tsx';
import { CreationArchiveRatioTooltip } from './CreationArchiveRatioTooltip.tsx';
import { getFlagTypeColors } from './flagTypeColors.ts';
import type { WeekData, RawWeekData } from './types.ts';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
TimeScale,
Tooltip,
Legend,
Filler,
);
interface ICreationArchiveChartProps {
creationArchiveTrends: GroupedDataByProject<
InstanceInsightsSchema['creationArchiveTrends']
>;
isAggregate?: boolean;
isLoading?: boolean;
}
type WeekData = {
archivedFlags: number;
totalCreatedFlags: number;
week: string;
date?: string;
};
type RawWeekData = {
archivedFlags: number;
createdFlags: Record<string, number>;
week: string;
date: string;
};
export const CreationArchiveChart: FC<ICreationArchiveChartProps> = ({
creationArchiveTrends,
isAggregate,
isLoading,
}) => {
const creationArchiveData = useProjectChartData(creationArchiveTrends);
const creationVsArchivedChart = useProjectChartData(creationArchiveTrends);
const theme = useTheme();
const placeholderData = usePlaceholderData();
const { locationSettings } = useLocationSettings();
const [tooltip, setTooltip] = useState<null | TooltipState>(null);
const aggregateHealthData = useMemo(() => {
const aggregateOrProjectData = useMemo(() => {
const labels: string[] = Array.from(
new Set(
creationArchiveData.datasets.flatMap((d) =>
creationVsArchivedChart.datasets.flatMap((d) =>
d.data.map((item) => item.week),
),
),
);
const allFlagTypes = new Set<string>();
creationVsArchivedChart.datasets.forEach((d) =>
d.data.forEach((item: any) => {
if (item.createdFlags) {
Object.keys(item.createdFlags).forEach((type) =>
allFlagTypes.add(type),
);
}
}),
);
const aggregateWeekData = (acc: WeekData, item: RawWeekData) => {
if (item) {
acc.archivedFlags += item.archivedFlags || 0;
if (item.createdFlags) {
Object.entries(item.createdFlags).forEach(
([type, count]) => {
acc.createdFlagsByType[type] =
(acc.createdFlagsByType[type] || 0) + count;
acc.totalCreatedFlags += count;
},
);
}
}
if (!acc.date) {
acc.date = item?.date;
}
return acc;
};
const createInitialWeekData = (label: string): WeekData => ({
archivedFlags: 0,
totalCreatedFlags: 0,
createdFlagsByType: {},
archivePercentage: 0,
week: label,
});
const weeks: WeekData[] = labels
.map((label) => {
return creationArchiveData.datasets
return creationVsArchivedChart.datasets
.map((d) => d.data.find((item) => item.week === label))
.reduce(
(acc: WeekData, item: RawWeekData) => {
if (item) {
acc.archivedFlags += item.archivedFlags || 0;
const createdFlagsSum = item.createdFlags
? Object.values(item.createdFlags).reduce(
(sum: number, count: number) =>
sum + count,
0,
)
: 0;
acc.totalCreatedFlags += createdFlagsSum;
}
if (!acc.date) {
acc.date = item?.date;
}
return acc;
},
{
archivedFlags: 0,
totalCreatedFlags: 0,
week: label,
} as WeekData,
);
.reduce(aggregateWeekData, createInitialWeekData(label));
})
.map((week) => ({
...week,
archivePercentage:
week.totalCreatedFlags > 0
? (week.archivedFlags / week.totalCreatedFlags) * 100
: 0,
}))
.sort((a, b) => (a.week > b.week ? 1 : -1));
const flagTypeColors = getFlagTypeColors(theme);
const flagTypeDatasets = Array.from(allFlagTypes).map(
(flagType, index) => ({
label: flagType,
data: weeks,
backgroundColor: flagTypeColors[index % flagTypeColors.length],
borderColor: flagTypeColors[index % flagTypeColors.length],
type: 'bar' as const,
parsing: {
yAxisKey: `createdFlagsByType.${flagType}`,
xAxisKey: 'date',
},
yAxisID: 'y',
stack: 'created',
order: 2,
}),
);
const flagTypeNames = Array.from(allFlagTypes);
return {
datasets: [
{
label: 'Number of created flags',
label: 'Archived flags',
data: weeks,
backgroundColor: theme.palette.background.application,
borderColor: theme.palette.background.application,
parsing: { yAxisKey: 'archivedFlags', xAxisKey: 'date' },
order: 2,
},
...flagTypeDatasets,
{
label: 'Flags archived / Flags created',
data: weeks,
borderColor: theme.palette.primary.light,
backgroundColor: fillGradientPrimary,
fill: true,
order: 3,
backgroundColor: theme.palette.primary.light,
type: 'line' as const,
parsing: {
yAxisKey: 'archivePercentage',
xAxisKey: 'date',
},
yAxisID: 'y1',
order: 1,
},
],
flagTypeNames,
};
}, [creationArchiveData, theme]);
}, [creationVsArchivedChart, theme]);
const aggregateOrProjectData = isAggregate
? aggregateHealthData
: creationArchiveData;
const notEnoughData = useMemo(
() =>
!isLoading &&
!creationArchiveData.datasets.some((d) => d.data.length > 1),
[creationArchiveData, isLoading],
!creationVsArchivedChart.datasets.some((d) => d.data.length > 1),
[creationVsArchivedChart, isLoading],
);
const data =
notEnoughData || isLoading ? placeholderData : aggregateOrProjectData;
const flagTypeNames = aggregateOrProjectData.flagTypeNames || [];
return (
<LineChart
key={isAggregate ? 'aggregate' : 'project'}
data={data}
overrideOptions={
notEnoughData
? {}
: {
parsing: {
yAxisKey: 'totalCreatedFlags',
xAxisKey: 'date',
},
}
}
cover={notEnoughData ? <NotEnoughData /> : isLoading}
/>
<>
<Chart
type='bar'
data={data}
options={{
responsive: true,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
filter: (legendItem) => {
return !flagTypeNames.includes(
legendItem.text || '',
);
},
generateLabels: (chart) => {
const original =
ChartJS.defaults.plugins.legend.labels.generateLabels(
chart,
);
const filtered = original.filter(
(item) =>
!flagTypeNames.includes(
item.text || '',
),
);
filtered.push({
text: 'Created Flags',
fillStyle: theme.palette.success.main,
strokeStyle: theme.palette.success.main,
lineWidth: 0,
hidden: false,
index: filtered.length,
datasetIndex: -1,
});
return filtered;
},
},
},
tooltip: {
enabled: false,
position: 'nearest',
external: createTooltip(setTooltip),
},
},
locale: locationSettings.locale,
scales: {
x: {
type: 'time',
time: {
unit: 'week',
tooltipFormat: 'PPP',
},
},
y: {
type: 'linear',
position: 'left',
beginAtZero: true,
title: {
display: true,
text: 'Number of flags',
},
},
y1: {
type: 'linear',
display: true,
position: 'right',
beginAtZero: true,
title: {
display: true,
text: 'Ratio',
},
grid: {
drawOnChartArea: false,
},
ticks: {
callback: (value) => `${value}%`,
},
},
},
}}
height={100}
width={250}
/>
{tooltip?.dataPoints?.some(
(point) =>
point.dataset.label !== 'Archived flags' &&
point.dataset.label !== 'Flags archived / Flags created',
) ? (
<CreationArchiveTooltip tooltip={tooltip} />
) : tooltip?.dataPoints?.some(
(point) =>
point.dataset.label === 'Flags archived / Flags created',
) ? (
<CreationArchiveRatioTooltip tooltip={tooltip} />
) : (
<ChartTooltip tooltip={tooltip} />
)}
</>
);
};

View File

@ -0,0 +1,100 @@
import type { FC } from 'react';
import { Box, Paper, Typography, styled, useTheme } from '@mui/material';
import type { TooltipState } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip';
import { ChartTooltipContainer } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip';
import type { Theme } from '@mui/material/styles/createTheme';
import type { WeekData } from './types.ts';
const getRatioTooltipColors = (theme: Theme) => ({
CREATED: theme.palette.success.main,
ARCHIVED: theme.palette.background.application,
});
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
padding: theme.spacing(2),
width: 200,
}));
const StyledFlagItem = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing(0.5),
}));
interface CreationArchiveRatioTooltipProps {
tooltip: TooltipState | null;
}
export const CreationArchiveRatioTooltip: FC<
CreationArchiveRatioTooltipProps
> = ({ tooltip }) => {
const theme = useTheme();
const colors = getRatioTooltipColors(theme);
if (!tooltip?.dataPoints) {
return null;
}
const ratioDataPoint = tooltip.dataPoints.find(
(point) => point.dataset.label === 'Flags archived / Flags created',
);
if (!ratioDataPoint) {
return null;
}
const rawData = ratioDataPoint.raw as WeekData;
if (!rawData) {
return null;
}
const archivedCount = rawData.archivedFlags || 0;
const createdCount = rawData.totalCreatedFlags || 0;
const ratio = Math.round(ratioDataPoint.parsed.y as number);
return (
<ChartTooltipContainer tooltip={tooltip}>
<StyledTooltipItemContainer elevation={3}>
<Typography
variant='body2'
component='div'
fontWeight='bold'
sx={{ marginBottom: 1 }}
>
Ratio {ratio}%
</Typography>
<StyledFlagItem>
<Typography variant='body2' component='span'>
<Typography
sx={{ color: colors.CREATED }}
component='span'
>
{'● '}
</Typography>
Flags created
</Typography>
<Typography variant='body2' component='span'>
{createdCount}
</Typography>
</StyledFlagItem>
<StyledFlagItem>
<Typography variant='body2' component='span'>
<Typography
sx={{ color: colors.ARCHIVED }}
component='span'
>
{'● '}
</Typography>
Flags archived
</Typography>
<Typography variant='body2' component='span'>
{archivedCount}
</Typography>
</StyledFlagItem>
</StyledTooltipItemContainer>
</ChartTooltipContainer>
);
};

View File

@ -0,0 +1,96 @@
import type { FC } from 'react';
import { Box, Paper, Typography, styled, useTheme } from '@mui/material';
import type { TooltipState } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip';
import { ChartTooltipContainer } from 'component/insights/components/LineChart/ChartTooltip/ChartTooltip';
import { getFlagTypeColors } from './flagTypeColors.ts';
import type { WeekData } from './types.ts';
const StyledTooltipItemContainer = styled(Paper)(({ theme }) => ({
padding: theme.spacing(2),
width: 240,
}));
const StyledFlagTypeItem = styled(Box)(({ theme }) => ({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}));
interface CreationArchiveTooltipProps {
tooltip: TooltipState | null;
}
export const CreationArchiveTooltip: FC<CreationArchiveTooltipProps> = ({
tooltip,
}) => {
const theme = useTheme();
if (!tooltip?.dataPoints) {
return null;
}
const createdFlagDataPoints = tooltip.dataPoints.filter(
(point) =>
point.dataset.label !== 'Archived flags' &&
point.dataset.label !== 'Flags archived / Flags created',
);
if (createdFlagDataPoints.length === 0) {
return null;
}
const rawData = createdFlagDataPoints[0]?.raw as WeekData;
if (!rawData?.createdFlagsByType) {
return null;
}
const flagTypeNames = createdFlagDataPoints.map(
(point) => point.dataset.label || '',
);
const flagTypeColors = getFlagTypeColors(theme);
const flagTypeEntries = Object.entries(rawData.createdFlagsByType)
.filter(([, count]) => (count as number) > 0)
.map(([flagType, count], index) => ({
type: flagType,
count: count as number,
color:
flagTypeColors[flagTypeNames.indexOf(flagType)] ||
flagTypeColors[index % flagTypeColors.length],
}));
if (flagTypeEntries.length === 0) {
return null;
}
return (
<ChartTooltipContainer tooltip={tooltip}>
<StyledTooltipItemContainer elevation={3}>
<Typography
variant='body2'
component='div'
fontWeight='bold'
sx={{ marginBottom: 1 }}
>
Flag type
</Typography>
{flagTypeEntries.map(({ type, count, color }) => (
<StyledFlagTypeItem key={type}>
<Typography variant='body2' component='span'>
<Typography sx={{ color }} component='span'>
{'● '}
</Typography>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Typography>
<Typography variant='body2' component='span'>
{count}
</Typography>
</StyledFlagTypeItem>
))}
</StyledTooltipItemContainer>
</ChartTooltipContainer>
);
};

View File

@ -0,0 +1,9 @@
import type { Theme } from '@mui/material';
export const getFlagTypeColors = (theme: Theme) => [
theme.palette.success.border,
theme.palette.success.main,
theme.palette.success.dark,
'#4D8007',
'#7D935E',
];

View File

@ -0,0 +1,15 @@
export type WeekData = {
archivedFlags: number;
totalCreatedFlags: number;
createdFlagsByType: Record<string, number>;
archivePercentage: number;
week: string;
date?: string;
};
export type RawWeekData = {
archivedFlags: number;
createdFlags: Record<string, number>;
week: string;
date: string;
};

View File

@ -0,0 +1,113 @@
import type { FC } from 'react';
import { Box, Typography, Link, styled } from '@mui/material';
import { HelpIcon } from 'component/common/HelpIcon/HelpIcon';
import InfoOutlined from '@mui/icons-material/InfoOutlined';
import Lightbulb from '@mui/icons-material/LightbulbOutlined';
import { StatsExplanation } from 'component/insights/InsightsCharts.styles';
import type { GroupedDataByProject } from 'component/insights/hooks/useGroupedProjectTrends';
import type { InstanceInsightsSchema } from 'openapi';
function getCurrentArchiveRatio(
groupedCreationArchiveData: GroupedDataByProject<
InstanceInsightsSchema['creationArchiveTrends']
>,
) {
if (
!groupedCreationArchiveData ||
Object.keys(groupedCreationArchiveData).length === 0
) {
return 0;
}
let totalArchived = 0;
let totalCreated = 0;
Object.values(groupedCreationArchiveData).forEach((projectData) => {
const latestData = projectData[projectData.length - 1];
if (latestData) {
totalArchived += latestData.archivedFlags || 0;
const createdSum = latestData.createdFlags
? Object.values(latestData.createdFlags).reduce(
(sum: number, count: number) => sum + count,
0,
)
: 0;
totalCreated += createdSum;
}
});
return totalCreated > 0
? Math.round((totalArchived / totalCreated) * 100)
: 0;
}
const StyledRatioContainer = styled(Box)(({ theme }) => ({
backgroundColor: theme.palette.background.elevation1,
borderRadius: theme.spacing(2),
padding: theme.spacing(2),
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(0.5),
}));
const StyledPercentageRow = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}));
const StyledRatioTypography = styled(Typography)(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.spacing(2.5),
fontWeight: 'bold',
}));
const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
color: theme.palette.text.secondary,
}));
const StyledLink = styled(Link)(({ theme }) => ({
color: theme.palette.primary.main,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}));
interface CreationArchiveStatsProps {
groupedCreationArchiveData: GroupedDataByProject<
InstanceInsightsSchema['creationArchiveTrends']
>;
isLoading?: boolean;
}
export const CreationArchiveStats: FC<CreationArchiveStatsProps> = ({
groupedCreationArchiveData,
isLoading,
}) => {
const currentRatio = getCurrentArchiveRatio(groupedCreationArchiveData);
return (
<>
<StyledRatioContainer>
<StyledPercentageRow>
<StyledRatioTypography>
{isLoading ? '...' : `${currentRatio}%`}
</StyledRatioTypography>
<HelpIcon tooltip='Ratio of archived flags to created flags'>
<StyledInfoIcon />
</HelpIcon>
</StyledPercentageRow>
<Typography variant='body2'>Current ratio</Typography>
</StyledRatioContainer>
<StatsExplanation>
<Lightbulb color='primary' />
Do you create more flags than you archive? Or do you have good
process for cleaning up?
</StatsExplanation>
<StyledLink href='/search?lifecycle=IS:completed' variant='body2'>
View flags in cleanup stage
</StyledLink>
</>
);
};

View File

@ -28,6 +28,7 @@ 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';
export const PerformanceInsights: FC = () => {
const statePrefix = 'performance-';
@ -116,11 +117,16 @@ export const PerformanceInsights: FC = () => {
<StyledWidget>
<StyledWidgetStats width={275}>
<WidgetTitle title='Flags created vs archived' />
<CreationArchiveStats
groupedCreationArchiveData={
groupedCreationArchiveData
}
isLoading={loading}
/>
</StyledWidgetStats>
<StyledChartContainer>
<CreationArchiveChart
creationArchiveTrends={groupedCreationArchiveData}
isAggregate={showAllProjects}
isLoading={loading}
/>
</StyledChartContainer>