1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-07 01:16:28 +02:00

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.

<img width="1538" alt="image"
src="https://github.com/user-attachments/assets/72e6a90a-6b84-47ce-af02-59596a7ff91f"
/>
This commit is contained in:
Thomas Heartman 2025-06-06 10:12:02 +02:00 committed by GitHub
parent f1c2706db7
commit e1cfd8e050
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 169 additions and 19 deletions

View File

@ -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<IPrettifyLargeNumberProps> = ({
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 = (
<span data-testid={LARGE_NUMBER_PRETTIFIED}>{prettyValue}</span>

View File

@ -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: {

View File

@ -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 = () => {
<ChartRow>
{Object.entries(mockData).map(([stage, data]) => {
return (
<ChartContainer key={stage}>
<Chart data={data} stage={stage} />
</ChartContainer>
<LifecycleTile key={stage}>
<TileHeader>
<HeaderNumber>
<PrettifyLargeNumber
value={data.totalFlags ?? 0}
threshold={1000}
precision={1}
/>
<FeatureLifecycleStageIcon
aria-hidden='true'
stage={{
name: lifecycleStageMap[stage],
}}
/>
</HeaderNumber>
<span>Flags in {stage} stage</span>
</TileHeader>
<div>
<Chart data={data} stage={stage} />
</div>
<Stats>
<StatRow>
<dt>Current median time spent in stage</dt>
<dd data-loading-project-lifecycle-summary>
{normalizeDays(
data.averageTimeInStageDays,
)}
</dd>
</StatRow>
<StatRow>
<dt>
Historical median time spent in stage
</dt>
<dd data-loading-project-lifecycle-summary>
{normalizeDays(
data.averageTimeInStageDays,
)}
</dd>
</StatRow>
</Stats>
</LifecycleTile>
);
})}
</ChartRow>
@ -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],
);
},
},

View File

@ -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`);
});

View File

@ -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`;
};