fix: improvements in the dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-17 18:15:23 +02:00
parent c8bea685db
commit 077a47a263
29 changed files with 1133 additions and 526 deletions

View File

@@ -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>
);

View File

@@ -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%">

View File

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

View File

@@ -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);