try improve overview

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-27 21:40:26 +01:00
parent 7cbdae8929
commit ee2ccbaa98
9 changed files with 198 additions and 80 deletions

View File

@@ -182,7 +182,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
name: 'Visit duration', name: 'Visit duration',
range, range,
previous, previous,
formula: 'A/1000/60', formula: 'A/1000',
metric: 'average', metric: 'average',
unit: 'min', unit: 'min',
}, },
@@ -192,10 +192,11 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
return ( return (
<> <>
<div className="grid grid-cols-6 col-span-6 gap-1">
{reports.map((report, index) => ( {reports.map((report, index) => (
<button <button
key={index} key={index}
className="relative col-span-6 md:col-span-3 lg:col-span-2 group" className="relative col-span-3 md:col-span-2 lg:col-span-1 group"
onClick={() => { onClick={() => {
setMetric(index); setMetric(index);
}} }}
@@ -203,13 +204,14 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<ChartSwitch hideID {...report} /> <ChartSwitch hideID {...report} />
<div <div
className={cn( className={cn(
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0', 'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-black ring-black',
metric === index ? 'opacity-100' : 'opacity-0' metric === index ? 'opacity-100' : 'opacity-0'
)} )}
/> />
{/* add active border */} {/* add active border */}
</button> </button>
))} ))}
</div>
<Widget className="col-span-6"> <Widget className="col-span-6">
<WidgetHead> <WidgetHead>
<div className="title">{selectedMetric.events[0]?.displayName}</div> <div className="title">{selectedMetric.events[0]?.displayName}</div>

View File

@@ -42,7 +42,7 @@ export function OverviewLiveHistogram({
metric: 'sum', metric: 'sum',
breakdowns: [], breakdowns: [],
lineType: 'monotone', lineType: 'monotone',
previous: true, previous: false,
}; };
return ( return (

View File

@@ -5,6 +5,25 @@ import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider'; import { useChartContext } from './chart/ChartProvider';
export function getDiffIndicator<A, B, C>(
inverted: boolean | undefined,
state: string | undefined | null,
positive: A,
negative: B,
neutral: C
): A | B | C {
if (state === 'neutral' || !state) {
return neutral;
}
if (inverted === true) {
return state === 'positive' ? negative : positive;
}
return state === 'positive' ? positive : negative;
}
// TODO: Fix this mess!
interface PreviousDiffIndicatorProps { interface PreviousDiffIndicatorProps {
diff?: number | null | undefined; diff?: number | null | undefined;
state?: string | null | undefined; state?: string | null | undefined;
@@ -18,38 +37,77 @@ export function PreviousDiffIndicator({
children, children,
}: PreviousDiffIndicatorProps) { }: PreviousDiffIndicatorProps) {
const { previous, previousIndicatorInverted } = useChartContext(); const { previous, previousIndicatorInverted } = useChartContext();
const variant = getDiffIndicator(
previousIndicatorInverted,
state,
'success',
'destructive',
undefined
);
const number = useNumber(); const number = useNumber();
if (diff === null || diff === undefined || previous === false) { if (diff === null || diff === undefined || previous === false) {
return children ?? null; return children ?? null;
} }
if (previousIndicatorInverted === true) { const renderIcon = () => {
return ( if (state === 'positive') {
<> return <TrendingUpIcon size={15} />;
<Badge
className="flex gap-1"
variant={state === 'positive' ? 'destructive' : 'success'}
>
{state === 'negative' && <TrendingUpIcon size={15} />}
{state === 'positive' && <TrendingDownIcon size={15} />}
{number.format(diff)}%
</Badge>
{children}
</>
);
} }
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
}
return null;
};
return ( return (
<> <>
<Badge <Badge className="flex gap-1" variant={variant}>
className="flex gap-1" {renderIcon()}
variant={state === 'positive' ? 'success' : 'destructive'}
>
{state === 'positive' && <TrendingUpIcon size={15} />}
{state === 'negative' && <TrendingDownIcon size={15} />}
{number.format(diff)}% {number.format(diff)}%
</Badge> </Badge>
{children} {children}
</> </>
); );
} }
export function PreviousDiffIndicatorText({
diff,
state,
className,
}: PreviousDiffIndicatorProps & { className?: string }) {
const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return null;
}
const renderIcon = () => {
if (state === 'positive') {
return <TrendingUpIcon size={15} />;
}
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
}
return null;
};
return (
<div
className={cn([
'flex gap-0.5 items-center',
getDiffIndicator(
previousIndicatorInverted,
state,
'text-emerald-600',
'text-red-600',
undefined
),
className,
])}
>
{renderIcon()}
{number.format(diff)}%
</div>
);
}

View File

@@ -2,14 +2,19 @@
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare'; import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter'; import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import { theme } from '@/utils/theme'; import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts'; import { Area, AreaChart } from 'recharts';
import type { IChartMetric } from '@mixan/validation'; import type { IChartMetric } from '@mixan/validation';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator'; import {
getDiffIndicator,
PreviousDiffIndicator,
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
interface MetricCardProps { interface MetricCardProps {
serie: IChartData['series'][number]; serie: IChartData['series'][number];
@@ -24,14 +29,39 @@ export function MetricCard({
metric, metric,
unit, unit,
}: MetricCardProps) { }: MetricCardProps) {
const { previousIndicatorInverted } = useChartContext();
const color = _color || theme?.colors['chart-0']; const color = _color || theme?.colors['chart-0'];
const number = useNumber(); const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => {
if (unit === 'min') {
return <>{fancyMinutes(value)}</>;
}
return (
<>
{number.short(value)}
{unit && <span className={unitClassName}>{unit}</span>}
</>
);
};
const previous = serie.metrics.previous[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
previous?.state,
'green',
'red',
'blue'
);
return ( return (
<div <div
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden h-24" className="group relative border border-border p-2 rounded-md bg-white overflow-hidden h-24"
key={serie.name} 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"> <div className="absolute -top-2 -left-2 -right-2 -bottom-2 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
<AutoSizer> <AutoSizer>
{({ width, height }) => ( {({ width, height }) => (
<AreaChart <AreaChart
@@ -41,17 +71,25 @@ export function MetricCard({
style={{ marginTop: (height / 3) * 2 }} style={{ marginTop: (height / 3) * 2 }}
> >
<defs> <defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="red" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} /> <stop offset="5%" stopColor={'red'} stopOpacity={0.5} />
<stop offset="95%" stopColor={color} stopOpacity={0.1} /> <stop offset="95%" stopColor={'red'} stopOpacity={0.2} />
</linearGradient>
<linearGradient id="green" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={'green'} stopOpacity={0.5} />
<stop offset="95%" stopColor={'green'} stopOpacity={0.2} />
</linearGradient>
<linearGradient id="blue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={'blue'} stopOpacity={0.5} />
<stop offset="95%" stopColor={'blue'} stopOpacity={0.2} />
</linearGradient> </linearGradient>
</defs> </defs>
<Area <Area
dataKey="count" dataKey="count"
type="monotone" type="monotone"
fill="url(#area)" fill={`url(#${graphColors})`}
fillOpacity={1} fillOpacity={1}
stroke={color} stroke={graphColors}
strokeWidth={2} strokeWidth={2}
/> />
</AreaChart> </AreaChart>
@@ -60,23 +98,22 @@ export function MetricCard({
</div> </div>
<div className="relative"> <div className="relative">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium"> <div className="flex items-center gap-2 font-medium text-left min-w-0">
<ColorSquare>{serie.event.id}</ColorSquare> <ColorSquare>{serie.event.id}</ColorSquare>
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
{serie.name || serie.event.displayName || serie.event.name} {serie.name || serie.event.displayName || serie.event.name}
</span>
</div> </div>
<PreviousDiffIndicator {...serie.metrics.previous[metric]} /> {/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
</div> </div>
<div className="flex justify-between items-end mt-2"> <div className="flex justify-between items-end mt-2">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{number.format(serie.metrics[metric])} {renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
{unit && <span className="ml-1 font-light text-xl">{unit}</span>}
</div> </div>
{!!serie.metrics.previous[metric] && ( <PreviousDiffIndicatorText
<div> {...serie.metrics.previous[metric]}
{number.format(serie.metrics.previous[metric]?.value)} className="font-medium text-xs mb-0.5"
{unit} />
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel'; import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { import {
Area, Area,
@@ -38,6 +37,7 @@ export function ReportAreaChart({
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series); const rechartData = useRechartDataModel(series);
const number = useNumber();
return ( return (
<> <>
@@ -59,6 +59,7 @@ export function ReportAreaChart({
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
allowDecimals={false} allowDecimals={false}
tickFormatter={number.short}
/> />
{series.map((serie) => { {series.map((serie) => {

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel'; import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme'; import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
@@ -40,8 +39,8 @@ export function ReportHistogramChart({
const { editMode, previous } = useChartContext(); const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series); const rechartData = useRechartDataModel(series);
const number = useNumber();
return ( return (
<> <>
@@ -64,6 +63,7 @@ export function ReportHistogramChart({
width={getYAxisWidth(data.metrics.max)} width={getYAxisWidth(data.metrics.max)}
allowDecimals={false} allowDecimals={false}
domain={[0, data.metrics.max]} domain={[0, data.metrics.max]}
tickFormatter={number.short}
/> />
{series.map((serie) => { {series.map((serie) => {
return ( return (

View File

@@ -2,11 +2,10 @@
import React from 'react'; import React from 'react';
import type { IChartData } from '@/app/_trpc/client'; import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval'; import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel'; import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries'; import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { import {
CartesianGrid, CartesianGrid,
@@ -40,7 +39,7 @@ export function ReportLineChart({
const formatDate = useFormatDateInterval(interval); const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data); const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series); const rechartData = useRechartDataModel(series);
const number = useNumber();
return ( return (
<> <>
<ResponsiveContainer> <ResponsiveContainer>
@@ -57,6 +56,7 @@ export function ReportLineChart({
axisLine={false} axisLine={false}
tickLine={false} tickLine={false}
allowDecimals={false} allowDecimals={false}
tickFormatter={number.short}
/> />
<Tooltip content={<ReportChartTooltip />} /> <Tooltip content={<ReportChartTooltip />} />
<XAxis <XAxis

View File

@@ -1,9 +1,11 @@
import { round } from '@/utils/math'; const formatter = new Intl.NumberFormat('en', {
notation: 'compact',
});
export function getYAxisWidth(value: number) { export function getYAxisWidth(value: number) {
if (!isFinite(value)) { if (!isFinite(value)) {
return 7.8 + 7.8; return 7.8 + 7.8;
} }
return round(value, 0).toString().length * 7.8 + 7.8; return formatter.format(value).toString().length * 7.8 + 7.8;
} }

View File

@@ -1,16 +1,34 @@
import { round } from '@/utils/math';
import { isNil } from 'ramda'; import { isNil } from 'ramda';
export function fancyMinutes(time: number) {
const minutes = Math.floor(time / 60);
const seconds = round(time - minutes * 60, 0);
return `${minutes}m ${seconds}s`;
}
export function useNumber() { export function useNumber() {
const locale = 'en-gb'; const locale = 'en-gb';
return { const format = (value: number | null | undefined) => {
format: (value: number | null | undefined) => {
if (isNil(value)) { if (isNil(value)) {
return 'N/A'; return 'N/A';
} }
return new Intl.NumberFormat(locale, { return new Intl.NumberFormat(locale, {
maximumSignificantDigits: 20, maximumSignificantDigits: 20,
}).format(value); }).format(value);
}, };
const short = (value: number | null | undefined) => {
if (isNil(value)) {
return 'N/A';
}
return new Intl.NumberFormat(locale, {
notation: 'compact',
}).format(value);
};
return {
format,
short,
}; };
} }