refactor(ui): modularize analytics page into directory structure

- split analytics.tsx (420 lines) into modular components
- extract analytics-header, charts-grid, cost-by-model-card
- add analytics-skeleton for loading states
- separate hooks, types, and utils
This commit is contained in:
kaitranntt
2025-12-21 03:27:15 -05:00
parent 104a404144
commit 03d9bf76c4
9 changed files with 655 additions and 420 deletions
-420
View File
@@ -1,420 +0,0 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts.
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
*/
import { useState, useMemo, useRef, useCallback } from 'react';
import type { DateRange } from 'react-day-picker';
import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
import {
useUsageSummary,
useUsageTrends,
useHourlyUsage,
useModelUsage,
useRefreshUsage,
useUsageStatus,
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
import { getModelColor, cn } from '@/lib/utils';
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
// Format token count to human-readable (K/M/B)
function formatTokens(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return num.toString();
}
export function AnalyticsPage() {
const { privacyMode } = usePrivacy();
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
const popoverAnchorRef = useRef<HTMLDivElement>(null);
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
};
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
// Handle "24H" preset click
const handleTodayClick = useCallback(() => {
const now = new Date();
setDateRange({ from: subDays(now, 1), to: now });
setViewMode('hourly');
}, []);
// Handle date range changes from DateRangeFilter
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
setDateRange(range);
setViewMode('daily'); // Switch back to daily view for multi-day ranges
}, []);
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
// Handle model click for popover
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
setSelectedModel(model);
}, []);
const handlePopoverClose = useCallback(() => {
setSelectedModel(null);
setPopoverPosition(null);
}, []);
return (
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={handleTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={handleDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-2 h-8"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
{/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model - 4/10 width with breakdown */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Cost by Model
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
{isModelsLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-0.5">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<button
key={model.model}
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
onClick={(e) => handleModelClick(model, e)}
title="Click for details"
>
{/* Model name */}
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="font-medium truncate group-hover:underline underline-offset-2">
{model.model}
</span>
</div>
{/* Cost breakdown mini-bar */}
<div className="flex-1 flex items-center gap-1 min-w-0">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
<div
className="h-full"
style={{
backgroundColor: '#335c67',
width: `${model.cost > 0 ? (model.costBreakdown.input.cost / model.cost) * 100 : 0}%`,
}}
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#fff3b0',
width: `${model.cost > 0 ? (model.costBreakdown.output.cost / model.cost) * 100 : 0}%`,
}}
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#e09f3e',
width: `${model.cost > 0 ? (model.costBreakdown.cacheCreation.cost / model.cost) * 100 : 0}%`,
}}
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: '#9e2a2b',
width: `${model.cost > 0 ? (model.costBreakdown.cacheRead.cost / model.cost) * 100 : 0}%`,
}}
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
/>
</div>
</div>
{/* Token count */}
<span
className={cn(
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{formatTokens(model.tokens)}
</span>
{/* Total cost */}
<span
className={cn(
'font-mono font-medium w-16 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
${model.cost.toFixed(2)}
</span>
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
</button>
))}
{/* Legend */}
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#335c67' }}
/>
Input
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full border border-muted-foreground/30"
style={{ backgroundColor: '#fff3b0' }}
/>
Output
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#e09f3e' }}
/>
Cache Write
</span>
<span className="flex items-center gap-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: '#9e2a2b' }}
/>
Cache Read
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Model Distribution - 2/10 width */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full w-full"
/>
</CardContent>
</Card>
{/* Session Stats - 2/10 width */}
<SessionStatsCard
data={sessions}
isLoading={isSessionsLoading}
className="lg:col-span-2"
/>
{/* CLIProxy Stats - 2/10 width */}
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
{/* Model Details Popover - positioned at cursor */}
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
<PopoverAnchor asChild>
<div
ref={popoverAnchorRef}
className="fixed pointer-events-none"
style={{
left: popoverPosition?.x ?? 0,
top: popoverPosition?.y ?? 0,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
<PopoverContent className="w-80 p-3" side="top" align="center">
{selectedModel && <ModelDetailsContent model={selectedModel} />}
</PopoverContent>
</Popover>
</div>
</div>
);
}
export function AnalyticsSkeleton() {
return (
<div className="space-y-4 h-full overflow-hidden">
{/* Usage Trends Skeleton */}
<Card className="flex flex-col min-h-[300px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cost Breakdown Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Model Usage Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex w-full h-full items-center">
<div className="flex-1 flex justify-center">
<Skeleton className="h-[180px] w-[180px] rounded-full" />
</div>
<div className="w-[140px] shrink-0 pl-2 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
/**
* Analytics Header Component
*
* Title, date filter, 24H button, and refresh controls.
*/
import type { DateRange } from 'react-day-picker';
import { subDays, startOfMonth } from 'date-fns';
import { Button } from '@/components/ui/button';
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
import { RefreshCw } from 'lucide-react';
interface AnalyticsHeaderProps {
dateRange: DateRange | undefined;
onDateRangeChange: (range: DateRange | undefined) => void;
onTodayClick: () => void;
onRefresh: () => void;
isRefreshing: boolean;
lastUpdatedText: string | null;
viewMode: 'daily' | 'hourly';
}
export function AnalyticsHeader({
dateRange,
onDateRangeChange,
onTodayClick,
onRefresh,
isRefreshing,
lastUpdatedText,
viewMode,
}: AnalyticsHeaderProps) {
return (
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-xl font-semibold">Analytics</h1>
<p className="text-sm text-muted-foreground">Track usage & insights</p>
</div>
<div className="flex items-center gap-2">
<Button
variant={viewMode === 'hourly' ? 'default' : 'outline'}
size="sm"
className="h-8"
onClick={onTodayClick}
>
24H
</Button>
<DateRangeFilter
value={dateRange}
onChange={onDateRangeChange}
presets={[
{ label: '7D', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: '30D', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'Month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'All Time', range: { from: undefined, to: new Date() } },
]}
/>
{lastUpdatedText && (
<span className="text-xs text-muted-foreground whitespace-nowrap">
Updated {lastUpdatedText}
</span>
)}
<Button
variant="outline"
size="sm"
className="gap-2 h-8"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-3.5 h-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
/**
* Analytics Skeleton Component
*
* Loading skeleton for the analytics page.
*/
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
export function AnalyticsSkeleton() {
return (
<div className="space-y-4 h-full overflow-hidden">
{/* Usage Trends Skeleton */}
<Card className="flex flex-col min-h-[300px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<Skeleton className="h-full w-full" />
</CardContent>
</Card>
{/* Bottom Row Skeletons */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Cost Breakdown Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-2">
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-2.5 h-2.5 rounded-full" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-16" />
</div>
))}
</div>
</CardContent>
</Card>
{/* Model Usage Skeleton */}
<Card className="flex flex-col min-h-[250px]">
<CardHeader className="p-4 pb-2">
<Skeleton className="h-4 w-28" />
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<div className="flex w-full h-full items-center">
<div className="flex-1 flex justify-center">
<Skeleton className="h-[180px] w-[180px] rounded-full" />
</div>
<div className="w-[140px] shrink-0 pl-2 space-y-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="w-2 h-2 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
/**
* Charts Grid Component
*
* Layout grid for analytics charts and cards.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-chart';
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
import { TrendingUp, PieChart } from 'lucide-react';
import { usePrivacy } from '@/contexts/privacy-context';
import { CostByModelCard } from './cost-by-model-card';
import type { ModelUsage, PaginatedSessions, DailyUsage, HourlyUsage } from '@/hooks/use-usage';
interface ChartsGridProps {
viewMode: 'daily' | 'hourly';
trends: DailyUsage[] | undefined;
hourlyData: HourlyUsage[] | undefined;
models: ModelUsage[] | undefined;
sessions: PaginatedSessions | undefined;
isTrendsLoading: boolean;
isHourlyLoading: boolean;
isModelsLoading: boolean;
isSessionsLoading: boolean;
isSummaryLoading: boolean;
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
}
export function ChartsGrid({
viewMode,
trends,
hourlyData,
models,
sessions,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
isSummaryLoading,
onModelClick,
}: ChartsGridProps) {
const { privacyMode } = usePrivacy();
return (
<div className="flex-1 flex flex-col min-h-0 gap-4">
{/* Usage Trend Chart - Full Width */}
<Card className="flex flex-col flex-1 min-h-0 max-h-[500px] overflow-hidden shadow-sm">
<CardHeader className="px-3 py-2 shrink-0">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3 pt-0 flex-1 min-h-0">
<UsageTrendChart
data={viewMode === 'hourly' ? hourlyData || [] : trends || []}
isLoading={viewMode === 'hourly' ? isHourlyLoading : isTrendsLoading}
granularity={viewMode === 'hourly' ? 'hourly' : 'daily'}
/>
</CardContent>
</Card>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-10 gap-4 h-auto lg:h-[180px] shrink-0">
{/* Cost by Model */}
<CostByModelCard
models={models}
isLoading={isModelsLoading}
onModelClick={onModelClick}
privacyMode={privacyMode}
/>
{/* Model Distribution */}
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-2">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<PieChart className="w-4 h-4" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 flex items-center justify-center">
<ModelBreakdownChart
data={models || []}
isLoading={isModelsLoading}
className="h-full w-full"
/>
</CardContent>
</Card>
{/* Session Stats */}
<SessionStatsCard data={sessions} isLoading={isSessionsLoading} className="lg:col-span-2" />
{/* CLIProxy Stats */}
<CliproxyStatsCard isLoading={isSummaryLoading} className="lg:col-span-2" />
</div>
</div>
);
}
@@ -0,0 +1,163 @@
/**
* Cost By Model Card Component
*
* Displays a list of models sorted by cost with breakdown bars.
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { DollarSign, ChevronRight } from 'lucide-react';
import { getModelColor, cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { formatTokens } from '../utils';
import type { ModelUsage } from '@/hooks/use-usage';
interface CostByModelCardProps {
models: ModelUsage[] | undefined;
isLoading: boolean;
onModelClick: (model: ModelUsage, event: React.MouseEvent) => void;
privacyMode: boolean;
}
export function CostByModelCard({
models,
isLoading,
onModelClick,
privacyMode,
}: CostByModelCardProps) {
return (
<Card className="flex flex-col h-full min-h-0 shadow-sm lg:col-span-4">
<CardHeader className="px-3 py-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4" />
Cost by Model
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-2 pt-0 flex-1 min-h-0 overflow-y-auto">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : (
<div className="space-y-0.5">
{[...(models || [])]
.sort((a, b) => b.cost - a.cost)
.map((model) => (
<button
key={model.model}
className="group flex items-center text-xs w-full hover:bg-muted/50 rounded px-2 py-1.5 transition-colors cursor-pointer gap-3"
onClick={(e) => onModelClick(model, e)}
title="Click for details"
>
{/* Model name */}
<div className="flex items-center gap-2 min-w-0 w-[180px] shrink-0">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="font-medium truncate group-hover:underline underline-offset-2">
{model.model}
</span>
</div>
{/* Cost breakdown mini-bar */}
<CostBreakdownBar model={model} />
{/* Token count */}
<span
className={cn(
'text-[10px] text-muted-foreground w-14 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{formatTokens(model.tokens)}
</span>
{/* Total cost */}
<span
className={cn(
'font-mono font-medium w-16 text-right shrink-0',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
${model.cost.toFixed(2)}
</span>
<ChevronRight className="w-3 h-3 opacity-0 group-hover:opacity-50 transition-opacity shrink-0" />
</button>
))}
{/* Legend */}
<CostLegend />
</div>
)}
</CardContent>
</Card>
);
}
function CostBreakdownBar({ model }: { model: ModelUsage }) {
const colors = {
input: '#335c67',
output: '#fff3b0',
cacheWrite: '#e09f3e',
cacheRead: '#9e2a2b',
};
const getWidth = (cost: number) => (model.cost > 0 ? (cost / model.cost) * 100 : 0);
return (
<div className="flex-1 flex items-center gap-1 min-w-0">
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden flex">
<div
className="h-full"
style={{
backgroundColor: colors.input,
width: `${getWidth(model.costBreakdown.input.cost)}%`,
}}
title={`Input: $${model.costBreakdown.input.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.output,
width: `${getWidth(model.costBreakdown.output.cost)}%`,
}}
title={`Output: $${model.costBreakdown.output.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.cacheWrite,
width: `${getWidth(model.costBreakdown.cacheCreation.cost)}%`,
}}
title={`Cache Write: $${model.costBreakdown.cacheCreation.cost.toFixed(2)}`}
/>
<div
className="h-full"
style={{
backgroundColor: colors.cacheRead,
width: `${getWidth(model.costBreakdown.cacheRead.cost)}%`,
}}
title={`Cache Read: $${model.costBreakdown.cacheRead.cost.toFixed(2)}`}
/>
</div>
</div>
);
}
function CostLegend() {
const items = [
{ color: '#335c67', label: 'Input' },
{ color: '#fff3b0', label: 'Output', hasBorder: true },
{ color: '#e09f3e', label: 'Cache Write' },
{ color: '#9e2a2b', label: 'Cache Read' },
];
return (
<div className="flex items-center gap-3 pt-2 px-2 text-[10px] text-muted-foreground border-t mt-2">
{items.map(({ color, label, hasBorder }) => (
<span key={label} className="flex items-center gap-1">
<div
className={cn('w-2 h-2 rounded-full', hasBorder && 'border border-muted-foreground/30')}
style={{ backgroundColor: color }}
/>
{label}
</span>
))}
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Analytics Page Hooks
*
* Composite hook centralizing all data fetching and state for the analytics page.
*/
import { useState, useMemo, useCallback } from 'react';
import type { DateRange } from 'react-day-picker';
import { subDays, formatDistanceToNow } from 'date-fns';
import {
useUsageSummary,
useUsageTrends,
useHourlyUsage,
useModelUsage,
useRefreshUsage,
useUsageStatus,
useSessions,
type ModelUsage,
} from '@/hooks/use-usage';
export function useAnalyticsPage() {
// Default to last 30 days
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [isRefreshing, setIsRefreshing] = useState(false);
const [selectedModel, setSelectedModel] = useState<ModelUsage | null>(null);
const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null);
const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily');
// Refresh hook
const refreshUsage = useRefreshUsage();
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
await refreshUsage();
} finally {
setIsRefreshing(false);
}
}, [refreshUsage]);
// Convert dates to API format
const apiOptions = {
startDate: dateRange?.from,
endDate: dateRange?.to,
};
// Fetch data
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions);
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
const { data: status } = useUsageStatus();
// Handle "24H" preset click
const handleTodayClick = useCallback(() => {
const now = new Date();
setDateRange({ from: subDays(now, 1), to: now });
setViewMode('hourly');
}, []);
// Handle date range changes from DateRangeFilter
const handleDateRangeChange = useCallback((range: DateRange | undefined) => {
setDateRange(range);
setViewMode('daily'); // Switch back to daily view for multi-day ranges
}, []);
// Format "Last updated" text
const lastUpdatedText = useMemo(() => {
if (!status?.lastFetch) return null;
return formatDistanceToNow(new Date(status.lastFetch), { addSuffix: true });
}, [status?.lastFetch]);
// Handle model click for popover
const handleModelClick = useCallback((model: ModelUsage, event: React.MouseEvent) => {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
setPopoverPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
setSelectedModel(model);
}, []);
const handlePopoverClose = useCallback(() => {
setSelectedModel(null);
setPopoverPosition(null);
}, []);
return {
// State
dateRange,
isRefreshing,
viewMode,
selectedModel,
popoverPosition,
// Data
summary,
trends,
hourlyData,
models,
sessions,
status,
// Loading states
isSummaryLoading,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
// Combined loading
isLoading: isSummaryLoading || isTrendsLoading || isModelsLoading || isSessionsLoading,
// Handlers
handleRefresh,
handleTodayClick,
handleDateRangeChange,
handleModelClick,
handlePopoverClose,
// Text
lastUpdatedText,
};
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Analytics Page
*
* Displays Claude Code usage analytics with charts.
* Features trend charts, model breakdown, cost analysis, and anomaly detection.
*/
import { useRef } from 'react';
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
import { useAnalyticsPage } from './hooks';
import { AnalyticsHeader } from './components/analytics-header';
import { ChartsGrid } from './components/charts-grid';
export function AnalyticsPage() {
const popoverAnchorRef = useRef<HTMLDivElement>(null);
const {
dateRange,
handleDateRangeChange,
handleTodayClick,
handleRefresh,
isRefreshing,
lastUpdatedText,
viewMode,
summary,
isSummaryLoading,
trends,
hourlyData,
models,
sessions,
isTrendsLoading,
isHourlyLoading,
isModelsLoading,
isSessionsLoading,
handleModelClick,
selectedModel,
popoverPosition,
handlePopoverClose,
} = useAnalyticsPage();
return (
<div className="flex flex-col h-full overflow-hidden px-4 pt-4 pb-50 gap-4">
{/* Header */}
<AnalyticsHeader
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
onTodayClick={handleTodayClick}
onRefresh={handleRefresh}
isRefreshing={isRefreshing}
lastUpdatedText={lastUpdatedText}
viewMode={viewMode}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Charts Grid */}
<ChartsGrid
viewMode={viewMode}
trends={trends}
hourlyData={hourlyData}
models={models}
sessions={sessions}
isTrendsLoading={isTrendsLoading}
isHourlyLoading={isHourlyLoading}
isModelsLoading={isModelsLoading}
isSessionsLoading={isSessionsLoading}
isSummaryLoading={isSummaryLoading}
onModelClick={handleModelClick}
/>
{/* Model Details Popover - positioned at cursor */}
<Popover open={!!selectedModel} onOpenChange={(open) => !open && handlePopoverClose()}>
<PopoverAnchor asChild>
<div
ref={popoverAnchorRef}
className="fixed pointer-events-none"
style={{
left: popoverPosition?.x ?? 0,
top: popoverPosition?.y ?? 0,
width: 1,
height: 1,
}}
/>
</PopoverAnchor>
<PopoverContent className="w-80 p-3" side="top" align="center">
{selectedModel && <ModelDetailsContent model={selectedModel} />}
</PopoverContent>
</Popover>
</div>
);
}
// Re-export skeleton for route-level loading
export { AnalyticsSkeleton } from './components/analytics-skeleton';
+19
View File
@@ -0,0 +1,19 @@
/**
* Analytics Page Types
*/
import type { DateRange } from 'react-day-picker';
import type { ModelUsage } from '@/hooks/use-usage';
export interface AnalyticsPageState {
dateRange: DateRange | undefined;
isRefreshing: boolean;
selectedModel: ModelUsage | null;
popoverPosition: { x: number; y: number } | null;
viewMode: 'daily' | 'hourly';
}
export interface DatePreset {
label: string;
range: DateRange;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Analytics Page Utilities
*/
/**
* Format token count to human-readable (K/M/B)
*/
export function formatTokens(num: number): string {
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 1_000) return `${(num / 1_000).toFixed(0)}K`;
return num.toString();
}