Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 65 additions & 22 deletions dashboard/src/components/dashboard/CacheBySourceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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, CacheSortKey>(
formattedData,
(row, key) => row[key],
{ key: 'sourceTool', direction: 'asc' }
);

if (isLoading) {
return (
<Card>
Expand All @@ -101,8 +124,6 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
);
}

const chartData = data?.rows ?? [];

if (chartData.length === 0) {
return (
<Card>
Expand All @@ -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 (
<Card>
<CardHeader>
Expand Down Expand Up @@ -176,16 +184,51 @@ export function CacheBySourceCard({ range, homeId, source }: CacheBySourceCardPr
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-2 text-left font-medium">Provider</th>
<th className="py-2 text-right font-medium">Sessions</th>
<th className="py-2 text-right font-medium">Total Input</th>
<th className="py-2 text-right font-medium">Cache Creation</th>
<th className="py-2 text-right font-medium">Cache Read</th>
<th className="py-2 text-right font-medium">Hit Rate</th>
<SortableTh
label="Provider"
active={sortKey === 'sourceTool'}
direction={sortDirection}
onClick={() => toggleSort('sourceTool')}
/>
<SortableTh
label="Sessions"
align="right"
active={sortKey === 'sessionCount'}
direction={sortDirection}
onClick={() => toggleSort('sessionCount')}
/>
<SortableTh
label="Total Input"
align="right"
active={sortKey === 'totalInput'}
direction={sortDirection}
onClick={() => toggleSort('totalInput')}
/>
<SortableTh
label="Cache Creation"
align="right"
active={sortKey === 'cacheCreation'}
direction={sortDirection}
onClick={() => toggleSort('cacheCreation')}
/>
<SortableTh
label="Cache Read"
align="right"
active={sortKey === 'cacheRead'}
direction={sortDirection}
onClick={() => toggleSort('cacheRead')}
/>
<SortableTh
label="Hit Rate"
align="right"
active={sortKey === 'hitRate'}
direction={sortDirection}
onClick={() => toggleSort('hitRate')}
/>
</tr>
</thead>
<tbody>
{formattedData.map((row) => (
{sortedData.map((row) => (
<tr key={row.sourceTool} className="border-b last:border-0">
<td className="py-2 font-medium">{row.sourceTool}</td>
<td className="py-2 text-right">{row.sessionCount}</td>
Expand Down
31 changes: 31 additions & 0 deletions dashboard/src/components/ui/sortable-th.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<th className={cn('py-3 font-medium', align === 'right' ? 'text-right' : 'text-left')}>
<button
type="button"
onClick={onClick}
className={cn(
'inline-flex items-center gap-1 hover:text-foreground transition-colors',
align === 'right' && 'flex-row-reverse',
active ? 'text-foreground' : 'text-muted-foreground'
)}
>
{label}
<Icon className="h-3.5 w-3.5 shrink-0" />
</button>
</th>
);
}
38 changes: 38 additions & 0 deletions dashboard/src/lib/hooks/useSort.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { useMemo, useState } from 'react';

export type SortDirection = 'asc' | 'desc';

export function useSort<T, K extends string>(
data: T[],
getValue: (item: T, key: K) => string | number,
initial: { key: K; direction: SortDirection }
) {
const [sortKey, setSortKey] = useState<K>(initial.key);
const [sortDirection, setSortDirection] = useState<SortDirection>(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 };
}
101 changes: 92 additions & 9 deletions dashboard/src/pages/AnalyticsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -428,13 +470,54 @@ export default function AnalyticsPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="py-3 text-left font-medium">Project</th>
<th className="py-3 text-right font-medium">Sessions</th>
<th className="py-3 text-right font-medium">Summaries</th>
<th className="py-3 text-right font-medium">Decisions</th>
<th className="py-3 text-right font-medium">Learnings</th>
<th className="py-3 text-right font-medium">Est. Cost</th>
<th className="py-3 text-right font-medium">Tokens</th>
<SortableTh
label="Project"
active={projectSortKey === 'projectName'}
direction={projectSortDirection}
onClick={() => handleProjectSort('projectName')}
/>
<SortableTh
label="Sessions"
align="right"
active={projectSortKey === 'sessionCount'}
direction={projectSortDirection}
onClick={() => handleProjectSort('sessionCount')}
/>
<SortableTh
label="Summaries"
align="right"
active={projectSortKey === 'summary'}
direction={projectSortDirection}
onClick={() => handleProjectSort('summary')}
/>
<SortableTh
label="Decisions"
align="right"
active={projectSortKey === 'decision'}
direction={projectSortDirection}
onClick={() => handleProjectSort('decision')}
/>
<SortableTh
label="Learnings"
align="right"
active={projectSortKey === 'learning'}
direction={projectSortDirection}
onClick={() => handleProjectSort('learning')}
/>
<SortableTh
label="Est. Cost"
align="right"
active={projectSortKey === 'estimatedCostUsd'}
direction={projectSortDirection}
onClick={() => handleProjectSort('estimatedCostUsd')}
/>
<SortableTh
label="Tokens"
align="right"
active={projectSortKey === 'tokens'}
direction={projectSortDirection}
onClick={() => handleProjectSort('tokens')}
/>
</tr>
</thead>
<tbody>
Expand Down
Loading