first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import {BaseChart, BaseChartProps} from './base-chart';
import {ChartData, ChartOptions} from 'chart.js';
import {ChartColors} from './chart-colors';
import {useSelectedLocale} from '../i18n/selected-locale';
import {FormattedDatasetItem} from './data/formatted-dataset-item';
import {useMemo} from 'react';
import {formatReportData} from './data/format-report-data';
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
import clsx from 'clsx';
interface BarChartProps extends Omit<BaseChartProps<'bar'>, 'type' | 'data'> {
direction?: 'horizontal' | 'vertical';
individualBarColors?: boolean;
data?: ReportMetric<DatasetItem>;
}
export function BarChart({
data,
direction = 'vertical',
individualBarColors = false,
className,
...props
}: BarChartProps) {
const {localeCode} = useSelectedLocale();
const formattedData: ChartData<'bar', FormattedDatasetItem[]> =
useMemo(() => {
const formattedData = formatReportData(data, {localeCode});
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
...dataset,
backgroundColor: individualBarColors
? ChartColors.map(c => c[1])
: ChartColors[i][1],
borderColor: individualBarColors
? ChartColors.map(c => c[0])
: ChartColors[i][0],
borderWidth: 2,
}));
return formattedData;
}, [data, localeCode, individualBarColors]);
const isHorizontal = direction === 'horizontal';
const options: ChartOptions<'bar'> = useMemo(() => {
return {
indexAxis: isHorizontal ? 'y' : 'x',
parsing: {
xAxisKey: isHorizontal ? 'value' : 'label',
yAxisKey: isHorizontal ? 'label' : 'value',
},
};
}, [isHorizontal]);
return (
<BaseChart
type="bar"
className={clsx(className, 'min-w-500')}
data={formattedData}
options={options}
{...props}
/>
);
}

View File

@@ -0,0 +1,33 @@
import type {ChartData, ChartOptions, ChartType} from 'chart.js';
import {lazy, Suspense} from 'react';
import {ChartLayout, ChartLayoutProps} from './chart-layout';
import {ChartLoadingIndicator} from '@common/charts/chart-loading-indicator';
const LazyChart = lazy(() => import('./lazy-chart'));
export interface BaseChartProps<Type extends ChartType = ChartType>
extends Omit<ChartLayoutProps, 'children'> {
type: Type;
data: ChartData<Type, unknown>;
options?: ChartOptions<Type>;
hideLegend?: boolean;
}
export function BaseChart<Type extends ChartType = ChartType>(
props: BaseChartProps<Type>
) {
const {title, description, className, contentRef, isLoading} = props;
return (
<ChartLayout
title={title}
description={description}
className={className}
contentRef={contentRef}
>
<Suspense fallback={<ChartLoadingIndicator />}>
<LazyChart {...props} />
{isLoading && <ChartLoadingIndicator />}
</Suspense>
</ChartLayout>
);
}

View File

@@ -0,0 +1,14 @@
import {getBootstrapData} from '@common/core/bootstrap-data/use-backend-bootstrap-data';
const primaryColor = getBootstrapData().themes.all[0].values['--be-primary'];
export const ChartColors = [
[
`rgb(${primaryColor.replaceAll(' ', ',')})`,
`rgba(${primaryColor.replaceAll(' ', ',')},0.2)`,
],
['rgb(255,112,67)', 'rgb(255,112,67,0.2)'],
['rgb(255,167,38)', 'rgb(255,167,38,0.2)'],
['rgb(141,110,99)', 'rgb(141,110,99,0.2)'],
['rgb(102,187,106)', 'rgba(102,187,106,0.2)'],
['rgb(92,107,192)', 'rgb(92,107,192,0.2)'],
];

View File

@@ -0,0 +1,51 @@
import {ReactNode, Ref} from 'react';
import clsx from 'clsx';
export interface ChartLayoutProps {
title: ReactNode;
description?: ReactNode;
className?: string;
children: ReactNode;
contentIsFlex?: boolean;
contentClassName?: string;
minHeight?: string;
contentRef?: Ref<HTMLDivElement>;
isLoading?: boolean;
}
export function ChartLayout(props: ChartLayoutProps) {
const {
title,
description,
children,
className,
contentIsFlex = true,
contentClassName,
contentRef,
minHeight = 'min-h-440',
} = props;
return (
<div
className={clsx(
'rounded-panel flex h-full flex-auto flex-col border bg',
minHeight,
className,
)}
>
<div className="flex flex-shrink-0 items-center justify-between p-14 text-xs">
<div className="text-sm font-semibold">{title}</div>
{description && <div className="text-muted">{description}</div>}
</div>
<div
ref={contentRef}
className={clsx(
'relative p-14',
contentIsFlex && 'flex flex-auto items-center justify-center',
contentClassName,
)}
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import {ProgressCircle} from '@common/ui/progress/progress-circle';
import {Trans} from '@common/i18n/trans';
export function ChartLoadingIndicator() {
return (
<div className="flex items-center gap-10 text-sm absolute mx-auto">
<ProgressCircle isIndeterminate size="sm" />
<Trans message="Chart loading" />
</div>
);
}

View File

@@ -0,0 +1,165 @@
import {DateFormatter, parseAbsoluteToLocal} from '@internationalized/date';
import memoize from 'nano-memoize';
import {ChartType} from 'chart.js';
import {
FormattedDatasetItem,
FormattedReportData,
} from './formatted-dataset-item';
import {
DatasetItem,
RangedDatasetGranularity,
ReportMetric,
} from '../../admin/analytics/report-metric';
import {shallowEqual} from '../../utils/shallow-equal';
interface Options {
localeCode: string;
shareFirstDatasetLabels?: boolean;
}
type FormattedDatasetLabels = Omit<FormattedDatasetItem, 'value'>;
export function formatReportData(
report: ReportMetric<DatasetItem> | undefined,
{localeCode = 'en', shareFirstDatasetLabels = true}: Options,
): FormattedReportData {
if (!report) return {datasets: []};
const firstDatasetLabels: FormattedDatasetLabels[] = [];
return {
...report,
datasets: report.datasets.map((dataset, datasetIndex) => {
const data = dataset.data.map((datasetItem, itemIndex) => {
let label: FormattedDatasetLabels;
// when there are multiple datasets, we'll need to use labels from the first dataset, so charts are
// overlapped over one another, otherwise they will be side by side, if labels in all datasets are not identical.
if (datasetIndex === 0 || !shareFirstDatasetLabels) {
label = generateDatasetLabels(
datasetItem,
report.granularity,
localeCode,
);
firstDatasetLabels[itemIndex] = label;
} else {
label = firstDatasetLabels[itemIndex];
}
return {
...label,
value: datasetItem.value,
};
});
return {...dataset, data};
}),
};
}
function generateDatasetLabels<T extends ChartType = ChartType>(
datum: DatasetItem,
granularity: RangedDatasetGranularity | undefined,
locale: string,
): FormattedDatasetLabels {
if (datum.label) {
return {label: datum.label};
}
if (!datum.date) {
return {label: ''};
}
return generateTimeLabels(datum, granularity, locale);
}
function generateTimeLabels(
{date: isoDate, endDate: isoEndDate}: DatasetItem,
granularity: RangedDatasetGranularity | undefined = 'day',
locale: string,
): Omit<FormattedDatasetItem, 'value'> {
const date = parseAbsoluteToLocal(isoDate!).toDate();
const endDate = isoEndDate ? parseAbsoluteToLocal(isoEndDate).toDate() : null;
switch (granularity) {
case 'minute':
return {
label: getFormatter(locale, {
second: '2-digit',
}).format(date),
tooltipTitle: getFormatter(locale, {
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
second: '2-digit',
}).format(date),
};
case 'hour':
return {
label: getFormatter(locale, {
hour: 'numeric',
minute: 'numeric',
}).format(date),
tooltipTitle: getFormatter(locale, {
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
}).format(date),
};
case 'day':
return {
label: getFormatter(locale, {
day: '2-digit',
weekday: 'short',
}).format(date),
tooltipTitle: getFormatter(locale, {
day: '2-digit',
weekday: 'short',
month: 'short',
}).format(date),
};
case 'week':
return {
label: getFormatter(locale, {
month: 'short',
day: '2-digit',
}).format(date),
tooltipTitle: getFormatter(locale, {
day: '2-digit',
month: 'long',
year: 'numeric',
}).formatRange(date, endDate as Date),
};
case 'month':
return {
label: getFormatter(locale, {
month: 'short',
year: 'numeric',
}).format(date),
tooltipTitle: getFormatter(locale, {
month: 'long',
year: 'numeric',
}).format(date),
};
case 'year':
return {
label: getFormatter(locale, {
year: 'numeric',
}).format(date),
tooltipTitle: getFormatter(locale, {
year: 'numeric',
}).format(date),
};
}
}
const getFormatter = memoize(
(locale, options: Intl.DateTimeFormatOptions) => {
return new DateFormatter(locale, options);
},
{
equals: (a, b) => {
return shallowEqual(a, b);
},
callTimeout: undefined as any,
},
);

View File

@@ -0,0 +1,11 @@
import {ReportMetric} from '../../admin/analytics/report-metric';
export interface FormattedReportData extends Omit<ReportMetric, 'datasets'> {
datasets: {label: string; data: FormattedDatasetItem[]}[];
}
export interface FormattedDatasetItem {
label: string;
value: number;
tooltipTitle?: string;
}

View File

@@ -0,0 +1,91 @@
import {
ArcElement,
BarController,
BarElement,
CategoryScale,
Chart,
ChartOptions,
ChartType,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PointElement,
PolarAreaController,
RadialLinearScale,
Tooltip,
} from 'chart.js';
import {useEffect, useRef} from 'react';
import {BaseChartProps} from './base-chart';
import {FormattedDatasetItem} from './data/formatted-dataset-item';
import deepMerge from 'deepmerge';
Chart.register([
LineElement,
PointElement,
BarElement,
ArcElement,
LineController,
BarController,
PolarAreaController,
RadialLinearScale,
CategoryScale,
LinearScale,
Tooltip,
Filler,
Legend,
]);
export default function LazyChart({
type,
data,
options,
hideLegend,
}: Omit<BaseChartProps<any>, 'children'>) {
const ref = useRef<HTMLCanvasElement>(null);
const chartRef = useRef<Chart<ChartType, unknown>>();
useEffect(() => {
if (ref.current) {
chartRef.current = new Chart(ref.current, {
type,
data,
options: deepMerge(
{
maintainAspectRatio: false,
animation: {
duration: 250,
},
plugins: {
legend: {
position: 'bottom',
display: !hideLegend,
},
tooltip: {
padding: 16,
cornerRadius: 4,
callbacks: {
title: ([item]) => {
const data = item.raw as FormattedDatasetItem;
return data.tooltipTitle ?? item.label;
},
label: item => {
return ` ${item.dataset.label}: ${item.formattedValue}`;
},
},
},
},
},
options as ChartOptions
),
});
}
return () => {
chartRef.current?.destroy();
};
}, [data, type, options, hideLegend]);
return <canvas ref={ref}></canvas>;
}

View File

@@ -0,0 +1,58 @@
import {BaseChart, BaseChartProps} from './base-chart';
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
import {useMemo} from 'react';
import {formatReportData} from './data/format-report-data';
import {useSelectedLocale} from '../i18n/selected-locale';
import {ChartData, ChartOptions} from 'chart.js';
import {ChartColors} from './chart-colors';
import {FormattedDatasetItem} from './data/formatted-dataset-item';
import clsx from 'clsx';
const LineChartOptions: ChartOptions<'line'> = {
parsing: {
xAxisKey: 'label',
yAxisKey: 'value',
},
datasets: {
line: {
fill: 'origin',
tension: 0.1,
pointBorderWidth: 4,
pointHitRadius: 10,
},
},
plugins: {
tooltip: {
intersect: false,
mode: 'index',
},
},
};
interface LineChartProps extends Omit<BaseChartProps<'line'>, 'type' | 'data'> {
data?: ReportMetric<DatasetItem>;
}
export function LineChart({data, className, ...props}: LineChartProps) {
const {localeCode} = useSelectedLocale();
const formattedData: ChartData<'line', FormattedDatasetItem[]> =
useMemo(() => {
const formattedData = formatReportData(data, {localeCode});
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
...dataset,
backgroundColor: ChartColors[i][1],
borderColor: ChartColors[i][0],
pointBackgroundColor: ChartColors[i][0],
}));
return formattedData;
}, [data, localeCode]);
return (
<BaseChart
{...props}
className={clsx(className, 'min-w-500')}
data={formattedData}
type="line"
options={LineChartOptions}
/>
);
}

View File

@@ -0,0 +1,54 @@
import {BaseChart, BaseChartProps} from './base-chart';
import {ChartData, ChartOptions} from 'chart.js';
import {ChartColors} from './chart-colors';
import {useSelectedLocale} from '../i18n/selected-locale';
import {useMemo} from 'react';
import {formatReportData} from './data/format-report-data';
import {DatasetItem, ReportMetric} from '../admin/analytics/report-metric';
import {FormattedDatasetItem} from './data/formatted-dataset-item';
import clsx from 'clsx';
const PolarAreaChartOptions: ChartOptions<'polarArea'> = {
parsing: {
key: 'value',
},
plugins: {
tooltip: {
intersect: true,
},
},
};
interface PolarAreaChartProps
extends Omit<BaseChartProps<'polarArea'>, 'type' | 'data'> {
data?: ReportMetric<DatasetItem>;
}
export function PolarAreaChart({
data,
className,
...props
}: PolarAreaChartProps) {
const {localeCode} = useSelectedLocale();
const formattedData: ChartData<'polarArea', FormattedDatasetItem[]> =
useMemo(() => {
const formattedData = formatReportData(data, {localeCode});
formattedData.labels = formattedData.datasets[0]?.data.map(d => d.label);
formattedData.datasets = formattedData.datasets.map((dataset, i) => ({
...dataset,
backgroundColor: ChartColors.map(c => c[1]),
borderColor: ChartColors.map(c => c[0]),
borderWidth: 2,
}));
return formattedData;
}, [data, localeCode]);
return (
<BaseChart
type="polarArea"
data={formattedData}
options={PolarAreaChartOptions}
className={clsx(className, 'min-w-500')}
{...props}
/>
);
}