From f5f55e7bf7143b9ee9c3d373b70287001048550d Mon Sep 17 00:00:00 2001 From: tianyao Date: Tue, 21 Jul 2026 16:13:34 +0000 Subject: [PATCH] feat(dashboard): add click-to-sort headers to data tables Adds a reusable useSort hook and SortableTh header component, and wires them into the two data tables in the dashboard (Analytics "All Projects" table and Cache Usage by Provider table) so every column is sortable. Co-Authored-By: Claude Sonnet 5 --- .../dashboard/CacheBySourceCard.tsx | 87 +++++++++++---- dashboard/src/components/ui/sortable-th.tsx | 31 ++++++ dashboard/src/lib/hooks/useSort.ts | 38 +++++++ dashboard/src/pages/AnalyticsPage.tsx | 101 ++++++++++++++++-- 4 files changed, 226 insertions(+), 31 deletions(-) create mode 100644 dashboard/src/components/ui/sortable-th.tsx create mode 100644 dashboard/src/lib/hooks/useSort.ts diff --git a/dashboard/src/components/dashboard/CacheBySourceCard.tsx b/dashboard/src/components/dashboard/CacheBySourceCard.tsx index 98de91f3..9fcef448 100644 --- a/dashboard/src/components/dashboard/CacheBySourceCard.tsx +++ b/dashboard/src/components/dashboard/CacheBySourceCard.tsx @@ -16,6 +16,8 @@ import { } from 'recharts'; import type { CacheBySourceRow } from '@/lib/types'; import { useCacheBySource } from '@/hooks/useAnalytics'; +import { SortableTh } from '@/components/ui/sortable-th'; +import { useSort } from '@/lib/hooks/useSort'; type AnalyticsRange = '7d' | '30d' | '90d' | 'all'; @@ -75,6 +77,27 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr const { tooltipBg, tooltipBorder } = useThemeColors(); const { data, isLoading, isError } = useCacheBySource(range, homeId, source); + const chartData = data?.rows ?? []; + const formattedData: FormattedData[] = chartData.map((row) => { + const totalWithCache = (row.cacheReadTokens || 0) + (row.totalInputTokens || 0); + const hitRate = totalWithCache > 0 ? ((row.cacheReadTokens || 0) / totalWithCache) * 100 : 0; + return { + sourceTool: row.sourceTool || 'Unknown', + cacheCreation: row.cacheCreationTokens || 0, + cacheRead: row.cacheReadTokens || 0, + sessionCount: row.sessionCount, + totalInput: row.totalInputTokens || 0, + hitRate, + }; + }); + + type CacheSortKey = 'sourceTool' | 'sessionCount' | 'totalInput' | 'cacheCreation' | 'cacheRead' | 'hitRate'; + const { sorted: sortedData, sortKey, sortDirection, toggleSort } = useSort( + formattedData, + (row, key) => row[key], + { key: 'sourceTool', direction: 'asc' } + ); + if (isLoading) { return ( @@ -101,8 +124,6 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr ); } - const chartData = data?.rows ?? []; - if (chartData.length === 0) { return ( @@ -118,19 +139,6 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr ); } - const formattedData: FormattedData[] = chartData.map((row) => { - const totalWithCache = (row.cacheReadTokens || 0) + (row.totalInputTokens || 0); - const hitRate = totalWithCache > 0 ? ((row.cacheReadTokens || 0) / totalWithCache) * 100 : 0; - return { - sourceTool: row.sourceTool || 'Unknown', - cacheCreation: row.cacheCreationTokens || 0, - cacheRead: row.cacheReadTokens || 0, - sessionCount: row.sessionCount, - totalInput: row.totalInputTokens || 0, - hitRate, - }; - }); - return ( @@ -176,16 +184,51 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr - - - - - - + toggleSort('sourceTool')} + /> + toggleSort('sessionCount')} + /> + toggleSort('totalInput')} + /> + toggleSort('cacheCreation')} + /> + toggleSort('cacheRead')} + /> + toggleSort('hitRate')} + /> - {formattedData.map((row) => ( + {sortedData.map((row) => ( diff --git a/dashboard/src/components/ui/sortable-th.tsx b/dashboard/src/components/ui/sortable-th.tsx new file mode 100644 index 00000000..f4e8c15e --- /dev/null +++ b/dashboard/src/components/ui/sortable-th.tsx @@ -0,0 +1,31 @@ +import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SortDirection } from '@/lib/hooks/useSort'; + +interface SortableThProps { + label: string; + active: boolean; + direction: SortDirection; + align?: 'left' | 'right'; + onClick: () => void; +} + +export function SortableTh({ label, active, direction, align = 'left', onClick }: SortableThProps) { + const Icon = active ? (direction === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown; + return ( + + ); +} diff --git a/dashboard/src/lib/hooks/useSort.ts b/dashboard/src/lib/hooks/useSort.ts new file mode 100644 index 00000000..6e77bb47 --- /dev/null +++ b/dashboard/src/lib/hooks/useSort.ts @@ -0,0 +1,38 @@ +import { useMemo, useState } from 'react'; + +export type SortDirection = 'asc' | 'desc'; + +export function useSort( + data: T[], + getValue: (item: T, key: K) => string | number, + initial: { key: K; direction: SortDirection } +) { + const [sortKey, setSortKey] = useState(initial.key); + const [sortDirection, setSortDirection] = useState(initial.direction); + + const sorted = useMemo(() => { + const copy = [...data]; + copy.sort((a, b) => { + const av = getValue(a, sortKey); + const bv = getValue(b, sortKey); + const cmp = + typeof av === 'string' && typeof bv === 'string' + ? av.localeCompare(bv) + : (av as number) - (bv as number); + return sortDirection === 'asc' ? cmp : -cmp; + }); + return copy; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, sortKey, sortDirection]); + + function toggleSort(key: K) { + if (key === sortKey) { + setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDirection('asc'); + } + } + + return { sorted, sortKey, sortDirection, toggleSort }; +} diff --git a/dashboard/src/pages/AnalyticsPage.tsx b/dashboard/src/pages/AnalyticsPage.tsx index 2bda5823..3ccff6fb 100644 --- a/dashboard/src/pages/AnalyticsPage.tsx +++ b/dashboard/src/pages/AnalyticsPage.tsx @@ -13,6 +13,8 @@ import { Button } from '@/components/ui/button'; import { CHART_COLORS } from '@/lib/constants/colors'; import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect'; import { HomeSelect } from '@/components/filters/HomeSelect'; +import { SortableTh } from '@/components/ui/sortable-th'; +import { useSort } from '@/lib/hooks/useSort'; import { BarChart, Bar, @@ -163,15 +165,55 @@ export default function AnalyticsPage() { } } - return Object.values(statsMap).sort((a, b) => b.sessionCount - a.sessionCount); + return Object.values(statsMap); }, [projects, filteredSessions, filteredInsights]); + type ProjectSortKey = + | 'projectName' + | 'sessionCount' + | 'summary' + | 'decision' + | 'learning' + | 'estimatedCostUsd' + | 'tokens'; + + const { sorted: sortedProjectStats, sortKey: projectSortKey, sortDirection: projectSortDirection, toggleSort: toggleProjectSort } = useSort< + (typeof projectStats)[number], + ProjectSortKey + >( + projectStats, + (p, key) => { + switch (key) { + case 'projectName': + return p.projectName; + case 'sessionCount': + return p.sessionCount; + case 'summary': + return p.insightCounts.summary; + case 'decision': + return p.insightCounts.decision; + case 'learning': + return p.insightCounts.learning; + case 'estimatedCostUsd': + return p.estimatedCostUsd; + case 'tokens': + return p.totalInputTokens + p.totalOutputTokens; + } + }, + { key: 'sessionCount', direction: 'desc' } + ); + + const handleProjectSort = (key: ProjectSortKey) => { + toggleProjectSort(key); + setProjectPage(0); + }; + const PROJECT_PAGE_SIZE = 10; const projectPageCount = Math.max(1, Math.ceil(projectStats.length / PROJECT_PAGE_SIZE)); // Clamp rather than reset via effect: keeps this a pure render-time derivation // even when a range/source change shrinks the list out from under the current page. const currentProjectPage = Math.min(projectPage, projectPageCount - 1); - const pagedProjectStats = projectStats.slice( + const pagedProjectStats = sortedProjectStats.slice( currentProjectPage * PROJECT_PAGE_SIZE, (currentProjectPage + 1) * PROJECT_PAGE_SIZE ); @@ -428,13 +470,54 @@ export default function AnalyticsPage() {
ProviderSessionsTotal InputCache CreationCache ReadHit Rate
{row.sourceTool} {row.sessionCount} + +
- - - - - - - + handleProjectSort('projectName')} + /> + handleProjectSort('sessionCount')} + /> + handleProjectSort('summary')} + /> + handleProjectSort('decision')} + /> + handleProjectSort('learning')} + /> + handleProjectSort('estimatedCostUsd')} + /> + handleProjectSort('tokens')} + />
ProjectSessionsSummariesDecisionsLearningsEst. CostTokens