commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Wed Nov 26 12:32:40 2025 +0100 wip commit8cd3b89fa3Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:33:58 2025 +0100 funnel commit95af86dc44Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:23:25 2025 +0100 wip commit727a218e6bAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:26 2025 +0100 conversion wip commit958ba535d6Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:20 2025 +0100 wip commit3bbeb927ccAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 09:18:48 2025 +0100 wip commitd99335e2f4Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 18:08:10 2025 +0100 wip commit1fa61b1ae9Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 15:50:28 2025 +0100 ts commit548747d826Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:17:01 2025 +0100 fix typecheck events -> series commit7b18544085Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:06:46 2025 +0100 fix report table commit57697a5a39Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Sat Nov 22 00:05:13 2025 +0100 wip commit06fb6c4f3cAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Fri Nov 21 11:21:17 2025 +0100 wip commitdd71fd4e11Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Thu Nov 20 13:56:58 2025 +0100 formulas
252 lines
6.7 KiB
TypeScript
252 lines
6.7 KiB
TypeScript
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
|
import { cn } from '@/utils/cn';
|
|
import AutoSizer from 'react-virtualized-auto-sizer';
|
|
import { Area, AreaChart, Tooltip } from 'recharts';
|
|
|
|
import { formatDate, timeAgo } from '@/utils/date';
|
|
import { getChartColor } from '@/utils/theme';
|
|
import { getPreviousMetric } from '@openpanel/common';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
ChartTooltipContainer,
|
|
ChartTooltipHeader,
|
|
ChartTooltipItem,
|
|
} from '../charts/chart-tooltip';
|
|
import {
|
|
PreviousDiffIndicatorPure,
|
|
getDiffIndicator,
|
|
} from '../report-chart/common/previous-diff-indicator';
|
|
import { Skeleton } from '../skeleton';
|
|
import { Tooltiper } from '../ui/tooltip';
|
|
|
|
interface MetricCardProps {
|
|
id: string;
|
|
data: {
|
|
current: number;
|
|
previous?: number;
|
|
date: string;
|
|
}[];
|
|
metric: {
|
|
current: number;
|
|
previous?: number | null;
|
|
};
|
|
unit?: '' | 'date' | 'timeAgo' | 'min' | '%' | 'currency';
|
|
label: string;
|
|
onClick?: () => void;
|
|
active?: boolean;
|
|
inverted?: boolean;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
export function OverviewMetricCard({
|
|
id,
|
|
data,
|
|
metric,
|
|
unit,
|
|
label,
|
|
onClick,
|
|
active,
|
|
inverted = false,
|
|
isLoading = false,
|
|
}: MetricCardProps) {
|
|
const [currentIndex, setCurrentIndex] = useState<number | null>(null);
|
|
const number = useNumber();
|
|
const { current, previous } = metric;
|
|
const timer = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current);
|
|
}
|
|
|
|
if (currentIndex) {
|
|
timer.current = setTimeout(() => {
|
|
setCurrentIndex(null);
|
|
}, 1000);
|
|
}
|
|
|
|
return () => {
|
|
if (timer.current) {
|
|
clearTimeout(timer.current);
|
|
}
|
|
};
|
|
}, [currentIndex]);
|
|
|
|
const renderValue = (value: number, unitClassName?: string, short = true) => {
|
|
if (unit === 'date') {
|
|
return <>{formatDate(new Date(value))}</>;
|
|
}
|
|
|
|
if (unit === 'timeAgo') {
|
|
return <>{timeAgo(new Date(value))}</>;
|
|
}
|
|
|
|
if (unit === 'min') {
|
|
return <>{fancyMinutes(value)}</>;
|
|
}
|
|
|
|
if (unit === 'currency') {
|
|
// Revenue is stored in cents, convert to dollars
|
|
return <>{number.currency(value / 100)}</>;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{short ? number.short(value) : number.format(value)}
|
|
{unit && <span className={unitClassName}>{unit}</span>}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const graphColors = getDiffIndicator(
|
|
inverted,
|
|
getPreviousMetric(current, previous)?.state,
|
|
'#6ee7b7', // green
|
|
'#fda4af', // red
|
|
'#93c5fd', // blue
|
|
);
|
|
|
|
const renderTooltip = () => {
|
|
if (currentIndex) {
|
|
return (
|
|
<span>
|
|
{formatDate(new Date(data[currentIndex]?.date))}:{' '}
|
|
<span className="font-semibold">
|
|
{renderValue(
|
|
data[currentIndex].current,
|
|
'ml-1 font-light text-xl',
|
|
false,
|
|
)}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<span>
|
|
{label}:{' '}
|
|
<span className="font-semibold">
|
|
{renderValue(metric.current, 'ml-1 font-light text-xl', false)}
|
|
</span>
|
|
</span>
|
|
);
|
|
};
|
|
return (
|
|
<Tooltiper content={renderTooltip()} asChild sideOffset={-20}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
'col-span-2 flex-1 shadow-[0_0_0_0.5px] shadow-border md:col-span-1',
|
|
active && 'bg-def-100',
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<div className={cn('group relative p-4')}>
|
|
<div
|
|
className={cn(
|
|
'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
|
|
)}
|
|
>
|
|
<AutoSizer>
|
|
{({ width, height }) => (
|
|
<AreaChart
|
|
width={width}
|
|
height={height / 4}
|
|
data={data}
|
|
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
|
|
onMouseMove={(event) => {
|
|
setCurrentIndex(event.activeTooltipIndex ?? null);
|
|
}}
|
|
>
|
|
<defs>
|
|
<linearGradient
|
|
id={`colorUv${id}`}
|
|
x1="0"
|
|
y1="0"
|
|
x2="0"
|
|
y2="1"
|
|
>
|
|
<stop
|
|
offset="0%"
|
|
stopColor={graphColors}
|
|
stopOpacity={0.2}
|
|
/>
|
|
<stop
|
|
offset="100%"
|
|
stopColor={graphColors}
|
|
stopOpacity={0.05}
|
|
/>
|
|
</linearGradient>
|
|
</defs>
|
|
<Tooltip content={() => null} />
|
|
<Area
|
|
dataKey={'current'}
|
|
type="step"
|
|
fill={`url(#colorUv${id})`}
|
|
fillOpacity={1}
|
|
stroke={graphColors}
|
|
strokeWidth={1}
|
|
isAnimationActive={false}
|
|
/>
|
|
</AreaChart>
|
|
)}
|
|
</AutoSizer>
|
|
</div>
|
|
<OverviewMetricCardNumber
|
|
label={label}
|
|
value={renderValue(current, 'ml-1 font-light text-xl')}
|
|
enhancer={
|
|
<PreviousDiffIndicatorPure
|
|
className="text-sm"
|
|
size="sm"
|
|
inverted={inverted}
|
|
{...getPreviousMetric(current, previous)}
|
|
/>
|
|
}
|
|
isLoading={isLoading}
|
|
/>
|
|
</div>
|
|
</button>
|
|
</Tooltiper>
|
|
);
|
|
}
|
|
|
|
export function OverviewMetricCardNumber({
|
|
label,
|
|
value,
|
|
enhancer,
|
|
className,
|
|
isLoading,
|
|
}: {
|
|
label: React.ReactNode;
|
|
value: React.ReactNode;
|
|
enhancer?: React.ReactNode;
|
|
className?: string;
|
|
isLoading?: boolean;
|
|
}) {
|
|
return (
|
|
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 items-center gap-2 text-left">
|
|
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
|
{label}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{isLoading ? (
|
|
<div className="flex items-end justify-between gap-4">
|
|
<Skeleton className="h-6 w-16" />
|
|
<Skeleton className="h-6 w-12" />
|
|
</div>
|
|
) : (
|
|
<div className="flex items-end justify-between gap-4">
|
|
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
|
{value}
|
|
</div>
|
|
{enhancer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|