From e1cfd8e050b22c7fa34775a10e8999e162bc4665 Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Fri, 6 Jun 2025 10:12:02 +0200 Subject: [PATCH] Feat(1-3801)/add more data to lifecycle tiles (#10092) This is the first pass at the full lifecycle tiles. It adds the tile header and current and historical median data. I have also added large number handling to all the number instances in the tile: in the header, the graph, and the median data. In doing so, I exposed the algorithm we use in the PrettifyLargeNumber component. Returning a react component isn't always a valid option (such as in the chart). This does mean that you don't get a tooltip when you use the function directly, but in things like the chart and the median measurement that makes sense to me. I've decided to return "No data" if the median days value is 0 or lower. There's no data for historical medians yet, so I'm using the same number for now. image --- .../PrettifyLargeNumber.tsx | 20 ++-- .../LifecycleChartComponent.tsx | 2 +- .../insights/sections/LifecycleInsights.tsx | 112 ++++++++++++++++-- .../insights/sections/normalize-days.test.ts | 37 ++++++ .../insights/sections/normalize-days.ts | 17 +++ 5 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 frontend/src/component/insights/sections/normalize-days.test.ts create mode 100644 frontend/src/component/insights/sections/normalize-days.ts 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`; +};