web: add previous/compare values for all charts

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-21 22:05:49 +01:00
parent 308ae98472
commit 46d5d203dc
27 changed files with 1290 additions and 231 deletions

View File

@@ -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">

View File

@@ -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({

View File

@@ -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 />}

View 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}
</>
);
}

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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,

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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>
</>
);

View File

@@ -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,

View File

@@ -36,6 +36,7 @@ const initialState: InitialState = {
range: '1m',
startDate: null,
endDate: null,
previous: false,
};
export const reportSlice = createSlice({

View File

@@ -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}

View File

@@ -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;
};
}

View 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);
},
};
}

View File

@@ -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;
},

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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,
};
}

View File

@@ -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({