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:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
};

View 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>
);
}

View 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>
);
}

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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> */}
</>
);
}

View File

@@ -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;

View 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;
}

View 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;

View 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}</>;
}

View 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;

View 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>
);
})}
</>
);
});

View 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>
);
}

View 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>
);
}

View 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>
);
})}
</>
);
});

View 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>
);
}

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
};

View 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}
/>
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
};

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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;

View 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>
);
}

View 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 ?? {}}
/>
);
};