server side events and ui improvemnt
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { BoxSelectIcon } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface FullPageEmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FullPageEmptyState({
|
||||
icon: Icon,
|
||||
icon: Icon = BoxSelectIcon,
|
||||
title,
|
||||
children,
|
||||
}: FullPageEmptyStateProps) {
|
||||
|
||||
@@ -8,7 +8,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-4 border-b border-border [&_.title]:font-medium',
|
||||
'p-4 border-b border-border [&_.title]:font-medium [&_.title]:whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
@@ -172,26 +174,28 @@ export default function OverviewTopDevices() {
|
||||
</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;
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<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;
|
||||
// }
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
@@ -67,7 +69,9 @@ export default function OverviewTopEvents() {
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<Chart hideID {...widget.chart} previous={false} />
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<Chart hideID {...widget.chart} previous={false} />
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
@@ -144,26 +146,28 @@ export default function OverviewTopGeo() {
|
||||
</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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
@@ -115,14 +117,16 @@ export default function OverviewTopPages() {
|
||||
</WidgetButtons>
|
||||
</WidgetHead>
|
||||
<WidgetBody>
|
||||
<Chart
|
||||
hideID
|
||||
{...widget.chart}
|
||||
previous={false}
|
||||
onClick={(item) => {
|
||||
setPage(item.name);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<Chart
|
||||
hideID
|
||||
{...widget.chart}
|
||||
previous={false}
|
||||
onClick={(item) => {
|
||||
setPage(item.name);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { Chart } from '@/components/report/chart';
|
||||
import type { IChartInput } from '@/types';
|
||||
import { ChartLoading } from '@/components/report/chart/ChartLoading';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { Widget, WidgetBody } from '../Widget';
|
||||
@@ -211,33 +212,35 @@ export default function OverviewTopSources() {
|
||||
</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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
<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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import type { WidgetHeadProps } from '../Widget';
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { api, handleError } from '@/app/_trpc/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/use-toast';
|
||||
import { useAppParams } from '@/hooks/useAppParams';
|
||||
import { pushModal } from '@/modals';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { SaveIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { resetDirty } from './reportSlice';
|
||||
|
||||
|
||||
31
apps/web/src/components/report/chart/ChartEmpty.tsx
Normal file
31
apps/web/src/components/report/chart/ChartEmpty.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useChartContext } from './ChartProvider';
|
||||
import { MetricCardEmpty } from './MetricCard';
|
||||
|
||||
export function ChartEmpty() {
|
||||
const { editMode, chartType } = useChartContext();
|
||||
|
||||
if (editMode) {
|
||||
return (
|
||||
<FullPageEmptyState title="No data">
|
||||
We could not find any data for selected events and filter.
|
||||
</FullPageEmptyState>
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <MetricCardEmpty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'aspect-video w-full max-h-[400px] flex justify-center items-center'
|
||||
}
|
||||
>
|
||||
No data
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/web/src/components/report/chart/ChartLoading.tsx
Normal file
15
apps/web/src/components/report/chart/ChartLoading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
interface ChartLoadingProps {
|
||||
className?: string;
|
||||
}
|
||||
export function ChartLoading({ className }: ChartLoadingProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'aspect-video w-full bg-slate-200 animate-pulse rounded max-h-[400px] min-h-[200px]',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { Suspense, useEffect, useRef } from 'react';
|
||||
import { useInViewport } from 'react-in-viewport';
|
||||
|
||||
import type { ReportChartProps } from '.';
|
||||
import { Chart } from '.';
|
||||
import { ChartLoading } from './ChartLoading';
|
||||
import type { ChartContextType } from './ChartProvider';
|
||||
|
||||
export function LazyChart(props: ReportChartProps & ChartContextType) {
|
||||
@@ -22,11 +23,13 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{once.current || inViewport ? (
|
||||
<Chart {...props} editMode={false} />
|
||||
) : (
|
||||
<div className="h-64 w-full bg-gray-200 animate-pulse rounded" />
|
||||
)}
|
||||
<Suspense fallback={<ChartLoading />}>
|
||||
{once.current || inViewport ? (
|
||||
<Chart {...props} editMode={false} />
|
||||
) : (
|
||||
<ChartLoading />
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function MetricCard({
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div
|
||||
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
|
||||
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden h-24"
|
||||
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">
|
||||
@@ -79,3 +79,22 @@ export function MetricCard({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardEmpty() {
|
||||
return (
|
||||
<div className="border border-border p-4 rounded-md bg-white h-24">
|
||||
<div className="flex items-center justify-center h-full text-slate-600">
|
||||
No data
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricCardLoading() {
|
||||
return (
|
||||
<div className="h-24 p-4 py-5 flex flex-col bg-white border border-border rounded-md">
|
||||
<div className="bg-slate-200 rounded animate-pulse h-4 w-1/2"></div>
|
||||
<div className="bg-slate-200 rounded animate-pulse h-6 w-1/5 mt-auto"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
@@ -48,7 +49,7 @@ export function ReportAreaChart({
|
||||
{({ width }) => (
|
||||
<AreaChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||
data={rechartData}
|
||||
>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
@@ -69,18 +70,41 @@ export function ReportAreaChart({
|
||||
/>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={serie.name}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={0}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
fill={getChartColor(serie.index)}
|
||||
stackId={'1'}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<React.Fragment key={serie.name}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.8}
|
||||
></stop>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
key={serie.name}
|
||||
type={lineType}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={color}
|
||||
fill={`url(#color${color})`}
|
||||
stackId={'1'}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<CartesianGrid
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
|
||||
import { ColorSquare } from '@/components/ColorSquare';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -17,153 +18,121 @@ import {
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
createColumnHelper,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
const columnHelper =
|
||||
createColumnHelper<RouterOutputs['chart']['chart']['series'][number]>();
|
||||
|
||||
interface ReportBarChartProps {
|
||||
data: IChartData;
|
||||
}
|
||||
|
||||
export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const { editMode, metric, unit, onClick } = useChartContext();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const maxCount = Math.max(
|
||||
...data.series.map((serie) => serie.metrics[metric])
|
||||
);
|
||||
const number = useNumber();
|
||||
const table = useReactTable({
|
||||
data: useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
[editMode, data]
|
||||
),
|
||||
columns: useMemo(() => {
|
||||
return [
|
||||
columnHelper.accessor((row) => row.name, {
|
||||
id: 'label',
|
||||
header: () => 'Label',
|
||||
cell(info) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorSquare>{info.row.original.event.id}</ColorSquare>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="text-ellipsis overflow-hidden">
|
||||
{info.getValue()}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{info.getValue()}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.metrics[metric], {
|
||||
id: 'totalCount',
|
||||
cell: (info) => (
|
||||
<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[metric]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
header: () => 'Count',
|
||||
enableSorting: true,
|
||||
}),
|
||||
];
|
||||
}, [maxCount, number]),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
const series = useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
[data]
|
||||
);
|
||||
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
|
||||
|
||||
return (
|
||||
<Table
|
||||
overflow={editMode}
|
||||
className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col w-full divide-y text-xs',
|
||||
editMode &&
|
||||
'text-base bg-white border border-border rounded-md p-4 pt-2'
|
||||
)}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
{...{
|
||||
colSpan: header.colSpan,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
{...{
|
||||
className: cn(
|
||||
'flex items-center gap-2',
|
||||
header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
),
|
||||
onClick: header.column.getToggleSortingHandler(),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{{
|
||||
asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
}[header.column.getIsSorted() as string] ?? null}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
{...(onClick
|
||||
? {
|
||||
onClick() {
|
||||
onClick(row.original);
|
||||
},
|
||||
className: 'cursor-pointer',
|
||||
}
|
||||
: {})}
|
||||
{editMode && (
|
||||
<div className="-m-4 -mb-px flex justify-between font-medium p-4 pt-5 border-b border-border font-medium text-muted-foreground">
|
||||
<div>Event</div>
|
||||
<div>Count</div>
|
||||
</div>
|
||||
)}
|
||||
{series.map((serie, index) => {
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className="py-2 flex flex-1 w-full gap-4 items-center"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex-1 break-all">{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">
|
||||
{number.format(serie.metrics.sum)}
|
||||
</div>
|
||||
<Progress
|
||||
color={getChartColor(index)}
|
||||
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
|
||||
value={(serie.metrics.sum / maxCount) * 100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
// return (
|
||||
// <Table
|
||||
// overflow={editMode}
|
||||
// className={cn('table-fixed', editMode ? '' : 'mini')}
|
||||
// >
|
||||
// <TableHeader>
|
||||
// {table.getHeaderGroups().map((headerGroup) => (
|
||||
// <TableRow key={headerGroup.id}>
|
||||
// {headerGroup.headers.map((header) => (
|
||||
// <TableHead
|
||||
// key={header.id}
|
||||
// {...{
|
||||
// colSpan: header.colSpan,
|
||||
// }}
|
||||
// >
|
||||
// <div
|
||||
// {...{
|
||||
// className: cn(
|
||||
// 'flex items-center gap-2',
|
||||
// header.column.getCanSort() && 'cursor-pointer select-none'
|
||||
// ),
|
||||
// onClick: header.column.getToggleSortingHandler(),
|
||||
// }}
|
||||
// >
|
||||
// {flexRender(
|
||||
// header.column.columnDef.header,
|
||||
// header.getContext()
|
||||
// )}
|
||||
// {{
|
||||
// asc: <ChevronUp className="ml-auto" size={14} />,
|
||||
// desc: <ChevronDown className="ml-auto" size={14} />,
|
||||
// }[header.column.getIsSorted() as string] ?? null}
|
||||
// </div>
|
||||
// </TableHead>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableHeader>
|
||||
// <TableBody>
|
||||
// {table.getRowModel().rows.map((row) => (
|
||||
// <TableRow
|
||||
// key={row.id}
|
||||
// {...(onClick
|
||||
// ? {
|
||||
// onClick() {
|
||||
// onClick(row.original);
|
||||
// },
|
||||
// className: 'cursor-pointer',
|
||||
// }
|
||||
// : {})}
|
||||
// >
|
||||
// {row.getVisibleCells().map((cell) => (
|
||||
// <TableCell key={cell.id}>
|
||||
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
// </TableCell>
|
||||
// ))}
|
||||
// </TableRow>
|
||||
// ))}
|
||||
// </TableBody>
|
||||
// </Table>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { previous, unit } = useChartContext();
|
||||
const { unit } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
@@ -57,11 +57,6 @@ 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 && (
|
||||
<div className="text-slate-400 italic">
|
||||
{formatDate(new Date(data.previous.date))}
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ReportHistogramChart({
|
||||
{({ width }) => (
|
||||
<BarChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||
data={rechartData}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
|
||||
@@ -49,7 +49,7 @@ export function ReportLineChart({
|
||||
{({ width }) => (
|
||||
<LineChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
height={Math.min(Math.max(width * 0.5625, 250), 400)}
|
||||
data={rechartData}
|
||||
>
|
||||
<CartesianGrid
|
||||
@@ -80,7 +80,7 @@ export function ReportLineChart({
|
||||
type={lineType}
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
isAnimationActive={false}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
@@ -90,7 +90,7 @@ export function ReportLineChart({
|
||||
type={lineType}
|
||||
key={`${serie.name}:prev`}
|
||||
name={`${serie.name}:prev`}
|
||||
isAnimationActive={false}
|
||||
isAnimationActive={true}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray={'6 6'}
|
||||
|
||||
@@ -39,19 +39,16 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
>
|
||||
<AutoSizer disableHeight>
|
||||
{({ width }) => {
|
||||
const height = Math.min(Math.max(width * 0.5, 250), 400);
|
||||
const height = Math.min(Math.max(width * 0.5625, 250), 400);
|
||||
return (
|
||||
<PieChart
|
||||
width={width}
|
||||
height={Math.min(Math.max(width * 0.5, 250), 400)}
|
||||
>
|
||||
<PieChart width={width} height={height}>
|
||||
<Tooltip content={<ReportChartTooltip />} />
|
||||
<Pie
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={height / 4}
|
||||
outerRadius={height / 2.5}
|
||||
isAnimationActive={false}
|
||||
isAnimationActive={true}
|
||||
label={renderLabel}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { round } from '@/utils/math';
|
||||
|
||||
export function getYAxisWidth(value: number) {
|
||||
return round(value, 0).toString().length * 7.5 + 7.5;
|
||||
if (!isFinite(value)) {
|
||||
return 7.8 + 7.8;
|
||||
}
|
||||
|
||||
return round(value, 0).toString().length * 7.8 + 7.8;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useAppParams } from '@/hooks/useAppParams';
|
||||
import type { IChartInput } from '@/types';
|
||||
|
||||
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
|
||||
import { ChartEmpty } from './ChartEmpty';
|
||||
import { withChartProivder } from './ChartProvider';
|
||||
import { ReportAreaChart } from './ReportAreaChart';
|
||||
import { ReportBarChart } from './ReportBarChart';
|
||||
@@ -36,12 +37,7 @@ export const Chart = memo(
|
||||
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(
|
||||
const [data] = api.chart.chart.useSuspenseQuery(
|
||||
{
|
||||
// dont send lineType since it does not need to be sent
|
||||
lineType: 'monotone',
|
||||
@@ -61,104 +57,46 @@ export const Chart = memo(
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled,
|
||||
initialData,
|
||||
}
|
||||
);
|
||||
|
||||
const anyData = Boolean(chart.data?.series?.[0]?.data);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
||||
<p className="text-center font-medium">
|
||||
Please select at least one event to see the chart.
|
||||
</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isLoading) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
|
||||
<p className="text-center font-medium">Loading...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (chart.isError) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
|
||||
<p className="text-center font-medium">Something went wrong...</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chart.isSuccess) {
|
||||
return (
|
||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
||||
);
|
||||
}
|
||||
|
||||
if (!anyData) {
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
|
||||
<p className="text-center font-medium">No data</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
if (data.series.length === 0) {
|
||||
return <ChartEmpty />;
|
||||
}
|
||||
|
||||
if (chartType === 'map') {
|
||||
return <ReportMapChart data={chart.data} />;
|
||||
return <ReportMapChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'histogram') {
|
||||
return <ReportHistogramChart interval={interval} data={chart.data} />;
|
||||
return <ReportHistogramChart interval={interval} data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'bar') {
|
||||
return <ReportBarChart data={chart.data} />;
|
||||
return <ReportBarChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'metric') {
|
||||
return <ReportMetricChart data={chart.data} />;
|
||||
return <ReportMetricChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'pie') {
|
||||
return <ReportPieChart data={chart.data} />;
|
||||
return <ReportPieChart data={data} />;
|
||||
}
|
||||
|
||||
if (chartType === 'linear') {
|
||||
return (
|
||||
<ReportLineChart
|
||||
lineType={lineType}
|
||||
interval={interval}
|
||||
data={chart.data}
|
||||
/>
|
||||
<ReportLineChart lineType={lineType} interval={interval} data={data} />
|
||||
);
|
||||
}
|
||||
|
||||
if (chartType === 'area') {
|
||||
return (
|
||||
<ReportAreaChart
|
||||
lineType={lineType}
|
||||
interval={interval}
|
||||
data={chart.data}
|
||||
/>
|
||||
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartAnimationContainer>
|
||||
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
|
||||
<p className="text-center font-medium">
|
||||
Chart type "{chartType}" is not supported yet.
|
||||
</p>
|
||||
</ChartAnimationContainer>
|
||||
);
|
||||
return <p>Unknown chart type</p>;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command';
|
||||
import { ChevronsUpDownIcon } from 'lucide-react';
|
||||
import { useOnClickOutside } from 'usehooks-ts';
|
||||
|
||||
import { Button } from './button';
|
||||
@@ -92,6 +93,7 @@ export function ComboboxAdvanced({
|
||||
})}
|
||||
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ComboboxProps<T> {
|
||||
icon?: LucideIcon;
|
||||
size?: ButtonProps['size'];
|
||||
label?: string;
|
||||
align?: 'start' | 'end' | 'center';
|
||||
}
|
||||
|
||||
export type ExtendedComboboxProps<T> = Omit<
|
||||
@@ -55,6 +56,7 @@ export function Combobox<T extends string>({
|
||||
searchable,
|
||||
icon: Icon,
|
||||
size,
|
||||
align = 'start',
|
||||
}: ComboboxProps<T>) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [search, setSearch] = React.useState('');
|
||||
@@ -85,7 +87,7 @@ export function Combobox<T extends string>({
|
||||
</Button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full max-w-md p-0" align="start">
|
||||
<PopoverContent className="w-full max-w-md p-0" align={align}>
|
||||
<Command>
|
||||
{searchable === true && (
|
||||
<CommandInput
|
||||
|
||||
30
apps/web/src/components/ui/progress.tsx
Normal file
30
apps/web/src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
color: string;
|
||||
}
|
||||
>(({ className, value, color, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={'h-full w-full flex-1 bg-primary transition-all'}
|
||||
style={{
|
||||
transform: `translateX(-${100 - (value || 0)}%)`,
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
29
apps/web/src/components/ui/sonner.tsx
Normal file
29
apps/web/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,35 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Toaster as Sonner } from 'sonner';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = 'system' } = useTheme();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
<Sonner
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
|
||||
Reference in New Issue
Block a user