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"