feat: dashboard v2, esm, upgrades (#211)
* esm * wip * wip * wip * wip * wip * wip * subscription notice * wip * wip * wip * fix envs * fix: update docker build * fix * esm/types * delete dashboard :D * add patches to dockerfiles * update packages + catalogs + ts * wip * remove native libs * ts * improvements * fix redirects and fetching session * try fix favicon * fixes * fix * order and resize reportds within a dashboard * improvements * wip * added userjot to dashboard * fix * add op * wip * different cache key * improve date picker * fix table * event details loading * redo onboarding completely * fix login * fix * fix * extend session, billing and improve bars * fix * reduce price on 10M
This commit is contained in:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
212
apps/start/src/components/report-chart/area/chart.tsx
Normal file
212
apps/start/src/components/report-chart/area/chart.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
|
||||
import { last } from 'ramda';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Legend,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { SolidToDashedGradient } from '../common/linear-gradient';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
trpc.reference.getChartReferences.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
),
|
||||
);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
// great care should be taken when computing lastIntervalPercent
|
||||
// the expression below works for data.length - 1 equal intervals
|
||||
// but if there are numeric x values in a "linear" axis, the formula
|
||||
// should be updated to use those values
|
||||
const lastIntervalPercent =
|
||||
((rechartData.length - 2) * 100) / (rechartData.length - 1);
|
||||
|
||||
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'day') {
|
||||
return isSameDay(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'month') {
|
||||
return isSameMonth(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
const CustomLegend = useCallback(() => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||
{series.map((serie) => (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
key={serie.id}
|
||||
style={{
|
||||
color: getChartColor(serie.index),
|
||||
}}
|
||||
>
|
||||
<SerieIcon name={serie.names} />
|
||||
<SerieName name={serie.names} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [series]);
|
||||
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
});
|
||||
const xAxisProps = useXAxisProps({
|
||||
hide: hideXAxis,
|
||||
interval,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'#94a3b8'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<React.Fragment key={serie.id}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop
|
||||
offset={'100%'}
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
{useDashedLastLine && (
|
||||
<SolidToDashedGradient
|
||||
percentage={lastIntervalPercent}
|
||||
baseColor={color}
|
||||
id={`stroke${color}`}
|
||||
/>
|
||||
)}
|
||||
</defs>
|
||||
<Area
|
||||
stackId="1"
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
stroke={useDashedLastLine ? `url(#stroke${color})` : color}
|
||||
fill={`url(#color${color})`}
|
||||
isAnimationActive={false}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
{previous && (
|
||||
<Area
|
||||
stackId="2"
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeOpacity={0.3}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
apps/start/src/components/report-chart/area/index.tsx
Normal file
68
apps/start/src/components/report-chart/area/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportAreaChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
30
apps/start/src/components/report-chart/aspect-container.tsx
Normal file
30
apps/start/src/components/report-chart/aspect-container.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { DEFAULT_ASPECT_RATIO } from '@openpanel/constants';
|
||||
|
||||
import { useReportChartContext } from './context';
|
||||
|
||||
interface AspectContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AspectContainer({ children, className }: AspectContainerProps) {
|
||||
const { options } = useReportChartContext();
|
||||
const minHeight = options?.minHeight ?? 100;
|
||||
const maxHeight = options?.maxHeight ?? 300;
|
||||
const aspectRatio = options?.aspectRatio ?? DEFAULT_ASPECT_RATIO;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('w-full', className)}
|
||||
style={{
|
||||
aspectRatio: 1 / aspectRatio,
|
||||
maxHeight,
|
||||
minHeight,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
apps/start/src/components/report-chart/bar/chart.tsx
Normal file
161
apps/start/src/components/report-chart/bar/chart.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { round } from '@openpanel/common';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import { OverviewWidgetTable } from '../../overview/overview-widget-table';
|
||||
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const [isOpen, setOpen] = useState<string | null>(null);
|
||||
const {
|
||||
isEditMode,
|
||||
report: { metric, limit, previous },
|
||||
options: { onClick, dropdownMenuContent, columns },
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
|
||||
[data, isEditMode, limit],
|
||||
);
|
||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
name: columns?.[0] || 'Name',
|
||||
width: 'w-full',
|
||||
render: (serie: (typeof series)[0]) => {
|
||||
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
|
||||
const isDropDownEnabled =
|
||||
!serie.names.includes(NOT_SET_VALUE) &&
|
||||
(dropdownMenuContent?.(serie) || []).length > 0;
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
onOpenChange={() =>
|
||||
setOpen((p) => (p === serie.id ? null : serie.id))
|
||||
}
|
||||
open={isOpen === serie.id}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
disabled={!isDropDownEnabled}
|
||||
{...(isDropDownEnabled
|
||||
? {
|
||||
onPointerDown: (e) => e.preventDefault(),
|
||||
onClick: () => setOpen(serie.id),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 break-all font-medium',
|
||||
(isClickable || isDropDownEnabled) && 'cursor-pointer',
|
||||
)}
|
||||
{...(isClickable && !isDropDownEnabled
|
||||
? {
|
||||
onClick: () => onClick(serie),
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<SerieIcon name={serie.names[0]} />
|
||||
<SerieName name={serie.names} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
{dropdownMenuContent?.(serie).map((item) => (
|
||||
<DropdownMenuItem key={item.title} onClick={item.onClick}>
|
||||
{item.icon && <item.icon size={16} className="mr-2" />}
|
||||
{item.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Percentage column
|
||||
{
|
||||
name: '%',
|
||||
width: '70px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="text-muted-foreground font-mono">
|
||||
{number.format(
|
||||
round((serie.metrics.sum / data.metrics.sum) * 100, 2),
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// Previous value column
|
||||
{
|
||||
name: 'Previous',
|
||||
width: '130px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="flex items-center gap-2 font-mono justify-end">
|
||||
<div className="font-bold">
|
||||
{number.format(serie.metrics.previous?.[metric]?.value)}
|
||||
</div>
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous?.[metric]}
|
||||
size="xs"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
// Main count column (always last)
|
||||
{
|
||||
name: 'Count',
|
||||
width: '80px',
|
||||
render: (serie: (typeof series)[0]) => (
|
||||
<div className="font-bold font-mono">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-sm',
|
||||
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3',
|
||||
)}
|
||||
>
|
||||
<OverviewWidgetTable
|
||||
data={series}
|
||||
keyExtractor={(serie) => serie.id}
|
||||
columns={tableColumns.filter((column) => {
|
||||
if (!previous && column.name === 'Previous') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})}
|
||||
getColumnPercentage={(serie) => serie.metrics.sum / maxCount}
|
||||
className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/components/report-chart/bar/index.tsx
Normal file
75
apps/start/src/components/report-chart/bar/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportBarChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return <Chart data={res.data} />;
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer className="col gap-4 overflow-hidden">
|
||||
{Array.from({ length: 10 }).map((_, index) => (
|
||||
<div
|
||||
key={index as number}
|
||||
className="row animate-pulse justify-between"
|
||||
>
|
||||
<div className="h-4 w-2/5 rounded bg-def-200" />
|
||||
<div className="row w-1/5 gap-2">
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
<div className="h-4 w-full rounded bg-def-200" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
93
apps/start/src/components/report-chart/common/axis.tsx
Normal file
93
apps/start/src/components/report-chart/common/axis.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { isNil } from 'ramda';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { AxisDomain } from 'recharts/types/util/types';
|
||||
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
|
||||
export const AXIS_FONT_PROPS = {
|
||||
fontSize: 8,
|
||||
className: 'font-mono',
|
||||
};
|
||||
|
||||
export function getYAxisWidth(value: string | undefined | null) {
|
||||
const charLength = AXIS_FONT_PROPS.fontSize * 0.6;
|
||||
|
||||
if (isNil(value) || value.length === 0) {
|
||||
return charLength * 2;
|
||||
}
|
||||
|
||||
return charLength * value.length + charLength;
|
||||
}
|
||||
|
||||
export const useYAxisProps = (options?: {
|
||||
hide?: boolean;
|
||||
tickFormatter?: (value: number) => string;
|
||||
}) => {
|
||||
const [width, setWidth] = useState(24);
|
||||
const setWidthDebounced = useDebounceFn(setWidth, 100);
|
||||
const number = useNumber();
|
||||
const ref = useRef<number[]>([]);
|
||||
|
||||
return {
|
||||
...AXIS_FONT_PROPS,
|
||||
width: options?.hide ? 0 : width,
|
||||
axisLine: false,
|
||||
tickLine: false,
|
||||
allowDecimals: false,
|
||||
tickFormatter: (value: number) => {
|
||||
const tick = options?.tickFormatter
|
||||
? options.tickFormatter(value)
|
||||
: number.short(value);
|
||||
const newWidth = getYAxisWidth(tick);
|
||||
ref.current.push(newWidth);
|
||||
setWidthDebounced(Math.max(...ref.current));
|
||||
return tick;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const X_AXIS_STYLE_PROPS = {
|
||||
height: 14,
|
||||
tickSize: 10,
|
||||
axisLine: false,
|
||||
tickLine: false,
|
||||
...AXIS_FONT_PROPS,
|
||||
};
|
||||
|
||||
export const useXAxisProps = (
|
||||
{
|
||||
interval = 'auto',
|
||||
hide,
|
||||
}: {
|
||||
interval?: IInterval | 'auto';
|
||||
hide?: boolean;
|
||||
} = {
|
||||
hide: false,
|
||||
interval: 'auto',
|
||||
},
|
||||
) => {
|
||||
const formatDate = useFormatDateInterval(
|
||||
interval === 'auto' ? 'day' : interval,
|
||||
);
|
||||
return {
|
||||
...X_AXIS_STYLE_PROPS,
|
||||
height: hide ? 0 : X_AXIS_STYLE_PROPS.height,
|
||||
dataKey: 'timestamp',
|
||||
scale: 'utc',
|
||||
domain: ['dataMin', 'dataMax'] as AxisDomain,
|
||||
tickFormatter:
|
||||
interval === 'auto'
|
||||
? undefined
|
||||
: (m: string) => {
|
||||
if (['dataMin', 'dataMax'].includes(m)) {
|
||||
return m;
|
||||
}
|
||||
|
||||
return formatDate(new Date(m));
|
||||
},
|
||||
type: 'number' as const,
|
||||
} as const;
|
||||
};
|
||||
62
apps/start/src/components/report-chart/common/empty.tsx
Normal file
62
apps/start/src/components/report-chart/common/empty.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import {
|
||||
ArrowUpLeftIcon,
|
||||
BirdIcon,
|
||||
CornerLeftUpIcon,
|
||||
Forklift,
|
||||
ForkliftIcon,
|
||||
} from 'lucide-react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function ReportChartEmpty({
|
||||
title = 'No data',
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { events },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||
<CornerLeftUpIcon
|
||||
strokeWidth={1.2}
|
||||
className="size-8 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="text-muted-foreground">Start here</div>
|
||||
</div>
|
||||
<ForkliftIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Ready when you're
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2">
|
||||
Pick atleast one event to start visualize
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'center-center h-full w-full flex-col',
|
||||
isEditMode && 'card p-4',
|
||||
)}
|
||||
>
|
||||
<BirdIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="font-medium text-muted-foreground">{title}</div>
|
||||
<div className="text-muted-foreground mt-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
apps/start/src/components/report-chart/common/error.tsx
Normal file
23
apps/start/src/components/report-chart/common/error.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ServerCrashIcon } from 'lucide-react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function ReportChartError() {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'center-center h-full w-full flex-col',
|
||||
isEditMode && 'card p-4',
|
||||
)}
|
||||
>
|
||||
<ServerCrashIcon
|
||||
strokeWidth={1.2}
|
||||
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||
/>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
There was an error loading this chart.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
interface GradientProps {
|
||||
percentage: number;
|
||||
baseColor: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const SolidToDashedGradient: React.FC<GradientProps> = ({
|
||||
percentage,
|
||||
baseColor,
|
||||
id,
|
||||
}) => {
|
||||
const stops = generateSolidToDashedLinearGradient(percentage, baseColor);
|
||||
|
||||
return (
|
||||
<linearGradient id={id} x1="0" y1="0" x2="1" y2="0">
|
||||
{stops.map((stop, index) => (
|
||||
<stop
|
||||
key={index as any}
|
||||
offset={stop.offset}
|
||||
stopColor={stop.color}
|
||||
stopOpacity={stop.opacity}
|
||||
/>
|
||||
))}
|
||||
</linearGradient>
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function moved to the same file
|
||||
const generateSolidToDashedLinearGradient = (
|
||||
percentage: number,
|
||||
baseColor: string,
|
||||
) => {
|
||||
// Start with solid baseColor up to percentage
|
||||
const stops = [
|
||||
{ offset: '0%', color: baseColor, opacity: 1 },
|
||||
{ offset: `${percentage}%`, color: baseColor, opacity: 1 },
|
||||
];
|
||||
|
||||
// Calculate the remaining space for dashes
|
||||
const remainingSpace = 100 - percentage;
|
||||
const dashWidth = remainingSpace / 20; // 10 dashes = 20 segments (dash + gap)
|
||||
|
||||
// Generate 10 dashes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const startOffset = percentage + i * 2 * dashWidth;
|
||||
|
||||
// Add dash and gap with sharp transitions
|
||||
stops.push(
|
||||
// Start of dash
|
||||
{ offset: `${startOffset}%`, color: baseColor, opacity: 1 },
|
||||
// End of dash
|
||||
{ offset: `${startOffset + dashWidth}%`, color: baseColor, opacity: 1 },
|
||||
// Start of gap (immediate transition)
|
||||
{
|
||||
offset: `${startOffset + dashWidth}%`,
|
||||
color: 'transparent',
|
||||
opacity: 0,
|
||||
},
|
||||
// End of gap
|
||||
{
|
||||
offset: `${startOffset + 2 * dashWidth}%`,
|
||||
color: 'transparent',
|
||||
opacity: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return stops;
|
||||
};
|
||||
88
apps/start/src/components/report-chart/common/loading.tsx
Normal file
88
apps/start/src/components/report-chart/common/loading.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
ActivityIcon,
|
||||
AlarmClockIcon,
|
||||
BarChart2Icon,
|
||||
BarChartIcon,
|
||||
ChartLineIcon,
|
||||
ChartPieIcon,
|
||||
LineChartIcon,
|
||||
MessagesSquareIcon,
|
||||
PieChartIcon,
|
||||
TrendingUpIcon,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
const icons = [
|
||||
{ Icon: ActivityIcon, color: 'text-chart-6' },
|
||||
{ Icon: BarChart2Icon, color: 'text-chart-9' },
|
||||
{ Icon: ChartLineIcon, color: 'text-chart-0' },
|
||||
{ Icon: AlarmClockIcon, color: 'text-chart-1' },
|
||||
{ Icon: ChartPieIcon, color: 'text-chart-2' },
|
||||
{ Icon: MessagesSquareIcon, color: 'text-chart-3' },
|
||||
{ Icon: BarChartIcon, color: 'text-chart-4' },
|
||||
{ Icon: TrendingUpIcon, color: 'text-chart-5' },
|
||||
{ Icon: PieChartIcon, color: 'text-chart-7' },
|
||||
{ Icon: LineChartIcon, color: 'text-chart-8' },
|
||||
];
|
||||
|
||||
export function ReportChartLoading({ things }: { things?: boolean }) {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const [currentIconIndex, setCurrentIconIndex] = React.useState(0);
|
||||
const [isSlow, setSlow] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIconIndex((prevIndex) => (prevIndex + 1) % icons.length);
|
||||
}, 1500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentIconIndex >= 3) {
|
||||
setSlow(true);
|
||||
}
|
||||
}, [currentIconIndex]);
|
||||
|
||||
const { Icon, color } = icons[currentIconIndex]!;
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<div
|
||||
className={
|
||||
'relative h-full w-full rounded bg-def-100 overflow-hidden center-center flex'
|
||||
}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
key={currentIconIndex}
|
||||
initial={{ x: '100%', opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: '-100%', opacity: 0 }}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 30,
|
||||
duration: 0.5,
|
||||
}}
|
||||
className={cn('absolute size-1/3', color)}
|
||||
>
|
||||
<Icon className="w-full h-full" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute top-3/4 opacity-0 transition-opacity text-muted-foreground',
|
||||
isSlow && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
Stay calm, its coming 🙄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
export function getDiffIndicator<A, B, C>(
|
||||
inverted: boolean | undefined,
|
||||
state: string | undefined | null,
|
||||
positive: A,
|
||||
negative: B,
|
||||
neutral: C,
|
||||
): A | B | C {
|
||||
if (state === 'neutral' || !state) {
|
||||
return neutral;
|
||||
}
|
||||
|
||||
if (inverted === true) {
|
||||
return state === 'positive' ? negative : positive;
|
||||
}
|
||||
return state === 'positive' ? positive : negative;
|
||||
}
|
||||
|
||||
// TODO: Fix this mess!
|
||||
|
||||
interface PreviousDiffIndicatorProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
children?: React.ReactNode;
|
||||
inverted?: boolean;
|
||||
className?: string;
|
||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
diff,
|
||||
state,
|
||||
inverted,
|
||||
size = 'sm',
|
||||
children,
|
||||
className,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const {
|
||||
report: { previousIndicatorInverted, previous },
|
||||
} = useReportChartContext();
|
||||
const variant = getDiffIndicator(
|
||||
inverted ?? previousIndicatorInverted,
|
||||
state,
|
||||
'bg-emerald-300',
|
||||
'bg-rose-300',
|
||||
undefined,
|
||||
);
|
||||
const number = useNumber();
|
||||
|
||||
if (diff === null || diff === undefined || previous === false) {
|
||||
return children ?? null;
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-mono font-medium',
|
||||
size === 'lg' && 'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-full',
|
||||
variant,
|
||||
size === 'lg' && 'size-8',
|
||||
size === 'md' && 'size-6',
|
||||
size === 'xs' && 'size-3',
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{number.format(diff)}%
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface PreviousDiffIndicatorPureProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
inverted?: boolean;
|
||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
||||
className?: string;
|
||||
showPrevious?: boolean;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicatorPure({
|
||||
diff,
|
||||
state,
|
||||
inverted,
|
||||
size = 'sm',
|
||||
className,
|
||||
showPrevious = true,
|
||||
}: PreviousDiffIndicatorPureProps) {
|
||||
const variant = getDiffIndicator(
|
||||
inverted,
|
||||
state,
|
||||
'bg-emerald-300',
|
||||
'bg-rose-300',
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (diff === null || diff === undefined || !showPrevious) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (state === 'positive') {
|
||||
return <ArrowUpIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
if (state === 'negative') {
|
||||
return <ArrowDownIcon strokeWidth={3} size={10} color="#000" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 font-mono font-medium',
|
||||
size === 'lg' && 'gap-2',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-2.5 items-center justify-center rounded-full',
|
||||
variant,
|
||||
size === 'lg' && 'size-8',
|
||||
size === 'md' && 'size-6',
|
||||
size === 'xs' && 'size-3',
|
||||
)}
|
||||
>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{diff.toFixed(1)}%
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IRechartPayloadItem } from '@/hooks/use-rechart-data-model';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
import * as Portal from '@radix-ui/react-portal';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import throttle from 'lodash.throttle';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useReportChartContext } from '../context';
|
||||
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||
import { SerieIcon } from './serie-icon';
|
||||
import { SerieName } from './serie-name';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
value: number;
|
||||
name: string;
|
||||
dataKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const {
|
||||
report: { interval, unit },
|
||||
} = useReportChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const number = useNumber();
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const inactive = !active || !payload?.length;
|
||||
useEffect(() => {
|
||||
const setPositionThrottled = throttle(setPosition, 50);
|
||||
const unsubMouseMove = bind(window, {
|
||||
type: 'mousemove',
|
||||
listener(event) {
|
||||
if (!inactive) {
|
||||
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
||||
}
|
||||
},
|
||||
});
|
||||
const unsubDragEnter = bind(window, {
|
||||
type: 'pointerdown',
|
||||
listener() {
|
||||
setPosition(null);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubMouseMove();
|
||||
unsubDragEnter();
|
||||
};
|
||||
}, [inactive]);
|
||||
|
||||
if (inactive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const limit = 3;
|
||||
const sorted = payload
|
||||
.slice(0)
|
||||
.filter((item) => !item.dataKey.includes(':prev:count'))
|
||||
.filter((item) => !item.name.includes(':noTooltip'))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
const correctXPosition = (x: number | undefined) => {
|
||||
if (!x) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tooltipWidth = 300;
|
||||
const screenWidth = window.innerWidth;
|
||||
const newX = x;
|
||||
|
||||
if (newX + tooltipWidth > screenWidth) {
|
||||
return screenWidth - tooltipWidth;
|
||||
}
|
||||
return newX;
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal.Portal
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position?.y,
|
||||
left: correctXPosition(position?.x),
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = (
|
||||
item.dataKey.includes(':')
|
||||
? // @ts-expect-error
|
||||
payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload
|
||||
) as IRechartPayloadItem;
|
||||
|
||||
return (
|
||||
<React.Fragment key={data.id}>
|
||||
{index === 0 && data.date && (
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{formatDate(new Date(data.date))}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<SerieIcon name={data.names} />
|
||||
<SerieName name={data.names} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count, unit)}
|
||||
{!!data.previous && (
|
||||
<span className="text-muted-foreground">
|
||||
({number.formatWithUnit(data.previous.value, unit)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicator {...data.previous} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
and {hidden.length} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Portal.Portal>
|
||||
);
|
||||
}
|
||||
190
apps/start/src/components/report-chart/common/report-table.tsx
Normal file
190
apps/start/src/components/report-chart/common/report-table.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Pagination, usePagination } from '@/components/pagination';
|
||||
import { Stats, StatsCard } from '@/components/stats';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useSelector } from '@/redux';
|
||||
import { getPropertyLabel } from '@/translations/properties';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { logDependencies } from 'mathjs';
|
||||
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||
import { SerieName } from './serie-name';
|
||||
|
||||
interface ReportTableProps {
|
||||
data: IChartData;
|
||||
visibleSeries: IChartData['series'];
|
||||
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
}
|
||||
|
||||
const ROWS_LIMIT = 50;
|
||||
|
||||
export function ReportTable({
|
||||
data,
|
||||
visibleSeries,
|
||||
setVisibleSeries,
|
||||
}: ReportTableProps) {
|
||||
const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
|
||||
const number = useNumber();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
function handleChange(name: string, checked: boolean) {
|
||||
setVisibleSeries((prev) => {
|
||||
if (checked) {
|
||||
return [...prev, name];
|
||||
}
|
||||
return prev.filter((item) => item !== name);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stats className="my-4 grid grid-cols-1 @xl:grid-cols-3 @4xl:grid-cols-6">
|
||||
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
|
||||
<StatsCard
|
||||
title="Average"
|
||||
value={number.format(data.metrics.average)}
|
||||
/>
|
||||
<StatsCard title="Min" value={number.format(data.metrics.min)} />
|
||||
<StatsCard title="Max" value={number.format(data.metrics.max)} />
|
||||
</Stats>
|
||||
<div className="grid grid-cols-[max(300px,30vw)_1fr] overflow-hidden rounded-md border border-border">
|
||||
<Table className="rounded-none border-b-0 border-l-0 border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{breakdowns.length === 0 && <TableHead>Name</TableHead>}
|
||||
{breakdowns.map((breakdown) => (
|
||||
<TableHead key={breakdown.name}>
|
||||
{getPropertyLabel(breakdown.name)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="bg-def-100">
|
||||
{paginate(data.series).map((serie, index) => {
|
||||
const checked = !!visibleSeries.find(
|
||||
(item) => item.id === serie.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow key={`${serie.id}-1`}>
|
||||
{serie.names.map((name, nameIndex) => {
|
||||
return (
|
||||
<TableCell className="h-10" key={name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{nameIndex === 0 ? (
|
||||
<>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange(serie.id, !!checked)
|
||||
}
|
||||
style={
|
||||
checked
|
||||
? {
|
||||
background: getChartColor(index),
|
||||
borderColor: getChartColor(index),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
<Tooltiper
|
||||
side="left"
|
||||
sideOffset={30}
|
||||
content={<SerieName name={serie.names} />}
|
||||
>
|
||||
{name}
|
||||
</Tooltiper>
|
||||
</>
|
||||
) : (
|
||||
<SerieName name={name} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-auto">
|
||||
<Table className="rounded-none border-none">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Average</TableHead>
|
||||
{data.series[0]?.data.map((serie) => (
|
||||
<TableHead
|
||||
key={serie.date.toString()}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{formatDate(serie.date)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginate(data.series).map((serie) => {
|
||||
return (
|
||||
<TableRow key={`${serie.id}-2`}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.sum)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous?.sum}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.average)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous?.average}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{serie.data.map((item) => {
|
||||
return (
|
||||
<TableCell className="h-10" key={item.date.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{number.format(item.count)}
|
||||
<PreviousDiffIndicator {...item.previous} />
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="row mt-4 justify-end">
|
||||
<Pagination
|
||||
cursor={page}
|
||||
setCursor={setPage}
|
||||
take={ROWS_LIMIT}
|
||||
count={data.series.length}
|
||||
/>
|
||||
</div> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
|
||||
const createFlagIcon = (url: string) => {
|
||||
return ((_props: LucideProps) => (
|
||||
<span
|
||||
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
|
||||
/>
|
||||
)) as LucideIcon;
|
||||
};
|
||||
|
||||
const data = {
|
||||
ie: createFlagIcon('ie'),
|
||||
tw: createFlagIcon('tw'),
|
||||
py: createFlagIcon('py'),
|
||||
kr: createFlagIcon('kr'),
|
||||
nz: createFlagIcon('nz'),
|
||||
do: createFlagIcon('do'),
|
||||
cl: createFlagIcon('cl'),
|
||||
dz: createFlagIcon('dz'),
|
||||
np: createFlagIcon('np'),
|
||||
ma: createFlagIcon('ma'),
|
||||
gh: createFlagIcon('gh'),
|
||||
zm: createFlagIcon('zm'),
|
||||
pa: createFlagIcon('pa'),
|
||||
tn: createFlagIcon('tn'),
|
||||
lk: createFlagIcon('lk'),
|
||||
sv: createFlagIcon('sv'),
|
||||
ve: createFlagIcon('ve'),
|
||||
sn: createFlagIcon('sn'),
|
||||
gt: createFlagIcon('gt'),
|
||||
xk: createFlagIcon('xk'),
|
||||
jm: createFlagIcon('jm'),
|
||||
cm: createFlagIcon('cm'),
|
||||
ni: createFlagIcon('ni'),
|
||||
uy: createFlagIcon('uy'),
|
||||
ss: createFlagIcon('ss'),
|
||||
cd: createFlagIcon('cd'),
|
||||
cu: createFlagIcon('cu'),
|
||||
kh: createFlagIcon('kh'),
|
||||
bb: createFlagIcon('bb'),
|
||||
gf: createFlagIcon('gf'),
|
||||
et: createFlagIcon('et'),
|
||||
pe: createFlagIcon('pe'),
|
||||
mo: createFlagIcon('mo'),
|
||||
mn: createFlagIcon('mn'),
|
||||
hn: createFlagIcon('hn'),
|
||||
cn: createFlagIcon('cn'),
|
||||
ng: createFlagIcon('ng'),
|
||||
se: createFlagIcon('se'),
|
||||
jp: createFlagIcon('jp'),
|
||||
hk: createFlagIcon('hk'),
|
||||
us: createFlagIcon('us'),
|
||||
gb: createFlagIcon('gb'),
|
||||
ua: createFlagIcon('ua'),
|
||||
ru: createFlagIcon('ru'),
|
||||
de: createFlagIcon('de'),
|
||||
fr: createFlagIcon('fr'),
|
||||
br: createFlagIcon('br'),
|
||||
in: createFlagIcon('in'),
|
||||
it: createFlagIcon('it'),
|
||||
es: createFlagIcon('es'),
|
||||
pl: createFlagIcon('pl'),
|
||||
nl: createFlagIcon('nl'),
|
||||
id: createFlagIcon('id'),
|
||||
tr: createFlagIcon('tr'),
|
||||
ph: createFlagIcon('ph'),
|
||||
ca: createFlagIcon('ca'),
|
||||
ar: createFlagIcon('ar'),
|
||||
mx: createFlagIcon('mx'),
|
||||
za: createFlagIcon('za'),
|
||||
au: createFlagIcon('au'),
|
||||
co: createFlagIcon('co'),
|
||||
ch: createFlagIcon('ch'),
|
||||
at: createFlagIcon('at'),
|
||||
be: createFlagIcon('be'),
|
||||
pt: createFlagIcon('pt'),
|
||||
my: createFlagIcon('my'),
|
||||
th: createFlagIcon('th'),
|
||||
vn: createFlagIcon('vn'),
|
||||
sg: createFlagIcon('sg'),
|
||||
eg: createFlagIcon('eg'),
|
||||
sa: createFlagIcon('sa'),
|
||||
pk: createFlagIcon('pk'),
|
||||
bd: createFlagIcon('bd'),
|
||||
ro: createFlagIcon('ro'),
|
||||
hu: createFlagIcon('hu'),
|
||||
cz: createFlagIcon('cz'),
|
||||
gr: createFlagIcon('gr'),
|
||||
il: createFlagIcon('il'),
|
||||
no: createFlagIcon('no'),
|
||||
fi: createFlagIcon('fi'),
|
||||
dk: createFlagIcon('dk'),
|
||||
sk: createFlagIcon('sk'),
|
||||
bg: createFlagIcon('bg'),
|
||||
hr: createFlagIcon('hr'),
|
||||
rs: createFlagIcon('rs'),
|
||||
ba: createFlagIcon('ba'),
|
||||
si: createFlagIcon('si'),
|
||||
lv: createFlagIcon('lv'),
|
||||
lt: createFlagIcon('lt'),
|
||||
ee: createFlagIcon('ee'),
|
||||
by: createFlagIcon('by'),
|
||||
md: createFlagIcon('md'),
|
||||
kz: createFlagIcon('kz'),
|
||||
uz: createFlagIcon('uz'),
|
||||
kg: createFlagIcon('kg'),
|
||||
tj: createFlagIcon('tj'),
|
||||
tm: createFlagIcon('tm'),
|
||||
az: createFlagIcon('az'),
|
||||
ge: createFlagIcon('ge'),
|
||||
am: createFlagIcon('am'),
|
||||
af: createFlagIcon('af'),
|
||||
ir: createFlagIcon('ir'),
|
||||
iq: createFlagIcon('iq'),
|
||||
sy: createFlagIcon('sy'),
|
||||
lb: createFlagIcon('lb'),
|
||||
jo: createFlagIcon('jo'),
|
||||
ps: createFlagIcon('ps'),
|
||||
kw: createFlagIcon('kw'),
|
||||
qa: createFlagIcon('qa'),
|
||||
om: createFlagIcon('om'),
|
||||
ye: createFlagIcon('ye'),
|
||||
ae: createFlagIcon('ae'),
|
||||
bh: createFlagIcon('bh'),
|
||||
cy: createFlagIcon('cy'),
|
||||
mt: createFlagIcon('mt'),
|
||||
sm: createFlagIcon('sm'),
|
||||
li: createFlagIcon('li'),
|
||||
is: createFlagIcon('is'),
|
||||
al: createFlagIcon('al'),
|
||||
mk: createFlagIcon('mk'),
|
||||
me: createFlagIcon('me'),
|
||||
ad: createFlagIcon('ad'),
|
||||
lu: createFlagIcon('lu'),
|
||||
mc: createFlagIcon('mc'),
|
||||
fo: createFlagIcon('fo'),
|
||||
gg: createFlagIcon('gg'),
|
||||
je: createFlagIcon('je'),
|
||||
im: createFlagIcon('im'),
|
||||
gi: createFlagIcon('gi'),
|
||||
va: createFlagIcon('va'),
|
||||
ax: createFlagIcon('ax'),
|
||||
bl: createFlagIcon('bl'),
|
||||
mf: createFlagIcon('mf'),
|
||||
pm: createFlagIcon('pm'),
|
||||
yt: createFlagIcon('yt'),
|
||||
wf: createFlagIcon('wf'),
|
||||
tf: createFlagIcon('tf'),
|
||||
re: createFlagIcon('re'),
|
||||
sc: createFlagIcon('sc'),
|
||||
mu: createFlagIcon('mu'),
|
||||
zw: createFlagIcon('zw'),
|
||||
mz: createFlagIcon('mz'),
|
||||
na: createFlagIcon('na'),
|
||||
bw: createFlagIcon('bw'),
|
||||
ls: createFlagIcon('ls'),
|
||||
sz: createFlagIcon('sz'),
|
||||
bi: createFlagIcon('bi'),
|
||||
rw: createFlagIcon('rw'),
|
||||
ug: createFlagIcon('ug'),
|
||||
ke: createFlagIcon('ke'),
|
||||
tz: createFlagIcon('tz'),
|
||||
mg: createFlagIcon('mg'),
|
||||
cr: createFlagIcon('cr'),
|
||||
ky: createFlagIcon('ky'),
|
||||
gy: createFlagIcon('gy'),
|
||||
mm: createFlagIcon('mm'),
|
||||
la: createFlagIcon('la'),
|
||||
gl: createFlagIcon('gl'),
|
||||
gp: createFlagIcon('gp'),
|
||||
fj: createFlagIcon('fj'),
|
||||
cv: createFlagIcon('cv'),
|
||||
gn: createFlagIcon('gn'),
|
||||
bj: createFlagIcon('bj'),
|
||||
bo: createFlagIcon('bo'),
|
||||
bq: createFlagIcon('bq'),
|
||||
bs: createFlagIcon('bs'),
|
||||
ly: createFlagIcon('ly'),
|
||||
bn: createFlagIcon('bn'),
|
||||
tt: createFlagIcon('tt'),
|
||||
sr: createFlagIcon('sr'),
|
||||
ec: createFlagIcon('ec'),
|
||||
mv: createFlagIcon('mv'),
|
||||
pr: createFlagIcon('pr'),
|
||||
ci: createFlagIcon('ci'),
|
||||
};
|
||||
|
||||
export default data;
|
||||
109
apps/start/src/components/report-chart/common/serie-icon.tsx
Normal file
109
apps/start/src/components/report-chart/common/serie-icon.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
import {
|
||||
ActivityIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircleIcon,
|
||||
MailIcon,
|
||||
MessageCircleIcon,
|
||||
MonitorIcon,
|
||||
MonitorPlayIcon,
|
||||
PodcastIcon,
|
||||
ScanIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
TabletIcon,
|
||||
TvIcon,
|
||||
} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import { useAppContext } from '@/hooks/use-app-context';
|
||||
import flags from './serie-icon.flags';
|
||||
import iconsWithUrls from './serie-icon.urls';
|
||||
|
||||
type SerieIconProps = Omit<LucideProps, 'name'> & {
|
||||
name?: string | string[];
|
||||
};
|
||||
|
||||
function getProxyImage(url: string) {
|
||||
return `/misc/favicon?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
const createImageIcon = (url: string) => {
|
||||
return ((_props: LucideProps) => {
|
||||
const context = useAppContext();
|
||||
return (
|
||||
<img
|
||||
alt="serie icon"
|
||||
className="max-h-4 rounded-[2px] object-contain"
|
||||
src={context.apiUrl?.replace(/\/$/, '') + url}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}) as LucideIcon;
|
||||
};
|
||||
|
||||
const mapper: Record<string, LucideIcon> = {
|
||||
// Events
|
||||
screen_view: MonitorPlayIcon,
|
||||
session_start: ActivityIcon,
|
||||
session_end: ActivityIcon,
|
||||
link_out: ExternalLinkIcon,
|
||||
|
||||
// Misc
|
||||
smarttv: TvIcon,
|
||||
mobile: SmartphoneIcon,
|
||||
desktop: MonitorIcon,
|
||||
tablet: TabletIcon,
|
||||
search: SearchIcon,
|
||||
social: PodcastIcon,
|
||||
email: MailIcon,
|
||||
podcast: PodcastIcon,
|
||||
comment: MessageCircleIcon,
|
||||
unknown: HelpCircleIcon,
|
||||
[NOT_SET_VALUE]: ScanIcon,
|
||||
|
||||
...Object.entries(iconsWithUrls).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: createImageIcon(getProxyImage(value)),
|
||||
}),
|
||||
{},
|
||||
),
|
||||
|
||||
...flags,
|
||||
};
|
||||
|
||||
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
|
||||
const name = Array.isArray(names) ? names[0] : names;
|
||||
const Icon = useMemo(() => {
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapped = mapper[name.toLowerCase()] ?? null;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
if (name.includes('http')) {
|
||||
return createImageIcon(getProxyImage(name));
|
||||
}
|
||||
|
||||
// Matching image file name
|
||||
if (name.match(/(.+)\.\w{2,3}$/)) {
|
||||
return createImageIcon(getProxyImage(`https://${name}`));
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [name]);
|
||||
|
||||
return Icon ? (
|
||||
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
|
||||
<Icon size={16} {...props} name={name} />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
155
apps/start/src/components/report-chart/common/serie-icon.urls.ts
Normal file
155
apps/start/src/components/report-chart/common/serie-icon.urls.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// biome-ignore format: annoying
|
||||
|
||||
const data = {
|
||||
amazon: 'https://upload.wikimedia.org/wikipedia/commons/4/4a/Amazon_icon.svg',
|
||||
'chromium os': 'https://upload.wikimedia.org/wikipedia/commons/2/28/Chromium_Logo.svg',
|
||||
'mac os': 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/30/MacOS_logo.svg/1200px-MacOS_logo.svg.png',
|
||||
apple: 'https://sladesportfolio.wordpress.com/wp-content/uploads/2015/08/apple_logo_black-svg.png',
|
||||
huawei: 'https://upload.wikimedia.org/wikipedia/en/0/04/Huawei_Standard_logo.svg',
|
||||
xiaomi: 'https://upload.wikimedia.org/wikipedia/commons/2/29/Xiaomi_logo.svg',
|
||||
sony: 'https://serialtrainer7.com/wp-content/uploads/2021/07/sony-logo-300px-square.png',
|
||||
lg: 'https://upload.wikimedia.org/wikipedia/commons/2/20/LG_symbol.svg',
|
||||
samsung: 'https://seekvectors.com/storage/images/Samsung-Logo-22.svg',
|
||||
oppo: 'https://indoleads.nyc3.cdn.digitaloceanspaces.com/uploads/offers/logos/8695_95411e367b832.png',
|
||||
motorola: 'https://upload.wikimedia.org/wikipedia/commons/8/8f/Motorola_M_symbol_blue.svg',
|
||||
oneplus: 'https://pbs.twimg.com/profile_images/1709165009148809216/ebHb4xhF_400x400.png',
|
||||
asus: 'https://cdn-icons-png.freepik.com/512/5969/5969050.png',
|
||||
fairphone: 'https://cdn.dribbble.com/users/433772/screenshots/2109827/fairphone_dribbble.jpg',
|
||||
nokia: 'https://www.gizchina.com/wp-content/uploads/images/2023/02/Nokia-logo.webp',
|
||||
'mobile safari': 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
|
||||
'openpanel.dev': 'https://openpanel.dev',
|
||||
'samsung internet': 'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png',
|
||||
'vstat.info': 'https://vstat.info',
|
||||
'yahoo!': 'https://yahoo.com',
|
||||
android: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/1745px-Android_robot.svg.png',
|
||||
'android browser': 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png',
|
||||
silk: 'https://m.media-amazon.com/images/I/51VCjQCvF0L.png',
|
||||
kakaotalk: 'https://www.kakaocorp.com/',
|
||||
bing: 'https://bing.com',
|
||||
electron: 'https://www.electronjs.org',
|
||||
whale: 'https://whale.naver.com',
|
||||
wechat: 'https://wechat.com',
|
||||
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
||||
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
|
||||
webkit: 'https://webkit.org',
|
||||
duckduckgo: 'https://duckduckgo.com',
|
||||
ecosia: 'https://ecosia.com',
|
||||
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
|
||||
facebook: 'https://facebook.com',
|
||||
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
|
||||
github: 'https://github.com',
|
||||
gmail: 'https://mail.google.com',
|
||||
google: 'https://google.com',
|
||||
gsa: 'https://google.com', // Google Search App
|
||||
instagram: 'https://instagram.com',
|
||||
ios: 'https://cdn0.iconfinder.com/data/icons/flat-round-system/512/apple-1024.png',
|
||||
linkedin: 'https://linkedin.com',
|
||||
linux: 'https://upload.wikimedia.org/wikipedia/commons/3/35/Tux.svg',
|
||||
ubuntu: 'https://upload.wikimedia.org/wikipedia/commons/a/ab/Logo-ubuntu_cof-orange-hex.svg',
|
||||
weibo: 'https://en.wikipedia.org/wiki/Weibo#/media/File:Sina_Weibo.svg',
|
||||
microlaunch: 'https://microlaunch.net',
|
||||
openalternative: 'https://openalternative.co',
|
||||
opera: 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Opera_2015_icon.svg/1920px-Opera_2015_icon.svg.png',
|
||||
'opera touch': 'https://upload.wikimedia.org/wikipedia/commons/thumb/4/49/Opera_2015_icon.svg/1920px-Opera_2015_icon.svg.png',
|
||||
'miui browser': 'https://www.apkmirror.com/wp-content/themes/APKMirror/ap_resize/ap_resize.php?src=https%3A%2F%2Fdownloadr2.apkmirror.com%2Fwp-content%2Fuploads%2F2020%2F07%2F79%2F5f06abb0c532a.png',
|
||||
'huawei browser': 'https://www.apkmirror.com/wp-content/themes/APKMirror/ap_resize/ap_resize.php?src=https%3A%2F%2Fdownloadr2.apkmirror.com%2Fwp-content%2Fuploads%2F2021%2F09%2F49%2F613e45827fe31.png',
|
||||
'vivo browser': 'https://play-lh.googleusercontent.com/SYGXsBZOFsI4p72IoWtJiuAdp2Acv0WB4e6R1jxNcQdPcOdP1sXk_Cfcr1KBt2lzZQ=w480-h960-rw',
|
||||
'qqbrowser': 'https://cdn6.aptoide.com/imgs/6/1/4/614713aba4f9ca93a4e20257014d0713_icon.png',
|
||||
'quark': 'https://play-lh.googleusercontent.com/o__Uwu3TEsDVJ92m6zacy2GXotP2faFAlLQJOgOxYFsgoDkDYwODQ4STHY1fe0n_imlpiiUiE-QgRQaWdyi99w=w240-h480-rw',
|
||||
pinterest: 'https://www.pinterest.se',
|
||||
producthunt: 'https://www.producthunt.com',
|
||||
reddit: 'https://reddit.com',
|
||||
safari: 'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg',
|
||||
slack: 'https://slack.com',
|
||||
snapchat: 'https://snapchat.com',
|
||||
taaft: 'https://theresanaiforthat.com',
|
||||
twitter: 'https://twitter.com',
|
||||
windows: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png',
|
||||
yandex: 'https://yandex.com',
|
||||
youtube: 'https://youtube.com',
|
||||
ossgallery: 'https://oss.gallery',
|
||||
convertkit: 'https://convertkit.com',
|
||||
whatsapp: 'https://www.whatsapp.com/',
|
||||
telegram: 'https://telegram.org/',
|
||||
tiktok: 'https://tiktok.com',
|
||||
sharpspring: 'https://sharpspring.com',
|
||||
'hacker news': 'https://news.ycombinator.com',
|
||||
betalist: 'https://betalist.com',
|
||||
qwant: 'https://www.qwant.com',
|
||||
flipboard: 'https://flipboard.com/',
|
||||
trustpilot: 'https://trustpilot.com',
|
||||
'outlook.com': 'https://login.live.com/',
|
||||
notion: 'https://notion.so',
|
||||
brave: 'https://brave.com',
|
||||
perplexity: 'https://perplexity.ai',
|
||||
vercel: 'https://vercel.com',
|
||||
discord: 'https://discord.com',
|
||||
openstatus: 'https://openstatus.dev',
|
||||
baidu: 'https://baidu.com',
|
||||
medium: 'https://medium.com',
|
||||
"google news": "https://news.google.com",
|
||||
"google ads": "https://ads.google.com",
|
||||
"google shopping": "https://www.google.com/shopping",
|
||||
"gmb": "https://business.google.com",
|
||||
"google business profile": "https://business.google.com",
|
||||
"yahoo": "https://www.yahoo.com",
|
||||
"yahoo! mail": "https://mail.yahoo.com",
|
||||
"yahoo! images": "https://images.search.yahoo.com",
|
||||
"startpagina": "https://www.startpagina.nl",
|
||||
"ask": "https://www.ask.com",
|
||||
"lycos": "https://www.lycos.com",
|
||||
"infospace": "https://www.infospace.com",
|
||||
"shenma": "https://m.sm.cn",
|
||||
"360.cn": "https://www.360.cn",
|
||||
"naver": "https://www.naver.com",
|
||||
"daum": "https://www.daum.net",
|
||||
"seznam": "https://www.seznam.cz",
|
||||
"seznam mail": "https://email.seznam.cz",
|
||||
"t-online": "https://www.t-online.de",
|
||||
"web.de": "https://web.de",
|
||||
"gmx": "https://www.gmx.net",
|
||||
"mail.ru": "https://mail.ru",
|
||||
"mail.com": "https://www.mail.com",
|
||||
"orange webmail": "https://mail.orange.fr",
|
||||
"aol": "https://www.aol.com",
|
||||
"aol mail": "https://mail.aol.com",
|
||||
"fb": "https://www.facebook.com",
|
||||
"facebook ads": "https://www.facebook.com/business/ads",
|
||||
"meta": "https://about.facebook.com",
|
||||
"meta_ads": "https://www.facebook.com/business/ads",
|
||||
"ig": "https://www.instagram.com",
|
||||
"threads": "https://www.threads.net",
|
||||
"x": "https://x.com",
|
||||
"pinterst": "https://www.pinterest.com",
|
||||
"twitch": "https://www.twitch.tv",
|
||||
"quora": "https://www.quora.com",
|
||||
"bluesky": "https://bsky.app",
|
||||
"vkontakte": "https://vk.com",
|
||||
"substack": "https://substack.com",
|
||||
"taboola": "https://www.taboola.com",
|
||||
"outbrain": "https://www.outbrain.com",
|
||||
"criteo": "https://www.criteo.com",
|
||||
"doubleclick": "https://doubleclick.net",
|
||||
"rakuten": "https://www.rakuten.com",
|
||||
"paypal": "https://www.paypal.com",
|
||||
"ebay": "https://www.ebay.com",
|
||||
"gitlab": "https://gitlab.com",
|
||||
"stack overflow": "https://stackoverflow.com",
|
||||
"figma": "https://www.figma.com",
|
||||
"dropbox": "https://www.dropbox.com",
|
||||
"openai": "https://openai.com",
|
||||
"chatgpt.com": "https://chatgpt.com",
|
||||
"mailchimp": "https://mailchimp.com",
|
||||
"activecampaign": "https://www.activecampaign.com",
|
||||
"customer.io": "https://customer.io",
|
||||
"iterable": "https://iterable.com",
|
||||
"moengage": "https://www.moengage.com",
|
||||
"klaviyo": "https://www.klaviyo.com",
|
||||
"brevo": "https://www.brevo.com",
|
||||
"tumblr": "https://www.tumblr.com",
|
||||
"dailymotion": "https://www.dailymotion.com",
|
||||
};
|
||||
|
||||
export default data;
|
||||
44
apps/start/src/components/report-chart/common/serie-name.tsx
Normal file
44
apps/start/src/components/report-chart/common/serie-name.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface SerieNameProps {
|
||||
name: string | string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SerieName({ name, className }: SerieNameProps) {
|
||||
const {
|
||||
options: { renderSerieName },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (Array.isArray(name)) {
|
||||
if (renderSerieName) {
|
||||
return renderSerieName(name);
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{name.map((n, index) => {
|
||||
return (
|
||||
<Fragment key={n}>
|
||||
<span>{n || NOT_SET_VALUE}</span>
|
||||
{name.length - 1 > index && (
|
||||
<ChevronRightIcon className="text-muted-foreground" size={12} />
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderSerieName) {
|
||||
return renderSerieName([name]);
|
||||
}
|
||||
|
||||
return <>{name}</>;
|
||||
}
|
||||
86
apps/start/src/components/report-chart/context.tsx
Normal file
86
apps/start/src/components/report-chart/context.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import isEqual from 'lodash.isequal';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
import type {
|
||||
IChartInput,
|
||||
IChartProps,
|
||||
IChartSerie,
|
||||
} from '@openpanel/validation';
|
||||
|
||||
export type ReportChartContextType = {
|
||||
options: Partial<{
|
||||
columns: React.ReactNode[];
|
||||
hideID: boolean;
|
||||
hideLegend: boolean;
|
||||
hideXAxis: boolean;
|
||||
hideYAxis: boolean;
|
||||
aspectRatio: number;
|
||||
maxHeight: number;
|
||||
minHeight: number;
|
||||
maxDomain: number;
|
||||
onClick: (serie: IChartSerie) => void;
|
||||
renderSerieName: (names: string[]) => React.ReactNode;
|
||||
renderSerieIcon: (serie: IChartSerie) => React.ReactNode;
|
||||
dropdownMenuContent: (serie: IChartSerie) => {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
}>;
|
||||
report: IChartProps;
|
||||
isLazyLoading: boolean;
|
||||
isEditMode: boolean;
|
||||
};
|
||||
|
||||
type ReportChartContextProviderProps = ReportChartContextType & {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||
report: IChartInput;
|
||||
lazy?: boolean;
|
||||
};
|
||||
|
||||
const context = createContext<ReportChartContextType | null>(null);
|
||||
|
||||
export const useReportChartContext = () => {
|
||||
const ctx = useContext(context);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useReportChartContext must be used within a ReportChartProvider',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
export const useSelectReportChartContext = <T,>(
|
||||
selector: (ctx: ReportChartContextType) => T,
|
||||
) => {
|
||||
const ctx = useReportChartContext();
|
||||
const [state, setState] = useState(selector(ctx));
|
||||
useEffect(() => {
|
||||
const newState = selector(ctx);
|
||||
if (!isEqual(newState, state)) {
|
||||
setState(newState);
|
||||
}
|
||||
}, [ctx]);
|
||||
return state;
|
||||
};
|
||||
|
||||
export const ReportChartProvider = ({
|
||||
children,
|
||||
...propsToContext
|
||||
}: ReportChartContextProviderProps) => {
|
||||
const [ctx, setContext] = useState(propsToContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(ctx, propsToContext)) {
|
||||
setContext(propsToContext);
|
||||
}
|
||||
}, [propsToContext]);
|
||||
|
||||
return <context.Provider value={ctx}>{children}</context.Provider>;
|
||||
};
|
||||
|
||||
export default context;
|
||||
212
apps/start/src/components/report-chart/conversion/chart.tsx
Normal file
212
apps/start/src/components/report-chart/conversion/chart.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { average, getPreviousMetric, round } from '@openpanel/common';
|
||||
import type { IInterval } from '@openpanel/validation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Fragment } from 'react';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['conversion'];
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
events,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
} = useReportChartContext();
|
||||
const dataLength = data.current.length || 0;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
trpc.reference.getChartReferences.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
});
|
||||
|
||||
const averageConversionRate = average(
|
||||
data.current.map((serie) => {
|
||||
return average(serie.data.map((item) => item.rate));
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider conversion={data} interval={interval}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'#94a3b8'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||
<Tooltip />
|
||||
{data.current.map((serie, index) => {
|
||||
const color = getChartColor(index);
|
||||
return (
|
||||
<Fragment key={serie.id}>
|
||||
<Line
|
||||
data={serie.data}
|
||||
dot={false}
|
||||
name={`rate_${index}`}
|
||||
dataKey="rate"
|
||||
stroke={color}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Line
|
||||
data={serie.data}
|
||||
dot={false}
|
||||
name={`prev_rate_${index}`}
|
||||
dataKey="previousRate"
|
||||
stroke={color}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{typeof averageConversionRate === 'number' &&
|
||||
averageConversionRate && (
|
||||
<ReferenceLine
|
||||
y={averageConversionRate}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Average (${round(averageConversionRate, 2)} %)`,
|
||||
fill: getChartColor(1),
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
NonNullable<
|
||||
RouterOutputs['chart']['conversion']['current'][number]
|
||||
>['data'][number],
|
||||
{
|
||||
conversion: RouterOutputs['chart']['conversion'];
|
||||
interval: IInterval;
|
||||
}
|
||||
>(({ data, context }) => {
|
||||
if (!data[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { date } = data[0];
|
||||
const formatDate = useFormatDateInterval(context.interval);
|
||||
const number = useNumber();
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{formatDate(date)}</div>
|
||||
</div>
|
||||
{context.conversion.current.map((serie, index) => {
|
||||
const item = data[index];
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
const prevItem =
|
||||
context.conversion?.previous?.[item.serieIndex]?.data[item.index];
|
||||
|
||||
const title =
|
||||
serie.breakdowns.length > 0
|
||||
? (serie.breakdowns.join(',') ?? 'Not set')
|
||||
: 'Conversion';
|
||||
return (
|
||||
<div className="row gap-2" key={serie.id}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(index) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{title}</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="col gap-1">
|
||||
<span>{number.formatWithUnit(item.rate / 100, '%')}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({number.format(item.total)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(item.rate, prevItem?.rate)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
73
apps/start/src/components/report-chart/conversion/index.tsx
Normal file
73
apps/start/src/components/report-chart/conversion/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
import { Summary } from './summary';
|
||||
|
||||
export function ReportConversionChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.conversion.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.current.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.current.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Summary data={res.data} />
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
227
apps/start/src/components/report-chart/conversion/summary.tsx
Normal file
227
apps/start/src/components/report-chart/conversion/summary.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { Stats, StatsCard } from '@/components/stats';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['conversion'];
|
||||
}
|
||||
|
||||
export function Summary({ data }: Props) {
|
||||
const number = useNumber();
|
||||
const { report } = useReportChartContext();
|
||||
|
||||
const bestConversionRateMatch = useMemo(() => {
|
||||
return data.current.reduce(
|
||||
(acc, serie, serieIndex) => {
|
||||
const serieMax = serie.data.reduce(
|
||||
(maxInSerie, item, dataIndex) => {
|
||||
if (item.rate > maxInSerie.rate) {
|
||||
return { rate: item.rate, serieIndex, dataIndex };
|
||||
}
|
||||
return maxInSerie;
|
||||
},
|
||||
{ rate: 0, serieIndex, dataIndex: 0 },
|
||||
);
|
||||
|
||||
return serieMax.rate > acc.rate ? serieMax : acc;
|
||||
},
|
||||
{
|
||||
rate: 0,
|
||||
serieIndex: 0,
|
||||
dataIndex: 0,
|
||||
},
|
||||
);
|
||||
}, [data.current]);
|
||||
|
||||
const worstConversionRateMatch = useMemo(() => {
|
||||
return data.current.reduce(
|
||||
(acc, serie, serieIndex) => {
|
||||
const serieMin = serie.data.reduce(
|
||||
(minInSerie, item, dataIndex) => {
|
||||
if (item.rate < minInSerie.rate) {
|
||||
return { rate: item.rate, serieIndex, dataIndex };
|
||||
}
|
||||
return minInSerie;
|
||||
},
|
||||
{ rate: 100, serieIndex, dataIndex: 0 },
|
||||
);
|
||||
|
||||
return serieMin.rate < acc.rate ? serieMin : acc;
|
||||
},
|
||||
{
|
||||
rate: 100,
|
||||
serieIndex: 0,
|
||||
dataIndex: 0,
|
||||
},
|
||||
);
|
||||
}, [data.current]);
|
||||
const bestConversionRate =
|
||||
data.current[bestConversionRateMatch.serieIndex]?.data[
|
||||
bestConversionRateMatch.dataIndex
|
||||
];
|
||||
const worstConversionRate =
|
||||
data.current[worstConversionRateMatch.serieIndex]?.data[
|
||||
worstConversionRateMatch.dataIndex
|
||||
];
|
||||
|
||||
const bestAverageConversionRateMatch = data.current.reduce(
|
||||
(acc, serie) => {
|
||||
const averageRate = average(serie.data.map((item) => item.rate));
|
||||
return averageRate > acc.averageRate ? { serie, averageRate } : acc;
|
||||
},
|
||||
{ serie: data.current[0], averageRate: 0 },
|
||||
);
|
||||
const worstAverageConversionRateMatch = data.current.reduce(
|
||||
(acc, serie) => {
|
||||
const averageRate = average(serie.data.map((item) => item.rate));
|
||||
return averageRate < acc.averageRate ? { serie, averageRate } : acc;
|
||||
},
|
||||
{ serie: data.current[0], averageRate: 100 },
|
||||
);
|
||||
|
||||
const averageConversionRate = average(
|
||||
data.current.map((serie) => {
|
||||
return average(serie.data.map((item) => item.rate));
|
||||
}, 0),
|
||||
);
|
||||
|
||||
const averageConversionRatePrevious =
|
||||
average(
|
||||
data.previous?.map((serie) => {
|
||||
return average(serie.data.map((item) => item.rate));
|
||||
}) ?? [],
|
||||
) ?? 0;
|
||||
|
||||
const sumConversions = data.current.reduce((acc, serie) => {
|
||||
return acc + sum(serie.data.map((item) => item.conversions));
|
||||
}, 0);
|
||||
const sumConversionsPrevious = data.previous?.reduce((acc, serie) => {
|
||||
return acc + sum(serie.data.map((item) => item.conversions));
|
||||
}, 0);
|
||||
|
||||
const hasManySeries = data.current.length > 1;
|
||||
|
||||
const getConversionRateNode = (
|
||||
item: RouterOutputs['chart']['conversion']['current'][0]['data'][0],
|
||||
) => {
|
||||
const breakdowns = item.serie.breakdowns.join(', ');
|
||||
if (breakdowns) {
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
On{' '}
|
||||
<span className="text-foreground">
|
||||
{item.serie.breakdowns.join(', ')}
|
||||
</span>{' '}
|
||||
with{' '}
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-foreground">
|
||||
{number.formatWithUnit(item.rate / 100, '%')}
|
||||
</span>{' '}
|
||||
at {formatDate(new Date(item.date))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
<StatsCard
|
||||
title="Flow"
|
||||
value={
|
||||
<div className="row flex-wrap gap-1">
|
||||
{report.events.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{bestAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Best breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
bestAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{worstAverageConversionRateMatch && hasManySeries && (
|
||||
<StatsCard
|
||||
title="Worst breakdown (avg)"
|
||||
value={
|
||||
<span>
|
||||
{worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||
<span className="text-muted-foreground">with</span>{' '}
|
||||
{number.formatWithUnit(
|
||||
worstAverageConversionRateMatch.averageRate / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<StatsCard
|
||||
title="Average conversion rate"
|
||||
value={number.formatWithUnit(averageConversionRate / 100, '%')}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
averageConversionRate,
|
||||
averageConversionRatePrevious,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total conversions"
|
||||
value={number.format(sumConversions)}
|
||||
enhancer={
|
||||
data.previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(sumConversions, sumConversionsPrevious)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{bestConversionRate && (
|
||||
<StatsCard
|
||||
title="Best conversion rate"
|
||||
value={getConversionRateNode(bestConversionRate)}
|
||||
/>
|
||||
)}
|
||||
{worstConversionRate && (
|
||||
<StatsCard
|
||||
title="Worst conversion rate"
|
||||
value={getConversionRateNode(worstConversionRate)}
|
||||
/>
|
||||
)}
|
||||
</Stats>
|
||||
);
|
||||
}
|
||||
392
apps/start/src/components/report-chart/funnel/chart.tsx
Normal file
392
apps/start/src/components/report-chart/funnel/chart.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
|
||||
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
|
||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { WidgetTable } from '@/components/widget-table';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { getPreviousMetric } from '@openpanel/common';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
current: RouterOutputs['chart']['funnel']['current'][number];
|
||||
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const Metric = ({
|
||||
label,
|
||||
value,
|
||||
enhancer,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
enhancer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<div className={cn('gap-1 justify-between flex-1 col', className)}>
|
||||
<div className="text-sm text-muted-foreground">{label}</div>
|
||||
<div className="row items-center gap-2 justify-between">
|
||||
<div className="font-mono font-semibold">{value}</div>
|
||||
{enhancer && <div>{enhancer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const number = useNumber();
|
||||
const highestConversion = data.current
|
||||
.slice(0)
|
||||
.sort((a, b) => b.lastStep.percent - a.lastStep.percent)[0];
|
||||
const highestCount = data.current
|
||||
.slice(0)
|
||||
.sort((a, b) => b.lastStep.count - a.lastStep.count)[0];
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{highestConversion && (
|
||||
<div className="card row items-center p-4 py-3">
|
||||
<Metric
|
||||
label="Highest conversion rate"
|
||||
value={
|
||||
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
||||
}
|
||||
/>
|
||||
<span className="text-xl font-semibold font-mono">
|
||||
{number.formatWithUnit(
|
||||
highestConversion.lastStep.percent / 100,
|
||||
'%',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{highestCount && (
|
||||
<div className="card row items-center p-4 py-3">
|
||||
<Metric
|
||||
label="Most conversions"
|
||||
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
||||
/>
|
||||
<span className="text-xl font-semibold font-mono">
|
||||
{number.format(highestCount.lastStep.count)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartName({
|
||||
breakdowns,
|
||||
className,
|
||||
}: { breakdowns: string[]; className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
||||
{breakdowns.map((name, index) => {
|
||||
return (
|
||||
<>
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span key={name}>{name}</span>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tables({
|
||||
data: {
|
||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||
previous,
|
||||
},
|
||||
}: Props) {
|
||||
const number = useNumber();
|
||||
const hasHeader = breakdowns.length > 0;
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
|
||||
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Conversion"
|
||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||
enhancer={
|
||||
previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.percent,
|
||||
previous.lastStep?.percent,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Completed"
|
||||
value={number.format(lastStep?.count)}
|
||||
enhancer={
|
||||
previous && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.count,
|
||||
previous.lastStep?.count,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!!mostDropoffsStep && (
|
||||
<Metric
|
||||
className="p-4 py-3"
|
||||
label="Most dropoffs after"
|
||||
value={mostDropoffsStep?.event?.displayName}
|
||||
enhancer={
|
||||
<Tooltiper
|
||||
tooltipClassName="max-w-xs"
|
||||
content={
|
||||
<span>
|
||||
<span className="font-semibold">
|
||||
{mostDropoffsStep?.dropoffCount}
|
||||
</span>{' '}
|
||||
dropped after this event. Improve this step and your
|
||||
conversion rate will likely increase.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon className="size-3" />
|
||||
</Tooltiper>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col divide-y divide-def-200">
|
||||
<WidgetTable
|
||||
data={steps}
|
||||
keyExtractor={(item) => item.event.id!}
|
||||
className={'text-sm @container'}
|
||||
columnClassName="px-2 group/row items-center"
|
||||
eachRow={(item, index) => {
|
||||
return (
|
||||
<div className="absolute inset-px !p-0">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full bg-def-300 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative',
|
||||
item.isHighestDropoff && [
|
||||
'bg-red-500/20',
|
||||
'group-hover/row:bg-red-500/70',
|
||||
],
|
||||
index === steps.length - 1 && 'rounded-bl-sm',
|
||||
)}
|
||||
style={{
|
||||
width: `${item.percent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
name: 'Event',
|
||||
render: (item, index) => (
|
||||
<div className="row items-center gap-2 row min-w-0 relative">
|
||||
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
||||
<span className="truncate">{item.event.displayName}</span>
|
||||
</div>
|
||||
),
|
||||
width: 'w-full',
|
||||
className: 'text-left font-mono font-semibold',
|
||||
},
|
||||
{
|
||||
name: 'Completed',
|
||||
render: (item) => number.format(item.count),
|
||||
className: 'text-right font-mono hidden @xl:block',
|
||||
width: '82px',
|
||||
},
|
||||
{
|
||||
name: 'Dropped after',
|
||||
render: (item) =>
|
||||
item.dropoffCount !== null && item.dropoffPercent !== null
|
||||
? number.format(item.dropoffCount)
|
||||
: null,
|
||||
className: 'text-right font-mono hidden @xl:block',
|
||||
width: '110px',
|
||||
},
|
||||
{
|
||||
name: 'Conversion',
|
||||
render: (item) => number.formatWithUnit(item.percent / 100, '%'),
|
||||
className: 'text-right font-mono font-semibold',
|
||||
width: '90px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RechartData = {
|
||||
name: string;
|
||||
[key: `step:percent:${number}`]: number | null;
|
||||
[key: `step:data:${number}`]:
|
||||
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
||||
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
||||
})
|
||||
| null;
|
||||
[key: `prev_step:percent:${number}`]: number | null;
|
||||
[key: `prev_step:data:${number}`]:
|
||||
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
||||
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
||||
})
|
||||
| null;
|
||||
};
|
||||
|
||||
const useRechartData = ({
|
||||
current,
|
||||
previous,
|
||||
}: RouterOutputs['chart']['funnel']): RechartData[] => {
|
||||
const firstFunnel = current[0];
|
||||
return (
|
||||
firstFunnel?.steps.map((step, stepIndex) => {
|
||||
return {
|
||||
name: step?.event.displayName ?? '',
|
||||
...current.reduce((acc, item, index) => {
|
||||
const diff = previous?.[index];
|
||||
return {
|
||||
...acc,
|
||||
[`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null,
|
||||
[`step:data:${index}`]: {
|
||||
...item,
|
||||
step: item.steps[stepIndex],
|
||||
},
|
||||
[`prev_step:percent:${index}`]:
|
||||
diff?.steps[stepIndex]?.percent ?? null,
|
||||
[`prev_step:data:${index}`]: diff
|
||||
? {
|
||||
...diff,
|
||||
step: diff?.steps?.[stepIndex],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}, {}),
|
||||
};
|
||||
}) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const rechartData = useRechartData(data);
|
||||
const xAxisProps = useXAxisProps();
|
||||
const yAxisProps = useYAxisProps();
|
||||
|
||||
return (
|
||||
<TooltipProvider data={data.current}>
|
||||
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={true}
|
||||
className="stroke-border"
|
||||
/>
|
||||
<XAxis
|
||||
{...xAxisProps}
|
||||
dataKey="name"
|
||||
allowDuplicatedCategory={false}
|
||||
type={'category'}
|
||||
scale="auto"
|
||||
domain={undefined}
|
||||
interval="preserveStartEnd"
|
||||
tickSize={0}
|
||||
tickMargin={4}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
{data.current.map((item, index) => (
|
||||
<Line
|
||||
stroke={getChartColor(index)}
|
||||
key={`step:percent:${item.id}`}
|
||||
dataKey={`step:percent:${index}`}
|
||||
type="linear"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
<Tooltip />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RechartData,
|
||||
Record<string, unknown>
|
||||
>(({ data: dataArray }) => {
|
||||
const data = dataArray[0]!;
|
||||
const number = useNumber();
|
||||
const variants = Object.keys(data).filter((key) =>
|
||||
key.startsWith('step:data:'),
|
||||
) as `step:data:${number}`[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.name}</div>
|
||||
</div>
|
||||
{variants.map((key, index) => {
|
||||
const variant = data[key];
|
||||
const prevVariant = data[`prev_${key}`];
|
||||
if (!variant?.step) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="row gap-2" key={key}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(index) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<ChartName breakdowns={variant.breakdowns ?? []} />
|
||||
</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="col gap-1">
|
||||
<span>
|
||||
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
({number.format(variant.step.count)})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
variant.step.percent,
|
||||
prevVariant?.step.percent,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
102
apps/start/src/components/report-chart/funnel/index.tsx
Normal file
102
apps/start/src/components/report-chart/funnel/index.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart, Summary, Tables } from './chart';
|
||||
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
startDate,
|
||||
endDate,
|
||||
previous,
|
||||
breakdowns,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
|
||||
const input: IChartInput = {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
chartType: 'funnel',
|
||||
breakdowns,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
previous,
|
||||
metric: 'sum',
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.funnel.queryOptions(input, {
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data.current.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
{res.data.current.length > 1 && <Summary data={res.data} />}
|
||||
<Chart data={res.data} />
|
||||
{res.data.current.map((item, index) => (
|
||||
<Tables
|
||||
key={item.id}
|
||||
data={{
|
||||
current: item,
|
||||
previous: res.data.previous?.[index] ?? null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
109
apps/start/src/components/report-chart/histogram/chart.tsx
Normal file
109
apps/start/src/components/report-chart/histogram/chart.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import React from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
const themeMode = useTheme();
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const def100 = styles.getPropertyValue('--def-100');
|
||||
const def300 = styles.getPropertyValue('--def-300');
|
||||
const bg = themeMode?.theme === 'dark' ? def100 : def300;
|
||||
return (
|
||||
<rect
|
||||
{...{ x, y, width, height, top, left, right, bottom }}
|
||||
rx="3"
|
||||
fill={bg}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { previous, interval },
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
});
|
||||
const xAxisProps = useXAxisProps({
|
||||
hide: hideXAxis,
|
||||
interval,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
className="stroke-def-200"
|
||||
/>
|
||||
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} scale={'auto'} type="category" />
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={`${serie.id}:prev`}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.3}
|
||||
radius={5}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
key={serie.id}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={5}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
apps/start/src/components/report-chart/histogram/index.tsx
Normal file
68
apps/start/src/components/report-chart/histogram/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportHistogramChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
75
apps/start/src/components/report-chart/index.tsx
Normal file
75
apps/start/src/components/report-chart/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
import React, { memo, type RefObject, useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import { shallowEqual } from 'react-redux';
|
||||
import { ReportAreaChart } from './area';
|
||||
import { ReportBarChart } from './bar';
|
||||
import type { ReportChartProps } from './context';
|
||||
import { ReportChartProvider } from './context';
|
||||
import { ReportConversionChart } from './conversion';
|
||||
import { ReportFunnelChart } from './funnel';
|
||||
import { ReportHistogramChart } from './histogram';
|
||||
import { ReportLineChart } from './line';
|
||||
import { ReportMapChart } from './map';
|
||||
import { ReportMetricChart } from './metric';
|
||||
import { ReportPieChart } from './pie';
|
||||
import { ReportRetentionChart } from './retention';
|
||||
|
||||
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const once = useRef(false);
|
||||
const { inViewport } = useInViewport(
|
||||
ref as RefObject<HTMLElement>,
|
||||
undefined,
|
||||
{
|
||||
disconnectOnLeave: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (inViewport) {
|
||||
once.current = true;
|
||||
}
|
||||
}, [inViewport]);
|
||||
|
||||
const loaded = lazy ? once.current || inViewport : true;
|
||||
|
||||
const renderReportChart = () => {
|
||||
switch (props.report.chartType) {
|
||||
case 'linear':
|
||||
return <ReportLineChart />;
|
||||
case 'bar':
|
||||
return <ReportBarChart />;
|
||||
case 'area':
|
||||
return <ReportAreaChart />;
|
||||
case 'histogram':
|
||||
return <ReportHistogramChart />;
|
||||
case 'pie':
|
||||
return <ReportPieChart />;
|
||||
case 'map':
|
||||
return <ReportMapChart />;
|
||||
case 'metric':
|
||||
return <ReportMetricChart />;
|
||||
case 'funnel':
|
||||
return <ReportFunnelChart />;
|
||||
case 'retention':
|
||||
return <ReportRetentionChart />;
|
||||
case 'conversion':
|
||||
return <ReportConversionChart />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<ReportChartProvider
|
||||
{...mergeDeepRight({ options: {}, isEditMode: false }, props)}
|
||||
isLazyLoading={!loaded}
|
||||
>
|
||||
{renderReportChart()}
|
||||
</ReportChartProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
301
apps/start/src/components/report-chart/line/chart.tsx
Normal file
301
apps/start/src/components/report-chart/line/chart.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { last } from 'ramda';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
Legend,
|
||||
Line,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
} = useReportChartContext();
|
||||
const dataLength = data.series[0]?.data?.length || 0;
|
||||
const trpc = useTRPC();
|
||||
const references = useQuery(
|
||||
trpc.reference.getChartReferences.queryOptions(
|
||||
{
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 10,
|
||||
},
|
||||
),
|
||||
);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
|
||||
let dotIndex = undefined;
|
||||
if (range === 'today') {
|
||||
// Find closest index based on times
|
||||
dotIndex = rechartData.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
|
||||
const lastSerieDataItem = last(series[0]?.data || [])?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (range === 'today') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'day') {
|
||||
return isSameDay(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'month') {
|
||||
return isSameMonth(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'week') {
|
||||
return isSameWeek(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
||||
useDashedStroke({
|
||||
dotIndex,
|
||||
});
|
||||
|
||||
const CustomLegend = useCallback(() => {
|
||||
return (
|
||||
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||
{series.map((serie) => (
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
key={serie.id}
|
||||
style={{
|
||||
color: getChartColor(serie.index),
|
||||
}}
|
||||
>
|
||||
<SerieIcon name={serie.names} />
|
||||
<SerieName name={serie.names} className="font-semibold" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [series]);
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'#94a3b8'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
{/* {series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<React.Fragment key={serie.id}>
|
||||
<defs>
|
||||
{isAreaStyle && (
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
)}
|
||||
</defs>
|
||||
<Line
|
||||
dot={isAreaStyle && dataLength <= 8}
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.id}:count`}
|
||||
stroke={color}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
{previous && (
|
||||
<Line
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
isAnimationActive
|
||||
dot={false}
|
||||
strokeOpacity={0.3}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})} */}
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={serie.id}
|
||||
dot={dataLength <= 8}
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.id}:count`}
|
||||
stroke={color}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
filter="url(#rainbow-line-glow)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Previous */}
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={`${serie.id}:prev`}
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
isAnimationActive
|
||||
dot={false}
|
||||
strokeOpacity={0.3}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
apps/start/src/components/report-chart/line/index.tsx
Normal file
69
apps/start/src/components/report-chart/line/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportLineChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
48
apps/start/src/components/report-chart/map/chart.tsx
Normal file
48
apps/start/src/components/report-chart/map/chart.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { useMemo } from 'react';
|
||||
import WorldMap from 'react-svg-worldmap';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: { metric, unit },
|
||||
} = useReportChartContext();
|
||||
const { series } = useVisibleSeries(data, 100);
|
||||
const [filters, setFilter] = useEventQueryFilters();
|
||||
|
||||
const mapData = useMemo(
|
||||
() =>
|
||||
series.map((s) => ({
|
||||
country: s.names[0]?.toLowerCase() ?? '',
|
||||
value: s.metrics[metric],
|
||||
})),
|
||||
[series, metric],
|
||||
);
|
||||
|
||||
return (
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => (
|
||||
<WorldMap
|
||||
onClickFunction={(event) => {
|
||||
if (event.countryCode) {
|
||||
setFilter('country', event.countryCode);
|
||||
}
|
||||
}}
|
||||
size={width}
|
||||
data={mapData}
|
||||
color={'var(--chart-0)'}
|
||||
borderColor={'var(--foreground)'}
|
||||
value-suffix={unit}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
64
apps/start/src/components/report-chart/map/index.tsx
Normal file
64
apps/start/src/components/report-chart/map/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMapChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return <Chart data={res.data} />;
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
37
apps/start/src/components/report-chart/metric/chart.tsx
Normal file
37
apps/start/src/components/report-chart/metric/chart.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useReportChartContext } from '../context';
|
||||
import { MetricCard } from './metric-card';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { metric, unit },
|
||||
} = useReportChartContext();
|
||||
const { series } = useVisibleSeries(data, isEditMode ? 20 : 4);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-4',
|
||||
isEditMode && 'md:grid-cols-2 lg:grid-cols-3',
|
||||
)}
|
||||
>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<MetricCard
|
||||
key={serie.id}
|
||||
serie={serie}
|
||||
metric={metric}
|
||||
unit={unit}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
74
apps/start/src/components/report-chart/metric/index.tsx
Normal file
74
apps/start/src/components/report-chart/metric/index.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportMetricChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return <Chart data={res.data} />;
|
||||
}
|
||||
|
||||
export function Loading() {
|
||||
return (
|
||||
<div className="flex h-[78px] flex-col justify-between p-4">
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-def-200" />
|
||||
<div className="row items-end justify-between">
|
||||
<div className="h-6 w-1/3 animate-pulse rounded bg-def-200" />
|
||||
<div className="h-3 w-1/5 animate-pulse rounded bg-def-200" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Error() {
|
||||
return (
|
||||
<div className="relative h-[70px]">
|
||||
<div className="opacity-50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||
<div className="text-sm font-medium">Error fetching data</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty() {
|
||||
return (
|
||||
<div className="relative h-[70px]">
|
||||
<div className="opacity-50">
|
||||
<Loading />
|
||||
</div>
|
||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
||||
<div className="text-sm font-medium">No data</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/start/src/components/report-chart/metric/metric-card.tsx
Normal file
143
apps/start/src/components/report-chart/metric/metric-card.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { Area, AreaChart } from 'recharts';
|
||||
|
||||
import type { IChartMetric } from '@openpanel/validation';
|
||||
|
||||
import {
|
||||
PreviousDiffIndicator,
|
||||
getDiffIndicator,
|
||||
} from '../common/previous-diff-indicator';
|
||||
import { SerieName } from '../common/serie-name';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface MetricCardProps {
|
||||
serie: IChartData['series'][number];
|
||||
color?: string;
|
||||
metric: IChartMetric;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export function MetricCard({
|
||||
serie,
|
||||
color: _color,
|
||||
metric,
|
||||
unit,
|
||||
}: MetricCardProps) {
|
||||
const {
|
||||
report: { previousIndicatorInverted },
|
||||
isEditMode,
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
|
||||
const renderValue = (value: number, unitClassName?: string) => {
|
||||
if (unit === 'min') {
|
||||
return <>{fancyMinutes(value)}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{number.short(value)}
|
||||
{unit && <span className={unitClassName}>{unit}</span>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const previous = serie.metrics.previous?.[metric];
|
||||
|
||||
const graphColors = getDiffIndicator(
|
||||
previousIndicatorInverted,
|
||||
previous?.state,
|
||||
'#6ee7b7', // green
|
||||
'#fda4af', // red
|
||||
'#93c5fd', // blue
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative p-4', isEditMode && 'card h-auto')}
|
||||
key={serie.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
||||
)}
|
||||
>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={height / 4}
|
||||
data={serie.data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`colorUv${serie.id}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={graphColors} stopOpacity={0.2} />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={graphColors}
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
dataKey="count"
|
||||
type="step"
|
||||
fill={`url(#colorUv${serie.id})`}
|
||||
fillOpacity={1}
|
||||
stroke={graphColors}
|
||||
strokeWidth={1}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<MetricCardNumber
|
||||
label={<SerieName name={serie.names} />}
|
||||
value={renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
||||
enhancer={
|
||||
<PreviousDiffIndicator
|
||||
{...previous}
|
||||
className="text-sm text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardNumber({
|
||||
label,
|
||||
value,
|
||||
enhancer,
|
||||
className,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
value: React.ReactNode;
|
||||
enhancer?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||
<span className="truncate text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div className="truncate font-mono text-3xl font-bold">{value}</div>
|
||||
{enhancer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
apps/start/src/components/report-chart/pie/chart.tsx
Normal file
126
apps/start/src/components/report-chart/pie/chart.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { round } from '@/utils/math';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { truncate } from '@/utils/truncate';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
import { AXIS_FONT_PROPS } from '../common/axis';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
interface Props {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const { isEditMode } = useReportChartContext();
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
|
||||
const pieData = series.map((serie) => ({
|
||||
id: serie.id,
|
||||
color: getChartColor(serie.index),
|
||||
index: serie.index,
|
||||
name: serie.names.join(' > '),
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('h-full w-full max-sm:-mx-3', isEditMode && 'card p-4')}
|
||||
>
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<Pie
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={'50%'}
|
||||
outerRadius={'80%'}
|
||||
isAnimationActive={false}
|
||||
label={renderLabel}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
return (
|
||||
<Cell
|
||||
key={item.id}
|
||||
strokeWidth={2}
|
||||
stroke={item.color}
|
||||
fill={item.color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const renderLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
fill,
|
||||
payload,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
fill: string;
|
||||
payload: { name: string; percent: number };
|
||||
}) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = 25 + innerRadius + (outerRadius - innerRadius);
|
||||
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
|
||||
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
|
||||
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
const name = payload.name;
|
||||
const percent = round(payload.percent * 100, 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
x={xProcent}
|
||||
y={yProcent}
|
||||
fill="white"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontWeight={700}
|
||||
pointerEvents={'none'}
|
||||
{...AXIS_FONT_PROPS}
|
||||
>
|
||||
{percent}%
|
||||
</text>
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill={fill}
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
{...AXIS_FONT_PROPS}
|
||||
>
|
||||
{truncate(name, 20)}
|
||||
</text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
68
apps/start/src/components/report-chart/pie/index.tsx
Normal file
68
apps/start/src/components/report-chart/pie/index.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
|
||||
export function ReportPieChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(
|
||||
trpc.chart.chart.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: !isLazyLoading,
|
||||
}),
|
||||
);
|
||||
|
||||
if (
|
||||
isLazyLoading ||
|
||||
res.isLoading ||
|
||||
(res.isFetching && !res.data?.series.length)
|
||||
) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.series.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
116
apps/start/src/components/report-chart/report-editor.tsx
Normal file
116
apps/start/src/components/report-chart/report-editor.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ReportChart } from '@/components/report-chart';
|
||||
import { ReportChartType } from '@/components/report/ReportChartType';
|
||||
import { ReportInterval } from '@/components/report/ReportInterval';
|
||||
import { ReportLineType } from '@/components/report/ReportLineType';
|
||||
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
|
||||
import {
|
||||
changeChartType,
|
||||
changeDateRanges,
|
||||
changeEndDate,
|
||||
changeInterval,
|
||||
changeStartDate,
|
||||
ready,
|
||||
reset,
|
||||
setName,
|
||||
setReport,
|
||||
} from '@/components/report/reportSlice';
|
||||
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
|
||||
import { TimeWindowPicker } from '@/components/time-window-picker';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { bind } from 'bind-event-listener';
|
||||
import { GanttChartSquareIcon } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import type { IServiceReport } from '@openpanel/db';
|
||||
|
||||
interface ReportEditorProps {
|
||||
report: IServiceReport | null;
|
||||
}
|
||||
|
||||
export default function ReportEditor({
|
||||
report: initialReport,
|
||||
}: ReportEditorProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const dispatch = useDispatch();
|
||||
const report = useSelector((state) => state.report);
|
||||
|
||||
// Set report if reportId exists
|
||||
useEffect(() => {
|
||||
if (initialReport) {
|
||||
dispatch(setReport(initialReport));
|
||||
} else {
|
||||
dispatch(ready());
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(reset());
|
||||
};
|
||||
}, [initialReport, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
return bind(window, {
|
||||
type: 'report-name-change',
|
||||
listener: (event) => {
|
||||
if (event instanceof CustomEvent && typeof event.detail === 'string') {
|
||||
dispatch(setName(event.detail));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 md:grid-cols-6">
|
||||
<SheetTrigger asChild>
|
||||
<div>
|
||||
<Button icon={GanttChartSquareIcon} variant="cta">
|
||||
Pick events
|
||||
</Button>
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<div className="col-span-4 grid grid-cols-2 gap-2 md:grid-cols-4">
|
||||
<ReportChartType
|
||||
className="min-w-0 flex-1"
|
||||
onChange={(type) => {
|
||||
dispatch(changeChartType(type));
|
||||
}}
|
||||
value={report.chartType}
|
||||
/>
|
||||
<TimeWindowPicker
|
||||
className="min-w-0 flex-1"
|
||||
onChange={(value) => {
|
||||
dispatch(changeDateRanges(value));
|
||||
}}
|
||||
value={report.range}
|
||||
onStartDateChange={(date) => dispatch(changeStartDate(date))}
|
||||
onEndDateChange={(date) => dispatch(changeEndDate(date))}
|
||||
endDate={report.endDate}
|
||||
startDate={report.startDate}
|
||||
/>
|
||||
<ReportInterval
|
||||
className="min-w-0 flex-1"
|
||||
interval={report.interval}
|
||||
onChange={(newInterval) => dispatch(changeInterval(newInterval))}
|
||||
range={report.range}
|
||||
chartType={report.chartType}
|
||||
/>
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
<div className="col-start-2 row-start-1 text-right md:col-start-6">
|
||||
<ReportSaveButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 p-4" id="report-editor">
|
||||
{report.ready && (
|
||||
<ReportChart report={{ ...report, projectId }} isEditMode />
|
||||
)}
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg" side="left">
|
||||
<ReportSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
110
apps/start/src/components/report-chart/retention/chart.tsx
Normal file
110
apps/start/src/components/report-chart/retention/chart.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { average, round } from '@openpanel/common';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { RetentionTooltip } from './tooltip';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['cohort'];
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
hide: hideYAxis,
|
||||
tickFormatter: (value) => `${value}%`,
|
||||
});
|
||||
const averageRow = data[0];
|
||||
const averageRetentionRate =
|
||||
average(averageRow?.percentages || [], true) * 100;
|
||||
const rechartData = averageRow?.percentages.map((item, index) => ({
|
||||
days: index,
|
||||
percentage: item * 100,
|
||||
value: averageRow.values?.[index],
|
||||
sum: averageRow.sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={true}
|
||||
className="stroke-border"
|
||||
/>
|
||||
<YAxis {...yAxisProps} dataKey="retentionRate" domain={[0, 100]} />
|
||||
<XAxis
|
||||
{...xAxisProps}
|
||||
dataKey="days"
|
||||
allowDuplicatedCategory
|
||||
scale="linear"
|
||||
tickFormatter={(value) => value.toString()}
|
||||
tickCount={31}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip content={<RetentionTooltip />} />
|
||||
<defs>
|
||||
<linearGradient id={'color'} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ReferenceLine
|
||||
y={averageRetentionRate}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Average (${round(averageRetentionRate, 2)} %)`,
|
||||
fill: getChartColor(1),
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="percentage"
|
||||
fill={'url(#color)'}
|
||||
type={'monotone'}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
stroke={getChartColor(0)}
|
||||
fillOpacity={0.1}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
108
apps/start/src/components/report-chart/retention/index.tsx
Normal file
108
apps/start/src/components/report-chart/retention/index.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AspectContainer } from '../aspect-container';
|
||||
import { ReportChartEmpty } from '../common/empty';
|
||||
import { ReportChartError } from '../common/error';
|
||||
import { ReportChartLoading } from '../common/loading';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { Chart } from './chart';
|
||||
import CohortTable from './table';
|
||||
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||
const isEnabled =
|
||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.cohort.queryOptions(
|
||||
{
|
||||
firstEvent,
|
||||
secondEvent,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
enabled: isEnabled,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
return <Disabled />;
|
||||
}
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (!res.data || res.data?.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
<CohortTable data={res.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty title="Select 2 events">
|
||||
We need two events to determine the retention rate.
|
||||
</ReportChartEmpty>
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
132
apps/start/src/components/report-chart/retention/table.tsx
Normal file
132
apps/start/src/components/report-chart/retention/table.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { max, min } from '@openpanel/common';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type CohortData = RouterOutputs['chart']['cohort'];
|
||||
|
||||
type CohortTableProps = {
|
||||
data: CohortData;
|
||||
};
|
||||
|
||||
const CohortTable: React.FC<CohortTableProps> = ({ data }) => {
|
||||
const {
|
||||
report: { unit, interval },
|
||||
} = useReportChartContext();
|
||||
const isPercentage = unit === '%';
|
||||
const number = useNumber();
|
||||
const highestValue = max(data.map((row) => max(row.values)));
|
||||
const lowestValue = min(data.map((row) => min(row.values)));
|
||||
const rowWithHigestSum = data.find(
|
||||
(row) => row.sum === max(data.map((row) => row.sum)),
|
||||
);
|
||||
|
||||
const getBackground = (value: number | undefined) => {
|
||||
if (!value)
|
||||
return {
|
||||
backgroundClassName: '',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const percentage = isPercentage
|
||||
? value / 100
|
||||
: (value - lowestValue) / (highestValue - lowestValue);
|
||||
const opacity = Math.max(0.05, percentage);
|
||||
|
||||
return {
|
||||
backgroundClassName: 'bg-highlight dark:bg-emerald-700',
|
||||
opacity,
|
||||
};
|
||||
};
|
||||
|
||||
const thClassName =
|
||||
'h-10 align-top pt-3 whitespace-nowrap font-semibold text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="relative card overflow-hidden">
|
||||
<div
|
||||
className={'h-10 absolute left-0 right-0 top-px bg-def-100 border-b'}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto hide-scrollbar">
|
||||
<div className="min-w-full relative">
|
||||
<table className="w-full table-auto whitespace-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(thClassName, 'sticky left-0 z-10')}>
|
||||
<div className="bg-def-100">
|
||||
<div className="h-10 center-center -mt-3">Date</div>
|
||||
</div>
|
||||
</th>
|
||||
<th className={cn(thClassName, 'pr-1')}>Total profiles</th>
|
||||
{data[0]?.values.map((column, index) => (
|
||||
<th
|
||||
key={index.toString()}
|
||||
className={cn(thClassName, 'capitalize')}
|
||||
>
|
||||
{index === 0 ? `< ${interval} 1` : `${interval} ${index}`}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const values = isPercentage ? row.percentages : row.values;
|
||||
return (
|
||||
<tr key={row.cohort_interval}>
|
||||
<td className="sticky left-0 bg-card z-10 w-36 p-0">
|
||||
<div className="h-10 center-center font-medium text-muted-foreground px-4">
|
||||
{row.cohort_interval}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-0 min-w-12">
|
||||
<div className={cn('font-mono rounded px-3 font-medium')}>
|
||||
{number.format(row?.sum)}
|
||||
{row.cohort_interval ===
|
||||
rowWithHigestSum?.cohort_interval && ' 🚀'}
|
||||
</div>
|
||||
</td>
|
||||
{values.map((value, index) => {
|
||||
const { opacity, backgroundClassName } =
|
||||
getBackground(value);
|
||||
return (
|
||||
<td
|
||||
key={row.cohort_interval + index.toString()}
|
||||
className="p-0 min-w-24"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-10 center-center font-mono hover:shadow-[inset_0_0_0_2px_rgb(255,255,255)] relative',
|
||||
opacity > 0.7 &&
|
||||
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
backgroundClassName,
|
||||
'w-full h-full inset-0 absolute',
|
||||
)}
|
||||
style={{
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
{number.formatWithUnit(value, unit)}
|
||||
{value === highestValue && ' 🚀'}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CohortTable;
|
||||
47
apps/start/src/components/report-chart/retention/tooltip.tsx
Normal file
47
apps/start/src/components/report-chart/retention/tooltip.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type Props = {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: any;
|
||||
}>;
|
||||
};
|
||||
export function RetentionTooltip({ active, payload }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { days, percentage, value, sum } = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{interval} {days}
|
||||
</h3>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retention Rate:</span>
|
||||
<span className="font-medium">
|
||||
{number.formatWithUnit(percentage / 100, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retained Users:</span>
|
||||
<span className="font-medium">{number.format(value)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Users:</span>
|
||||
<span className="font-medium">{number.format(sum)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/start/src/components/report-chart/shortcut.tsx
Normal file
43
apps/start/src/components/report-chart/shortcut.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ReportChart } from '.';
|
||||
import type { ReportChartProps } from './context';
|
||||
|
||||
type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
|
||||
projectId: ReportChartProps['report']['projectId'];
|
||||
range?: ReportChartProps['report']['range'];
|
||||
previous?: ReportChartProps['report']['previous'];
|
||||
chartType?: ReportChartProps['report']['chartType'];
|
||||
interval?: ReportChartProps['report']['interval'];
|
||||
events: ReportChartProps['report']['events'];
|
||||
breakdowns?: ReportChartProps['report']['breakdowns'];
|
||||
lineType?: ReportChartProps['report']['lineType'];
|
||||
};
|
||||
|
||||
export const ReportChartShortcut = ({
|
||||
projectId,
|
||||
range = '7d',
|
||||
previous = false,
|
||||
chartType = 'linear',
|
||||
interval = 'day',
|
||||
events,
|
||||
breakdowns,
|
||||
lineType = 'monotone',
|
||||
options,
|
||||
}: ChartRootShortcutProps) => {
|
||||
return (
|
||||
<ReportChart
|
||||
report={{
|
||||
name: 'Shortcut',
|
||||
projectId,
|
||||
range,
|
||||
breakdowns: breakdowns ?? [],
|
||||
previous,
|
||||
chartType,
|
||||
interval,
|
||||
events,
|
||||
lineType,
|
||||
metric: 'sum',
|
||||
}}
|
||||
options={options ?? {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user