diff --git a/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx b/frontend/src/component/executiveDashboard/ExecutiveDashboard.tsx
index e9f924bec1..427a9780db 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 5eec326864..0d887cf3bb 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 52943a07c7..3bd3485307 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 69ff748170..c3b95b5d60 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 ab6b46d2ba..e86301c73d 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 0000000000..744144b84f
--- /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 0000000000..b91f60d306
--- /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 0000000000..6f01262807
--- /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 0000000000..53fac7bea6
--- /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 0c08bd0a7f..05e99732d9 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 94b2ffbc63..f156225ca6 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 {