temp move

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-19 23:22:59 +01:00
parent 303c4060f3
commit e4643ce63e
249 changed files with 9 additions and 2 deletions

View File

@@ -1,113 +0,0 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider';
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;
}
export function PreviousDiffIndicator({
diff,
state,
children,
}: PreviousDiffIndicatorProps) {
const { previous, previousIndicatorInverted } = useChartContext();
const variant = getDiffIndicator(
previousIndicatorInverted,
state,
'success',
'destructive',
undefined
);
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return children ?? null;
}
const renderIcon = () => {
if (state === 'positive') {
return <TrendingUpIcon size={15} />;
}
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
}
return null;
};
return (
<>
<Badge className="flex gap-1" variant={variant}>
{renderIcon()}
{number.format(diff)}%
</Badge>
{children}
</>
);
}
export function PreviousDiffIndicatorText({
diff,
state,
className,
}: PreviousDiffIndicatorProps & { className?: string }) {
const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return null;
}
const renderIcon = () => {
if (state === 'positive') {
return <TrendingUpIcon size={15} />;
}
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
}
return null;
};
return (
<div
className={cn([
'flex gap-0.5 items-center',
getDiffIndicator(
previousIndicatorInverted,
state,
'text-emerald-600',
'text-red-600',
undefined
),
className,
])}
>
{renderIcon()}
{number.short(diff)}%
</div>
);
}

View File

@@ -1,32 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { LineChartIcon } from 'lucide-react';
import { chartTypes } from '@openpanel/constants';
import { objectToZodEnums } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeChartType } from './reportSlice';
interface ReportChartTypeProps {
className?: string;
}
export function ReportChartType({ className }: ReportChartTypeProps) {
const dispatch = useDispatch();
const type = useSelector((state) => state.report.chartType);
return (
<Combobox
icon={LineChartIcon}
className={className}
placeholder="Chart type"
onChange={(value) => {
dispatch(changeChartType(value));
}}
value={type}
items={objectToZodEnums(chartTypes).map((key) => ({
label: chartTypes[key],
value: key,
}))}
/>
);
}

View File

@@ -1,66 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { ClockIcon } from 'lucide-react';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import type { IInterval } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
interface ReportIntervalProps {
className?: string;
}
export function ReportInterval({ className }: ReportIntervalProps) {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const chartType = useSelector((state) => state.report.chartType);
if (
chartType !== 'linear' &&
chartType !== 'histogram' &&
chartType !== 'area' &&
chartType !== 'metric'
) {
return null;
}
return (
<Combobox
icon={ClockIcon}
className={className}
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value));
}}
value={interval}
items={[
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',
label: 'Day',
},
{
value: 'month',
label: 'Month',
disabled:
range === 'today' ||
range === '24h' ||
range === '1h' ||
range === '30min',
},
]}
/>
);
}

View File

@@ -1,37 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { Tv2Icon } from 'lucide-react';
import { lineTypes } from '@openpanel/constants';
import { objectToZodEnums } from '@openpanel/validation';
import { Combobox } from '../ui/combobox';
import { changeLineType } from './reportSlice';
interface ReportLineTypeProps {
className?: string;
}
export function ReportLineType({ className }: ReportLineTypeProps) {
const dispatch = useDispatch();
const chartType = useSelector((state) => state.report.chartType);
const type = useSelector((state) => state.report.lineType);
if (chartType != 'linear' && chartType != 'area') {
return null;
}
return (
<Combobox
icon={Tv2Icon}
className={className}
placeholder="Line type"
onChange={(value) => {
dispatch(changeLineType(value));
}}
value={type}
items={objectToZodEnums(lineTypes).map((key) => ({
label: lineTypes[key],
value: key,
}))}
/>
);
}

View File

@@ -1,100 +0,0 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { cn } from '@/utils/cn';
import { format } from 'date-fns';
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
import type { SelectRangeEventHandler } from 'react-day-picker';
import { timeRanges } from '@openpanel/constants';
import type { IChartRange } from '@openpanel/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
export function ReportRange({
range,
onRangeChange,
onDatesChange,
dates,
className,
...props
}: {
range: IChartRange;
onRangeChange: (range: IChartRange) => void;
onDatesChange: SelectRangeEventHandler;
dates: { startDate: string | null; endDate: string | null };
} & Omit<ExtendedComboboxProps<string>, 'value' | 'onChange'>) {
const { isBelowSm } = useBreakpoint('sm');
return (
<>
<Popover>
<PopoverTrigger asChild>
<Button
id="date"
variant={'outline'}
className={cn('justify-start text-left font-normal', className)}
icon={CalendarIcon}
{...props}
>
<span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
{dates.startDate ? (
dates.endDate ? (
<>
{format(dates.startDate, 'LLL dd')} -{' '}
{format(dates.endDate, 'LLL dd')}
</>
) : (
format(dates.startDate, 'LLL dd, y')
)
) : (
<span>{range}</span>
)}
</span>
<ChevronsUpDownIcon className="ml-auto h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 border-b border-border">
<ToggleGroup
value={range}
onValueChange={(value) => {
if (value) onRangeChange(value as IChartRange);
}}
type="single"
variant="outline"
className="flex-wrap max-sm:max-w-xs"
>
{Object.values(timeRanges).map((key) => (
<ToggleGroupItem value={key} aria-label={key} key={key}>
{key}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<Calendar
initialFocus
mode="range"
defaultMonth={
dates.startDate ? new Date(dates.startDate) : new Date()
}
selected={{
from: dates.startDate ? new Date(dates.startDate) : undefined,
to: dates.endDate ? new Date(dates.endDate) : undefined,
}}
onSelect={onDatesChange}
numberOfMonths={isBelowSm ? 1 : 2}
className="[&_table]:mx-auto [&_table]:w-auto"
/>
</PopoverContent>
</Popover>
</>
);
}

View File

@@ -1,63 +0,0 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react';
import { toast } from 'sonner';
import { resetDirty } from './reportSlice';
interface ReportSaveButtonProps {
className?: string;
}
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
const { reportId } = useAppParams<{ reportId: string | undefined }>();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
onSuccess() {
dispatch(resetDirty());
toast('Success', {
description: 'Report updated.',
});
},
onError: handleError,
});
const report = useSelector((state) => state.report);
if (reportId) {
return (
<Button
className={className}
disabled={!report.dirty}
loading={update.isLoading}
onClick={() => {
update.mutate({
reportId: reportId,
report,
});
}}
icon={SaveIcon}
>
Update
</Button>
);
} else {
return (
<Button
className={className}
disabled={!report.dirty}
onClick={() => {
pushModal('SaveReport', {
report,
});
}}
icon={SaveIcon}
>
Save
</Button>
);
}
}

View File

@@ -1,106 +0,0 @@
'use client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@openpanel/validation';
import { ChartEmpty } from './ChartEmpty';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
export function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
startDate,
endDate,
}: ReportChartProps) {
const [references] = api.reference.getChartReferences.useSuspenseQuery({
projectId,
startDate,
endDate,
range,
});
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate,
endDate,
projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: true,
}
);
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={data}
references={references}
/>
);
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return <p>Unknown chart type</p>;
}

View File

@@ -1,32 +0,0 @@
import airplane from '@/lottie/airplane.json';
import ballon from '@/lottie/ballon.json';
import noData from '@/lottie/no-data.json';
import { cn } from '@/utils/cn';
import type { LottieComponentProps } from 'lottie-react';
import Lottie from 'lottie-react';
const animations = {
airplane,
ballon,
noData,
};
type Animations = keyof typeof animations;
export const ChartAnimation = ({
name,
...props
}: Omit<LottieComponentProps, 'animationData'> & {
name: Animations;
}) => <Lottie animationData={animations[name]} loop={true} {...props} />;
export const ChartAnimationContainer = (
props: React.ButtonHTMLAttributes<HTMLDivElement>
) => (
<div
{...props}
className={cn(
'border border-border rounded-md p-8 bg-white',
props.className
)}
/>
);

View File

@@ -1,31 +0,0 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCardEmpty } from './MetricCard';
export function ChartEmpty() {
const { editMode, chartType } = useChartContext();
if (editMode) {
return (
<FullPageEmptyState title="No data">
We could not find any data for selected events and filter.
</FullPageEmptyState>
);
}
if (chartType === 'metric') {
return <MetricCardEmpty />;
}
return (
<div
className={
'aspect-video w-full max-h-[300px] min-h-[200px] flex justify-center items-center'
}
>
No data
</div>
);
}

View File

@@ -1,15 +0,0 @@
import { cn } from '@/utils/cn';
interface ChartLoadingProps {
className?: string;
}
export function ChartLoading({ className }: ChartLoadingProps) {
return (
<div
className={cn(
'aspect-video w-full bg-slate-200 animate-pulse rounded max-h-[300px] min-h-[200px]',
className
)}
/>
);
}

View File

@@ -1,110 +0,0 @@
'use client';
import {
createContext,
memo,
Suspense,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@openpanel/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;
}
type ChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
});
export function ChartProvider({
children,
editMode,
previous,
hideID,
...props
}: ChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
...props,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
}),
[editMode, previous, hideID, props]
)}
>
{children}
</ChartContext.Provider>
);
}
export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps>
) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
)
}
>
<ChartProvider {...props}>
<WrappedComponent {...props} />
</ChartProvider>
</Suspense>
);
};
WithChartProvider.displayName = `WithChartProvider(${
WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'
})`;
return memo(WithChartProvider);
}
export function useChartContext() {
return useContext(ChartContext)!;
}

View File

@@ -1,33 +0,0 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { ChartSwitch } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
return (
<div ref={ref}>
{once.current || inViewport ? (
<ChartSwitch {...props} editMode={false} />
) : (
<ChartLoading />
)}
</div>
);
}

View File

@@ -1,124 +0,0 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/color-square';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import type { IChartMetric } from '@openpanel/validation';
import {
getDiffIndicator,
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
interface MetricCardProps {
serie: IChartData['series'][number];
color?: string;
metric: IChartMetric;
unit?: string;
}
export function MetricCard({
serie,
color: _color,
metric,
unit,
}: MetricCardProps) {
const { previousIndicatorInverted } = useChartContext();
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,
'green',
'red',
'blue'
);
return (
<div
className="group relative card p-4 overflow-hidden h-24"
key={serie.name}
>
<div className="absolute inset-0 -left-1 -right-1 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 4}
data={serie.data}
style={{ marginTop: (height / 4) * 3 }}
>
<Area
dataKey="count"
type="monotone"
fill={`transparent`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium text-left min-w-0">
<ColorSquare>{serie.event.id}</ColorSquare>
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
{serie.name || serie.event.displayName || serie.event.name}
</span>
</div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
</div>
<div className="flex justify-between items-end mt-2">
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
</div>
<PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="font-medium text-xs mb-0.5"
/>
</div>
</div>
</div>
);
}
export function MetricCardEmpty() {
return (
<div className="card p-4 h-24">
<div className="flex items-center justify-center h-full text-slate-600">
No data
</div>
</div>
);
}
export function MetricCardLoading() {
return (
<div className="h-24 p-4 py-5 flex flex-col card">
<div className="bg-slate-200 rounded animate-pulse h-4 w-1/2"></div>
<div className="bg-slate-200 rounded animate-pulse h-6 w-1/5 mt-auto"></div>
</div>
);
}

View File

@@ -1,120 +0,0 @@
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportAreaChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportAreaChart({
lineType,
interval,
data,
}: ReportAreaChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart width={width} height={height} data={rechartData}>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
tickFormatter={number.short}
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.name}>
<defs>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<Area
key={serie.name}
type={lineType}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={color}
fill={`url(#color${color})`}
stackId={'1'}
fillOpacity={1}
/>
</React.Fragment>
);
})}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
</AreaChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,134 +0,0 @@
'use client';
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Progress } from '@/components/ui/progress';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, onClick } = useChartContext();
const number = useNumber();
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, 10)),
[data, editMode]
);
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
return (
<div
className={cn(
'flex flex-col w-full text-xs -mx-2',
editMode && 'text-base card p-4'
)}
>
{series.map((serie, index) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return (
<div
key={serie.name}
className={cn(
'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 rounded overflow-hidden',
'[&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm',
isClickable && 'cursor-pointer hover:!bg-slate-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all flex items-center gap-2 font-medium">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous[metric]?.value}
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
<Progress
color={getChartColor(index)}
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
value={(serie.metrics.sum / maxCount) * 100}
/>
</div>
</div>
);
})}
</div>
);
// return (
// <Table
// overflow={editMode}
// className={cn('table-fixed', editMode ? '' : 'mini')}
// >
// <TableHeader>
// {table.getHeaderGroups().map((headerGroup) => (
// <TableRow key={headerGroup.id}>
// {headerGroup.headers.map((header) => (
// <TableHead
// key={header.id}
// {...{
// colSpan: header.colSpan,
// }}
// >
// <div
// {...{
// className: cn(
// 'flex items-center gap-2',
// header.column.getCanSort() && 'cursor-pointer select-none'
// ),
// onClick: header.column.getToggleSortingHandler(),
// }}
// >
// {flexRender(
// header.column.columnDef.header,
// header.getContext()
// )}
// {{
// asc: <ChevronUp className="ml-auto" size={14} />,
// desc: <ChevronDown className="ml-auto" size={14} />,
// }[header.column.getIsSorted() as string] ?? null}
// </div>
// </TableHead>
// ))}
// </TableRow>
// ))}
// </TableHeader>
// <TableBody>
// {table.getRowModel().rows.map((row) => (
// <TableRow
// key={row.id}
// {...(onClick
// ? {
// onClick() {
// onClick(row.original);
// },
// className: 'cursor-pointer',
// }
// : {})}
// >
// {row.getVisibleCells().map((cell) => (
// <TableCell key={cell.id}>
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
// </TableCell>
// ))}
// </TableRow>
// ))}
// </TableBody>
// </Table>
// );
}

View File

@@ -1,89 +0,0 @@
import React from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
dataKey: string;
payload: Record<string, unknown>;
}>;
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { unit, interval } = useChartContext();
const getLabel = useMappings();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!active || !payload) {
return null;
}
if (!payload.length) {
return null;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
{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.label}>
{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="flex flex-col flex-1">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.label)}
</div>
<div className="flex justify-between gap-8">
<div>{number.formatWithUnit(data.count, unit)}</div>
<div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}>
{!!data.previous &&
`(${number.formatWithUnit(data.previous.value, unit)})`}
</PreviousDiffIndicator>
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
);
}

View File

@@ -1,103 +0,0 @@
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportHistogramChartProps {
data: IChartData;
interval: IInterval;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const bg = theme?.colors?.slate?.['200'] as string;
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
rx="8"
fill={bg}
fillOpacity={0.5}
/>
);
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<ResponsiveContainer>
{({ width, height }) => (
<BarChart width={width} height={height} data={rechartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
allowDecimals={false}
domain={[0, data.metrics.max]}
tickFormatter={number.short}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
{previous && (
<Bar
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
dataKey={`${serie.index}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
radius={8}
/>
)}
<Bar
key={serie.name}
name={serie.name}
dataKey={`${serie.index}:count`}
fill={getChartColor(serie.index)}
radius={8}
/>
</React.Fragment>
);
})}
</BarChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,132 +0,0 @@
'use client';
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { IServiceReference } from '@openpanel/db';
import type { IChartLineType, IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportLineChartProps {
data: IChartData;
references: IServiceReference[];
interval: IInterval;
lineType: IChartLineType;
}
export function ReportLineChart({
lineType,
interval,
data,
references,
}: ReportLineChartProps) {
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<ResponsiveContainer>
{({ width, height }) => (
<LineChart width={width} height={height} data={rechartData}>
{references.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}
/>
))}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
tickFormatter={number.short}
/>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="timestamp"
scale="utc"
domain={['dataMin', 'dataMax']}
tickFormatter={(m: string) => formatDate(new Date(m))}
type="number"
tickLine={false}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
<Line
type={lineType}
key={serie.name}
name={serie.name}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
/>
{previous && (
<Line
type={lineType}
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
isAnimationActive={true}
strokeWidth={1}
dot={false}
strokeDasharray={'6 6'}
dataKey={`${serie.index}:prev:count`}
stroke={getChartColor(serie.index)}
/>
)}
</React.Fragment>
);
})}
</LineChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,40 +0,0 @@
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { theme } from '@/utils/theme';
import WorldMap from 'react-svg-worldmap';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useChartContext } from './ChartProvider';
interface ReportMapChartProps {
data: IChartData;
}
export function ReportMapChart({ data }: ReportMapChartProps) {
const { metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, 100);
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.name.toLowerCase(),
value: s.metrics[metric],
})),
[series, metric]
);
return (
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
size={width}
data={mapData}
color={theme.colors['chart-0']}
borderColor={'#103A96'}
value-suffix={unit}
/>
)}
</AutoSizer>
);
}

View File

@@ -1,36 +0,0 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCard } from './MetricCard';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode, metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
editMode && 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{series.map((serie) => {
return (
<MetricCard
key={serie.name}
serie={serie}
metric={metric}
unit={unit}
/>
);
})}
</div>
);
}

View File

@@ -1,129 +0,0 @@
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportPieChartProps {
data: IChartData;
}
export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
const pieData = series.map((serie) => ({
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));
return (
<>
<div className={cn('max-sm:-mx-3', editMode && 'card p-4')}>
<AutoSizer disableHeight>
{({ width }) => {
const height = Math.min(Math.max(width * 0.5625, 250), 400);
return (
<PieChart width={width} height={height}>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={height / 4}
outerRadius={height / 2.5}
isAnimationActive={true}
label={renderLabel}
>
{pieData.map((item) => {
return (
<Cell
key={item.id}
strokeWidth={2}
stroke={item.color}
fill={item.color}
/>
);
})}
</Pie>
</PieChart>
);
}}
</AutoSizer>
</div>
{editMode && (
<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: { label: 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 label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xProcent}
y={yProcent}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={700}
pointerEvents={'none'}
>
{percent}%
</text>
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={10}
>
{truncate(label, 20)}
</text>
</>
);
};

View File

@@ -1,176 +0,0 @@
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/pagination';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useSelector } from '@/redux';
import { getChartColor } from '@/utils/theme';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
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 formatDate = useFormatDateInterval(interval);
const getLabel = useMappings();
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
if (checked) {
return [...prev, name];
} else {
return prev.filter((item) => item !== name);
}
});
}
return (
<>
<div className="grid grid-cols-[200px_1fr] border border-border rounded-md overflow-hidden">
<Table className="rounded-none border-t-0 border-l-0 border-b-0">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
);
return (
<TableRow key={serie.name}>
<TableCell className="h-10">
<div className="flex items-center gap-2">
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.name, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
{getLabel(serie.name)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getLabel(serie.name)}</p>
</TooltipContent>
</Tooltip>
</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.name}>
<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="flex flex-col-reverse gap-4 md:flex-row md:justify-between md:items-center">
<div className="flex gap-1 flex-wrap">
<Badge>Total: {number.format(data.metrics.sum)}</Badge>
<Badge>Average: {number.format(data.metrics.average)}</Badge>
<Badge>Min: {number.format(data.metrics.min)}</Badge>
<Badge>Max: {number.format(data.metrics.max)}</Badge>
</div>
<Pagination
cursor={page}
setCursor={setPage}
take={ROWS_LIMIT}
count={data.series.length}
/>
</div>
</>
);
}

View File

@@ -1,36 +0,0 @@
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useChartContext } from './ChartProvider';
interface ResponsiveContainerProps {
children: (props: { width: number; height: number }) => React.ReactNode;
}
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
const { editMode } = useChartContext();
const maxHeight = 300;
const minHeight = 200;
return (
<div
style={{
maxHeight,
minHeight,
}}
className={cn('max-sm:-mx-3 aspect-video w-full', editMode && 'card p-4')}
>
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(
Math.max(width * 0.5625, minHeight),
// we add p-4 (16px) padding in edit mode
editMode ? maxHeight - 16 : maxHeight
),
})
}
</AutoSizer>
</div>
);
}

View File

@@ -1,261 +0,0 @@
import { useMemo } from 'react';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
ExternalLinkIcon,
HelpCircleIcon,
MailIcon,
MonitorIcon,
MonitorPlayIcon,
PodcastIcon,
ScanIcon,
SearchIcon,
SmartphoneIcon,
TabletIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
interface SerieIconProps extends LucideProps {
name?: string;
}
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
}
const createImageIcon = (url: string) => {
return function (props: LucideProps) {
return <img className="h-4 object-contain rounded-[2px]" src={url} />;
} as LucideIcon;
};
const createFlagIcon = (url: string) => {
return function (props: LucideProps) {
return (
<span
className={`rounded-[2px] overflow-hidden !block !leading-[1rem] fi fi-${url}`}
></span>
);
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = {
// Events
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
session_end: ActivityIcon,
link_out: ExternalLinkIcon,
// Websites
linkedin: createImageIcon(getProxyImage('https://linkedin.com')),
slack: createImageIcon(getProxyImage('https://slack.com')),
pinterest: createImageIcon(getProxyImage('https://www.pinterest.se')),
ecosia: createImageIcon(getProxyImage('https://ecosia.com')),
yandex: createImageIcon(getProxyImage('https://yandex.com')),
google: createImageIcon(getProxyImage('https://google.com')),
facebook: createImageIcon(getProxyImage('https://facebook.com')),
bing: createImageIcon(getProxyImage('https://bing.com')),
twitter: createImageIcon(getProxyImage('https://x.com')),
duckduckgo: createImageIcon(getProxyImage('https://duckduckgo.com')),
'yahoo!': createImageIcon(getProxyImage('https://yahoo.com')),
instagram: createImageIcon(getProxyImage('https://instagram.com')),
gmail: createImageIcon(getProxyImage('https://mail.google.com/')),
'mobile safari': createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
)
),
chrome: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg'
)
),
'samsung internet': createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png'
)
),
safari: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
)
),
edge: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png'
)
),
firefox: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png'
)
),
snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
// OS
'mac os': createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/c/c9/Finder_Icon_macOS_Big_Sur.png'
),
windows: createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/c/c7/Windows_logo_-_2012.png'
),
ios: createImageIcon(
'https://upload.wikimedia.org/wikipedia/commons/9/96/IOS_17_logo.png'
),
android: createImageIcon(
'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.png'
),
// Misc
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
search: SearchIcon,
social: PodcastIcon,
email: MailIcon,
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
// Flags
se: createFlagIcon('se'),
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'),
};
export function SerieIcon({ name, ...props }: SerieIconProps) {
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));
}
return null;
}, [name]);
return Icon ? (
<div className="h-4 flex-shrink-0 relative [&_a]:![&_a]:!h-4 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} />
</div>
) : null;
}

View File

@@ -1,11 +0,0 @@
const formatter = new Intl.NumberFormat('en', {
notation: 'compact',
});
export function getYAxisWidth(value: number) {
if (!isFinite(value)) {
return 7.8 + 7.8;
}
return formatter.format(value).toString().length * 7.8 + 7.8;
}

View File

@@ -1,52 +0,0 @@
'use client';
import type { IChartInput } from '@openpanel/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
export type ReportChartProps = IChartInput;
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: ReportChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
}
return <Chart {...props} />;
});
interface ChartSwitchShortcutProps {
projectId: ReportChartProps['projectId'];
range?: ReportChartProps['range'];
previous?: ReportChartProps['previous'];
chartType?: ReportChartProps['chartType'];
interval?: ReportChartProps['interval'];
events: ReportChartProps['events'];
}
export const ChartSwitchShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
}: ChartSwitchShortcutProps) => {
return (
<ChartSwitch
projectId={projectId}
range={range}
breakdowns={[]}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType="bump"
metric="sum"
events={events}
/>
);
};

View File

@@ -1,175 +0,0 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ArrowRight, ArrowRightIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
function FunnelChart({ from, to }: { from: number; to: number }) {
const fromY = 100 - from;
const toY = 100 - to;
const steps = [
`M0,${fromY}`,
'L0,100',
'L100,100',
`L100,${toY}`,
`L0,${fromY}`,
];
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient
id="blue"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#2564eb" />
{/* top */}
<stop offset="100%" stop-color="#2564eb" />
</linearGradient>
<linearGradient
id="red"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#f87171" />
{/* top */}
<stop offset="100%" stop-color="#fca5a5" />
</linearGradient>
</defs>
<rect
x="0"
y={fromY}
width="100"
height="100"
fill="url(#red)"
fillOpacity={0.2}
/>
<path d={steps.join(' ')} fill="url(#blue)" />
</svg>
);
}
function getDropoffColor(value: number) {
if (value > 80) {
return 'text-red-600';
}
if (value > 50) {
return 'text-orange-600';
}
if (value > 30) {
return 'text-yellow-600';
}
return 'text-green-600';
}
export function FunnelSteps({
steps,
totalSessions,
}: RouterOutputs['chart']['funnel']) {
const { editMode } = useChartContext();
return (
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
<CarouselContent>
<CarouselItem className={'flex-[0_0_0] pl-3'} />
{steps.map((step, index, list) => {
const finalStep = index === list.length - 1;
return (
<CarouselItem
className={cn(
'flex-[0_0_250px] max-w-full p-0 px-1',
editMode && 'flex-[0_0_320px]'
)}
key={step.event.id}
>
<div className="card divide-y divide-border bg-white">
<div className="p-4">
<p className="text-muted-foreground">Step {index + 1}</p>
<h3 className="font-bold">
{step.event.displayName || step.event.name}
</h3>
</div>
<div className="aspect-square relative">
<FunnelChart from={step.prevPercent} to={step.percent} />
<div className="absolute top-0 left-0 right-0 p-4 flex flex-col bg-white/40">
<div className="uppercase font-medium text-muted-foreground">
Sessions
</div>
<div className="uppercase text-3xl font-bold flex items-center">
<span className="text-muted-foreground">
{step.before}
</span>
<ArrowRightIcon size={16} className="mx-2" />
<span>{step.current}</span>
</div>
{index !== 0 && (
<>
<div className="text-muted-foreground">
{step.current} of {totalSessions} (
{round(step.percent, 1)}%)
</div>
</>
)}
</div>
</div>
{finalStep ? (
<div className={cn('p-4 flex flex-col items-center')}>
<div className="uppercase text-xs font-medium">
Conversion
</div>
<div
className={cn(
'uppercase text-3xl font-bold',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.percent, 1)}%
</div>
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
Converted {step.current} of {totalSessions} sessions
</div>
</div>
) : (
<div className={cn('p-4 flex flex-col items-center')}>
<div className="uppercase text-xs font-medium">Dropoff</div>
<div
className={cn(
'uppercase text-3xl font-bold',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.dropoff.percent, 1)}%
</div>
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
Lost {step.dropoff.count} sessions
</div>
</div>
)}
</div>
</CarouselItem>
);
})}
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@openpanel/validation';
import { ChartEmpty } from '../chart/ChartEmpty';
import { withChartProivder } from '../chart/ChartProvider';
import { FunnelSteps } from './Funnel';
export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['funnel'];
};
export const Funnel = withChartProivder(function Chart({
events,
name,
range,
projectId,
}: ReportChartProps) {
const [data] = api.chart.funnel.useSuspenseQuery(
{
events,
name,
range,
projectId,
lineType: 'monotone',
interval: 'day',
chartType: 'funnel',
breakdowns: [],
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
},
{
keepPreviousData: true,
}
);
if (data.steps.length === 0) {
return <ChartEmpty />;
}
return (
<div className="-mx-4">
<FunnelSteps {...data} />
</div>
);
});

View File

@@ -1,273 +0,0 @@
import { start } from 'repl';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { isSameDay, isSameMonth } from 'date-fns';
import {
alphabetIds,
getDefaultIntervalByDates,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartInput,
IChartLineType,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
type InitialState = IChartInput & {
dirty: boolean;
ready: boolean;
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: 'Untitled',
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
range: '1m',
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
};
export const reportSlice = createSlice({
name: 'report',
initialState,
reducers: {
resetDirty(state) {
return {
...state,
dirty: false,
};
},
reset() {
return initialState;
},
ready() {
return {
...initialState,
ready: true,
};
},
setReport(state, action: PayloadAction<IChartInput>) {
return {
...state,
...action.payload,
startDate: null,
endDate: null,
dirty: false,
ready: true,
};
},
setName(state, action: PayloadAction<string>) {
state.dirty = true;
state.name = action.payload;
},
// Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
id: alphabetIds[state.events.length]!,
...action.payload,
});
},
removeEvent: (
state,
action: PayloadAction<{
id: string;
}>
) => {
state.dirty = true;
state.events = state.events.filter(
(event) => event.id !== action.payload.id
);
},
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
state.dirty = true;
state.events = state.events.map((event) => {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
});
},
// Previous
changePrevious: (state, action: PayloadAction<boolean>) => {
state.dirty = true;
state.previous = action.payload;
},
// Breakdowns
addBreakdown: (
state,
action: PayloadAction<Omit<IChartBreakdown, 'id'>>
) => {
state.dirty = true;
state.breakdowns.push({
id: alphabetIds[state.breakdowns.length]!,
...action.payload,
});
},
removeBreakdown: (
state,
action: PayloadAction<{
id: string;
}>
) => {
state.dirty = true;
state.breakdowns = state.breakdowns.filter(
(event) => event.id !== action.payload.id
);
},
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
state.dirty = true;
state.breakdowns = state.breakdowns.map((breakdown) => {
if (breakdown.id === action.payload.id) {
return action.payload;
}
return breakdown;
});
},
// Interval
changeInterval: (state, action: PayloadAction<IInterval>) => {
state.dirty = true;
state.interval = action.payload;
},
// Chart type
changeChartType: (state, action: PayloadAction<IChartType>) => {
state.dirty = true;
state.chartType = action.payload;
if (
!isMinuteIntervalEnabledByRange(state.range) &&
state.interval === 'minute'
) {
state.interval = 'hour';
}
if (
!isHourIntervalEnabledByRange(state.range) &&
state.interval === 'hour'
) {
state.interval = 'day';
}
},
// Line type
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
state.dirty = true;
state.lineType = action.payload;
},
// Custom start and end date
changeDates: (
state,
action: PayloadAction<{
startDate: string;
endDate: string;
}>
) => {
state.dirty = true;
state.startDate = action.payload.startDate;
state.endDate = action.payload.endDate;
if (isSameDay(state.startDate, state.endDate)) {
state.interval = 'hour';
} else if (isSameMonth(state.startDate, state.endDate)) {
state.interval = 'day';
} else {
state.interval = 'month';
}
},
// Date range
changeStartDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.startDate = action.payload;
const interval = getDefaultIntervalByDates(
state.startDate,
state.endDate
);
if (interval) {
state.interval = interval;
}
},
// Date range
changeEndDate: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.endDate = action.payload;
const interval = getDefaultIntervalByDates(
state.startDate,
state.endDate
);
if (interval) {
state.interval = interval;
}
},
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
state.startDate = null;
state.endDate = null;
state.interval = getDefaultIntervalByRange(action.payload);
},
// Formula
changeFormula: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.formula = action.payload;
},
},
});
// Action creators are generated for each case reducer function
export const {
reset,
ready,
setReport,
setName,
addEvent,
removeEvent,
changeEvent,
addBreakdown,
removeBreakdown,
changeBreakdown,
changeInterval,
changeDates,
changeStartDate,
changeEndDate,
changeDateRanges,
changeChartType,
changeLineType,
resetDirty,
changeFormula,
changePrevious,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -1,63 +0,0 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react';
import type { IChartEvent } from '@openpanel/validation';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {
event: IChartEvent;
}
export function EventPropertiesCombobox({
event,
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{
event: event.name,
projectId,
},
{
enabled: !!event.name,
}
);
const properties = (query.data ?? []).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
onChange={(value) => {
dispatch(
changeEvent({
...event,
property: value,
})
);
}}
>
<button
className={cn(
'flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs',
!event.property && 'border-destructive text-destructive'
)}
>
<DatabaseIcon size={12} />{' '}
{event.property ? `Property: ${event.property}` : 'Select property'}
</button>
</Combobox>
);
}

View File

@@ -1,41 +0,0 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Trash } from 'lucide-react';
export interface ReportBreakdownMoreProps {
onClick: (action: 'remove') => void;
}
export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) {
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem
className="text-red-600"
onClick={() => onClick('remove')}
>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,90 +0,0 @@
'use client';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import { SplitIcon } from 'lucide-react';
import type { IChartBreakdown } from '@openpanel/validation';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const { projectId } = useAppParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
projectId,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
label: item, // <RenderDots truncate>{item}</RenderDots>,
}));
const handleMore = (breakdown: IChartBreakdown) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeBreakdown(breakdown));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Breakdown</h3>
<div className="flex flex-col gap-4">
{selectedBreakdowns.map((item, index) => {
return (
<div key={item.name} className="rounded-lg border bg-slate-50">
<div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare>
<Combobox
icon={SplitIcon}
className="flex-1"
searchable
value={item.name}
onChange={(value) => {
dispatch(
changeBreakdown({
...item,
name: value,
})
);
}}
items={propertiesCombobox}
placeholder="Select..."
/>
<ReportBreakdownMore onClick={handleMore(item)} />
</div>
</div>
);
})}
{selectedBreakdowns.length === 0 && (
<Combobox
icon={SplitIcon}
searchable
value={''}
onChange={(value) => {
dispatch(
addBreakdown({
name: value,
})
);
}}
items={propertiesCombobox}
placeholder="Select breakdown"
/>
)}
</div>
</div>
);
}

View File

@@ -1,65 +0,0 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Filter, MoreHorizontal, Tags, Trash } from 'lucide-react';
const labels = [
'feature',
'bug',
'enhancement',
'documentation',
'design',
'question',
'maintenance',
];
export interface ReportEventMoreProps {
onClick: (action: 'remove') => void;
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
const [open, setOpen] = React.useState(false);
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => onClick('remove')}
>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,218 +0,0 @@
'use client';
import { ColorSquare } from '@/components/color-square';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useEventNames } from '@/hooks/useEventNames';
import { useDispatch, useSelector } from '@/redux';
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
import type { IChartEvent } from '@openpanel/validation';
import {
addEvent,
changeEvent,
changePrevious,
removeEvent,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { FiltersCombobox } from './filters/FiltersCombobox';
import { FiltersList } from './filters/FiltersList';
import { ReportEventMore } from './ReportEventMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportEvents() {
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames(projectId);
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(removeEvent(event));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Events</h3>
<div className="flex flex-col gap-4">
{selectedEvents.map((event) => {
return (
<div key={event.name} className="rounded-lg border bg-slate-50">
<div className="flex items-center gap-2 p-2">
<ColorSquare>{event.id}</ColorSquare>
<Combobox
icon={GanttChartIcon}
className="flex-1"
searchable
value={event.name}
onChange={(value) => {
dispatch(
changeEvent({
...event,
name: value,
filters: [],
})
);
}}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
<Input
placeholder={
event.name ? `${event.name} (${event.id})` : 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
<ReportEventMore onClick={handleMore(event)} />
</div>
{/* Segment and Filter buttons */}
<div className="flex gap-2 p-2 pt-0 text-sm">
<DropdownMenuComposed
onChange={(segment) => {
dispatch(
changeEvent({
...event,
segment,
})
);
}}
items={[
{
value: 'event',
label: 'All events',
},
{
value: 'user',
label: 'Unique users',
},
{
value: 'session',
label: 'Unique sessions',
},
{
value: 'user_average',
label: 'Average event per user',
},
{
value: 'one_event_per_user',
label: 'One event per user',
},
{
value: 'property_sum',
label: 'Sum of property',
},
{
value: 'property_average',
label: 'Average of property',
},
]}
label="Segment"
>
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
{event.segment === 'user' ? (
<>
<Users size={12} /> Unique users
</>
) : event.segment === 'session' ? (
<>
<Users size={12} /> Unique sessions
</>
) : event.segment === 'user_average' ? (
<>
<Users size={12} /> Average event per user
</>
) : event.segment === 'one_event_per_user' ? (
<>
<Users size={12} /> One event per user
</>
) : event.segment === 'property_sum' ? (
<>
<Users size={12} /> Sum of property
</>
) : event.segment === 'property_average' ? (
<>
<Users size={12} /> Average of property
</>
) : (
<>
<GanttChart size={12} /> All events
</>
)}
</button>
</DropdownMenuComposed>
{/* */}
<FiltersCombobox event={event} />
{(event.segment === 'property_average' ||
event.segment === 'property_sum') && (
<EventPropertiesCombobox event={event} />
)}
</div>
{/* Filters */}
<FiltersList event={event} />
</div>
);
})}
<Combobox
icon={GanttChartIcon}
value={''}
searchable
onChange={(value) => {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
})
);
}}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
</div>
<label
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
htmlFor="previous"
>
<Checkbox
id="previous"
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
Show previous / Compare
</label>
</div>
);
}

View File

@@ -1,26 +0,0 @@
'use client';
import { Input } from '@/components/ui/input';
import { useDispatch, useSelector } from '@/redux';
import { changeFormula } from '../reportSlice';
export function ReportForumula() {
const forumula = useSelector((state) => state.report.formula);
const dispatch = useDispatch();
return (
<div>
<h3 className="mb-2 font-medium">Forumula</h3>
<div className="flex flex-col gap-4">
<Input
placeholder="eg: A/B"
value={forumula}
onChange={(event) => {
dispatch(changeFormula(event.target.value));
}}
/>
</div>
</div>
);
}

View File

@@ -1,27 +0,0 @@
import { Button } from '@/components/ui/button';
import { SheetClose, SheetFooter } from '@/components/ui/sheet';
import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportForumula } from './ReportForumula';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showForumula = chartType !== 'funnel';
const showBreakdown = chartType !== 'funnel';
return (
<>
<div className="flex flex-col gap-8">
<ReportEvents />
{showForumula && <ReportForumula />}
{showBreakdown && <ReportBreakdowns />}
</div>
<SheetFooter>
<SheetClose asChild>
<Button className="w-full">Done</Button>
</SheetClose>
</SheetFooter>
</>
);
}

View File

@@ -1,133 +0,0 @@
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { useDispatch } from '@/redux';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { operators } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@openpanel/validation';
import { mapKeys } from '@openpanel/validation';
import { changeEvent } from '../../reportSlice';
interface FilterProps {
event: IChartEvent;
filter: IChartEvent['filters'][number];
}
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useAppParams();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectId,
});
const valuesCombobox =
potentialValues.data?.values?.map((item) => ({
value: item,
label: getLabel(item),
})) ?? [];
const removeFilter = () => {
dispatch(
changeEvent({
...event,
filters: event.filters.filter((item) => item.id !== filter.id),
})
);
};
const changeFilterValue = (
value: IChartEventFilterValue | IChartEventFilterValue[]
) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
return {
...item,
value: Array.isArray(value) ? value : [value],
};
}
return item;
}),
})
);
};
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
return {
...item,
operator,
};
}
return item;
}),
})
);
};
return (
<div
key={filter.name}
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t"
>
<div className="mb-2 flex items-center gap-2">
<ColorSquare className="bg-emerald-500">
<SlidersHorizontal size={10} />
</ColorSquare>
<div className="flex flex-1 text-sm">
<RenderDots truncate>{filter.name}</RenderDots>
</div>
<Button variant="ghost" size="sm" onClick={removeFilter}>
<Trash size={16} />
</Button>
</div>
<div className="flex gap-1">
<DropdownMenuComposed
onChange={changeFilterOperator}
items={mapKeys(operators).map((key) => ({
value: key,
label: operators[key],
}))}
label="Operator"
>
<Button variant={'ghost'} className="whitespace-nowrap">
{operators[filter.operator]}
</Button>
</DropdownMenuComposed>
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}
className="flex-1"
onChange={(setFn) => {
changeFilterValue(
typeof setFn === 'function' ? setFn(filter.value) : setFn
);
}}
placeholder="Select..."
/>
</div>
</div>
);
}

View File

@@ -1,62 +0,0 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import { FilterIcon } from 'lucide-react';
import type { IChartEvent } from '@openpanel/validation';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps {
event: IChartEvent;
}
export function FiltersCombobox({ event }: FiltersComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{
event: event.name,
projectId,
},
{
enabled: !!event.name,
}
);
const properties = (query.data ?? []).map((item) => ({
label: item,
value: item,
}));
return (
<Combobox
searchable
placeholder="Select a filter"
value=""
items={properties}
onChange={(value) => {
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: value,
operator: 'is',
value: [],
},
],
})
);
}}
>
<button className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs bg-white">
<FilterIcon size={12} /> Add filter
</button>
</Combobox>
);
}

View File

@@ -1,19 +0,0 @@
import type { IChartEvent } from '@openpanel/validation';
import { FilterItem } from './FilterItem';
interface ReportEventFiltersProps {
event: IChartEvent;
}
export function FiltersList({ event }: ReportEventFiltersProps) {
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <FilterItem key={filter.name} filter={filter} event={event} />;
})}
</div>
</div>
);
}