fix: dashboard improvements and query speed improvements
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
65
apps/start/src/components/delta-chip.tsx
Normal file
65
apps/start/src/components/delta-chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
82
apps/start/src/components/organization/feedback-prompt.tsx
Normal file
82
apps/start/src/components/organization/feedback-prompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/start/src/components/organization/prompt-card.tsx
Normal file
66
apps/start/src/components/organization/prompt-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
303
apps/start/src/components/overview/overview-line-chart.tsx
Normal file
303
apps/start/src/components/overview/overview-line-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
264
apps/start/src/components/overview/overview-list-modal.tsx
Normal file
264
apps/start/src/components/overview/overview-list-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
54
apps/start/src/components/overview/overview-view-toggle.tsx
Normal file
54
apps/start/src/components/overview/overview-view-toggle.tsx
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
// );
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
packages/db/src/services/pages.service.ts
Normal file
96
packages/db/src/services/pages.service.ts
Normal 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);
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user