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

@@ -118,7 +118,11 @@ async function fetchImage(
// Check if URL is an ICO file
function isIcoFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
return (
url.toLowerCase().endsWith('.ico') ||
contentType === 'image/x-icon' ||
contentType === 'image/vnd.microsoft.icon'
);
}
function isSvgFile(url: string, contentType?: string): boolean {
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
@@ -239,7 +243,9 @@ export async function getFavicon(
try {
const url = validateUrl(request.query.url);
if (!url) {
return createFallbackImage();
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
}
const cacheKey = createCacheKey(url.toString());
@@ -260,21 +266,65 @@ export async function getFavicon(
} else {
// For website URLs, extract favicon from HTML
const meta = await parseUrlMeta(url.toString());
logger.info('parseUrlMeta result', {
url: url.toString(),
favicon: meta?.favicon,
});
if (meta?.favicon) {
imageUrl = new URL(meta.favicon);
} else {
// Fallback to Google's favicon service
const { hostname } = url;
imageUrl = new URL(
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
);
// Try standard favicon location first
const { origin } = url;
imageUrl = new URL(`${origin}/favicon.ico`);
}
}
// Fetch the image
const { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Fetching favicon', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
});
if (status !== 200 || buffer.length === 0) {
// Fetch the image
let { buffer, contentType, status } = await fetchImage(imageUrl);
logger.info('Favicon fetch result', {
originalUrl: url.toString(),
imageUrl: imageUrl.toString(),
status,
bufferLength: buffer.length,
contentType,
});
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
// try DuckDuckGo's favicon service as a fallback
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
const { hostname } = url;
const duckduckgoUrl = new URL(
`https://icons.duckduckgo.com/ip3/${hostname}.ico`,
);
logger.info('Trying DuckDuckGo favicon service', {
originalUrl: url.toString(),
duckduckgoUrl: duckduckgoUrl.toString(),
});
const duckduckgoResult = await fetchImage(duckduckgoUrl);
buffer = duckduckgoResult.buffer;
contentType = duckduckgoResult.contentType;
status = duckduckgoResult.status;
imageUrl = duckduckgoUrl;
logger.info('DuckDuckGo favicon result', {
status,
bufferLength: buffer.length,
contentType,
});
}
// Accept any response as long as we have valid image data
if (buffer.length === 0) {
reply.header('Content-Type', 'image/png');
reply.header('Cache-Control', 'public, max-age=3600');
return reply.send(createFallbackImage());
}
@@ -285,9 +335,31 @@ export async function getFavicon(
contentType,
);
logger.info('Favicon processing result', {
originalUrl: url.toString(),
originalBufferLength: buffer.length,
processedBufferLength: processedBuffer.length,
});
// Determine the correct content type for caching and response
const isIco = isIcoFile(imageUrl.toString(), contentType);
const responseContentType = isIco ? 'image/x-icon' : contentType;
const isSvg = isSvgFile(imageUrl.toString(), contentType);
let responseContentType = contentType;
if (isIco) {
responseContentType = 'image/x-icon';
} else if (isSvg) {
responseContentType = 'image/svg+xml';
} else if (
processedBuffer.length < 5000 &&
buffer.length === processedBuffer.length
) {
// Image was returned as-is, keep original content type
responseContentType = contentType;
} else {
// Image was processed by Sharp, it's now a PNG
responseContentType = 'image/png';
}
// Cache the result with correct content type
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);

View File

@@ -1,7 +1,13 @@
import urlMetadata from 'url-metadata';
function fallbackFavicon(url: string) {
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
try {
const hostname = new URL(url).hostname;
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
} catch {
// If URL parsing fails, use the original string
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
}
}
function findBestFavicon(favicons: UrlMetaData['favicons']) {

View File

@@ -0,0 +1,65 @@
import { cn } from '@/utils/cn';
import { type VariantProps, cva } from 'class-variance-authority';
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
const deltaChipVariants = cva(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
{
variants: {
variant: {
inc: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
dec: 'bg-red-500/10 text-red-600 dark:text-red-400',
default: 'bg-muted text-muted-foreground',
},
size: {
sm: 'text-xs',
md: 'text-sm',
lg: 'text-base',
},
},
defaultVariants: {
variant: 'default',
size: 'md',
},
},
);
type DeltaChipProps = VariantProps<typeof deltaChipVariants> & {
children: React.ReactNode;
inverted?: boolean;
};
const iconVariants: Record<NonNullable<DeltaChipProps['size']>, number> = {
sm: 12,
md: 16,
lg: 20,
};
const getVariant = (variant: DeltaChipProps['variant'], inverted?: boolean) => {
if (inverted) {
return variant === 'inc' ? 'dec' : variant === 'dec' ? 'inc' : variant;
}
return variant;
};
export function DeltaChip({
variant,
size,
inverted,
children,
}: DeltaChipProps) {
return (
<div
className={cn(
deltaChipVariants({ variant: getVariant(variant, inverted), size }),
)}
>
{variant === 'inc' ? (
<ArrowUpIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : variant === 'dec' ? (
<ArrowDownIcon size={iconVariants[size || 'md']} className="shrink-0" />
) : null}
<span>{children}</span>
</div>
);
}

View File

@@ -5,6 +5,7 @@ import type { InsightPayload } from '@openpanel/validation';
import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
import { last } from 'ramda';
import { useState } from 'react';
import { DeltaChip } from '../delta-chip';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Badge } from '../ui/badge';
@@ -188,42 +189,13 @@ export function InsightCard({
{/* Delta chip */}
<DeltaChip
isIncrease={isIncrease}
isDecrease={isDecrease}
deltaText={deltaText}
/>
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
size="sm"
>
{deltaText}
</DeltaChip>
</div>
</div>
</div>
);
}
function DeltaChip({
isIncrease,
isDecrease,
deltaText,
}: {
isIncrease: boolean;
isDecrease: boolean;
deltaText: string;
}) {
return (
<div
className={cn(
'flex items-center gap-1 rounded-full px-2 py-1 text-sm font-semibold',
isIncrease
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: isDecrease
? 'bg-red-500/10 text-red-600 dark:text-red-400'
: 'bg-muted text-muted-foreground',
)}
>
{isIncrease ? (
<ArrowUp size={16} className="shrink-0" />
) : isDecrease ? (
<ArrowDown size={16} className="shrink-0" />
) : null}
<span>{deltaText}</span>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { PromptCard } from '@/components/organization/prompt-card';
import { Button } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { op } from '@/utils/op';
import { MessageSquareIcon } from 'lucide-react';
import { useEffect, useMemo } from 'react';
const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30;
export default function FeedbackPrompt() {
const { isSelfHosted } = useAppContext();
const [feedbackPromptSeen, setFeedbackPromptSeen] = useCookieStore(
'feedback-prompt-seen',
'',
{ maxAge: THIRTY_DAYS_IN_SECONDS },
);
const shouldShow = useMemo(() => {
if (isSelfHosted) {
return false;
}
if (!feedbackPromptSeen) {
return true;
}
try {
const lastSeenDate = new Date(feedbackPromptSeen);
const now = new Date();
const daysSinceLastSeen =
(now.getTime() - lastSeenDate.getTime()) / (1000 * 60 * 60 * 24);
return daysSinceLastSeen >= 30;
} catch {
// If date parsing fails, show the prompt
return true;
}
}, [isSelfHosted, feedbackPromptSeen]);
const handleGiveFeedback = () => {
// Open userjot widget
if (typeof window !== 'undefined' && 'uj' in window) {
(window.uj as any).showWidget();
}
// Set cookie with current timestamp
setFeedbackPromptSeen(new Date().toISOString());
op.track('feedback_prompt_button_clicked');
};
const handleClose = () => {
// Set cookie with current timestamp when closed
setFeedbackPromptSeen(new Date().toISOString());
};
useEffect(() => {
if (shouldShow) {
op.track('feedback_prompt_viewed');
}
}, [shouldShow]);
return (
<PromptCard
title="Share Your Feedback"
subtitle="Help us improve OpenPanel with your insights"
onClose={handleClose}
show={shouldShow}
gradientColor="rgb(59 130 246)"
>
<div className="px-6 col gap-4">
<p className="text-sm text-foreground leading-normal">
Your feedback helps us build features you actually need. Share your
thoughts, report bugs, or suggest improvements
</p>
<Button className="self-start" onClick={handleGiveFeedback}>
Give Feedback
</Button>
</div>
</PromptCard>
);
}

View File

@@ -0,0 +1,66 @@
import { Button } from '@/components/ui/button';
import { AnimatePresence, motion } from 'framer-motion';
import { XIcon } from 'lucide-react';
interface PromptCardProps {
title: string;
subtitle: string;
onClose: () => void;
children: React.ReactNode;
gradientColor?: string;
show: boolean;
}
export function PromptCard({
title,
subtitle,
onClose,
children,
gradientColor = 'rgb(16 185 129)',
show,
}: PromptCardProps) {
return (
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
>
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_rgba(20,20,20,1)] col gap-6 py-6 overflow-hidden">
<div className="relative px-6 col gap-1">
<div
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"
style={{
background: `radial-gradient(circle, ${gradientColor} 0%, transparent 70%)`,
}}
/>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold max-w-[200px] leading-snug">
{title}
</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">{subtitle}</p>
</div>
{children}
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,7 +1,7 @@
import { Button, LinkButton } from '@/components/ui/button';
import { PromptCard } from '@/components/organization/prompt-card';
import { LinkButton } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { AnimatePresence, motion } from 'framer-motion';
import {
AwardIcon,
HeartIcon,
@@ -9,7 +9,6 @@ import {
MessageCircleIcon,
RocketIcon,
SparklesIcon,
XIcon,
ZapIcon,
} from 'lucide-react';
@@ -78,70 +77,43 @@ export default function SupporterPrompt() {
}
return (
<AnimatePresence>
{!supporterPromptClosed && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
<PromptCard
title="Support OpenPanel"
subtitle="Help us build the future of open analytics"
onClose={() => setSupporterPromptClosed(true)}
show={!supporterPromptClosed}
gradientColor="rgb(16 185 129)"
>
<div className="col gap-3 px-6">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="px-6">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
<div>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setSupporterPromptClosed(true)}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Help us build the future of open analytics
</p>
</div>
<div className="col gap-3">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="pt-2">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</PromptCard>
);
}

View File

@@ -0,0 +1,153 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import React from 'react';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import type { IInterval } from '@openpanel/validation';
import { SerieIcon } from '../report-chart/common/serie-icon';
type Data = {
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
};
type Context = {
interval: IInterval;
};
export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
({ context: { interval }, data }) => {
const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber();
if (!data || data.length === 0) {
return null;
}
const firstItem = data[0];
// Get all payload items from the first data point
// Keys are in format "prefix:name:payload" or "name:payload"
const payloadItems = Object.keys(firstItem)
.filter((key) => key.endsWith(':payload'))
.map((key) => {
const payload = firstItem[key as keyof typeof firstItem] as {
name: string;
prefix?: string;
color: string;
};
// Extract the base key (without :payload) to access sessions/pageviews/revenue
const baseKey = key.replace(':payload', '');
return {
payload,
baseKey,
};
})
.filter(
(item) =>
item.payload &&
typeof item.payload === 'object' &&
'name' in item.payload,
);
// Sort by sessions (descending)
const sorted = payloadItems.sort((a, b) => {
const aSessions =
(firstItem[
`${a.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const bSessions =
(firstItem[
`${b.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
return bSessions - aSessions;
});
const limit = 3;
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<>
{visible.map((item, index) => {
const sessions =
(firstItem[
`${item.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const pageviews =
(firstItem[
`${item.baseKey}:pageviews` as keyof typeof firstItem
] as number) ?? 0;
const revenue = firstItem[
`${item.baseKey}:revenue` as keyof typeof firstItem
] as number | undefined;
return (
<React.Fragment key={item.baseKey}>
{index === 0 && firstItem.date && (
<ChartTooltipHeader>
<div>{formatDate(new Date(firstItem.date))}</div>
</ChartTooltipHeader>
)}
<ChartTooltipItem color={item.payload.color}>
<div className="flex items-center gap-1">
<SerieIcon name={item.payload.prefix || item.payload.name} />
<div className="font-medium">
{item.payload.prefix && (
<>
<span className="text-muted-foreground">
{item.payload.prefix}
</span>
<span className="mx-1">/</span>
</>
)}
{item.payload.name || 'Not set'}
</div>
</div>
<div className="col gap-1 text-sm">
{revenue !== undefined && revenue > 0 && (
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Revenue</span>
<span style={{ color: '#3ba974' }}>
{number.currency(revenue / 100, { short: true })}
</span>
</div>
)}
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Pageviews</span>
<span>{number.short(pageviews)}</span>
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Sessions</span>
<span>{number.short(sessions)}</span>
</div>
</div>
</ChartTooltipItem>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground text-sm">
and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'}
</div>
)}
</>
);
},
);

View File

@@ -0,0 +1,303 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
type SeriesData =
RouterOutputs['overview']['topGenericSeries']['items'][number];
interface OverviewLineChartProps {
data: RouterOutputs['overview']['topGenericSeries'];
interval: IInterval;
searchQuery?: string;
className?: string;
}
function transformDataForRecharts(
items: SeriesData[],
searchQuery?: string,
): Array<{
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
}> {
// Filter items by search query
const filteredItems = searchQuery
? items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: items;
// Limit to top 15
const topItems = filteredItems.slice(0, 15);
// Get all unique dates from all items
const allDates = new Set<string>();
topItems.forEach((item) => {
item.data.forEach((d) => allDates.add(d.date));
});
const sortedDates = Array.from(allDates).sort();
// Transform to recharts format
return sortedDates.map((date) => {
const timestamp = new Date(date).getTime();
const result: Record<string, any> = {
date,
timestamp,
};
topItems.forEach((item, index) => {
const dataPoint = item.data.find((d) => d.date === date);
if (dataPoint) {
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix ? `${item.prefix}:${item.name}` : item.name;
result[`${key}:sessions`] = dataPoint.sessions;
result[`${key}:pageviews`] = dataPoint.pageviews;
if (dataPoint.revenue !== undefined) {
result[`${key}:revenue`] = dataPoint.revenue;
}
result[`${key}:payload`] = {
name: item.name,
prefix: item.prefix,
color: getChartColor(index),
};
}
});
return result as typeof result & {
date: string;
timestamp: number;
};
});
}
export function OverviewLineChart({
data,
interval,
searchQuery,
className,
}: OverviewLineChartProps) {
const number = useNumber();
const chartData = useMemo(
() => transformDataForRecharts(data.items, searchQuery),
[data.items, searchQuery],
);
const visibleItems = useMemo(() => {
const filtered = searchQuery
? data.items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: data.items;
return filtered.slice(0, 15);
}, [data.items, searchQuery]);
const xAxisProps = useXAxisProps({ interval, hide: false });
const yAxisProps = useYAxisProps({});
if (visibleItems.length === 0) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">
{searchQuery ? 'No results found' : 'No data available'}
</div>
</div>
);
}
return (
<div className={cn('w-full p-4', className)}>
<div className="h-[358px] w-full">
<OverviewLineChartTooltip.TooltipProvider interval={interval}>
<ResponsiveContainer>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<OverviewLineChartTooltip.Tooltip />} />
{visibleItems.map((item, index) => {
const color = getChartColor(index);
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix
? `${item.prefix}:${item.name}`
: item.name;
return (
<Line
key={key}
type="monotone"
dataKey={`${key}:sessions`}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</OverviewLineChartTooltip.TooltipProvider>
</div>
{/* Legend */}
<LegendScrollable items={visibleItems} />
</div>
);
}
function LegendScrollable({
items,
}: {
items: SeriesData[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when items change
useEffect(() => {
requestAnimationFrame(updateGradients);
}, [items, updateGradients]);
return (
<div className="relative mt-4 -mb-2">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable legend */}
<div
ref={scrollRef}
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
>
{items.map((item, index) => {
const color = getChartColor(index);
return (
<div
className="flex shrink-0 items-center gap-1"
key={item.prefix ? `${item.prefix}:${item.name}` : item.name}
style={{ color }}
>
<SerieIcon name={item.prefix || item.name} />
<span className="font-semibold whitespace-nowrap">
{item.prefix && (
<>
<span className="text-muted-foreground">{item.prefix}</span>
<span className="mx-1">/</span>
</>
)}
{item.name || 'Not set'}
</span>
</div>
);
})}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
);
}
export function OverviewLineChartLoading({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
export function OverviewLineChartEmpty({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">No data available</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { ModalContent } from '@/modals/Modal/Container';
import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
// Revenue pie chart component
function RevenuePieChart({ percentage }: { percentage: number }) {
const size = 16;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - percentage * circumference;
return (
<svg width={size} height={size} className="flex-shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3ba974"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
// Base data type that all items must conform to
export interface OverviewListItem {
sessions: number;
pageviews: number;
revenue?: number;
}
interface OverviewListModalProps<T extends OverviewListItem> {
/** Modal title */
title: string;
/** Search placeholder text */
searchPlaceholder?: string;
/** The data to display */
data: T[];
/** Extract a unique key for each item */
keyExtractor: (item: T) => string;
/** Filter function for search - receives item and lowercase search query */
searchFilter: (item: T, query: string) => boolean;
/** Render the main content cell (first column) */
renderItem: (item: T) => React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
/** Optional header content (appears below title/search) */
headerContent?: React.ReactNode;
/** Column name for the first column */
columnName?: string;
/** Whether to show pageviews column */
showPageviews?: boolean;
/** Whether to show sessions column */
showSessions?: boolean;
}
export function OverviewListModal<T extends OverviewListItem>({
title,
searchPlaceholder = 'Search...',
data,
keyExtractor,
searchFilter,
renderItem,
footer,
headerContent,
columnName = 'Name',
showPageviews = true,
showSessions = true,
}: OverviewListModalProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const number = useNumber();
// Filter data based on search query
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => searchFilter(item, queryLower));
}, [data, searchQuery, searchFilter]);
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
);
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews =
showPageviews && filteredData.some((item) => item.pageviews > 0);
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
}, [filteredData, showPageviews]);
// Virtual list setup
const virtualizer = useVirtualizer({
count: filteredData.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
{/* Sticky Header */}
<div className="flex-shrink-0 border-b border-border">
<div className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold mb-4">
{title}
</DialogTitle>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{headerContent}
</div>
{/* Column Headers */}
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
{hasRevenue && <div className="text-right">Revenue</div>}
{hasPageviews && <div className="text-right">Views</div>}
{showSessions && <div className="text-right">Sessions</div>}
</div>
</div>
{/* Virtualized Scrollable Body */}
<div
ref={scrollAreaRef}
className="flex-1 min-h-0 overflow-y-auto"
style={{ maxHeight: '60vh' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = filteredData[virtualRow.index];
if (!item) return null;
const percentage = item.sessions / maxSessions;
const revenuePercentage =
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
return (
<div
key={keyExtractor(item)}
className="absolute top-0 left-0 w-full group/row"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Background bar */}
<div className="absolute inset-0 overflow-hidden">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
style={{ width: `${percentage * 100}%` }}
/>
</div>
{/* Row content */}
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
{/* Revenue cell */}
{hasRevenue && (
<div className="flex items-center justify-end gap-2">
<span
className="font-semibold font-mono text-sm"
style={{ color: '#3ba974' }}
>
{(item.revenue ?? 0) > 0
? number.currency((item.revenue ?? 0) / 100, {
short: true,
})
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
)}
{/* Pageviews cell */}
{hasPageviews && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.pageviews)}
</div>
)}
{/* Sessions cell */}
{showSessions && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.sessions)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{searchQuery ? 'No results found' : 'No data available'}
</div>
)}
</div>
{/* Fixed Footer */}
{footer && (
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
)}
</ModalContent>
);
}

View File

@@ -1,5 +1,4 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { useNumber } from '@/hooks/use-numer-formatter';
@@ -8,18 +7,14 @@ 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 { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps {
projectId: string;
@@ -86,10 +81,8 @@ export function OverviewLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="sessionCount"
fill="rgba(59, 121, 255, 0.2)"
className="fill-chart-0"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -1,7 +1,7 @@
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 { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
@@ -144,51 +144,33 @@ export function OverviewMetricCard({
<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',
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)}
>
<AutoSizer>
{({ width, height }) => (
<AreaChart
<AutoSizer style={{ height: 20 }}>
{({ width }) => (
<BarChart
width={width}
height={height / 4}
height={20}
data={data}
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
style={{
background: 'transparent',
}}
onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null);
}}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<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
<Tooltip content={() => null} cursor={false} />
<Bar
dataKey={'current'}
type="step"
fill={`url(#colorUv${id})`}
fill={graphColors}
fillOpacity={1}
stroke={graphColors}
strokeWidth={1}
strokeWidth={0}
isAnimationActive={false}
/>
</AreaChart>
</BarChart>
)}
</AutoSizer>
</div>
@@ -225,13 +207,11 @@ export function OverviewMetricCardNumber({
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 className={cn('min-w-0 col gap-2 items-start', className)}>
<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>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
@@ -239,13 +219,13 @@ export function OverviewMetricCardNumber({
<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 className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
)}
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
{enhancer}
</div>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
@@ -13,7 +11,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -31,6 +34,7 @@ export default function OverviewTopDevices({
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
@@ -316,6 +320,7 @@ export default function OverviewTopDevices({
});
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -328,31 +333,67 @@ export default function OverviewTopDevices({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -384,7 +425,8 @@ export default function OverviewTopDevices({
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<OverviewViewToggle />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,225 +1,174 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
type EventTableItem,
OverviewWidgetTableEvents,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { useOverviewWidgetV2 } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
your: {
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
title: 'Events',
btn: 'Events',
meta: {
filters: [
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
eventName: '*',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
meta: {
filters: [
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
eventName: '*',
},
},
link_out: {
title: 'Link out',
btn: 'Link out',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
id: 'A',
name: 'link_out',
filters: [],
},
],
breakdowns: [
{
id: 'A',
name: 'properties.href',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Link out',
range: range,
previous: previous,
metric: 'sum',
},
meta: {
filters: [],
eventName: 'link_out',
breakdownProperty: 'properties.href',
},
},
});
const report: IChartInput = useMemo(
() => ({
limit: 1000,
projectId,
startDate,
endDate,
series: [
{
type: 'event' as const,
segment: 'event' as const,
filters: [...filters, ...(widget.meta?.filters ?? [])],
id: 'A',
name: widget.meta?.eventName ?? '*',
},
],
breakdowns: [
{
id: 'A',
name: widget.meta?.breakdownProperty ?? 'name',
},
],
chartType: 'bar' as const,
lineType: 'monotone' as const,
interval,
name: widget.title,
range,
previous,
metric: 'sum' as const,
}),
[projectId, startDate, endDate, filters, widget, interval, range, previous],
);
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
const tableData: EventTableItem[] = useMemo(() => {
if (!query.data?.series) return [];
return query.data.series.map((serie) => ({
id: serie.id,
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
count: serie.metrics.sum,
}));
}, [query.data]);
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData.slice(0, 15);
}
const queryLower = searchQuery.toLowerCase();
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
}, [tableData, searchQuery]);
const tabs = useMemo(
() =>
widgets
.filter((item) => item.hide !== true)
.map((w) => ({
key: w.key,
label: w.btn,
})),
[widgets],
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{
hideID: true,
columns: ['Event'],
renderSerieName(names) {
return names[1];
},
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.breakdownProperty) {
setFilter(widget.meta.breakdownProperty, name);
} else {
setFilter('name', name);
}
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewChartToggle {...{ chartType, setChartType }} />
<div className="flex-1" />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,18 +1,15 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { OverviewListModal } from './overview-list-modal';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
@@ -24,83 +21,55 @@ export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [filters, setFilter] = useEventQueryFilters();
const [_filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
),
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
filters: _filters,
startDate,
endDate,
range,
column,
}),
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
<OverviewListModal
title={`Top ${columnNamePlural}`}
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
data={query.data ?? []}
keyExtractor={(item) => (item.prefix ?? '') + item.name}
searchFilter={(item, query) =>
item.name?.toLowerCase().includes(query) ||
item.prefix?.toLowerCase().includes(query) ||
false
}
columnName={columnName}
renderItem={(item) => (
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter(column, item.name);
}}
>
Load more
</Button>
{item.prefix && (
<span className="mr-1 inline-flex items-center gap-1">
<span>{item.prefix}</span>
<ChevronRightIcon className="size-3" />
</span>
)}
{item.name || 'Not set'}
</button>
</div>
</ScrollArea>
</ModalContent>
)}
/>
);
}

View File

@@ -1,10 +1,8 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { countries } from '@/translations/countries';
@@ -16,7 +14,16 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import {
WidgetFooter,
WidgetHead,
WidgetHeadSearchable,
} from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -32,6 +39,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
@@ -48,8 +56,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
});
const number = useNumber();
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -62,31 +70,74 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.name?.toLowerCase().includes(queryLower) ||
item.prefix?.toLowerCase().includes(queryLower) ||
countries[item.name as keyof typeof countries]
?.toLowerCase()
.includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -139,8 +190,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<span className="text-sm text-muted-foreground pr-2">
<div className="flex-1" />
<OverviewViewToggle />
<span className="text-sm text-muted-foreground pr-2 ml-2">
Geo data provided by{' '}
<a
href="https://ipdata.co"

View File

@@ -1,11 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
import { OverviewListModal } from './overview-list-modal';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
@@ -18,44 +18,54 @@ export default function OverviewTopPagesModal({
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
),
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
}),
);
const data = query.data?.pages.flat();
return (
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
<OverviewListModal
title="Top Pages"
searchPlaceholder="Search pages..."
data={query.data ?? []}
keyExtractor={(item) => item.path + item.origin}
searchFilter={(item, query) =>
item.path.toLowerCase().includes(query) ||
item.origin.toLowerCase().includes(query)
}
columnName="Path"
renderItem={(item) => (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path || <span className="opacity-40">Not set</span>}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
</a>
</div>
</Tooltiper>
)}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,8 +9,9 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableEntries,
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
@@ -25,15 +26,11 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
btn: 'Pages',
},
entry: {
title: 'Entry Pages',
@@ -53,10 +50,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
@@ -71,37 +64,53 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
}),
);
const data = query.data;
const filteredData = useMemo(() => {
const data = query.data?.slice(0, 15) ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.path.toLowerCase().includes(queryLower) ||
item.origin.toLowerCase().includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
{widget.meta?.columns.sessions ? (
<OverviewWidgetTableEntries
data={filteredData}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
) : (
<OverviewWidgetTablePages
data={filteredData}
showDomain={!!domain}
/>
)}
</>
)}
</WidgetBody>
@@ -109,7 +118,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}

View File

@@ -1,5 +1,5 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,7 +9,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -23,16 +28,18 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { range, startDate, endDate } = useOverviewOptions();
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const [view] = useOverviewView();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'All',
btn: 'Refs',
},
referrer: {
title: 'Top urls',
btn: 'URLs',
btn: 'Urls',
},
referrer_type: {
title: 'Top types',
@@ -72,31 +79,67 @@ export default function OverviewTopSources({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -137,7 +180,8 @@ export default function OverviewTopSources({
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<OverviewViewToggle />
</WidgetFooter>
</Widget>
</>

View File

@@ -0,0 +1,54 @@
import { LineChartIcon, TableIcon } from 'lucide-react';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { Button } from '../ui/button';
type ViewType = 'table' | 'chart';
interface OverviewViewToggleProps {
defaultView?: ViewType;
className?: string;
}
export function OverviewViewToggle({
defaultView = 'table',
className,
}: OverviewViewToggleProps) {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault(defaultView)
.withOptions({ history: 'push' }),
);
return (
<div className={className}>
<Button
size="icon"
variant="ghost"
onClick={() => {
setView(view === 'table' ? 'chart' : 'table');
}}
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
>
{view === 'table' ? (
<LineChartIcon size={16} />
) : (
<TableIcon size={16} />
)}
</Button>
</div>
);
}
export function useOverviewView() {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault('table')
.withOptions({ history: 'push' }),
);
return [view, setView] as const;
}

View File

@@ -61,7 +61,7 @@ export const OverviewWidgetTable = <T,>({
<WidgetTable
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
eachRow={(item) => {
return (
@@ -109,15 +109,6 @@ export function OverviewWidgetTableLoading({
render: () => <Skeleton className="h-4 w-1/3" />,
width: 'w-full',
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '60px',
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
@@ -142,27 +133,24 @@ function getPath(path: string, showDomain = false) {
export function OverviewWidgetTablePages({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
revenue: number;
pageviews: number;
revenue?: number;
}[];
showDomain?: boolean;
}) {
const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
const hasRevenue = data.some((item) => item.revenue > 0);
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return (
<OverviewWidgetTable
className={className}
@@ -214,20 +202,135 @@ export function OverviewWidgetTablePages({
);
},
},
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{revenue > 0 ? number.currency(revenue / 100) : '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
);
},
} as const,
]
: []),
{
name: 'BR',
width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
name: 'Views',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
},
{
name: 'Duration',
width: '75px',
responsive: { priority: 7 }, // Hidden when space is tight
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableEntries({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
sessions: number;
pageviews: number;
revenue?: number;
}[];
showDomain?: boolean;
}) {
const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
width: 'w-full',
responsive: { priority: 1 }, // Always visible
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path ? (
<>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</>
) : (
<span className="opacity-40">Not set</span>
)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
...(hasRevenue
@@ -237,17 +340,16 @@ export function OverviewWidgetTablePages({
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{item.revenue > 0
? number.currency(item.revenue / 100)
: '-'}
{revenue > 0 ? number.currency(revenue / 100) : '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
@@ -373,6 +475,7 @@ export function OverviewWidgetTableGeneric({
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews = data.some((item) => item.pageviews > 0);
return (
<OverviewWidgetTable
className={className}
@@ -385,27 +488,12 @@ export function OverviewWidgetTableGeneric({
width: 'w-full',
responsive: { priority: 1 }, // Always visible
},
{
name: 'BR',
width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
responsive: { priority: 3 },
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -427,10 +515,28 @@ export function OverviewWidgetTableGeneric({
} as const,
]
: []),
...(hasPageviews
? [
{
name: 'Views',
width: '84px',
responsive: { priority: 2 },
render(item: RouterOutputs['overview']['topGeneric'][number]) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
} as const,
]
: []),
{
name: 'Sessions',
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
responsive: { priority: 2 },
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -445,3 +551,65 @@ export function OverviewWidgetTableGeneric({
/>
);
}
export type EventTableItem = {
id: string;
name: string;
count: number;
};
export function OverviewWidgetTableEvents({
data,
className,
onItemClick,
}: {
className?: string;
data: EventTableItem[];
onItemClick?: (name: string) => void;
}) {
const number = useNumber();
const maxCount = Math.max(...data.map((item) => item.count), 1);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.id}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Event',
width: 'w-full',
responsive: { priority: 1 },
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name} />
<button
type="button"
className="truncate"
onClick={() => onItemClick?.(item.name)}
>
{item.name || 'Not set'}
</button>
</div>
);
},
},
{
name: 'Count',
width: '84px',
responsive: { priority: 2 },
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -1,8 +1,8 @@
import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useEffect, useRef, useState } from 'react';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
@@ -169,6 +170,128 @@ export function WidgetButtons({
);
}
interface WidgetTab<T extends string = string> {
key: T;
label: string;
}
interface WidgetHeadSearchableProps<T extends string = string> {
tabs: WidgetTab<T>[];
activeTab: T;
className?: string;
onTabChange: (key: T) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
}
export function WidgetHeadSearchable<T extends string>({
tabs,
className,
activeTab,
onTabChange,
searchValue,
onSearchChange,
searchPlaceholder = 'Search',
}: WidgetHeadSearchableProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when tabs change
useEffect(() => {
// Use RAF to ensure DOM has updated
requestAnimationFrame(updateGradients);
}, [tabs, updateGradients]);
return (
<div className={cn('border-b border-border', className)}>
{/* Scrollable tabs container */}
<div className="relative">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable tabs */}
<div
ref={scrollRef}
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={cn(
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
activeTab === tab.key
? 'text-foreground'
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
{/* Search input */}
{onSearchChange && (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
/>
</div>
)}
</div>
);
}
export function WidgetFooter({
className,
children,

View File

@@ -33,7 +33,10 @@ export function useOverviewWidget<T extends string>(
export function useOverviewWidgetV2<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; meta?: any }>,
widgets: Record<
T,
{ title: string; btn: string; meta?: any; hide?: boolean }
>,
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(

View File

@@ -16,7 +16,6 @@ import {
YAxis,
} from 'recharts';
import { AnimatedNumber } from '../animated-number';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface RealtimeLiveHistogramProps {
@@ -87,10 +86,8 @@ export function RealtimeLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="visitorCount"
fill="rgba(59, 121, 255, 0.2)"
className="fill-chart-0"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

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,

View File

@@ -386,6 +386,7 @@ export function ReportSeries() {
}}
placeholder="Select event"
items={eventNames}
className="flex-1"
/>
{showFormula && (
<Button
@@ -401,6 +402,7 @@ export function ReportSeries() {
}),
);
}}
className="px-4"
>
Add Formula
</Button>

View File

@@ -50,7 +50,7 @@ export function ReportSettings() {
return (
<div>
<h3 className="mb-2 font-medium">Settings</h3>
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
<div className="col rounded-lg border bg-card p-4 gap-2">
{fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap">

View File

@@ -4,7 +4,7 @@ import { cva } from 'class-variance-authority';
import * as React from 'react';
const inputVariant = cva(
'file: flex w-full rounded-md border border-input bg-card ring-offset-background file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
'flex w-full rounded-md border border-input bg-card ring-offset-background file:border-0 file:bg-transparent file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
size: {

View File

@@ -185,7 +185,6 @@ export function WidgetTable<T>({
columns.length > 1 && column !== columns[0]
? 'text-right'
: 'text-left',
column.className,
responsiveClass,
)}
style={{ width: column.width }}

View File

@@ -10,20 +10,27 @@ const VALID_COOKIES = [
'chartType',
'range',
'supporter-prompt-closed',
'feedback-prompt-seen',
] as const;
const COOKIE_EVENT_NAME = '__cookie-change';
const setCookieFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() }))
.handler(({ data: { key, value } }) => {
.inputValidator(
z.object({
key: z.enum(VALID_COOKIES),
value: z.string(),
maxAge: z.number().optional(),
}),
)
.handler(({ data: { key, value, maxAge } }) => {
if (!VALID_COOKIES.includes(key)) {
return;
}
const maxAge = 60 * 60 * 24 * 365 * 10;
const cookieMaxAge = maxAge ?? 60 * 60 * 24 * 365 * 10;
setCookie(key, value, {
maxAge,
maxAge: cookieMaxAge,
path: '/',
expires: new Date(Date.now() + maxAge),
expires: new Date(Date.now() + cookieMaxAge),
});
});
@@ -36,6 +43,7 @@ export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
export function useCookieStore<T>(
key: (typeof VALID_COOKIES)[number],
defaultValue: T,
options?: { maxAge?: number },
) {
const { cookies } = useRouteContext({ strict: false });
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
@@ -68,7 +76,9 @@ export function useCookieStore<T>(
value,
(newValue: T) => {
setValue(newValue);
setCookieFn({ data: { key, value: String(newValue) } });
setCookieFn({
data: { key, value: String(newValue), maxAge: options?.maxAge },
});
window.dispatchEvent(
new CustomEvent(COOKIE_EVENT_NAME, {
detail: { key, value: newValue, from: ref.current },
@@ -76,6 +86,6 @@ export function useCookieStore<T>(
);
},
] as const,
[value, key],
[value, key, options?.maxAge],
);
}

View File

@@ -1,3 +1,4 @@
import { LazyComponent } from '@/components/lazy-component';
import {
OverviewFilterButton,
OverviewFiltersButtons,
@@ -58,7 +59,9 @@ function ProjectDashboard() {
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<OverviewTopGeo projectId={projectId} />
<OverviewUserJourney projectId={projectId} />
<LazyComponent className="col-span-6">
<OverviewUserJourney projectId={projectId} />
</LazyComponent>
</div>
</div>
);

View File

@@ -1,18 +1,15 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFilterButton } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewInterval } from '@/components/overview/overview-interval';
import { OverviewRange } from '@/components/overview/overview-range';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { Pagination } from '@/components/pagination';
import { FloatingPagination } from '@/components/pagination-floating';
import { ReportChart } from '@/components/report-chart';
import { Skeleton } from '@/components/skeleton';
import { Input } from '@/components/ui/input';
import { TableButtons } from '@/components/ui/table';
import { useAppContext } from '@/hooks/use-app-context';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
import { useTRPC } from '@/integrations/trpc/react';
@@ -22,7 +19,7 @@ import type { IChartRange, IInterval } from '@openpanel/validation';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs';
import { memo } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
component: Component,
@@ -42,30 +39,102 @@ function Component() {
const trpc = useTRPC();
const take = 20;
const { range, interval } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [cursor, setCursor] = useQueryState(
'cursor',
parseAsInteger.withDefault(1),
);
const { debouncedSearch, setSearch, search } = useSearchQueryState();
const query = useQuery(
// Track if we should use backend search (when client-side filtering finds nothing)
const [useBackendSearch, setUseBackendSearch] = useState(false);
// Reset to client-side filtering when search changes
useEffect(() => {
setUseBackendSearch(false);
setCursor(1);
}, [debouncedSearch, setCursor]);
// Query for all pages (without search) - used for client-side filtering
const allPagesQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor,
take,
search: debouncedSearch,
cursor: 1,
take: 1000,
search: undefined, // No search - get all pages
range,
interval,
filters,
},
{
placeholderData: keepPreviousData,
},
),
);
const data = query.data ?? [];
// Query for backend search (only when client-side filtering finds nothing)
const backendSearchQuery = useQuery(
trpc.event.pages.queryOptions(
{
projectId,
cursor: 1,
take: 1000,
search: debouncedSearch || undefined,
range,
interval,
},
{
placeholderData: keepPreviousData,
enabled: useBackendSearch && !!debouncedSearch,
},
),
);
// Client-side filtering: filter all pages by search query
const clientSideFiltered = useMemo(() => {
if (!debouncedSearch || useBackendSearch) {
return allPagesQuery.data ?? [];
}
const searchLower = debouncedSearch.toLowerCase();
return (allPagesQuery.data ?? []).filter(
(page) =>
page.path.toLowerCase().includes(searchLower) ||
page.origin.toLowerCase().includes(searchLower),
);
}, [allPagesQuery.data, debouncedSearch, useBackendSearch]);
// Check if client-side filtering found results
useEffect(() => {
if (
debouncedSearch &&
!useBackendSearch &&
allPagesQuery.isSuccess &&
clientSideFiltered.length === 0
) {
// No results from client-side filtering, switch to backend search
setUseBackendSearch(true);
}
}, [
debouncedSearch,
useBackendSearch,
allPagesQuery.isSuccess,
clientSideFiltered.length,
]);
// Determine which data source to use
const allData = useBackendSearch
? (backendSearchQuery.data ?? [])
: clientSideFiltered;
const isLoading = useBackendSearch
? backendSearchQuery.isLoading
: allPagesQuery.isLoading;
// Client-side pagination: slice the items based on cursor
const startIndex = (cursor - 1) * take;
const endIndex = startIndex + take;
const data = allData.slice(startIndex, endIndex);
const totalPages = Math.ceil(allData.length / take);
return (
<PageContainer>
@@ -77,24 +146,27 @@ function Component() {
<TableButtons>
<OverviewRange />
<OverviewInterval />
<OverviewFilterButton enableEventsFilter />
<Input
className="self-auto"
placeholder="Search path"
value={search ?? ''}
onChange={(e) => {
setSearch(e.target.value);
setCursor(0);
setCursor(1);
}}
/>
</TableButtons>
{data.length === 0 && !query.isLoading && (
{data.length === 0 && !isLoading && (
<FullPageEmptyState
title="No pages"
description={'Integrate our web sdk to your site to get pages here.'}
description={
debouncedSearch
? `No pages found matching "${debouncedSearch}"`
: 'Integrate our web sdk to your site to get pages here.'
}
/>
)}
{query.isLoading && (
{isLoading && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<PageCardSkeleton />
<PageCardSkeleton />
@@ -105,7 +177,7 @@ function Component() {
{data.map((page) => {
return (
<PageCard
key={page.path}
key={page.origin + page.path}
page={page}
range={range}
interval={interval}
@@ -114,18 +186,18 @@ function Component() {
);
})}
</div>
{data.length !== 0 && (
{allData.length !== 0 && (
<div className="p-4">
<FloatingPagination
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
canNextPage={true}
canPreviousPage={cursor > 0}
canNextPage={cursor < totalPages}
canPreviousPage={cursor > 1}
pageIndex={cursor - 1}
nextPage={() => {
setCursor((p) => p + 1);
setCursor((p) => Math.min(p + 1, totalPages));
}}
previousPage={() => {
setCursor((p) => p - 1);
setCursor((p) => Math.max(p - 1, 1));
}}
/>
</div>

View File

@@ -1,4 +1,5 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import FeedbackPrompt from '@/components/organization/feedback-prompt';
import SupporterPrompt from '@/components/organization/supporter-prompt';
import { LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
@@ -154,6 +155,7 @@ function Component() {
)}
<Outlet />
<SupporterPrompt />
<FeedbackPrompt />
</>
);
}

View File

@@ -28,5 +28,6 @@ export * from './src/types';
export * from './src/clickhouse/query-builder';
export * from './src/services/import.service';
export * from './src/services/overview.service';
export * from './src/services/pages.service';
export * from './src/services/insights';
export * from './src/session-context';

View File

@@ -90,6 +90,7 @@ function getClickhouseSettings(): ClickHouseSettings {
{};
return {
distributed_product_mode: 'allow',
date_time_input_format: 'best_effort',
...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
? {

View File

@@ -519,7 +519,7 @@ export class Query<T = any> {
const query = this.buildQuery();
console.log(
'query',
`${query} SETTINGS session_timezone = '${this.timezone}'`,
`${query.replaceAll('\n', ' ').replaceAll('\t', ' ').replaceAll('\r', ' ')} SETTINGS session_timezone = '${this.timezone}'`,
);
const result = await this.client.query({

View File

@@ -1,7 +1,16 @@
import { getPreviousMetric } from '@openpanel/common';
import type { FinalChart, IChartInput } from '@openpanel/validation';
import { getChartPrevStartEndDate } from '../services/chart.service';
import { getPreviousMetric, groupByLabels } from '@openpanel/common';
import type { ISerieDataItem } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type {
FinalChart,
IChartEventItem,
IChartInput,
} from '@openpanel/validation';
import { chQuery } from '../clickhouse/client';
import {
getAggregateChartSql,
getChartPrevStartEndDate,
} from '../services/chart.service';
import {
getOrganizationSubscriptionChartEndDate,
getSettingsForProject,
@@ -69,7 +78,280 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
return response;
}
/**
* Aggregate Chart Engine - Optimized for bar/pie charts without time series
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
*/
export async function executeAggregateChart(
input: IChartInput,
): Promise<FinalChart> {
// Stage 1: Normalize input
const normalized = await normalize(input);
// Handle subscription end date limit
const endDate = await getOrganizationSubscriptionChartEndDate(
input.projectId,
normalized.endDate,
);
if (endDate) {
normalized.endDate = endDate;
}
const { timezone } = await getSettingsForProject(normalized.projectId);
// Stage 2: Fetch aggregate data for current period (event series only)
const fetchedSeries: ConcreteSeries[] = [];
for (let i = 0; i < normalized.series.length; i++) {
const definition = normalized.series[i]!;
if (definition.type !== 'event') {
// Skip formulas - they'll be computed in the next stage
continue;
}
const event = definition as IChartEventItem & { type: 'event' };
// Build query input
const queryInput = {
event: {
id: event.id,
name: event.name,
segment: event.segment,
filters: event.filters,
displayName: event.displayName,
property: event.property,
},
projectId: normalized.projectId,
startDate: normalized.startDate,
endDate: normalized.endDate,
breakdowns: normalized.breakdowns,
limit: normalized.limit,
timezone,
};
// Execute aggregate query
let queryResult = await chQuery<ISerieDataItem>(
getAggregateChartSql(queryInput),
{
session_timezone: timezone,
},
);
// Fallback: if no results with breakdowns, try without breakdowns
if (queryResult.length === 0 && normalized.breakdowns.length > 0) {
queryResult = await chQuery<ISerieDataItem>(
getAggregateChartSql({
...queryInput,
breakdowns: [],
}),
{
session_timezone: timezone,
},
);
}
// Group by labels (handles breakdown expansion)
const groupedSeries = groupByLabels(queryResult);
// Create concrete series for each grouped result
groupedSeries.forEach((grouped) => {
// Extract breakdown value from name array
const breakdownValue =
normalized.breakdowns.length > 0 && grouped.name.length > 1
? grouped.name.slice(1).join(' - ')
: undefined;
// Build breakdowns object
const breakdowns: Record<string, string> | undefined =
normalized.breakdowns.length > 0 && grouped.name.length > 1
? {}
: undefined;
if (breakdowns) {
normalized.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
breakdowns[breakdown.name] = breakdownNamePart;
}
});
}
// Build filters including breakdown value
const filters = [...event.filters];
if (breakdownValue && normalized.breakdowns.length > 0) {
normalized.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
filters.push({
id: `breakdown-${idx}`,
name: breakdown.name,
operator: 'is',
value: [breakdownNamePart],
});
}
});
}
// For aggregate charts, grouped.data should have a single data point
// (since we use a constant date in the query)
const concrete: ConcreteSeries = {
id: `${event.name}-${grouped.name.join('-')}-${i}`,
definitionId: definition.id ?? alphabetIds[i] ?? `series-${i}`,
definitionIndex: i,
name: grouped.name,
context: {
event: event.name,
filters,
breakdownValue,
breakdowns,
},
data: grouped.data,
definition,
};
fetchedSeries.push(concrete);
});
}
// Stage 3: Compute formula series from fetched event series
const computedSeries = compute(fetchedSeries, normalized.series);
// Stage 4: Fetch previous period if requested
let previousSeries: ConcreteSeries[] | null = null;
if (input.previous) {
const currentPeriod = {
startDate: normalized.startDate,
endDate: normalized.endDate,
};
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const previousFetchedSeries: ConcreteSeries[] = [];
for (let i = 0; i < normalized.series.length; i++) {
const definition = normalized.series[i]!;
if (definition.type !== 'event') {
continue;
}
const event = definition as IChartEventItem & { type: 'event' };
const queryInput = {
event: {
id: event.id,
name: event.name,
segment: event.segment,
filters: event.filters,
displayName: event.displayName,
property: event.property,
},
projectId: normalized.projectId,
startDate: previousPeriod.startDate,
endDate: previousPeriod.endDate,
breakdowns: normalized.breakdowns,
limit: normalized.limit,
timezone,
};
let queryResult = await chQuery<ISerieDataItem>(
getAggregateChartSql(queryInput),
{
session_timezone: timezone,
},
);
if (queryResult.length === 0 && normalized.breakdowns.length > 0) {
queryResult = await chQuery<ISerieDataItem>(
getAggregateChartSql({
...queryInput,
breakdowns: [],
}),
{
session_timezone: timezone,
},
);
}
const groupedSeries = groupByLabels(queryResult);
groupedSeries.forEach((grouped) => {
const breakdownValue =
normalized.breakdowns.length > 0 && grouped.name.length > 1
? grouped.name.slice(1).join(' - ')
: undefined;
const breakdowns: Record<string, string> | undefined =
normalized.breakdowns.length > 0 && grouped.name.length > 1
? {}
: undefined;
if (breakdowns) {
normalized.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
breakdowns[breakdown.name] = breakdownNamePart;
}
});
}
const filters = [...event.filters];
if (breakdownValue && normalized.breakdowns.length > 0) {
normalized.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
filters.push({
id: `breakdown-${idx}`,
name: breakdown.name,
operator: 'is',
value: [breakdownNamePart],
});
}
});
}
const concrete: ConcreteSeries = {
id: `${event.name}-${grouped.name.join('-')}-${i}`,
definitionId: definition.id ?? alphabetIds[i] ?? `series-${i}`,
definitionIndex: i,
name: grouped.name,
context: {
event: event.name,
filters,
breakdownValue,
breakdowns,
},
data: grouped.data,
definition,
};
previousFetchedSeries.push(concrete);
});
}
// Compute formula series for previous period
previousSeries = compute(previousFetchedSeries, normalized.series);
}
// Stage 5: Format final output with previous period data
const includeAlphaIds = normalized.series.length > 1;
const response = format(
computedSeries,
normalized.series,
includeAlphaIds,
previousSeries,
normalized.limit,
);
return response;
}
// Export as ChartEngine for backward compatibility
export const ChartEngine = {
execute: executeChart,
};
// Export aggregate chart engine
export const AggregateChartEngine = {
execute: executeAggregateChart,
};

View File

@@ -348,6 +348,246 @@ export function getChartSql({
return sql;
}
export function getAggregateChartSql({
event,
breakdowns,
startDate,
endDate,
projectId,
limit,
timezone,
}: Omit<IGetChartDataInput, 'interval' | 'chartType'> & {
timezone: string;
}) {
const {
sb,
join,
getWhere,
getFrom,
getJoins,
getSelect,
getOrderBy,
getGroupBy,
getWith,
with: addCte,
getSql,
} = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
if (event.name !== '*') {
sb.select.label_0 = `${sqlstring.escape(event.name)} as label_0`;
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`;
} else {
sb.select.label_0 = `'*' as label_0`;
}
const anyFilterOnProfile = event.filters.some((filter) =>
filter.name.startsWith('profile.'),
);
const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
breakdown.name.startsWith('profile.'),
);
// Build WHERE clause without the bar filter (for use in subqueries and CTEs)
const getWhereWithoutBar = () => {
const whereWithoutBar = { ...sb.where };
delete whereWithoutBar.bar;
return Object.keys(whereWithoutBar).length
? `WHERE ${join(whereWithoutBar, ' AND ')}`
: '';
};
// Collect all profile fields used in filters and breakdowns
const getProfileFields = () => {
const fields = new Set<string>();
// Always need id for the join
fields.add('id');
// Collect from filters
event.filters
.filter((f) => f.name.startsWith('profile.'))
.forEach((f) => {
const fieldName = f.name.replace('profile.', '').split('.')[0];
if (fieldName && fieldName === 'properties') {
fields.add('properties');
} else if (
fieldName &&
['email', 'first_name', 'last_name'].includes(fieldName)
) {
fields.add(fieldName);
}
});
// Collect from breakdowns
breakdowns
.filter((b) => b.name.startsWith('profile.'))
.forEach((b) => {
const fieldName = b.name.replace('profile.', '').split('.')[0];
if (fieldName && fieldName === 'properties') {
fields.add('properties');
} else if (
fieldName &&
['email', 'first_name', 'last_name'].includes(fieldName)
) {
fields.add(fieldName);
}
});
return Array.from(fields);
};
// Create profiles CTE if profiles are needed
const profilesJoinRef =
anyFilterOnProfile || anyBreakdownOnProfile
? 'LEFT ANY JOIN profile ON profile.id = profile_id'
: '';
if (anyFilterOnProfile || anyBreakdownOnProfile) {
const profileFields = getProfileFields();
const selectFields = profileFields.map((field) => {
if (field === 'id') {
return 'id as "profile.id"';
}
if (field === 'properties') {
return 'properties as "profile.properties"';
}
if (field === 'email') {
return 'email as "profile.email"';
}
if (field === 'first_name') {
return 'first_name as "profile.first_name"';
}
if (field === 'last_name') {
return 'last_name as "profile.last_name"';
}
return field;
});
addCte(
'profile',
`SELECT ${selectFields.join(', ')}
FROM ${TABLE_NAMES.profiles} FINAL
WHERE project_id = ${sqlstring.escape(projectId)}`,
);
sb.joins.profiles = profilesJoinRef;
}
// Date range filters
if (startDate) {
sb.where.startDate = `created_at >= toDateTime('${formatClickhouseDate(startDate)}')`;
}
if (endDate) {
sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`;
}
// Add a constant date field for aggregate charts (groupByLabels expects it)
// Use startDate as the date value since we're aggregating across the entire range
sb.select.date = `${sqlstring.escape(startDate)} as date`;
// Use CTE to define top breakdown values once, then reference in WHERE clause
if (breakdowns.length > 0 && limit) {
const breakdownSelects = breakdowns
.map((b) => getSelectPropertyKey(b.name))
.join(', ');
addCte(
'top_breakdowns',
`SELECT ${breakdownSelects}
FROM ${TABLE_NAMES.events} e
${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()}
GROUP BY ${breakdownSelects}
ORDER BY count(*) DESC
LIMIT ${limit}`,
);
// Filter main query to only include top breakdown values
sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`;
}
// Add breakdowns to SELECT and GROUP BY
breakdowns.forEach((breakdown, index) => {
// Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.groupBy[key] = `${key}`;
});
// Always group by label_0 (event name) for aggregate charts
sb.groupBy.label_0 = 'label_0';
// Default count aggregation
sb.select.count = 'count(*) as count';
// Handle different segments
if (event.segment === 'user') {
sb.select.count = 'countDistinct(profile_id) as count';
}
if (event.segment === 'session') {
sb.select.count = 'countDistinct(session_id) as count';
}
if (event.segment === 'user_average') {
sb.select.count =
'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count';
}
const mathFunction = {
property_sum: 'sum',
property_average: 'avg',
property_max: 'max',
property_min: 'min',
}[event.segment as string];
if (mathFunction && event.property) {
const propertyKey = getSelectPropertyKey(event.property);
if (isNumericColumn(event.property)) {
sb.select.count = `${mathFunction}(${propertyKey}) as count`;
sb.where.property = `${propertyKey} IS NOT NULL`;
} else {
sb.select.count = `${mathFunction}(toFloat64OrNull(${propertyKey})) as count`;
sb.where.property = `${propertyKey} IS NOT NULL AND notEmpty(${propertyKey})`;
}
}
if (event.segment === 'one_event_per_user') {
sb.from = `(
SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join(
sb.where,
' AND ',
)}
ORDER BY profile_id, created_at DESC
) as subQuery`;
sb.joins = {};
const sql = getSql();
console.log('-- Aggregate Chart --');
console.log(sql.replaceAll(/[\n\r]/g, ' '));
console.log('-- End --');
return sql;
}
// Order by count DESC (biggest first) for aggregate charts
sb.orderBy.count = 'count DESC';
// Apply limit if specified
if (limit) {
sb.limit = limit;
}
const sql = getSql();
console.log('-- Aggregate Chart --');
console.log(sql.replaceAll(/[\n\r]/g, ' '));
console.log('-- End --');
return sql;
}
function isNumericColumn(columnName: string): boolean {
const numericColumns = ['duration', 'revenue', 'longitude', 'latitude'];
return numericColumns.includes(columnName);

View File

@@ -11,6 +11,12 @@ import { getEventFiltersWhereClause } from './chart.service';
// Constants
const ROLLUP_DATE_PREFIX = '1970-01-01';
// Toggle revenue tracking in overview queries
const INCLUDE_REVENUE = true; // TODO: Make this configurable later
// Maximum number of records to return (for detail modals)
const MAX_RECORDS_LIMIT = 1000;
const COLUMN_PREFIX_MAP: Record<string, string> = {
region: 'country',
city: 'country',
@@ -47,8 +53,6 @@ export const zGetTopPagesInput = z.object({
filters: z.array(z.any()),
startDate: z.string(),
endDate: z.string(),
cursor: z.number().optional(),
limit: z.number().optional(),
});
export type IGetTopPagesInput = z.infer<typeof zGetTopPagesInput> & {
@@ -61,8 +65,6 @@ export const zGetTopEntryExitInput = z.object({
startDate: z.string(),
endDate: z.string(),
mode: z.enum(['entry', 'exit']),
cursor: z.number().optional(),
limit: z.number().optional(),
});
export type IGetTopEntryExitInput = z.infer<typeof zGetTopEntryExitInput> & {
@@ -97,14 +99,20 @@ export const zGetTopGenericInput = z.object({
'os',
'os_version',
]),
cursor: z.number().optional(),
limit: z.number().optional(),
});
export type IGetTopGenericInput = z.infer<typeof zGetTopGenericInput> & {
timezone: string;
};
export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
interval: zTimeInterval,
});
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
timezone: string;
};
export const zGetUserJourneyInput = z.object({
projectId: z.string(),
filters: z.array(z.any()),
@@ -543,18 +551,27 @@ export class OverviewService {
filters,
startDate,
endDate,
cursor = 1,
limit = 10,
timezone,
}: IGetTopPagesInput) {
const pageStatsQuery = clix(this.client, timezone)
.select([
'origin',
'path',
`last_value(properties['__title']) as title`,
'uniq(session_id) as count',
'round(avg(duration)/1000, 2) as avg_duration',
])
const selectColumns: (string | null | undefined | false)[] = [
'origin',
'path',
'uniq(session_id) as sessions',
'count() as pageviews',
];
if (INCLUDE_REVENUE) {
selectColumns.push('sum(revenue) as revenue');
}
const query = clix(this.client, timezone)
.select<{
origin: string;
path: string;
sessions: number;
pageviews: number;
revenue?: number;
}>(selectColumns)
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('name', '=', 'screen_view')
@@ -563,57 +580,12 @@ export class OverviewService {
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['origin', 'path'])
.orderBy('count', 'DESC')
.limit(limit)
.offset((cursor - 1) * limit);
const bounceStatsQuery = clix(this.client, timezone)
.select([
'entry_path',
'entry_origin',
'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate',
])
.from(TABLE_NAMES.sessions, true)
.where('sign', '=', 1)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.groupBy(['entry_path', 'entry_origin']);
pageStatsQuery.rawWhere(this.getRawWhereClause('events', filters));
bounceStatsQuery.rawWhere(this.getRawWhereClause('sessions', filters));
const mainQuery = clix(this.client, timezone)
.with('page_stats', pageStatsQuery)
.with('bounce_stats', bounceStatsQuery)
.select<{
title: string;
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
revenue: number;
}>([
'p.title',
'p.origin',
'p.path',
'p.avg_duration',
'p.count as sessions',
'b.bounce_rate',
])
.from('page_stats p', false)
.leftJoin(
'bounce_stats b',
'p.path = b.entry_path AND p.origin = b.entry_origin',
)
.orderBy('sessions', 'DESC')
.limit(limit);
.limit(MAX_RECORDS_LIMIT);
return mainQuery.execute();
return query.execute();
}
async getTopEntryExit({
@@ -622,28 +594,27 @@ export class OverviewService {
startDate,
endDate,
mode,
cursor = 1,
limit = 10,
timezone,
}: IGetTopEntryExitInput) {
const offset = (cursor - 1) * limit;
const selectColumns: (string | null | undefined | false)[] = [
`${mode}_origin AS origin`,
`${mode}_path AS path`,
'sum(sign) as sessions',
'sum(sign * screen_view_count) as pageviews',
];
if (INCLUDE_REVENUE) {
selectColumns.push('sum(revenue * sign) as revenue');
}
const query = clix(this.client, timezone)
.select<{
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
revenue: number;
}>([
`${mode}_origin AS origin`,
`${mode}_path AS path`,
'round(avg(duration * sign)/1000, 2) as avg_duration',
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate',
'sum(sign) as sessions',
'sum(revenue * sign) as revenue',
])
pageviews: number;
revenue?: number;
}>(selectColumns)
.from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
@@ -653,8 +624,7 @@ export class OverviewService {
.groupBy([`${mode}_origin`, `${mode}_path`])
.having('sum(sign)', '>', 0)
.orderBy('sessions', 'DESC')
.limit(limit)
.offset(offset);
.limit(MAX_RECORDS_LIMIT);
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
projectId,
@@ -697,29 +667,29 @@ export class OverviewService {
startDate,
endDate,
column,
cursor = 1,
limit = 10,
timezone,
}: IGetTopGenericInput) {
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
const offset = (cursor - 1) * limit;
const selectColumns: (string | null | undefined | false)[] = [
prefixColumn && `${prefixColumn} as prefix`,
`nullIf(${column}, '') as name`,
'sum(sign) as sessions',
'sum(sign * screen_view_count) as pageviews',
];
if (INCLUDE_REVENUE) {
selectColumns.push('sum(revenue * sign) as revenue');
}
const query = clix(this.client, timezone)
.select<{
prefix?: string;
name: string;
sessions: number;
bounce_rate: number;
avg_session_duration: number;
revenue: number;
}>([
prefixColumn && `${prefixColumn} as prefix`,
`nullIf(${column}, '') as name`,
'sum(sign) as sessions',
'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) AS bounce_rate',
'round(avgIf(duration, duration > 0 AND sign > 0), 2)/1000 AS avg_session_duration',
'sum(revenue * sign) as revenue',
])
pageviews: number;
revenue?: number;
}>(selectColumns)
.from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
@@ -729,8 +699,7 @@ export class OverviewService {
.groupBy([prefixColumn, column].filter(Boolean))
.having('sum(sign)', '>', 0)
.orderBy('sessions', 'DESC')
.limit(limit)
.offset(offset);
.limit(MAX_RECORDS_LIMIT);
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
projectId,
@@ -743,6 +712,177 @@ export class OverviewService {
return mainQuery.execute();
}
async getTopGenericSeries({
projectId,
filters,
startDate,
endDate,
column,
interval,
timezone,
}: IGetTopGenericSeriesInput): Promise<{
items: Array<{
name: string;
prefix?: string;
data: Array<{
date: string;
sessions: number;
pageviews: number;
revenue?: number;
}>;
total: { sessions: number; pageviews: number; revenue?: number };
}>;
}> {
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
const TOP_LIMIT = 15;
const fillConfig = this.getFillConfig(interval, startDate, endDate);
// Step 1: Get top 15 items
const selectColumns: (string | null | undefined | false)[] = [
prefixColumn && `${prefixColumn} as prefix`,
`nullIf(${column}, '') as name`,
'sum(sign) as sessions',
'sum(sign * screen_view_count) as pageviews',
];
if (INCLUDE_REVENUE) {
selectColumns.push('sum(revenue * sign) as revenue');
}
const topItemsQuery = clix(this.client, timezone)
.select<{
prefix?: string;
name: string;
sessions: number;
pageviews: number;
revenue?: number;
}>(selectColumns)
.from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.groupBy([prefixColumn, column].filter(Boolean))
.having('sum(sign)', '>', 0)
.orderBy('sessions', 'DESC')
.limit(TOP_LIMIT);
const mainTopItemsQuery = this.withDistinctSessionsIfNeeded(topItemsQuery, {
projectId,
filters,
startDate,
endDate,
timezone,
});
const topItems = await mainTopItemsQuery.execute();
if (topItems.length === 0) {
return { items: [] };
}
// Step 2: Build time-series query for each top item
const where = this.getRawWhereClause('sessions', filters);
const timeSeriesSelectColumns: (string | null | undefined | false)[] = [
`${clix.toStartOf('created_at', interval as any, timezone)} AS date`,
prefixColumn && `${prefixColumn} as prefix`,
`nullIf(${column}, '') as name`,
'sum(sign) as sessions',
'sum(sign * screen_view_count) as pageviews',
];
if (INCLUDE_REVENUE) {
timeSeriesSelectColumns.push('sum(revenue * sign) as revenue');
}
const timeSeriesQuery = clix(this.client, timezone)
.select<{
date: string;
prefix?: string;
name: string;
sessions: number;
pageviews: number;
revenue?: number;
}>(timeSeriesSelectColumns)
.from(TABLE_NAMES.sessions, true)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.rawWhere(where)
.groupBy(['date', prefixColumn, column].filter(Boolean))
.having('sum(sign)', '>', 0)
.orderBy('date', 'ASC')
.fill(fillConfig.from, fillConfig.to, fillConfig.step)
.transform({
date: (item) => new Date(item.date).toISOString(),
});
const mainTimeSeriesQuery = this.withDistinctSessionsIfNeeded(
timeSeriesQuery,
{
projectId,
filters,
startDate,
endDate,
timezone,
},
);
const timeSeriesData = await mainTimeSeriesQuery.execute();
// Step 3: Group time-series data by item and calculate totals
const itemsMap = new Map<
string,
{
name: string;
prefix?: string;
data: Array<{
date: string;
sessions: number;
pageviews: number;
revenue?: number;
}>;
total: { sessions: number; pageviews: number; revenue?: number };
}
>();
// Initialize items from topItems
for (const item of topItems) {
const key = `${item.prefix || ''}:${item.name}`;
itemsMap.set(key, {
name: item.name,
prefix: item.prefix,
data: [],
total: {
sessions: item.sessions,
pageviews: item.pageviews,
revenue: item.revenue ?? 0,
},
});
}
// Populate time-series data
for (const row of timeSeriesData) {
const key = `${row.prefix || ''}:${row.name}`;
const item = itemsMap.get(key);
if (item) {
item.data.push({
date: row.date,
sessions: row.sessions,
pageviews: row.pageviews,
revenue: row.revenue,
});
}
}
return {
items: Array.from(itemsMap.values()),
};
}
async getUserJourney({
projectId,
filters,

View File

@@ -0,0 +1,96 @@
import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder';
export interface IGetTopPagesInput {
projectId: string;
startDate: string;
endDate: string;
timezone: string;
search?: string;
}
export interface ITopPage {
origin: string;
path: string;
title: string;
sessions: number;
pageviews: number;
avg_duration: number;
bounce_rate: number;
}
export class PagesService {
constructor(private client: typeof ch) {}
async getTopPages({
projectId,
startDate,
endDate,
timezone,
search,
}: IGetTopPagesInput): Promise<ITopPage[]> {
// CTE: Get titles from the last 30 days for faster retrieval
const titlesCte = clix(this.client, timezone)
.select([
'concat(origin, path) as page_key',
"anyLast(properties['__title']) as title",
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('name', '=', 'screen_view')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'))
.groupBy(['origin', 'path']);
// Pre-filtered sessions subquery for better performance
const sessionsSubquery = clix(this.client, timezone)
.select(['id', 'project_id', 'is_bounce'])
.from(TABLE_NAMES.sessions, true) // FINAL
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.where('sign', '=', 1);
// Main query: aggregate events and calculate bounce rate from pre-filtered sessions
const query = clix(this.client, timezone)
.with('page_titles', titlesCte)
.select<ITopPage>([
'e.origin as origin',
'e.path as path',
"coalesce(pt.title, '') as title",
'uniq(e.session_id) as sessions',
'count() as pageviews',
'round(avg(e.duration) / 1000 / 60, 2) as avg_duration',
`round(
(uniqIf(e.session_id, s.is_bounce = 1) * 100.0) /
nullIf(uniq(e.session_id), 0),
2
) as bounce_rate`,
])
.from(`${TABLE_NAMES.events} e`, false)
.leftJoin(
sessionsSubquery,
'e.session_id = s.id AND e.project_id = s.project_id',
's',
)
.leftJoin('page_titles pt', 'concat(e.origin, e.path) = pt.page_key')
.where('e.project_id', '=', projectId)
.where('e.name', '=', 'screen_view')
.where('e.path', '!=', '')
.where('e.created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.when(!!search, (q) => {
q.where('e.path', 'LIKE', `%${search}%`);
})
.groupBy(['e.origin', 'e.path', 'pt.title'])
.orderBy('sessions', 'DESC')
.limit(1000);
return query.execute();
}
}
export const pagesService = new PagesService(ch);

View File

@@ -35,7 +35,7 @@ import {
} from '@openpanel/validation';
import { round } from '@openpanel/common';
import { ChartEngine } from '@openpanel/db';
import { AggregateChartEngine, ChartEngine } from '@openpanel/db';
import {
differenceInDays,
differenceInMonths,
@@ -414,6 +414,42 @@ export const chartRouter = createTRPCRouter({
// Use new chart engine
return ChartEngine.execute(input);
}),
aggregate: publicProcedure
.input(zChartInput)
.query(async ({ input, ctx }) => {
if (ctx.session.userId) {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
} else {
const share = await db.shareOverview.findFirst({
where: {
projectId: input.projectId,
},
});
if (!share) {
throw TRPCAccessError('You do not have access to this project');
}
}
// Use aggregate chart engine (optimized for bar/pie charts)
return AggregateChartEngine.execute(input);
}),
cohort: protectedProcedure
.input(
z.object({

View File

@@ -15,7 +15,7 @@ import {
getEventList,
getEventMetasCached,
getSettingsForProject,
overviewService,
pagesService,
sessionService,
} from '@openpanel/db';
import {
@@ -324,28 +324,17 @@ export const eventRouter = createTRPCRouter({
search: z.string().optional(),
range: zRange,
interval: zTimeInterval,
filters: z.array(zChartEventFilter).default([]),
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
if (input.search) {
input.filters.push({
id: 'path',
name: 'path',
value: [input.search],
operator: 'contains',
});
}
return overviewService.getTopPages({
return pagesService.getTopPages({
projectId: input.projectId,
filters: input.filters,
startDate,
endDate,
cursor: input.cursor || 1,
limit: input.take,
timezone,
search: input.search,
});
}),

View File

@@ -10,6 +10,7 @@ import {
overviewService,
zGetMetricsInput,
zGetTopGenericInput,
zGetTopGenericSeriesInput,
zGetTopPagesInput,
zGetUserJourneyInput,
} from '@openpanel/db';
@@ -305,6 +306,26 @@ export const overviewRouter = createTRPCRouter({
return current;
}),
topGenericSeries: publicProcedure
.input(
zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({
startDate: z.string().nullish(),
endDate: z.string().nullish(),
range: zRange,
}),
)
.use(cacher)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { current } = await getCurrentAndPrevious(
{ ...input, timezone },
false,
timezone,
)(overviewService.getTopGenericSeries.bind(overviewService));
return current;
}),
userJourney: publicProcedure
.input(
zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({