import type { IInterval } from '@openpanel/validation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts'; import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; import { SerieIcon } from '../report-chart/common/serie-icon'; import { OverviewLineChartTooltip } from './overview-line-chart-tooltip'; import { useNumber } from '@/hooks/use-numer-formatter'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; 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
); }