Skip to content

Commit 40d3480

Browse files
ClaudiaFangclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e27ff89 commit 40d3480

4 files changed

Lines changed: 226 additions & 31 deletions

File tree

dashboard/src/components/dashboard/CacheBySourceCard.tsx

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
} from 'recharts';
1717
import type { CacheBySourceRow } from '@/lib/types';
1818
import { useCacheBySource } from '@/hooks/useAnalytics';
19+
import { SortableTh } from '@/components/ui/sortable-th';
20+
import { useSort } from '@/lib/hooks/useSort';
1921

2022
type AnalyticsRange = '7d' | '30d' | '90d' | 'all';
2123

@@ -75,6 +77,27 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
7577
const { tooltipBg, tooltipBorder } = useThemeColors();
7678
const { data, isLoading, isError } = useCacheBySource(range, homeId, source);
7779

80+
const chartData = data?.rows ?? [];
81+
const formattedData: FormattedData[] = chartData.map((row) => {
82+
const totalWithCache = (row.cacheReadTokens || 0) + (row.totalInputTokens || 0);
83+
const hitRate = totalWithCache > 0 ? ((row.cacheReadTokens || 0) / totalWithCache) * 100 : 0;
84+
return {
85+
sourceTool: row.sourceTool || 'Unknown',
86+
cacheCreation: row.cacheCreationTokens || 0,
87+
cacheRead: row.cacheReadTokens || 0,
88+
sessionCount: row.sessionCount,
89+
totalInput: row.totalInputTokens || 0,
90+
hitRate,
91+
};
92+
});
93+
94+
type CacheSortKey = 'sourceTool' | 'sessionCount' | 'totalInput' | 'cacheCreation' | 'cacheRead' | 'hitRate';
95+
const { sorted: sortedData, sortKey, sortDirection, toggleSort } = useSort<FormattedData, CacheSortKey>(
96+
formattedData,
97+
(row, key) => row[key],
98+
{ key: 'sourceTool', direction: 'asc' }
99+
);
100+
78101
if (isLoading) {
79102
return (
80103
<Card>
@@ -101,8 +124,6 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
101124
);
102125
}
103126

104-
const chartData = data?.rows ?? [];
105-
106127
if (chartData.length === 0) {
107128
return (
108129
<Card>
@@ -118,19 +139,6 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
118139
);
119140
}
120141

121-
const formattedData: FormattedData[] = chartData.map((row) => {
122-
const totalWithCache = (row.cacheReadTokens || 0) + (row.totalInputTokens || 0);
123-
const hitRate = totalWithCache > 0 ? ((row.cacheReadTokens || 0) / totalWithCache) * 100 : 0;
124-
return {
125-
sourceTool: row.sourceTool || 'Unknown',
126-
cacheCreation: row.cacheCreationTokens || 0,
127-
cacheRead: row.cacheReadTokens || 0,
128-
sessionCount: row.sessionCount,
129-
totalInput: row.totalInputTokens || 0,
130-
hitRate,
131-
};
132-
});
133-
134142
return (
135143
<Card>
136144
<CardHeader>
@@ -176,16 +184,51 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
176184
<table className="w-full text-sm">
177185
<thead>
178186
<tr className="border-b">
179-
<th className="py-2 text-left font-medium">Provider</th>
180-
<th className="py-2 text-right font-medium">Sessions</th>
181-
<th className="py-2 text-right font-medium">Total Input</th>
182-
<th className="py-2 text-right font-medium">Cache Creation</th>
183-
<th className="py-2 text-right font-medium">Cache Read</th>
184-
<th className="py-2 text-right font-medium">Hit Rate</th>
187+
<SortableTh
188+
label="Provider"
189+
active={sortKey === 'sourceTool'}
190+
direction={sortDirection}
191+
onClick={() => toggleSort('sourceTool')}
192+
/>
193+
<SortableTh
194+
label="Sessions"
195+
align="right"
196+
active={sortKey === 'sessionCount'}
197+
direction={sortDirection}
198+
onClick={() => toggleSort('sessionCount')}
199+
/>
200+
<SortableTh
201+
label="Total Input"
202+
align="right"
203+
active={sortKey === 'totalInput'}
204+
direction={sortDirection}
205+
onClick={() => toggleSort('totalInput')}
206+
/>
207+
<SortableTh
208+
label="Cache Creation"
209+
align="right"
210+
active={sortKey === 'cacheCreation'}
211+
direction={sortDirection}
212+
onClick={() => toggleSort('cacheCreation')}
213+
/>
214+
<SortableTh
215+
label="Cache Read"
216+
align="right"
217+
active={sortKey === 'cacheRead'}
218+
direction={sortDirection}
219+
onClick={() => toggleSort('cacheRead')}
220+
/>
221+
<SortableTh
222+
label="Hit Rate"
223+
align="right"
224+
active={sortKey === 'hitRate'}
225+
direction={sortDirection}
226+
onClick={() => toggleSort('hitRate')}
227+
/>
185228
</tr>
186229
</thead>
187230
<tbody>
188-
{formattedData.map((row) => (
231+
{sortedData.map((row) => (
189232
<tr key={row.sourceTool} className="border-b last:border-0">
190233
<td className="py-2 font-medium">{row.sourceTool}</td>
191234
<td className="py-2 text-right">{row.sessionCount}</td>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
2+
import { cn } from '@/lib/utils';
3+
import type { SortDirection } from '@/lib/hooks/useSort';
4+
5+
interface SortableThProps {
6+
label: string;
7+
active: boolean;
8+
direction: SortDirection;
9+
align?: 'left' | 'right';
10+
onClick: () => void;
11+
}
12+
13+
export function SortableTh({ label, active, direction, align = 'left', onClick }: SortableThProps) {
14+
const Icon = active ? (direction === 'asc' ? ChevronUp : ChevronDown) : ChevronsUpDown;
15+
return (
16+
<th className={cn('py-3 font-medium', align === 'right' ? 'text-right' : 'text-left')}>
17+
<button
18+
type="button"
19+
onClick={onClick}
20+
className={cn(
21+
'inline-flex items-center gap-1 hover:text-foreground transition-colors',
22+
align === 'right' && 'flex-row-reverse',
23+
active ? 'text-foreground' : 'text-muted-foreground'
24+
)}
25+
>
26+
{label}
27+
<Icon className="h-3.5 w-3.5 shrink-0" />
28+
</button>
29+
</th>
30+
);
31+
}

dashboard/src/lib/hooks/useSort.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useMemo, useState } from 'react';
2+
3+
export type SortDirection = 'asc' | 'desc';
4+
5+
export function useSort<T, K extends string>(
6+
data: T[],
7+
getValue: (item: T, key: K) => string | number,
8+
initial: { key: K; direction: SortDirection }
9+
) {
10+
const [sortKey, setSortKey] = useState<K>(initial.key);
11+
const [sortDirection, setSortDirection] = useState<SortDirection>(initial.direction);
12+
13+
const sorted = useMemo(() => {
14+
const copy = [...data];
15+
copy.sort((a, b) => {
16+
const av = getValue(a, sortKey);
17+
const bv = getValue(b, sortKey);
18+
const cmp =
19+
typeof av === 'string' && typeof bv === 'string'
20+
? av.localeCompare(bv)
21+
: (av as number) - (bv as number);
22+
return sortDirection === 'asc' ? cmp : -cmp;
23+
});
24+
return copy;
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
}, [data, sortKey, sortDirection]);
27+
28+
function toggleSort(key: K) {
29+
if (key === sortKey) {
30+
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
31+
} else {
32+
setSortKey(key);
33+
setSortDirection('asc');
34+
}
35+
}
36+
37+
return { sorted, sortKey, sortDirection, toggleSort };
38+
}

dashboard/src/pages/AnalyticsPage.tsx

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { Button } from '@/components/ui/button';
1313
import { CHART_COLORS } from '@/lib/constants/colors';
1414
import { SourceToolMultiSelect } from '@/components/filters/SourceToolMultiSelect';
1515
import { HomeSelect } from '@/components/filters/HomeSelect';
16+
import { SortableTh } from '@/components/ui/sortable-th';
17+
import { useSort } from '@/lib/hooks/useSort';
1618
import {
1719
BarChart,
1820
Bar,
@@ -163,15 +165,55 @@ export default function AnalyticsPage() {
163165
}
164166
}
165167

166-
return Object.values(statsMap).sort((a, b) => b.sessionCount - a.sessionCount);
168+
return Object.values(statsMap);
167169
}, [projects, filteredSessions, filteredInsights]);
168170

171+
type ProjectSortKey =
172+
| 'projectName'
173+
| 'sessionCount'
174+
| 'summary'
175+
| 'decision'
176+
| 'learning'
177+
| 'estimatedCostUsd'
178+
| 'tokens';
179+
180+
const { sorted: sortedProjectStats, sortKey: projectSortKey, sortDirection: projectSortDirection, toggleSort: toggleProjectSort } = useSort<
181+
(typeof projectStats)[number],
182+
ProjectSortKey
183+
>(
184+
projectStats,
185+
(p, key) => {
186+
switch (key) {
187+
case 'projectName':
188+
return p.projectName;
189+
case 'sessionCount':
190+
return p.sessionCount;
191+
case 'summary':
192+
return p.insightCounts.summary;
193+
case 'decision':
194+
return p.insightCounts.decision;
195+
case 'learning':
196+
return p.insightCounts.learning;
197+
case 'estimatedCostUsd':
198+
return p.estimatedCostUsd;
199+
case 'tokens':
200+
return p.totalInputTokens + p.totalOutputTokens;
201+
}
202+
},
203+
{ key: 'sessionCount', direction: 'desc' }
204+
);
205+
206+
const handleProjectSort = (key: ProjectSortKey) => {
207+
toggleProjectSort(key);
208+
setProjectPage(0);
209+
};
210+
169211
const PROJECT_PAGE_SIZE = 10;
170212
const projectPageCount = Math.max(1, Math.ceil(projectStats.length / PROJECT_PAGE_SIZE));
171213
// Clamp rather than reset via effect: keeps this a pure render-time derivation
172214
// even when a range/source change shrinks the list out from under the current page.
173215
const currentProjectPage = Math.min(projectPage, projectPageCount - 1);
174-
const pagedProjectStats = projectStats.slice(
216+
const pagedProjectStats = sortedProjectStats.slice(
175217
currentProjectPage * PROJECT_PAGE_SIZE,
176218
(currentProjectPage + 1) * PROJECT_PAGE_SIZE
177219
);
@@ -428,13 +470,54 @@ export default function AnalyticsPage() {
428470
<table className="w-full text-sm">
429471
<thead>
430472
<tr className="border-b">
431-
<th className="py-3 text-left font-medium">Project</th>
432-
<th className="py-3 text-right font-medium">Sessions</th>
433-
<th className="py-3 text-right font-medium">Summaries</th>
434-
<th className="py-3 text-right font-medium">Decisions</th>
435-
<th className="py-3 text-right font-medium">Learnings</th>
436-
<th className="py-3 text-right font-medium">Est. Cost</th>
437-
<th className="py-3 text-right font-medium">Tokens</th>
473+
<SortableTh
474+
label="Project"
475+
active={projectSortKey === 'projectName'}
476+
direction={projectSortDirection}
477+
onClick={() => handleProjectSort('projectName')}
478+
/>
479+
<SortableTh
480+
label="Sessions"
481+
align="right"
482+
active={projectSortKey === 'sessionCount'}
483+
direction={projectSortDirection}
484+
onClick={() => handleProjectSort('sessionCount')}
485+
/>
486+
<SortableTh
487+
label="Summaries"
488+
align="right"
489+
active={projectSortKey === 'summary'}
490+
direction={projectSortDirection}
491+
onClick={() => handleProjectSort('summary')}
492+
/>
493+
<SortableTh
494+
label="Decisions"
495+
align="right"
496+
active={projectSortKey === 'decision'}
497+
direction={projectSortDirection}
498+
onClick={() => handleProjectSort('decision')}
499+
/>
500+
<SortableTh
501+
label="Learnings"
502+
align="right"
503+
active={projectSortKey === 'learning'}
504+
direction={projectSortDirection}
505+
onClick={() => handleProjectSort('learning')}
506+
/>
507+
<SortableTh
508+
label="Est. Cost"
509+
align="right"
510+
active={projectSortKey === 'estimatedCostUsd'}
511+
direction={projectSortDirection}
512+
onClick={() => handleProjectSort('estimatedCostUsd')}
513+
/>
514+
<SortableTh
515+
label="Tokens"
516+
align="right"
517+
active={projectSortKey === 'tokens'}
518+
direction={projectSortDirection}
519+
onClick={() => handleProjectSort('tokens')}
520+
/>
438521
</tr>
439522
</thead>
440523
<tbody>

0 commit comments

Comments
 (0)