diff --git a/frontend/src/component/common/PrettifyLargeNumber/PrettifyLargeNumber.tsx b/frontend/src/component/common/PrettifyLargeNumber/PrettifyLargeNumber.tsx index 5fff6cb610..69e44ed196 100644 --- a/frontend/src/component/common/PrettifyLargeNumber/PrettifyLargeNumber.tsx +++ b/frontend/src/component/common/PrettifyLargeNumber/PrettifyLargeNumber.tsx @@ -21,20 +21,22 @@ interface IPrettifyLargeNumberProps { precision?: number; } +export const prettifyLargeNumber = + (threshold: number = 1_000_000, precision: number = 2) => + (value: number) => { + if (value < threshold) { + return value.toLocaleString(); + } + return millify(value, { precision }); + }; + export const PrettifyLargeNumber: FC = ({ value, threshold = 1_000_000, precision = 2, }) => { - let prettyValue: string; - let showTooltip = false; - - if (value < threshold) { - prettyValue = value.toLocaleString(); - } else { - prettyValue = millify(value, { precision }); - showTooltip = true; - } + const prettyValue = prettifyLargeNumber(threshold, precision)(value); + const showTooltip = value > threshold; const valueSpan = ( {prettyValue} diff --git a/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx b/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx index fa5c194f0b..41d3e5be5e 100644 --- a/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx +++ b/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx @@ -48,8 +48,8 @@ export const createOptions = (theme: Theme): ChartOptions<'bar'> => { offset: -6, }, }, - aspectRatio: 2 / 1, responsive: true, + maintainAspectRatio: false, color: theme.palette.text.secondary, scales: { y: { diff --git a/frontend/src/component/insights/sections/LifecycleInsights.tsx b/frontend/src/component/insights/sections/LifecycleInsights.tsx index 6d1e28c335..f0a2fd2ccf 100644 --- a/frontend/src/component/insights/sections/LifecycleInsights.tsx +++ b/frontend/src/component/insights/sections/LifecycleInsights.tsx @@ -7,6 +7,12 @@ import { allOption } from 'component/common/ProjectSelect/ProjectSelect'; import { useInsights } from 'hooks/api/getters/useInsights/useInsights'; import { LifecycleChart } from '../components/LifecycleChart/LifecycleChart.tsx'; import { styled, useTheme } from '@mui/material'; +import { + prettifyLargeNumber, + PrettifyLargeNumber, +} from 'component/common/PrettifyLargeNumber/PrettifyLargeNumber.tsx'; +import { FeatureLifecycleStageIcon } from 'component/common/FeatureLifecycle/FeatureLifecycleStageIcon.tsx'; +import { normalizeDays } from './normalize-days.ts'; type LifecycleTrend = { totalFlags: number; @@ -53,13 +59,61 @@ const ChartRow = styled('div')(({ theme }) => ({ gap: theme.spacing(2), })); -const ChartContainer = styled('article')(({ theme }) => ({ +const LifecycleTile = styled('article')(({ theme }) => ({ background: theme.palette.background.default, borderRadius: theme.shape.borderRadiusLarge, - padding: theme.spacing(2), + padding: theme.spacing(3), minWidth: 0, })); +const lifecycleStageMap = { + develop: 'pre-live', + production: 'live', + cleanup: 'completed', +}; + +const TileHeader = styled('h3')(({ theme }) => ({ + margin: 0, + fontSize: theme.typography.body1.fontSize, + fontWeight: 'normal', + padding: theme.spacing(1), + marginBottom: theme.spacing(3), +})); + +const HeaderNumber = styled('span')(({ theme }) => ({ + display: 'flex', + flexFlow: 'row nowrap', + alignItems: 'center', + gap: theme.spacing(2), + fontSize: theme.typography.h1.fontSize, + fontWeight: 'bold', +})); + +const Stats = styled('dl')(({ theme }) => ({ + background: theme.palette.background.elevation1, + borderRadius: theme.shape.borderRadiusMedium, + fontSize: theme.typography.body2.fontSize, + '& dt::after': { + content: '":"', + }, + '& dd': { + margin: 0, + fontWeight: 'bold', + }, + paddingInline: theme.spacing(2), + paddingBlock: theme.spacing(1.5), + gap: theme.spacing(1.5), + margin: 0, + marginTop: theme.spacing(2), +})); + +const StatRow = styled('div')(({ theme }) => ({ + display: 'flex', + flexFlow: 'row wrap', + gap: theme.spacing(0.5), + fontSize: theme.typography.body2.fontSize, +})); + export const LifecycleInsights: FC = () => { const statePrefix = 'lifecycle-'; const stateConfig = { @@ -90,9 +144,47 @@ export const LifecycleInsights: FC = () => { {Object.entries(mockData).map(([stage, data]) => { return ( - - - + + + + + + Flags in {stage} stage + +
+ +
+ + +
Current median time spent in stage
+
+ {normalizeDays( + data.averageTimeInStageDays, + )} +
+
+ +
+ Historical median time spent in stage +
+
+ {normalizeDays( + data.averageTimeInStageDays, + )} +
+
+
+
); })}
@@ -100,6 +192,8 @@ export const LifecycleInsights: FC = () => { ); }; +const prettifyFlagCount = prettifyLargeNumber(1000, 2); + const Chart: React.FC<{ stage: string; data: LifecycleTrend }> = ({ stage, data, @@ -151,7 +245,7 @@ const Chart: React.FC<{ stage: string; data: LifecycleTrend }> = ({ context.chart.legend ?.legendItems?.[1].hidden ) { - return value; + return prettifyFlagCount(value); } return ''; }, @@ -177,10 +271,10 @@ const Chart: React.FC<{ stage: string; data: LifecycleTrend }> = ({ context.chart.legend ?.legendItems?.[0].hidden ) { - return value; + return prettifyFlagCount(value); } - return ( - value + oldData[context.dataIndex] + return prettifyFlagCount( + value + oldData[context.dataIndex], ); }, }, diff --git a/frontend/src/component/insights/sections/normalize-days.test.ts b/frontend/src/component/insights/sections/normalize-days.test.ts new file mode 100644 index 0000000000..912e0c2765 --- /dev/null +++ b/frontend/src/component/insights/sections/normalize-days.test.ts @@ -0,0 +1,37 @@ +import { normalizeDays } from './normalize-days.ts'; + +test('0 or less', () => { + const testCases = [0, -1]; + expect( + testCases.map(normalizeDays).every((text) => text === 'No data'), + ).toBeTruthy(); +}); + +test('less than one', () => { + const testCases = [0.1, 0.25, 0.5, 0.9, 0.9999999]; + expect( + testCases.map(normalizeDays).every((text) => text === '<1 day'), + ).toBeTruthy(); +}); + +test('rounds to one', () => { + const testCases = [1.1, 1.25, 1.33, 1.499999]; + expect( + testCases.map(normalizeDays).every((text) => text === '1 day'), + ).toBeTruthy(); +}); + +test('1.5 or more', () => { + const testCases = [1.5, 2.4]; + expect( + testCases.map(normalizeDays).every((text) => text === '2 days'), + ).toBeTruthy(); +}); + +test.each([ + [10_000, '10K'], + [100_000, '100K'], + [1_000_000, '1M'], +])('Big numbers: %s -> %s', (number, rendered) => { + expect(normalizeDays(number)).toBe(`${rendered} days`); +}); diff --git a/frontend/src/component/insights/sections/normalize-days.ts b/frontend/src/component/insights/sections/normalize-days.ts new file mode 100644 index 0000000000..b880ede392 --- /dev/null +++ b/frontend/src/component/insights/sections/normalize-days.ts @@ -0,0 +1,17 @@ +import { prettifyLargeNumber } from 'component/common/PrettifyLargeNumber/PrettifyLargeNumber'; + +const prettifyNumber = prettifyLargeNumber(1000, 2); + +export const normalizeDays = (days: number) => { + if (days <= 0) { + return 'No data'; + } + if (days < 1) { + return '<1 day'; + } + const rounded = Math.round(days); + if (rounded === 1) { + return '1 day'; + } + return `${prettifyNumber(rounded)} days`; +};