* wip * wip * wip * wip * show revenue better on overview * align realtime and overview counters * update revenue docs * always return device id * add project settings, improve projects charts, * fix: comments * fixes * fix migration * ignore sql files * fix comments
245 lines
6.9 KiB
TypeScript
245 lines
6.9 KiB
TypeScript
import { useTRPC } from '@/integrations/trpc/react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
import { getChartColor } from '@/utils/theme';
|
|
import * as Portal from '@radix-ui/react-portal';
|
|
import { bind } from 'bind-event-listener';
|
|
import throttle from 'lodash.throttle';
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
Bar,
|
|
BarChart,
|
|
ResponsiveContainer,
|
|
Tooltip,
|
|
XAxis,
|
|
YAxis,
|
|
} from 'recharts';
|
|
import { AnimatedNumber } from '../animated-number';
|
|
import { BarShapeBlue } from '../charts/common-bar';
|
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
|
|
|
interface RealtimeLiveHistogramProps {
|
|
projectId: string;
|
|
}
|
|
|
|
export function RealtimeLiveHistogram({
|
|
projectId,
|
|
}: RealtimeLiveHistogramProps) {
|
|
const trpc = useTRPC();
|
|
|
|
// Use the same liveData endpoint as overview
|
|
const { data: liveData, isLoading } = useQuery(
|
|
trpc.overview.liveData.queryOptions({ projectId }),
|
|
);
|
|
|
|
const chartData = liveData?.minuteCounts ?? [];
|
|
// Calculate total unique visitors (sum of unique visitors per minute)
|
|
// Note: This is an approximation - ideally we'd want unique visitors across all minutes
|
|
const totalVisitors = liveData?.totalSessions ?? 0;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Wrapper count={0}>
|
|
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
|
</Wrapper>
|
|
);
|
|
}
|
|
|
|
if (!liveData) {
|
|
return null;
|
|
}
|
|
|
|
const maxDomain =
|
|
Math.max(...chartData.map((item) => item.visitorCount), 0) * 1.2 || 1;
|
|
|
|
return (
|
|
<Wrapper
|
|
count={totalVisitors}
|
|
icons={
|
|
liveData.referrers && liveData.referrers.length > 0 ? (
|
|
<div className="row gap-2">
|
|
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
|
<div
|
|
key={`${ref.referrer}-${ref.count}-${index}`}
|
|
className="font-bold text-xs row gap-1 items-center"
|
|
>
|
|
<SerieIcon name={ref.referrer} />
|
|
<span>{ref.count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null
|
|
}
|
|
>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
data={chartData}
|
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
|
>
|
|
<Tooltip
|
|
content={CustomTooltip}
|
|
cursor={{
|
|
fill: 'var(--def-200)',
|
|
}}
|
|
/>
|
|
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
|
<YAxis hide domain={[0, maxDomain]} />
|
|
<Bar
|
|
dataKey="visitorCount"
|
|
fill="rgba(59, 121, 255, 0.2)"
|
|
isAnimationActive={false}
|
|
shape={BarShapeBlue}
|
|
activeBar={BarShapeBlue}
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</Wrapper>
|
|
);
|
|
}
|
|
|
|
interface WrapperProps {
|
|
children: React.ReactNode;
|
|
count: number;
|
|
icons?: React.ReactNode;
|
|
}
|
|
|
|
function Wrapper({ children, count, icons }: WrapperProps) {
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="row gap-2 justify-between mb-2">
|
|
<div className="relative text-sm font-medium text-muted-foreground leading-normal">
|
|
Unique visitors {icons ? <br /> : null}
|
|
last 30 min
|
|
</div>
|
|
<div>{icons}</div>
|
|
</div>
|
|
<div className="col gap-2 mb-4">
|
|
<div className="font-mono text-6xl font-bold">
|
|
<AnimatedNumber value={count} />
|
|
</div>
|
|
</div>
|
|
<div className="relative aspect-[6/1] w-full">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Custom tooltip component that uses portals to escape overflow hidden
|
|
const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
|
const number = useNumber();
|
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(
|
|
null,
|
|
);
|
|
|
|
const inactive = !active || !payload?.length;
|
|
useEffect(() => {
|
|
const setPositionThrottled = throttle(setPosition, 50);
|
|
const unsubMouseMove = bind(window, {
|
|
type: 'mousemove',
|
|
listener(event) {
|
|
if (!inactive) {
|
|
setPositionThrottled({ x: event.clientX, y: event.clientY + 20 });
|
|
}
|
|
},
|
|
});
|
|
const unsubDragEnter = bind(window, {
|
|
type: 'pointerdown',
|
|
listener() {
|
|
setPosition(null);
|
|
},
|
|
});
|
|
|
|
return () => {
|
|
unsubMouseMove();
|
|
unsubDragEnter();
|
|
};
|
|
}, [inactive]);
|
|
|
|
if (inactive) {
|
|
return null;
|
|
}
|
|
|
|
if (!active || !payload || !payload.length) {
|
|
return null;
|
|
}
|
|
|
|
const data = payload[0].payload;
|
|
|
|
const tooltipWidth = 200;
|
|
const correctXPosition = (x: number | undefined) => {
|
|
if (!x) {
|
|
return undefined;
|
|
}
|
|
|
|
const screenWidth = window.innerWidth;
|
|
const newX = x;
|
|
|
|
if (newX + tooltipWidth > screenWidth) {
|
|
return screenWidth - tooltipWidth;
|
|
}
|
|
return newX;
|
|
};
|
|
|
|
return (
|
|
<Portal.Portal
|
|
style={{
|
|
position: 'fixed',
|
|
top: position?.y,
|
|
left: correctXPosition(position?.x),
|
|
zIndex: 1000,
|
|
width: tooltipWidth,
|
|
}}
|
|
className="bg-background/80 p-3 rounded-md border shadow-xl backdrop-blur-sm"
|
|
>
|
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
|
<div>{data.time}</div>
|
|
</div>
|
|
<React.Fragment>
|
|
<div className="flex gap-2">
|
|
<div
|
|
className="w-[3px] rounded-full"
|
|
style={{ background: getChartColor(0) }}
|
|
/>
|
|
<div className="col flex-1 gap-1">
|
|
<div className="flex items-center gap-1">Active users</div>
|
|
<div className="flex justify-between gap-8 font-mono font-medium">
|
|
<div className="row gap-1">
|
|
{number.formatWithUnit(data.visitorCount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{data.referrers && data.referrers.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-border">
|
|
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
|
<div className="space-y-1">
|
|
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
|
<div
|
|
key={`${ref.referrer}-${ref.count}-${index}`}
|
|
className="row items-center justify-between text-xs"
|
|
>
|
|
<div className="row items-center gap-1">
|
|
<SerieIcon name={ref.referrer} />
|
|
<span
|
|
className="truncate max-w-[120px]"
|
|
title={ref.referrer}
|
|
>
|
|
{ref.referrer}
|
|
</span>
|
|
</div>
|
|
<span className="font-mono">{ref.count}</span>
|
|
</div>
|
|
))}
|
|
{data.referrers.length > 3 && (
|
|
<div className="text-xs text-muted-foreground">
|
|
+{data.referrers.length - 3} more
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
</Portal.Portal>
|
|
);
|
|
};
|