diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/charts/events-per-day-chart.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/charts/events-per-day-chart.tsx index 86be57f7..6834e054 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/charts/events-per-day-chart.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/charts/events-per-day-chart.tsx @@ -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) { Events per day - - { Page views - + @@ -93,7 +93,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => { Events per day - + diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/report-editor.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/report-editor.tsx index e4295749..995b55f0 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/report-editor.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/reports/report-editor.tsx @@ -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({
{report.ready && ( - + )}
diff --git a/apps/dashboard/src/components/color-square.tsx b/apps/dashboard/src/components/color-square.tsx index 52f79340..61fbf8d1 100644 --- a/apps/dashboard/src/components/color-square.tsx +++ b/apps/dashboard/src/components/color-square.tsx @@ -1,19 +1,13 @@ import type { HtmlProps } from '@/types'; import { cn } from '@/utils/cn'; -import { useChartContext } from './report/chart/ChartProvider'; - type ColorSquareProps = HtmlProps; export function ColorSquare({ children, className }: ColorSquareProps) { - const { hideID } = useChartContext(); - if (hideID) { - return null; - } return (
diff --git a/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx b/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx index 2a7a3460..02a4ee16 100644 --- a/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx +++ b/apps/dashboard/src/components/overview/filters/overview-filters-buttons.tsx @@ -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')} > - {filter.name} is + {getPropertyLabel(filter.name)} is {filter.value[0]} ); diff --git a/apps/dashboard/src/components/overview/overview-metrics.tsx b/apps/dashboard/src/components/overview/overview-metrics.tsx index 64fcbab5..f67219a8 100644 --- a/apps/dashboard/src/components/overview/overview-metrics.tsx +++ b/apps/dashboard/src/components/overview/overview-metrics.tsx @@ -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); }} > - + ))}
- { 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; } }} diff --git a/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx index 63e7e3d1..0d759176 100644 --- a/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx +++ b/apps/dashboard/src/components/overview/overview-top-events/overview-top-events.tsx @@ -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', diff --git a/apps/dashboard/src/components/overview/overview-top-geo.tsx b/apps/dashboard/src/components/overview/overview-top-geo.tsx index a811e5d8..62b43ba2 100644 --- a/apps/dashboard/src/components/overview/overview-top-geo.tsx +++ b/apps/dashboard/src/components/overview/overview-top-geo.tsx @@ -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) {
Map
- ('bar'); const [filters, setFilter] = useEventQueryFilters(); + const renderSerieName = (names: string[]) => { + return ( + + {names[1] || NOT_SET_VALUE} + + ); + }; 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 && } diff --git a/apps/dashboard/src/components/overview/overview-top-sources.tsx b/apps/dashboard/src/components/overview/overview-top-sources.tsx index ca03a5b4..1ddc8044 100644 --- a/apps/dashboard/src/components/overview/overview-top-sources.tsx +++ b/apps/dashboard/src/components/overview/overview-top-sources.tsx @@ -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; } }} diff --git a/apps/dashboard/src/components/overview/useOverviewWidget.tsx b/apps/dashboard/src/components/overview/useOverviewWidget.tsx index 50bdd238..9203f33d 100644 --- a/apps/dashboard/src/components/overview/useOverviewWidget.tsx +++ b/apps/dashboard/src/components/overview/useOverviewWidget.tsx @@ -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( 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[]; diff --git a/apps/dashboard/src/components/report/chart/Chart.tsx b/apps/dashboard/src/components/report/chart/Chart.tsx index 674dfa9d..5f055f26 100644 --- a/apps/dashboard/src/components/report/chart/Chart.tsx +++ b/apps/dashboard/src/components/report/chart/Chart.tsx @@ -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 ; + return ; } if (chartType === 'bar') { @@ -89,20 +78,11 @@ export function Chart({ } if (chartType === 'linear') { - return ( - - ); + return ; } if (chartType === 'area') { - return ( - - ); + return ; } return

Unknown chart type

; diff --git a/apps/dashboard/src/components/report/chart/ChartProvider.tsx b/apps/dashboard/src/components/report/chart/ChartProvider.tsx index 4a30cca7..8725740f 100644 --- a/apps/dashboard/src/components/report/chart/ChartProvider.tsx +++ b/apps/dashboard/src/components/report/chart/ChartProvider.tsx @@ -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({ - events: [], - breakdowns: [], - chartType: 'linear', - lineType: 'monotone', - interval: 'day', - name: '', - range: '7d', - metric: 'sum', - previous: false, - projectId: '', - limit: undefined, -}); +const ChartContext = createContext(null); -export function ChartProvider({ - children, - editMode, - previous, - hideID, - limit, - ...props -}: ChartProviderProps) { +export function ChartProvider({ children, ...props }: IChartProviderProps) { return ( - ({ - ...props, - editMode: editMode ?? false, - previous: previous ?? false, - hideID: hideID ?? false, - limit, - }), - [editMode, previous, hideID, limit, props] - )} - > - {children} - + {children} ); } -export function withChartProivder( - WrappedComponent: React.FC -) { - const WithChartProvider = (props: ComponentProps & ChartContextType) => { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return props.chartType === 'metric' ? ( - - ) : ( - - ); - } - - return ( - - ) : ( - - ) - } - > - - - - - ); - }; - - WithChartProvider.displayName = `WithChartProvider(${ - WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component' - })`; - - return memo(WithChartProvider); -} - export function useChartContext() { return useContext(ChartContext)!; } diff --git a/apps/dashboard/src/components/report/chart/LazyChart.tsx b/apps/dashboard/src/components/report/chart/LazyChart.tsx index ee53f8ad..a3c72832 100644 --- a/apps/dashboard/src/components/report/chart/LazyChart.tsx +++ b/apps/dashboard/src/components/report/chart/LazyChart.tsx @@ -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(null); const once = useRef(false); const { inViewport } = useInViewport(ref, undefined, { @@ -23,7 +23,7 @@ export function LazyChart(props: ChartContextType) { return (
{once.current || inViewport ? ( - + ) : ( )} diff --git a/apps/dashboard/src/components/report/chart/MetricCard.tsx b/apps/dashboard/src/components/report/chart/MetricCard.tsx index dd05c9ca..9238ec6a 100644 --- a/apps/dashboard/src/components/report/chart/MetricCard.tsx +++ b/apps/dashboard/src/components/report/chart/MetricCard.tsx @@ -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 (
@@ -91,9 +92,8 @@ export function MetricCard({
- {serie.event.id} - {serie.name} +
{/* */} diff --git a/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx b/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx index 62554fa4..3ce2802e 100644 --- a/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportAreaChart.tsx @@ -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 ( - + (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 ( -
onClick(serie) } : {})} - > -
-
-
- - {serie.name} -
-
- + +
onClick?.(serie) } + : {})} + > +
- {serie.metrics.previous?.[metric]?.value} -
- {number.format( - round((serie.metrics.sum / data.metrics.sum) * 100, 2) - )} - % -
-
- {number.format(serie.metrics.sum)} +
+
+ + +
+
+ + {serie.metrics.previous?.[metric]?.value} +
+ {number.format( + round((serie.metrics.sum / data.metrics.sum) * 100, 2) + )} + % +
+
+ {number.format(serie.metrics.sum)} +
+
-
-
+
+ + + {dropdownMenuContent?.(serie).map((item) => ( + + {item.icon && } + {item.title} + + ))} + + + ); })}
diff --git a/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx index eddc3905..7fe3554c 100644 --- a/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx +++ b/apps/dashboard/src/components/report/chart/ReportChartTooltip.tsx @@ -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 ( - + {index === 0 && data.date && (
{formatDate(new Date(data.date))}
@@ -65,8 +67,9 @@ export function ReportChartTooltip({ style={{ background: data.color }} />
-
- {getLabel(data.name)} +
+ +
{number.formatWithUnit(data.count, unit)}
diff --git a/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx b/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx index 24feab88..51dd8cd2 100644 --- a/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportHistogramChart.tsx @@ -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 ( - + {previous && ( )} - {props.payload - .filter((entry) => !entry.value.includes('noTooltip')) - .filter((entry) => !entry.value.includes(':prev')) - .map((entry) => ( -
- -
- {entry.value} -
-
- ))} -
+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({ ); - 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 ( +
+ {series.map((serie) => ( +
+ + +
+ ))} +
+ ); + }, [series]); + + const isAreaStyle = series.length === 1; return ( <> {({ width, height }) => ( - + - {references.map((ref) => ( + {references.data?.map((ref) => ( {series.map((serie) => { + const color = getChartColor(serie.index); return ( - + + {isAreaStyle && ( + + + + + )} {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({ + {isAreaStyle && ( + + )} {useDashedLastLine && ( <> )} ); })} - + )} {editMode && ( diff --git a/apps/dashboard/src/components/report/chart/ReportMapChart.tsx b/apps/dashboard/src/components/report/chart/ReportMapChart.tsx index d14401c0..312ef37f 100644 --- a/apps/dashboard/src/components/report/chart/ReportMapChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportMapChart.tsx @@ -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] diff --git a/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx b/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx index 2474e3dc..5fd585f2 100644 --- a/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx +++ b/apps/dashboard/src/components/report/chart/ReportMetricChart.tsx @@ -24,7 +24,7 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) { {series.map((serie) => { return ( '), count: serie.metrics.sum, percent: serie.metrics.sum / sum, })); diff --git a/apps/dashboard/src/components/report/chart/ReportTable.tsx b/apps/dashboard/src/components/report/chart/ReportTable.tsx index 12dd8bd5..f1dc2920 100644 --- a/apps/dashboard/src/components/report/chart/ReportTable.tsx +++ b/apps/dashboard/src/components/report/chart/ReportTable.tsx @@ -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 ( <> -
+
- Name + {breakdowns.length === 0 && Name} + {breakdowns.map((breakdown) => ( + + {getPropertyLabel(breakdown.name)} + + ))} - + {paginate(data.series).map((serie, index) => { const checked = !!visibleSeries.find( - (item) => item.name === serie.name + (item) => item.id === serie.id ); return ( - - -
- - handleChange(serie.name, !!checked) - } - style={ - checked - ? { - background: getChartColor(index), - borderColor: getChartColor(index), - } - : undefined - } - checked={checked} - /> - - -
- {getLabel(serie.name)} -
-
- -

{getLabel(serie.name)}

-
-
-
-
+ + {serie.names.map((name, nameIndex) => { + return ( + +
+ {nameIndex === 0 ? ( + <> + + handleChange(serie.id, !!checked) + } + style={ + checked + ? { + background: getChartColor(index), + borderColor: getChartColor(index), + } + : undefined + } + checked={checked} + /> + } + > + {name} + + + ) : ( + + )} +
+
+ ); + })}
); })} @@ -122,7 +137,7 @@ export function ReportTable({ {paginate(data.series).map((serie) => { return ( - +
{number.format(serie.metrics.sum)} diff --git a/apps/dashboard/src/components/report/chart/SerieIcon.flags.tsx b/apps/dashboard/src/components/report/chart/SerieIcon.flags.tsx index 84ce060d..a3ea22f5 100644 --- a/apps/dashboard/src/components/report/chart/SerieIcon.flags.tsx +++ b/apps/dashboard/src/components/report/chart/SerieIcon.flags.tsx @@ -4,7 +4,7 @@ const createFlagIcon = (url: string) => { return function (_props: LucideProps) { return ( ); } as LucideIcon; diff --git a/apps/dashboard/src/components/report/chart/SerieIcon.tsx b/apps/dashboard/src/components/report/chart/SerieIcon.tsx index 04f8eee5..57ded279 100644 --- a/apps/dashboard/src/components/report/chart/SerieIcon.tsx +++ b/apps/dashboard/src/components/report/chart/SerieIcon.tsx @@ -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 & { + 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 ; + return ; } as LucideIcon; }; @@ -42,6 +43,7 @@ const mapper: Record = { link_out: ExternalLinkIcon, // Misc + smarttv: TvIcon, mobile: SmartphoneIcon, desktop: MonitorIcon, tablet: TabletIcon, @@ -64,7 +66,8 @@ const mapper: Record = { ...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 ? ( -
- +
+
) : null; } diff --git a/apps/dashboard/src/components/report/chart/SerieIcon.urls.ts b/apps/dashboard/src/components/report/chart/SerieIcon.urls.ts index d57450bc..4f0cdb04 100644 --- a/apps/dashboard/src/components/report/chart/SerieIcon.urls.ts +++ b/apps/dashboard/src/components/report/chart/SerieIcon.urls.ts @@ -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', diff --git a/apps/dashboard/src/components/report/chart/SerieName.tsx b/apps/dashboard/src/components/report/chart/SerieName.tsx new file mode 100644 index 00000000..c53a0547 --- /dev/null +++ b/apps/dashboard/src/components/report/chart/SerieName.tsx @@ -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 ( +
+ {name.map((n, index) => { + return ( + <> + {n || NOT_SET_VALUE} + {name.length - 1 > index && ( + + )} + + ); + })} +
+ ); + } + + if (chart.renderSerieName) { + return chart.renderSerieName([name]); + } + + return <>{name}; +} diff --git a/apps/dashboard/src/components/report/chart/index.tsx b/apps/dashboard/src/components/report/chart/index.tsx index 595d0276..7032cae7 100644 --- a/apps/dashboard/src/components/report/chart/index.tsx +++ b/apps/dashboard/src/components/report/chart/index.tsx @@ -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 ; +export type IChartRoot = IChartContextType; + +export function ChartRoot(props: IChartContextType) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return props.chartType === 'metric' ? ( + + ) : ( + + ); } - return ; -}); + return ( + : + } + > + + {props.chartType === 'funnel' ? : } + + + ); +} -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 ( -
); -}); +} diff --git a/apps/dashboard/src/components/ui/table.tsx b/apps/dashboard/src/components/ui/table.tsx index cfa93b2d..77cb3a9a 100644 --- a/apps/dashboard/src/components/ui/table.tsx +++ b/apps/dashboard/src/components/ui/table.tsx @@ -75,7 +75,7 @@ const TableHead = React.forwardRef<
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 ( - + {children} - {content} + + {content} + ); diff --git a/apps/dashboard/src/hooks/useMappings.ts b/apps/dashboard/src/hooks/useMappings.ts index 35130c0c..c32a1b4e 100644 --- a/apps/dashboard/src/hooks/useMappings.ts +++ b/apps/dashboard/src/hooks/useMappings.ts @@ -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; }; } diff --git a/apps/dashboard/src/hooks/useRechartDataModel.ts b/apps/dashboard/src/hooks/useRechartDataModel.ts index a8dabedc..858641bc 100644 --- a/apps/dashboard/src/hooks/useRechartDataModel.ts +++ b/apps/dashboard/src/hooks/useRechartDataModel.ts @@ -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; } diff --git a/apps/dashboard/src/hooks/useVisibleSeries.ts b/apps/dashboard/src/hooks/useVisibleSeries.ts index 70898234..957b1362 100644 --- a/apps/dashboard/src/hooks/useVisibleSeries.ts +++ b/apps/dashboard/src/hooks/useVisibleSeries.ts @@ -7,12 +7,12 @@ export type IVisibleSeries = ReturnType['series']; export function useVisibleSeries(data: IChartData, limit?: number | undefined) { const max = limit ?? 5; const [visibleSeries, setVisibleSeries] = useState( - 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]); diff --git a/apps/dashboard/src/modals/OverviewChartDetails.tsx b/apps/dashboard/src/modals/OverviewChartDetails.tsx index eccf7c6e..4700c847 100644 --- a/apps/dashboard/src/modals/OverviewChartDetails.tsx +++ b/apps/dashboard/src/modals/OverviewChartDetails.tsx @@ -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) => {
- +
diff --git a/apps/dashboard/src/translations/countries.ts b/apps/dashboard/src/translations/countries.ts new file mode 100644 index 00000000..3cb29811 --- /dev/null +++ b/apps/dashboard/src/translations/countries.ts @@ -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]; +} diff --git a/apps/dashboard/src/translations/properties.ts b/apps/dashboard/src/translations/properties.ts new file mode 100644 index 00000000..c155b02c --- /dev/null +++ b/apps/dashboard/src/translations/properties.ts @@ -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; +} diff --git a/apps/worker/src/jobs/events.incoming-event.ts b/apps/worker/src/jobs/events.incoming-event.ts index 1b8af943..10bbc545 100644 --- a/apps/worker/src/jobs/events.incoming-event.ts +++ b/apps/worker/src/jobs/events.incoming-event.ts @@ -171,7 +171,7 @@ export async function incomingEvent(job: Job) { 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 || '', diff --git a/packages/common/src/fill-series.ts b/packages/common/src/fill-series.ts index 2de6e7cb..fe2abd88 100644 --- a/packages/common/src/fill-series.ts +++ b/packages/common/src/fill-series.ts @@ -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 = {}; + const result: Record = {}; 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, }); diff --git a/packages/common/src/object.ts b/packages/common/src/object.ts index 0a456abb..aa849557 100644 --- a/packages/common/src/object.ts +++ b/packages/common/src/object.ts @@ -15,7 +15,7 @@ export function toDots( return { ...acc, - [`${path}${key}`]: value, + [`${path}${key}`]: typeof value === 'string' ? value.trim() : value, }; }, {}); } diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index cbfdec9f..a5568bbd 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -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': { diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts index a29c376f..e1709b9a 100644 --- a/packages/trpc/src/routers/chart.helpers.ts +++ b/packages/trpc/src/routers/chart.helpers.ts @@ -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, diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 006f4262..8d1bafa8 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -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`; } diff --git a/packages/trpc/src/routers/profile.ts b/packages/trpc/src/routers/profile.ts index aed54b43..43b2045f 100644 --- a/packages/trpc/src/routers/profile.ts +++ b/packages/trpc/src/routers/profile.ts @@ -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`; } diff --git a/packages/validation/src/types.validation.ts b/packages/validation/src/types.validation.ts index 0270c778..0e13dafe 100644 --- a/packages/validation/src/types.validation.ts +++ b/packages/validation/src/types.validation.ts @@ -65,7 +65,7 @@ export type Metrics = { export type IChartSerie = { id: string; - name: string; + names: string[]; event: { id: string; name: string;