multiple breakpoints

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-06-20 23:25:18 +02:00
parent c07f0d302c
commit cf8617e809
48 changed files with 908 additions and 432 deletions

View File

@@ -1,4 +1,4 @@
import { ChartSwitchShortcut } from '@/components/report/chart';
import { ChartRootShortcut } from '@/components/report/chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IChartEvent } from '@openpanel/validation';
@@ -26,7 +26,7 @@ export function EventsPerDayChart({ projectId, filters, events }: Props) {
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody>
<ChartSwitchShortcut
<ChartRootShortcut
projectId={projectId}
range="30d"
chartType="histogram"

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { ChartSwitchShortcut } from '@/components/report/chart';
import { ChartRootShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { KeyValue } from '@/components/ui/key-value';
import {
@@ -195,7 +195,7 @@ export function EventDetails({ event, open, setOpen }: Props) {
Show all
</button>
</div>
<ChartSwitchShortcut
<ChartRootShortcut
projectId={event.projectId}
chartType="histogram"
events={[

View File

@@ -1,7 +1,7 @@
'use client';
import { memo } from 'react';
import { ChartSwitch } from '@/components/report/chart';
import { ChartRoot } from '@/components/report/chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IChartProps } from '@openpanel/validation';
@@ -85,7 +85,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
<span className="title">Page views</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<ChartSwitch {...pageViewsChart} />
<ChartRoot {...pageViewsChart} />
</WidgetBody>
</Widget>
<Widget className="col-span-3 w-full">
@@ -93,7 +93,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<ChartSwitch {...eventsChart} />
<ChartRoot {...eventsChart} />
</WidgetBody>
</Widget>
</>

View File

@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/[organizationSlug]/[projectId]/layout-sticky-below-header';
import { ChartSwitch } from '@/components/report/chart';
import { ChartRoot } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
@@ -99,7 +99,7 @@ export default function ReportEditor({
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4" id="report-editor">
{report.ready && (
<ChartSwitch {...report} projectId={projectId} editMode />
<ChartRoot {...report} projectId={projectId} editMode />
)}
</div>
<SheetContent className="!max-w-lg" side="left">

View File

@@ -1,19 +1,13 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
import { useChartContext } from './report/chart/ChartProvider';
type ColorSquareProps = HtmlProps<HTMLDivElement>;
export function ColorSquare({ children, className }: ColorSquareProps) {
const { hideID } = useChartContext();
if (hideID) {
return null;
}
return (
<div
className={cn(
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-xs font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
className
)}
>

View File

@@ -5,6 +5,7 @@ import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getPropertyLabel } from '@/translations/properties';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -47,7 +48,7 @@ export function OverviewFiltersButtons({
icon={X}
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
>
<span className="mr-1">{filter.name} is</span>
<span className="mr-1">{getPropertyLabel(filter.name)} is</span>
<strong>{filter.value[0]}</strong>
</Button>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ChartSwitch } from '@/components/report/chart';
import { ChartRoot } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -205,7 +205,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
setMetric(index);
}}
>
<ChartSwitch hideID {...report} />
<ChartRoot hideID {...report} />
</button>
))}
<div
@@ -217,7 +217,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
</div>
</div>
<div className="card col-span-6 p-4">
<ChartSwitch
<ChartRoot
key={selectedMetric.id}
hideID
{...selectedMetric}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { LazyChart } from '../report/chart/LazyChart';
@@ -51,7 +52,7 @@ export default function OverviewTopDevices({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top devices',
range: range,
previous: previous,
metric: 'sum',
@@ -82,7 +83,7 @@ export default function OverviewTopDevices({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top browser',
range: range,
previous: previous,
metric: 'sum',
@@ -92,6 +93,9 @@ export default function OverviewTopDevices({
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
limit: 10,
projectId,
startDate,
@@ -107,13 +111,17 @@ export default function OverviewTopDevices({
breakdowns: [
{
id: 'A',
name: 'browser',
},
{
id: 'B',
name: 'browser_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top Browser Version',
range: range,
previous: previous,
metric: 'sum',
@@ -144,7 +152,7 @@ export default function OverviewTopDevices({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top OS',
range: range,
previous: previous,
metric: 'sum',
@@ -154,6 +162,9 @@ export default function OverviewTopDevices({
title: 'Top OS version',
btn: 'OS Version',
chart: {
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
limit: 10,
projectId,
startDate,
@@ -169,13 +180,17 @@ export default function OverviewTopDevices({
breakdowns: [
{
id: 'A',
name: 'os',
},
{
id: 'B',
name: 'os_version',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top OS version',
range: range,
previous: previous,
metric: 'sum',
@@ -211,19 +226,19 @@ export default function OverviewTopDevices({
onClick={(item) => {
switch (widget.key) {
case 'devices':
setFilter('device', item.name);
setFilter('device', item.names[0]);
break;
case 'browser':
setFilter('browser', item.name);
setFilter('browser', item.names[0]);
break;
case 'browser_version':
setFilter('browser_version', item.name);
setFilter('browser_version', item.names[1]);
break;
case 'os':
setFilter('os', item.name);
setFilter('os', item.names[0]);
break;
case 'os_version':
setFilter('os_version', item.name);
setFilter('os_version', item.names[1]);
break;
}
}}

View File

@@ -60,7 +60,7 @@ export default function OverviewTopEvents({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
@@ -91,7 +91,7 @@ export default function OverviewTopEvents({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
@@ -131,7 +131,7 @@ export default function OverviewTopEvents({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',

View File

@@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
import { ChartSwitch } from '@/components/report/chart';
import { ChartRoot } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getCountry } from '@/translations/countries';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { LazyChart } from '../report/chart/LazyChart';
@@ -29,6 +31,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
title: 'Top countries',
btn: 'Countries',
chart: {
renderSerieName(name) {
return getCountry(name[0]) || NOT_SET_VALUE;
},
limit: 10,
projectId,
startDate,
@@ -50,7 +55,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top countries',
range: range,
previous: previous,
metric: 'sum',
@@ -60,6 +65,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
title: 'Top regions',
btn: 'Regions',
chart: {
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
limit: 10,
projectId,
startDate,
@@ -75,13 +83,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'region',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top regions',
range: range,
previous: previous,
metric: 'sum',
@@ -91,6 +103,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
title: 'Top cities',
btn: 'Cities',
chart: {
renderSerieName(name) {
return name[1] || NOT_SET_VALUE;
},
limit: 10,
projectId,
startDate,
@@ -106,13 +121,17 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
breakdowns: [
{
id: 'A',
name: 'country',
},
{
id: 'B',
name: 'city',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top cities',
range: range,
previous: previous,
metric: 'sum',
@@ -149,14 +168,14 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
switch (widget.key) {
case 'countries':
setWidget('regions');
setFilter('country', item.name);
setFilter('country', item.names[0]);
break;
case 'regions':
setWidget('cities');
setFilter('region', item.name);
setFilter('region', item.names[1]);
break;
case 'cities':
setFilter('city', item.name);
setFilter('city', item.names[1]);
break;
}
}}
@@ -169,7 +188,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ChartSwitch
<ChartRoot
hideID
{...{
projectId,

View File

@@ -3,10 +3,13 @@
import { useState } from 'react';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { ExternalLinkIcon, FilterIcon } from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { LazyChart } from '../report/chart/LazyChart';
import { Tooltiper } from '../ui/tooltip';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import OverviewDetailsButton from './overview-details-button';
@@ -23,11 +26,19 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const renderSerieName = (names: string[]) => {
return (
<Tooltiper content={names.join('')} side="left" className="text-left">
{names[1] || NOT_SET_VALUE}
</Tooltiper>
);
};
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
renderSerieName,
limit: 10,
projectId,
startDate,
@@ -43,13 +54,17 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
name: 'Top pages',
range,
previous,
metric: 'sum',
@@ -59,6 +74,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
title: 'Entry Pages',
btn: 'Entries',
chart: {
renderSerieName,
limit: 10,
projectId,
startDate,
@@ -74,13 +90,17 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
name: 'Entry Pages',
range,
previous,
metric: 'sum',
@@ -90,6 +110,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
title: 'Exit Pages',
btn: 'Exits',
chart: {
renderSerieName,
limit: 10,
projectId,
startDate,
@@ -105,13 +126,17 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
breakdowns: [
{
id: 'A',
name: 'origin',
},
{
id: 'B',
name: 'path',
},
],
chartType,
lineType: 'monotone',
interval,
name: 'Top sources',
name: 'Exit Pages',
range,
previous,
metric: 'sum',
@@ -153,9 +178,22 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setFilter('path', item.name);
}}
dropdownMenuContent={(serie) => [
{
title: 'Visit page',
icon: ExternalLinkIcon,
onClick: () => {
window.open(serie.names.join(''), '_blank');
},
},
{
title: 'Set filter',
icon: FilterIcon,
onClick: () => {
setFilter('path', serie.names[1]);
},
},
]}
/>
)}
{widget.chart?.name && <OverviewDetailsButton chart={widget.chart} />}

View File

@@ -53,7 +53,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top groups',
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
@@ -84,7 +84,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'Top urls',
range: range,
previous: previous,
metric: 'sum',
@@ -146,7 +146,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'UTM Source',
range: range,
previous: previous,
metric: 'sum',
@@ -177,7 +177,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'UTM Medium',
range: range,
previous: previous,
metric: 'sum',
@@ -208,7 +208,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'UTM Campaign',
range: range,
previous: previous,
metric: 'sum',
@@ -239,7 +239,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'UTM Term',
range: range,
previous: previous,
metric: 'sum',
@@ -270,7 +270,7 @@ export default function OverviewTopSources({
chartType,
lineType: 'monotone',
interval: interval,
name: 'Top sources',
name: 'UTM Content',
range: range,
previous: previous,
metric: 'sum',
@@ -307,30 +307,30 @@ export default function OverviewTopSources({
onClick={(item) => {
switch (widget.key) {
case 'all':
setFilter('referrer_name', item.name);
setFilter('referrer_name', item.names[0]);
setWidget('domain');
break;
case 'domain':
setFilter('referrer', item.name);
setFilter('referrer', item.names[0]);
break;
case 'type':
setFilter('referrer_type', item.name);
setFilter('referrer_type', item.names[0]);
setWidget('domain');
break;
case 'utm_source':
setFilter('properties.__query.utm_source', item.name);
setFilter('properties.__query.utm_source', item.names[0]);
break;
case 'utm_medium':
setFilter('properties.__query.utm_medium', item.name);
setFilter('properties.__query.utm_medium', item.names[0]);
break;
case 'utm_campaign':
setFilter('properties.__query.utm_campaign', item.name);
setFilter('properties.__query.utm_campaign', item.names[0]);
break;
case 'utm_term':
setFilter('properties.__query.utm_term', item.name);
setFilter('properties.__query.utm_term', item.names[0]);
break;
case 'utm_content':
setFilter('properties.__query.utm_content', item.name);
setFilter('properties.__query.utm_content', item.names[0]);
break;
}
}}

View File

@@ -1,13 +1,14 @@
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { mapKeys } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import type { IChartRoot } from '../report/chart';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<
T,
{ title: string; btn: string; chart: IChartProps; hide?: boolean }
{ title: string; btn: string; chart: IChartRoot; hide?: boolean }
>
) {
const keys = Object.keys(widgets) as T[];

View File

@@ -5,6 +5,7 @@ import { api } from '@/trpc/client';
import type { IChartProps } from '@openpanel/validation';
import { ChartEmpty } from './ChartEmpty';
import { useChartContext } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
@@ -15,34 +16,22 @@ import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartProps;
export function Chart({
interval,
events,
breakdowns,
chartType,
range,
lineType,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
}: ReportChartProps) {
const [references] = api.reference.getChartReferences.useSuspenseQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 5,
}
);
export function Chart() {
const {
interval,
events,
breakdowns,
chartType,
range,
previous,
formula,
metric,
projectId,
startDate,
endDate,
limit,
offset,
} = useChartContext();
const [data] = api.chart.chart.useSuspenseQuery(
{
interval,
@@ -73,7 +62,7 @@ export function Chart({
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
return <ReportHistogramChart data={data} />;
}
if (chartType === 'bar') {
@@ -89,20 +78,11 @@ export function Chart({
}
if (chartType === 'linear') {
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={data}
references={references}
/>
);
return <ReportLineChart data={data} />;
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
return <ReportAreaChart data={data} />;
}
return <p>Unknown chart type</p>;

View File

@@ -1,113 +1,35 @@
'use client';
import {
createContext,
memo,
Suspense,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { createContext, useContext } from 'react';
import type { LucideIcon } from 'lucide-react';
import type { IChartProps, IChartSerie } from '@openpanel/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartProps {
export interface IChartContextType extends IChartProps {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;
limit?: number;
renderSerieName?: (names: string[]) => React.ReactNode;
renderSerieIcon?: (serie: IChartSerie) => React.ReactNode;
dropdownMenuContent?: (serie: IChartSerie) => {
icon: LucideIcon;
title: string;
onClick: () => void;
}[];
}
type ChartProviderProps = {
type IChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
} & IChartContextType;
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
limit: undefined,
});
const ChartContext = createContext<IChartContextType | null>(null);
export function ChartProvider({
children,
editMode,
previous,
hideID,
limit,
...props
}: ChartProviderProps) {
export function ChartProvider({ children, ...props }: IChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
...props,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
limit,
}),
[editMode, previous, hideID, limit, props]
)}
>
{children}
</ChartContext.Provider>
<ChartContext.Provider value={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

@@ -3,11 +3,11 @@
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import { ChartSwitch } from '.';
import type { IChartRoot } from '.';
import { ChartRoot } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ChartContextType) {
export function LazyChart(props: IChartRoot) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
@@ -23,7 +23,7 @@ export function LazyChart(props: ChartContextType) {
return (
<div ref={ref}>
{once.current || inViewport ? (
<ChartSwitch {...props} editMode={false} />
<ChartRoot {...props} editMode={false} />
) : (
<ChartLoading />
)}

View File

@@ -1,6 +1,5 @@
'use client';
import { ColorSquare } from '@/components/color-square';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
@@ -14,6 +13,7 @@ import {
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieName } from './SerieName';
interface MetricCardProps {
serie: IChartData['series'][number];
@@ -28,7 +28,7 @@ export function MetricCard({
metric,
unit,
}: MetricCardProps) {
const { previousIndicatorInverted } = useChartContext();
const { previousIndicatorInverted, editMode } = useChartContext();
const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => {
@@ -57,14 +57,15 @@ export function MetricCard({
return (
<div
className={cn(
'group relative h-[70px] overflow-hidden'
// '[#report-editor_&&]:card [#report-editor_&&]:px-4 [#report-editor_&&]:py-2'
'group relative h-[70px] overflow-hidden',
editMode && 'card h-[100px] px-4 py-2'
)}
key={serie.name}
key={serie.id}
>
<div
className={cn(
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50'
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50',
editMode && 'bottom-1'
)}
>
<AutoSizer>
@@ -91,9 +92,8 @@ export function MetricCard({
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left font-semibold">
<ColorSquare>{serie.event.id}</ColorSquare>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
{serie.name}
<SerieName name={serie.names} />
</span>
</div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}

View File

@@ -14,8 +14,6 @@ import {
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@openpanel/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
@@ -24,16 +22,10 @@ import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportAreaChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportAreaChart({
lineType,
interval,
data,
}: ReportAreaChartProps) {
const { editMode } = useChartContext();
export function ReportAreaChart({ data }: ReportAreaChartProps) {
const { editMode, lineType, interval } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series);
@@ -65,7 +57,7 @@ export function ReportAreaChart({
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.name}>
<React.Fragment key={serie.id}>
<defs>
<linearGradient
id={`color${color}`}
@@ -87,7 +79,7 @@ export function ReportAreaChart({
</linearGradient>
</defs>
<Area
key={serie.name}
key={serie.id}
type={lineType}
isAnimationActive={false}
strokeWidth={2}

View File

@@ -1,9 +1,18 @@
'use client';
import { useMemo } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { ExternalLinkIcon, FilterIcon } from 'lucide-react';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
@@ -11,13 +20,15 @@ import { NOT_SET_VALUE } from '@openpanel/constants';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, onClick, limit } = useChartContext();
const { editMode, metric, onClick, limit, dropdownMenuContent } =
useChartContext();
const number = useNumber();
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, limit || 10)),
@@ -33,42 +44,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
)}
>
{series.map((serie) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
return (
<div
key={serie.name}
className={cn('relative', isClickable && 'cursor-pointer')}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
/>
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
<div className="flex flex-1 items-center gap-2 break-all font-medium">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4">
<PreviousDiffIndicatorText
{...serie.metrics.previous?.[metric]}
className="text-xs font-medium"
<DropdownMenu key={serie.id}>
<DropdownMenuTrigger asChild disabled={!isDropDownEnabled}>
<div
className={cn(
'relative',
(isClickable || isDropDownEnabled) && 'cursor-pointer'
)}
{...(isClickable && !isDropDownEnabled
? { onClick: () => onClick?.(serie) }
: {})}
>
<div
className="absolute bottom-0 left-0 top-0 rounded bg-def-200"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
)}
%
</div>
<div className="font-bold">
{number.format(serie.metrics.sum)}
<div className="relative z-10 flex w-full flex-1 items-center gap-4 overflow-hidden px-3 py-2">
<div className="flex flex-1 items-center gap-2 break-all font-medium">
<SerieIcon name={serie.names[0]} />
<SerieName name={serie.names} />
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4">
<PreviousDiffIndicatorText
{...serie.metrics.previous?.[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2)
)}
%
</div>
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
</div>
</div>
</div>
</div>
</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>
);
})}
</div>

View File

@@ -7,6 +7,8 @@ import type { IToolTipProps } from '@/types';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
@@ -53,7 +55,7 @@ export function ReportChartTooltip({
) as IRechartPayloadItem;
return (
<React.Fragment key={data.name}>
<React.Fragment key={data.id}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
@@ -65,8 +67,9 @@ export function ReportChartTooltip({
style={{ background: data.color }}
/>
<div className="flex flex-1 flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.name)}
<div className="flex items-center gap-1">
<SerieIcon name={data.names} />
<SerieName name={data.names} />
</div>
<div className="flex justify-between gap-8">
<div>{number.formatWithUnit(data.count, unit)}</div>

View File

@@ -17,7 +17,6 @@ import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportHistogramChartProps {
data: IChartData;
interval: IInterval;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
@@ -32,11 +31,8 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
);
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode, previous } = useChartContext();
export function ReportHistogramChart({ data }: ReportHistogramChartProps) {
const { editMode, previous, interval } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
@@ -71,11 +67,11 @@ export function ReportHistogramChart({
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
<React.Fragment key={serie.id}>
{previous && (
<Bar
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
key={`${serie.id}:prev`}
name={`${serie.id}:prev`}
dataKey={`${serie.id}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
@@ -83,8 +79,8 @@ export function ReportHistogramChart({
/>
)}
<Bar
key={serie.name}
name={serie.name}
key={serie.id}
name={serie.id}
dataKey={`${serie.id}:count`}
fill={getChartColor(serie.index)}
radius={3}

View File

@@ -1,15 +1,20 @@
'use client';
import React from 'react';
import React, { useCallback, useMemo } from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { api } from '@/trpc/client';
import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { isSameDay, isSameHour, isSameMonth } from 'date-fns';
import { SplineIcon } from 'lucide-react';
import { last, pathOr } from 'ramda';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
LineChart,
@@ -27,49 +32,35 @@ import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
interface ReportLineChartProps {
data: IChartData;
references: IServiceReference[];
interval: IInterval;
lineType: IChartLineType;
}
function CustomLegend(props: {
payload?: { value: string; payload: { fill: string } }[];
}) {
if (!props.payload) {
return null;
}
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1">
{props.payload
.filter((entry) => !entry.value.includes('noTooltip'))
.filter((entry) => !entry.value.includes(':prev'))
.map((entry) => (
<div className="flex gap-1" key={entry.value}>
<SplineIcon size={12} color={entry.payload.fill} />
<div
style={{
color: entry.payload.fill,
}}
>
{entry.value}
</div>
</div>
))}
</div>
export function ReportLineChart({ data }: ReportLineChartProps) {
const {
editMode,
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
} = useChartContext();
const references = api.reference.getChartReferences.useQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 5,
}
);
}
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);
@@ -96,20 +87,56 @@ export function ReportLineChart({
</linearGradient>
);
const useDashedLastLine = (series[0]?.data?.length || 0) > 2;
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">
{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 isAreaStyle = series.length === 1;
return (
<>
<ResponsiveContainer>
{({ width, height }) => (
<LineChart width={width} height={height} data={rechartData}>
<ComposedChart width={width} height={height} data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-def-200"
/>
{references.map((ref) => (
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
@@ -150,18 +177,39 @@ export function ReportLineChart({
tickLine={false}
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.name}>
<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>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
)}
{gradientTwoColors(
`hideAllButLastInterval_${serie.id}`,
'rgba(0,0,0,0)',
getChartColor(serie.index),
color,
lastIntervalPercent
)}
{gradientTwoColors(
`hideJustLastInterval_${serie.id}`,
getChartColor(serie.index),
color,
'rgba(0,0,0,0)',
lastIntervalPercent
)}
@@ -169,24 +217,30 @@ export function ReportLineChart({
<Line
dot={false}
type={lineType}
name={serie.name}
name={serie.id}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={
useDashedLastLine
? 'transparent'
: getChartColor(serie.index)
}
stroke={useDashedLastLine ? 'transparent' : color}
// Use for legend
fill={getChartColor(serie.index)}
fill={color}
/>
{isAreaStyle && (
<Area
name={`${serie.id}:area:noTooltip`}
dataKey={`${serie.id}:count`}
fill={`url(#color${color})`}
type={lineType}
isAnimationActive={false}
fillOpacity={0.1}
/>
)}
{useDashedLastLine && (
<>
<Line
dot={false}
type={lineType}
name={`${serie.name}:dashed:noTooltip`}
name={`${serie.id}:dashed:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
@@ -197,7 +251,7 @@ export function ReportLineChart({
<Line
dot={false}
type={lineType}
name={`${serie.name}:solid:noTooltip`}
name={`${serie.id}:solid:noTooltip`}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
@@ -208,22 +262,22 @@ export function ReportLineChart({
{previous && (
<Line
type={lineType}
name={`${serie.name}:prev`}
name={`${serie.id}:prev`}
isAnimationActive={false}
strokeWidth={1}
dot={false}
strokeDasharray={'1 1'}
strokeOpacity={0.5}
dataKey={`${serie.id}:prev:count`}
stroke={getChartColor(serie.index)}
stroke={color}
// Use for legend
fill={getChartColor(serie.index)}
fill={color}
/>
)}
</React.Fragment>
);
})}
</LineChart>
</ComposedChart>
)}
</ResponsiveContainer>
{editMode && (

View File

@@ -18,7 +18,7 @@ export function ReportMapChart({ data }: ReportMapChartProps) {
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.name.toLowerCase(),
country: s.names[0]?.toLowerCase() ?? '',
value: s.metrics[metric],
})),
[series, metric]

View File

@@ -24,7 +24,7 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
{series.map((serie) => {
return (
<MetricCard
key={serie.name}
key={serie.id}
serie={serie}
metric={metric}
unit={unit}

View File

@@ -24,7 +24,7 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
id: serie.id,
color: getChartColor(serie.index),
index: serie.index,
name: serie.name,
name: serie.names.join(' > '),
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));

View File

@@ -13,16 +13,19 @@ import {
import {
Tooltip,
TooltipContent,
Tooltiper,
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 { getPropertyLabel } from '@/translations/properties';
import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { SerieName } from './SerieName';
interface ReportTableProps {
data: IChartData;
@@ -40,8 +43,8 @@ export function ReportTable({
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);
const getLabel = useMappings();
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
@@ -55,49 +58,61 @@ export function ReportTable({
return (
<>
<div className="grid grid-cols-[200px_1fr] overflow-hidden rounded-md border border-border">
<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>
<TableHead>Name</TableHead>
{breakdowns.length === 0 && <TableHead>Name</TableHead>}
{breakdowns.map((breakdown) => (
<TableHead key={breakdown.name}>
{getPropertyLabel(breakdown.name)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
<TableBody className="bg-def-100">
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
(item) => item.id === serie.id
);
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 text-ellipsis whitespace-nowrap">
{getLabel(serie.name)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getLabel(serie.name)}</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableRow key={serie.id}>
{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>
);
})}
@@ -122,7 +137,7 @@ export function ReportTable({
<TableBody>
{paginate(data.series).map((serie) => {
return (
<TableRow key={serie.name}>
<TableRow key={serie.id}>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.sum)}

View File

@@ -4,7 +4,7 @@ const createFlagIcon = (url: string) => {
return function (_props: LucideProps) {
return (
<span
className={`fi fis !block overflow-hidden rounded-full !leading-[1rem] fi-${url}`}
className={`fi !block aspect-[1.33] overflow-hidden rounded-[2px] fi-${url}`}
></span>
);
} as LucideIcon;

View File

@@ -13,6 +13,7 @@ import {
SearchIcon,
SmartphoneIcon,
TabletIcon,
TvIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
@@ -20,9 +21,9 @@ import { NOT_SET_VALUE } from '@openpanel/constants';
import flags from './SerieIcon.flags';
import iconsWithUrls from './SerieIcon.urls';
interface SerieIconProps extends LucideProps {
name?: string;
}
type SerieIconProps = Omit<LucideProps, 'name'> & {
name?: string | string[];
};
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
@@ -30,7 +31,7 @@ function getProxyImage(url: string) {
const createImageIcon = (url: string) => {
return function (_props: LucideProps) {
return <img className="h-4 rounded-[2px] object-contain" src={url} />;
return <img className="max-h-4 rounded-[2px] object-contain" src={url} />;
} as LucideIcon;
};
@@ -42,6 +43,7 @@ const mapper: Record<string, LucideIcon> = {
link_out: ExternalLinkIcon,
// Misc
smarttv: TvIcon,
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
@@ -64,7 +66,8 @@ const mapper: Record<string, LucideIcon> = {
...flags,
};
export function SerieIcon({ name, ...props }: SerieIconProps) {
export function SerieIcon({ name: names, ...props }: SerieIconProps) {
const name = Array.isArray(names) ? names[0] : names;
const Icon = useMemo(() => {
if (!name) {
return null;
@@ -80,6 +83,7 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
return createImageIcon(getProxyImage(name));
}
// Matching image file name
if (name.match(/(.+)\.\w{2,3}$/)) {
return createImageIcon(getProxyImage(`https://${name}`));
}
@@ -88,8 +92,8 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
}, [name]);
return Icon ? (
<div className="[&_a]:![&_a]:!h-4 relative h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} />
<div className="relative max-h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<Icon size={16} {...props} name={name} />
</div>
) : null;
}

View File

@@ -8,9 +8,18 @@ const data = {
'vstat.info': 'https://vstat.info',
'yahoo!': 'https://yahoo.com',
android: 'https://image.similarpng.com/very-thumbnail/2020/08/Android-icon-on-transparent--background-PNG.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',
@@ -19,6 +28,7 @@ const data = {
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',

View File

@@ -0,0 +1,40 @@
import { cn } from '@/utils/cn';
import { ChevronRightIcon } from 'lucide-react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { useChartContext } from './ChartProvider';
interface SerieNameProps {
name: string | string[];
className?: string;
}
export function SerieName({ name, className }: SerieNameProps) {
const chart = useChartContext();
if (Array.isArray(name)) {
if (chart.renderSerieName) {
return chart.renderSerieName(name);
}
return (
<div className={cn('flex items-center gap-1', className)}>
{name.map((n, index) => {
return (
<>
<span>{n || NOT_SET_VALUE}</span>
{name.length - 1 > index && (
<ChevronRightIcon className="text-muted-foreground" size={12} />
)}
</>
);
})}
</div>
);
}
if (chart.renderSerieName) {
return chart.renderSerieName([name]);
}
return <>{name}</>;
}

View File

@@ -1,22 +1,47 @@
'use client';
import { Suspense, useEffect, useState } from 'react';
import type { IChartProps } from '@openpanel/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
import { ChartLoading } from './ChartLoading';
import type { IChartContextType } from './ChartProvider';
import { ChartProvider } from './ChartProvider';
import { MetricCardLoading } from './MetricCard';
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: IChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
export type IChartRoot = IChartContextType;
export function ChartRoot(props: IChartContextType) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
);
}
return <Chart {...props} />;
});
return (
<Suspense
fallback={
props.chartType === 'metric' ? <MetricCardLoading /> : <ChartLoading />
}
>
<ChartProvider {...props}>
{props.chartType === 'funnel' ? <Funnel /> : <Chart />}
</ChartProvider>
</Suspense>
);
}
interface ChartSwitchShortcutProps {
interface ChartRootShortcutProps {
projectId: IChartProps['projectId'];
range?: IChartProps['range'];
previous?: IChartProps['previous'];
@@ -25,16 +50,16 @@ interface ChartSwitchShortcutProps {
events: IChartProps['events'];
}
export const ChartSwitchShortcut = ({
export const ChartRootShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
}: ChartSwitchShortcutProps) => {
}: ChartRootShortcutProps) => {
return (
<ChartSwitch
<ChartRoot
projectId={projectId}
range={range}
breakdowns={[]}

View File

@@ -2,19 +2,15 @@
import { api } from '@/trpc/client';
import type { IChartInput, IChartProps } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import { ChartEmpty } from '../chart/ChartEmpty';
import { withChartProivder } from '../chart/ChartProvider';
import { useChartContext } from '../chart/ChartProvider';
import { FunnelSteps } from './Funnel';
export type ReportChartProps = IChartProps;
export function Funnel() {
const { events, range, projectId } = useChartContext();
export const Funnel = withChartProivder(function Chart({
events,
range,
projectId,
}: ReportChartProps) {
const input: IChartInput = {
events,
range,
@@ -38,4 +34,4 @@ export const Funnel = withChartProivder(function Chart({
<FunnelSteps {...data} input={input} />
</div>
);
});
}

View File

@@ -75,7 +75,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
'bg-def-200 h-10 border-b border-border px-4 text-left align-middle text-sm font-medium text-muted-foreground shadow-[0_0_0_0.5px] shadow-border [&:has([role=checkbox])]:pr-0',
'h-10 border-b border-border bg-def-200 px-4 text-left align-middle text-sm font-medium text-muted-foreground shadow-[0_0_0_0.5px] shadow-border [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
@@ -90,7 +90,7 @@ const TableCell = React.forwardRef<
<td
ref={ref}
className={cn(
'h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border [&:has([role=checkbox])]:pr-0',
'h-12 whitespace-nowrap px-4 align-middle shadow-[0_0_0_0.5px] shadow-border',
className
)}
{...props}

View File

@@ -35,10 +35,13 @@ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltiperProps {
asChild?: boolean;
content: string;
content: React.ReactNode;
children: React.ReactNode;
className?: string;
onClick?: () => void;
side?: 'top' | 'right' | 'bottom' | 'left';
delayDuration?: number;
sideOffset?: number;
}
export function Tooltiper({
asChild,
@@ -46,14 +49,19 @@ export function Tooltiper({
children,
className,
onClick,
side,
delayDuration = 0,
sideOffset = 10,
}: TooltiperProps) {
return (
<Tooltip delayDuration={0}>
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild={asChild} className={className} onClick={onClick}>
{children}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent sideOffset={10}>{content}</TooltipContent>
<TooltipContent sideOffset={sideOffset} side={side}>
{content}
</TooltipContent>
</TooltipPortal>
</Tooltip>
);

View File

@@ -1,7 +1,13 @@
import mappings from '@/mappings.json';
export function useMappings() {
return (val: string | null) => {
return (val: string | string[]): string => {
if (Array.isArray(val)) {
return val
.map((v) => mappings.find((item) => item.id === v)?.name ?? v)
.join('');
}
return mappings.find((item) => item.id === val)?.name ?? val;
};
}

View File

@@ -6,7 +6,7 @@ import { getChartColor } from '@/utils/theme';
export type IRechartPayloadItem = {
id: string;
name: string;
names: string[];
color: string;
event: { id: string; name: string };
count: number;
@@ -39,7 +39,7 @@ export function useRechartDataModel(series: IChartData['series']) {
...item,
id: serie.id,
event: serie.event,
name: serie.name,
names: serie.names,
color: getChartColor(idx),
} satisfies IRechartPayloadItem;
}

View File

@@ -7,12 +7,12 @@ export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
data?.series?.slice(0, max).map((serie) => serie.id) ?? []
);
useEffect(() => {
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
data?.series?.slice(0, max).map((serie) => serie.id) ?? []
);
}, [data, max]);
@@ -23,7 +23,7 @@ export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
...serie,
index,
}))
.filter((serie) => visibleSeries.includes(serie.name)),
.filter((serie) => visibleSeries.includes(serie.id)),
setVisibleSeries,
} as const;
}, [visibleSeries, data.series]);

View File

@@ -1,4 +1,4 @@
import { ChartSwitch } from '@/components/report/chart';
import { ChartRoot } from '@/components/report/chart';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IChartProps } from '@openpanel/validation';
@@ -15,7 +15,7 @@ const OverviewChartDetails = (props: Props) => {
<ModalHeader title={props.chart.name} />
<ScrollArea className="-m-6 max-h-[calc(100vh-200px)]">
<div className="p-6">
<ChartSwitch {...props.chart} limit={999} chartType="bar" />
<ChartRoot {...props.chart} limit={999} chartType="bar" />
</div>
</ScrollArea>
</ModalContent>

View File

@@ -0,0 +1,255 @@
export const countries = {
AF: 'Afghanistan',
AL: 'Albania',
DZ: 'Algeria',
AS: 'American Samoa',
AD: 'Andorra',
AO: 'Angola',
AI: 'Anguilla',
AQ: 'Antarctica',
AG: 'Antigua and Barbuda',
AR: 'Argentina',
AM: 'Armenia',
AW: 'Aruba',
AU: 'Australia',
AT: 'Austria',
AZ: 'Azerbaijan',
BS: 'Bahamas',
BH: 'Bahrain',
BD: 'Bangladesh',
BB: 'Barbados',
BY: 'Belarus',
BE: 'Belgium',
BZ: 'Belize',
BJ: 'Benin',
BM: 'Bermuda',
BT: 'Bhutan',
BO: 'Bolivia',
BQ: 'Bonaire, Sint Eustatius and Saba',
BA: 'Bosnia and Herzegovina',
BW: 'Botswana',
BV: 'Bouvet Island',
BR: 'Brazil',
IO: 'British Indian Ocean Territory',
BN: 'Brunei Darussalam',
BG: 'Bulgaria',
BF: 'Burkina Faso',
BI: 'Burundi',
CV: 'Cabo Verde',
KH: 'Cambodia',
CM: 'Cameroon',
CA: 'Canada',
KY: 'Cayman Islands',
CF: 'Central African Republic',
TD: 'Chad',
CL: 'Chile',
CN: 'China',
CX: 'Christmas Island',
CC: 'Cocos (Keeling) Islands',
CO: 'Colombia',
KM: 'Comoros',
CD: 'Congo (Democratic Republic)',
CG: 'Congo',
CK: 'Cook Islands',
CR: 'Costa Rica',
HR: 'Croatia',
CU: 'Cuba',
CW: 'Curaçao',
CY: 'Cyprus',
CZ: 'Czechia',
CI: "Côte d'Ivoire",
DK: 'Denmark',
DJ: 'Djibouti',
DM: 'Dominica',
DO: 'Dominican Republic',
EC: 'Ecuador',
EG: 'Egypt',
SV: 'El Salvador',
GQ: 'Equatorial Guinea',
ER: 'Eritrea',
EE: 'Estonia',
SZ: 'Eswatina',
ET: 'Ethiopia',
FK: 'Falkland Islands',
FO: 'Faroe Islands',
FJ: 'Fiji',
FI: 'Finland',
FR: 'France',
GF: 'French Guiana',
PF: 'French Polynesia',
TF: 'French Southern Territories',
GA: 'Gabon',
GM: 'Gambia',
GE: 'Georgia',
DE: 'Germany',
GH: 'Ghana',
GI: 'Gibraltar',
GR: 'Greece',
GL: 'Greenland',
GD: 'Grenada',
GP: 'Guadeloupe',
GU: 'Guam',
GT: 'Guatemala',
GG: 'Guernsey',
GN: 'Guinea',
GW: 'Guinea-Bissau',
GY: 'Guyana',
HT: 'Haiti',
HM: 'Heard Island and McDonald Islands',
VA: 'Holy See',
HN: 'Honduras',
HK: 'Hong Kong',
HU: 'Hungary',
IS: 'Iceland',
IN: 'India',
ID: 'Indonesia',
IR: 'Iran',
IQ: 'Iraq',
IE: 'Ireland',
IM: 'Isle of Man',
IL: 'Israel',
IT: 'Italy',
JM: 'Jamaica',
JP: 'Japan',
JE: 'Jersey',
JO: 'Jordan',
KZ: 'Kazakhstan',
KE: 'Kenya',
KI: 'Kiribati',
KP: "Korea (Democratic People's Republic)",
KR: 'Korea (Republic)',
KW: 'Kuwait',
KG: 'Kyrgyzstan',
LA: "Lao People's Democratic Republic",
LV: 'Latvia',
LB: 'Lebanon',
LS: 'Lesotho',
LR: 'Liberia',
LY: 'Libya',
LI: 'Liechtenstein',
LT: 'Lithuania',
LU: 'Luxembourg',
MO: 'Macao',
MG: 'Madagascar',
MW: 'Malawi',
MY: 'Malaysia',
MV: 'Maldives',
ML: 'Mali',
MT: 'Malta',
MH: 'Marshall Islands',
MQ: 'Martinique',
MR: 'Mauritania',
MU: 'Mauritius',
YT: 'Mayotte',
MX: 'Mexico',
FM: 'Micronesia',
MD: 'Moldova',
MC: 'Monaco',
MN: 'Mongolia',
ME: 'Montenegro',
MS: 'Montserrat',
MA: 'Morocco',
MZ: 'Mozambique',
MM: 'Myanmar',
NA: 'Namibia',
NR: 'Nauru',
NP: 'Nepal',
NL: 'Netherlands',
NC: 'New Caledonia',
NZ: 'New Zealand',
NI: 'Nicaragua',
NE: 'Niger',
NG: 'Nigeria',
NU: 'Niue',
NF: 'Norfolk Island',
MP: 'Northern Mariana Islands',
NO: 'Norway',
OM: 'Oman',
PK: 'Pakistan',
PW: 'Palau',
PS: 'Palestine, State of',
PA: 'Panama',
PG: 'Papua New Guinea',
PY: 'Paraguay',
PE: 'Peru',
PH: 'Philippines',
PN: 'Pitcairn',
PL: 'Poland',
PT: 'Portugal',
PR: 'Puerto Rico',
QA: 'Qatar',
MK: 'Republic of North Macedonia',
RO: 'Romania',
RU: 'Russian Federation',
RW: 'Rwanda',
RE: 'Réunion',
BL: 'Saint Barthélemy',
SH: 'Saint Helena, Ascension and Tristan da Cunha',
KN: 'Saint Kitts and Nevis',
LC: 'Saint Lucia',
MF: 'Saint Martin (French part)',
PM: 'Saint Pierre and Miquelon',
VC: 'Saint Vincent and the Grenadines',
WS: 'Samoa',
SM: 'San Marino',
ST: 'Sao Tome and Principe',
SA: 'Saudi Arabia',
SN: 'Senegal',
RS: 'Serbia',
SC: 'Seychelles',
SL: 'Sierra Leone',
SG: 'Singapore',
SX: 'Sint Maarten (Dutch part)',
SK: 'Slovakia',
SI: 'Slovenia',
SB: 'Solomon Islands',
SO: 'Somalia',
ZA: 'South Africa',
GS: 'South Georgia and the South Sandwich Islands',
SS: 'South Sudan',
ES: 'Spain',
LK: 'Sri Lanka',
SD: 'Sudan',
SR: 'Suriname',
SJ: 'Svalbard and Jan Mayen',
SE: 'Sweden',
CH: 'Switzerland',
SY: 'Syrian Arab Republic',
TW: 'Taiwan',
TJ: 'Tajikistan',
TZ: 'Tanzania, United Republic of',
TH: 'Thailand',
TL: 'Timor-Leste',
TG: 'Togo',
TK: 'Tokelau',
TO: 'Tonga',
TT: 'Trinidad and Tobago',
TN: 'Tunisia',
TR: 'Turkey',
TM: 'Turkmenistan',
TC: 'Turks and Caicos Islands',
TV: 'Tuvalu',
UG: 'Uganda',
UA: 'Ukraine',
AE: 'United Arab Emirates',
GB: 'United Kingdom',
US: 'United States',
UM: 'United States Minor Outlying Islands',
UY: 'Uruguay',
UZ: 'Uzbekistan',
VU: 'Vanuatu',
VE: 'Venezuela',
VN: 'Viet Nam',
VG: 'Virgin Islands (British)',
VI: 'Virgin Islands (U.S.)',
WF: 'Wallis and Futuna',
EH: 'Western Sahara',
YE: 'Yemen',
ZM: 'Zambia',
ZW: 'Zimbabwe',
AX: 'Åland Islands',
} as const;
export function getCountry(code?: string) {
return countries[code as keyof typeof countries];
}

View File

@@ -0,0 +1,24 @@
const properties = {
has_profile: 'Has a profile',
name: 'Name',
path: 'Path',
origin: 'Origin',
referrer: 'Referrer',
referrer_name: 'Referrer name',
duration: 'Duration',
created_at: 'Created at',
country: 'Country',
city: 'City',
region: 'Region',
os: 'OS',
os_version: 'OS version',
browser: 'Browser',
browser_version: 'Browser version',
device: 'Device',
brand: 'Brand',
model: 'Model',
};
export function getPropertyLabel(property: string) {
return properties[property as keyof typeof properties] || property;
}

View File

@@ -171,7 +171,7 @@ export async function incomingEvent(job: Job<EventsQueuePayloadIncomingEvent>) {
model: uaInfo?.model ?? '',
duration: 0,
path: path,
origin: origin,
origin: origin || sessionStartEvent?.origin || '',
referrer: referrer?.url,
referrerName: referrer?.name || utmReferrer?.name || '',
referrerType: referrer?.type || utmReferrer?.type || '',

View File

@@ -16,7 +16,16 @@ import type { IInterval } from '@openpanel/validation';
// Define the data structure
export interface ISerieDataItem {
label: string | null | undefined;
label_0: string | null | undefined;
label_1?: string | null | undefined;
label_2?: string | null | undefined;
label_3?: string | null | undefined;
count: number;
date: string;
}
export interface ISerieDataItemComplete {
labels: string[];
count: number;
date: string;
}
@@ -37,6 +46,39 @@ function roundDate(date: Date, interval: IInterval): Date {
}
}
function filterFalsyAfterTruthy(array: (string | undefined | null)[]) {
let foundTruthy = false;
const filtered = array.filter((item) => {
if (foundTruthy) {
// After a truthy, filter out falsy values
return !!item;
}
if (item) {
// Mark when the first truthy is encountered
foundTruthy = true;
}
// Return all elements until the first truthy is found
return true;
});
if (filtered.some((item) => !!item)) {
return filtered;
}
return [null];
}
function concatLabels(entry: ISerieDataItem): string {
return filterFalsyAfterTruthy([
entry.label_0,
entry.label_1,
entry.label_2,
entry.label_3,
])
.map((label) => label || NOT_SET_VALUE)
.join(':::');
}
// Function to complete the timeline for each label
export function completeSerie(
data: ISerieDataItem[],
@@ -51,23 +93,23 @@ export function completeSerie(
data.forEach((entry) => {
const roundedDate = roundDate(parseISO(entry.date), interval);
const dateKey = format(roundedDate, 'yyyy-MM-dd HH:mm:ss');
const label = entry.label || NOT_SET_VALUE;
const label = concatLabels(entry) || NOT_SET_VALUE;
if (!labelsMap.has(label)) {
labelsMap.set(label, new Map());
}
const labelData = labelsMap.get(label);
labelData?.set(dateKey, (labelData.get(dateKey) || 0) + (entry.count || 0));
const labelData = labelsMap.get(label)!;
labelData.set(dateKey, (labelData.get(dateKey) || 0) + (entry.count || 0));
});
// Complete the timeline for each label
const result: Record<string, ISerieDataItem[]> = {};
const result: Record<string, ISerieDataItemComplete[]> = {};
labelsMap.forEach((counts, label) => {
let currentDate = roundDate(startDate, interval);
result[label] = [];
while (currentDate <= endDate) {
const dateKey = format(currentDate, 'yyyy-MM-dd HH:mm:ss');
result[label]!.push({
label: label,
labels: label.split(':::'),
date: dateKey,
count: counts.get(dateKey) || 0,
});

View File

@@ -15,7 +15,7 @@ export function toDots(
return {
...acc,
[`${path}${key}`]: value,
[`${path}${key}`]: typeof value === 'string' ? value.trim() : value,
};
}, {});
}

View File

@@ -24,10 +24,10 @@ export function getChartSql({
sb.where.projectId = `project_id = ${escape(projectId)}`;
if (event.name !== '*') {
sb.select.label = `${escape(event.name)} as label`;
sb.select.label_0 = `${escape(event.name)} as label_0`;
sb.where.eventName = `name = ${escape(event.name)}`;
} else {
sb.select.label = `'*' as label`;
sb.select.label_0 = `'*' as label_0`;
}
sb.select.count = `count(*) as count`;
@@ -60,11 +60,11 @@ export function getChartSql({
}
breakdowns.forEach((breakdown, index) => {
const key = index === 0 ? 'label' : `label_${index}`;
const key = `label_${index}`;
const value = breakdown.name.startsWith('properties.')
? `mapValues(mapExtractKeyLike(properties, ${escape(
? `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
breakdown.name.replace(/^properties\./, '').replace('.*.', '.%.')
)}))`
)})))`
: escape(breakdown.name);
sb.select[key] = breakdown.name.startsWith('properties.')
? `arrayElement(${value}, 1) as ${key}`
@@ -125,9 +125,9 @@ export function getEventFiltersWhereClause(filters: IChartEventFilter[]) {
}
if (name.startsWith('properties.')) {
const whereFrom = `mapValues(mapExtractKeyLike(properties, ${escape(
const whereFrom = `arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
name.replace(/^properties\./, '').replace('.*.', '.%.')
)}))`;
)})))`;
switch (operator) {
case 'is': {

View File

@@ -458,14 +458,17 @@ export async function getChartSerie(payload: IGetChartDataInput) {
completeSerie(data, payload.startDate, payload.endDate, payload.interval)
)
.then((series) => {
return Object.keys(series).map((label) => {
return Object.keys(series).map((key) => {
const firstDataItem = series[key]![0]!;
const isBreakdown =
payload.breakdowns.length && !alphabetIds.includes(label as 'A');
const serieLabel = isBreakdown ? label : getEventLegend(payload.event);
payload.breakdowns.length && firstDataItem.labels.length;
const serieLabel = isBreakdown
? firstDataItem.labels
: [getEventLegend(payload.event)];
return {
name: serieLabel,
event: payload.event,
data: series[label]!.map((item) => ({
data: series[key]!.map((item) => ({
...item,
date: toDynamicISODateWithTZ(
item.date,
@@ -523,7 +526,7 @@ export async function getChart(input: IChartInput) {
const final: FinalChart = {
series: series.map((serie) => {
const previousSerie = previousSeries?.find(
(item) => item.name === serie.name
(item) => item.name.join('-') === serie.name.join('-')
);
const metrics = {
sum: sum(serie.data.map((item) => item.count)),
@@ -533,8 +536,8 @@ export async function getChart(input: IChartInput) {
};
return {
id: slug(serie.name),
name: serie.name,
id: slug(serie.name.join('-')),
names: serie.name,
event: {
id: serie.event.id!,
name: serie.event.displayName ?? serie.event.name,

View File

@@ -102,9 +102,9 @@ export const chartRouter = createTRPCRouter({
sb.where.event = `name = ${escape(event)}`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, ${escape(
sb.select.values = `distinct arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
)})) as values`;
)}))) as values`;
} else {
sb.select.values = `distinct ${property} as values`;
}

View File

@@ -40,9 +40,9 @@ export const profileRouter = createTRPCRouter({
sb.from = 'profiles';
sb.where.project_id = `project_id = ${escape(projectId)}`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, ${escape(
sb.select.values = `distinct arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
property.replace(/^properties\./, '').replace('.*.', '.%.')
)})) as values`;
)}))) as values`;
} else {
sb.select.values = `${property} as values`;
}

View File

@@ -65,7 +65,7 @@ export type Metrics = {
export type IChartSerie = {
id: string;
name: string;
names: string[];
event: {
id: string;
name: string;