From 1d3aea47dc34605b4f8da881ff3adf7b8ea9db05 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Thu, 31 Jul 2025 10:54:37 +0300 Subject: [PATCH] feat: create flags created vs archived chart (#10429) --- .../CreationArchiveChart.tsx | 311 +++++++++++++----- .../CreationArchiveRatioTooltip.tsx | 100 ++++++ .../CreationArchiveTooltip.tsx | 96 ++++++ .../CreationArchiveChart/flagTypeColors.ts | 9 + .../CreationArchiveChart/types.ts | 15 + .../CreationArchiveStats.tsx | 113 +++++++ .../insights/sections/PerformanceInsights.tsx | 8 +- 7 files changed, 576 insertions(+), 76 deletions(-) create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveTooltip.tsx create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/flagTypeColors.ts create mode 100644 frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts create mode 100644 frontend/src/component/insights/componentsStat/CreationArchiveStats/CreationArchiveStats.tsx diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx index 965f48f43d..389395f8ee 100644 --- a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveChart.tsx @@ -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; - week: string; - date: string; -}; - export const CreationArchiveChart: FC = ({ 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); - 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(); + 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 ( - : isLoading} - /> + <> + { + 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', + ) ? ( + + ) : tooltip?.dataPoints?.some( + (point) => + point.dataset.label === 'Flags archived / Flags created', + ) ? ( + + ) : ( + + )} + ); }; diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx new file mode 100644 index 0000000000..0edc04c797 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveRatioTooltip.tsx @@ -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 ( + + + + Ratio {ratio}% + + + + + + {'● '} + + Flags created + + + {createdCount} + + + + + + + {'● '} + + Flags archived + + + {archivedCount} + + + + + ); +}; diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveTooltip.tsx b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveTooltip.tsx new file mode 100644 index 0000000000..2fc274f68c --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/CreationArchiveTooltip.tsx @@ -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 = ({ + 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 ( + + + + Flag type + + + {flagTypeEntries.map(({ type, count, color }) => ( + + + + {'● '} + + {type.charAt(0).toUpperCase() + type.slice(1)} + + + {count} + + + ))} + + + ); +}; diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/flagTypeColors.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/flagTypeColors.ts new file mode 100644 index 0000000000..5455478703 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/flagTypeColors.ts @@ -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', +]; diff --git a/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts b/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts new file mode 100644 index 0000000000..0276e01ca7 --- /dev/null +++ b/frontend/src/component/insights/componentsChart/CreationArchiveChart/types.ts @@ -0,0 +1,15 @@ +export type WeekData = { + archivedFlags: number; + totalCreatedFlags: number; + createdFlagsByType: Record; + archivePercentage: number; + week: string; + date?: string; +}; + +export type RawWeekData = { + archivedFlags: number; + createdFlags: Record; + week: string; + date: string; +}; diff --git a/frontend/src/component/insights/componentsStat/CreationArchiveStats/CreationArchiveStats.tsx b/frontend/src/component/insights/componentsStat/CreationArchiveStats/CreationArchiveStats.tsx new file mode 100644 index 0000000000..895ff7665c --- /dev/null +++ b/frontend/src/component/insights/componentsStat/CreationArchiveStats/CreationArchiveStats.tsx @@ -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 = ({ + groupedCreationArchiveData, + isLoading, +}) => { + const currentRatio = getCurrentArchiveRatio(groupedCreationArchiveData); + + return ( + <> + + + + {isLoading ? '...' : `${currentRatio}%`} + + + + + + Current ratio + + + + Do you create more flags than you archive? Or do you have good + process for cleaning up? + + + View flags in cleanup stage + + + ); +}; diff --git a/frontend/src/component/insights/sections/PerformanceInsights.tsx b/frontend/src/component/insights/sections/PerformanceInsights.tsx index 3b5e08241e..efc4d4895b 100644 --- a/frontend/src/component/insights/sections/PerformanceInsights.tsx +++ b/frontend/src/component/insights/sections/PerformanceInsights.tsx @@ -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 = () => { +