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
|
// Check if URL is an ICO file
|
||||||
function isIcoFile(url: string, contentType?: string): boolean {
|
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 {
|
function isSvgFile(url: string, contentType?: string): boolean {
|
||||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||||
@@ -239,7 +243,9 @@ export async function getFavicon(
|
|||||||
try {
|
try {
|
||||||
const url = validateUrl(request.query.url);
|
const url = validateUrl(request.query.url);
|
||||||
if (!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());
|
const cacheKey = createCacheKey(url.toString());
|
||||||
@@ -260,21 +266,65 @@ export async function getFavicon(
|
|||||||
} else {
|
} else {
|
||||||
// For website URLs, extract favicon from HTML
|
// For website URLs, extract favicon from HTML
|
||||||
const meta = await parseUrlMeta(url.toString());
|
const meta = await parseUrlMeta(url.toString());
|
||||||
|
logger.info('parseUrlMeta result', {
|
||||||
|
url: url.toString(),
|
||||||
|
favicon: meta?.favicon,
|
||||||
|
});
|
||||||
if (meta?.favicon) {
|
if (meta?.favicon) {
|
||||||
imageUrl = new URL(meta.favicon);
|
imageUrl = new URL(meta.favicon);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to Google's favicon service
|
// Try standard favicon location first
|
||||||
const { hostname } = url;
|
const { origin } = url;
|
||||||
imageUrl = new URL(
|
imageUrl = new URL(`${origin}/favicon.ico`);
|
||||||
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Fetching favicon', {
|
||||||
|
originalUrl: url.toString(),
|
||||||
|
imageUrl: imageUrl.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch the image
|
// Fetch the image
|
||||||
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
let { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||||
|
|
||||||
if (status !== 200 || buffer.length === 0) {
|
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());
|
return reply.send(createFallbackImage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +335,31 @@ export async function getFavicon(
|
|||||||
contentType,
|
contentType,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.info('Favicon processing result', {
|
||||||
|
originalUrl: url.toString(),
|
||||||
|
originalBufferLength: buffer.length,
|
||||||
|
processedBufferLength: processedBuffer.length,
|
||||||
|
});
|
||||||
|
|
||||||
// Determine the correct content type for caching and response
|
// Determine the correct content type for caching and response
|
||||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
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
|
// Cache the result with correct content type
|
||||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import urlMetadata from 'url-metadata';
|
import urlMetadata from 'url-metadata';
|
||||||
|
|
||||||
function fallbackFavicon(url: string) {
|
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']) {
|
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 { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { DeltaChip } from '../delta-chip';
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
|
|
||||||
@@ -188,42 +189,13 @@ export function InsightCard({
|
|||||||
|
|
||||||
{/* Delta chip */}
|
{/* Delta chip */}
|
||||||
<DeltaChip
|
<DeltaChip
|
||||||
isIncrease={isIncrease}
|
variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'}
|
||||||
isDecrease={isDecrease}
|
size="sm"
|
||||||
deltaText={deltaText}
|
|
||||||
/>
|
|
||||||
</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 ? (
|
{deltaText}
|
||||||
<ArrowUp size={16} className="shrink-0" />
|
</DeltaChip>
|
||||||
) : isDecrease ? (
|
</div>
|
||||||
<ArrowDown size={16} className="shrink-0" />
|
</div>
|
||||||
) : null}
|
|
||||||
<span>{deltaText}</span>
|
|
||||||
</div>
|
</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 { useAppContext } from '@/hooks/use-app-context';
|
||||||
import { useCookieStore } from '@/hooks/use-cookie-store';
|
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
|
||||||
import {
|
import {
|
||||||
AwardIcon,
|
AwardIcon,
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
MessageCircleIcon,
|
MessageCircleIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SparklesIcon,
|
SparklesIcon,
|
||||||
XIcon,
|
|
||||||
ZapIcon,
|
ZapIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -78,38 +77,14 @@ export default function SupporterPrompt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<PromptCard
|
||||||
{!supporterPromptClosed && (
|
title="Support OpenPanel"
|
||||||
<motion.div
|
subtitle="Help us build the future of open analytics"
|
||||||
initial={{ opacity: 0, x: 100, scale: 0.95 }}
|
onClose={() => setSupporterPromptClosed(true)}
|
||||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
show={!supporterPromptClosed}
|
||||||
exit={{ opacity: 0, x: 100, scale: 0.95 }}
|
gradientColor="rgb(16 185 129)"
|
||||||
transition={{
|
|
||||||
type: 'spring',
|
|
||||||
stiffness: 300,
|
|
||||||
damping: 30,
|
|
||||||
}}
|
|
||||||
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
|
|
||||||
>
|
>
|
||||||
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
|
<div className="col gap-3 px-6">
|
||||||
<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) => (
|
{PERKS.map((perk) => (
|
||||||
<PerkPoint
|
<PerkPoint
|
||||||
key={perk.text}
|
key={perk.text}
|
||||||
@@ -120,7 +95,7 @@ export default function SupporterPrompt() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2">
|
<div className="px-6">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className="w-full"
|
className="w-full"
|
||||||
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
|
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
|
||||||
@@ -139,9 +114,6 @@ export default function SupporterPrompt() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PromptCard>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
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 { bind } from 'bind-event-listener';
|
||||||
import throttle from 'lodash.throttle';
|
import throttle from 'lodash.throttle';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
|
||||||
Customized,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarShapeBlue } from '../charts/common-bar';
|
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
interface OverviewLiveHistogramProps {
|
interface OverviewLiveHistogramProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -86,10 +81,8 @@ export function OverviewLiveHistogram({
|
|||||||
<YAxis hide domain={[0, maxDomain]} />
|
<YAxis hide domain={[0, maxDomain]} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="sessionCount"
|
dataKey="sessionCount"
|
||||||
fill="rgba(59, 121, 255, 0.2)"
|
className="fill-chart-0"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
shape={BarShapeBlue}
|
|
||||||
activeBar={BarShapeBlue}
|
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
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 { formatDate, timeAgo } from '@/utils/date';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
@@ -144,51 +144,33 @@ export function OverviewMetricCard({
|
|||||||
<div className={cn('group relative p-4')}>
|
<div className={cn('group relative p-4')}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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>
|
<AutoSizer style={{ height: 20 }}>
|
||||||
{({ width, height }) => (
|
{({ width }) => (
|
||||||
<AreaChart
|
<BarChart
|
||||||
width={width}
|
width={width}
|
||||||
height={height / 4}
|
height={20}
|
||||||
data={data}
|
data={data}
|
||||||
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
}}
|
||||||
onMouseMove={(event) => {
|
onMouseMove={(event) => {
|
||||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||||
}}
|
}}
|
||||||
|
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<defs>
|
<Tooltip content={() => null} cursor={false} />
|
||||||
<linearGradient
|
<Bar
|
||||||
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
|
|
||||||
dataKey={'current'}
|
dataKey={'current'}
|
||||||
type="step"
|
type="step"
|
||||||
fill={`url(#colorUv${id})`}
|
fill={graphColors}
|
||||||
fillOpacity={1}
|
fillOpacity={1}
|
||||||
stroke={graphColors}
|
strokeWidth={0}
|
||||||
strokeWidth={1}
|
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</BarChart>
|
||||||
)}
|
)}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,27 +207,25 @@ export function OverviewMetricCardNumber({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
<div className={cn('min-w-0 col gap-2 items-start', className)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
<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]">
|
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<Skeleton className="h-6 w-16" />
|
<Skeleton className="h-6 w-16" />
|
||||||
<Skeleton className="h-6 w-12" />
|
<Skeleton className="h-6 w-12" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-end justify-between gap-4">
|
|
||||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
|
||||||
{enhancer}
|
{enhancer}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { cn } from '@/utils/cn';
|
import { useMemo, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
@@ -13,7 +11,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
|
|||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
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 {
|
import {
|
||||||
OverviewWidgetTableGeneric,
|
OverviewWidgetTableGeneric,
|
||||||
OverviewWidgetTableLoading,
|
OverviewWidgetTableLoading,
|
||||||
@@ -31,6 +34,7 @@ export default function OverviewTopDevices({
|
|||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
const [chartType] = useState<IChartType>('bar');
|
const [chartType] = useState<IChartType>('bar');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||||
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
|
||||||
device: {
|
device: {
|
||||||
@@ -316,6 +320,7 @@ export default function OverviewTopDevices({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const [view] = useOverviewView();
|
||||||
|
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.queryOptions({
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
<WidgetHead>
|
<WidgetHeadSearchable
|
||||||
<div className="title">{widget.title}</div>
|
tabs={tabs}
|
||||||
|
activeTab={widget.key}
|
||||||
<WidgetButtons>
|
onTabChange={setWidget}
|
||||||
{widgets.map((w) => (
|
searchValue={searchQuery}
|
||||||
<button
|
onSearchChange={setSearchQuery}
|
||||||
type="button"
|
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||||
key={w.key}
|
className="border-b-0 pb-2"
|
||||||
onClick={() => setWidget(w.key)}
|
/>
|
||||||
className={cn(w.key === widget.key && 'active')}
|
|
||||||
>
|
|
||||||
{w.btn}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</WidgetButtons>
|
|
||||||
</WidgetHead>
|
|
||||||
<WidgetBody className="p-0">
|
<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 />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
data={query.data ?? []}
|
data={filteredData}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
render(item) {
|
render(item) {
|
||||||
@@ -384,7 +425,8 @@ export default function OverviewTopDevices({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
<div className="flex-1" />
|
||||||
|
<OverviewViewToggle />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
import { ReportChart } from '@/components/report-chart';
|
|
||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { cn } from '@/utils/cn';
|
import { useMemo, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
import {
|
||||||
|
type EventTableItem,
|
||||||
|
OverviewWidgetTableEvents,
|
||||||
|
OverviewWidgetTableLoading,
|
||||||
|
} from './overview-widget-table';
|
||||||
import { useOverviewOptions } from './useOverviewOptions';
|
import { useOverviewOptions } from './useOverviewOptions';
|
||||||
import { useOverviewWidget } from './useOverviewWidget';
|
import { useOverviewWidgetV2 } from './useOverviewWidget';
|
||||||
|
|
||||||
export interface OverviewTopEventsProps {
|
export interface OverviewTopEventsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewTopEvents({
|
export default function OverviewTopEvents({
|
||||||
projectId,
|
projectId,
|
||||||
}: OverviewTopEventsProps) {
|
}: OverviewTopEventsProps) {
|
||||||
const { interval, range, previous, startDate, endDate } =
|
const { interval, range, previous, startDate, endDate } =
|
||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { data: conversions } = useQuery(
|
const { data: conversions } = useQuery(
|
||||||
trpc.event.conversionNames.queryOptions({ projectId }),
|
trpc.event.conversionNames.queryOptions({ projectId }),
|
||||||
);
|
);
|
||||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
|
|
||||||
|
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
|
||||||
your: {
|
your: {
|
||||||
title: 'Top events',
|
title: 'Events',
|
||||||
btn: 'Your',
|
btn: 'Events',
|
||||||
chart: {
|
meta: {
|
||||||
report: {
|
|
||||||
limit: 10,
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'event',
|
|
||||||
segment: 'event',
|
|
||||||
filters: [
|
filters: [
|
||||||
...filters,
|
|
||||||
{
|
{
|
||||||
id: 'ex_session',
|
id: 'ex_session',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -50,76 +44,15 @@ export default function OverviewTopEvents({
|
|||||||
value: ['session_start', 'session_end', 'screen_view'],
|
value: ['session_start', 'session_end', 'screen_view'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
id: 'A',
|
eventName: '*',
|
||||||
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',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
conversions: {
|
conversions: {
|
||||||
title: 'Conversions',
|
title: 'Conversions',
|
||||||
btn: 'Conversions',
|
btn: 'Conversions',
|
||||||
hide: !conversions || conversions.length === 0,
|
hide: !conversions || conversions.length === 0,
|
||||||
chart: {
|
meta: {
|
||||||
report: {
|
|
||||||
limit: 10,
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
type: 'event',
|
|
||||||
segment: 'event',
|
|
||||||
filters: [
|
filters: [
|
||||||
...filters,
|
|
||||||
{
|
{
|
||||||
id: 'conversion',
|
id: 'conversion',
|
||||||
name: 'name',
|
name: 'name',
|
||||||
@@ -127,99 +60,115 @@ export default function OverviewTopEvents({
|
|||||||
value: conversions?.map((c) => c.name) ?? [],
|
value: conversions?.map((c) => c.name) ?? [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
id: 'A',
|
eventName: '*',
|
||||||
name: '*',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
breakdowns: [
|
|
||||||
{
|
|
||||||
id: 'A',
|
|
||||||
name: 'name',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
chartType,
|
|
||||||
lineType: 'monotone',
|
|
||||||
interval: interval,
|
|
||||||
name: 'Conversions',
|
|
||||||
range: range,
|
|
||||||
previous: previous,
|
|
||||||
metric: 'sum',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
link_out: {
|
link_out: {
|
||||||
title: 'Link out',
|
title: 'Link out',
|
||||||
btn: 'Link out',
|
btn: 'Link out',
|
||||||
chart: {
|
meta: {
|
||||||
report: {
|
filters: [],
|
||||||
limit: 10,
|
eventName: 'link_out',
|
||||||
|
breakdownProperty: 'properties.href',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const report: IChartInput = useMemo(
|
||||||
|
() => ({
|
||||||
|
limit: 1000,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'event',
|
type: 'event' as const,
|
||||||
segment: 'event',
|
segment: 'event' as const,
|
||||||
|
filters: [...filters, ...(widget.meta?.filters ?? [])],
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: 'link_out',
|
name: widget.meta?.eventName ?? '*',
|
||||||
filters: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
breakdowns: [
|
breakdowns: [
|
||||||
{
|
{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: 'properties.href',
|
name: widget.meta?.breakdownProperty ?? 'name',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
chartType,
|
chartType: 'bar' as const,
|
||||||
lineType: 'monotone',
|
lineType: 'monotone' as const,
|
||||||
interval: interval,
|
interval,
|
||||||
name: 'Link out',
|
name: widget.title,
|
||||||
range: range,
|
range,
|
||||||
previous: previous,
|
previous,
|
||||||
metric: 'sum',
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
<WidgetHead>
|
<WidgetHeadSearchable
|
||||||
<div className="title">{widget.title}</div>
|
tabs={tabs}
|
||||||
<WidgetButtons>
|
activeTab={widget.key}
|
||||||
{widgets
|
onTabChange={setWidget}
|
||||||
.filter((item) => item.hide !== true)
|
searchValue={searchQuery}
|
||||||
.map((w) => (
|
onSearchChange={setSearchQuery}
|
||||||
<button
|
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||||
type="button"
|
className="border-b-0 pb-2"
|
||||||
key={w.key}
|
/>
|
||||||
onClick={() => setWidget(w.key)}
|
<WidgetBody className="p-0">
|
||||||
className={cn(w.key === widget.key && 'active')}
|
{query.isLoading ? (
|
||||||
>
|
<OverviewWidgetTableLoading />
|
||||||
{w.btn}
|
) : (
|
||||||
</button>
|
<OverviewWidgetTableEvents
|
||||||
))}
|
data={filteredData}
|
||||||
</WidgetButtons>
|
onItemClick={(name) => {
|
||||||
</WidgetHead>
|
if (widget.meta?.breakdownProperty) {
|
||||||
<WidgetBody className="p-3">
|
setFilter(widget.meta.breakdownProperty, name);
|
||||||
<ReportChart
|
} else {
|
||||||
options={{
|
setFilter('name', name);
|
||||||
hideID: true,
|
}
|
||||||
columns: ['Event'],
|
|
||||||
renderSerieName(names) {
|
|
||||||
return names[1];
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
report={{
|
|
||||||
...widget.chart.report,
|
|
||||||
previous: false,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
<WidgetFooter>
|
<WidgetFooter>
|
||||||
<OverviewChartToggle {...{ chartType, setChartType }} />
|
<div className="flex-1" />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</Widget>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
|
||||||
import type { IGetTopGenericInput } from '@openpanel/db';
|
import type { IGetTopGenericInput } from '@openpanel/db';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { Button } from '../ui/button';
|
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
|
||||||
import {
|
import {
|
||||||
OVERVIEW_COLUMNS_NAME,
|
OVERVIEW_COLUMNS_NAME,
|
||||||
OVERVIEW_COLUMNS_NAME_PLURAL,
|
OVERVIEW_COLUMNS_NAME_PLURAL,
|
||||||
} from './overview-constants';
|
} from './overview-constants';
|
||||||
import { OverviewWidgetTableGeneric } from './overview-widget-table';
|
import { OverviewListModal } from './overview-list-modal';
|
||||||
import { useOverviewOptions } from './useOverviewOptions';
|
import { useOverviewOptions } from './useOverviewOptions';
|
||||||
|
|
||||||
interface OverviewTopGenericModalProps {
|
interface OverviewTopGenericModalProps {
|
||||||
@@ -24,83 +21,55 @@ export default function OverviewTopGenericModal({
|
|||||||
projectId,
|
projectId,
|
||||||
column,
|
column,
|
||||||
}: OverviewTopGenericModalProps) {
|
}: OverviewTopGenericModalProps) {
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [_filters, setFilter] = useEventQueryFilters();
|
||||||
const { startDate, endDate, range } = useOverviewOptions();
|
const { startDate, endDate, range } = useOverviewOptions();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const query = useInfiniteQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.infiniteQueryOptions(
|
trpc.overview.topGeneric.queryOptions({
|
||||||
{
|
|
||||||
projectId,
|
projectId,
|
||||||
filters,
|
filters: _filters,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
limit: 50,
|
|
||||||
column,
|
column,
|
||||||
},
|
}),
|
||||||
{
|
|
||||||
getNextPageParam: (lastPage, pages) => {
|
|
||||||
if (lastPage.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pages.length + 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = query.data?.pages.flat() || [];
|
|
||||||
const isEmpty = !query.hasNextPage && !query.isFetching;
|
|
||||||
|
|
||||||
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
|
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
|
||||||
const columnName = OVERVIEW_COLUMNS_NAME[column];
|
const columnName = OVERVIEW_COLUMNS_NAME[column];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent>
|
<OverviewListModal
|
||||||
<ModalHeader title={`Top ${columnNamePlural}`} />
|
title={`Top ${columnNamePlural}`}
|
||||||
<ScrollArea className="-mx-6 px-2">
|
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
|
||||||
<OverviewWidgetTableGeneric
|
data={query.data ?? []}
|
||||||
data={data}
|
keyExtractor={(item) => (item.prefix ?? '') + item.name}
|
||||||
column={{
|
searchFilter={(item, query) =>
|
||||||
name: columnName,
|
item.name?.toLowerCase().includes(query) ||
|
||||||
render(item) {
|
item.prefix?.toLowerCase().includes(query) ||
|
||||||
return (
|
false
|
||||||
<div className="row items-center gap-2 min-w-0 relative">
|
}
|
||||||
|
columnName={columnName}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<SerieIcon name={item.prefix || item.name} />
|
<SerieIcon name={item.prefix || item.name} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="truncate"
|
className="truncate hover:underline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setFilter(column, item.name);
|
setFilter(column, item.name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.prefix && (
|
{item.prefix && (
|
||||||
<span className="mr-1 row inline-flex items-center gap-1">
|
<span className="mr-1 inline-flex items-center gap-1">
|
||||||
<span>{item.prefix}</span>
|
<span>{item.prefix}</span>
|
||||||
<span>
|
|
||||||
<ChevronRightIcon className="size-3" />
|
<ChevronRightIcon className="size-3" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
{item.name || 'Not set'}
|
{item.name || 'Not set'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="row center-center p-4 pb-0">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => query.fetchNextPage()}
|
|
||||||
disabled={isEmpty}
|
|
||||||
>
|
|
||||||
Load more
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { cn } from '@/utils/cn';
|
import { useMemo, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import type { IChartType } from '@openpanel/validation';
|
import type { IChartType } from '@openpanel/validation';
|
||||||
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { countries } from '@/translations/countries';
|
import { countries } from '@/translations/countries';
|
||||||
@@ -16,7 +14,16 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
|
|||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
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 {
|
import {
|
||||||
OverviewWidgetTableGeneric,
|
OverviewWidgetTableGeneric,
|
||||||
OverviewWidgetTableLoading,
|
OverviewWidgetTableLoading,
|
||||||
@@ -32,6 +39,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
useOverviewOptions();
|
useOverviewOptions();
|
||||||
const [chartType, setChartType] = useState<IChartType>('bar');
|
const [chartType, setChartType] = useState<IChartType>('bar');
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
const isPageFilter = filters.find((filter) => filter.name === 'path');
|
||||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
|
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
|
||||||
country: {
|
country: {
|
||||||
@@ -48,8 +56,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const number = useNumber();
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const [view] = useOverviewView();
|
||||||
|
|
||||||
const query = useQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topGeneric.queryOptions({
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
<WidgetHead>
|
<WidgetHeadSearchable
|
||||||
<div className="title">{widget.title}</div>
|
tabs={tabs}
|
||||||
|
activeTab={widget.key}
|
||||||
<WidgetButtons>
|
onTabChange={setWidget}
|
||||||
{widgets.map((w) => (
|
searchValue={searchQuery}
|
||||||
<button
|
onSearchChange={setSearchQuery}
|
||||||
type="button"
|
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||||
key={w.key}
|
className="border-b-0 pb-2"
|
||||||
onClick={() => setWidget(w.key)}
|
/>
|
||||||
className={cn(w.key === widget.key && 'active')}
|
|
||||||
>
|
|
||||||
{w.btn}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</WidgetButtons>
|
|
||||||
</WidgetHead>
|
|
||||||
<WidgetBody className="p-0">
|
<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 />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
data={query.data ?? []}
|
data={filteredData}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
render(item) {
|
render(item) {
|
||||||
@@ -139,8 +190,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
<div className="flex-1" />
|
||||||
<span className="text-sm text-muted-foreground pr-2">
|
<OverviewViewToggle />
|
||||||
|
<span className="text-sm text-muted-foreground pr-2 ml-2">
|
||||||
Geo data provided by{' '}
|
Geo data provided by{' '}
|
||||||
<a
|
<a
|
||||||
href="https://ipdata.co"
|
href="https://ipdata.co"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
import { Button } from '../ui/button';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
import { OverviewWidgetTablePages } from './overview-widget-table';
|
import { OverviewListModal } from './overview-list-modal';
|
||||||
import { useOverviewOptions } from './useOverviewOptions';
|
import { useOverviewOptions } from './useOverviewOptions';
|
||||||
|
|
||||||
interface OverviewTopPagesProps {
|
interface OverviewTopPagesProps {
|
||||||
@@ -18,44 +18,54 @@ export default function OverviewTopPagesModal({
|
|||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
const { startDate, endDate, range } = useOverviewOptions();
|
const { startDate, endDate, range } = useOverviewOptions();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const query = useInfiniteQuery(
|
const query = useQuery(
|
||||||
trpc.overview.topPages.infiniteQueryOptions(
|
trpc.overview.topPages.queryOptions({
|
||||||
{
|
|
||||||
projectId,
|
projectId,
|
||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
mode: 'page',
|
mode: 'page',
|
||||||
range,
|
range,
|
||||||
limit: 50,
|
}),
|
||||||
},
|
|
||||||
{
|
|
||||||
getNextPageParam: (_, pages) => pages.length + 1,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = query.data?.pages.flat();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent>
|
<OverviewListModal
|
||||||
<ModalHeader title="Top Pages" />
|
title="Top Pages"
|
||||||
<ScrollArea className="-mx-6 px-2">
|
searchPlaceholder="Search pages..."
|
||||||
<OverviewWidgetTablePages
|
data={query.data ?? []}
|
||||||
data={data ?? []}
|
keyExtractor={(item) => item.path + item.origin}
|
||||||
lastColumnName={'Sessions'}
|
searchFilter={(item, query) =>
|
||||||
/>
|
item.path.toLowerCase().includes(query) ||
|
||||||
<div className="row center-center p-4 pb-0">
|
item.origin.toLowerCase().includes(query)
|
||||||
<Button
|
}
|
||||||
variant="outline"
|
columnName="Path"
|
||||||
className="w-full"
|
renderItem={(item) => (
|
||||||
onClick={() => query.fetchNextPage()}
|
<Tooltiper asChild content={item.origin + item.path} side="left">
|
||||||
loading={query.isFetching}
|
<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);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Load more
|
{item.path || <span className="opacity-40">Not set</span>}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</ScrollArea>
|
</Tooltiper>
|
||||||
</ModalContent>
|
)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { Globe2Icon } from 'lucide-react';
|
import { Globe2Icon } from 'lucide-react';
|
||||||
import { parseAsBoolean, useQueryState } from 'nuqs';
|
import { parseAsBoolean, useQueryState } from 'nuqs';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
@@ -9,8 +9,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
|
||||||
import {
|
import {
|
||||||
|
OverviewWidgetTableEntries,
|
||||||
OverviewWidgetTableLoading,
|
OverviewWidgetTableLoading,
|
||||||
OverviewWidgetTablePages,
|
OverviewWidgetTablePages,
|
||||||
} from './overview-widget-table';
|
} from './overview-widget-table';
|
||||||
@@ -25,15 +26,11 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
|
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
|
||||||
page: {
|
page: {
|
||||||
title: 'Top pages',
|
title: 'Top pages',
|
||||||
btn: 'Top pages',
|
btn: 'Pages',
|
||||||
meta: {
|
|
||||||
columns: {
|
|
||||||
sessions: 'Sessions',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
entry: {
|
entry: {
|
||||||
title: 'Entry Pages',
|
title: 'Entry Pages',
|
||||||
@@ -53,10 +50,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// bot: {
|
|
||||||
// title: 'Bots',
|
|
||||||
// btn: 'Bots',
|
|
||||||
// },
|
|
||||||
});
|
});
|
||||||
const trpc = useTRPC();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
<WidgetHead>
|
<WidgetHeadSearchable
|
||||||
<div className="title">{widget.title}</div>
|
tabs={tabs}
|
||||||
<WidgetButtons>
|
activeTab={widget.key}
|
||||||
{widgets.map((w) => (
|
onTabChange={setWidget}
|
||||||
<button
|
searchValue={searchQuery}
|
||||||
type="button"
|
onSearchChange={setSearchQuery}
|
||||||
key={w.key}
|
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||||
onClick={() => setWidget(w.key)}
|
className="border-b-0 pb-2"
|
||||||
className={cn(w.key === widget.key && 'active')}
|
/>
|
||||||
>
|
|
||||||
{w.btn}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</WidgetButtons>
|
|
||||||
</WidgetHead>
|
|
||||||
<WidgetBody className="p-0">
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<OverviewWidgetTableLoading />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
|
{widget.meta?.columns.sessions ? (
|
||||||
<OverviewWidgetTablePages
|
<OverviewWidgetTableEntries
|
||||||
data={data ?? []}
|
data={filteredData}
|
||||||
lastColumnName={widget.meta.columns.sessions}
|
lastColumnName={widget.meta.columns.sessions}
|
||||||
showDomain={!!domain}
|
showDomain={!!domain}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewWidgetTablePages
|
||||||
|
data={filteredData}
|
||||||
|
showDomain={!!domain}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
@@ -109,7 +118,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
<OverviewDetailsButton
|
<OverviewDetailsButton
|
||||||
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
|
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
|
||||||
/>
|
/>
|
||||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
variant={'ghost'}
|
variant={'ghost'}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
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 { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
@@ -9,7 +9,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
|
|||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
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 {
|
import {
|
||||||
OverviewWidgetTableGeneric,
|
OverviewWidgetTableGeneric,
|
||||||
OverviewWidgetTableLoading,
|
OverviewWidgetTableLoading,
|
||||||
@@ -23,16 +28,18 @@ interface OverviewTopSourcesProps {
|
|||||||
export default function OverviewTopSources({
|
export default function OverviewTopSources({
|
||||||
projectId,
|
projectId,
|
||||||
}: OverviewTopSourcesProps) {
|
}: OverviewTopSourcesProps) {
|
||||||
const { range, startDate, endDate } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters, setFilter] = useEventQueryFilters();
|
const [filters, setFilter] = useEventQueryFilters();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [view] = useOverviewView();
|
||||||
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
|
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
|
||||||
referrer_name: {
|
referrer_name: {
|
||||||
title: 'Top sources',
|
title: 'Top sources',
|
||||||
btn: 'All',
|
btn: 'Refs',
|
||||||
},
|
},
|
||||||
referrer: {
|
referrer: {
|
||||||
title: 'Top urls',
|
title: 'Top urls',
|
||||||
btn: 'URLs',
|
btn: 'Urls',
|
||||||
},
|
},
|
||||||
referrer_type: {
|
referrer_type: {
|
||||||
title: 'Top types',
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Widget className="col-span-6 md:col-span-3">
|
<Widget className="col-span-6 md:col-span-3">
|
||||||
<WidgetHead>
|
<WidgetHeadSearchable
|
||||||
<div className="title">{widget.title}</div>
|
tabs={tabs}
|
||||||
|
activeTab={widget.key}
|
||||||
<WidgetButtons>
|
onTabChange={setWidget}
|
||||||
{widgets.map((w) => (
|
searchValue={searchQuery}
|
||||||
<button
|
onSearchChange={setSearchQuery}
|
||||||
type="button"
|
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
|
||||||
key={w.key}
|
className="border-b-0 pb-2"
|
||||||
onClick={() => setWidget(w.key)}
|
/>
|
||||||
className={cn(w.key === widget.key && 'active')}
|
|
||||||
>
|
|
||||||
{w.btn}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</WidgetButtons>
|
|
||||||
</WidgetHead>
|
|
||||||
<WidgetBody className="p-0">
|
<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 />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
data={query.data ?? []}
|
data={filteredData}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
render(item) {
|
render(item) {
|
||||||
@@ -137,7 +180,8 @@ export default function OverviewTopSources({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
|
<div className="flex-1" />
|
||||||
|
<OverviewViewToggle />
|
||||||
</WidgetFooter>
|
</WidgetFooter>
|
||||||
</Widget>
|
</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
|
<WidgetTable
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={keyExtractor}
|
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"
|
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||||
eachRow={(item) => {
|
eachRow={(item) => {
|
||||||
return (
|
return (
|
||||||
@@ -109,15 +109,6 @@ export function OverviewWidgetTableLoading({
|
|||||||
render: () => <Skeleton className="h-4 w-1/3" />,
|
render: () => <Skeleton className="h-4 w-1/3" />,
|
||||||
width: 'w-full',
|
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',
|
name: 'Sessions',
|
||||||
render: () => <Skeleton className="h-4 w-[30px]" />,
|
render: () => <Skeleton className="h-4 w-[30px]" />,
|
||||||
@@ -142,27 +133,24 @@ function getPath(path: string, showDomain = false) {
|
|||||||
|
|
||||||
export function OverviewWidgetTablePages({
|
export function OverviewWidgetTablePages({
|
||||||
data,
|
data,
|
||||||
lastColumnName,
|
|
||||||
className,
|
className,
|
||||||
showDomain = false,
|
showDomain = false,
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
lastColumnName: string;
|
|
||||||
data: {
|
data: {
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
avg_duration: number;
|
|
||||||
bounce_rate: number;
|
|
||||||
sessions: number;
|
sessions: number;
|
||||||
revenue: number;
|
pageviews: number;
|
||||||
|
revenue?: number;
|
||||||
}[];
|
}[];
|
||||||
showDomain?: boolean;
|
showDomain?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [_filters, setFilter] = useEventQueryFilters();
|
const [_filters, setFilter] = useEventQueryFilters();
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||||
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
|
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||||
const hasRevenue = data.some((item) => item.revenue > 0);
|
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||||
return (
|
return (
|
||||||
<OverviewWidgetTable
|
<OverviewWidgetTable
|
||||||
className={className}
|
className={className}
|
||||||
@@ -214,20 +202,135 @@ export function OverviewWidgetTablePages({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(hasRevenue
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: 'BR',
|
name: 'Revenue',
|
||||||
width: '60px',
|
width: '100px',
|
||||||
responsive: { priority: 6 }, // Hidden when space is tight
|
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: 'Views',
|
||||||
|
width: '84px',
|
||||||
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
render(item) {
|
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',
|
name: 'Sess.',
|
||||||
width: '75px',
|
width: '84px',
|
||||||
responsive: { priority: 7 }, // Hidden when space is tight
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
render(item) {
|
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
|
...(hasRevenue
|
||||||
@@ -237,17 +340,16 @@ export function OverviewWidgetTablePages({
|
|||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 }, // Always show if possible
|
||||||
render(item: (typeof data)[number]) {
|
render(item: (typeof data)[number]) {
|
||||||
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
|
totalRevenue > 0 ? revenue / totalRevenue : 0;
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 items-center justify-end">
|
<div className="row gap-2 items-center justify-end">
|
||||||
<span
|
<span
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
style={{ color: '#3ba974' }}
|
style={{ color: '#3ba974' }}
|
||||||
>
|
>
|
||||||
{item.revenue > 0
|
{revenue > 0 ? number.currency(revenue / 100) : '-'}
|
||||||
? number.currency(item.revenue / 100)
|
|
||||||
: '-'}
|
|
||||||
</span>
|
</span>
|
||||||
<RevenuePieChart percentage={revenuePercentage} />
|
<RevenuePieChart percentage={revenuePercentage} />
|
||||||
</div>
|
</div>
|
||||||
@@ -373,6 +475,7 @@ export function OverviewWidgetTableGeneric({
|
|||||||
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
const maxSessions = Math.max(...data.map((item) => item.sessions));
|
||||||
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
|
||||||
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
|
||||||
|
const hasPageviews = data.some((item) => item.pageviews > 0);
|
||||||
return (
|
return (
|
||||||
<OverviewWidgetTable
|
<OverviewWidgetTable
|
||||||
className={className}
|
className={className}
|
||||||
@@ -385,27 +488,12 @@ export function OverviewWidgetTableGeneric({
|
|||||||
width: 'w-full',
|
width: 'w-full',
|
||||||
responsive: { priority: 1 }, // Always visible
|
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
|
...(hasRevenue
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 },
|
||||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -427,10 +515,28 @@ export function OverviewWidgetTableGeneric({
|
|||||||
} as const,
|
} as const,
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
...(hasPageviews
|
||||||
|
? [
|
||||||
{
|
{
|
||||||
name: 'Sessions',
|
name: 'Views',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
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: 'Sess.',
|
||||||
|
width: '84px',
|
||||||
|
responsive: { priority: 2 },
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<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 { useThrottle } from '@/hooks/use-throttle';
|
||||||
import { cn } from '@/utils/cn';
|
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 { last } from 'ramda';
|
||||||
import { Children, useEffect, useRef, useState } from 'react';
|
import { Children, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../ui/dropdown-menu';
|
} from '../ui/dropdown-menu';
|
||||||
|
import { Input } from '../ui/input';
|
||||||
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
|
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
|
||||||
import { WidgetHead as WidgetHeadBase } 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({
|
export function WidgetFooter({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -33,7 +33,10 @@ export function useOverviewWidget<T extends string>(
|
|||||||
|
|
||||||
export function useOverviewWidgetV2<T extends string>(
|
export function useOverviewWidgetV2<T extends string>(
|
||||||
key: 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 keys = Object.keys(widgets) as T[];
|
||||||
const [widget, setWidget] = useQueryState<T>(
|
const [widget, setWidget] = useQueryState<T>(
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { AnimatedNumber } from '../animated-number';
|
import { AnimatedNumber } from '../animated-number';
|
||||||
import { BarShapeBlue } from '../charts/common-bar';
|
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
|
|
||||||
interface RealtimeLiveHistogramProps {
|
interface RealtimeLiveHistogramProps {
|
||||||
@@ -87,10 +86,8 @@ export function RealtimeLiveHistogram({
|
|||||||
<YAxis hide domain={[0, maxDomain]} />
|
<YAxis hide domain={[0, maxDomain]} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="visitorCount"
|
dataKey="visitorCount"
|
||||||
fill="rgba(59, 121, 255, 0.2)"
|
className="fill-chart-0"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
shape={BarShapeBlue}
|
|
||||||
activeBar={BarShapeBlue}
|
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -4,52 +4,221 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
|
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { SearchIcon } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { round } from '@openpanel/common';
|
import { round } from '@openpanel/common';
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
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 { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
import { SerieName } from '../common/serie-name';
|
import { SerieName } from '../common/serie-name';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
type SortOption =
|
||||||
|
| 'count-desc'
|
||||||
|
| 'count-asc'
|
||||||
|
| 'name-asc'
|
||||||
|
| 'name-desc'
|
||||||
|
| 'percent-desc'
|
||||||
|
| 'percent-asc';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chart({ data }: Props) {
|
export function Chart({ data }: Props) {
|
||||||
const [isOpen, setOpen] = useState<string | null>(null);
|
const [isOpen, setOpen] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState<SortOption>('count-desc');
|
||||||
const {
|
const {
|
||||||
isEditMode,
|
isEditMode,
|
||||||
report: { metric, limit, previous },
|
report: { metric, limit, previous },
|
||||||
options: { onClick, dropdownMenuContent, columns },
|
options: { onClick, dropdownMenuContent },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const number = useNumber();
|
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 = [
|
// Use useVisibleSeries to add index property for colors
|
||||||
{
|
const { series: allSeriesWithIndex } = useVisibleSeries(data, 500);
|
||||||
name: columns?.[0] || 'Name',
|
|
||||||
width: 'w-full',
|
const totalSum = data.metrics.sum || 1;
|
||||||
render: (serie: (typeof series)[0]) => {
|
|
||||||
const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick;
|
// 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]);
|
||||||
|
|
||||||
|
// 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('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>
|
||||||
|
)}
|
||||||
|
<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 =
|
const isDropDownEnabled =
|
||||||
!serie.names.includes(NOT_SET_VALUE) &&
|
!serie.names.includes(NOT_SET_VALUE) &&
|
||||||
(dropdownMenuContent?.(serie) || []).length > 0;
|
(dropdownMenuContent?.(serie) || []).length > 0;
|
||||||
|
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
const percentOfTotal = round(
|
||||||
|
(serie.metrics.sum / totalSum) * 100,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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
|
<DropdownMenu
|
||||||
onOpenChange={() =>
|
onOpenChange={() =>
|
||||||
setOpen((p) => (p === serie.id ? null : serie.id))
|
setOpen((p) => (p === serie.id ? null : serie.id))
|
||||||
@@ -62,102 +231,95 @@ export function Chart({ data }: Props) {
|
|||||||
{...(isDropDownEnabled
|
{...(isDropDownEnabled
|
||||||
? {
|
? {
|
||||||
onPointerDown: (e) => e.preventDefault(),
|
onPointerDown: (e) => e.preventDefault(),
|
||||||
onClick: () => setOpen(serie.id),
|
onClick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(serie.id);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 break-all font-medium',
|
'min-w-0',
|
||||||
(isClickable || isDropDownEnabled) && 'cursor-pointer',
|
isDropDownEnabled && 'cursor-pointer',
|
||||||
)}
|
)}
|
||||||
{...(isClickable && !isDropDownEnabled
|
{...(isClickable && !isDropDownEnabled
|
||||||
? {
|
? {
|
||||||
onClick: () => onClick(serie),
|
onClick: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(serie);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
>
|
>
|
||||||
<SerieIcon name={serie.names[0]} />
|
<SerieName
|
||||||
<SerieName name={serie.names} />
|
name={serie.names}
|
||||||
|
className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold tracking-tight"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
{dropdownMenuContent?.(serie).map((item) => (
|
{dropdownMenuContent?.(serie).map((item) => (
|
||||||
<DropdownMenuItem key={item.title} onClick={item.onClick}>
|
<DropdownMenuItem
|
||||||
{item.icon && <item.icon size={16} className="mr-2" />}
|
key={item.title}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
item.onClick();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<item.icon size={16} className="mr-2" />
|
||||||
|
)}
|
||||||
{item.title}
|
{item.title}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</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>
|
</div>
|
||||||
),
|
</div>
|
||||||
},
|
|
||||||
|
|
||||||
// Previous value column
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
{
|
<div className="flex items-center gap-2">
|
||||||
name: 'Previous',
|
<div className="text-base font-semibold font-mono tracking-tight">
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// 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)}
|
{number.format(serie.metrics.sum)}
|
||||||
</div>
|
</div>
|
||||||
),
|
{previous && serie.metrics.previous?.[metric] && (
|
||||||
},
|
<DeltaChip
|
||||||
];
|
variant={
|
||||||
|
serie.metrics.previous[metric].state ===
|
||||||
return (
|
'positive'
|
||||||
<div
|
? 'inc'
|
||||||
className={cn(
|
: 'dec'
|
||||||
'text-sm',
|
|
||||||
isEditMode ? 'card gap-2 p-4 text-base' : '-m-3',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<OverviewWidgetTable
|
|
||||||
data={series}
|
|
||||||
keyExtractor={(serie) => serie.id}
|
|
||||||
columns={tableColumns.filter((column) => {
|
|
||||||
if (!previous && column.name === 'Previous') {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
return true;
|
size="sm"
|
||||||
})}
|
>
|
||||||
getColumnPercentage={(serie) => serie.metrics.sum / maxCount}
|
{serie.metrics.previous[metric].diff?.toFixed(1)}%
|
||||||
className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')}
|
</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>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -12,7 +13,7 @@ export function ReportBarChart() {
|
|||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.aggregate.queryOptions(report, {
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 1000 * 60 * 1,
|
staleTime: 1000 * 60 * 1,
|
||||||
enabled: !isLazyLoading,
|
enabled: !isLazyLoading,
|
||||||
@@ -26,7 +27,6 @@ export function ReportBarChart() {
|
|||||||
) {
|
) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.isError) {
|
if (res.isError) {
|
||||||
return <Error />;
|
return <Error />;
|
||||||
}
|
}
|
||||||
@@ -39,22 +39,62 @@ export function ReportBarChart() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
|
const { isEditMode } = useReportChartContext();
|
||||||
return (
|
return (
|
||||||
<AspectContainer className="col gap-4 overflow-hidden">
|
<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) => (
|
{Array.from({ length: 10 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index as number}
|
key={index as number}
|
||||||
className="row animate-pulse justify-between"
|
className="relative px-4 py-3 animate-pulse"
|
||||||
>
|
>
|
||||||
<div className="h-4 w-2/5 rounded bg-def-200" />
|
<div className="relative z-10 flex flex-col gap-2">
|
||||||
<div className="row w-1/5 gap-2">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="h-4 w-full rounded bg-def-200" />
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="h-4 w-full rounded bg-def-200" />
|
{/* Icon skeleton */}
|
||||||
<div className="h-4 w-full rounded bg-def-200" />
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</AspectContainer>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function ReportChartEmpty({
|
|||||||
</div>
|
</div>
|
||||||
<ForkliftIcon
|
<ForkliftIcon
|
||||||
strokeWidth={1.2}
|
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">
|
<div className="font-medium text-muted-foreground">
|
||||||
Ready when you're
|
Ready when you're
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useNumber } from '@/hooks/use-numer-formatter';
|
|||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import { DeltaChip } from '@/components/delta-chip';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
export function getDiffIndicator<A, B, C>(
|
export function getDiffIndicator<A, B, C>(
|
||||||
@@ -29,7 +30,7 @@ interface PreviousDiffIndicatorProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
size?: 'sm' | 'lg' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreviousDiffIndicator({
|
export function PreviousDiffIndicator({
|
||||||
@@ -81,7 +82,6 @@ export function PreviousDiffIndicator({
|
|||||||
variant,
|
variant,
|
||||||
size === 'lg' && 'size-8',
|
size === 'lg' && 'size-8',
|
||||||
size === 'md' && 'size-6',
|
size === 'md' && 'size-6',
|
||||||
size === 'xs' && 'size-3',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
@@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps {
|
|||||||
diff?: number | null | undefined;
|
diff?: number | null | undefined;
|
||||||
state?: string | null | undefined;
|
state?: string | null | undefined;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
size?: 'sm' | 'lg' | 'md' | 'xs';
|
size?: 'sm' | 'lg' | 'md';
|
||||||
className?: string;
|
className?: string;
|
||||||
showPrevious?: boolean;
|
showPrevious?: boolean;
|
||||||
}
|
}
|
||||||
@@ -133,25 +133,35 @@ export function PreviousDiffIndicatorPure({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DeltaChip
|
||||||
className={cn(
|
variant={state === 'positive' ? 'inc' : 'dec'}
|
||||||
'flex items-center gap-1 font-mono font-medium',
|
size={size}
|
||||||
size === 'lg' && 'gap-2',
|
inverted={inverted}
|
||||||
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)}%
|
{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',
|
whale: 'https://whale.naver.com',
|
||||||
wechat: 'https://wechat.com',
|
wechat: 'https://wechat.com',
|
||||||
chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg',
|
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 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',
|
'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',
|
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',
|
edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png',
|
||||||
facebook: 'https://facebook.com',
|
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',
|
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',
|
github: 'https://github.com',
|
||||||
gmail: 'https://mail.google.com',
|
gmail: 'https://mail.google.com',
|
||||||
google: 'https://google.com',
|
google: 'https://google.com',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
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 { useReportChartContext } from '../context';
|
||||||
import { Chart } from './chart';
|
import { Chart } from './chart';
|
||||||
|
|
||||||
@@ -47,28 +50,18 @@ export function Loading() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Error() {
|
function Error() {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[70px]">
|
<AspectContainer>
|
||||||
<div className="opacity-50">
|
<ReportChartError />
|
||||||
<Loading />
|
</AspectContainer>
|
||||||
</div>
|
|
||||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
|
||||||
<div className="text-sm font-medium">Error fetching data</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Empty() {
|
function Empty() {
|
||||||
return (
|
return (
|
||||||
<div className="relative h-[70px]">
|
<AspectContainer>
|
||||||
<div className="opacity-50">
|
<ReportChartEmpty />
|
||||||
<Loading />
|
</AspectContainer>
|
||||||
</div>
|
|
||||||
<div className="center-center absolute inset-0 text-muted-foreground">
|
|
||||||
<div className="text-sm font-medium">No data</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export function ReportPieChart() {
|
|||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(report, {
|
trpc.chart.aggregate.queryOptions(report, {
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
staleTime: 1000 * 60 * 1,
|
staleTime: 1000 * 60 * 1,
|
||||||
enabled: !isLazyLoading,
|
enabled: !isLazyLoading,
|
||||||
|
|||||||
@@ -386,6 +386,7 @@ export function ReportSeries() {
|
|||||||
}}
|
}}
|
||||||
placeholder="Select event"
|
placeholder="Select event"
|
||||||
items={eventNames}
|
items={eventNames}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
{showFormula && (
|
{showFormula && (
|
||||||
<Button
|
<Button
|
||||||
@@ -401,6 +402,7 @@ export function ReportSeries() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
className="px-4"
|
||||||
>
|
>
|
||||||
Add Formula
|
Add Formula
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function ReportSettings() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Settings</h3>
|
<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') && (
|
{fields.includes('previous') && (
|
||||||
<Label className="flex items-center justify-between mb-0">
|
<Label className="flex items-center justify-between mb-0">
|
||||||
<span className="whitespace-nowrap">
|
<span className="whitespace-nowrap">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva } from 'class-variance-authority';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
const inputVariant = cva(
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export function WidgetTable<T>({
|
|||||||
columns.length > 1 && column !== columns[0]
|
columns.length > 1 && column !== columns[0]
|
||||||
? 'text-right'
|
? 'text-right'
|
||||||
: 'text-left',
|
: 'text-left',
|
||||||
column.className,
|
|
||||||
responsiveClass,
|
responsiveClass,
|
||||||
)}
|
)}
|
||||||
style={{ width: column.width }}
|
style={{ width: column.width }}
|
||||||
|
|||||||
@@ -10,20 +10,27 @@ const VALID_COOKIES = [
|
|||||||
'chartType',
|
'chartType',
|
||||||
'range',
|
'range',
|
||||||
'supporter-prompt-closed',
|
'supporter-prompt-closed',
|
||||||
|
'feedback-prompt-seen',
|
||||||
] as const;
|
] as const;
|
||||||
const COOKIE_EVENT_NAME = '__cookie-change';
|
const COOKIE_EVENT_NAME = '__cookie-change';
|
||||||
|
|
||||||
const setCookieFn = createServerFn({ method: 'POST' })
|
const setCookieFn = createServerFn({ method: 'POST' })
|
||||||
.inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() }))
|
.inputValidator(
|
||||||
.handler(({ data: { key, value } }) => {
|
z.object({
|
||||||
|
key: z.enum(VALID_COOKIES),
|
||||||
|
value: z.string(),
|
||||||
|
maxAge: z.number().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.handler(({ data: { key, value, maxAge } }) => {
|
||||||
if (!VALID_COOKIES.includes(key)) {
|
if (!VALID_COOKIES.includes(key)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const maxAge = 60 * 60 * 24 * 365 * 10;
|
const cookieMaxAge = maxAge ?? 60 * 60 * 24 * 365 * 10;
|
||||||
setCookie(key, value, {
|
setCookie(key, value, {
|
||||||
maxAge,
|
maxAge: cookieMaxAge,
|
||||||
path: '/',
|
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>(
|
export function useCookieStore<T>(
|
||||||
key: (typeof VALID_COOKIES)[number],
|
key: (typeof VALID_COOKIES)[number],
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
|
options?: { maxAge?: number },
|
||||||
) {
|
) {
|
||||||
const { cookies } = useRouteContext({ strict: false });
|
const { cookies } = useRouteContext({ strict: false });
|
||||||
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
|
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
|
||||||
@@ -68,7 +76,9 @@ export function useCookieStore<T>(
|
|||||||
value,
|
value,
|
||||||
(newValue: T) => {
|
(newValue: T) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
setCookieFn({ data: { key, value: String(newValue) } });
|
setCookieFn({
|
||||||
|
data: { key, value: String(newValue), maxAge: options?.maxAge },
|
||||||
|
});
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(COOKIE_EVENT_NAME, {
|
new CustomEvent(COOKIE_EVENT_NAME, {
|
||||||
detail: { key, value: newValue, from: ref.current },
|
detail: { key, value: newValue, from: ref.current },
|
||||||
@@ -76,6 +86,6 @@ export function useCookieStore<T>(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
] as const,
|
] as const,
|
||||||
[value, key],
|
[value, key, options?.maxAge],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { LazyComponent } from '@/components/lazy-component';
|
||||||
import {
|
import {
|
||||||
OverviewFilterButton,
|
OverviewFilterButton,
|
||||||
OverviewFiltersButtons,
|
OverviewFiltersButtons,
|
||||||
@@ -58,7 +59,9 @@ function ProjectDashboard() {
|
|||||||
<OverviewTopDevices projectId={projectId} />
|
<OverviewTopDevices projectId={projectId} />
|
||||||
<OverviewTopEvents projectId={projectId} />
|
<OverviewTopEvents projectId={projectId} />
|
||||||
<OverviewTopGeo projectId={projectId} />
|
<OverviewTopGeo projectId={projectId} />
|
||||||
|
<LazyComponent className="col-span-6">
|
||||||
<OverviewUserJourney projectId={projectId} />
|
<OverviewUserJourney projectId={projectId} />
|
||||||
|
</LazyComponent>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
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 { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { PageContainer } from '@/components/page-container';
|
import { PageContainer } from '@/components/page-container';
|
||||||
import { PageHeader } from '@/components/page-header';
|
import { PageHeader } from '@/components/page-header';
|
||||||
import { Pagination } from '@/components/pagination';
|
|
||||||
import { FloatingPagination } from '@/components/pagination-floating';
|
import { FloatingPagination } from '@/components/pagination-floating';
|
||||||
import { ReportChart } from '@/components/report-chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { Skeleton } from '@/components/skeleton';
|
import { Skeleton } from '@/components/skeleton';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { TableButtons } from '@/components/ui/table';
|
import { TableButtons } from '@/components/ui/table';
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
import { useSearchQueryState } from '@/hooks/use-search-query-state';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
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 { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { memo } from 'react';
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
||||||
component: Component,
|
component: Component,
|
||||||
@@ -42,30 +39,102 @@ function Component() {
|
|||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const take = 20;
|
const take = 20;
|
||||||
const { range, interval } = useOverviewOptions();
|
const { range, interval } = useOverviewOptions();
|
||||||
const [filters] = useEventQueryFilters();
|
|
||||||
const [cursor, setCursor] = useQueryState(
|
const [cursor, setCursor] = useQueryState(
|
||||||
'cursor',
|
'cursor',
|
||||||
parseAsInteger.withDefault(1),
|
parseAsInteger.withDefault(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { debouncedSearch, setSearch, search } = useSearchQueryState();
|
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(
|
trpc.event.pages.queryOptions(
|
||||||
{
|
{
|
||||||
projectId,
|
projectId,
|
||||||
cursor,
|
cursor: 1,
|
||||||
take,
|
take: 1000,
|
||||||
search: debouncedSearch,
|
search: undefined, // No search - get all pages
|
||||||
range,
|
range,
|
||||||
interval,
|
interval,
|
||||||
filters,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
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 (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
@@ -77,24 +146,27 @@ function Component() {
|
|||||||
<TableButtons>
|
<TableButtons>
|
||||||
<OverviewRange />
|
<OverviewRange />
|
||||||
<OverviewInterval />
|
<OverviewInterval />
|
||||||
<OverviewFilterButton enableEventsFilter />
|
|
||||||
<Input
|
<Input
|
||||||
className="self-auto"
|
className="self-auto"
|
||||||
placeholder="Search path"
|
placeholder="Search path"
|
||||||
value={search ?? ''}
|
value={search ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSearch(e.target.value);
|
setSearch(e.target.value);
|
||||||
setCursor(0);
|
setCursor(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</TableButtons>
|
</TableButtons>
|
||||||
{data.length === 0 && !query.isLoading && (
|
{data.length === 0 && !isLoading && (
|
||||||
<FullPageEmptyState
|
<FullPageEmptyState
|
||||||
title="No pages"
|
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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<PageCardSkeleton />
|
<PageCardSkeleton />
|
||||||
<PageCardSkeleton />
|
<PageCardSkeleton />
|
||||||
@@ -105,7 +177,7 @@ function Component() {
|
|||||||
{data.map((page) => {
|
{data.map((page) => {
|
||||||
return (
|
return (
|
||||||
<PageCard
|
<PageCard
|
||||||
key={page.path}
|
key={page.origin + page.path}
|
||||||
page={page}
|
page={page}
|
||||||
range={range}
|
range={range}
|
||||||
interval={interval}
|
interval={interval}
|
||||||
@@ -114,18 +186,18 @@ function Component() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{data.length !== 0 && (
|
{allData.length !== 0 && (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<FloatingPagination
|
<FloatingPagination
|
||||||
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
|
firstPage={cursor > 1 ? () => setCursor(1) : undefined}
|
||||||
canNextPage={true}
|
canNextPage={cursor < totalPages}
|
||||||
canPreviousPage={cursor > 0}
|
canPreviousPage={cursor > 1}
|
||||||
pageIndex={cursor - 1}
|
pageIndex={cursor - 1}
|
||||||
nextPage={() => {
|
nextPage={() => {
|
||||||
setCursor((p) => p + 1);
|
setCursor((p) => Math.min(p + 1, totalPages));
|
||||||
}}
|
}}
|
||||||
previousPage={() => {
|
previousPage={() => {
|
||||||
setCursor((p) => p - 1);
|
setCursor((p) => Math.max(p - 1, 1));
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
|
import FeedbackPrompt from '@/components/organization/feedback-prompt';
|
||||||
import SupporterPrompt from '@/components/organization/supporter-prompt';
|
import SupporterPrompt from '@/components/organization/supporter-prompt';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -154,6 +155,7 @@ function Component() {
|
|||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<SupporterPrompt />
|
<SupporterPrompt />
|
||||||
|
<FeedbackPrompt />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,5 +28,6 @@ export * from './src/types';
|
|||||||
export * from './src/clickhouse/query-builder';
|
export * from './src/clickhouse/query-builder';
|
||||||
export * from './src/services/import.service';
|
export * from './src/services/import.service';
|
||||||
export * from './src/services/overview.service';
|
export * from './src/services/overview.service';
|
||||||
|
export * from './src/services/pages.service';
|
||||||
export * from './src/services/insights';
|
export * from './src/services/insights';
|
||||||
export * from './src/session-context';
|
export * from './src/session-context';
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ function getClickhouseSettings(): ClickHouseSettings {
|
|||||||
{};
|
{};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
distributed_product_mode: 'allow',
|
||||||
date_time_input_format: 'best_effort',
|
date_time_input_format: 'best_effort',
|
||||||
...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
|
...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ export class Query<T = any> {
|
|||||||
const query = this.buildQuery();
|
const query = this.buildQuery();
|
||||||
console.log(
|
console.log(
|
||||||
'query',
|
'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({
|
const result = await this.client.query({
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric, groupByLabels } from '@openpanel/common';
|
||||||
|
import type { ISerieDataItem } from '@openpanel/common';
|
||||||
import type { FinalChart, IChartInput } from '@openpanel/validation';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import { getChartPrevStartEndDate } from '../services/chart.service';
|
import type {
|
||||||
|
FinalChart,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartInput,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { chQuery } from '../clickhouse/client';
|
||||||
|
import {
|
||||||
|
getAggregateChartSql,
|
||||||
|
getChartPrevStartEndDate,
|
||||||
|
} from '../services/chart.service';
|
||||||
import {
|
import {
|
||||||
getOrganizationSubscriptionChartEndDate,
|
getOrganizationSubscriptionChartEndDate,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
@@ -69,7 +78,280 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
|||||||
return response;
|
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 as ChartEngine for backward compatibility
|
||||||
export const ChartEngine = {
|
export const ChartEngine = {
|
||||||
execute: executeChart,
|
execute: executeChart,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Export aggregate chart engine
|
||||||
|
export const AggregateChartEngine = {
|
||||||
|
execute: executeAggregateChart,
|
||||||
|
};
|
||||||
|
|||||||
@@ -348,6 +348,246 @@ export function getChartSql({
|
|||||||
return sql;
|
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 {
|
function isNumericColumn(columnName: string): boolean {
|
||||||
const numericColumns = ['duration', 'revenue', 'longitude', 'latitude'];
|
const numericColumns = ['duration', 'revenue', 'longitude', 'latitude'];
|
||||||
return numericColumns.includes(columnName);
|
return numericColumns.includes(columnName);
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import { getEventFiltersWhereClause } from './chart.service';
|
|||||||
// Constants
|
// Constants
|
||||||
const ROLLUP_DATE_PREFIX = '1970-01-01';
|
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> = {
|
const COLUMN_PREFIX_MAP: Record<string, string> = {
|
||||||
region: 'country',
|
region: 'country',
|
||||||
city: 'country',
|
city: 'country',
|
||||||
@@ -47,8 +53,6 @@ export const zGetTopPagesInput = z.object({
|
|||||||
filters: z.array(z.any()),
|
filters: z.array(z.any()),
|
||||||
startDate: z.string(),
|
startDate: z.string(),
|
||||||
endDate: z.string(),
|
endDate: z.string(),
|
||||||
cursor: z.number().optional(),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type IGetTopPagesInput = z.infer<typeof zGetTopPagesInput> & {
|
export type IGetTopPagesInput = z.infer<typeof zGetTopPagesInput> & {
|
||||||
@@ -61,8 +65,6 @@ export const zGetTopEntryExitInput = z.object({
|
|||||||
startDate: z.string(),
|
startDate: z.string(),
|
||||||
endDate: z.string(),
|
endDate: z.string(),
|
||||||
mode: z.enum(['entry', 'exit']),
|
mode: z.enum(['entry', 'exit']),
|
||||||
cursor: z.number().optional(),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type IGetTopEntryExitInput = z.infer<typeof zGetTopEntryExitInput> & {
|
export type IGetTopEntryExitInput = z.infer<typeof zGetTopEntryExitInput> & {
|
||||||
@@ -97,14 +99,20 @@ export const zGetTopGenericInput = z.object({
|
|||||||
'os',
|
'os',
|
||||||
'os_version',
|
'os_version',
|
||||||
]),
|
]),
|
||||||
cursor: z.number().optional(),
|
|
||||||
limit: z.number().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type IGetTopGenericInput = z.infer<typeof zGetTopGenericInput> & {
|
export type IGetTopGenericInput = z.infer<typeof zGetTopGenericInput> & {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
|
||||||
|
interval: zTimeInterval,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const zGetUserJourneyInput = z.object({
|
export const zGetUserJourneyInput = z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
filters: z.array(z.any()),
|
filters: z.array(z.any()),
|
||||||
@@ -543,18 +551,27 @@ export class OverviewService {
|
|||||||
filters,
|
filters,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
cursor = 1,
|
|
||||||
limit = 10,
|
|
||||||
timezone,
|
timezone,
|
||||||
}: IGetTopPagesInput) {
|
}: IGetTopPagesInput) {
|
||||||
const pageStatsQuery = clix(this.client, timezone)
|
const selectColumns: (string | null | undefined | false)[] = [
|
||||||
.select([
|
|
||||||
'origin',
|
'origin',
|
||||||
'path',
|
'path',
|
||||||
`last_value(properties['__title']) as title`,
|
'uniq(session_id) as sessions',
|
||||||
'uniq(session_id) as count',
|
'count() as pageviews',
|
||||||
'round(avg(duration)/1000, 2) as avg_duration',
|
];
|
||||||
])
|
|
||||||
|
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)
|
.from(TABLE_NAMES.events, false)
|
||||||
.where('project_id', '=', projectId)
|
.where('project_id', '=', projectId)
|
||||||
.where('name', '=', 'screen_view')
|
.where('name', '=', 'screen_view')
|
||||||
@@ -563,57 +580,12 @@ export class OverviewService {
|
|||||||
clix.datetime(startDate, 'toDateTime'),
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
clix.datetime(endDate, 'toDateTime'),
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
])
|
])
|
||||||
|
.rawWhere(this.getRawWhereClause('events', filters))
|
||||||
.groupBy(['origin', 'path'])
|
.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')
|
.orderBy('sessions', 'DESC')
|
||||||
.limit(limit);
|
.limit(MAX_RECORDS_LIMIT);
|
||||||
|
|
||||||
return mainQuery.execute();
|
return query.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTopEntryExit({
|
async getTopEntryExit({
|
||||||
@@ -622,28 +594,27 @@ export class OverviewService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
mode,
|
mode,
|
||||||
cursor = 1,
|
|
||||||
limit = 10,
|
|
||||||
timezone,
|
timezone,
|
||||||
}: IGetTopEntryExitInput) {
|
}: 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)
|
const query = clix(this.client, timezone)
|
||||||
.select<{
|
.select<{
|
||||||
origin: string;
|
origin: string;
|
||||||
path: string;
|
path: string;
|
||||||
avg_duration: number;
|
|
||||||
bounce_rate: number;
|
|
||||||
sessions: number;
|
sessions: number;
|
||||||
revenue: number;
|
pageviews: number;
|
||||||
}>([
|
revenue?: number;
|
||||||
`${mode}_origin AS origin`,
|
}>(selectColumns)
|
||||||
`${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',
|
|
||||||
])
|
|
||||||
.from(TABLE_NAMES.sessions, true)
|
.from(TABLE_NAMES.sessions, true)
|
||||||
.where('project_id', '=', projectId)
|
.where('project_id', '=', projectId)
|
||||||
.where('created_at', 'BETWEEN', [
|
.where('created_at', 'BETWEEN', [
|
||||||
@@ -653,8 +624,7 @@ export class OverviewService {
|
|||||||
.groupBy([`${mode}_origin`, `${mode}_path`])
|
.groupBy([`${mode}_origin`, `${mode}_path`])
|
||||||
.having('sum(sign)', '>', 0)
|
.having('sum(sign)', '>', 0)
|
||||||
.orderBy('sessions', 'DESC')
|
.orderBy('sessions', 'DESC')
|
||||||
.limit(limit)
|
.limit(MAX_RECORDS_LIMIT);
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
|
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -697,29 +667,29 @@ export class OverviewService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
column,
|
column,
|
||||||
cursor = 1,
|
|
||||||
limit = 10,
|
|
||||||
timezone,
|
timezone,
|
||||||
}: IGetTopGenericInput) {
|
}: IGetTopGenericInput) {
|
||||||
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
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)
|
const query = clix(this.client, timezone)
|
||||||
.select<{
|
.select<{
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
name: string;
|
name: string;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
bounce_rate: number;
|
pageviews: number;
|
||||||
avg_session_duration: number;
|
revenue?: number;
|
||||||
revenue: number;
|
}>(selectColumns)
|
||||||
}>([
|
|
||||||
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',
|
|
||||||
])
|
|
||||||
.from(TABLE_NAMES.sessions, true)
|
.from(TABLE_NAMES.sessions, true)
|
||||||
.where('project_id', '=', projectId)
|
.where('project_id', '=', projectId)
|
||||||
.where('created_at', 'BETWEEN', [
|
.where('created_at', 'BETWEEN', [
|
||||||
@@ -729,8 +699,7 @@ export class OverviewService {
|
|||||||
.groupBy([prefixColumn, column].filter(Boolean))
|
.groupBy([prefixColumn, column].filter(Boolean))
|
||||||
.having('sum(sign)', '>', 0)
|
.having('sum(sign)', '>', 0)
|
||||||
.orderBy('sessions', 'DESC')
|
.orderBy('sessions', 'DESC')
|
||||||
.limit(limit)
|
.limit(MAX_RECORDS_LIMIT);
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
|
const mainQuery = this.withDistinctSessionsIfNeeded(query, {
|
||||||
projectId,
|
projectId,
|
||||||
@@ -743,6 +712,177 @@ export class OverviewService {
|
|||||||
return mainQuery.execute();
|
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({
|
async getUserJourney({
|
||||||
projectId,
|
projectId,
|
||||||
filters,
|
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';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { round } from '@openpanel/common';
|
import { round } from '@openpanel/common';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { AggregateChartEngine, ChartEngine } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
differenceInMonths,
|
differenceInMonths,
|
||||||
@@ -414,6 +414,42 @@ export const chartRouter = createTRPCRouter({
|
|||||||
// Use new chart engine
|
// Use new chart engine
|
||||||
return ChartEngine.execute(input);
|
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
|
cohort: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
getEventList,
|
getEventList,
|
||||||
getEventMetasCached,
|
getEventMetasCached,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
overviewService,
|
pagesService,
|
||||||
sessionService,
|
sessionService,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
@@ -324,28 +324,17 @@ export const eventRouter = createTRPCRouter({
|
|||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
interval: zTimeInterval,
|
interval: zTimeInterval,
|
||||||
filters: z.array(zChartEventFilter).default([]),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
if (input.search) {
|
return pagesService.getTopPages({
|
||||||
input.filters.push({
|
|
||||||
id: 'path',
|
|
||||||
name: 'path',
|
|
||||||
value: [input.search],
|
|
||||||
operator: 'contains',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return overviewService.getTopPages({
|
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
filters: input.filters,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
cursor: input.cursor || 1,
|
|
||||||
limit: input.take,
|
|
||||||
timezone,
|
timezone,
|
||||||
|
search: input.search,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
overviewService,
|
overviewService,
|
||||||
zGetMetricsInput,
|
zGetMetricsInput,
|
||||||
zGetTopGenericInput,
|
zGetTopGenericInput,
|
||||||
|
zGetTopGenericSeriesInput,
|
||||||
zGetTopPagesInput,
|
zGetTopPagesInput,
|
||||||
zGetUserJourneyInput,
|
zGetUserJourneyInput,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
@@ -305,6 +306,26 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
return current;
|
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
|
userJourney: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({
|
zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({
|
||||||
|
|||||||
Reference in New Issue
Block a user