fix: improvements in the dashboard
This commit is contained in:
@@ -2,8 +2,6 @@ import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { IChartProps } from '@openpanel/validation';
|
||||
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { BarShapeBlue } from '../charts/common-bar';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
|
||||
interface OverviewLiveHistogramProps {
|
||||
projectId: string;
|
||||
@@ -27,72 +26,17 @@ interface OverviewLiveHistogramProps {
|
||||
export function OverviewLiveHistogram({
|
||||
projectId,
|
||||
}: OverviewLiveHistogramProps) {
|
||||
const report: IChartProps = {
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: ['screen_view', 'session_start'],
|
||||
},
|
||||
],
|
||||
id: 'A',
|
||||
name: '*',
|
||||
displayName: 'Active users',
|
||||
},
|
||||
],
|
||||
chartType: 'histogram',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
name: '',
|
||||
metric: 'sum',
|
||||
breakdowns: [],
|
||||
lineType: 'monotone',
|
||||
previous: false,
|
||||
};
|
||||
const countReport: IChartProps = {
|
||||
name: '',
|
||||
projectId,
|
||||
events: [
|
||||
{
|
||||
segment: 'user',
|
||||
filters: [],
|
||||
id: 'A',
|
||||
name: 'session_start',
|
||||
},
|
||||
],
|
||||
breakdowns: [],
|
||||
chartType: 'metric',
|
||||
lineType: 'monotone',
|
||||
interval: 'minute',
|
||||
range: '30min',
|
||||
previous: false,
|
||||
metric: 'sum',
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
|
||||
const res = useQuery(trpc.chart.chart.queryOptions(report));
|
||||
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
|
||||
// Use the new liveData endpoint instead of chart props
|
||||
const { data: liveData, isLoading } = useQuery(
|
||||
trpc.overview.liveData.queryOptions({ projectId }),
|
||||
);
|
||||
|
||||
const metrics = res.data?.series[0]?.metrics;
|
||||
const minutes = (res.data?.series[0]?.data || []).slice(-30);
|
||||
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
|
||||
const totalSessions = liveData?.totalSessions ?? 0;
|
||||
const chartData = liveData?.minuteCounts ?? [];
|
||||
|
||||
// Transform data for Recharts
|
||||
const chartData = minutes.map((minute) => ({
|
||||
...minute,
|
||||
timestamp: new Date(minute.date).getTime(),
|
||||
time: new Date(minute.date).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
}));
|
||||
|
||||
if (res.isInitialLoading || countRes.isInitialLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper count={0}>
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
@@ -100,12 +44,30 @@ export function OverviewLiveHistogram({
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.isSuccess && !countRes.isSuccess) {
|
||||
if (!liveData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxDomain =
|
||||
Math.max(...chartData.map((item) => item.sessionCount)) * 1.2;
|
||||
|
||||
return (
|
||||
<Wrapper count={liveCount}>
|
||||
<Wrapper
|
||||
count={totalSessions}
|
||||
icons={
|
||||
<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>
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
@@ -119,9 +81,9 @@ export function OverviewLiveHistogram({
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
dataKey="sessionCount"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
@@ -137,13 +99,17 @@ export function OverviewLiveHistogram({
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icons?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} unique vistors last 30 minutes
|
||||
<div className="row gap-2 justify-between">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} sessions last 30 minutes
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
|
||||
{children}
|
||||
@@ -182,8 +148,8 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
const data = payload[0].payload;
|
||||
|
||||
// Smart positioning to avoid going out of bounds
|
||||
const tooltipWidth = 180; // min-w-[180px]
|
||||
const tooltipHeight = 80; // approximate height
|
||||
const tooltipWidth = 220; // min-w-[220px] to accommodate referrers
|
||||
const tooltipHeight = 120; // approximate height with referrers
|
||||
const offset = 10;
|
||||
|
||||
let left = mousePosition.x + offset;
|
||||
@@ -211,7 +177,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
|
||||
const tooltipContent = (
|
||||
<div
|
||||
className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
className="flex min-w-[220px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
@@ -221,12 +187,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>
|
||||
{new Date(data.date).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div>{data.time}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
@@ -235,14 +196,43 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count)}
|
||||
{number.formatWithUnit(data.sessionCount)}
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||
import { cn } from '@/utils/cn';
|
||||
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
@@ -18,11 +19,14 @@ import {
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Customized,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||
@@ -80,6 +84,11 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
const [filters] = useEventQueryFilters();
|
||||
const trpc = useTRPC();
|
||||
|
||||
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
|
||||
'chartType',
|
||||
'bars',
|
||||
);
|
||||
|
||||
const activeMetric = TITLES[metric]!;
|
||||
const overviewQuery = useQuery(
|
||||
trpc.overview.stats.queryOptions({
|
||||
@@ -132,8 +141,36 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="text-center mb-3 -mt-1 text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
<div className="flex items-center justify-between mb-3 -mt-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('bars')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'bars'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('lines')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'lines'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Lines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
@@ -141,6 +178,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
chartType={chartType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,15 +243,142 @@ function Chart({
|
||||
activeMetric,
|
||||
interval,
|
||||
data,
|
||||
chartType,
|
||||
}: {
|
||||
activeMetric: (typeof TITLES)[number];
|
||||
interval: IInterval;
|
||||
data: RouterOutputs['overview']['stats']['series'];
|
||||
chartType: 'bars' | 'lines';
|
||||
}) {
|
||||
const xAxisProps = useXAxisProps({ interval });
|
||||
const yAxisProps = useYAxisProps();
|
||||
const [activeBar, setActiveBar] = useState(-1);
|
||||
|
||||
// Line chart specific logic
|
||||
let dotIndex = undefined;
|
||||
if (chartType === 'lines') {
|
||||
if (interval === 'hour') {
|
||||
// Find closest index based on times
|
||||
dotIndex = data.findIndex((item) => {
|
||||
return isSameHour(item.date, new Date());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
|
||||
useDashedStroke({
|
||||
dotIndex,
|
||||
});
|
||||
|
||||
const lastSerieDataItem = last(data)?.date || new Date();
|
||||
const useDashedLastLine = (() => {
|
||||
if (interval === 'hour') {
|
||||
return isSameHour(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'day') {
|
||||
return isSameDay(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'month') {
|
||||
return isSameMonth(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
if (interval === 'week') {
|
||||
return isSameWeek(lastSerieDataItem, new Date());
|
||||
}
|
||||
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (chartType === 'lines') {
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 1.5,
|
||||
r: 2,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 90
|
||||
? false
|
||||
: {
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 1.5,
|
||||
r: 3,
|
||||
}
|
||||
}
|
||||
activeDot={{
|
||||
stroke: getChartColor(0),
|
||||
fill: 'var(--def-100)',
|
||||
strokeWidth: 2,
|
||||
r: 4,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar chart (default)
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -42,7 +42,11 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
|
||||
<Button
|
||||
icon={data?.public ? Globe2Icon : LockIcon}
|
||||
responsive
|
||||
loading={query.isLoading}
|
||||
>
|
||||
{data?.public ? 'Public' : 'Private'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useQueryState,
|
||||
} from 'nuqs';
|
||||
|
||||
import { getStorageItem, setStorageItem } from '@/utils/storage';
|
||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||
import {
|
||||
getDefaultIntervalByDates,
|
||||
getDefaultIntervalByRange,
|
||||
@@ -27,10 +27,14 @@ export function useOverviewOptions() {
|
||||
'end',
|
||||
parseAsString.withOptions(nuqsOptions),
|
||||
);
|
||||
const [cookieRange, setCookieRange] = useCookieStore<IChartRange>(
|
||||
'range',
|
||||
'7d',
|
||||
);
|
||||
const [range, setRange] = useQueryState(
|
||||
'range',
|
||||
parseAsStringEnum(mapKeys(timeWindows))
|
||||
.withDefault(getStorageItem('range', '7d'))
|
||||
.withDefault(cookieRange)
|
||||
.withOptions({
|
||||
...nuqsOptions,
|
||||
clearOnDefault: false,
|
||||
@@ -69,7 +73,9 @@ export function useOverviewOptions() {
|
||||
if (value !== 'custom') {
|
||||
setStartDate(null);
|
||||
setEndDate(null);
|
||||
setStorageItem('range', value);
|
||||
if (value) {
|
||||
setCookieRange(value);
|
||||
}
|
||||
setInterval(null);
|
||||
}
|
||||
setRange(value);
|
||||
|
||||
Reference in New Issue
Block a user