From 03d9bf76c474f93f12fcc5dbdaa55c1f215b1e39 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 21 Dec 2025 03:27:15 -0500 Subject: [PATCH] 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 --- ui/src/pages/analytics.tsx | 420 ------------------ .../analytics/components/analytics-header.tsx | 75 ++++ .../components/analytics-skeleton.tsx | 69 +++ .../analytics/components/charts-grid.tsx | 100 +++++ .../components/cost-by-model-card.tsx | 163 +++++++ ui/src/pages/analytics/hooks.ts | 120 +++++ ui/src/pages/analytics/index.tsx | 96 ++++ ui/src/pages/analytics/types.ts | 19 + ui/src/pages/analytics/utils.ts | 13 + 9 files changed, 655 insertions(+), 420 deletions(-) delete mode 100644 ui/src/pages/analytics.tsx create mode 100644 ui/src/pages/analytics/components/analytics-header.tsx create mode 100644 ui/src/pages/analytics/components/analytics-skeleton.tsx create mode 100644 ui/src/pages/analytics/components/charts-grid.tsx create mode 100644 ui/src/pages/analytics/components/cost-by-model-card.tsx create mode 100644 ui/src/pages/analytics/hooks.ts create mode 100644 ui/src/pages/analytics/index.tsx create mode 100644 ui/src/pages/analytics/types.ts create mode 100644 ui/src/pages/analytics/utils.ts diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx deleted file mode 100644 index 520d385d..00000000 --- a/ui/src/pages/analytics.tsx +++ /dev/null @@ -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({ - from: subDays(new Date(), 30), - to: new Date(), - }); - const [isRefreshing, setIsRefreshing] = useState(false); - const [selectedModel, setSelectedModel] = useState(null); - const [popoverPosition, setPopoverPosition] = useState<{ x: number; y: number } | null>(null); - const [viewMode, setViewMode] = useState<'daily' | 'hourly'>('daily'); - const popoverAnchorRef = useRef(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 ( -
- {/* Header */} -
-
-

Analytics

-

Track usage & insights

-
-
- - - - {lastUpdatedText && ( - - Updated {lastUpdatedText} - - )} - -
-
- - {/* Summary Cards */} - - - {/* Main Content */} -
- {/* Usage Trend Chart - Full Width */} - - - - - {viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'} - - - - - - - - {/* Bottom Row - Cost by Model (4) + Model Usage (2) + Session Stats (2) + CLIProxy Stats (2) */} -
- {/* Cost by Model - 4/10 width with breakdown */} - - - - - Cost by Model - - - - {isModelsLoading ? ( - - ) : ( -
- {[...(models || [])] - .sort((a, b) => b.cost - a.cost) - .map((model) => ( - - ))} - {/* Legend */} -
- -
- Input - - -
- Output - - -
- Cache Write - - -
- Cache Read - -
-
- )} - - - - {/* Model Distribution - 2/10 width */} - - - - - Model Usage - - - - - - - - {/* Session Stats - 2/10 width */} - - - {/* CLIProxy Stats - 2/10 width */} - -
- - {/* Model Details Popover - positioned at cursor */} - !open && handlePopoverClose()}> - -
- - - {selectedModel && } - - -
-
- ); -} - -export function AnalyticsSkeleton() { - return ( -
- {/* Usage Trends Skeleton */} - - - - - - - - - - {/* Bottom Row Skeletons */} -
- {/* Cost Breakdown Skeleton */} - - - - - -
- {[1, 2, 3, 4, 5].map((i) => ( -
-
- - -
- -
- ))} -
-
-
- - {/* Model Usage Skeleton */} - - - - - -
-
- -
-
- {[1, 2, 3, 4].map((i) => ( -
- - -
- ))} -
-
-
-
-
-
- ); -} diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx new file mode 100644 index 00000000..484d2383 --- /dev/null +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -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 ( +
+
+

Analytics

+

Track usage & insights

+
+
+ + + + {lastUpdatedText && ( + + Updated {lastUpdatedText} + + )} + +
+
+ ); +} diff --git a/ui/src/pages/analytics/components/analytics-skeleton.tsx b/ui/src/pages/analytics/components/analytics-skeleton.tsx new file mode 100644 index 00000000..e8fe789c --- /dev/null +++ b/ui/src/pages/analytics/components/analytics-skeleton.tsx @@ -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 ( +
+ {/* Usage Trends Skeleton */} + + + + + + + + + + {/* Bottom Row Skeletons */} +
+ {/* Cost Breakdown Skeleton */} + + + + + +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+ + +
+ +
+ ))} +
+
+
+ + {/* Model Usage Skeleton */} + + + + + +
+
+ +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+
+
+
+
+
+ ); +} diff --git a/ui/src/pages/analytics/components/charts-grid.tsx b/ui/src/pages/analytics/components/charts-grid.tsx new file mode 100644 index 00000000..17aed1d3 --- /dev/null +++ b/ui/src/pages/analytics/components/charts-grid.tsx @@ -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 ( +
+ {/* Usage Trend Chart - Full Width */} + + + + + {viewMode === 'hourly' ? 'Last 24 Hours' : 'Usage Trends'} + + + + + + + + {/* Bottom Row */} +
+ {/* Cost by Model */} + + + {/* Model Distribution */} + + + + + Model Usage + + + + + + + + {/* Session Stats */} + + + {/* CLIProxy Stats */} + +
+
+ ); +} diff --git a/ui/src/pages/analytics/components/cost-by-model-card.tsx b/ui/src/pages/analytics/components/cost-by-model-card.tsx new file mode 100644 index 00000000..248b77c4 --- /dev/null +++ b/ui/src/pages/analytics/components/cost-by-model-card.tsx @@ -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 ( + + + + + Cost by Model + + + + {isLoading ? ( + + ) : ( +
+ {[...(models || [])] + .sort((a, b) => b.cost - a.cost) + .map((model) => ( + + ))} + {/* Legend */} + +
+ )} +
+
+ ); +} + +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 ( +
+
+
+
+
+
+
+
+ ); +} + +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 ( +
+ {items.map(({ color, label, hasBorder }) => ( + +
+ {label} + + ))} +
+ ); +} diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts new file mode 100644 index 00000000..8f8e39a7 --- /dev/null +++ b/ui/src/pages/analytics/hooks.ts @@ -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({ + from: subDays(new Date(), 30), + to: new Date(), + }); + const [isRefreshing, setIsRefreshing] = useState(false); + const [selectedModel, setSelectedModel] = useState(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, + }; +} diff --git a/ui/src/pages/analytics/index.tsx b/ui/src/pages/analytics/index.tsx new file mode 100644 index 00000000..2271d06a --- /dev/null +++ b/ui/src/pages/analytics/index.tsx @@ -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(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 ( +
+ {/* Header */} + + + {/* Summary Cards */} + + + {/* Charts Grid */} + + + {/* Model Details Popover - positioned at cursor */} + !open && handlePopoverClose()}> + +
+ + + {selectedModel && } + + +
+ ); +} + +// Re-export skeleton for route-level loading +export { AnalyticsSkeleton } from './components/analytics-skeleton'; diff --git a/ui/src/pages/analytics/types.ts b/ui/src/pages/analytics/types.ts new file mode 100644 index 00000000..58b970c9 --- /dev/null +++ b/ui/src/pages/analytics/types.ts @@ -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; +} diff --git a/ui/src/pages/analytics/utils.ts b/ui/src/pages/analytics/utils.ts new file mode 100644 index 00000000..bcff9e73 --- /dev/null +++ b/ui/src/pages/analytics/utils.ts @@ -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(); +}