diff --git a/apps/api/src/controllers/misc.controller.ts b/apps/api/src/controllers/misc.controller.ts index dbfc51d6..0cb484a3 100644 --- a/apps/api/src/controllers/misc.controller.ts +++ b/apps/api/src/controllers/misc.controller.ts @@ -118,7 +118,11 @@ async function fetchImage( // Check if URL is an ICO file function isIcoFile(url: string, contentType?: string): boolean { - return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon'; + return ( + url.toLowerCase().endsWith('.ico') || + contentType === 'image/x-icon' || + contentType === 'image/vnd.microsoft.icon' + ); } function isSvgFile(url: string, contentType?: string): boolean { return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml'; @@ -239,7 +243,9 @@ export async function getFavicon( try { const url = validateUrl(request.query.url); if (!url) { - return createFallbackImage(); + reply.header('Content-Type', 'image/png'); + reply.header('Cache-Control', 'public, max-age=3600'); + return reply.send(createFallbackImage()); } const cacheKey = createCacheKey(url.toString()); @@ -260,21 +266,65 @@ export async function getFavicon( } else { // For website URLs, extract favicon from HTML const meta = await parseUrlMeta(url.toString()); + logger.info('parseUrlMeta result', { + url: url.toString(), + favicon: meta?.favicon, + }); if (meta?.favicon) { imageUrl = new URL(meta.favicon); } else { - // Fallback to Google's favicon service - const { hostname } = url; - imageUrl = new URL( - `https://www.google.com/s2/favicons?domain=${hostname}&sz=256`, - ); + // Try standard favicon location first + const { origin } = url; + imageUrl = new URL(`${origin}/favicon.ico`); } } - // Fetch the image - const { buffer, contentType, status } = await fetchImage(imageUrl); + logger.info('Fetching favicon', { + originalUrl: url.toString(), + imageUrl: imageUrl.toString(), + }); - if (status !== 200 || buffer.length === 0) { + // Fetch the image + let { buffer, contentType, status } = await fetchImage(imageUrl); + + logger.info('Favicon fetch result', { + originalUrl: url.toString(), + imageUrl: imageUrl.toString(), + status, + bufferLength: buffer.length, + contentType, + }); + + // If the direct favicon fetch failed and it's not from DuckDuckGo's service, + // try DuckDuckGo's favicon service as a fallback + if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) { + const { hostname } = url; + const duckduckgoUrl = new URL( + `https://icons.duckduckgo.com/ip3/${hostname}.ico`, + ); + + logger.info('Trying DuckDuckGo favicon service', { + originalUrl: url.toString(), + duckduckgoUrl: duckduckgoUrl.toString(), + }); + + const duckduckgoResult = await fetchImage(duckduckgoUrl); + buffer = duckduckgoResult.buffer; + contentType = duckduckgoResult.contentType; + status = duckduckgoResult.status; + imageUrl = duckduckgoUrl; + + logger.info('DuckDuckGo favicon result', { + status, + bufferLength: buffer.length, + contentType, + }); + } + + // Accept any response as long as we have valid image data + if (buffer.length === 0) { + reply.header('Content-Type', 'image/png'); + reply.header('Cache-Control', 'public, max-age=3600'); return reply.send(createFallbackImage()); } @@ -285,9 +335,31 @@ export async function getFavicon( contentType, ); + logger.info('Favicon processing result', { + originalUrl: url.toString(), + originalBufferLength: buffer.length, + processedBufferLength: processedBuffer.length, + }); + // Determine the correct content type for caching and response const isIco = isIcoFile(imageUrl.toString(), contentType); - const responseContentType = isIco ? 'image/x-icon' : contentType; + const isSvg = isSvgFile(imageUrl.toString(), contentType); + let responseContentType = contentType; + + if (isIco) { + responseContentType = 'image/x-icon'; + } else if (isSvg) { + responseContentType = 'image/svg+xml'; + } else if ( + processedBuffer.length < 5000 && + buffer.length === processedBuffer.length + ) { + // Image was returned as-is, keep original content type + responseContentType = contentType; + } else { + // Image was processed by Sharp, it's now a PNG + responseContentType = 'image/png'; + } // Cache the result with correct content type await setToCacheBinary(cacheKey, processedBuffer, responseContentType); diff --git a/apps/api/src/utils/parseUrlMeta.ts b/apps/api/src/utils/parseUrlMeta.ts index b9de388c..87a82e9f 100644 --- a/apps/api/src/utils/parseUrlMeta.ts +++ b/apps/api/src/utils/parseUrlMeta.ts @@ -1,7 +1,13 @@ import urlMetadata from 'url-metadata'; function fallbackFavicon(url: string) { - return `https://www.google.com/s2/favicons?domain=${url}&sz=256`; + try { + const hostname = new URL(url).hostname; + return `https://icons.duckduckgo.com/ip3/${hostname}.ico`; + } catch { + // If URL parsing fails, use the original string + return `https://icons.duckduckgo.com/ip3/${url}.ico`; + } } function findBestFavicon(favicons: UrlMetaData['favicons']) { diff --git a/apps/start/src/components/delta-chip.tsx b/apps/start/src/components/delta-chip.tsx new file mode 100644 index 00000000..09e321e3 --- /dev/null +++ b/apps/start/src/components/delta-chip.tsx @@ -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 & { + children: React.ReactNode; + inverted?: boolean; +}; + +const iconVariants: Record, 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 ( +
+ {variant === 'inc' ? ( + + ) : variant === 'dec' ? ( + + ) : null} + {children} +
+ ); +} diff --git a/apps/start/src/components/insights/insight-card.tsx b/apps/start/src/components/insights/insight-card.tsx index abeff89a..9c9c8d5a 100644 --- a/apps/start/src/components/insights/insight-card.tsx +++ b/apps/start/src/components/insights/insight-card.tsx @@ -5,6 +5,7 @@ import type { InsightPayload } from '@openpanel/validation'; import { ArrowDown, ArrowUp, FilterIcon, RotateCcwIcon } from 'lucide-react'; import { last } from 'ramda'; import { useState } from 'react'; +import { DeltaChip } from '../delta-chip'; import { SerieIcon } from '../report-chart/common/serie-icon'; import { Badge } from '../ui/badge'; @@ -188,42 +189,13 @@ export function InsightCard({ {/* Delta chip */} + variant={isIncrease ? 'inc' : isDecrease ? 'dec' : 'default'} + size="sm" + > + {deltaText} + ); } - -function DeltaChip({ - isIncrease, - isDecrease, - deltaText, -}: { - isIncrease: boolean; - isDecrease: boolean; - deltaText: string; -}) { - return ( -
- {isIncrease ? ( - - ) : isDecrease ? ( - - ) : null} - {deltaText} -
- ); -} diff --git a/apps/start/src/components/organization/feedback-prompt.tsx b/apps/start/src/components/organization/feedback-prompt.tsx new file mode 100644 index 00000000..e3a008cb --- /dev/null +++ b/apps/start/src/components/organization/feedback-prompt.tsx @@ -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 ( + +
+

+ Your feedback helps us build features you actually need. Share your + thoughts, report bugs, or suggest improvements +

+ + +
+
+ ); +} diff --git a/apps/start/src/components/organization/prompt-card.tsx b/apps/start/src/components/organization/prompt-card.tsx new file mode 100644 index 00000000..97a49d48 --- /dev/null +++ b/apps/start/src/components/organization/prompt-card.tsx @@ -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 ( + + {show && ( + +
+
+
+
+

+ {title} +

+ +
+

{subtitle}

+
+ + {children} +
+ + )} + + ); +} diff --git a/apps/start/src/components/organization/supporter-prompt.tsx b/apps/start/src/components/organization/supporter-prompt.tsx index 4d942b49..69f30b3b 100644 --- a/apps/start/src/components/organization/supporter-prompt.tsx +++ b/apps/start/src/components/organization/supporter-prompt.tsx @@ -1,7 +1,7 @@ -import { Button, LinkButton } from '@/components/ui/button'; +import { PromptCard } from '@/components/organization/prompt-card'; +import { LinkButton } from '@/components/ui/button'; import { useAppContext } from '@/hooks/use-app-context'; import { useCookieStore } from '@/hooks/use-cookie-store'; -import { AnimatePresence, motion } from 'framer-motion'; import { AwardIcon, HeartIcon, @@ -9,7 +9,6 @@ import { MessageCircleIcon, RocketIcon, SparklesIcon, - XIcon, ZapIcon, } from 'lucide-react'; @@ -78,70 +77,43 @@ export default function SupporterPrompt() { } return ( - - {!supporterPromptClosed && ( - setSupporterPromptClosed(true)} + show={!supporterPromptClosed} + gradientColor="rgb(16 185 129)" + > +
+ {PERKS.map((perk) => ( + + ))} +
+ +
+ -
-
-
-

Support OpenPanel

- -
-

- Help us build the future of open analytics -

-
- -
- {PERKS.map((perk) => ( - - ))} -
- -
- - Become a Supporter - -

- Starting at $20/month • Cancel anytime •{' '} - - Learn more - -

-
-
- - )} - + Become a Supporter +
+

+ Starting at $20/month • Cancel anytime •{' '} + + Learn more + +

+
+ ); } diff --git a/apps/start/src/components/overview/overview-line-chart-tooltip.tsx b/apps/start/src/components/overview/overview-line-chart-tooltip.tsx new file mode 100644 index 00000000..b03bcbdd --- /dev/null +++ b/apps/start/src/components/overview/overview-line-chart-tooltip.tsx @@ -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( + ({ 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 ( + + {index === 0 && firstItem.date && ( + +
{formatDate(new Date(firstItem.date))}
+
+ )} + +
+ +
+ {item.payload.prefix && ( + <> + + {item.payload.prefix} + + / + + )} + {item.payload.name || 'Not set'} +
+
+
+ {revenue !== undefined && revenue > 0 && ( +
+ Revenue + + {number.currency(revenue / 100, { short: true })} + +
+ )} +
+ Pageviews + {number.short(pageviews)} +
+
+ Sessions + {number.short(sessions)} +
+
+
+
+ ); + })} + {hidden.length > 0 && ( +
+ and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'} +
+ )} + + ); + }, +); diff --git a/apps/start/src/components/overview/overview-line-chart.tsx b/apps/start/src/components/overview/overview-line-chart.tsx new file mode 100644 index 00000000..423fa5c4 --- /dev/null +++ b/apps/start/src/components/overview/overview-line-chart.tsx @@ -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(); + 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 = { + 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 ( +
+
+ {searchQuery ? 'No results found' : 'No data available'} +
+
+ ); + } + + return ( +
+
+ + + + + + + } /> + {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 ( + + ); + })} + + + +
+ + {/* Legend */} + +
+ ); +} + +function LegendScrollable({ + items, +}: { + items: SeriesData[]; +}) { + const scrollRef = useRef(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 ( +
+ {/* Left gradient */} +
+ + {/* Scrollable legend */} +
+ {items.map((item, index) => { + const color = getChartColor(index); + return ( +
+ + + {item.prefix && ( + <> + {item.prefix} + / + + )} + {item.name || 'Not set'} + +
+ ); + })} +
+ + {/* Right gradient */} +
+
+ ); +} + +export function OverviewLineChartLoading({ + className, +}: { + className?: string; +}) { + return ( +
+
Loading...
+
+ ); +} + +export function OverviewLineChartEmpty({ + className, +}: { + className?: string; +}) { + return ( +
+
No data available
+
+ ); +} diff --git a/apps/start/src/components/overview/overview-list-modal.tsx b/apps/start/src/components/overview/overview-list-modal.tsx new file mode 100644 index 00000000..0bee06cc --- /dev/null +++ b/apps/start/src/components/overview/overview-list-modal.tsx @@ -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 ( + + + + + ); +} + +// Base data type that all items must conform to +export interface OverviewListItem { + sessions: number; + pageviews: number; + revenue?: number; +} + +interface OverviewListModalProps { + /** 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({ + title, + searchPlaceholder = 'Search...', + data, + keyExtractor, + searchFilter, + renderItem, + footer, + headerContent, + columnName = 'Name', + showPageviews = true, + showSessions = true, +}: OverviewListModalProps) { + const [searchQuery, setSearchQuery] = useState(''); + const scrollAreaRef = useRef(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 ( + + {/* Sticky Header */} +
+
+ + {title} + +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ {headerContent} +
+ + {/* Column Headers */} +
+
{columnName}
+ {hasRevenue &&
Revenue
} + {hasPageviews &&
Views
} + {showSessions &&
Sessions
} +
+
+ + {/* Virtualized Scrollable Body */} +
+
+ {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 ( +
+ {/* Background bar */} +
+
+
+ + {/* Row content */} +
+ {/* Main content cell */} +
{renderItem(item)}
+ + {/* Revenue cell */} + {hasRevenue && ( +
+ + {(item.revenue ?? 0) > 0 + ? number.currency((item.revenue ?? 0) / 100, { + short: true, + }) + : '-'} + + +
+ )} + + {/* Pageviews cell */} + {hasPageviews && ( +
+ {number.short(item.pageviews)} +
+ )} + + {/* Sessions cell */} + {showSessions && ( +
+ {number.short(item.sessions)} +
+ )} +
+
+ ); + })} +
+ + {/* Empty state */} + {filteredData.length === 0 && ( +
+ {searchQuery ? 'No results found' : 'No data available'} +
+ )} +
+ + {/* Fixed Footer */} + {footer && ( +
{footer}
+ )} + + ); +} + diff --git a/apps/start/src/components/overview/overview-live-histogram.tsx b/apps/start/src/components/overview/overview-live-histogram.tsx index 5bc231d9..61dbf081 100644 --- a/apps/start/src/components/overview/overview-live-histogram.tsx +++ b/apps/start/src/components/overview/overview-live-histogram.tsx @@ -1,5 +1,4 @@ import { useTRPC } from '@/integrations/trpc/react'; -import { cn } from '@/utils/cn'; import { useQuery } from '@tanstack/react-query'; import { useNumber } from '@/hooks/use-numer-formatter'; @@ -8,18 +7,14 @@ import * as Portal from '@radix-ui/react-portal'; import { bind } from 'bind-event-listener'; import throttle from 'lodash.throttle'; import React, { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; import { Bar, BarChart, - CartesianGrid, - Customized, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; -import { BarShapeBlue } from '../charts/common-bar'; import { SerieIcon } from '../report-chart/common/serie-icon'; interface OverviewLiveHistogramProps { projectId: string; @@ -86,10 +81,8 @@ export function OverviewLiveHistogram({ diff --git a/apps/start/src/components/overview/overview-metric-card.tsx b/apps/start/src/components/overview/overview-metric-card.tsx index 2b4cc039..0809e91e 100644 --- a/apps/start/src/components/overview/overview-metric-card.tsx +++ b/apps/start/src/components/overview/overview-metric-card.tsx @@ -1,7 +1,7 @@ import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter'; import { cn } from '@/utils/cn'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { Area, AreaChart, Tooltip } from 'recharts'; +import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts'; import { formatDate, timeAgo } from '@/utils/date'; import { getChartColor } from '@/utils/theme'; @@ -144,51 +144,33 @@ export function OverviewMetricCard({
- - {({ width, height }) => ( - + {({ width }) => ( + { setCurrentIndex(event.activeTooltipIndex ?? null); }} + margin={{ top: 0, right: 0, left: 0, bottom: 0 }} > - - - - - - - null} /> - null} cursor={false} /> + - + )}
@@ -225,13 +207,11 @@ export function OverviewMetricCardNumber({ isLoading?: boolean; }) { return ( -
-
-
- - {label} - -
+
+
+ + {label} +
{isLoading ? (
@@ -239,13 +219,13 @@ export function OverviewMetricCardNumber({
) : ( -
-
- {value} -
- {enhancer} +
+ {value}
)} +
+ {enhancer} +
); } diff --git a/apps/start/src/components/overview/overview-top-devices.tsx b/apps/start/src/components/overview/overview-top-devices.tsx index afce59db..c093d75b 100644 --- a/apps/start/src/components/overview/overview-top-devices.tsx +++ b/apps/start/src/components/overview/overview-top-devices.tsx @@ -1,11 +1,9 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; -import { cn } from '@/utils/cn'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { NOT_SET_VALUE } from '@openpanel/constants'; import type { IChartType } from '@openpanel/validation'; -import { useNumber } from '@/hooks/use-numer-formatter'; import { useTRPC } from '@/integrations/trpc/react'; import { pushModal } from '@/modals'; import { useQuery } from '@tanstack/react-query'; @@ -13,7 +11,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon'; import { Widget, WidgetBody } from '../widget'; import { OVERVIEW_COLUMNS_NAME } from './overview-constants'; import OverviewDetailsButton from './overview-details-button'; -import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; +import { + OverviewLineChart, + OverviewLineChartLoading, +} from './overview-line-chart'; +import { OverviewViewToggle, useOverviewView } from './overview-view-toggle'; +import { WidgetFooter, WidgetHeadSearchable } from './overview-widget'; import { OverviewWidgetTableGeneric, OverviewWidgetTableLoading, @@ -31,6 +34,7 @@ export default function OverviewTopDevices({ useOverviewOptions(); const [filters, setFilter] = useEventQueryFilters(); const [chartType] = useState('bar'); + const [searchQuery, setSearchQuery] = useState(''); const isPageFilter = filters.find((filter) => filter.name === 'path'); const [widget, setWidget, widgets] = useOverviewWidget('tech', { device: { @@ -316,6 +320,7 @@ export default function OverviewTopDevices({ }); const trpc = useTRPC(); + const [view] = useOverviewView(); const query = useQuery( trpc.overview.topGeneric.queryOptions({ @@ -328,31 +333,67 @@ export default function OverviewTopDevices({ }), ); + const seriesQuery = useQuery( + trpc.overview.topGenericSeries.queryOptions( + { + projectId, + range, + filters, + column: widget.key, + startDate, + endDate, + interval, + }, + { + enabled: view === 'chart', + }, + ), + ); + + const filteredData = useMemo(() => { + const data = (query.data ?? []).slice(0, 15); + if (!searchQuery.trim()) { + return data; + } + const queryLower = searchQuery.toLowerCase(); + return data.filter((item) => item.name?.toLowerCase().includes(queryLower)); + }, [query.data, searchQuery]); + + const tabs = widgets.map((w) => ({ + key: w.key, + label: w.btn, + })); + return ( <> - -
{widget.title}
- - - {widgets.map((w) => ( - - ))} - -
+ - {query.isLoading ? ( + {view === 'chart' ? ( + seriesQuery.isLoading ? ( + + ) : seriesQuery.data ? ( + + ) : ( + + ) + ) : query.isLoading ? ( ) : ( - {/* */} +
+ diff --git a/apps/start/src/components/overview/overview-top-events.tsx b/apps/start/src/components/overview/overview-top-events.tsx index fc43fc15..bd2fe377 100644 --- a/apps/start/src/components/overview/overview-top-events.tsx +++ b/apps/start/src/components/overview/overview-top-events.tsx @@ -1,225 +1,174 @@ -import { ReportChart } from '@/components/report-chart'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; -import { cn } from '@/utils/cn'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; -import type { IChartType } from '@openpanel/validation'; +import type { IChartInput } from '@openpanel/validation'; import { useTRPC } from '@/integrations/trpc/react'; import { useQuery } from '@tanstack/react-query'; import { Widget, WidgetBody } from '../widget'; -import { OverviewChartToggle } from './overview-chart-toggle'; -import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; +import { WidgetFooter, WidgetHeadSearchable } from './overview-widget'; +import { + type EventTableItem, + OverviewWidgetTableEvents, + OverviewWidgetTableLoading, +} from './overview-widget-table'; import { useOverviewOptions } from './useOverviewOptions'; -import { useOverviewWidget } from './useOverviewWidget'; +import { useOverviewWidgetV2 } from './useOverviewWidget'; export interface OverviewTopEventsProps { projectId: string; } + export default function OverviewTopEvents({ projectId, }: OverviewTopEventsProps) { const { interval, range, previous, startDate, endDate } = useOverviewOptions(); - const [filters] = useEventQueryFilters(); + const [filters, setFilter] = useEventQueryFilters(); const trpc = useTRPC(); const { data: conversions } = useQuery( trpc.event.conversionNames.queryOptions({ projectId }), ); - const [chartType, setChartType] = useState('bar'); - const [widget, setWidget, widgets] = useOverviewWidget('ev', { + const [searchQuery, setSearchQuery] = useState(''); + + const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', { your: { - title: 'Top events', - btn: 'Your', - chart: { - report: { - limit: 10, - projectId, - startDate, - endDate, - series: [ - { - type: 'event', - segment: 'event', - filters: [ - ...filters, - { - id: 'ex_session', - name: 'name', - operator: 'isNot', - value: ['session_start', 'session_end', 'screen_view'], - }, - ], - id: 'A', - name: '*', - }, - ], - breakdowns: [ - { - id: 'A', - name: 'name', - }, - ], - chartType, - lineType: 'monotone', - interval: interval, - name: 'Your top events', - range: range, - previous: previous, - metric: 'sum', - }, - }, - }, - all: { - title: 'Top events', - btn: 'All', - chart: { - report: { - limit: 10, - projectId, - startDate, - endDate, - series: [ - { - type: 'event', - segment: 'event', - filters: [...filters], - id: 'A', - name: '*', - }, - ], - breakdowns: [ - { - id: 'A', - name: 'name', - }, - ], - chartType, - lineType: 'monotone', - interval: interval, - name: 'All top events', - range: range, - previous: previous, - metric: 'sum', - }, + title: 'Events', + btn: 'Events', + meta: { + filters: [ + { + id: 'ex_session', + name: 'name', + operator: 'isNot', + value: ['session_start', 'session_end', 'screen_view'], + }, + ], + eventName: '*', }, }, conversions: { title: 'Conversions', btn: 'Conversions', hide: !conversions || conversions.length === 0, - chart: { - report: { - limit: 10, - projectId, - startDate, - endDate, - series: [ - { - type: 'event', - segment: 'event', - filters: [ - ...filters, - { - id: 'conversion', - name: 'name', - operator: 'is', - value: conversions?.map((c) => c.name) ?? [], - }, - ], - id: 'A', - name: '*', - }, - ], - breakdowns: [ - { - id: 'A', - name: 'name', - }, - ], - chartType, - lineType: 'monotone', - interval: interval, - name: 'Conversions', - range: range, - previous: previous, - metric: 'sum', - }, + meta: { + filters: [ + { + id: 'conversion', + name: 'name', + operator: 'is', + value: conversions?.map((c) => c.name) ?? [], + }, + ], + eventName: '*', }, }, link_out: { title: 'Link out', btn: 'Link out', - chart: { - report: { - limit: 10, - projectId, - startDate, - endDate, - series: [ - { - type: 'event', - segment: 'event', - id: 'A', - name: 'link_out', - filters: [], - }, - ], - breakdowns: [ - { - id: 'A', - name: 'properties.href', - }, - ], - chartType, - lineType: 'monotone', - interval: interval, - name: 'Link out', - range: range, - previous: previous, - metric: 'sum', - }, + meta: { + filters: [], + eventName: 'link_out', + breakdownProperty: 'properties.href', }, }, }); + const report: IChartInput = useMemo( + () => ({ + limit: 1000, + projectId, + startDate, + endDate, + series: [ + { + type: 'event' as const, + segment: 'event' as const, + filters: [...filters, ...(widget.meta?.filters ?? [])], + id: 'A', + name: widget.meta?.eventName ?? '*', + }, + ], + breakdowns: [ + { + id: 'A', + name: widget.meta?.breakdownProperty ?? 'name', + }, + ], + chartType: 'bar' as const, + lineType: 'monotone' as const, + interval, + name: widget.title, + range, + previous, + metric: 'sum' as const, + }), + [projectId, startDate, endDate, filters, widget, interval, range, previous], + ); + + const query = useQuery(trpc.chart.aggregate.queryOptions(report)); + + const tableData: EventTableItem[] = useMemo(() => { + if (!query.data?.series) return []; + + return query.data.series.map((serie) => ({ + id: serie.id, + name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '', + count: serie.metrics.sum, + })); + }, [query.data]); + + const filteredData = useMemo(() => { + if (!searchQuery.trim()) { + return tableData.slice(0, 15); + } + const queryLower = searchQuery.toLowerCase(); + return tableData + .filter((item) => item.name?.toLowerCase().includes(queryLower)) + .slice(0, 15); + }, [tableData, searchQuery]); + + const tabs = useMemo( + () => + widgets + .filter((item) => item.hide !== true) + .map((w) => ({ + key: w.key, + label: w.btn, + })), + [widgets], + ); + return ( <> - -
{widget.title}
- - {widgets - .filter((item) => item.hide !== true) - .map((w) => ( - - ))} - -
- - + + + {query.isLoading ? ( + + ) : ( + { + if (widget.meta?.breakdownProperty) { + setFilter(widget.meta.breakdownProperty, name); + } else { + setFilter('name', name); + } + }} + /> + )} - +
diff --git a/apps/start/src/components/overview/overview-top-generic-modal.tsx b/apps/start/src/components/overview/overview-top-generic-modal.tsx index 9766a758..0ff17593 100644 --- a/apps/start/src/components/overview/overview-top-generic-modal.tsx +++ b/apps/start/src/components/overview/overview-top-generic-modal.tsx @@ -1,18 +1,15 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useTRPC } from '@/integrations/trpc/react'; -import { ModalContent, ModalHeader } from '@/modals/Modal/Container'; import type { IGetTopGenericInput } from '@openpanel/db'; -import { useInfiniteQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { ChevronRightIcon } from 'lucide-react'; import { SerieIcon } from '../report-chart/common/serie-icon'; -import { Button } from '../ui/button'; -import { ScrollArea } from '../ui/scroll-area'; import { OVERVIEW_COLUMNS_NAME, OVERVIEW_COLUMNS_NAME_PLURAL, } from './overview-constants'; -import { OverviewWidgetTableGeneric } from './overview-widget-table'; +import { OverviewListModal } from './overview-list-modal'; import { useOverviewOptions } from './useOverviewOptions'; interface OverviewTopGenericModalProps { @@ -24,83 +21,55 @@ export default function OverviewTopGenericModal({ projectId, column, }: OverviewTopGenericModalProps) { - const [filters, setFilter] = useEventQueryFilters(); + const [_filters, setFilter] = useEventQueryFilters(); const { startDate, endDate, range } = useOverviewOptions(); const trpc = useTRPC(); - const query = useInfiniteQuery( - trpc.overview.topGeneric.infiniteQueryOptions( - { - projectId, - filters, - startDate, - endDate, - range, - limit: 50, - column, - }, - { - getNextPageParam: (lastPage, pages) => { - if (lastPage.length === 0) { - return null; - } - - return pages.length + 1; - }, - }, - ), + const query = useQuery( + trpc.overview.topGeneric.queryOptions({ + projectId, + filters: _filters, + startDate, + endDate, + range, + column, + }), ); - const data = query.data?.pages.flat() || []; - const isEmpty = !query.hasNextPage && !query.isFetching; - const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column]; const columnName = OVERVIEW_COLUMNS_NAME[column]; return ( - - - - - - -
- ); - }, - }} - /> -
- + {item.prefix && ( + + {item.prefix} + + + )} + {item.name || 'Not set'} +
- - + )} + /> ); } diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index 6c788cb3..1ce23552 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -1,10 +1,8 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; -import { cn } from '@/utils/cn'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import type { IChartType } from '@openpanel/validation'; -import { useNumber } from '@/hooks/use-numer-formatter'; import { useTRPC } from '@/integrations/trpc/react'; import { pushModal } from '@/modals'; import { countries } from '@/translations/countries'; @@ -16,7 +14,16 @@ import { SerieIcon } from '../report-chart/common/serie-icon'; import { Widget, WidgetBody } from '../widget'; import { OVERVIEW_COLUMNS_NAME } from './overview-constants'; import OverviewDetailsButton from './overview-details-button'; -import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; +import { + OverviewLineChart, + OverviewLineChartLoading, +} from './overview-line-chart'; +import { OverviewViewToggle, useOverviewView } from './overview-view-toggle'; +import { + WidgetFooter, + WidgetHead, + WidgetHeadSearchable, +} from './overview-widget'; import { OverviewWidgetTableGeneric, OverviewWidgetTableLoading, @@ -32,6 +39,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { useOverviewOptions(); const [chartType, setChartType] = useState('bar'); const [filters, setFilter] = useEventQueryFilters(); + const [searchQuery, setSearchQuery] = useState(''); const isPageFilter = filters.find((filter) => filter.name === 'path'); const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', { country: { @@ -48,8 +56,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { }, }); - const number = useNumber(); const trpc = useTRPC(); + const [view] = useOverviewView(); const query = useQuery( trpc.overview.topGeneric.queryOptions({ @@ -62,31 +70,74 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { }), ); + const seriesQuery = useQuery( + trpc.overview.topGenericSeries.queryOptions( + { + projectId, + range, + filters, + column: widget.key, + startDate, + endDate, + interval, + }, + { + enabled: view === 'chart', + }, + ), + ); + + const filteredData = useMemo(() => { + const data = (query.data ?? []).slice(0, 15); + if (!searchQuery.trim()) { + return data; + } + const queryLower = searchQuery.toLowerCase(); + return data.filter( + (item) => + item.name?.toLowerCase().includes(queryLower) || + item.prefix?.toLowerCase().includes(queryLower) || + countries[item.name as keyof typeof countries] + ?.toLowerCase() + .includes(queryLower), + ); + }, [query.data, searchQuery]); + + const tabs = widgets.map((w) => ({ + key: w.key, + label: w.btn, + })); + return ( <> - -
{widget.title}
- - - {widgets.map((w) => ( - - ))} - -
+ - {query.isLoading ? ( + {view === 'chart' ? ( + seriesQuery.isLoading ? ( + + ) : seriesQuery.data ? ( + + ) : ( + + ) + ) : query.isLoading ? ( ) : ( - {/* */} - +
+ + Geo data provided by{' '} pages.length + 1, - }, - ), + const query = useQuery( + trpc.overview.topPages.queryOptions({ + projectId, + filters, + startDate, + endDate, + mode: 'page', + range, + }), ); - const data = query.data?.pages.flat(); - return ( - - - - -
- -
-
-
+ item.path + item.origin} + searchFilter={(item, query) => + item.path.toLowerCase().includes(query) || + item.origin.toLowerCase().includes(query) + } + columnName="Path" + renderItem={(item) => ( + +
+ + )} + /> ); } diff --git a/apps/start/src/components/overview/overview-top-pages.tsx b/apps/start/src/components/overview/overview-top-pages.tsx index c5c83a90..c4bf583c 100644 --- a/apps/start/src/components/overview/overview-top-pages.tsx +++ b/apps/start/src/components/overview/overview-top-pages.tsx @@ -1,7 +1,7 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; -import { cn } from '@/utils/cn'; import { Globe2Icon } from 'lucide-react'; import { parseAsBoolean, useQueryState } from 'nuqs'; +import { useMemo, useState } from 'react'; import { useTRPC } from '@/integrations/trpc/react'; import { pushModal } from '@/modals'; @@ -9,8 +9,9 @@ import { useQuery } from '@tanstack/react-query'; import { Button } from '../ui/button'; import { Widget, WidgetBody } from '../widget'; import OverviewDetailsButton from './overview-details-button'; -import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; +import { WidgetFooter, WidgetHeadSearchable } from './overview-widget'; import { + OverviewWidgetTableEntries, OverviewWidgetTableLoading, OverviewWidgetTablePages, } from './overview-widget-table'; @@ -25,15 +26,11 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { const { interval, range, startDate, endDate } = useOverviewOptions(); const [filters] = useEventQueryFilters(); const [domain, setDomain] = useQueryState('d', parseAsBoolean); + const [searchQuery, setSearchQuery] = useState(''); const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', { page: { title: 'Top pages', - btn: 'Top pages', - meta: { - columns: { - sessions: 'Sessions', - }, - }, + btn: 'Pages', }, entry: { title: 'Entry Pages', @@ -53,10 +50,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { }, }, }, - // bot: { - // title: 'Bots', - // btn: 'Bots', - // }, }); const trpc = useTRPC(); @@ -71,37 +64,53 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { }), ); - const data = query.data; + const filteredData = useMemo(() => { + const data = query.data?.slice(0, 15) ?? []; + if (!searchQuery.trim()) { + return data; + } + const queryLower = searchQuery.toLowerCase(); + return data.filter( + (item) => + item.path.toLowerCase().includes(queryLower) || + item.origin.toLowerCase().includes(queryLower), + ); + }, [query.data, searchQuery]); + + const tabs = widgets.map((w) => ({ + key: w.key, + label: w.btn, + })); return ( <> - -
{widget.title}
- - {widgets.map((w) => ( - - ))} - -
+ {query.isLoading ? ( ) : ( <> - {/**/} - + {widget.meta?.columns.sessions ? ( + + ) : ( + + )} )} @@ -109,7 +118,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { pushModal('OverviewTopPagesModal', { projectId })} /> - {/* */}
- ))} - - + - {query.isLoading ? ( + {view === 'chart' ? ( + seriesQuery.isLoading ? ( + + ) : seriesQuery.data ? ( + + ) : ( + + ) + ) : query.isLoading ? ( ) : ( - {/* */} +
+ diff --git a/apps/start/src/components/overview/overview-view-toggle.tsx b/apps/start/src/components/overview/overview-view-toggle.tsx new file mode 100644 index 00000000..776e649f --- /dev/null +++ b/apps/start/src/components/overview/overview-view-toggle.tsx @@ -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( + 'view', + parseAsStringEnum(['table', 'chart']) + .withDefault(defaultView) + .withOptions({ history: 'push' }), + ); + + return ( +
+ +
+ ); +} + +export function useOverviewView() { + const [view, setView] = useQueryState( + 'view', + parseAsStringEnum(['table', 'chart']) + .withDefault('table') + .withOptions({ history: 'push' }), + ); + + return [view, setView] as const; +} + diff --git a/apps/start/src/components/overview/overview-widget-table.tsx b/apps/start/src/components/overview/overview-widget-table.tsx index c04d2ae0..be9f857e 100644 --- a/apps/start/src/components/overview/overview-widget-table.tsx +++ b/apps/start/src/components/overview/overview-widget-table.tsx @@ -61,7 +61,7 @@ export const OverviewWidgetTable = ({ { return ( @@ -109,15 +109,6 @@ export function OverviewWidgetTableLoading({ render: () => , width: 'w-full', }, - { - name: 'BR', - render: () => , - width: '60px', - }, - // { - // name: 'Duration', - // render: () => , - // }, { name: 'Sessions', render: () => , @@ -142,27 +133,24 @@ function getPath(path: string, showDomain = false) { export function OverviewWidgetTablePages({ data, - lastColumnName, className, showDomain = false, }: { className?: string; - lastColumnName: string; data: { origin: string; path: string; - avg_duration: number; - bounce_rate: number; sessions: number; - revenue: number; + pageviews: number; + revenue?: number; }[]; showDomain?: boolean; }) { const [_filters, setFilter] = useEventQueryFilters(); const number = useNumber(); const maxSessions = Math.max(...data.map((item) => item.sessions)); - const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0); - const hasRevenue = data.some((item) => item.revenue > 0); + const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0); + const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0); return ( 0 ? revenue / totalRevenue : 0; + return ( +
+ + {revenue > 0 ? number.currency(revenue / 100) : '-'} + + +
+ ); + }, + } as const, + ] + : []), { - name: 'BR', - width: '60px', - responsive: { priority: 6 }, // Hidden when space is tight + name: 'Views', + width: '84px', + responsive: { priority: 2 }, // Always show if possible render(item) { - return number.shortWithUnit(item.bounce_rate, '%'); + return ( +
+ + {number.short(item.pageviews)} + +
+ ); }, }, { - name: 'Duration', - width: '75px', - responsive: { priority: 7 }, // Hidden when space is tight + name: 'Sess.', + width: '84px', + responsive: { priority: 2 }, // Always show if possible render(item) { - return number.shortWithUnit(item.avg_duration, 'min'); + return ( +
+ + {number.short(item.sessions)} + +
+ ); + }, + }, + ]} + /> + ); +} + +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 ( + item.path + item.origin} + getColumnPercentage={(item) => item.sessions / maxSessions} + columns={[ + { + name: 'Path', + width: 'w-full', + responsive: { priority: 1 }, // Always visible + render(item) { + return ( + +
+ + + + + +
+
+ ); }, }, ...(hasRevenue @@ -237,17 +340,16 @@ export function OverviewWidgetTablePages({ width: '100px', responsive: { priority: 3 }, // Always show if possible render(item: (typeof data)[number]) { + const revenue = item.revenue ?? 0; const revenuePercentage = - totalRevenue > 0 ? item.revenue / totalRevenue : 0; + totalRevenue > 0 ? revenue / totalRevenue : 0; return (
- {item.revenue > 0 - ? number.currency(item.revenue / 100) - : '-'} + {revenue > 0 ? number.currency(revenue / 100) : '-'}
@@ -373,6 +475,7 @@ export function OverviewWidgetTableGeneric({ const maxSessions = Math.max(...data.map((item) => item.sessions)); const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0); const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0); + const hasPageviews = data.some((item) => item.pageviews > 0); return (
+ ); + }, + } as const, + ] + : []), { - name: 'Sessions', + name: 'Sess.', width: '84px', - responsive: { priority: 2 }, // Always show if possible + responsive: { priority: 2 }, render(item) { return (
@@ -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 ( + item.id} + getColumnPercentage={(item) => item.count / maxCount} + columns={[ + { + name: 'Event', + width: 'w-full', + responsive: { priority: 1 }, + render(item) { + return ( +
+ + +
+ ); + }, + }, + { + name: 'Count', + width: '84px', + responsive: { priority: 2 }, + render(item) { + return ( +
+ + {number.short(item.count)} + +
+ ); + }, + }, + ]} + /> + ); +} diff --git a/apps/start/src/components/overview/overview-widget.tsx b/apps/start/src/components/overview/overview-widget.tsx index cb30c431..5ad0b4c0 100644 --- a/apps/start/src/components/overview/overview-widget.tsx +++ b/apps/start/src/components/overview/overview-widget.tsx @@ -1,8 +1,8 @@ import { useThrottle } from '@/hooks/use-throttle'; import { cn } from '@/utils/cn'; -import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react'; +import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react'; import { last } from 'ramda'; -import { Children, useEffect, useRef, useState } from 'react'; +import { Children, useCallback, useEffect, useRef, useState } from 'react'; import { DropdownMenu, @@ -11,6 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; +import { Input } from '../ui/input'; import type { WidgetHeadProps, WidgetTitleProps } from '../widget'; import { WidgetHead as WidgetHeadBase } from '../widget'; @@ -169,6 +170,128 @@ export function WidgetButtons({ ); } +interface WidgetTab { + key: T; + label: string; +} + +interface WidgetHeadSearchableProps { + tabs: WidgetTab[]; + activeTab: T; + className?: string; + onTabChange: (key: T) => void; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; +} + +export function WidgetHeadSearchable({ + tabs, + className, + activeTab, + onTabChange, + searchValue, + onSearchChange, + searchPlaceholder = 'Search', +}: WidgetHeadSearchableProps) { + const scrollRef = useRef(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 ( +
+ {/* Scrollable tabs container */} +
+ {/* Left gradient */} +
+ + {/* Scrollable tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Right gradient */} +
+
+ + {/* Search input */} + {onSearchChange && ( +
+ + 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" + /> +
+ )} +
+ ); +} + export function WidgetFooter({ className, children, diff --git a/apps/start/src/components/overview/useOverviewWidget.tsx b/apps/start/src/components/overview/useOverviewWidget.tsx index 8d03a30b..52fb56b8 100644 --- a/apps/start/src/components/overview/useOverviewWidget.tsx +++ b/apps/start/src/components/overview/useOverviewWidget.tsx @@ -33,7 +33,10 @@ export function useOverviewWidget( export function useOverviewWidgetV2( key: string, - widgets: Record, + widgets: Record< + T, + { title: string; btn: string; meta?: any; hide?: boolean } + >, ) { const keys = Object.keys(widgets) as T[]; const [widget, setWidget] = useQueryState( diff --git a/apps/start/src/components/realtime/realtime-live-histogram.tsx b/apps/start/src/components/realtime/realtime-live-histogram.tsx index cd97312c..8783e668 100644 --- a/apps/start/src/components/realtime/realtime-live-histogram.tsx +++ b/apps/start/src/components/realtime/realtime-live-histogram.tsx @@ -16,7 +16,6 @@ import { YAxis, } from 'recharts'; import { AnimatedNumber } from '../animated-number'; -import { BarShapeBlue } from '../charts/common-bar'; import { SerieIcon } from '../report-chart/common/serie-icon'; interface RealtimeLiveHistogramProps { @@ -87,10 +86,8 @@ export function RealtimeLiveHistogram({ diff --git a/apps/start/src/components/report-chart/bar/chart.tsx b/apps/start/src/components/report-chart/bar/chart.tsx index db3901fd..13e4b6db 100644 --- a/apps/start/src/components/report-chart/bar/chart.tsx +++ b/apps/start/src/components/report-chart/bar/chart.tsx @@ -4,160 +4,322 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useNumber } from '@/hooks/use-numer-formatter'; +import { useVisibleSeries } from '@/hooks/use-visible-series'; import type { IChartData } from '@/trpc/client'; import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu'; +import { SearchIcon } from 'lucide-react'; import { useMemo, useState } from 'react'; import { round } from '@openpanel/common'; import { NOT_SET_VALUE } from '@openpanel/constants'; -import { OverviewWidgetTable } from '../../overview/overview-widget-table'; +import { DeltaChip } from '@/components/delta-chip'; import { PreviousDiffIndicator } from '../common/previous-diff-indicator'; import { SerieIcon } from '../common/serie-icon'; import { SerieName } from '../common/serie-name'; import { useReportChartContext } from '../context'; +type SortOption = + | 'count-desc' + | 'count-asc' + | 'name-asc' + | 'name-desc' + | 'percent-desc' + | 'percent-asc'; + interface Props { data: IChartData; } export function Chart({ data }: Props) { const [isOpen, setOpen] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [sortBy, setSortBy] = useState('count-desc'); const { isEditMode, report: { metric, limit, previous }, - options: { onClick, dropdownMenuContent, columns }, + options: { onClick, dropdownMenuContent }, } = useReportChartContext(); const number = useNumber(); - const series = useMemo( - () => (isEditMode ? data.series : data.series.slice(0, limit || 10)), - [data, isEditMode, limit], - ); - const maxCount = Math.max( - ...series.map((serie) => serie.metrics[metric] ?? 0), - ); - const tableColumns = [ - { - name: columns?.[0] || 'Name', - width: 'w-full', - render: (serie: (typeof series)[0]) => { - const isClickable = !serie.names.includes(NOT_SET_VALUE) && onClick; - const isDropDownEnabled = - !serie.names.includes(NOT_SET_VALUE) && - (dropdownMenuContent?.(serie) || []).length > 0; + // Use useVisibleSeries to add index property for colors + const { series: allSeriesWithIndex } = useVisibleSeries(data, 500); - return ( - - setOpen((p) => (p === serie.id ? null : serie.id)) - } - open={isOpen === serie.id} - > - e.preventDefault(), - onClick: () => setOpen(serie.id), - } - : {})} - > -
onClick(serie), - } - : {})} - > - - -
-
- - - {dropdownMenuContent?.(serie).map((item) => ( - - {item.icon && } - {item.title} - - ))} - - -
- ); - }, - }, - // Percentage column - { - name: '%', - width: '70px', - render: (serie: (typeof series)[0]) => ( -
- {number.format( - round((serie.metrics.sum / data.metrics.sum) * 100, 2), - )} - % -
- ), - }, + const totalSum = data.metrics.sum || 1; - // Previous value column - { - name: 'Previous', - width: '130px', - render: (serie: (typeof series)[0]) => ( -
-
- {number.format(serie.metrics.previous?.[metric]?.value)} -
- -
- ), - }, + // 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(); + sortedByCount.forEach((serie, idx) => { + rankMap.set(serie.id, idx + 1); + }); + return allSeriesWithIndex.map((serie) => ({ + ...serie, + originalRank: rankMap.get(serie.id) ?? 0, + })); + }, [allSeriesWithIndex]); - // Main count column (always last) - { - name: 'Count', - width: '80px', - render: (serie: (typeof series)[0]) => ( -
- {number.format(serie.metrics.sum)} -
- ), - }, - ]; + // 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 ( -
+ {isEditMode && ( +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + size="sm" + /> +
+ +
)} - > - serie.id} - columns={tableColumns.filter((column) => { - if (!previous && column.name === 'Previous') { - return false; - } - return true; - })} - getColumnPercentage={(serie) => serie.metrics.sum / maxCount} - className={cn(isEditMode ? 'min-h-[358px]' : 'min-h-0')} - /> +
+
+ {series.map((serie, idx) => { + const isClickable = + !serie.names.includes(NOT_SET_VALUE) && !!onClick; + const isDropDownEnabled = + !serie.names.includes(NOT_SET_VALUE) && + (dropdownMenuContent?.(serie) || []).length > 0; + + const color = getChartColor(serie.index); + const percentOfTotal = round( + (serie.metrics.sum / totalSum) * 100, + 1, + ); + + return ( +
{ + if (isClickable && !isDropDownEnabled) { + onClick?.(serie); + } + }} + onKeyDown={(e) => { + if (!isClickable || isDropDownEnabled) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(serie); + } + }} + > + {/* Subtle accent glow */} +
+ +
+
+
+
+ +
+ +
+
+
+ + Rank {serie.originalRank} + +
+ + + setOpen((p) => (p === serie.id ? null : serie.id)) + } + open={isOpen === serie.id} + > + e.preventDefault(), + onClick: (e) => { + e.stopPropagation(); + setOpen(serie.id); + }, + } + : {})} + > +
{ + e.stopPropagation(); + onClick?.(serie); + }, + } + : {})} + > + +
+
+ + + {dropdownMenuContent?.(serie).map((item) => ( + { + e.stopPropagation(); + item.onClick(); + }} + > + {item.icon && ( + + )} + {item.title} + + ))} + + +
+
+
+ +
+
+
+ {number.format(serie.metrics.sum)} +
+ {previous && serie.metrics.previous?.[metric] && ( + + {serie.metrics.previous[metric].diff?.toFixed(1)}% + + )} +
+
+
+ + {/* Bar */} +
+
+
+
+
+
+
+ ); + })} +
+
); } diff --git a/apps/start/src/components/report-chart/bar/index.tsx b/apps/start/src/components/report-chart/bar/index.tsx index 28f2779b..877f104d 100644 --- a/apps/start/src/components/report-chart/bar/index.tsx +++ b/apps/start/src/components/report-chart/bar/index.tsx @@ -1,6 +1,7 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { cn } from '@/utils/cn'; import { AspectContainer } from '../aspect-container'; import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; @@ -12,7 +13,7 @@ export function ReportBarChart() { const trpc = useTRPC(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { + trpc.chart.aggregate.queryOptions(report, { placeholderData: keepPreviousData, staleTime: 1000 * 60 * 1, enabled: !isLazyLoading, @@ -26,7 +27,6 @@ export function ReportBarChart() { ) { return ; } - if (res.isError) { return ; } @@ -39,22 +39,62 @@ export function ReportBarChart() { } function Loading() { + const { isEditMode } = useReportChartContext(); return ( - - {Array.from({ length: 10 }).map((_, index) => ( -
-
-
-
-
-
-
+
+
+
+ {Array.from({ length: 10 }).map((_, index) => ( +
+
+
+
+ {/* Icon skeleton */} +
+ +
+ {/* Rank badge skeleton */} +
+
+
+
+ + {/* Name skeleton */} +
+
+
+ + {/* Count skeleton */} +
+
+
+
+ + {/* Bar skeleton */} +
+
+
+
+
+
+
+ ))}
- ))} - +
+
); } diff --git a/apps/start/src/components/report-chart/common/empty.tsx b/apps/start/src/components/report-chart/common/empty.tsx index 73341a7c..9f4ecab7 100644 --- a/apps/start/src/components/report-chart/common/empty.tsx +++ b/apps/start/src/components/report-chart/common/empty.tsx @@ -32,7 +32,7 @@ export function ReportChartEmpty({
Ready when you're diff --git a/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx b/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx index 17be3bf9..38988963 100644 --- a/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx +++ b/apps/start/src/components/report-chart/common/previous-diff-indicator.tsx @@ -2,6 +2,7 @@ import { useNumber } from '@/hooks/use-numer-formatter'; import { cn } from '@/utils/cn'; import { ArrowDownIcon, ArrowUpIcon } from 'lucide-react'; +import { DeltaChip } from '@/components/delta-chip'; import { useReportChartContext } from '../context'; export function getDiffIndicator( @@ -29,7 +30,7 @@ interface PreviousDiffIndicatorProps { children?: React.ReactNode; inverted?: boolean; className?: string; - size?: 'sm' | 'lg' | 'md' | 'xs'; + size?: 'sm' | 'lg' | 'md'; } export function PreviousDiffIndicator({ @@ -81,7 +82,6 @@ export function PreviousDiffIndicator({ variant, size === 'lg' && 'size-8', size === 'md' && 'size-6', - size === 'xs' && 'size-3', )} > {renderIcon()} @@ -97,7 +97,7 @@ interface PreviousDiffIndicatorPureProps { diff?: number | null | undefined; state?: string | null | undefined; inverted?: boolean; - size?: 'sm' | 'lg' | 'md' | 'xs'; + size?: 'sm' | 'lg' | 'md'; className?: string; showPrevious?: boolean; } @@ -133,25 +133,35 @@ export function PreviousDiffIndicatorPure({ }; return ( -
-
- {renderIcon()} -
{diff.toFixed(1)}% -
+ ); + + // return ( + //
+ //
+ // {renderIcon()} + //
+ // {diff.toFixed(1)}% + //
+ // ); } diff --git a/apps/start/src/components/report-chart/common/serie-icon.urls.ts b/apps/start/src/components/report-chart/common/serie-icon.urls.ts index 03b48906..173a6d6f 100644 --- a/apps/start/src/components/report-chart/common/serie-icon.urls.ts +++ b/apps/start/src/components/report-chart/common/serie-icon.urls.ts @@ -30,6 +30,7 @@ const data = { whale: 'https://whale.naver.com', wechat: 'https://wechat.com', chrome: 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg', + 'mobile chrome': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg', 'chrome webview': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg', 'chrome headless': 'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg', chromecast: 'https://upload.wikimedia.org/wikipedia/commons/archive/2/26/20161130022732%21Chromecast_cast_button_icon.svg', @@ -39,6 +40,7 @@ const data = { edge: 'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png', facebook: 'https://facebook.com', firefox: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png', + 'mobile firefox': 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png', github: 'https://github.com', gmail: 'https://mail.google.com', google: 'https://google.com', diff --git a/apps/start/src/components/report-chart/metric/index.tsx b/apps/start/src/components/report-chart/metric/index.tsx index ade245d0..7d8e5829 100644 --- a/apps/start/src/components/report-chart/metric/index.tsx +++ b/apps/start/src/components/report-chart/metric/index.tsx @@ -1,6 +1,9 @@ import { useTRPC } from '@/integrations/trpc/react'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; +import { AspectContainer } from '../aspect-container'; +import { ReportChartEmpty } from '../common/empty'; +import { ReportChartError } from '../common/error'; import { useReportChartContext } from '../context'; import { Chart } from './chart'; @@ -47,28 +50,18 @@ export function Loading() { ); } -export function Error() { +function Error() { return ( -
-
- -
-
-
Error fetching data
-
-
+ + + ); } -export function Empty() { +function Empty() { return ( -
-
- -
-
-
No data
-
-
+ + + ); } diff --git a/apps/start/src/components/report-chart/pie/index.tsx b/apps/start/src/components/report-chart/pie/index.tsx index 58f7edcc..bf2589cc 100644 --- a/apps/start/src/components/report-chart/pie/index.tsx +++ b/apps/start/src/components/report-chart/pie/index.tsx @@ -13,7 +13,7 @@ export function ReportPieChart() { const trpc = useTRPC(); const res = useQuery( - trpc.chart.chart.queryOptions(report, { + trpc.chart.aggregate.queryOptions(report, { placeholderData: keepPreviousData, staleTime: 1000 * 60 * 1, enabled: !isLazyLoading, diff --git a/apps/start/src/components/report/sidebar/ReportSeries.tsx b/apps/start/src/components/report/sidebar/ReportSeries.tsx index 216573b4..103cceec 100644 --- a/apps/start/src/components/report/sidebar/ReportSeries.tsx +++ b/apps/start/src/components/report/sidebar/ReportSeries.tsx @@ -386,6 +386,7 @@ export function ReportSeries() { }} placeholder="Select event" items={eventNames} + className="flex-1" /> {showFormula && ( diff --git a/apps/start/src/components/report/sidebar/ReportSettings.tsx b/apps/start/src/components/report/sidebar/ReportSettings.tsx index 589da9ae..a212db84 100644 --- a/apps/start/src/components/report/sidebar/ReportSettings.tsx +++ b/apps/start/src/components/report/sidebar/ReportSettings.tsx @@ -50,7 +50,7 @@ export function ReportSettings() { return (

Settings

-
+
{fields.includes('previous') && (
); diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index 4313d804..539e4cb1 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx @@ -1,18 +1,15 @@ import { FullPageEmptyState } from '@/components/full-page-empty-state'; -import { OverviewFilterButton } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewInterval } from '@/components/overview/overview-interval'; import { OverviewRange } from '@/components/overview/overview-range'; import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { PageContainer } from '@/components/page-container'; import { PageHeader } from '@/components/page-header'; -import { Pagination } from '@/components/pagination'; import { FloatingPagination } from '@/components/pagination-floating'; import { ReportChart } from '@/components/report-chart'; import { Skeleton } from '@/components/skeleton'; import { Input } from '@/components/ui/input'; import { TableButtons } from '@/components/ui/table'; import { useAppContext } from '@/hooks/use-app-context'; -import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useNumber } from '@/hooks/use-numer-formatter'; import { useSearchQueryState } from '@/hooks/use-search-query-state'; import { useTRPC } from '@/integrations/trpc/react'; @@ -22,7 +19,7 @@ import type { IChartRange, IInterval } from '@openpanel/validation'; import { keepPreviousData, useQuery } from '@tanstack/react-query'; import { createFileRoute } from '@tanstack/react-router'; import { parseAsInteger, useQueryState } from 'nuqs'; -import { memo } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({ component: Component, @@ -42,30 +39,102 @@ function Component() { const trpc = useTRPC(); const take = 20; const { range, interval } = useOverviewOptions(); - const [filters] = useEventQueryFilters(); const [cursor, setCursor] = useQueryState( 'cursor', parseAsInteger.withDefault(1), ); const { debouncedSearch, setSearch, search } = useSearchQueryState(); - const query = useQuery( + + // Track if we should use backend search (when client-side filtering finds nothing) + const [useBackendSearch, setUseBackendSearch] = useState(false); + + // Reset to client-side filtering when search changes + useEffect(() => { + setUseBackendSearch(false); + setCursor(1); + }, [debouncedSearch, setCursor]); + + // Query for all pages (without search) - used for client-side filtering + const allPagesQuery = useQuery( trpc.event.pages.queryOptions( { projectId, - cursor, - take, - search: debouncedSearch, + cursor: 1, + take: 1000, + search: undefined, // No search - get all pages range, interval, - filters, }, { placeholderData: keepPreviousData, }, ), ); - const data = query.data ?? []; + + // Query for backend search (only when client-side filtering finds nothing) + const backendSearchQuery = useQuery( + trpc.event.pages.queryOptions( + { + projectId, + cursor: 1, + take: 1000, + search: debouncedSearch || undefined, + range, + interval, + }, + { + placeholderData: keepPreviousData, + enabled: useBackendSearch && !!debouncedSearch, + }, + ), + ); + + // Client-side filtering: filter all pages by search query + const clientSideFiltered = useMemo(() => { + if (!debouncedSearch || useBackendSearch) { + return allPagesQuery.data ?? []; + } + const searchLower = debouncedSearch.toLowerCase(); + return (allPagesQuery.data ?? []).filter( + (page) => + page.path.toLowerCase().includes(searchLower) || + page.origin.toLowerCase().includes(searchLower), + ); + }, [allPagesQuery.data, debouncedSearch, useBackendSearch]); + + // Check if client-side filtering found results + useEffect(() => { + if ( + debouncedSearch && + !useBackendSearch && + allPagesQuery.isSuccess && + clientSideFiltered.length === 0 + ) { + // No results from client-side filtering, switch to backend search + setUseBackendSearch(true); + } + }, [ + debouncedSearch, + useBackendSearch, + allPagesQuery.isSuccess, + clientSideFiltered.length, + ]); + + // Determine which data source to use + const allData = useBackendSearch + ? (backendSearchQuery.data ?? []) + : clientSideFiltered; + + const isLoading = useBackendSearch + ? backendSearchQuery.isLoading + : allPagesQuery.isLoading; + + // Client-side pagination: slice the items based on cursor + const startIndex = (cursor - 1) * take; + const endIndex = startIndex + take; + const data = allData.slice(startIndex, endIndex); + const totalPages = Math.ceil(allData.length / take); return ( @@ -77,24 +146,27 @@ function Component() { - { setSearch(e.target.value); - setCursor(0); + setCursor(1); }} /> - {data.length === 0 && !query.isLoading && ( + {data.length === 0 && !isLoading && ( )} - {query.isLoading && ( + {isLoading && (
@@ -105,7 +177,7 @@ function Component() { {data.map((page) => { return ( - {data.length !== 0 && ( + {allData.length !== 0 && (
1 ? () => setCursor(1) : undefined} - canNextPage={true} - canPreviousPage={cursor > 0} + canNextPage={cursor < totalPages} + canPreviousPage={cursor > 1} pageIndex={cursor - 1} nextPage={() => { - setCursor((p) => p + 1); + setCursor((p) => Math.min(p + 1, totalPages)); }} previousPage={() => { - setCursor((p) => p - 1); + setCursor((p) => Math.max(p - 1, 1)); }} />
diff --git a/apps/start/src/routes/_app.$organizationId.tsx b/apps/start/src/routes/_app.$organizationId.tsx index a907dc0f..c44748e6 100644 --- a/apps/start/src/routes/_app.$organizationId.tsx +++ b/apps/start/src/routes/_app.$organizationId.tsx @@ -1,4 +1,5 @@ import FullPageLoadingState from '@/components/full-page-loading-state'; +import FeedbackPrompt from '@/components/organization/feedback-prompt'; import SupporterPrompt from '@/components/organization/supporter-prompt'; import { LinkButton } from '@/components/ui/button'; import { useTRPC } from '@/integrations/trpc/react'; @@ -154,6 +155,7 @@ function Component() { )} + ); } diff --git a/packages/db/index.ts b/packages/db/index.ts index aaa9b5a7..51ca6b9f 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -28,5 +28,6 @@ export * from './src/types'; export * from './src/clickhouse/query-builder'; export * from './src/services/import.service'; export * from './src/services/overview.service'; +export * from './src/services/pages.service'; export * from './src/services/insights'; export * from './src/session-context'; diff --git a/packages/db/src/clickhouse/client.ts b/packages/db/src/clickhouse/client.ts index 50cfbb17..0260a342 100644 --- a/packages/db/src/clickhouse/client.ts +++ b/packages/db/src/clickhouse/client.ts @@ -90,6 +90,7 @@ function getClickhouseSettings(): ClickHouseSettings { {}; return { + distributed_product_mode: 'allow', date_time_input_format: 'best_effort', ...(!process.env.CLICKHOUSE_SETTINGS_REMOVE_CONVERT_ANY_JOIN ? { diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index 066b7b7c..80bf708c 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -519,7 +519,7 @@ export class Query { const query = this.buildQuery(); console.log( 'query', - `${query} SETTINGS session_timezone = '${this.timezone}'`, + `${query.replaceAll('\n', ' ').replaceAll('\t', ' ').replaceAll('\r', ' ')} SETTINGS session_timezone = '${this.timezone}'`, ); const result = await this.client.query({ diff --git a/packages/db/src/engine/index.ts b/packages/db/src/engine/index.ts index d9e94d96..4b0bd3d2 100644 --- a/packages/db/src/engine/index.ts +++ b/packages/db/src/engine/index.ts @@ -1,7 +1,16 @@ -import { getPreviousMetric } from '@openpanel/common'; - -import type { FinalChart, IChartInput } from '@openpanel/validation'; -import { getChartPrevStartEndDate } from '../services/chart.service'; +import { getPreviousMetric, groupByLabels } from '@openpanel/common'; +import type { ISerieDataItem } from '@openpanel/common'; +import { alphabetIds } from '@openpanel/constants'; +import type { + FinalChart, + IChartEventItem, + IChartInput, +} from '@openpanel/validation'; +import { chQuery } from '../clickhouse/client'; +import { + getAggregateChartSql, + getChartPrevStartEndDate, +} from '../services/chart.service'; import { getOrganizationSubscriptionChartEndDate, getSettingsForProject, @@ -69,7 +78,280 @@ export async function executeChart(input: IChartInput): Promise { 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 { + // 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( + 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( + 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 | 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( + getAggregateChartSql(queryInput), + { + session_timezone: timezone, + }, + ); + + if (queryResult.length === 0 && normalized.breakdowns.length > 0) { + queryResult = await chQuery( + 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 | undefined = + normalized.breakdowns.length > 0 && grouped.name.length > 1 + ? {} + : undefined; + + if (breakdowns) { + normalized.breakdowns.forEach((breakdown, idx) => { + const breakdownNamePart = grouped.name[idx + 1]; + if (breakdownNamePart) { + breakdowns[breakdown.name] = breakdownNamePart; + } + }); + } + + const filters = [...event.filters]; + if (breakdownValue && normalized.breakdowns.length > 0) { + normalized.breakdowns.forEach((breakdown, idx) => { + const breakdownNamePart = grouped.name[idx + 1]; + if (breakdownNamePart) { + filters.push({ + id: `breakdown-${idx}`, + name: breakdown.name, + operator: 'is', + value: [breakdownNamePart], + }); + } + }); + } + + const concrete: ConcreteSeries = { + id: `${event.name}-${grouped.name.join('-')}-${i}`, + definitionId: definition.id ?? alphabetIds[i] ?? `series-${i}`, + definitionIndex: i, + name: grouped.name, + context: { + event: event.name, + filters, + breakdownValue, + breakdowns, + }, + data: grouped.data, + definition, + }; + + previousFetchedSeries.push(concrete); + }); + } + + // Compute formula series for previous period + previousSeries = compute(previousFetchedSeries, normalized.series); + } + + // Stage 5: Format final output with previous period data + const includeAlphaIds = normalized.series.length > 1; + const response = format( + computedSeries, + normalized.series, + includeAlphaIds, + previousSeries, + normalized.limit, + ); + + return response; +} + // Export as ChartEngine for backward compatibility export const ChartEngine = { execute: executeChart, }; + +// Export aggregate chart engine +export const AggregateChartEngine = { + execute: executeAggregateChart, +}; diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index f9e60337..735e6a09 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -348,6 +348,246 @@ export function getChartSql({ return sql; } +export function getAggregateChartSql({ + event, + breakdowns, + startDate, + endDate, + projectId, + limit, + timezone, +}: Omit & { + 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(); + + // Always need id for the join + fields.add('id'); + + // Collect from filters + event.filters + .filter((f) => f.name.startsWith('profile.')) + .forEach((f) => { + const fieldName = f.name.replace('profile.', '').split('.')[0]; + if (fieldName && fieldName === 'properties') { + fields.add('properties'); + } else if ( + fieldName && + ['email', 'first_name', 'last_name'].includes(fieldName) + ) { + fields.add(fieldName); + } + }); + + // Collect from breakdowns + breakdowns + .filter((b) => b.name.startsWith('profile.')) + .forEach((b) => { + const fieldName = b.name.replace('profile.', '').split('.')[0]; + if (fieldName && fieldName === 'properties') { + fields.add('properties'); + } else if ( + fieldName && + ['email', 'first_name', 'last_name'].includes(fieldName) + ) { + fields.add(fieldName); + } + }); + + return Array.from(fields); + }; + + // Create profiles CTE if profiles are needed + const profilesJoinRef = + anyFilterOnProfile || anyBreakdownOnProfile + ? 'LEFT ANY JOIN profile ON profile.id = profile_id' + : ''; + + if (anyFilterOnProfile || anyBreakdownOnProfile) { + const profileFields = getProfileFields(); + const selectFields = profileFields.map((field) => { + if (field === 'id') { + return 'id as "profile.id"'; + } + if (field === 'properties') { + return 'properties as "profile.properties"'; + } + if (field === 'email') { + return 'email as "profile.email"'; + } + if (field === 'first_name') { + return 'first_name as "profile.first_name"'; + } + if (field === 'last_name') { + return 'last_name as "profile.last_name"'; + } + return field; + }); + + addCte( + 'profile', + `SELECT ${selectFields.join(', ')} + FROM ${TABLE_NAMES.profiles} FINAL + WHERE project_id = ${sqlstring.escape(projectId)}`, + ); + + sb.joins.profiles = profilesJoinRef; + } + + // Date range filters + if (startDate) { + sb.where.startDate = `created_at >= toDateTime('${formatClickhouseDate(startDate)}')`; + } + + if (endDate) { + sb.where.endDate = `created_at <= toDateTime('${formatClickhouseDate(endDate)}')`; + } + + // Add a constant date field for aggregate charts (groupByLabels expects it) + // Use startDate as the date value since we're aggregating across the entire range + sb.select.date = `${sqlstring.escape(startDate)} as date`; + + // Use CTE to define top breakdown values once, then reference in WHERE clause + if (breakdowns.length > 0 && limit) { + const breakdownSelects = breakdowns + .map((b) => getSelectPropertyKey(b.name)) + .join(', '); + + addCte( + 'top_breakdowns', + `SELECT ${breakdownSelects} + FROM ${TABLE_NAMES.events} e + ${profilesJoinRef ? `${profilesJoinRef} ` : ''}${getWhereWithoutBar()} + GROUP BY ${breakdownSelects} + ORDER BY count(*) DESC + LIMIT ${limit}`, + ); + + // Filter main query to only include top breakdown values + sb.where.bar = `(${breakdowns.map((b) => getSelectPropertyKey(b.name)).join(',')}) IN (SELECT * FROM top_breakdowns)`; + } + + // Add breakdowns to SELECT and GROUP BY + breakdowns.forEach((breakdown, index) => { + // Breakdowns start at label_1 (label_0 is reserved for event name) + const key = `label_${index + 1}`; + sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`; + sb.groupBy[key] = `${key}`; + }); + + // Always group by label_0 (event name) for aggregate charts + sb.groupBy.label_0 = 'label_0'; + + // Default count aggregation + sb.select.count = 'count(*) as count'; + + // Handle different segments + if (event.segment === 'user') { + sb.select.count = 'countDistinct(profile_id) as count'; + } + + if (event.segment === 'session') { + sb.select.count = 'countDistinct(session_id) as count'; + } + + if (event.segment === 'user_average') { + sb.select.count = + 'COUNT(*)::float / COUNT(DISTINCT profile_id)::float as count'; + } + + const mathFunction = { + property_sum: 'sum', + property_average: 'avg', + property_max: 'max', + property_min: 'min', + }[event.segment as string]; + + if (mathFunction && event.property) { + const propertyKey = getSelectPropertyKey(event.property); + + if (isNumericColumn(event.property)) { + sb.select.count = `${mathFunction}(${propertyKey}) as count`; + sb.where.property = `${propertyKey} IS NOT NULL`; + } else { + sb.select.count = `${mathFunction}(toFloat64OrNull(${propertyKey})) as count`; + sb.where.property = `${propertyKey} IS NOT NULL AND notEmpty(${propertyKey})`; + } + } + + if (event.segment === 'one_event_per_user') { + sb.from = `( + SELECT DISTINCT ON (profile_id) * from ${TABLE_NAMES.events} ${getJoins()} WHERE ${join( + sb.where, + ' AND ', + )} + ORDER BY profile_id, created_at DESC + ) as subQuery`; + sb.joins = {}; + + const sql = getSql(); + console.log('-- Aggregate Chart --'); + console.log(sql.replaceAll(/[\n\r]/g, ' ')); + console.log('-- End --'); + return sql; + } + + // Order by count DESC (biggest first) for aggregate charts + sb.orderBy.count = 'count DESC'; + + // Apply limit if specified + if (limit) { + sb.limit = limit; + } + + const sql = getSql(); + console.log('-- Aggregate Chart --'); + console.log(sql.replaceAll(/[\n\r]/g, ' ')); + console.log('-- End --'); + return sql; +} + function isNumericColumn(columnName: string): boolean { const numericColumns = ['duration', 'revenue', 'longitude', 'latitude']; return numericColumns.includes(columnName); diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index a58c05a2..7a333518 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -11,6 +11,12 @@ import { getEventFiltersWhereClause } from './chart.service'; // Constants const ROLLUP_DATE_PREFIX = '1970-01-01'; +// Toggle revenue tracking in overview queries +const INCLUDE_REVENUE = true; // TODO: Make this configurable later + +// Maximum number of records to return (for detail modals) +const MAX_RECORDS_LIMIT = 1000; + const COLUMN_PREFIX_MAP: Record = { region: 'country', city: 'country', @@ -47,8 +53,6 @@ export const zGetTopPagesInput = z.object({ filters: z.array(z.any()), startDate: z.string(), endDate: z.string(), - cursor: z.number().optional(), - limit: z.number().optional(), }); export type IGetTopPagesInput = z.infer & { @@ -61,8 +65,6 @@ export const zGetTopEntryExitInput = z.object({ startDate: z.string(), endDate: z.string(), mode: z.enum(['entry', 'exit']), - cursor: z.number().optional(), - limit: z.number().optional(), }); export type IGetTopEntryExitInput = z.infer & { @@ -97,14 +99,20 @@ export const zGetTopGenericInput = z.object({ 'os', 'os_version', ]), - cursor: z.number().optional(), - limit: z.number().optional(), }); export type IGetTopGenericInput = z.infer & { timezone: string; }; +export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({ + interval: zTimeInterval, +}); + +export type IGetTopGenericSeriesInput = z.infer & { + timezone: string; +}; + export const zGetUserJourneyInput = z.object({ projectId: z.string(), filters: z.array(z.any()), @@ -543,18 +551,27 @@ export class OverviewService { filters, startDate, endDate, - cursor = 1, - limit = 10, timezone, }: IGetTopPagesInput) { - const pageStatsQuery = clix(this.client, timezone) - .select([ - 'origin', - 'path', - `last_value(properties['__title']) as title`, - 'uniq(session_id) as count', - 'round(avg(duration)/1000, 2) as avg_duration', - ]) + const selectColumns: (string | null | undefined | false)[] = [ + 'origin', + 'path', + 'uniq(session_id) as sessions', + 'count() as pageviews', + ]; + + if (INCLUDE_REVENUE) { + selectColumns.push('sum(revenue) as revenue'); + } + + const query = clix(this.client, timezone) + .select<{ + origin: string; + path: string; + sessions: number; + pageviews: number; + revenue?: number; + }>(selectColumns) .from(TABLE_NAMES.events, false) .where('project_id', '=', projectId) .where('name', '=', 'screen_view') @@ -563,57 +580,12 @@ export class OverviewService { clix.datetime(startDate, 'toDateTime'), clix.datetime(endDate, 'toDateTime'), ]) + .rawWhere(this.getRawWhereClause('events', filters)) .groupBy(['origin', 'path']) - .orderBy('count', 'DESC') - .limit(limit) - .offset((cursor - 1) * limit); - - const bounceStatsQuery = clix(this.client, timezone) - .select([ - 'entry_path', - 'entry_origin', - 'coalesce(round(countIf(is_bounce = 1 AND sign = 1) * 100.0 / countIf(sign = 1), 2), 0) as bounce_rate', - ]) - .from(TABLE_NAMES.sessions, true) - .where('sign', '=', 1) - .where('project_id', '=', projectId) - .where('created_at', 'BETWEEN', [ - clix.datetime(startDate, 'toDateTime'), - clix.datetime(endDate, 'toDateTime'), - ]) - .groupBy(['entry_path', 'entry_origin']); - - pageStatsQuery.rawWhere(this.getRawWhereClause('events', filters)); - bounceStatsQuery.rawWhere(this.getRawWhereClause('sessions', filters)); - - const mainQuery = clix(this.client, timezone) - .with('page_stats', pageStatsQuery) - .with('bounce_stats', bounceStatsQuery) - .select<{ - title: string; - origin: string; - path: string; - avg_duration: number; - bounce_rate: number; - sessions: number; - revenue: number; - }>([ - 'p.title', - 'p.origin', - 'p.path', - 'p.avg_duration', - 'p.count as sessions', - 'b.bounce_rate', - ]) - .from('page_stats p', false) - .leftJoin( - 'bounce_stats b', - 'p.path = b.entry_path AND p.origin = b.entry_origin', - ) .orderBy('sessions', 'DESC') - .limit(limit); + .limit(MAX_RECORDS_LIMIT); - return mainQuery.execute(); + return query.execute(); } async getTopEntryExit({ @@ -622,28 +594,27 @@ export class OverviewService { startDate, endDate, mode, - cursor = 1, - limit = 10, timezone, }: IGetTopEntryExitInput) { - const offset = (cursor - 1) * limit; + const selectColumns: (string | null | undefined | false)[] = [ + `${mode}_origin AS origin`, + `${mode}_path AS path`, + 'sum(sign) as sessions', + 'sum(sign * screen_view_count) as pageviews', + ]; + + if (INCLUDE_REVENUE) { + selectColumns.push('sum(revenue * sign) as revenue'); + } const query = clix(this.client, timezone) .select<{ origin: string; path: string; - avg_duration: number; - bounce_rate: number; sessions: number; - revenue: number; - }>([ - `${mode}_origin AS origin`, - `${mode}_path AS path`, - 'round(avg(duration * sign)/1000, 2) as avg_duration', - 'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) as bounce_rate', - 'sum(sign) as sessions', - 'sum(revenue * sign) as revenue', - ]) + pageviews: number; + revenue?: number; + }>(selectColumns) .from(TABLE_NAMES.sessions, true) .where('project_id', '=', projectId) .where('created_at', 'BETWEEN', [ @@ -653,8 +624,7 @@ export class OverviewService { .groupBy([`${mode}_origin`, `${mode}_path`]) .having('sum(sign)', '>', 0) .orderBy('sessions', 'DESC') - .limit(limit) - .offset(offset); + .limit(MAX_RECORDS_LIMIT); const mainQuery = this.withDistinctSessionsIfNeeded(query, { projectId, @@ -697,29 +667,29 @@ export class OverviewService { startDate, endDate, column, - cursor = 1, - limit = 10, timezone, }: IGetTopGenericInput) { const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null; - const offset = (cursor - 1) * limit; + + const selectColumns: (string | null | undefined | false)[] = [ + prefixColumn && `${prefixColumn} as prefix`, + `nullIf(${column}, '') as name`, + 'sum(sign) as sessions', + 'sum(sign * screen_view_count) as pageviews', + ]; + + if (INCLUDE_REVENUE) { + selectColumns.push('sum(revenue * sign) as revenue'); + } const query = clix(this.client, timezone) .select<{ prefix?: string; name: string; sessions: number; - bounce_rate: number; - avg_session_duration: number; - revenue: number; - }>([ - prefixColumn && `${prefixColumn} as prefix`, - `nullIf(${column}, '') as name`, - 'sum(sign) as sessions', - 'round(sum(sign * is_bounce) * 100.0 / sum(sign), 2) AS bounce_rate', - 'round(avgIf(duration, duration > 0 AND sign > 0), 2)/1000 AS avg_session_duration', - 'sum(revenue * sign) as revenue', - ]) + pageviews: number; + revenue?: number; + }>(selectColumns) .from(TABLE_NAMES.sessions, true) .where('project_id', '=', projectId) .where('created_at', 'BETWEEN', [ @@ -729,8 +699,7 @@ export class OverviewService { .groupBy([prefixColumn, column].filter(Boolean)) .having('sum(sign)', '>', 0) .orderBy('sessions', 'DESC') - .limit(limit) - .offset(offset); + .limit(MAX_RECORDS_LIMIT); const mainQuery = this.withDistinctSessionsIfNeeded(query, { projectId, @@ -743,6 +712,177 @@ export class OverviewService { return mainQuery.execute(); } + async getTopGenericSeries({ + projectId, + filters, + startDate, + endDate, + column, + interval, + timezone, + }: IGetTopGenericSeriesInput): Promise<{ + items: Array<{ + name: string; + prefix?: string; + data: Array<{ + date: string; + sessions: number; + pageviews: number; + revenue?: number; + }>; + total: { sessions: number; pageviews: number; revenue?: number }; + }>; + }> { + const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null; + const TOP_LIMIT = 15; + const fillConfig = this.getFillConfig(interval, startDate, endDate); + + // Step 1: Get top 15 items + const selectColumns: (string | null | undefined | false)[] = [ + prefixColumn && `${prefixColumn} as prefix`, + `nullIf(${column}, '') as name`, + 'sum(sign) as sessions', + 'sum(sign * screen_view_count) as pageviews', + ]; + + if (INCLUDE_REVENUE) { + selectColumns.push('sum(revenue * sign) as revenue'); + } + + const topItemsQuery = clix(this.client, timezone) + .select<{ + prefix?: string; + name: string; + sessions: number; + pageviews: number; + revenue?: number; + }>(selectColumns) + .from(TABLE_NAMES.sessions, true) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .groupBy([prefixColumn, column].filter(Boolean)) + .having('sum(sign)', '>', 0) + .orderBy('sessions', 'DESC') + .limit(TOP_LIMIT); + + const mainTopItemsQuery = this.withDistinctSessionsIfNeeded(topItemsQuery, { + projectId, + filters, + startDate, + endDate, + timezone, + }); + + const topItems = await mainTopItemsQuery.execute(); + + if (topItems.length === 0) { + return { items: [] }; + } + + // Step 2: Build time-series query for each top item + const where = this.getRawWhereClause('sessions', filters); + const timeSeriesSelectColumns: (string | null | undefined | false)[] = [ + `${clix.toStartOf('created_at', interval as any, timezone)} AS date`, + prefixColumn && `${prefixColumn} as prefix`, + `nullIf(${column}, '') as name`, + 'sum(sign) as sessions', + 'sum(sign * screen_view_count) as pageviews', + ]; + + if (INCLUDE_REVENUE) { + timeSeriesSelectColumns.push('sum(revenue * sign) as revenue'); + } + + const timeSeriesQuery = clix(this.client, timezone) + .select<{ + date: string; + prefix?: string; + name: string; + sessions: number; + pageviews: number; + revenue?: number; + }>(timeSeriesSelectColumns) + .from(TABLE_NAMES.sessions, true) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate, 'toDateTime'), + clix.datetime(endDate, 'toDateTime'), + ]) + .rawWhere(where) + .groupBy(['date', prefixColumn, column].filter(Boolean)) + .having('sum(sign)', '>', 0) + .orderBy('date', 'ASC') + .fill(fillConfig.from, fillConfig.to, fillConfig.step) + .transform({ + date: (item) => new Date(item.date).toISOString(), + }); + + const mainTimeSeriesQuery = this.withDistinctSessionsIfNeeded( + timeSeriesQuery, + { + projectId, + filters, + startDate, + endDate, + timezone, + }, + ); + + const timeSeriesData = await mainTimeSeriesQuery.execute(); + + // Step 3: Group time-series data by item and calculate totals + const itemsMap = new Map< + string, + { + name: string; + prefix?: string; + data: Array<{ + date: string; + sessions: number; + pageviews: number; + revenue?: number; + }>; + total: { sessions: number; pageviews: number; revenue?: number }; + } + >(); + + // Initialize items from topItems + for (const item of topItems) { + const key = `${item.prefix || ''}:${item.name}`; + itemsMap.set(key, { + name: item.name, + prefix: item.prefix, + data: [], + total: { + sessions: item.sessions, + pageviews: item.pageviews, + revenue: item.revenue ?? 0, + }, + }); + } + + // Populate time-series data + for (const row of timeSeriesData) { + const key = `${row.prefix || ''}:${row.name}`; + const item = itemsMap.get(key); + if (item) { + item.data.push({ + date: row.date, + sessions: row.sessions, + pageviews: row.pageviews, + revenue: row.revenue, + }); + } + } + + return { + items: Array.from(itemsMap.values()), + }; + } + async getUserJourney({ projectId, filters, diff --git a/packages/db/src/services/pages.service.ts b/packages/db/src/services/pages.service.ts new file mode 100644 index 00000000..70f3ab58 --- /dev/null +++ b/packages/db/src/services/pages.service.ts @@ -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 { + // 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([ + '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); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index ccecf3d7..66badb85 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -35,7 +35,7 @@ import { } from '@openpanel/validation'; import { round } from '@openpanel/common'; -import { ChartEngine } from '@openpanel/db'; +import { AggregateChartEngine, ChartEngine } from '@openpanel/db'; import { differenceInDays, differenceInMonths, @@ -414,6 +414,42 @@ export const chartRouter = createTRPCRouter({ // Use new chart engine return ChartEngine.execute(input); }), + + aggregate: publicProcedure + .input(zChartInput) + .query(async ({ input, ctx }) => { + if (ctx.session.userId) { + const access = await getProjectAccess({ + projectId: input.projectId, + userId: ctx.session.userId, + }); + if (!access) { + const share = await db.shareOverview.findFirst({ + where: { + projectId: input.projectId, + }, + }); + + if (!share) { + throw TRPCAccessError('You do not have access to this project'); + } + } + } else { + const share = await db.shareOverview.findFirst({ + where: { + projectId: input.projectId, + }, + }); + + if (!share) { + throw TRPCAccessError('You do not have access to this project'); + } + } + + // Use aggregate chart engine (optimized for bar/pie charts) + return AggregateChartEngine.execute(input); + }), + cohort: protectedProcedure .input( z.object({ diff --git a/packages/trpc/src/routers/event.ts b/packages/trpc/src/routers/event.ts index bd62cd86..bf68bdd6 100644 --- a/packages/trpc/src/routers/event.ts +++ b/packages/trpc/src/routers/event.ts @@ -15,7 +15,7 @@ import { getEventList, getEventMetasCached, getSettingsForProject, - overviewService, + pagesService, sessionService, } from '@openpanel/db'; import { @@ -324,28 +324,17 @@ export const eventRouter = createTRPCRouter({ search: z.string().optional(), range: zRange, interval: zTimeInterval, - filters: z.array(zChartEventFilter).default([]), }), ) .query(async ({ input }) => { const { timezone } = await getSettingsForProject(input.projectId); const { startDate, endDate } = getChartStartEndDate(input, timezone); - if (input.search) { - input.filters.push({ - id: 'path', - name: 'path', - value: [input.search], - operator: 'contains', - }); - } - return overviewService.getTopPages({ + return pagesService.getTopPages({ projectId: input.projectId, - filters: input.filters, startDate, endDate, - cursor: input.cursor || 1, - limit: input.take, timezone, + search: input.search, }); }), diff --git a/packages/trpc/src/routers/overview.ts b/packages/trpc/src/routers/overview.ts index 83b58517..a28825bd 100644 --- a/packages/trpc/src/routers/overview.ts +++ b/packages/trpc/src/routers/overview.ts @@ -10,6 +10,7 @@ import { overviewService, zGetMetricsInput, zGetTopGenericInput, + zGetTopGenericSeriesInput, zGetTopPagesInput, zGetUserJourneyInput, } from '@openpanel/db'; @@ -305,6 +306,26 @@ export const overviewRouter = createTRPCRouter({ return current; }), + topGenericSeries: publicProcedure + .input( + zGetTopGenericSeriesInput.omit({ startDate: true, endDate: true }).extend({ + startDate: z.string().nullish(), + endDate: z.string().nullish(), + range: zRange, + }), + ) + .use(cacher) + .query(async ({ input }) => { + const { timezone } = await getSettingsForProject(input.projectId); + const { current } = await getCurrentAndPrevious( + { ...input, timezone }, + false, + timezone, + )(overviewService.getTopGenericSeries.bind(overviewService)); + + return current; + }), + userJourney: publicProcedure .input( zGetUserJourneyInput.omit({ startDate: true, endDate: true }).extend({