diff --git a/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx b/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx index e9f924bec1b..427a9780dba 100644 --- a/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx +++ b/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx @@ -26,6 +26,10 @@ import { ProjectHealthChart } from './componentsChart/ProjectHealthChart/Project import { MetricsSummaryChart } from './componentsChart/MetricsSummaryChart/MetricsSummaryChart'; import { UsersPerProjectChart } from './componentsChart/UsersPerProjectChart/UsersPerProjectChart'; import { UpdatesPerEnvironmentTypeChart } from './componentsChart/UpdatesPerEnvironmentTypeChart/UpdatesPerEnvironmentTypeChart'; +import { TimeToProduction } from './componentsStat/TimeToProduction/TimeToProduction'; +import { TimeToProductionChart } from './componentsChart/TimeToProductionChart/TimeToProductionChart'; +import { useGroupedProjectTrends } from './hooks/useGroupedProjectTrends'; +import { useAvgTimeToProduction } from './hooks/useAvgTimeToProduction'; const StyledGrid = styled(Box)(({ theme }) => ({ display: 'grid', @@ -74,14 +78,21 @@ export const ExecutiveDashboard: VFC = () => { executiveDashboardData.projectFlagTrends, projects, ); + + const groupedProjectsData = useGroupedProjectTrends(projectsData); + const metricsData = useFilteredTrends( executiveDashboardData.metricsSummaryTrends, projects, ); + const groupedMetricsData = useGroupedProjectTrends(metricsData); const { users, environmentTypeTrends } = executiveDashboardData; const summary = useFilteredFlagsSummary(projectsData); + + const avgDaysToProduction = useAvgTimeToProduction(groupedProjectsData); + const isOneProjectSelected = projects.length === 1; const handleScroll = () => { @@ -147,7 +158,7 @@ export const ExecutiveDashboard: VFC = () => { elseShow={ } @@ -178,7 +189,7 @@ export const ExecutiveDashboard: VFC = () => { elseShow={ } @@ -200,25 +211,32 @@ export const ExecutiveDashboard: VFC = () => { } > - {/* - + + - - - */} + + + - + ; } export const FlagsProjectChart: VFC = ({ diff --git a/frontend/src/component/executiveDashboard/componentsChart/MetricsSummaryChart/MetricsSummaryChart.tsx b/frontend/src/component/executiveDashboard/componentsChart/MetricsSummaryChart/MetricsSummaryChart.tsx index 5eec3268645..0d887cf3bb3 100644 --- a/frontend/src/component/executiveDashboard/componentsChart/MetricsSummaryChart/MetricsSummaryChart.tsx +++ b/frontend/src/component/executiveDashboard/componentsChart/MetricsSummaryChart/MetricsSummaryChart.tsx @@ -5,9 +5,12 @@ import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart'; import { MetricsSummaryTooltip } from './MetricsChartTooltip/MetricsChartTooltip'; import { useMetricsSummary } from '../../hooks/useMetricsSummary'; import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData'; +import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends'; interface IMetricsSummaryChartProps { - metricsSummaryTrends: ExecutiveSummarySchema['metricsSummaryTrends']; + metricsSummaryTrends: GroupedDataByProject< + ExecutiveSummarySchema['metricsSummaryTrends'] + >; } export const MetricsSummaryChart: VFC = ({ @@ -15,7 +18,7 @@ export const MetricsSummaryChart: VFC = ({ }) => { const data = useMetricsSummary(metricsSummaryTrends); const notEnoughData = useMemo( - () => (data.datasets.some((d) => d.data.length > 1) ? false : true), + () => !data.datasets.some((d) => d.data.length > 1), [data], ); const placeholderData = usePlaceholderData(); diff --git a/frontend/src/component/executiveDashboard/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx b/frontend/src/component/executiveDashboard/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx index 52943a07c7d..3bd3485307a 100644 --- a/frontend/src/component/executiveDashboard/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx +++ b/frontend/src/component/executiveDashboard/componentsChart/ProjectHealthChart/ProjectHealthChart.tsx @@ -8,13 +8,16 @@ import { NotEnoughData, } from 'component/executiveDashboard/components/LineChart/LineChart'; import { useTheme } from '@mui/material'; +import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends'; -interface IFlagsProjectChartProps { - projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends']; +interface IProjectHealthChartProps { + projectFlagTrends: GroupedDataByProject< + ExecutiveSummarySchema['projectFlagTrends'] + >; isAggregate?: boolean; } -export const ProjectHealthChart: VFC = ({ +export const ProjectHealthChart: VFC = ({ projectFlagTrends, isAggregate, }) => { diff --git a/frontend/src/component/executiveDashboard/componentsChart/TimeToProductionChart/TimeToProductionChart.tsx b/frontend/src/component/executiveDashboard/componentsChart/TimeToProductionChart/TimeToProductionChart.tsx index 69ff7481701..c3b95b5d603 100644 --- a/frontend/src/component/executiveDashboard/componentsChart/TimeToProductionChart/TimeToProductionChart.tsx +++ b/frontend/src/component/executiveDashboard/componentsChart/TimeToProductionChart/TimeToProductionChart.tsx @@ -1,21 +1,33 @@ -import { type VFC } from 'react'; +import { useMemo, type VFC } from 'react'; import 'chartjs-adapter-date-fns'; import { ExecutiveSummarySchema } from 'openapi'; import { LineChart } from '../../components/LineChart/LineChart'; import { useProjectChartData } from '../../hooks/useProjectChartData'; +import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends'; +import { usePlaceholderData } from '../../hooks/usePlaceholderData'; +import { TimeToProductionTooltip } from './TimeToProductionTooltip/TimeToProductionTooltip'; -interface IFlagsProjectChartProps { - projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends']; +interface ITimeToProductionChartProps { + projectFlagTrends: GroupedDataByProject< + ExecutiveSummarySchema['projectFlagTrends'] + >; } -export const TimeToProductionChart: VFC = ({ +export const TimeToProductionChart: VFC = ({ projectFlagTrends, }) => { const data = useProjectChartData(projectFlagTrends); + const notEnoughData = useMemo( + () => !data.datasets.some((d) => d.data.length > 1), + [data], + ); + + const placeholderData = usePlaceholderData(); return ( ({ + padding: theme.spacing(2), +})); + +const StyledItemHeader = styled(Box)(({ theme }) => ({ + display: 'flex', + justifyContent: 'space-between', + gap: theme.spacing(2), + alignItems: 'center', +})); + +const getInterval = (days?: number) => { + if (!days) { + return 'N/A'; + } + + if (days > 11) { + const weeks = days / 7; + if (weeks > 6) { + const months = weeks / 4.34524; + return `${months.toFixed(2)} months`; + } else { + return `${weeks.toFixed(1)} weeks`; + } + } else { + return `${days} days`; + } +}; + +const resolveBadge = (input?: number) => { + const ONE_MONTH = 30; + const ONE_WEEK = 7; + + if (!input) { + return null; + } + + if (input >= ONE_MONTH) { + return Low; + } + + if (input <= ONE_MONTH && input >= ONE_WEEK + 1) { + return Medium; + } + + if (input <= ONE_WEEK) { + return High; + } +}; + +export const TimeToProductionTooltip: VFC<{ tooltip: TooltipState | null }> = ({ + tooltip, +}) => { + const data = tooltip?.dataPoints.map((point) => { + return { + label: point.label, + title: point.dataset.label, + color: point.dataset.borderColor, + value: point.raw as ExecutiveSummarySchemaProjectFlagTrendsItem, + }; + }); + + const limitedData = data?.slice(0, 5); + + return ( + ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + width: '300px', + })} + > + {limitedData?.map((point, index) => ( + + + + {point.label} + + + + + + {'● '} + + {point.title} + + theme.spacing(1), pt: 0.25 }} + > + {getInterval(point.value.timeToProduction)} + + {resolveBadge(point.value.timeToProduction)} + + + )) || null} + + ); +}; diff --git a/frontend/src/component/executiveDashboard/componentsChart/UsersPerProjectChart/UsersPerProjectChart.tsx b/frontend/src/component/executiveDashboard/componentsChart/UsersPerProjectChart/UsersPerProjectChart.tsx index ab6b46d2bae..e86301c73de 100644 --- a/frontend/src/component/executiveDashboard/componentsChart/UsersPerProjectChart/UsersPerProjectChart.tsx +++ b/frontend/src/component/executiveDashboard/componentsChart/UsersPerProjectChart/UsersPerProjectChart.tsx @@ -4,9 +4,12 @@ import { ExecutiveSummarySchema } from 'openapi'; import { LineChart, NotEnoughData } from '../../components/LineChart/LineChart'; import { useProjectChartData } from 'component/executiveDashboard/hooks/useProjectChartData'; import { usePlaceholderData } from 'component/executiveDashboard/hooks/usePlaceholderData'; +import { GroupedDataByProject } from '../../hooks/useGroupedProjectTrends'; interface IUsersPerProjectChartProps { - projectFlagTrends: ExecutiveSummarySchema['projectFlagTrends']; + projectFlagTrends: GroupedDataByProject< + ExecutiveSummarySchema['projectFlagTrends'] + >; } export const UsersPerProjectChart: VFC = ({ diff --git a/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.test.ts b/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.test.ts new file mode 100644 index 00000000000..744144b84ff --- /dev/null +++ b/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.test.ts @@ -0,0 +1,34 @@ +import { useAvgTimeToProduction } from './useAvgTimeToProduction'; +import { renderHook } from '@testing-library/react-hooks'; + +describe('useAvgTimeToProduction', () => { + test('returns 0 when projectsData is empty', () => { + const projectsData = {}; + const { result } = renderHook(() => + useAvgTimeToProduction(projectsData), + ); + expect(result.current).toBe(0); + }); + + test('calculates result.current time to production correctly', () => { + const projectsData = { + project1: [{ timeToProduction: 10 }, { timeToProduction: 20 }], + project2: [{ timeToProduction: 15 }, { timeToProduction: 25 }], + } as any; + const { result } = renderHook(() => + useAvgTimeToProduction(projectsData), + ); + expect(result.current).toBe(17.5); + }); + + test('ignores projects without time to production data', () => { + const projectsData = { + project1: [{ timeToProduction: 10 }, { timeToProduction: 20 }], + project2: [], + } as any; + const { result } = renderHook(() => + useAvgTimeToProduction(projectsData), + ); + expect(result.current).toBe(7.5); + }); +}); diff --git a/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.ts b/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.ts new file mode 100644 index 00000000000..b91f60d3068 --- /dev/null +++ b/frontend/src/component/executiveDashboard/hooks/useAvgTimeToProduction.ts @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import type { ExecutiveSummarySchema } from 'openapi'; +import type { GroupedDataByProject } from './useGroupedProjectTrends'; + +export const useAvgTimeToProduction = ( + projectsData: GroupedDataByProject< + ExecutiveSummarySchema['projectFlagTrends'] + >, +) => + useMemo(() => { + const totalProjects = Object.keys(projectsData).length; + + if (totalProjects === 0) { + return 0; + } + + const totalAvgTimeToProduction = Object.entries(projectsData).reduce( + (acc, [_, trends]) => { + const validTrends = trends.filter( + (trend) => trend.timeToProduction !== undefined, + ); + const avgTimeToProduction = + validTrends.reduce( + (sum, item) => sum + (item.timeToProduction || 0), + 0, + ) / (validTrends.length || 1); + + return acc + (validTrends.length > 0 ? avgTimeToProduction : 0); + }, + 0, + ); + + const overallAverage = totalAvgTimeToProduction / totalProjects; + + return overallAverage; + }, [projectsData]); diff --git a/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.test.ts b/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.test.ts new file mode 100644 index 00000000000..6f01262807f --- /dev/null +++ b/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.test.ts @@ -0,0 +1,62 @@ +import { useGroupedProjectTrends } from './useGroupedProjectTrends'; +import { renderHook } from '@testing-library/react-hooks'; + +describe('useGroupedProjectTrends', () => { + test('returns an empty object when input data is empty', () => { + const input: any[] = []; + const { result } = renderHook(() => useGroupedProjectTrends(input)); + expect(result.current).toEqual({}); + }); + + test('groups data by project correctly', () => { + const input = [ + { project: 'project1', data: 'data1' }, + { project: 'project2', data: 'data2' }, + { project: 'project1', data: 'data3' }, + ]; + const { result } = renderHook(() => useGroupedProjectTrends(input)); + expect(result.current).toEqual({ + project1: [ + { project: 'project1', data: 'data1' }, + { project: 'project1', data: 'data3' }, + ], + project2: [{ project: 'project2', data: 'data2' }], + }); + }); + + test('groups complex data by project correctly', () => { + const input = [ + { + project: 'project1', + data: { some: { complex: { type: 'data1' } } }, + }, + { + project: 'project2', + data: { some: { complex: { type: 'data2' } } }, + }, + { + project: 'project1', + data: { some: { complex: { type: 'data3' } } }, + }, + ]; + const { result } = renderHook(() => useGroupedProjectTrends(input)); + expect(result.current).toEqual({ + project1: [ + { + project: 'project1', + data: { some: { complex: { type: 'data1' } } }, + }, + { + project: 'project1', + data: { some: { complex: { type: 'data3' } } }, + }, + ], + project2: [ + { + project: 'project2', + data: { some: { complex: { type: 'data2' } } }, + }, + ], + }); + }); +}); diff --git a/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.ts b/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.ts new file mode 100644 index 00000000000..53fac7bea6e --- /dev/null +++ b/frontend/src/component/executiveDashboard/hooks/useGroupedProjectTrends.ts @@ -0,0 +1,35 @@ +import { useMemo } from 'react'; + +export type GroupedDataByProject = Record; + +export function groupDataByProject( + data: T[], +): GroupedDataByProject { + if (!data || data.length === 0 || !('project' in data[0])) { + return {}; + } + + const groupedData: GroupedDataByProject = {}; + + data.forEach((item) => { + const { project } = item; + if (!groupedData[project]) { + groupedData[project] = []; + } + groupedData[project].push(item); + }); + + return groupedData; +} + +export const useGroupedProjectTrends = < + T extends { + project: string; + }, +>( + input: T[], +) => + useMemo>( + () => groupDataByProject(input), + [JSON.stringify(input)], + ); diff --git a/frontend/src/component/executiveDashboard/hooks/useMetricsSummary.ts b/frontend/src/component/executiveDashboard/hooks/useMetricsSummary.ts index 0c08bd0a7fb..05e99732d99 100644 --- a/frontend/src/component/executiveDashboard/hooks/useMetricsSummary.ts +++ b/frontend/src/component/executiveDashboard/hooks/useMetricsSummary.ts @@ -1,43 +1,19 @@ import { useMemo } from 'react'; import { useTheme } from '@mui/material'; -import { - ExecutiveSummarySchema, - ExecutiveSummarySchemaMetricsSummaryTrendsItem, -} from 'openapi'; +import { ExecutiveSummarySchema } from 'openapi'; import { useProjectColor } from './useProjectColor'; +import { GroupedDataByProject } from './useGroupedProjectTrends'; type MetricsSummaryTrends = ExecutiveSummarySchema['metricsSummaryTrends']; -type GroupedDataByProject = Record< - string, - ExecutiveSummarySchemaMetricsSummaryTrendsItem[] ->; - -function groupDataByProject( - data: ExecutiveSummarySchemaMetricsSummaryTrendsItem[], -): GroupedDataByProject { - const groupedData: GroupedDataByProject = {}; - - data.forEach((item) => { - const { project } = item; - if (!groupedData[project]) { - groupedData[project] = []; - } - groupedData[project].push(item); - }); - - return groupedData; -} - export const useMetricsSummary = ( - metricsSummaryTrends: MetricsSummaryTrends, + metricsSummaryTrends: GroupedDataByProject, ) => { const theme = useTheme(); const getProjectColor = useProjectColor(); const data = useMemo(() => { - const groupedMetrics = groupDataByProject(metricsSummaryTrends); - const datasets = Object.entries(groupedMetrics).map( + const datasets = Object.entries(metricsSummaryTrends).map( ([project, trends]) => { const color = getProjectColor(project); return { diff --git a/frontend/src/component/executiveDashboard/hooks/useProjectChartData.ts b/frontend/src/component/executiveDashboard/hooks/useProjectChartData.ts index 94b2ffbc637..f156225ca61 100644 --- a/frontend/src/component/executiveDashboard/hooks/useProjectChartData.ts +++ b/frontend/src/component/executiveDashboard/hooks/useProjectChartData.ts @@ -1,29 +1,19 @@ import { useMemo } from 'react'; -import { - ExecutiveSummarySchema, - ExecutiveSummarySchemaProjectFlagTrendsItem, -} from '../../../openapi'; +import { ExecutiveSummarySchema } from 'openapi'; import { useProjectColor } from './useProjectColor'; import { useTheme } from '@mui/material'; +import { GroupedDataByProject } from './useGroupedProjectTrends'; type ProjectFlagTrends = ExecutiveSummarySchema['projectFlagTrends']; -export const useProjectChartData = (projectFlagTrends: ProjectFlagTrends) => { +export const useProjectChartData = ( + projectFlagTrends: GroupedDataByProject, +) => { const theme = useTheme(); const getProjectColor = useProjectColor(); const data = useMemo(() => { - const groupedFlagTrends = projectFlagTrends.reduce< - Record - >((groups, item) => { - if (!groups[item.project]) { - groups[item.project] = []; - } - groups[item.project].push(item); - return groups; - }, {}); - - const datasets = Object.entries(groupedFlagTrends).map( + const datasets = Object.entries(projectFlagTrends).map( ([project, trends]) => { const color = getProjectColor(project); return {