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

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