fix: dashboard improvements and query speed improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-09 14:42:11 +01:00
parent 4867260ece
commit cabfb1f3f0
49 changed files with 3398 additions and 950 deletions

View File

@@ -4,160 +4,322 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { SearchIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { OverviewWidgetTable } from '../../overview/overview-widget-table';
import { DeltaChip } from '@/components/delta-chip';
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context';
type SortOption =
| 'count-desc'
| 'count-asc'
| 'name-asc'
| 'name-desc'
| 'percent-desc'
| 'percent-asc';
interface Props {
data: IChartData;
}
export function Chart({ data }: Props) {
const [isOpen, setOpen] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [sortBy, setSortBy] = useState<SortOption>('count-desc');
const {
isEditMode,
report: { metric, limit, previous },
options: { onClick, dropdownMenuContent, columns },
options: { onClick, dropdownMenuContent },
} = useReportChartContext();
const number = useNumber();
const series = useMemo(
() => (isEditMode ? data.series : data.series.slice(0, limit || 10)),
[data, isEditMode, limit],
);
const maxCount = Math.max(
...series.map((serie) => serie.metrics[metric] ?? 0),
);
const tableColumns = [
{
name: columns?.[0] || 'Name',
width: 'w-full',
render: (serie: (typeof series)[0]) => {
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
// Use useVisibleSeries to add index property for colors
const { series: allSeriesWithIndex } = useVisibleSeries(data, 500);
return (
<DropdownMenu
onOpenChange={() =>
setOpen((p) => (p === serie.id ? null : serie.id))
}
open={isOpen === serie.id}
>
<DropdownMenuTrigger
asChild
disabled={!isDropDownEnabled}
{...(isDropDownEnabled
? {
onPointerDown: (e) => e.preventDefault(),
onClick: () => setOpen(serie.id),
}
: {})}
>
<div
className={cn(
'flex items-center gap-2 break-all font-medium',
(isClickable || isDropDownEnabled) && 'cursor-pointer',
)}
{...(isClickable && !isDropDownEnabled
? {
onClick: () => onClick(serie),
}
: {})}
>
<SerieIcon name={serie.names[0]} />
<SerieName name={serie.names} />
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem key={item.title} onClick={item.onClick}>
{item.icon && <item.icon size={16} className="mr-2" />}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
},
},
// Percentage column
{
name: '%',
width: '70px',
render: (serie: (typeof series)[0]) => (
<div className="text-muted-foreground font-mono">
{number.format(
round((serie.metrics.sum / data.metrics.sum) * 100, 2),
)}
%
</div>
),
},
const totalSum = data.metrics.sum || 1;
// Previous value column
{
name: 'Previous',
width: '130px',
render: (serie: (typeof series)[0]) => (
<div className="flex items-center gap-2 font-mono justify-end">
<div className="font-bold">
{number.format(serie.metrics.previous?.[metric]?.value)}
</div>
<PreviousDiffIndicator
{...serie.metrics.previous?.[metric]}
size="xs"
className="text-muted-foreground"
/>
</div>
),
},
// Calculate original ranks (based on count descending - default sort)
const seriesWithOriginalRank = useMemo(() => {
const sortedByCount = [...allSeriesWithIndex].sort(
(a, b) => b.metrics.sum - a.metrics.sum,
);
const rankMap = new Map<string, number>();
sortedByCount.forEach((serie, idx) => {
rankMap.set(serie.id, idx + 1);
});
return allSeriesWithIndex.map((serie) => ({
...serie,
originalRank: rankMap.get(serie.id) ?? 0,
}));
}, [allSeriesWithIndex]);
// Main count column (always last)
{
name: 'Count',
width: '80px',
render: (serie: (typeof series)[0]) => (
<div className="font-bold font-mono">
{number.format(serie.metrics.sum)}
</div>
),
},
];
// Filter and sort series
const series = useMemo(() => {
let filtered = seriesWithOriginalRank;
// Filter by search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((serie) =>
serie.names.some((name) => name.toLowerCase().includes(query)),
);
}
// Sort
const sorted = [...filtered].sort((a, b) => {
switch (sortBy) {
case 'count-desc':
return b.metrics.sum - a.metrics.sum;
case 'count-asc':
return a.metrics.sum - b.metrics.sum;
case 'name-asc':
return a.names.join(' > ').localeCompare(b.names.join(' > '));
case 'name-desc':
return b.names.join(' > ').localeCompare(a.names.join(' > '));
case 'percent-desc':
return b.metrics.sum / totalSum - a.metrics.sum / totalSum;
case 'percent-asc':
return a.metrics.sum / totalSum - b.metrics.sum / totalSum;
default:
return 0;
}
});
// Apply limit if not in edit mode
return isEditMode ? sorted : sorted.slice(0, limit || 10);
}, [
seriesWithOriginalRank,
searchQuery,
sortBy,
totalSum,
isEditMode,
limit,
]);
return (
<div
className={cn(
'text-sm',
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3',
<div className={cn('w-full', isEditMode && 'card')}>
{isEditMode && (
<div className="flex items-center gap-3 p-4 border-b border-def-200 dark:border-def-800">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
<Input
type="text"
placeholder="Filter by name"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
size="sm"
/>
</div>
<Select
value={sortBy}
onValueChange={(value) => setSortBy(value as SortOption)}
>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="count-desc">Count (High Low)</SelectItem>
<SelectItem value="count-asc">Count (Low High)</SelectItem>
<SelectItem value="name-asc">Name (A Z)</SelectItem>
<SelectItem value="name-desc">Name (Z A)</SelectItem>
<SelectItem value="percent-desc">
Percentage (High Low)
</SelectItem>
<SelectItem value="percent-asc">
Percentage (Low High)
</SelectItem>
</SelectContent>
</Select>
</div>
)}
>
<OverviewWidgetTable
data={series}
keyExtractor={(serie) => serie.id}
columns={tableColumns.filter((column) => {
if (!previous && column.name === 'Previous') {
return false;
}
return true;
})}
getColumnPercentage={(serie) => serie.metrics.sum / maxCount}
className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')}
/>
<div className="overflow-hidden">
<div className="divide-y divide-def-200 dark:divide-def-800">
{series.map((serie, idx) => {
const isClickable =
!serie.names.includes(NOT_SET_VALUE) && !!onClick;
const isDropDownEnabled =
!serie.names.includes(NOT_SET_VALUE) &&
(dropdownMenuContent?.(serie) || []).length > 0;
const color = getChartColor(serie.index);
const percentOfTotal = round(
(serie.metrics.sum / totalSum) * 100,
1,
);
return (
<div
key={serie.id}
className={cn(
'group relative px-4 py-3 transition-colors overflow-hidden',
isClickable && 'cursor-pointer',
)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={() => {
if (isClickable && !isDropDownEnabled) {
onClick?.(serie);
}
}}
onKeyDown={(e) => {
if (!isClickable || isDropDownEnabled) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick?.(serie);
}
}}
>
{/* Subtle accent glow */}
<div
className="pointer-events-none absolute -left-10 -top-10 h-40 w-96 rounded-full opacity-0 blur-3xl transition-opacity duration-500 group-hover:opacity-10"
style={{
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
}}
/>
<div className="relative z-10 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-50 dark:border-def-800 dark:bg-def-900"
style={{ borderColor: `${color}22` }}
>
<SerieIcon name={serie.names[0]} />
</div>
<div className="min-w-0">
<div className="mb-1 flex items-center gap-2">
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Rank {serie.originalRank}
</span>
</div>
<DropdownMenu
onOpenChange={() =>
setOpen((p) => (p === serie.id ? null : serie.id))
}
open={isOpen === serie.id}
>
<DropdownMenuTrigger
asChild
disabled={!isDropDownEnabled}
{...(isDropDownEnabled
? {
onPointerDown: (e) => e.preventDefault(),
onClick: (e) => {
e.stopPropagation();
setOpen(serie.id);
},
}
: {})}
>
<div
className={cn(
'min-w-0',
isDropDownEnabled && 'cursor-pointer',
)}
{...(isClickable && !isDropDownEnabled
? {
onClick: (e) => {
e.stopPropagation();
onClick?.(serie);
},
}
: {})}
>
<SerieName
name={serie.names}
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold tracking-tight"
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
{dropdownMenuContent?.(serie).map((item) => (
<DropdownMenuItem
key={item.title}
onClick={(e) => {
e.stopPropagation();
item.onClick();
}}
>
{item.icon && (
<item.icon size={16} className="mr-2" />
)}
{item.title}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="flex items-center gap-2">
<div className="text-base font-semibold font-mono tracking-tight">
{number.format(serie.metrics.sum)}
</div>
{previous && serie.metrics.previous?.[metric] && (
<DeltaChip
variant={
serie.metrics.previous[metric].state ===
'positive'
? 'inc'
: 'dec'
}
size="sm"
>
{serie.metrics.previous[metric].diff?.toFixed(1)}%
</DeltaChip>
)}
</div>
</div>
</div>
{/* Bar */}
<div className="flex items-center">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
<div
className="h-full rounded-full transition-[width] duration-700 ease-out"
style={{
width: `${percentOfTotal}%`,
background: `linear-gradient(90deg, ${color}aa, ${color})`,
}}
/>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,7 +13,7 @@ export function ReportBarChart() {
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
@@ -26,7 +27,6 @@ export function ReportBarChart() {
) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
@@ -39,22 +39,62 @@ export function ReportBarChart() {
}
function Loading() {
const { isEditMode } = useReportChartContext();
return (
<AspectContainer className="col gap-4 overflow-hidden">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index as number}
className="row animate-pulse justify-between"
>
<div className="h-4 w-2/5 rounded bg-def-200" />
<div className="row w-1/5 gap-2">
<div className="h-4 w-full rounded bg-def-200" />
<div className="h-4 w-full rounded bg-def-200" />
<div className="h-4 w-full rounded bg-def-200" />
</div>
<div className={cn('w-full', isEditMode && 'card')}>
<div className="overflow-hidden">
<div className="divide-y divide-def-200 dark:divide-def-800">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index as number}
className="relative px-4 py-3 animate-pulse"
>
<div className="relative z-10 flex flex-col gap-2">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
{/* Icon skeleton */}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border bg-def-100 dark:border-def-800 dark:bg-def-900" />
<div className="min-w-0">
{/* Rank badge skeleton */}
<div className="mb-1 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-def-200 dark:bg-def-700" />
<div className="h-2 w-12 rounded bg-def-200 dark:bg-def-700" />
</div>
{/* Name skeleton */}
<div
className="h-4 rounded bg-def-200 dark:bg-def-700"
style={{
width: `${Math.random() * 100 + 100}px`,
}}
/>
</div>
</div>
{/* Count skeleton */}
<div className="flex shrink-0 flex-col items-end gap-1">
<div className="h-5 w-16 rounded bg-def-200 dark:bg-def-700" />
</div>
</div>
{/* Bar skeleton */}
<div className="flex items-center">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-def-100 dark:bg-def-900">
<div
className="h-full rounded-full bg-def-200 dark:bg-def-700"
style={{
width: `${Math.random() * 60 + 20}%`,
}}
/>
</div>
</div>
</div>
</div>
))}
</div>
))}
</AspectContainer>
</div>
</div>
);
}

View File

@@ -32,7 +32,7 @@ export function ReportChartEmpty({
</div>
<ForkliftIcon
strokeWidth={1.2}
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
className="mb-4 size-1/3 max-w-40 animate-pulse text-muted-foreground"
/>
<div className="font-medium text-muted-foreground">
Ready when you're

View File

@@ -2,6 +2,7 @@ import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
import { DeltaChip } from '@/components/delta-chip';
import { useReportChartContext } from '../context';
export function getDiffIndicator<A, B, C>(
@@ -29,7 +30,7 @@ interface PreviousDiffIndicatorProps {
children?: React.ReactNode;
inverted?: boolean;
className?: string;
size?: 'sm' | 'lg' | 'md' | 'xs';
size?: 'sm' | 'lg' | 'md';
}
export function PreviousDiffIndicator({
@@ -81,7 +82,6 @@ export function PreviousDiffIndicator({
variant,
size === 'lg' && 'size-8',
size === 'md' && 'size-6',
size === 'xs' && 'size-3',
)}
>
{renderIcon()}
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
diff?: number | null | undefined;
state?: string | null | undefined;
inverted?: boolean;
size?: 'sm' | 'lg' | 'md' | 'xs';
size?: 'sm' | 'lg' | 'md';
className?: string;
showPrevious?: boolean;
}
@@ -133,25 +133,35 @@ export function PreviousDiffIndicatorPure({
};
return (
<div
className={cn(
'flex items-center gap-1 font-mono font-medium',
size === 'lg' && 'gap-2',
className,
)}
<DeltaChip
variant={state === 'positive' ? 'inc' : 'dec'}
size={size}
inverted={inverted}
>
<div
className={cn(
'flex size-2.5 items-center justify-center rounded-full',
variant,
size === 'lg' && 'size-8',
size === 'md' && 'size-6',
size === 'xs' && 'size-3',
)}
>
{renderIcon()}
</div>
{diff.toFixed(1)}%
</div>
</DeltaChip>
);
// return (
// <div
// className={cn(
// 'flex items-center gap-1 font-mono font-medium',
// size === 'lg' && 'gap-2',
// className,
// )}
// >
// <div
// className={cn(
// 'flex size-2.5 items-center justify-center rounded-full',
// variant,
// size === 'lg' && 'size-8',
// size === 'md' && 'size-6',
// size === 'xs' && 'size-3',
// )}
// >
// {renderIcon()}
// </div>
// {diff.toFixed(1)}%
// </div>
// );
}

View File

@@ -30,6 +30,7 @@ const data = {
whale: 'https://whale.naver.com',
wechat: 'https://wechat.com',
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'mobile chrome': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg',
@@ -39,6 +40,7 @@ const data = {
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
facebook: 'https://facebook.com',
firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
'mobile firefox': 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png',
github: 'https://github.com',
gmail: 'https://mail.google.com',
google: 'https://google.com',

View File

@@ -1,6 +1,9 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
@@ -47,28 +50,18 @@ export function Loading() {
);
}
export function Error() {
function Error() {
return (
<div className="relative h-[70px]">
<div className="opacity-50">
<Loading />
</div>
<div className="center-center absolute inset-0 text-muted-foreground">
<div className="text-sm font-medium">Error fetching data</div>
</div>
</div>
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
export function Empty() {
function Empty() {
return (
<div className="relative h-[70px]">
<div className="opacity-50">
<Loading />
</div>
<div className="center-center absolute inset-0 text-muted-foreground">
<div className="text-sm font-medium">No data</div>
</div>
</div>
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -13,7 +13,7 @@ export function ReportPieChart() {
const trpc = useTRPC();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,