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