diff --git a/src/components/chart/SeriesPercentageChart/index.tsx b/src/components/chart/SeriesPercentageChart/index.tsx new file mode 100644 index 0000000..b243c0c --- /dev/null +++ b/src/components/chart/SeriesPercentageChart/index.tsx @@ -0,0 +1,179 @@ +import React, { type ReactElement, useMemo } from 'react'; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; +import Decimal from 'decimal.js'; +import { Box, type SxProps } from '@mui/material'; + +import { formatDateMMYY, formatExtendedDate } from '../../../utils/date'; + +interface KeyValue { + key: string; + name: string; + color: string; +} + +interface ChartDataPoint { + date: number; + [key: string]: number; // Allow dynamic key access +} + +interface SeriesChartData { + uuid: string; + chartData: ChartDataPoint[]; +} + +interface SeriesPercentageChartProps { + data: SeriesChartData[]; + filter: { timezone: string }; + keyValues: KeyValue[]; + sx?: SxProps; +} + +interface FormattedChartData { + date: string; + total: number; + oneClickSuccess: number; + oneClickCreated: number; + oneClickCancelled: number; + [key: string]: string | number; // Allow dynamic key access +} + +const formatChartData = ( + data: SeriesChartData[], + keyValues: KeyValue[], +): FormattedChartData[] => { + // Create a map to store all unique dates + const dateMap = new Map(); + + // Collect all unique dates from all series + data.forEach((series) => { + series.chartData.forEach((point) => { + if (!dateMap.has(point.date)) { + const entry: FormattedChartData = { + date: point.date.toString(), + total: 0, + oneClickSuccess: 0, + oneClickCreated: 0, + oneClickCancelled: 0, + }; + // Initialize all keyValues with 0 + keyValues.forEach(({ key }) => { + entry[key] = 0; + }); + dateMap.set(point.date, entry); + } + + // Add the values for each key + keyValues.forEach(({ key }) => { + const currentEntry = dateMap.get( + point.date, + ) as unknown as FormattedChartData; + currentEntry[key] = point[key] || 0; + }); + }); + }); + + // Convert the map to array and add totals + return Array.from(dateMap.values()) + .map((entry) => { + const total = keyValues.reduce( + (sum, { key }) => sum + ((entry[key] as number) || 0), + 0, + ); + return { + ...entry, + total, + }; + }) + .sort((a, b) => parseInt(a.date) - parseInt(b.date)); +}; + +export function SeriesPercentageChart( + props: SeriesPercentageChartProps, +): ReactElement { + const formattedData = useMemo(() => { + if (!props.data) return []; + return formatChartData(props.data, props.keyValues); + }, [props.data, props.keyValues]); + + return ( + + + + + + formatDateMMYY(value, { + timeZone: props.filter.timezone, + hour12: false, + hour: 'numeric', + }) + } + allowDuplicatedCategory={false} + tickLine={false} + fontSize={12} + tickMargin={12} + /> + `${(value * 100).toFixed(2)}%`} + /> + { + const total = item.payload.total; + const percentage = + total === 0 + ? '0.00' + : new Decimal(value.toString()) + .div(total) + .mul(100) + .toFixed(2, Decimal.ROUND_DOWN); + + return [`${value.toString()} (${percentage}%)`, name]; + }} + labelFormatter={(value) => + formatExtendedDate(value, { + timeZone: props.filter.timezone, + hour12: false, + }) + } + /> + {props.keyValues.map((keyValue) => ( + + ))} + + + + ); +} diff --git a/src/components/chart/index.ts b/src/components/chart/index.ts index 0713be9..217a254 100644 --- a/src/components/chart/index.ts +++ b/src/components/chart/index.ts @@ -1,2 +1,3 @@ export * from './SeriesChart'; export * from './SeriesChartLegend'; +export * from './SeriesPercentageChart'; diff --git a/src/stories/components/chart/SeriesPercentageChart.stories.tsx b/src/stories/components/chart/SeriesPercentageChart.stories.tsx new file mode 100644 index 0000000..d08459b --- /dev/null +++ b/src/stories/components/chart/SeriesPercentageChart.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { SeriesPercentageChart } from '../../../components/chart/SeriesPercentageChart'; + +const meta = { + title: 'components/chart/SeriesPercentageChart', + component: SeriesPercentageChart, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const generateRandomData = (days = 30) => { + const data: any = []; + const now = new Date().getTime(); // Using provided current time + + for (let i = 0; i < days; i++) { + // Generate timestamp for each day, starting from the most recent + const date = now - i * 24 * 60 * 60 * 1000; + + // Generate random values between 0-5 for each series + const oneClickCreated = Math.floor(Math.random() * 6); + const oneClickSuccess = Math.floor(Math.random() * (oneClickCreated + 1)); // Success should be <= Created + const oneClickCancelled = Math.floor( + Math.random() * (oneClickCreated - oneClickSuccess + 1), + ); // Cancelled should be <= (Created - Success) + + data.push({ + date, + oneClickCreated, + oneClickSuccess, + oneClickCancelled, + }); + } + + // Sort by date ascending + return data.sort((a, b) => a.date - b.date); +}; + +const sampleData = [ + { + uuid: 'b07cbc37-fe8f-4920-a6b9-c4e9dfe193cd', + chartData: generateRandomData(30), + }, +]; + +const keyValues = [ + { key: 'oneClickSuccess', name: 'Success', color: '#0DBC3D' }, + { key: 'oneClickCreated', name: 'Created', color: '#fbbc05' }, + { key: 'oneClickCancelled', name: 'Cancelled', color: '#808080' }, +]; + +export const Default: Story = { + args: { + data: sampleData, + filter: { + timezone: 'UTC', + }, + keyValues: keyValues, + sx: { + width: 800, + height: 400, + }, + }, +}; + +export const Empty: Story = { + args: { + data: [], + filter: { + timezone: 'UTC', + }, + keyValues: keyValues, + sx: { + width: 800, + height: 400, + }, + }, +}; diff --git a/vite.config.mts b/vite.config.mts index 5416c30..63bebc5 100644 --- a/vite.config.mts +++ b/vite.config.mts @@ -23,6 +23,9 @@ export default defineConfig({ 'src/components/animation/index.ts', ), 'components/chart': resolvePath('src/components/chart/index.ts'), + 'components/typographies': resolvePath( + 'src/components/typographies/index.ts', + ), hooks: resolvePath('src/hooks/index.ts'), styles: resolvePath('src/styles/index.ts'), validations: resolvePath('src/validations/index.ts'),