This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-04 13:23:21 +01:00
parent 30af9cab2f
commit ccd1a1456f
135 changed files with 5588 additions and 1758 deletions

View File

@@ -1,9 +1,15 @@
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(

View File

@@ -1,7 +1,15 @@
export function Logo() {
import { cn } from '@/utils/cn';
interface LogoProps {
className?: string;
}
export function Logo({ className }: LogoProps) {
return (
<div className="text-xl font-medium flex gap-2 items-center">
<img src="/logo.svg" className="max-h-10" />
<div
className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<img src="/logo.svg" className="max-h-8 rounded-md" />
openpanel.dev
</div>
);

View File

@@ -1,6 +1,6 @@
import { cn } from '@/utils/cn';
interface WidgetHeadProps {
export interface WidgetHeadProps {
children: React.ReactNode;
className?: string;
}
@@ -17,7 +17,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
);
}
interface WidgetBodyProps {
export interface WidgetBodyProps {
children: React.ReactNode;
className?: string;
}
@@ -25,7 +25,7 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
return <div className={cn('p-4', className)}>{children}</div>;
}
interface WidgetProps {
export interface WidgetProps {
children: React.ReactNode;
className?: string;
}

View File

@@ -1,4 +1,4 @@
import { toDots } from '@/utils/object';
import { toDots } from '@mixan/common';
import { Table, TableBody, TableCell, TableRow } from '../ui/table';

View File

@@ -1,9 +1,10 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import { strip } from '@/utils/object';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { strip } from '@mixan/common';
import { NavbarUserDropdown } from './NavbarUserDropdown';
function Item({

View File

@@ -0,0 +1,129 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
const options = useOverviewOptions();
return (
<>
{options.referrer && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setReferrer(null)}
>
{options.referrer}
</Button>
)}
{options.device && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setDevice(null)}
>
{options.device}
</Button>
)}
{options.page && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setPage(null)}
>
{options.page}
</Button>
)}
{options.utmSource && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmSource(null)}
>
{options.utmSource}
</Button>
)}
{options.utmMedium && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmMedium(null)}
>
{options.utmMedium}
</Button>
)}
{options.utmCampaign && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmCampaign(null)}
>
{options.utmCampaign}
</Button>
)}
{options.utmTerm && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmTerm(null)}
>
{options.utmTerm}
</Button>
)}
{options.utmContent && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setUtmContent(null)}
>
{options.utmContent}
</Button>
)}
{options.country && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setCountry(null)}
>
{options.country}
</Button>
)}
{options.region && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setRegion(null)}
>
{options.region}
</Button>
)}
{options.city && (
<Button
size="sm"
variant="ghost"
icon={X}
onClick={() => options.setCity(null)}
>
{options.city}
</Button>
)}
</>
);
}

View File

@@ -0,0 +1,121 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFilters() {
const { projectId } = useAppParams();
const options = useOverviewOptions();
const { data: referrers } = api.chart.values.useQuery({
projectId,
property: 'referrer',
event: 'session_start',
});
const { data: devices } = api.chart.values.useQuery({
projectId,
property: 'device',
event: 'session_start',
});
const { data: pages } = api.chart.values.useQuery({
projectId,
property: 'path',
event: 'screen_view',
});
return (
<div>
<h2 className="text-xl font-medium mb-8">Overview filters</h2>
<div className="flex flex-col gap-4">
<div>
<Label className="flex justify-between">
Referrer
<button
className={cn(
'text-slate-500 transition-opacity opacity-100',
options.referrer === null && 'opacity-0'
)}
onClick={() => options.setReferrer(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setReferrer(value)}
label="Referrer"
placeholder="Referrer"
items={
referrers?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.referrer}
/>
</div>
<div>
<Label className="flex justify-between">
Device
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.device === null && 'opacity-0'
)}
onClick={() => options.setDevice(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setDevice(value)}
label="Device"
placeholder="Device"
items={
devices?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.device}
/>
</div>
<div>
<Label className="flex justify-between">
Page
<button
className={cn(
'opacity-100 text-slate-500 transition-opacity',
options.page === null && 'opacity-0'
)}
onClick={() => options.setPage(null)}
>
Reset
</button>
</Label>
<Combobox
className="w-full"
onChange={(value) => options.setPage(value)}
label="Page"
placeholder="Page"
items={
pages?.values?.filter(Boolean)?.map((value) => ({
value,
label: value,
})) ?? []
}
value={options.page}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
btn: 'Devices',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'device',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser: {
title: 'Top browser',
btn: 'Browser',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
browser_version: {
title: 'Top Browser Version',
btn: 'Browser Version',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'browser_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os: {
title: 'Top OS',
btn: 'OS',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
os_version: {
title: 'Top OS version',
btn: 'OS Version',
chart: {
projectId: '',
events: [
{
segment: 'user',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'os_version',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopEvents() {
const { filters, interval, range, previous } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,171 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopGeo() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: {
title: 'Map',
btn: 'Map',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
countries: {
title: 'Top countries',
btn: 'Countries',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'country',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
regions: {
title: 'Top regions',
btn: 'Regions',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'region',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
cities: {
title: 'Top cities',
btn: 'Cities',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'city',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setCountry(item.name);
break;
case 'regions':
setWidget('cities');
setRegion(item.name);
break;
case 'cities':
setCity(item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { Chart } from '@/components/report/chart';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopPages() {
const { filters, interval, range, previous, setPage } = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
btn: 'Top pages',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'screen_view',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
entries: {
title: 'Entry Pages',
btn: 'Entries',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
exits: {
title: 'Exit Pages',
btn: 'Exits',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_end',
},
],
breakdowns: [
{
id: 'A',
name: 'path',
},
],
chartType: 'bar',
lineType: 'monotone',
interval,
name: 'Top sources',
range,
previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,245 @@
'use client';
import { Chart } from '@/components/report/chart';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopSources() {
const {
filters,
interval,
range,
previous,
setReferrer,
setUtmSource,
setUtmMedium,
setUtmCampaign,
setUtmTerm,
setUtmContent,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
btn: 'All',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters: filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'referrer',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_source: {
title: 'UTM Source',
btn: 'Source',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_source',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_medium: {
title: 'UTM Medium',
btn: 'Medium',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_medium',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_campaign: {
title: 'UTM Campaign',
btn: 'Campaign',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_campaign',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_term: {
title: 'UTM Term',
btn: 'Term',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_term',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
utm_content: {
title: 'UTM Content',
btn: 'Content',
chart: {
projectId: '',
events: [
{
segment: 'event',
filters,
id: 'A',
name: 'session_start',
},
],
breakdowns: [
{
id: 'A',
name: 'properties.query.utm_content',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead className="flex items-center justify-between">
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrer(item.name);
break;
case 'utm_source':
setUtmSource(item.name);
break;
case 'utm_medium':
setUtmMedium(item.name);
break;
case 'utm_campaign':
setUtmCampaign(item.name);
break;
case 'utm_term':
setUtmTerm(item.name);
break;
case 'utm_content':
setUtmContent(item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/utils/cn';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn('flex items-center justify-between', className)}
{...props}
/>
);
}
export function WidgetButtons({ className, ...props }: WidgetHeadProps) {
return (
<div
className={cn(
'flex gap-2 [&_button]:text-xs [&_button]:opacity-50 [&_button.active]:opacity-100',
className
)}
{...props}
/>
);
}

View File

@@ -0,0 +1,234 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { mapKeys } from '@/utils/validation';
import {
parseAsBoolean,
parseAsInteger,
parseAsString,
parseAsStringEnum,
useQueryState,
} from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {
const [previous, setPrevious] = useQueryState(
'name',
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeRanges))
.withDefault('7d')
.withOptions(nuqsOptions)
);
const interval = getDefaultIntervalByRange(range);
const [metric, setMetric] = useQueryState(
'metric',
parseAsInteger.withDefault(0).withOptions(nuqsOptions)
);
// Filters
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
);
const [utmMedium, setUtmMedium] = useQueryState(
'utm_medium',
parseAsString.withOptions(nuqsOptions)
);
const [utmCampaign, setUtmCampaign] = useQueryState(
'utm_campaign',
parseAsString.withOptions(nuqsOptions)
);
const [utmContent, setUtmContent] = useQueryState(
'utm_content',
parseAsString.withOptions(nuqsOptions)
);
const [utmTerm, setUtmTerm] = useQueryState(
'utm_term',
parseAsString.withOptions(nuqsOptions)
);
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
);
const [region, setRegion] = useQueryState(
'region',
parseAsString.withOptions(nuqsOptions)
);
const [city, setCity] = useQueryState(
'city',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (page) {
filters.push({
id: 'path',
operator: 'is',
name: 'path',
value: [page],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device',
value: [device],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
operator: 'is',
name: 'properties.query.utm_source',
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utm_medium',
operator: 'is',
name: 'properties.query.utm_medium',
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utm_campaign',
operator: 'is',
name: 'properties.query.utm_campaign',
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utm_content',
operator: 'is',
name: 'properties.query.utm_content',
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utm_term',
operator: 'is',
name: 'properties.query.utm_term',
value: [utmTerm],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country',
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region',
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city',
value: [city],
});
}
return filters;
}, [
referrer,
page,
device,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
country,
region,
city,
]);
return {
previous,
setPrevious,
range,
setRange,
metric,
setMetric,
referrer,
setReferrer,
device,
setDevice,
page,
setPage,
// Computed
interval,
filters,
// UTM
utmSource,
setUtmSource,
utmMedium,
setUtmMedium,
utmCampaign,
setUtmCampaign,
utmContent,
setUtmContent,
utmTerm,
setUtmTerm,
// GEO
country,
setCountry,
region,
setRegion,
city,
setCity,
};
}

View File

@@ -0,0 +1,27 @@
import type { IChartInput } from '@/types';
import { mapKeys } from '@/utils/validation';
import { parseAsStringEnum, useQueryState } from 'nuqs';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; chart: IChartInput }>
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(
key,
parseAsStringEnum(keys)
.withDefault(keys[0]!)
.withOptions({ history: 'push' })
);
return [
{
...widgets[widget]!,
key: widget,
},
setWidget,
mapKeys(widgets).map((key) => ({
...widgets[key],
key,
})),
] as const;
}

View File

@@ -1,13 +1,15 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider';
interface PreviousDiffIndicatorProps {
diff?: number | null | undefined;
state?: string | null | undefined;
children?: React.ReactNode;
inverted?: boolean;
}
export function PreviousDiffIndicator({
@@ -15,25 +17,38 @@ export function PreviousDiffIndicator({
state,
children,
}: PreviousDiffIndicatorProps) {
const { previous } = useChartContext();
const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return children ?? null;
}
if (previousIndicatorInverted === true) {
return (
<>
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'destructive' : 'success'}
>
{state === 'negative' && <TrendingUpIcon size={15} />}
{state === 'positive' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</Badge>
{children}
</>
);
}
return (
<>
<div
className={cn('flex items-center', [
state === 'positive' && 'text-emerald-500',
state === 'negative' && 'text-rose-500',
state === 'neutral' && 'text-slate-400',
])}
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'success' : 'destructive'}
>
{state === 'positive' && <ChevronUp size={20} />}
{state === 'negative' && <ChevronDown size={20} />}
{state === 'positive' && <TrendingUpIcon size={15} />}
{state === 'negative' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</div>
</Badge>
{children}
</>
);

View File

@@ -1,28 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { timeRanges } from '@/utils/constants';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { changeDateRanges } from './reportSlice';
export function ReportDateRange() {
const dispatch = useDispatch();
const range = useSelector((state) => state.report.range);
return (
<RadioGroup className="overflow-auto">
{Object.values(timeRanges).map((key) => {
return (
<RadioGroupItem
key={key}
active={key === range}
onClick={() => {
dispatch(changeDateRanges(key));
}}
>
{key}
</RadioGroupItem>
);
})}
</RadioGroup>
);
}

View File

@@ -0,0 +1,20 @@
import type { IChartRange } from '@/types';
import { timeRanges } from '@/utils/constants';
import { CalendarIcon } from 'lucide-react';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { Combobox } from '../ui/combobox';
export function ReportRange(props: ExtendedComboboxProps<IChartRange>) {
return (
<Combobox
icon={CalendarIcon}
placeholder={'Range'}
items={Object.values(timeRanges).map((key) => ({
label: key,
value: key,
}))}
{...props}
/>
);
}

View File

@@ -7,7 +7,6 @@ import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
import { resetDirty } from './reportSlice';

View File

@@ -1,31 +1,46 @@
import { createContext, memo, useContext, useMemo } from 'react';
import type { IChartInput } from '@/types';
export interface ChartContextType {
editMode: boolean;
previous?: boolean;
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: any) => void;
}
type ChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
const ChartContext = createContext<ChartContextType>({
editMode: false,
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
});
export function ChartProvider({
children,
editMode,
previous,
hideID,
...props
}: ChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
editMode,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
...props,
}),
[editMode, previous]
[editMode, previous, hideID, props]
)}
>
{children}
@@ -52,5 +67,5 @@ export function withChartProivder<ComponentProps>(
}
export function useChartContext() {
return useContext(ChartContext);
return useContext(ChartContext)!;
}

View File

@@ -0,0 +1,81 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartMetric } from '@/types';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
interface MetricCardProps {
serie: IChartData['series'][number];
color?: string;
metric: IChartMetric;
unit?: string;
}
export function MetricCard({
serie,
color: _color,
metric,
unit,
}: MetricCardProps) {
const color = _color || theme?.colors['chart-0'];
const number = useNumber();
return (
<div
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} />
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
</div>
<div className="flex justify-between items-end mt-2">
<div className="text-2xl font-bold">
{number.format(serie.metrics[metric])}
{unit && <span className="ml-1 font-light text-xl">{unit}</span>}
</div>
{!!serie.metrics.previous[metric] && (
<div>
{number.format(serie.metrics.previous[metric]?.value)}
{unit}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -34,7 +34,7 @@ export function ReportAreaChart({
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

@@ -9,6 +9,11 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
@@ -21,7 +26,6 @@ import {
} from '@tanstack/react-table';
import type { SortingState } from '@tanstack/react-table';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useElementSize } from 'usehooks-ts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
@@ -34,10 +38,11 @@ interface ReportBarChartProps {
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode } = useChartContext();
const [ref, { width }] = useElementSize();
const { editMode, metric, unit, onClick } = useChartContext();
const [sorting, setSorting] = useState<SortingState>([]);
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
const maxCount = Math.max(
...data.series.map((serie) => serie.metrics[metric])
);
const number = useNumber();
const table = useReactTable({
data: useMemo(
@@ -53,46 +58,45 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
return (
<div className="flex items-center gap-2">
<ColorSquare>{info.row.original.event.id}</ColorSquare>
{info.getValue()}
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="text-ellipsis overflow-hidden">
{info.getValue()}
</div>
</TooltipTrigger>
<TooltipContent>{info.getValue()}</TooltipContent>
</Tooltip>
</div>
);
},
footer: (info) => info.column.id,
size: width ? width * 0.3 : undefined,
}),
columnHelper.accessor((row) => row.metrics.sum, {
columnHelper.accessor((row) => row.metrics[metric], {
id: 'totalCount',
cell: (info) => (
<div className="text-right font-medium flex gap-2">
<div>{number.format(info.getValue())}</div>
<div className="flex gap-4 w-full">
<div className="relative flex-1">
<div
className="top-0 absolute shine h-[20px] rounded-full"
style={{
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
</div>
<div className="font-bold">
{number.format(info.getValue())}
{unit}
</div>
<PreviousDiffIndicator
{...info.row.original.metrics.previous.sum}
{...info.row.original.metrics.previous[metric]}
/>
</div>
),
header: () => 'Count',
footer: (info) => info.column.id,
size: width ? width * 0.1 : undefined,
enableSorting: true,
}),
columnHelper.accessor((row) => row.metrics.sum, {
id: 'graph',
cell: (info) => (
<div
className="shine h-4 rounded [.mini_&]:h-3"
style={{
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
),
header: () => 'Graph',
footer: (info) => info.column.id,
size: width ? width * 0.6 : undefined,
}),
];
}, [width]),
columnResizeMode: 'onChange',
}, [maxCount, number]),
state: {
sorting,
},
@@ -102,85 +106,64 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
});
return (
<div ref={ref}>
<div className="overflow-x-auto">
<Table
{...{
className: editMode ? '' : 'mini',
style: {
width: table.getTotalSize(),
},
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
style: {
width: header.getSize(),
},
}}
>
<div
{...{
className: cn(
'flex items-center gap-2',
header.column.getCanSort() &&
'cursor-pointer select-none'
),
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUp className="ml-auto" size={14} />,
desc: <ChevronDown className="ml-auto" size={14} />,
}[header.column.getIsSorted() as string] ?? null}
</div>
<div
{...(editMode
? {
onMouseDown: header.getResizeHandler(),
onTouchStart: header.getResizeHandler(),
className: `resizer ${
header.column.getIsResizing() ? 'isResizing' : ''
}`,
style: {},
}
: {})}
/>
</TableHead>
))}
</TableRow>
<Table
overflow={editMode}
className={cn('table-fixed', editMode ? '' : 'mini')}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
}}
>
<div
{...{
className: cn(
'flex items-center gap-2',
header.column.getCanSort() && 'cursor-pointer select-none'
),
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUp className="ml-auto" size={14} />,
desc: <ChevronDown className="ml-auto" size={14} />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</TableHead>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
{...{
style: {
width: cell.column.getSize(),
},
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
{...(onClick
? {
onClick() {
onClick(row.original);
},
className: 'cursor-pointer',
}
: {})}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableBody>
</Table>
</div>
</div>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -19,7 +19,7 @@ export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { previous } = useChartContext();
const { previous, unit } = useChartContext();
const getLabel = useMappings();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
@@ -41,7 +41,7 @@ export function ReportChartTooltip({
const hidden = sorted.slice(limit);
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
@@ -57,11 +57,11 @@ export function ReportChartTooltip({
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
{previous && data.previous?.date && (
{/* {previous && data.previous?.date && (
<div className="text-slate-400 italic">
{formatDate(new Date(data.previous.date))}
</div>
)}
)} */}
</div>
)}
<div className="flex gap-2">
@@ -74,11 +74,15 @@ export function ReportChartTooltip({
{getLabel(data.label)}
</div>
<div className="flex justify-between gap-8">
<div>{number.format(data.count)}</div>
<div>
{number.format(data.count)}
{unit}
</div>
<div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}>
{!!data.previous && `(${data.previous.count})`}
{!!data.previous &&
`(${data.previous.value + (unit ? unit : '')})`}
</PreviousDiffIndicator>
</div>
</div>

View File

@@ -32,7 +32,7 @@ export function ReportHistogramChart({
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
@@ -35,7 +35,7 @@ export function ReportLineChart({
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
const rechartData = useRechartDataModel(series);
return (
<>

View File

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

View File

@@ -1,25 +1,17 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { theme } from '@/utils/theme';
import { ChevronDown, ChevronUp, ChevronUpCircle } from 'lucide-react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { MetricCard } from './MetricCard';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode } = useChartContext();
const { editMode, metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
const color = theme?.colors['chart-0'];
const number = useNumber();
return (
<div
className={cn(
@@ -29,62 +21,12 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
>
{series.map((serie) => {
return (
<div
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
<MetricCard
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop
offset="95%"
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<div className="flex justify-between items-end">
<div className="mt-6 font-mono text-3xl font-bold">
{number.format(serie.metrics.sum)}
</div>
{!!serie.metrics.previous.sum && (
<div className="flex flex-col items-end">
<PreviousDiffIndicator {...serie.metrics.previous.sum}>
<div className="font-mono">
{number.format(serie.metrics.previous.sum.value)}
</div>
</PreviousDiffIndicator>
</div>
)}
</div>
</div>
</div>
serie={serie}
metric={metric}
unit={unit}
/>
);
})}
</div>

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider';
@@ -15,66 +15,19 @@ interface ReportPieChartProps {
data: IChartData;
}
const RADIAN = Math.PI / 180;
const renderLabel = ({
x,
y,
cx,
cy,
midAngle,
innerRadius,
outerRadius,
payload,
...props
}: any) => {
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const xx = cx + radius * Math.cos(-midAngle * RADIAN);
const yy = cy + radius * Math.sin(-midAngle * RADIAN);
const label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xx}
y={yy}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
>
{percent}%
</text>
<text
x={x}
y={y}
fill="black"
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
>
{label}
</text>
</>
);
};
export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
// Get max 10 series and than combine others into one
const pieData = series.map((serie) => {
return {
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
};
});
const pieData = series.map((serie) => ({
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));
return (
<>
@@ -127,3 +80,58 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
</>
);
}
const renderLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
fill,
payload,
}: {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
fill: string;
payload: { label: string; percent: number };
}) => {
const RADIAN = Math.PI / 180;
const radius = 25 + innerRadius + (outerRadius - innerRadius);
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
const label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xProcent}
y={yProcent}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={700}
pointerEvents={'none'}
>
{percent}%
</text>
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={10}
>
{truncate(label, 20)}
</text>
</>
);
};

View File

@@ -1,6 +1,7 @@
'use client';
import { memo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
@@ -11,10 +12,13 @@ import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['chart'];
};
export const Chart = memo(
withChartProivder(function Chart({
@@ -26,18 +30,23 @@ export const Chart = memo(
range,
lineType,
previous,
formula,
unit,
metric,
initialData,
}: ReportChartProps) {
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
const enabled = events.length > 0 && !hasEmptyFilters;
const chart = api.chart.chart.useQuery(
{
interval,
chartType,
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
@@ -46,10 +55,14 @@ export const Chart = memo(
endDate: null,
projectId: params.projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: false,
keepPreviousData: true,
enabled,
initialData,
}
);
@@ -66,10 +79,10 @@ export const Chart = memo(
);
}
if (chart.isFetching) {
if (chart.isLoading) {
return (
<ChartAnimationContainer>
<ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" />
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
<p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer>
);
@@ -99,6 +112,10 @@ export const Chart = memo(
);
}
if (chartType === 'map') {
return <ReportMapChart data={chart.data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={chart.data} />;
}

View File

@@ -27,6 +27,8 @@ type InitialState = IChartInput & {
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: 'Untitled',
chartType: 'linear',
lineType: 'monotone',
@@ -37,6 +39,9 @@ const initialState: InitialState = {
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
};
export const reportSlice = createSlice({
@@ -100,6 +105,12 @@ export const reportSlice = createSlice({
});
},
// Previous
changePrevious: (state, action: PayloadAction<boolean>) => {
state.dirty = true;
state.previous = action.payload;
},
// Breakdowns
addBreakdown: (
state,
@@ -181,6 +192,12 @@ export const reportSlice = createSlice({
state.range = action.payload;
state.interval = getDefaultIntervalByRange(action.payload);
},
// Formula
changeFormula: (state, action: PayloadAction<string>) => {
state.dirty = true;
state.formula = action.payload;
},
},
});
@@ -201,6 +218,8 @@ export const {
changeChartType,
changeLineType,
resetDirty,
changeFormula,
changePrevious,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

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

View File

@@ -34,7 +34,7 @@ const labels = [
];
export interface ReportEventMoreProps {
onClick: (action: 'createFilter' | 'remove') => void;
onClick: (action: 'remove') => void;
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
@@ -49,10 +49,6 @@ export function ReportEventMore({ onClick }: ReportEventMoreProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('createFilter')}>
<Filter className="mr-2 h-4 w-4" />
Add filter
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"

View File

@@ -1,25 +1,31 @@
'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { Filter, GanttChart, Users } from 'lucide-react';
import { useParams } from 'next/navigation';
import { GanttChart, Users } from 'lucide-react';
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
import { ReportEventFilters } from './ReportEventFilters';
import {
addEvent,
changeEvent,
changePrevious,
removeEvent,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { FiltersCombobox } from './filters/FiltersCombobox';
import { FiltersList } from './filters/FiltersList';
import { ReportEventMore } from './ReportEventMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const params = useAppParams();
@@ -37,9 +43,6 @@ export function ReportEvents() {
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'createFilter': {
return setIsCreating(true);
}
case 'remove': {
return dispatch(removeEvent(event));
}
@@ -111,12 +114,20 @@ export function ReportEvents() {
},
{
value: 'user_average',
label: 'Unique users (average)',
label: 'Average event per user',
},
{
value: 'one_event_per_user',
label: 'One event per user',
},
{
value: 'property_sum',
label: 'Sum of property',
},
{
value: 'property_average',
label: 'Average of property',
},
]}
label="Segment"
>
@@ -127,12 +138,20 @@ export function ReportEvents() {
</>
) : event.segment === 'user_average' ? (
<>
<Users size={12} /> Unique users (average)
<Users size={12} /> Average event per user
</>
) : event.segment === 'one_event_per_user' ? (
<>
<Users size={12} /> One event per user
</>
) : event.segment === 'property_sum' ? (
<>
<Users size={12} /> Sum of property
</>
) : event.segment === 'property_average' ? (
<>
<Users size={12} /> Average of property
</>
) : (
<>
<GanttChart size={12} /> All events
@@ -140,18 +159,17 @@ export function ReportEvents() {
)}
</button>
</Dropdown>
<button
onClick={() => {
handleMore(event)('createFilter');
}}
className="flex items-center gap-1 rounded-md border border-border p-1 px-2 font-medium leading-none text-xs"
>
<Filter size={12} /> Filter
</button>
{/* */}
<FiltersCombobox event={event} />
{(event.segment === 'property_average' ||
event.segment === 'property_sum') && (
<EventPropertiesCombobox event={event} />
)}
</div>
{/* Filters */}
<ReportEventFilters {...{ isCreating, setIsCreating, event }} />
<FiltersList event={event} />
</div>
);
})}
@@ -172,6 +190,17 @@ export function ReportEvents() {
placeholder="Select event"
/>
</div>
<label
className="flex items-center gap-2 cursor-pointer select-none text-sm font-medium mt-4"
htmlFor="previous"
>
<Checkbox
id="previous"
checked={previous}
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
/>
Show previous / Compare
</label>
</div>
);
}

View File

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

View File

@@ -3,11 +3,13 @@ import { SheetClose } from '@/components/ui/sheet';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportForumula } from './ReportForumula';
export function ReportSidebar() {
return (
<div className="flex flex-col gap-8 pb-12">
<ReportEvents />
<ReportForumula />
<ReportBreakdowns />
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
<SheetClose asChild>

View File

@@ -1,18 +1,8 @@
import type { Dispatch } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
@@ -23,93 +13,24 @@ import type {
IChartEventFilterValue,
} from '@/types';
import { operators } from '@/utils/constants';
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation';
import { changeEvent } from '../reportSlice';
interface ReportEventFiltersProps {
event: IChartEvent;
isCreating: boolean;
setIsCreating: Dispatch<boolean>;
}
export function ReportEventFilters({
event,
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const params = useAppParams();
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery(
{
event: event.name,
projectId: params.projectId,
},
{
enabled: !!event.name,
}
);
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <Filter key={filter.name} filter={filter} event={event} />;
})}
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
<CommandInput placeholder="Search properties" />
<CommandList>
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
<CommandGroup heading="Properties">
{propertiesQuery.data?.map((item) => (
<CommandItem
key={item}
onSelect={() => {
setIsCreating(false);
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: item,
operator: 'is',
value: [],
},
],
})
);
}}
>
<CreditCard className="mr-2 h-4 w-4" />
<RenderDots className="text-sm">{item}</RenderDots>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</CommandDialog>
</div>
</div>
);
}
import { changeEvent } from '../../reportSlice';
interface FilterProps {
event: IChartEvent;
filter: IChartEvent['filters'][number];
}
function Filter({ filter, event }: FilterProps) {
const params = useParams<{ organizationId: string; projectId: string }>();
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useAppParams();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectId: params?.projectId!,
projectId,
});
const valuesCombobox =

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'inline-flex items-center rounded-full border px-1.5 h-[20px] text-[10px] font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
@@ -16,6 +16,8 @@ const badgeVariants = cva(
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
success:
'border-transparent bg-emerald-500 text-emerald-100 hover:bg-emerald-500/80',
outline: 'text-foreground',
},
},

View File

@@ -38,7 +38,7 @@ const buttonVariants = cva(
}
);
interface ButtonProps
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;

View File

@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import type { ButtonProps } from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Command,
@@ -15,9 +16,10 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/utils/cn';
import type { LucideIcon } from 'lucide-react';
import { Check, ChevronsUpDown } from 'lucide-react';
interface ComboboxProps<T> {
export interface ComboboxProps<T> {
placeholder: string;
items: {
value: T;
@@ -30,8 +32,18 @@ interface ComboboxProps<T> {
onCreate?: (value: T) => void;
className?: string;
searchable?: boolean;
icon?: LucideIcon;
size?: ButtonProps['size'];
label?: string;
}
export type ExtendedComboboxProps<T> = Omit<
ComboboxProps<T>,
'items' | 'placeholder'
> & {
placeholder?: string;
};
export function Combobox<T extends string>({
placeholder,
items,
@@ -41,6 +53,9 @@ export function Combobox<T extends string>({
onCreate,
className,
searchable,
icon: Icon,
size,
label,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@@ -55,11 +70,13 @@ export function Combobox<T extends string>({
<PopoverTrigger asChild>
{children ?? (
<Button
size={size}
variant="outline"
role="combobox"
aria-expanded={open}
className={cn('justify-between min-w-[150px]', className)}
className={cn('justify-between', className)}
>
{Icon ? <Icon className="mr-2" size={16} /> : null}
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{value ? find(value)?.label ?? 'No match' : placeholder}
</span>
@@ -67,7 +84,7 @@ export function Combobox<T extends string>({
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-full min-w-0 p-0" align="start">
<PopoverContent className="w-full max-w-md p-0" align="start">
<Command>
{searchable === true && (
<CommandInput
@@ -80,7 +97,7 @@ export function Combobox<T extends string>({
<CommandEmpty className="p-2">
<Button
onClick={() => {
onCreate(search);
onCreate(search as T);
setSearch('');
setOpen(false);
}}
@@ -99,7 +116,7 @@ export function Combobox<T extends string>({
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value);
onChange(value as T);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}

View File

@@ -32,7 +32,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
'fixed z-50 gap-4 bg-background shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-150 data-[state=open]:duration-150',
{
variants: {
side: {

View File

@@ -5,10 +5,13 @@ import { cn } from '@/utils/cn';
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> & { wrapper?: boolean }
>(({ className, wrapper, ...props }, ref) => (
React.HTMLAttributes<HTMLTableElement> & {
wrapper?: boolean;
overflow?: boolean;
}
>(({ className, wrapper, overflow = true, ...props }, ref) => (
<div className={cn('border border-border rounded-md bg-white', className)}>
<div className="relative w-full overflow-auto ">
<div className={cn('relative w-full', overflow && 'overflow-auto')}>
<table
ref={ref}
className={cn(