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:
parent
f1c2706db7
commit
e1cfd8e050
@ -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>
|
||||
|
@ -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: {
|
||||
|
@ -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],
|
||||
);
|
||||
},
|
||||
},
|
||||
|
@ -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`);
|
||||
});
|
17
frontend/src/component/insights/sections/normalize-days.ts
Normal file
17
frontend/src/component/insights/sections/normalize-days.ts
Normal 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`;
|
||||
};
|
Loading…
Reference in New Issue
Block a user