From b39d076b3237dce484fea1f6a4eafe91f42ba175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 22 Jan 2026 20:53:05 +0100 Subject: [PATCH] feat: add sortable overview widgets --- .../overview/overview-list-modal.tsx | 18 +- .../overview/overview-top-devices.tsx | 2 +- .../overview/overview-top-events.tsx | 8 +- .../components/overview/overview-top-geo.tsx | 2 +- .../overview/overview-top-pages.tsx | 2 +- .../overview/overview-top-sources.tsx | 2 +- .../overview/overview-widget-table.tsx | 174 ++++++++++++++++-- apps/start/src/components/widget-table.tsx | 26 ++- packages/db/src/services/overview.service.ts | 6 +- 9 files changed, 208 insertions(+), 32 deletions(-) diff --git a/apps/start/src/components/overview/overview-list-modal.tsx b/apps/start/src/components/overview/overview-list-modal.tsx index 0bee06cc..aa710bb4 100644 --- a/apps/start/src/components/overview/overview-list-modal.tsx +++ b/apps/start/src/components/overview/overview-list-modal.tsx @@ -4,7 +4,8 @@ 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 type React from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Input } from '../ui/input'; const ROW_HEIGHT = 36; @@ -106,7 +107,9 @@ export function OverviewListModal({ // Calculate totals and check for revenue const { maxSessions, totalRevenue, hasRevenue, hasPageviews } = useMemo(() => { - const maxSessions = Math.max(...filteredData.map((item) => item.sessions)); + const maxSessions = Math.max( + ...filteredData.map((item) => item.sessions), + ); const totalRevenue = filteredData.reduce( (sum, item) => sum + (item.revenue ?? 0), 0, @@ -152,7 +155,8 @@ export function OverviewListModal({
{columnName}
@@ -204,11 +208,14 @@ export function OverviewListModal({
{/* Main content cell */} -
{renderItem(item)}
+
+ {renderItem(item)} +
{/* Revenue cell */} {hasRevenue && ( @@ -261,4 +268,3 @@ export function OverviewListModal({ ); } - diff --git a/apps/start/src/components/overview/overview-top-devices.tsx b/apps/start/src/components/overview/overview-top-devices.tsx index c093d75b..31383643 100644 --- a/apps/start/src/components/overview/overview-top-devices.tsx +++ b/apps/start/src/components/overview/overview-top-devices.tsx @@ -351,7 +351,7 @@ export default function OverviewTopDevices({ ); const filteredData = useMemo(() => { - const data = (query.data ?? []).slice(0, 15); + const data = query.data ?? []; if (!searchQuery.trim()) { return data; } diff --git a/apps/start/src/components/overview/overview-top-events.tsx b/apps/start/src/components/overview/overview-top-events.tsx index 6b2fb678..a4d4dc7f 100644 --- a/apps/start/src/components/overview/overview-top-events.tsx +++ b/apps/start/src/components/overview/overview-top-events.tsx @@ -118,12 +118,12 @@ export default function OverviewTopEvents({ const filteredData = useMemo(() => { if (!searchQuery.trim()) { - return tableData.slice(0, 15); + return tableData; } const queryLower = searchQuery.toLowerCase(); - return tableData - .filter((item) => item.name?.toLowerCase().includes(queryLower)) - .slice(0, 15); + return tableData.filter((item) => + item.name?.toLowerCase().includes(queryLower), + ); }, [tableData, searchQuery]); const tabs = useMemo( diff --git a/apps/start/src/components/overview/overview-top-geo.tsx b/apps/start/src/components/overview/overview-top-geo.tsx index 2afaa58a..ac03ec0d 100644 --- a/apps/start/src/components/overview/overview-top-geo.tsx +++ b/apps/start/src/components/overview/overview-top-geo.tsx @@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { ); const filteredData = useMemo(() => { - const data = (query.data ?? []).slice(0, 15); + const data = query.data ?? []; if (!searchQuery.trim()) { return data; } diff --git a/apps/start/src/components/overview/overview-top-pages.tsx b/apps/start/src/components/overview/overview-top-pages.tsx index c4bf583c..5fe69967 100644 --- a/apps/start/src/components/overview/overview-top-pages.tsx +++ b/apps/start/src/components/overview/overview-top-pages.tsx @@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) { ); const filteredData = useMemo(() => { - const data = query.data?.slice(0, 15) ?? []; + const data = query.data ?? []; if (!searchQuery.trim()) { return data; } diff --git a/apps/start/src/components/overview/overview-top-sources.tsx b/apps/start/src/components/overview/overview-top-sources.tsx index c53ebfa3..05e7772d 100644 --- a/apps/start/src/components/overview/overview-top-sources.tsx +++ b/apps/start/src/components/overview/overview-top-sources.tsx @@ -97,7 +97,7 @@ export default function OverviewTopSources({ ); const filteredData = useMemo(() => { - const data = (query.data ?? []).slice(0, 15); + const data = query.data ?? []; if (!searchQuery.trim()) { return data; } diff --git a/apps/start/src/components/overview/overview-widget-table.tsx b/apps/start/src/components/overview/overview-widget-table.tsx index be9f857e..50a297c7 100644 --- a/apps/start/src/components/overview/overview-widget-table.tsx +++ b/apps/start/src/components/overview/overview-widget-table.tsx @@ -2,7 +2,8 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { useNumber } from '@/hooks/use-numer-formatter'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; -import { ExternalLinkIcon } from 'lucide-react'; +import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { SerieIcon } from '../report-chart/common/serie-icon'; import { Skeleton } from '../skeleton'; import { Tooltiper } from '../ui/tooltip'; @@ -45,6 +46,42 @@ function RevenuePieChart({ percentage }: { percentage: number }) { ); } +function SortableHeader({ + name, + isSorted, + sortDirection, + onClick, + isRightAligned, +}: { + name: string; + isSorted: boolean; + sortDirection: 'asc' | 'desc' | null; + onClick: () => void; + isRightAligned?: boolean; +}) { + return ( + + ); +} + type Props = WidgetTableProps & { getColumnPercentage: (item: T) => number; }; @@ -56,10 +93,113 @@ export const OverviewWidgetTable = ({ getColumnPercentage, className, }: Props) => { + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>( + null, + ); + + // Handle column header click for sorting + const handleSort = (columnName: string) => { + if (sortColumn === columnName) { + // Cycle through: desc -> asc -> null + if (sortDirection === 'desc') { + setSortDirection('asc'); + } else if (sortDirection === 'asc') { + setSortColumn(null); + setSortDirection(null); + } + } else { + // First click on a column = descending (highest to lowest) + setSortColumn(columnName); + setSortDirection('desc'); + } + }; + + // Sort data based on current sort state + // Sort all available items, then limit display to top 15 + const sortedData = useMemo(() => { + const allData = data ?? []; + + if (!sortColumn || !sortDirection) { + // When not sorting, return top 15 (maintain original behavior) + return allData; + } + + const column = columns.find((col) => { + if (typeof col.name === 'string') { + return col.name === sortColumn; + } + return false; + }); + + if (!column?.getSortValue) { + return allData; + } + + // Sort all available items + const sorted = [...allData].sort((a, b) => { + const aValue = column.getSortValue!(a); + const bValue = column.getSortValue!(b); + + // Handle null values + if (aValue === null && bValue === null) return 0; + if (aValue === null) return 1; + if (bValue === null) return -1; + + // Compare values + let comparison = 0; + if (typeof aValue === 'number' && typeof bValue === 'number') { + comparison = aValue - bValue; + } else { + comparison = String(aValue).localeCompare(String(bValue)); + } + + return sortDirection === 'desc' ? -comparison : comparison; + }); + + return sorted; + }, [data, sortColumn, sortDirection, columns]).slice(0, 15); + + // Create columns with sortable headers + const columnsWithSortableHeaders = useMemo(() => { + return columns.map((column, index) => { + const columnName = + typeof column.name === 'string' ? column.name : String(column.name); + const isSortable = !!column.getSortValue; + const isSorted = sortColumn === columnName; + const currentSortDirection = isSorted ? sortDirection : null; + const isRightAligned = index !== 0; + + return { + ...column, + // Add a key property for React keys (using the original column name string) + key: columnName, + name: isSortable ? ( + handleSort(columnName)} + isRightAligned={isRightAligned} + /> + ) : ( + column.name + ), + className: cn( + index === 0 + ? 'text-left w-full font-medium min-w-0' + : 'text-right font-mono', + // Remove old responsive logic - now handled by responsive prop + column.className, + ), + }; + }); + }, [columns, sortColumn, sortDirection]); + return (
({
); }} - columns={columns.map((column, index) => { - return { - ...column, - className: cn( - index === 0 - ? 'text-left w-full font-medium min-w-0' - : 'text-right font-mono', - // Remove old responsive logic - now handled by responsive prop - column.className, - ), - }; - })} + columns={columnsWithSortableHeaders} />
); @@ -208,6 +337,8 @@ export function OverviewWidgetTablePages({ name: 'Revenue', width: '100px', responsive: { priority: 3 }, // Always show if possible + getSortValue: (item: (typeof data)[number]) => + item.revenue ?? 0, render(item: (typeof data)[number]) { const revenue = item.revenue ?? 0; const revenuePercentage = @@ -231,6 +362,7 @@ export function OverviewWidgetTablePages({ name: 'Views', width: '84px', responsive: { priority: 2 }, // Always show if possible + getSortValue: (item: (typeof data)[number]) => item.pageviews, render(item) { return (
@@ -245,6 +377,7 @@ export function OverviewWidgetTablePages({ name: 'Sess.', width: '84px', responsive: { priority: 2 }, // Always show if possible + getSortValue: (item: (typeof data)[number]) => item.sessions, render(item) { return (
@@ -339,6 +472,8 @@ export function OverviewWidgetTableEntries({ name: 'Revenue', width: '100px', responsive: { priority: 3 }, // Always show if possible + getSortValue: (item: (typeof data)[number]) => + item.revenue ?? 0, render(item: (typeof data)[number]) { const revenue = item.revenue ?? 0; const revenuePercentage = @@ -362,6 +497,7 @@ export function OverviewWidgetTableEntries({ name: lastColumnName, width: '84px', responsive: { priority: 2 }, // Always show if possible + getSortValue: (item: (typeof data)[number]) => item.sessions, render(item) { return (
@@ -494,6 +630,9 @@ export function OverviewWidgetTableGeneric({ name: 'Revenue', width: '100px', responsive: { priority: 3 }, + getSortValue: ( + item: RouterOutputs['overview']['topGeneric'][number], + ) => item.revenue ?? 0, render(item: RouterOutputs['overview']['topGeneric'][number]) { const revenue = item.revenue ?? 0; const revenuePercentage = @@ -521,6 +660,9 @@ export function OverviewWidgetTableGeneric({ name: 'Views', width: '84px', responsive: { priority: 2 }, + getSortValue: ( + item: RouterOutputs['overview']['topGeneric'][number], + ) => item.pageviews, render(item: RouterOutputs['overview']['topGeneric'][number]) { return (
@@ -537,6 +679,9 @@ export function OverviewWidgetTableGeneric({ name: 'Sess.', width: '84px', responsive: { priority: 2 }, + getSortValue: ( + item: RouterOutputs['overview']['topGeneric'][number], + ) => item.sessions, render(item) { return (
@@ -599,6 +744,7 @@ export function OverviewWidgetTableEvents({ name: 'Count', width: '84px', responsive: { priority: 2 }, + getSortValue: (item: EventTableItem) => item.count, render(item) { return (
diff --git a/apps/start/src/components/widget-table.tsx b/apps/start/src/components/widget-table.tsx index 677f16cf..ab9c0421 100644 --- a/apps/start/src/components/widget-table.tsx +++ b/apps/start/src/components/widget-table.tsx @@ -29,6 +29,14 @@ export interface Props { * If not provided, column is always visible. */ responsive?: ColumnResponsive; + /** + * Function to extract sortable value. If provided, header becomes clickable. + */ + getSortValue?: (item: T) => number | string | null; + /** + * Optional key for React keys. If not provided, will try to extract from name or use index. + */ + key?: string; }[]; keyExtractor: (item: T) => string; data: T[]; @@ -177,9 +185,16 @@ export function WidgetTable({ dataAttrs['data-min-width'] = String(column.responsive.minWidth); } + // Use column.key if available, otherwise try to extract string from name, fallback to index + const columnKey = + column.key ?? + (typeof column.name === 'string' + ? column.name + : `col-${colIndex}`); + return (
1 && column !== columns[0] @@ -231,9 +246,16 @@ export function WidgetTable({ ); } + // Use column.key if available, otherwise try to extract string from name, fallback to index + const columnKey = + column.key ?? + (typeof column.name === 'string' + ? column.name + : `col-${colIndex}`); + return (
1 && column !== columns[0] diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index 7a333518..71282e24 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -109,7 +109,9 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({ interval: zTimeInterval, }); -export type IGetTopGenericSeriesInput = z.infer & { +export type IGetTopGenericSeriesInput = z.infer< + typeof zGetTopGenericSeriesInput +> & { timezone: string; }; @@ -734,7 +736,7 @@ export class OverviewService { }>; }> { const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null; - const TOP_LIMIT = 15; + const TOP_LIMIT = 500; const fillConfig = this.getFillConfig(interval, startDate, endDate); // Step 1: Get top 15 items