From 16df33b0787d39fd2eff1818a9de1951cfec102f Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Thu, 5 Jun 2025 08:35:14 +0200 Subject: [PATCH] feat: add lifecycle trend graphs (#10077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds lifecycle trend graphs to the insights page. The graphs are each placed within their own boxes. The boxes do not have any more information in them yet. Also, because the data returned from the API is still all zeroes, I've used mock data that matches the sketches. Finally, the chart configuration and how it's split into a LifecycleChart that lazy loads a LifecycleChartComponent is based on the LineChart and LineChartComponent that we use elsewhere on the insights page. Light mode: image We might want to tweak some colors in dark mode, but maybe not? 🤷🏼 ![image](https://github.com/user-attachments/assets/9647e6b8-d8ea-4eb5-b9fd-6f4a24692476) --- frontend/package.json | 1 + .../LifecycleChart/LifecycleChart.tsx | 5 + .../LifecycleChartComponent.tsx | 121 +++++++++++ .../insights/sections/LifecycleInsights.tsx | 200 +++++++++++++++++- frontend/yarn.lock | 10 + 5 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 frontend/src/component/insights/components/LifecycleChart/LifecycleChart.tsx create mode 100644 frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx diff --git a/frontend/package.json b/frontend/package.json index 3a947a8e92..78d23bdbd9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -159,6 +159,7 @@ }, "packageManager": "yarn@4.9.1", "dependencies": { + "chartjs-plugin-datalabels": "^2.2.0", "json-2-csv": "^5.5.5" } } diff --git a/frontend/src/component/insights/components/LifecycleChart/LifecycleChart.tsx b/frontend/src/component/insights/components/LifecycleChart/LifecycleChart.tsx new file mode 100644 index 0000000000..af6c026f2b --- /dev/null +++ b/frontend/src/component/insights/components/LifecycleChart/LifecycleChart.tsx @@ -0,0 +1,5 @@ +import { lazy } from 'react'; + +export const LifecycleChart = lazy( + () => import('./LifecycleChartComponent.tsx'), +); diff --git a/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx b/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx new file mode 100644 index 0000000000..9d79e298f9 --- /dev/null +++ b/frontend/src/component/insights/components/LifecycleChart/LifecycleChartComponent.tsx @@ -0,0 +1,121 @@ +import { type FC, useMemo } from 'react'; +import { + CategoryScale, + LinearScale, + PointElement, + Tooltip, + Legend, + TimeScale, + Chart, + Filler, + type ChartData, + type ChartOptions, + BarElement, +} from 'chart.js'; +import { Bar } from 'react-chartjs-2'; +import 'chartjs-adapter-date-fns'; +import { type Theme, useTheme } from '@mui/material'; +import { useLocationSettings } from 'hooks/useLocationSettings'; +import merge from 'deepmerge'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; + +export const createOptions = (theme: Theme): ChartOptions<'bar'> => { + const fontSize = 10; + return { + plugins: { + legend: { + position: 'right', + maxWidth: 150, + align: 'start', + labels: { + color: theme.palette.text.secondary, + usePointStyle: true, + padding: 21, + boxHeight: 8, + font: { + size: fontSize, + }, + }, + }, + datalabels: { + color: theme.palette.text.primary, + font: { + weight: 'bold', + size: fontSize, + }, + anchor: 'end', + align: 'top', + offset: -6, + }, + }, + aspectRatio: 2 / 1, + responsive: true, + color: theme.palette.text.secondary, + scales: { + y: { + beginAtZero: true, + grid: { + color: theme.palette.divider, + borderColor: theme.palette.divider, + drawBorder: false, + }, + ticks: { + stepSize: 1, + color: theme.palette.text.disabled, + font: { + size: fontSize, + }, + }, + }, + x: { + grid: { + display: false, + }, + ticks: { + color: theme.palette.text.primary, + font: { + size: fontSize, + }, + }, + }, + }, + } as const; +}; + +function mergeAll(objects: Partial[]): T { + return merge.all(objects.filter((i) => i)); +} + +const LifecycleChartComponent: FC<{ + data: ChartData<'bar', unknown>; + overrideOptions?: ChartOptions<'bar'>; +}> = ({ data, overrideOptions }) => { + const theme = useTheme(); + const { locationSettings } = useLocationSettings(); + + const options = useMemo( + () => mergeAll([createOptions(theme)]), + [theme, locationSettings, overrideOptions], + ); + + return ( + <> + + {/* todo: implement fallback for screen readers */} + + ); +}; + +Chart.register( + CategoryScale, + LinearScale, + PointElement, + BarElement, + TimeScale, + Tooltip, + Legend, + Filler, +); + +// for lazy-loading +export default LifecycleChartComponent; diff --git a/frontend/src/component/insights/sections/LifecycleInsights.tsx b/frontend/src/component/insights/sections/LifecycleInsights.tsx index 483ab2eed6..4527c62671 100644 --- a/frontend/src/component/insights/sections/LifecycleInsights.tsx +++ b/frontend/src/component/insights/sections/LifecycleInsights.tsx @@ -3,13 +3,73 @@ import type { FC } from 'react'; import { FilterItemParam } from 'utils/serializeQueryParams'; import { InsightsSection } from 'component/insights/sections/InsightsSection'; import { InsightsFilters } from 'component/insights/InsightsFilters'; +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'; + +type LifecycleTrend = { + totalFlags: number; + averageTimeInStageDays: number; + categories: { + experimental: { + flagsOlderThanWeek: number; + newFlagsThisWeek: number; + }; + release: { + flagsOlderThanWeek: number; + newFlagsThisWeek: number; + }; + permanent: { + flagsOlderThanWeek: number; + newFlagsThisWeek: number; + }; + }; +}; + +type LifecycleInsights = { + develop: LifecycleTrend; + production: LifecycleTrend; + cleanup: LifecycleTrend; +}; + +const useChartColors = () => { + const theme = useTheme(); + return { + olderThanWeek: theme.palette.primary.light, + newThisWeek: theme.palette.success.border, + }; +}; + +const ChartRow = styled('div')(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: theme.spacing(2), +})); + +const ChartContainer = styled('article')(({ theme }) => ({ + background: theme.palette.background.default, + borderRadius: theme.shape.borderRadiusLarge, + padding: theme.spacing(2), + minWidth: 0, +})); export const LifecycleInsights: FC = () => { const statePrefix = 'lifecycle-'; const stateConfig = { [`${statePrefix}project`]: FilterItemParam, }; - const [state, setState] = usePersistentTableState('insights', stateConfig); + const [state, setState] = usePersistentTableState( + 'insights-lifecycle', + stateConfig, + ); + + // todo: use data from the actual endpoint when we have something useful to return + const projects = state[`${statePrefix}project`]?.values ?? [allOption.id]; + const { insights, loading } = useInsights(); + + // @ts-expect-error (lifecycleMetrics): The schema hasn't been updated yet. + const { lifecycleTrends } = insights; return ( { filterNamePrefix={statePrefix} /> } + > + + {Object.entries(mockData).map(([stage, data]) => { + return ( + + + + ); + })} + + + ); +}; + +const Chart: React.FC<{ data: LifecycleTrend }> = ({ data }) => { + const chartColors = useChartColors(); + const oldData = [ + data.categories.experimental.flagsOlderThanWeek, + data.categories.release.flagsOlderThanWeek, + data.categories.permanent.flagsOlderThanWeek, + ]; + return ( + 1 week old', + data: oldData, + stack: '1', + backgroundColor: chartColors.olderThanWeek, + borderRadius: 4, + datalabels: { + labels: { + value: { + formatter: (value, context) => { + // todo (lifecycleMetrics): use a nice + // formatter here, so that 1,000,000 + // flags are instead formatted as 1M + if ( + context.chart.legend + ?.legendItems?.[1].hidden + ) { + return value; + } + return ''; + }, + }, + }, + }, + }, + { + label: 'New flags this week', + data: [ + data.categories.experimental.newFlagsThisWeek, + data.categories.release.newFlagsThisWeek, + data.categories.permanent.newFlagsThisWeek, + ], + stack: '1', + backgroundColor: chartColors.newThisWeek, + borderRadius: 4, + datalabels: { + labels: { + value: { + formatter: (value, context) => { + if ( + context.chart.legend + ?.legendItems?.[0].hidden + ) { + return value; + } + return ( + value + oldData[context.dataIndex] + ); + }, + }, + }, + }, + }, + ], + }} /> ); }; + +const mockData: LifecycleInsights = { + develop: { + totalFlags: 35, + averageTimeInStageDays: 28, + categories: { + experimental: { + flagsOlderThanWeek: 11, + newFlagsThisWeek: 4, + }, + release: { + flagsOlderThanWeek: 12, + newFlagsThisWeek: 1, + }, + permanent: { + flagsOlderThanWeek: 7, + newFlagsThisWeek: 0, + }, + }, + }, + production: { + totalFlags: 10, + averageTimeInStageDays: 14, + categories: { + experimental: { + flagsOlderThanWeek: 2, + newFlagsThisWeek: 3, + }, + release: { + flagsOlderThanWeek: 1, + newFlagsThisWeek: 1, + }, + permanent: { + flagsOlderThanWeek: 3, + newFlagsThisWeek: 0, + }, + }, + }, + cleanup: { + totalFlags: 5, + averageTimeInStageDays: 16, + categories: { + experimental: { + flagsOlderThanWeek: 0, + newFlagsThisWeek: 3, + }, + release: { + flagsOlderThanWeek: 0, + newFlagsThisWeek: 1, + }, + permanent: { + flagsOlderThanWeek: 1, + newFlagsThisWeek: 0, + }, + }, + }, +}; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fa44c2b99b..afea3bd880 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4106,6 +4106,15 @@ __metadata: languageName: node linkType: hard +"chartjs-plugin-datalabels@npm:^2.2.0": + version: 2.2.0 + resolution: "chartjs-plugin-datalabels@npm:2.2.0" + peerDependencies: + chart.js: ">=3.0.0" + checksum: 10c0/de4855a795e4eef34869a16db1a8a0f905b6dfed0258c733338f472625361eb56fb899214b18651c1c1064cd343a78285ba576576693a40ec51285a84f022ea0 + languageName: node + linkType: hard + "check-error@npm:^2.1.1": version: 2.1.1 resolution: "check-error@npm:2.1.1" @@ -10138,6 +10147,7 @@ __metadata: chart.js: "npm:3.9.1" chartjs-adapter-date-fns: "npm:3.0.0" chartjs-plugin-annotation: "npm:2.2.1" + chartjs-plugin-datalabels: "npm:^2.2.0" classnames: "npm:2.5.1" copy-to-clipboard: "npm:3.3.3" countries-and-timezones: "npm:^3.4.0"