a lot
This commit is contained in:
11
apps/web/src/components/overview/live-counter/index.tsx
Normal file
11
apps/web/src/components/overview/live-counter/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getLiveVisitors } from '@mixan/db';
|
||||
|
||||
import type { LiveCounterProps } from './live-counter';
|
||||
import LiveCounter from './live-counter';
|
||||
|
||||
export default async function ServerLiveCounter(
|
||||
props: Omit<LiveCounterProps, 'data'>
|
||||
) {
|
||||
const count = await getLiveVisitors(props.projectId);
|
||||
return <LiveCounter data={count} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface LiveCounterProps {
|
||||
data: number;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
|
||||
ssr: false,
|
||||
loading: () => <div>0</div>,
|
||||
});
|
||||
|
||||
const FIFTEEN_SECONDS = 1000 * 15;
|
||||
|
||||
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
|
||||
const ws = String(process.env.NEXT_PUBLIC_API_URL)
|
||||
.replace(/^https/, 'wss')
|
||||
.replace(/^http/, 'ws');
|
||||
const client = useQueryClient();
|
||||
const [counter, setCounter] = useState(data);
|
||||
const [socketUrl] = useState(`${ws}/live/visitors/${projectId}`);
|
||||
const lastRefresh = useRef(Date.now());
|
||||
|
||||
useWebSocket(socketUrl, {
|
||||
shouldReconnect: () => true,
|
||||
onMessage(event) {
|
||||
const value = parseInt(event.data, 10);
|
||||
if (!isNaN(value)) {
|
||||
setCounter(value);
|
||||
if (Date.now() - lastRefresh.current > FIFTEEN_SECONDS) {
|
||||
lastRefresh.current = Date.now();
|
||||
toast('Refreshed data');
|
||||
client.refetchQueries({
|
||||
type: 'active',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={cn(
|
||||
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
|
||||
counter === 0 && 'bg-destructive opacity-0'
|
||||
)}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
|
||||
counter === 0 && 'bg-destructive'
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
<AnimatedNumbers
|
||||
includeComma
|
||||
transitions={(index) => ({
|
||||
type: 'spring',
|
||||
duration: index + 0.3,
|
||||
|
||||
damping: 10,
|
||||
stiffness: 200,
|
||||
})}
|
||||
animateToNumber={counter}
|
||||
locale="en"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{counter} unique visitors last 5 minutes
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,28 @@ export function OverviewFiltersButtons() {
|
||||
<strong>{options.referrer}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.referrerName && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setReferrerName(null)}
|
||||
>
|
||||
<span className="mr-1">Referrer name is</span>
|
||||
<strong>{options.referrerName}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.referrerType && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
icon={X}
|
||||
onClick={() => options.setReferrerType(null)}
|
||||
>
|
||||
<span className="mr-1">Referrer type is</span>
|
||||
<strong>{options.referrerType}</strong>
|
||||
</Button>
|
||||
)}
|
||||
{options.device && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -8,8 +8,10 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Label } from '../ui/label';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
|
||||
export function OverviewFilters() {
|
||||
const { projectId } = useAppParams();
|
||||
interface OverviewFiltersProps {
|
||||
projectId: string;
|
||||
}
|
||||
export function OverviewFilters({ projectId }: OverviewFiltersProps) {
|
||||
const options = useOverviewOptions();
|
||||
|
||||
const { data: referrers } = api.chart.values.useQuery({
|
||||
|
||||
76
apps/web/src/components/overview/overview-share.tsx
Normal file
76
apps/web/src/components/overview/overview-share.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { pushModal } from '@/modals';
|
||||
import { EyeIcon, Globe2Icon, LockIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { ShareOverview } from '@mixan/db';
|
||||
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu';
|
||||
|
||||
interface OverviewShareProps {
|
||||
data: ShareOverview | null;
|
||||
}
|
||||
|
||||
export function OverviewShare({ data }: OverviewShareProps) {
|
||||
const router = useRouter();
|
||||
const mutation = api.share.shareOverview.useMutation({
|
||||
onSuccess() {
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={data && data.public ? Globe2Icon : LockIcon} responsive>
|
||||
{data && data.public ? 'Public' : 'Private'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
{(!data || data.public === false) && (
|
||||
<DropdownMenuItem onClick={() => pushModal('ShareOverviewModal')}>
|
||||
<Globe2Icon size={16} className="mr-2" />
|
||||
Make public
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data?.public && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`${process.env.NEXT_PUBLIC_DASHBOARD_URL}/share/overview/${data.id}`}
|
||||
>
|
||||
<EyeIcon size={16} className="mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{data?.public && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
mutation.mutate({
|
||||
public: false,
|
||||
projectId: data?.project_id,
|
||||
organizationId: data?.organization_slug,
|
||||
password: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LockIcon size={16} className="mr-2" />
|
||||
Make private
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopDevices() {
|
||||
interface OverviewTopDevicesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopDevices({
|
||||
projectId,
|
||||
}: OverviewTopDevicesProps) {
|
||||
const {
|
||||
filters,
|
||||
interval,
|
||||
@@ -18,19 +23,15 @@ export default function OverviewTopDevices() {
|
||||
previous,
|
||||
setBrowser,
|
||||
setBrowserVersion,
|
||||
browser,
|
||||
browserVersion,
|
||||
setOS,
|
||||
setOSVersion,
|
||||
os,
|
||||
osVersion,
|
||||
} = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||
devices: {
|
||||
title: 'Top devices',
|
||||
btn: 'Devices',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -58,7 +59,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top browser',
|
||||
btn: 'Browser',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -86,7 +87,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top Browser Version',
|
||||
btn: 'Browser Version',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -114,7 +115,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top OS',
|
||||
btn: 'OS',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
@@ -142,7 +143,7 @@ export default function OverviewTopDevices() {
|
||||
title: 'Top OS version',
|
||||
btn: 'OS Version',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
|
||||
@@ -10,14 +10,19 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopEvents() {
|
||||
interface OverviewTopEventsProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopEvents({
|
||||
projectId,
|
||||
}: OverviewTopEventsProps) {
|
||||
const { filters, interval, range, previous } = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
|
||||
all: {
|
||||
title: 'Top events',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,7 +10,10 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopGeo() {
|
||||
interface OverviewTopGeoProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
|
||||
useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
|
||||
@@ -18,7 +21,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Map',
|
||||
btn: 'Map',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -46,7 +49,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top countries',
|
||||
btn: 'Countries',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -74,7 +77,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top regions',
|
||||
btn: 'Regions',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -102,7 +105,7 @@ export default function OverviewTopGeo() {
|
||||
title: 'Top cities',
|
||||
btn: 'Cities',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,14 +10,17 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopPages() {
|
||||
interface OverviewTopPagesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
||||
const { filters, interval, range, previous, setPage } = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
|
||||
top: {
|
||||
title: 'Top pages',
|
||||
btn: 'Top pages',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -45,7 +48,7 @@ export default function OverviewTopPages() {
|
||||
title: 'Entry Pages',
|
||||
btn: 'Entries',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -73,7 +76,7 @@ export default function OverviewTopPages() {
|
||||
title: 'Exit Pages',
|
||||
btn: 'Exits',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
|
||||
@@ -10,7 +10,12 @@ import { WidgetButtons, WidgetHead } from './overview-widget';
|
||||
import { useOverviewOptions } from './useOverviewOptions';
|
||||
import { useOverviewWidget } from './useOverviewWidget';
|
||||
|
||||
export default function OverviewTopSources() {
|
||||
interface OverviewTopSourcesProps {
|
||||
projectId: string;
|
||||
}
|
||||
export default function OverviewTopSources({
|
||||
projectId,
|
||||
}: OverviewTopSourcesProps) {
|
||||
const {
|
||||
filters,
|
||||
interval,
|
||||
@@ -22,13 +27,43 @@ export default function OverviewTopSources() {
|
||||
setUtmCampaign,
|
||||
setUtmTerm,
|
||||
setUtmContent,
|
||||
setReferrerName,
|
||||
setReferrerType,
|
||||
} = useOverviewOptions();
|
||||
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
|
||||
all: {
|
||||
title: 'Top sources',
|
||||
btn: 'All',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_name',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top groups',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
domain: {
|
||||
title: 'Top urls',
|
||||
btn: 'URLs',
|
||||
chart: {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -52,11 +87,39 @@ export default function OverviewTopSources() {
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
type: {
|
||||
title: 'Top types',
|
||||
btn: 'Types',
|
||||
chart: {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
filters: filters,
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [
|
||||
{
|
||||
id: 'A',
|
||||
name: 'referrer_type',
|
||||
},
|
||||
],
|
||||
chartType: 'bar',
|
||||
lineType: 'monotone',
|
||||
interval: interval,
|
||||
name: 'Top types',
|
||||
range: range,
|
||||
previous: previous,
|
||||
metric: 'sum',
|
||||
},
|
||||
},
|
||||
utm_source: {
|
||||
title: 'UTM Source',
|
||||
btn: 'Source',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -84,7 +147,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Medium',
|
||||
btn: 'Medium',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -112,7 +175,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Campaign',
|
||||
btn: 'Campaign',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -140,7 +203,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Term',
|
||||
btn: 'Term',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -168,7 +231,7 @@ export default function OverviewTopSources() {
|
||||
title: 'UTM Content',
|
||||
btn: 'Content',
|
||||
chart: {
|
||||
projectId: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
@@ -220,8 +283,16 @@ export default function OverviewTopSources() {
|
||||
onClick={(item) => {
|
||||
switch (widget.key) {
|
||||
case 'all':
|
||||
setReferrerName(item.name);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'domain':
|
||||
setReferrer(item.name);
|
||||
break;
|
||||
case 'type':
|
||||
setReferrerType(item.name);
|
||||
setWidget('domain');
|
||||
break;
|
||||
case 'utm_source':
|
||||
setUtmSource(item.name);
|
||||
break;
|
||||
|
||||
@@ -33,7 +33,7 @@ export function WidgetButtons({
|
||||
}: WidgetHeadProps) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const sizes = useRef<number[]>([]);
|
||||
const [slice, setSlice] = useState(Children.count(children) - 1);
|
||||
const [slice, setSlice] = useState(-1);
|
||||
const gap = 8;
|
||||
|
||||
const handleResize = useThrottle(() => {
|
||||
|
||||
@@ -30,12 +30,22 @@ export function useOverviewOptions() {
|
||||
);
|
||||
|
||||
// Filters
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
// Referrer
|
||||
const [referrer, setReferrer] = useQueryState(
|
||||
'referrer',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
const [referrerName, setReferrerName] = useQueryState(
|
||||
'referrer_name',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
const [referrerType, setReferrerType] = useQueryState(
|
||||
'referrer_type',
|
||||
parseAsString.withOptions(nuqsOptions)
|
||||
);
|
||||
|
||||
@@ -99,14 +109,6 @@ export function useOverviewOptions() {
|
||||
|
||||
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({
|
||||
@@ -126,6 +128,33 @@ export function useOverviewOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
if (referrer) {
|
||||
filters.push({
|
||||
id: 'referrer',
|
||||
operator: 'is',
|
||||
name: 'referrer',
|
||||
value: [referrer],
|
||||
});
|
||||
}
|
||||
|
||||
if (referrerName) {
|
||||
filters.push({
|
||||
id: 'referrer_name',
|
||||
operator: 'is',
|
||||
name: 'referrer_name',
|
||||
value: [referrerName],
|
||||
});
|
||||
}
|
||||
|
||||
if (referrerType) {
|
||||
filters.push({
|
||||
id: 'referrer_type',
|
||||
operator: 'is',
|
||||
name: 'referrer_type',
|
||||
value: [referrerType],
|
||||
});
|
||||
}
|
||||
|
||||
if (utmSource) {
|
||||
filters.push({
|
||||
id: 'utm_source',
|
||||
@@ -236,9 +265,11 @@ export function useOverviewOptions() {
|
||||
|
||||
return filters;
|
||||
}, [
|
||||
referrer,
|
||||
page,
|
||||
device,
|
||||
referrer,
|
||||
referrerName,
|
||||
referrerType,
|
||||
utmSource,
|
||||
utmMedium,
|
||||
utmCampaign,
|
||||
@@ -260,8 +291,6 @@ export function useOverviewOptions() {
|
||||
setRange,
|
||||
metric,
|
||||
setMetric,
|
||||
referrer,
|
||||
setReferrer,
|
||||
page,
|
||||
setPage,
|
||||
|
||||
@@ -269,6 +298,14 @@ export function useOverviewOptions() {
|
||||
interval,
|
||||
filters,
|
||||
|
||||
// Refs
|
||||
referrer,
|
||||
setReferrer,
|
||||
referrerName,
|
||||
setReferrerName,
|
||||
referrerType,
|
||||
setReferrerType,
|
||||
|
||||
// UTM
|
||||
utmSource,
|
||||
setUtmSource,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ChartEmpty() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'aspect-video w-full max-h-[400px] flex justify-center items-center'
|
||||
'aspect-video w-full max-h-[400px] min-h-[200px] flex justify-center items-center'
|
||||
}
|
||||
>
|
||||
No data
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import { createContext, memo, useContext, useMemo } from 'react';
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { IChartSerie } from '@/server/api/routers/chart';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
|
||||
export interface ChartContextType extends IChartInput {
|
||||
editMode?: boolean;
|
||||
hideID?: boolean;
|
||||
@@ -53,6 +64,16 @@ export function withChartProivder<ComponentProps>(
|
||||
WrappedComponent: React.FC<ComponentProps>
|
||||
) {
|
||||
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
|
||||
const [mounted, setMounted] = useState(props.chartType === 'metric');
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return <ChartLoading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartProvider {...props}>
|
||||
<WrappedComponent {...props} />
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ReportAreaChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,35 +1,23 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
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 { NOT_SET_VALUE } from '@/utils/constants';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { SerieIcon } from './SerieIcon';
|
||||
|
||||
interface ReportBarChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode, metric, unit, onClick } = useChartContext();
|
||||
const { editMode, metric, onClick } = useChartContext();
|
||||
const number = useNumber();
|
||||
const series = useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
@@ -62,7 +50,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
)}
|
||||
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
|
||||
>
|
||||
<div className="flex-1 break-all">{serie.name}</div>
|
||||
<div className="flex-1 break-all flex items-center gap-2">
|
||||
<SerieIcon name={serie.name} />
|
||||
{serie.name}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
|
||||
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
|
||||
<div className="font-bold">
|
||||
|
||||
@@ -19,9 +19,16 @@ interface ReportHistogramChartProps {
|
||||
interval: IInterval;
|
||||
}
|
||||
|
||||
function BarHover(props: any) {
|
||||
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
const bg = theme?.colors?.slate?.['200'] as string;
|
||||
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
|
||||
return (
|
||||
<rect
|
||||
{...{ x, y, width, height, top, left, right, bottom }}
|
||||
rx="8"
|
||||
fill={bg}
|
||||
fillOpacity={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportHistogramChart({
|
||||
@@ -38,7 +45,7 @@ export function ReportHistogramChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
@@ -41,7 +43,7 @@ export function ReportLineChart({
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'max-sm:-mx-3',
|
||||
'max-sm:-mx-3 aspect-video w-full max-h-[400px] min-h-[200px]',
|
||||
editMode && 'border border-border bg-white rounded-md p-4'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
|
||||
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal file
65
apps/web/src/components/report/chart/SerieIcon.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { NOT_SET_VALUE } from '@/utils/constants';
|
||||
import type { LucideIcon, LucideProps } from 'lucide-react';
|
||||
import {
|
||||
ActivityIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircleIcon,
|
||||
MonitorIcon,
|
||||
MonitorPlayIcon,
|
||||
PhoneIcon,
|
||||
SmartphoneIcon,
|
||||
SquareAsteriskIcon,
|
||||
TabletIcon,
|
||||
TabletSmartphoneIcon,
|
||||
TwitterIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getKeys,
|
||||
getNetworks,
|
||||
networkFor,
|
||||
register,
|
||||
SocialIcon,
|
||||
} from 'react-social-icons';
|
||||
|
||||
interface SerieIconProps extends LucideProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mapper: Record<string, LucideIcon> = {
|
||||
screen_view: MonitorPlayIcon,
|
||||
session_start: ActivityIcon,
|
||||
link_out: ExternalLinkIcon,
|
||||
mobile: SmartphoneIcon,
|
||||
desktop: MonitorIcon,
|
||||
tablet: TabletIcon,
|
||||
[NOT_SET_VALUE]: HelpCircleIcon,
|
||||
};
|
||||
|
||||
const networks = getNetworks();
|
||||
|
||||
register('duckduckgo', {
|
||||
color: 'red',
|
||||
path: 'https://duckduckgo.com/favicon.ico',
|
||||
});
|
||||
|
||||
export function SerieIcon({ name, ...props }: SerieIconProps) {
|
||||
let Icon = mapper[name] ?? null;
|
||||
|
||||
if (name.includes('http')) {
|
||||
Icon = ((_props) => (
|
||||
<SocialIcon network={networkFor(name)} />
|
||||
)) as LucideIcon;
|
||||
}
|
||||
|
||||
if (Icon === null && networks.includes(name.toLowerCase())) {
|
||||
Icon = ((_props) => (
|
||||
<SocialIcon network={name.toLowerCase()} />
|
||||
)) as LucideIcon;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
|
||||
{Icon ? <Icon size={16} {...props} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import { memo, useEffect, useState } 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';
|
||||
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
@@ -33,9 +33,8 @@ export const Chart = memo(
|
||||
formula,
|
||||
unit,
|
||||
metric,
|
||||
initialData,
|
||||
projectId,
|
||||
}: ReportChartProps) {
|
||||
const params = useAppParams();
|
||||
const [data] = api.chart.chart.useSuspenseQuery(
|
||||
{
|
||||
// dont send lineType since it does not need to be sent
|
||||
@@ -48,7 +47,7 @@ export const Chart = memo(
|
||||
range,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
previous,
|
||||
formula,
|
||||
unit,
|
||||
@@ -56,7 +55,6 @@ export const Chart = memo(
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
initialData,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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 { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { changeEvent } from '../reportSlice';
|
||||
|
||||
interface EventPropertiesComboboxProps {
|
||||
@@ -16,7 +16,7 @@ export function EventPropertiesCombobox({
|
||||
event,
|
||||
}: EventPropertiesComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
import { api } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import type { IChartBreakdown } from '@/types';
|
||||
import { SplitIcon } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
|
||||
import { ReportBreakdownMore } from './ReportBreakdownMore';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
|
||||
export function ReportBreakdowns() {
|
||||
const params = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||
const dispatch = useDispatch();
|
||||
const propertiesQuery = api.chart.properties.useQuery({
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
});
|
||||
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
|
||||
value: item,
|
||||
|
||||
@@ -6,12 +6,12 @@ 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 { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from '../chart/ChartProvider';
|
||||
import {
|
||||
addEvent,
|
||||
changeEvent,
|
||||
@@ -28,9 +28,9 @@ export function ReportEvents() {
|
||||
const previous = useSelector((state) => state.report.previous);
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const dispatch = useDispatch();
|
||||
const params = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const eventsQuery = api.chart.events.useQuery({
|
||||
projectId: params.projectId,
|
||||
projectId,
|
||||
});
|
||||
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
|
||||
value: item.name,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Dropdown } from '@/components/Dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||
import { RenderDots } from '@/components/ui/RenderDots';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useDispatch } from '@/redux';
|
||||
import type {
|
||||
@@ -14,8 +13,8 @@ import type {
|
||||
} from '@/types';
|
||||
import { operators } from '@/utils/constants';
|
||||
import { SlidersHorizontal, Trash } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
import { useChartContext } from '../../chart/ChartProvider';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FilterProps {
|
||||
@@ -24,7 +23,7 @@ interface FilterProps {
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const dispatch = useDispatch();
|
||||
const potentialValues = api.chart.values.useQuery({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { useChartContext } from '../../chart/ChartProvider';
|
||||
import { changeEvent } from '../../reportSlice';
|
||||
|
||||
interface FiltersComboboxProps {
|
||||
@@ -13,7 +13,7 @@ interface FiltersComboboxProps {
|
||||
|
||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const { projectId } = useChartContext();
|
||||
|
||||
const query = api.chart.properties.useQuery(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user