web: add previous/compare values for all charts
This commit is contained in:
@@ -57,6 +57,7 @@ export default function ReportEditor({
|
||||
</div>
|
||||
</SheetTrigger>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 col-span-4">
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<Combobox
|
||||
className="min-w-0 flex-1"
|
||||
placeholder="Range"
|
||||
@@ -70,7 +71,6 @@ export default function ReportEditor({
|
||||
}))}
|
||||
/>
|
||||
<ReportInterval className="min-w-0 flex-1" />
|
||||
<ReportChartType className="min-w-0 flex-1" />
|
||||
<ReportLineType className="min-w-0 flex-1" />
|
||||
</div>
|
||||
<div className="col-start-2 md:col-start-6 row-start-1 text-right">
|
||||
|
||||
@@ -21,6 +21,7 @@ export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
*/
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
export type IChartData = RouterOutputs['chart']['chart'];
|
||||
export type IChartSerieDataItem = IChartData['series'][number]['data'][number];
|
||||
|
||||
export function handleError(error: TRPCClientErrorBase<any>) {
|
||||
toast({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cn } from '@/utils/cn';
|
||||
import { Space_Grotesk } from 'next/font/google';
|
||||
|
||||
import Providers from './providers';
|
||||
|
||||
@@ -7,18 +6,9 @@ import '@/styles/globals.css';
|
||||
|
||||
import { getSession } from '@/server/auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import Auth from './auth';
|
||||
|
||||
// import { cookies } from 'next/headers';
|
||||
|
||||
const font = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
variable: '--text',
|
||||
});
|
||||
|
||||
export const metadata = {};
|
||||
|
||||
export const viewport = {
|
||||
@@ -38,10 +28,7 @@ export default async function RootLayout({
|
||||
return (
|
||||
<html lang="en" className="light">
|
||||
<body
|
||||
className={cn(
|
||||
'min-h-screen font-sans antialiased grainy bg-slate-50',
|
||||
font.className
|
||||
)}
|
||||
className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')}
|
||||
>
|
||||
<Providers cookies={cookies().getAll()} session={session}>
|
||||
{session ? children : <Auth />}
|
||||
|
||||
43
apps/web/src/components/report/PreviousDiffIndicator.tsx
Normal file
43
apps/web/src/components/report/PreviousDiffIndicator.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import { useChartContext } from './chart/ChartProvider';
|
||||
|
||||
interface PreviousDiffIndicatorProps {
|
||||
diff?: number | null | undefined;
|
||||
state?: string | null | undefined;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PreviousDiffIndicator({
|
||||
diff,
|
||||
state,
|
||||
children,
|
||||
}: PreviousDiffIndicatorProps) {
|
||||
const { previous } = useChartContext();
|
||||
const number = useNumber();
|
||||
if (
|
||||
(children === undefined && (diff === null || diff === undefined)) ||
|
||||
previous === false
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn('flex items-center', [
|
||||
state === 'positive' && 'text-emerald-500',
|
||||
state === 'negative' && 'text-rose-500',
|
||||
state === 'neutral' && 'text-slate-400',
|
||||
])}
|
||||
>
|
||||
{state === 'positive' && <ChevronUp size={20} />}
|
||||
{state === 'negative' && <ChevronDown size={20} />}
|
||||
{number.format(diff)}%
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,12 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const range = useSelector((state) => state.report.range);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
if (chartType !== 'linear' && chartType !== 'histogram') {
|
||||
if (
|
||||
chartType !== 'linear' &&
|
||||
chartType !== 'histogram' &&
|
||||
chartType !== 'area' &&
|
||||
chartType !== 'metric'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createContext, memo, useContext, useMemo } from 'react';
|
||||
|
||||
export interface ChartContextType {
|
||||
editMode: boolean;
|
||||
previous?: boolean;
|
||||
}
|
||||
|
||||
type ChartProviderProps = {
|
||||
@@ -12,14 +13,19 @@ const ChartContext = createContext<ChartContextType>({
|
||||
editMode: false,
|
||||
});
|
||||
|
||||
export function ChartProvider({ children, editMode }: ChartProviderProps) {
|
||||
export function ChartProvider({
|
||||
children,
|
||||
editMode,
|
||||
previous,
|
||||
}: ChartProviderProps) {
|
||||
return (
|
||||
<ChartContext.Provider
|
||||
value={useMemo(
|
||||
() => ({
|
||||
editMode,
|
||||
previous: previous ?? false,
|
||||
}),
|
||||
[editMode]
|
||||
[editMode, previous]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ 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';
|
||||
|
||||
const columnHelper =
|
||||
@@ -36,6 +38,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
const [ref, { width }] = useElementSize();
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
|
||||
const number = useNumber();
|
||||
const table = useReactTable({
|
||||
data: useMemo(
|
||||
() => (editMode ? data.series : data.series.slice(0, 20)),
|
||||
@@ -60,7 +63,12 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
|
||||
columnHelper.accessor((row) => row.metrics.sum, {
|
||||
id: 'totalCount',
|
||||
cell: (info) => (
|
||||
<div className="text-right font-medium">{info.getValue()}</div>
|
||||
<div className="text-right font-medium flex gap-2">
|
||||
<div>{number.format(info.getValue())}</div>
|
||||
<PreviousDiffIndicator
|
||||
{...info.row.original.metrics.previous.sum}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
header: () => 'Count',
|
||||
footer: (info) => info.column.id,
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import React from 'react';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
|
||||
import { useSelector } from '@/redux';
|
||||
import type { IToolTipProps } from '@/types';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
import { useChartContext } from './ChartProvider';
|
||||
|
||||
type ReportLineChartTooltipProps = IToolTipProps<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
payload: {
|
||||
date: Date;
|
||||
count: number;
|
||||
label: string;
|
||||
color: string;
|
||||
} & Record<string, any>;
|
||||
payload: Record<string, unknown>;
|
||||
}>;
|
||||
|
||||
export function ReportChartTooltip({
|
||||
active,
|
||||
payload,
|
||||
}: ReportLineChartTooltipProps) {
|
||||
const { previous } = useChartContext();
|
||||
const getLabel = useMappings();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
|
||||
const number = useNumber();
|
||||
if (!active || !payload) {
|
||||
return null;
|
||||
}
|
||||
@@ -31,7 +33,10 @@ export function ReportChartTooltip({
|
||||
}
|
||||
|
||||
const limit = 3;
|
||||
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
|
||||
const sorted = payload
|
||||
.slice(0)
|
||||
.filter((item) => !item.dataKey.includes(':prev:count'))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
const visible = sorted.slice(0, limit);
|
||||
const hidden = sorted.slice(limit);
|
||||
|
||||
@@ -40,26 +45,46 @@ export function ReportChartTooltip({
|
||||
{visible.map((item, index) => {
|
||||
// If we have a <Cell /> component, payload can be nested
|
||||
const payload = item.payload.payload ?? item.payload;
|
||||
const data = item.dataKey.includes(':')
|
||||
? payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload;
|
||||
const data = (
|
||||
item.dataKey.includes(':')
|
||||
? // @ts-expect-error
|
||||
payload[`${item.dataKey.split(':')[0]}:payload`]
|
||||
: payload
|
||||
) as IRechartPayloadItem;
|
||||
|
||||
return (
|
||||
<>
|
||||
{index === 0 && data.date ? formatDate(new Date(data.date)) : null}
|
||||
<div key={item.payload.label} className="flex gap-2">
|
||||
<React.Fragment key={data.label}>
|
||||
{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">
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: data.color }}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{getLabel(data.label)}
|
||||
</div>
|
||||
<div>{data.count}</div>
|
||||
<div className="flex justify-between gap-8">
|
||||
<div>{number.format(data.count)}</div>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<PreviousDiffIndicator {...data.previous}>
|
||||
{!!data.previous && `(${data.previous.count})`}
|
||||
</PreviousDiffIndicator>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{hidden.length > 0 && (
|
||||
|
||||
@@ -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';
|
||||
@@ -27,7 +28,7 @@ export function ReportHistogramChart({
|
||||
interval,
|
||||
data,
|
||||
}: ReportHistogramChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
|
||||
@@ -65,14 +66,25 @@ export function ReportHistogramChart({
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Bar
|
||||
stackId={serie.index}
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
dataKey={`${serie.index}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={8}
|
||||
/>
|
||||
<React.Fragment key={serie.name}>
|
||||
{previous && (
|
||||
<Bar
|
||||
key={`${serie.name}:prev`}
|
||||
name={`${serie.name}:prev`}
|
||||
dataKey={`${serie.index}:prev:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
fillOpacity={0.2}
|
||||
radius={8}
|
||||
/>
|
||||
)}
|
||||
<Bar
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
dataKey={`${serie.index}:count`}
|
||||
fill={getChartColor(serie.index)}
|
||||
radius={8}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { AutoSizer } from '@/components/AutoSizer';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
@@ -32,7 +32,7 @@ export function ReportLineChart({
|
||||
interval,
|
||||
data,
|
||||
}: ReportLineChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(data);
|
||||
@@ -75,15 +75,30 @@ export function ReportLineChart({
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
<Line
|
||||
type={lineType}
|
||||
key={serie.name}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
name={serie.name}
|
||||
/>
|
||||
<React.Fragment key={serie.name}>
|
||||
<Line
|
||||
type={lineType}
|
||||
key={serie.name}
|
||||
name={serie.name}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.index}:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
/>
|
||||
{previous && (
|
||||
<Line
|
||||
type={lineType}
|
||||
key={`${serie.name}:prev`}
|
||||
name={`${serie.name}:prev`}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
strokeDasharray={'6 6'}
|
||||
dataKey={`${serie.index}:prev:count`}
|
||||
stroke={getChartColor(serie.index)}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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';
|
||||
|
||||
interface ReportMetricChartProps {
|
||||
@@ -16,6 +19,7 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
const { editMode } = useChartContext();
|
||||
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
|
||||
const color = theme?.colors['chart-0'];
|
||||
const number = useNumber();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -29,7 +33,7 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
className="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-50">
|
||||
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-20">
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
@@ -65,10 +69,19 @@ export function ReportMetricChart({ data }: ReportMetricChartProps) {
|
||||
<ColorSquare>{serie.event.id}</ColorSquare>
|
||||
{serie.name ?? serie.event.displayName ?? serie.event.name}
|
||||
</div>
|
||||
<div className="mt-6 font-mono text-4xl font-light">
|
||||
{new Intl.NumberFormat('en', {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(serie.metrics.sum)}
|
||||
<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>
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { Cell, Pie, PieChart, Tooltip } from 'recharts';
|
||||
|
||||
@@ -14,10 +15,55 @@ 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 {
|
||||
@@ -26,6 +72,7 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
index: serie.index,
|
||||
label: serie.name,
|
||||
count: serie.metrics.sum,
|
||||
percent: serie.metrics.sum / sum,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -50,8 +97,9 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
|
||||
dataKey={'count'}
|
||||
data={pieData}
|
||||
innerRadius={height / 4}
|
||||
outerRadius={height / 2 - 20}
|
||||
outerRadius={height / 2.5}
|
||||
isAnimationActive={false}
|
||||
label={renderLabel}
|
||||
>
|
||||
{pieData.map((item) => {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import * as React from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { Pagination, usePagination } from '@/components/Pagination';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -8,10 +18,12 @@ import {
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||
import { useMappings } from '@/hooks/useMappings';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import { useSelector } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
|
||||
|
||||
interface ReportTableProps {
|
||||
data: IChartData;
|
||||
visibleSeries: IChartData['series'];
|
||||
@@ -23,6 +35,8 @@ export function ReportTable({
|
||||
visibleSeries,
|
||||
setVisibleSeries,
|
||||
}: ReportTableProps) {
|
||||
const pagination = usePagination(50);
|
||||
const number = useNumber();
|
||||
const interval = useSelector((state) => state.report.interval);
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const getLabel = useMappings();
|
||||
@@ -37,117 +51,125 @@ export function ReportTable({
|
||||
});
|
||||
}
|
||||
|
||||
const row = 'flex border-b border-border last:border-b-0 flex-1';
|
||||
const cell = 'p-2 last:pr-8 last:w-[8rem]';
|
||||
const value = 'min-w-[6rem] text-right';
|
||||
const header = 'text-sm font-medium';
|
||||
const total =
|
||||
'bg-gray-50 text-emerald-600 font-medium border-r border-border';
|
||||
return (
|
||||
<>
|
||||
<div className="flex w-fit max-w-full rounded-md border border-border bg-white">
|
||||
{/* Labels */}
|
||||
<div className="border-r border-border">
|
||||
<div className={cn(header, row, cell)}>Name</div>
|
||||
{data.series.map((serie, index) => {
|
||||
const checked = !!visibleSeries.find(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
<div className="grid grid-cols-[200px_1fr] border border-border rounded-md overflow-hidden">
|
||||
<Table className="rounded-none border-t-0 border-l-0 border-b-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.series
|
||||
.slice(pagination.skip, pagination.skip + pagination.take)
|
||||
.map((serie, index) => {
|
||||
const checked = !!visibleSeries.find(
|
||||
(item) => item.name === serie.name
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={serie.name}
|
||||
className={cn(
|
||||
'flex max-w-[200px] lg:max-w-[400px] xl:max-w-[600px] w-full min-w-full items-center gap-2',
|
||||
row,
|
||||
// avoid using cell since its better on the right side
|
||||
'p-2'
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange(serie.name, !!checked)
|
||||
}
|
||||
style={
|
||||
checked
|
||||
? {
|
||||
background: getChartColor(index),
|
||||
borderColor: getChartColor(index),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{getLabel(serie.name)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getLabel(serie.name)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ScrollView for all values */}
|
||||
<div className="w-full overflow-auto">
|
||||
{/* Header */}
|
||||
<div className={cn('w-max', row)}>
|
||||
<div className={cn(header, value, cell, total)}>Total</div>
|
||||
<div className={cn(header, value, cell, total)}>Average</div>
|
||||
{data.series[0]?.data.map((serie) => (
|
||||
<div
|
||||
key={serie.date.toString()}
|
||||
className={cn(header, value, cell)}
|
||||
>
|
||||
{formatDate(serie.date)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Values */}
|
||||
{data.series.map((serie) => {
|
||||
return (
|
||||
<div className={cn('w-max', row)} key={serie.name}>
|
||||
<div className={cn(header, value, cell, total)}>
|
||||
{serie.metrics.sum}
|
||||
</div>
|
||||
<div className={cn(header, value, cell, total)}>
|
||||
{serie.metrics.average}
|
||||
</div>
|
||||
{serie.data.map((item) => {
|
||||
return (
|
||||
<TableRow key={serie.name}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) =>
|
||||
handleChange(serie.name, !!checked)
|
||||
}
|
||||
style={
|
||||
checked
|
||||
? {
|
||||
background: getChartColor(index),
|
||||
borderColor: getChartColor(index),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
checked={checked}
|
||||
/>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{getLabel(serie.name)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{getLabel(serie.name)}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="overflow-auto">
|
||||
<Table className="rounded-none border-none">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Total</TableHead>
|
||||
<TableHead>Average</TableHead>
|
||||
{data.series[0]?.data.map((serie) => (
|
||||
<TableHead
|
||||
key={serie.date.toString()}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{formatDate(serie.date)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.series
|
||||
.slice(pagination.skip, pagination.skip + pagination.take)
|
||||
.map((serie) => {
|
||||
return (
|
||||
<div key={item.date} className={cn(value, cell)}>
|
||||
{item.count}
|
||||
</div>
|
||||
<TableRow key={serie.name}>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.sum)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous.sum}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-10">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
{number.format(serie.metrics.average)}
|
||||
<PreviousDiffIndicator
|
||||
{...serie.metrics.previous.average}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{serie.data.map((item) => {
|
||||
return (
|
||||
<TableCell
|
||||
className="h-10"
|
||||
key={item.date.toString()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{number.format(item.count)}
|
||||
<PreviousDiffIndicator {...item.previous} />
|
||||
</div>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex gap-1">
|
||||
<div>Total</div>
|
||||
<div>{data.metrics.sum}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div>Average</div>
|
||||
<div>{data.metrics.averge}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div>Min</div>
|
||||
<div>{data.metrics.min}</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div>Max</div>
|
||||
<div>{data.metrics.max}</div>
|
||||
<div className="flex flex-col-reverse gap-4 md:flex-row md:justify-between md:items-center">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Badge>Total: {number.format(data.metrics.sum)}</Badge>
|
||||
<Badge>Average: {number.format(data.metrics.average)}</Badge>
|
||||
<Badge>Min: {number.format(data.metrics.min)}</Badge>
|
||||
<Badge>Max: {number.format(data.metrics.max)}</Badge>
|
||||
</div>
|
||||
<Pagination {...pagination} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export const Chart = memo(
|
||||
name,
|
||||
range,
|
||||
lineType,
|
||||
previous,
|
||||
}: ReportChartProps) {
|
||||
const params = useAppParams();
|
||||
const hasEmptyFilters = events.some((event) =>
|
||||
@@ -44,6 +45,7 @@ export const Chart = memo(
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
projectId: params.projectId,
|
||||
previous,
|
||||
},
|
||||
{
|
||||
keepPreviousData: false,
|
||||
|
||||
@@ -36,6 +36,7 @@ const initialState: InitialState = {
|
||||
range: '1m',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
previous: false,
|
||||
};
|
||||
|
||||
export const reportSlice = createSlice({
|
||||
|
||||
@@ -91,7 +91,7 @@ const TableCell = React.forwardRef<
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border [.mini_&]:p-2',
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border [.mini_&]:p-2 whitespace-nowrap',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import mappings from '@/mappings.json';
|
||||
|
||||
export function useMappings() {
|
||||
return (val: string) => {
|
||||
return (val: string | null) => {
|
||||
return mappings.find((item) => item.id === val)?.name ?? val;
|
||||
};
|
||||
}
|
||||
|
||||
11
apps/web/src/hooks/useNumerFormatter.ts
Normal file
11
apps/web/src/hooks/useNumerFormatter.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function useNumber() {
|
||||
const locale = 'en-gb';
|
||||
|
||||
return {
|
||||
format: (value: number) => {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
import { alphabetIds } from '@/utils/constants';
|
||||
import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
|
||||
export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
|
||||
|
||||
export function useRechartDataModel(data: IChartData) {
|
||||
return useMemo(() => {
|
||||
return (
|
||||
@@ -15,11 +16,14 @@ export function useRechartDataModel(data: IChartData) {
|
||||
...serie.data.reduce(
|
||||
(acc2, item) => {
|
||||
if (item.date === date) {
|
||||
if (item.previous) {
|
||||
acc2[`${idx}:prev:count`] = item.previous.count;
|
||||
}
|
||||
acc2[`${idx}:count`] = item.count;
|
||||
acc2[`${idx}:payload`] = {
|
||||
...item,
|
||||
color: getChartColor(idx),
|
||||
};
|
||||
} satisfies IRechartPayloadItem;
|
||||
}
|
||||
return acc2;
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { IChartData } from '@/app/_trpc/client';
|
||||
|
||||
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
|
||||
const max = limit ?? 20;
|
||||
const max = limit ?? 5;
|
||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||
const ref = useRef(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -119,75 +119,199 @@ export const chartRouter = createTRPCRouter({
|
||||
|
||||
chart: protectedProcedure
|
||||
.input(zChartInputWithDates.merge(z.object({ projectId: z.string() })))
|
||||
.query(async ({ input: { projectId, events, ...input } }) => {
|
||||
const { startDate, endDate } =
|
||||
input.startDate && input.endDate
|
||||
? {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}
|
||||
: getDatesFromRange(input.range);
|
||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||
for (const event of events) {
|
||||
const result = await getChartData({
|
||||
...input,
|
||||
startDate,
|
||||
endDate,
|
||||
event,
|
||||
projectId: projectId,
|
||||
});
|
||||
series.push(...result);
|
||||
.query(async ({ input }) => {
|
||||
const current = getDatesFromRange(input.range);
|
||||
let diff = 0;
|
||||
|
||||
switch (input.range) {
|
||||
case '24h':
|
||||
case 'today': {
|
||||
diff = 1000 * 60 * 60 * 24;
|
||||
break;
|
||||
}
|
||||
case '7d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 17;
|
||||
break;
|
||||
}
|
||||
case '14d': {
|
||||
diff = 1000 * 60 * 60 * 24 * 14;
|
||||
break;
|
||||
}
|
||||
case '1m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 30;
|
||||
break;
|
||||
}
|
||||
case '3m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 90;
|
||||
break;
|
||||
}
|
||||
case '6m': {
|
||||
diff = 1000 * 60 * 60 * 24 * 180;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...series].sort((a, b) => {
|
||||
if (input.chartType === 'linear') {
|
||||
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
|
||||
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
|
||||
return sumB - sumA;
|
||||
} else {
|
||||
return b.metrics.sum - a.metrics.sum;
|
||||
}
|
||||
});
|
||||
const promises = [wrapper(input)];
|
||||
|
||||
const metrics = {
|
||||
max: Math.max(...sorted.map((item) => item.metrics.max)),
|
||||
min: Math.min(...sorted.map((item) => item.metrics.min)),
|
||||
sum: sum(sorted.map((item) => item.metrics.sum, 0)),
|
||||
averge: round(
|
||||
average(sorted.map((item) => item.metrics.average, 0)),
|
||||
2
|
||||
),
|
||||
};
|
||||
if (input.previous) {
|
||||
promises.push(
|
||||
wrapper({
|
||||
...input,
|
||||
...{
|
||||
startDate: new Date(
|
||||
new Date(current.startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(
|
||||
new Date(current.endDate).getTime() - diff
|
||||
).toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const awaitedPromises = await Promise.all(promises);
|
||||
const data = awaitedPromises[0]!;
|
||||
const previousData = awaitedPromises[1];
|
||||
|
||||
return {
|
||||
events: Object.entries(
|
||||
series.reduce(
|
||||
(acc, item) => {
|
||||
if (acc[item.event.id]) {
|
||||
acc[item.event.id] += item.metrics.sum;
|
||||
} else {
|
||||
acc[item.event.id] = item.metrics.sum;
|
||||
}
|
||||
return acc;
|
||||
...data,
|
||||
series: data.series.map((item, sIndex) => {
|
||||
function getPreviousDiff(key: keyof (typeof data)['metrics']) {
|
||||
const prev = previousData?.series?.[sIndex]?.metrics?.[key];
|
||||
const diff = getPreviousDataDiff(item.metrics[key], prev);
|
||||
|
||||
return diff && prev
|
||||
? {
|
||||
diff: diff?.diff,
|
||||
state: diff?.state,
|
||||
value: prev,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metrics: {
|
||||
...item.metrics,
|
||||
previous: {
|
||||
sum: getPreviousDiff('sum'),
|
||||
average: getPreviousDiff('average'),
|
||||
},
|
||||
},
|
||||
{} as Record<(typeof series)[number]['event']['id'], number>
|
||||
)
|
||||
).map(([id, count]) => ({
|
||||
count,
|
||||
...events.find((event) => event.id === id)!,
|
||||
})),
|
||||
series: sorted.map((item) => ({
|
||||
...item,
|
||||
metrics: {
|
||||
...item.metrics,
|
||||
totalMetrics: metrics,
|
||||
},
|
||||
})),
|
||||
metrics,
|
||||
data: item.data.map((item, dIndex) => {
|
||||
const diff = getPreviousDataDiff(
|
||||
item.count,
|
||||
previousData?.series?.[sIndex]?.data?.[dIndex]?.count
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
previous:
|
||||
diff && previousData?.series?.[sIndex]?.data?.[dIndex]
|
||||
? Object.assign(
|
||||
{},
|
||||
previousData?.series?.[sIndex]?.data?.[dIndex],
|
||||
diff
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const chartValidator = zChartInputWithDates.merge(
|
||||
z.object({ projectId: z.string() })
|
||||
);
|
||||
type ChartInput = z.infer<typeof chartValidator>;
|
||||
|
||||
function getPreviousDataDiff(current: number, previous: number | undefined) {
|
||||
if (!previous) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const diff = round(
|
||||
((current > previous
|
||||
? current / previous
|
||||
: current < previous
|
||||
? previous / current
|
||||
: 0) -
|
||||
1) *
|
||||
100,
|
||||
1
|
||||
);
|
||||
|
||||
return {
|
||||
diff: Number.isNaN(diff) || !Number.isFinite(diff) ? null : diff,
|
||||
state:
|
||||
current > previous
|
||||
? 'positive'
|
||||
: current < previous
|
||||
? 'negative'
|
||||
: 'neutral',
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapper({ events, projectId, ...input }: ChartInput) {
|
||||
const { startDate, endDate } =
|
||||
input.startDate && input.endDate
|
||||
? {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
}
|
||||
: getDatesFromRange(input.range);
|
||||
const series: Awaited<ReturnType<typeof getChartData>> = [];
|
||||
for (const event of events) {
|
||||
const result = await getChartData({
|
||||
...input,
|
||||
startDate,
|
||||
endDate,
|
||||
event,
|
||||
projectId: projectId,
|
||||
});
|
||||
series.push(...result);
|
||||
}
|
||||
|
||||
const sorted = [...series].sort((a, b) => {
|
||||
if (input.chartType === 'linear') {
|
||||
const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
|
||||
const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
|
||||
return sumB - sumA;
|
||||
} else {
|
||||
return b.metrics.sum - a.metrics.sum;
|
||||
}
|
||||
});
|
||||
|
||||
const metrics = {
|
||||
max: Math.max(...sorted.map((item) => item.metrics.max)),
|
||||
min: Math.min(...sorted.map((item) => item.metrics.min)),
|
||||
sum: sum(sorted.map((item) => item.metrics.sum, 0)),
|
||||
average: round(average(sorted.map((item) => item.metrics.average, 0)), 2),
|
||||
};
|
||||
|
||||
return {
|
||||
events: Object.entries(
|
||||
series.reduce(
|
||||
(acc, item) => {
|
||||
if (acc[item.event.id]) {
|
||||
acc[item.event.id] += item.metrics.sum;
|
||||
} else {
|
||||
acc[item.event.id] = item.metrics.sum;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<(typeof series)[number]['event']['id'], number>
|
||||
)
|
||||
).map(([id, count]) => ({
|
||||
count,
|
||||
...events.find((event) => event.id === id)!,
|
||||
})),
|
||||
series: sorted,
|
||||
metrics,
|
||||
};
|
||||
}
|
||||
|
||||
interface ResultItem {
|
||||
label: string | null;
|
||||
count: number;
|
||||
@@ -294,7 +418,9 @@ async function getChartData(payload: IGetChartDataInput) {
|
||||
payload.chartType === 'area' ||
|
||||
payload.chartType === 'linear' ||
|
||||
payload.chartType === 'histogram' ||
|
||||
payload.chartType === 'metric'
|
||||
payload.chartType === 'metric' ||
|
||||
payload.chartType === 'pie' ||
|
||||
payload.chartType === 'bar'
|
||||
? fillEmptySpotsInTimeline(
|
||||
series[key] ?? [],
|
||||
payload.interval,
|
||||
|
||||
@@ -51,6 +51,7 @@ export function transformReport(
|
||||
interval: report.interval,
|
||||
name: report.name || 'Untitled',
|
||||
range: (report.range as IChartRange) ?? timeRanges['1m'],
|
||||
previous: report.previous ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export const zChartInput = z.object({
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
range: z.enum(objectToZodEnums(timeRanges)),
|
||||
previous: z.boolean(),
|
||||
});
|
||||
|
||||
export const zChartInputWithDates = zChartInput.extend({
|
||||
|
||||
Reference in New Issue
Block a user