diff --git a/bun.lock b/bun.lock index 3e11076c..f541b922 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "@kaitranntt/ccs", "dependencies": { + "better-ccusage": "^1.2.6", "boxen": "^8.0.1", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -445,6 +446,8 @@ "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], + "better-ccusage": ["better-ccusage@1.2.6", "", { "bin": { "better-ccusage": "dist/index.js" } }, "sha512-IZCYBX1kF0IfJ6ho9JMwLKn2o820WRiVGZ+2tVS2olODU5J7Np5mJ1j1i5HtazZPNo2S9wKU9C9iysc0f8Cjqw=="], + "body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], diff --git a/package.json b/package.json index 41454141..fd640734 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "postinstall": "node scripts/postinstall.js" }, "dependencies": { + "better-ccusage": "^1.2.6", "boxen": "^8.0.1", "chalk": "^5.6.2", "chokidar": "^5.0.0", diff --git a/src/types/external.d.ts b/src/types/external.d.ts index ba86374a..c1e87455 100644 --- a/src/types/external.d.ts +++ b/src/types/external.d.ts @@ -2,6 +2,81 @@ * Type shims for incomplete external dependencies */ +// better-ccusage types (package has JS exports but incomplete TS subpath support) +declare module 'better-ccusage/data-loader' { + export interface ModelBreakdown { + modelName: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + } + + export interface DailyUsage { + date: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + } + + export interface MonthlyUsage { + month: string; + source: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + } + + export interface SessionUsage { + sessionId: string; + projectPath: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + cost: number; + totalCost: number; + lastActivity: string; + versions: string[]; + modelsUsed: string[]; + modelBreakdowns: ModelBreakdown[]; + source: string; + } + + export interface DataLoaderOptions { + mode?: 'calculate' | 'cached'; + claudePaths?: string[]; + } + + export function loadDailyUsageData(options?: DataLoaderOptions): Promise; + export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise; + export function loadSessionData(options?: DataLoaderOptions): Promise; +} + +declare module 'better-ccusage/calculate-cost' { + export interface Totals { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + costUSD: number; + } + + export function calculateTotals(entries: unknown[]): Totals; + export function getTotalTokens(entries: unknown[]): number; +} + declare module 'cli-table3' { interface TableOptions { head?: string[]; diff --git a/src/web-server/index.ts b/src/web-server/index.ts index b98668a5..6d084f20 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise { + data: T; + timestamp: number; +} + +// Cache TTLs (milliseconds) +const CACHE_TTL = { + daily: 60 * 1000, // 1 minute - changes frequently + monthly: 5 * 60 * 1000, // 5 minutes - aggregated data + session: 60 * 1000, // 1 minute - user may refresh +}; + +// In-memory cache +const cache = new Map>(); + +// Pending requests for coalescing (prevents duplicate concurrent calls) +const pendingRequests = new Map>(); + +/** + * Get cached data or fetch from loader with TTL + * Also coalesces concurrent requests to prevent duplicate library calls + */ +async function getCachedData(key: string, ttl: number, loader: () => Promise): Promise { + // Check cache first + const cached = cache.get(key) as CacheEntry | undefined; + if (cached && Date.now() - cached.timestamp < ttl) { + return cached.data; + } + + // Check if request is already pending (coalesce) + const pending = pendingRequests.get(key) as Promise | undefined; + if (pending) { + return pending; + } + + // Create new request + const promise = loader() + .then((data) => { + cache.set(key, { data, timestamp: Date.now() }); + return data; + }) + .finally(() => { + pendingRequests.delete(key); + }); + + pendingRequests.set(key, promise); + return promise; +} + +/** Cached loader for daily usage data */ +async function getCachedDailyData(): Promise { + return getCachedData('daily', CACHE_TTL.daily, async () => { + return (await loadDailyUsageData()) as DailyUsage[]; + }); +} + +/** Cached loader for monthly usage data */ +async function getCachedMonthlyData(): Promise { + return getCachedData('monthly', CACHE_TTL.monthly, async () => { + return (await loadMonthlyUsageData()) as MonthlyUsage[]; + }); +} + +/** Cached loader for session data */ +async function getCachedSessionData(): Promise { + return getCachedData('session', CACHE_TTL.session, async () => { + return (await loadSessionData()) as SessionUsage[]; + }); +} + +/** + * Clear all cached data (useful for manual refresh) + */ +export function clearUsageCache(): void { + cache.clear(); +} + +// ============================================================================ +// Validation Helpers +// ============================================================================ + +/** + * Validate date string in YYYYMMDD format + */ +function validateDate(dateString?: string): string | undefined { + if (!dateString) return undefined; + + if (!DATE_REGEX.test(dateString)) { + throw new Error('Invalid date format. Use YYYYMMDD'); + } + + // Basic range check + const year = parseInt(dateString.substring(0, 4), 10); + const month = parseInt(dateString.substring(4, 6), 10); + const day = parseInt(dateString.substring(6, 8), 10); + + if (year < 2024 || year > 2100) throw new Error('Year out of valid range'); + if (month < 1 || month > 12) throw new Error('Month out of valid range'); + if (day < 1 || day > 31) throw new Error('Day out of valid range'); + + return dateString; +} + +/** + * Validate and parse limit parameter + */ +function validateLimit(limit?: string): number { + if (!limit) return DEFAULT_LIMIT; + + const num = parseInt(limit, 10); + if (isNaN(num) || num < 1 || num > MAX_LIMIT) { + throw new Error(`Limit must be between 1 and ${MAX_LIMIT}`); + } + + return num; +} + +/** + * Validate and parse offset parameter + */ +function validateOffset(offset?: string): number { + if (!offset) return 0; + + const num = parseInt(offset, 10); + if (isNaN(num) || num < 0) { + throw new Error('Offset must be a non-negative number'); + } + + return num; +} + +/** + * Filter data by date range + */ +function filterByDateRange( + data: T[], + since?: string, + until?: string +): T[] { + if (!since && !until) return data; + + return data.filter((item) => { + // Get the date field (prioritize date, then month, then lastActivity) + const itemDate = + item.date || item.month?.replace('-', '') || item.lastActivity?.replace(/-/g, ''); + if (!itemDate) return true; + + // Normalize to YYYYMMDD for comparison + const normalizedDate = itemDate.replace(/-/g, '').substring(0, 8); + + if (since && normalizedDate < since) return false; + if (until && normalizedDate > until) return false; + + return true; + }); +} + +/** + * Create standard error response + */ +function errorResponse(res: Response, error: unknown, defaultMessage: string): void { + console.error(defaultMessage + ':', error); + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const isValidationError = + errorMessage.includes('Invalid') || + errorMessage.includes('format') || + errorMessage.includes('range') || + errorMessage.includes('must be'); + + const statusCode = isValidationError ? 400 : 500; + + res.status(statusCode).json({ + success: false, + error: isValidationError ? errorMessage : defaultMessage, + }); +} + +/** + * GET /api/usage/summary + * + * Returns usage summary data for quick dashboard display. + * Query: ?since=YYYYMMDD&until=YYYYMMDD + */ +usageRoutes.get( + '/summary', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + // Calculate totals + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheTokens = 0; + let totalCost = 0; + + for (const day of filtered) { + totalInputTokens += day.inputTokens; + totalOutputTokens += day.outputTokens; + totalCacheTokens += day.cacheCreationTokens + day.cacheReadTokens; + totalCost += day.totalCost; + } + + const totalTokens = totalInputTokens + totalOutputTokens; + + res.json({ + success: true, + data: { + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheTokens, + totalCost: Math.round(totalCost * 100) / 100, + totalDays: filtered.length, + averageTokensPerDay: filtered.length > 0 ? Math.round(totalTokens / filtered.length) : 0, + averageCostPerDay: + filtered.length > 0 ? Math.round((totalCost / filtered.length) * 100) / 100 : 0, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch usage summary'); + } + } +); + +/** + * GET /api/usage/daily + * + * Returns daily usage trends for chart visualization. + * Query: ?since=YYYYMMDD&until=YYYYMMDD + */ +usageRoutes.get( + '/daily', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + // Transform for chart consumption + const trends = filtered.map((day) => ({ + date: day.date, + tokens: day.inputTokens + day.outputTokens, + inputTokens: day.inputTokens, + outputTokens: day.outputTokens, + cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, + cost: Math.round(day.totalCost * 100) / 100, + modelsUsed: day.modelsUsed.length, + })); + + res.json({ + success: true, + data: trends, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch daily usage'); + } + } +); + +/** + * GET /api/usage/models + * + * Returns usage breakdown by model for pie/bar charts. + * Query: ?since=YYYYMMDD&until=YYYYMMDD + */ +usageRoutes.get( + '/models', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const dailyData = await getCachedDailyData(); + const filtered = filterByDateRange(dailyData, since, until); + + // Aggregate model usage across all days + const modelMap = new Map< + string, + { + model: string; + inputTokens: number; + outputTokens: number; + cacheTokens: number; + cost: number; + } + >(); + + for (const day of filtered) { + for (const breakdown of day.modelBreakdowns) { + const existing = modelMap.get(breakdown.modelName) || { + model: breakdown.modelName, + inputTokens: 0, + outputTokens: 0, + cacheTokens: 0, + cost: 0, + }; + + existing.inputTokens += breakdown.inputTokens; + existing.outputTokens += breakdown.outputTokens; + existing.cacheTokens += breakdown.cacheCreationTokens + breakdown.cacheReadTokens; + existing.cost += breakdown.cost; + + modelMap.set(breakdown.modelName, existing); + } + } + + // Calculate totals for percentage + const models = Array.from(modelMap.values()); + const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); + + // Add percentage and sort by tokens + const result = models + .map((m) => ({ + ...m, + tokens: m.inputTokens + m.outputTokens, + cost: Math.round(m.cost * 100) / 100, + percentage: + totalTokens > 0 + ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 + : 0, + })) + .sort((a, b) => b.tokens - a.tokens); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch model usage'); + } + } +); + +/** + * GET /api/usage/sessions + * + * Returns paginated list of sessions. + * Query: ?since=YYYYMMDD&until=YYYYMMDD&limit=50&offset=0 + */ +usageRoutes.get( + '/sessions', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + const limit = validateLimit(req.query.limit); + const offset = validateOffset(req.query.offset); + + const sessionData = await getCachedSessionData(); + + // Filter by date range using lastActivity + const filtered = filterByDateRange(sessionData, since, until); + + // Sort by lastActivity descending + const sorted = [...filtered].sort( + (a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime() + ); + + // Paginate + const paginated = sorted.slice(offset, offset + limit); + + // Transform for frontend + const sessions = paginated.map((s) => ({ + sessionId: s.sessionId, + projectPath: s.projectPath, + tokens: s.inputTokens + s.outputTokens, + inputTokens: s.inputTokens, + outputTokens: s.outputTokens, + cost: Math.round(s.totalCost * 100) / 100, + lastActivity: s.lastActivity, + modelsUsed: s.modelsUsed, + })); + + res.json({ + success: true, + data: { + sessions, + total: filtered.length, + limit, + offset, + hasMore: offset + limit < filtered.length, + }, + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch sessions'); + } + } +); + +/** + * GET /api/usage/monthly + * + * Returns monthly usage summary for charts. + * Query: ?since=YYYYMMDD&until=YYYYMMDD + */ +usageRoutes.get( + '/monthly', + async (req: Request, res: Response) => { + try { + const since = validateDate(req.query.since); + const until = validateDate(req.query.until); + + const monthlyData = await getCachedMonthlyData(); + + // Filter by date range (convert month YYYY-MM to YYYYMM01 for comparison) + const filtered = + since || until + ? monthlyData.filter((m) => { + const monthDate = m.month.replace('-', '') + '01'; + if (since && monthDate < since) return false; + if (until && monthDate > until) return false; + return true; + }) + : monthlyData; + + // Transform for charts + const result = filtered.map((m) => ({ + month: m.month, + tokens: m.inputTokens + m.outputTokens, + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, + cost: Math.round(m.totalCost * 100) / 100, + modelsUsed: m.modelsUsed.length, + })); + + res.json({ + success: true, + data: result.sort((a, b) => a.month.localeCompare(b.month)), + }); + } catch (error) { + errorResponse(res, error, 'Failed to fetch monthly usage'); + } + } +); + +/** + * POST /api/usage/refresh + * + * Clears the usage cache to force fresh data fetch. + * Useful when user wants to see latest data immediately. + */ +usageRoutes.post('/refresh', (_req: Request, res: Response) => { + clearUsageCache(); + res.json({ + success: true, + message: 'Usage cache cleared', + }); +}); diff --git a/ui/bun.lock b/ui/bun.lock index 59c1bef1..e1e67280 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -20,11 +20,14 @@ "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.556.0", "react": "^19.2.0", + "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.1.13", @@ -35,6 +38,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/recharts": "^1.8.29", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", @@ -82,12 +86,16 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -352,6 +360,24 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@1.0.11", "", {}, "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@1.3.12", "", { "dependencies": { "@types/d3-path": "^1" } }, "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -362,6 +388,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/recharts": ["@types/recharts@1.8.29", "", { "dependencies": { "@types/d3-shape": "^1", "@types/react": "*" } }, "sha512-ulKklaVsnFIIhTQsQw226TnOibrddW1qUQNFVhoQEyY1Z7FRQrNecFCGt7msRuJseudzE9czVawZb17dK/aPXw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.48.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/type-utils": "8.48.1", "@typescript-eslint/utils": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.48.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.48.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/types": "8.48.1", "@typescript-eslint/typescript-estree": "8.48.1", "@typescript-eslint/visitor-keys": "8.48.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA=="], @@ -430,14 +458,44 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.266", "", {}, "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], @@ -470,8 +528,12 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-equals": ["fast-equals@5.3.3", "", {}, "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw=="], + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], @@ -512,6 +574,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -564,8 +628,12 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-react": ["lucide-react@0.556.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A=="], @@ -582,6 +650,8 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -604,14 +674,20 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="], + "react-day-picker": ["react-day-picker@9.12.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA=="], + "react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="], "react-hook-form": ["react-hook-form@7.68.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q=="], + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], @@ -622,10 +698,18 @@ "react-router-dom": ["react-router-dom@7.10.1", "", { "dependencies": { "react-router": "7.10.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw=="], + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="], @@ -654,6 +738,8 @@ "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], @@ -676,6 +762,8 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + "vite": ["vite@7.2.6", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -728,6 +816,12 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "victory-vendor/@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "victory-vendor/@types/d3-shape/@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], } } diff --git a/ui/package.json b/ui/package.json index ef229add..6462722c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -31,11 +31,14 @@ "chokidar": "^5.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.556.0", "react": "^19.2.0", + "react-day-picker": "^9.12.0", "react-dom": "^19.2.0", "react-hook-form": "^7.68.0", "react-router-dom": "^7.10.1", + "recharts": "^2.12.0", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "zod": "^4.1.13" @@ -46,6 +49,7 @@ "@types/node": "^24.10.1", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", + "@types/recharts": "^1.8.29", "@vitejs/plugin-react": "^5.1.1", "eslint": "^9.39.1", "eslint-config-prettier": "^10.1.8", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 52f572ba..02a0f239 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -15,6 +15,7 @@ import { SettingsPage, HealthPage, SharedPage, + AnalyticsPage, } from '@/pages'; function Layout() { @@ -42,6 +43,7 @@ export default function App() { }> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/analytics/date-range-filter.tsx b/ui/src/components/analytics/date-range-filter.tsx new file mode 100644 index 00000000..e2dca7f1 --- /dev/null +++ b/ui/src/components/analytics/date-range-filter.tsx @@ -0,0 +1,95 @@ +/** + * Date Range Filter Component + * + * Provides date range selection with preset options for analytics. + * Uses react-day-picker for date selection UI. + */ + +import React from 'react'; +import { format } from 'date-fns'; +import type { DateRange } from 'react-day-picker'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; +import { CalendarIcon } from 'lucide-react'; + +interface DateRangeFilterProps { + value?: DateRange; + onChange: (dateRange: DateRange | undefined) => void; + presets?: Array<{ + label: string; + range: DateRange; + }>; + className?: string; +} + +export function DateRangeFilter({ + value, + onChange, + presets = [], + className, +}: DateRangeFilterProps) { + const handlePresetClick = (range: DateRange) => { + onChange(range); + }; + + const handleFromChange = (e: React.ChangeEvent) => { + const from = e.target.value ? new Date(e.target.value) : undefined; + onChange({ from, to: value?.to }); + }; + + const handleToChange = (e: React.ChangeEvent) => { + const to = e.target.value ? new Date(e.target.value) : undefined; + onChange({ from: value?.from, to }); + }; + + return ( +
+ {/* Preset Buttons */} + {presets.map((preset, index) => ( + + ))} + + {/* Custom Date Range Inputs */} +
+
+ + +
+ to + +
+
+ ); +} + +// Helper to compare date ranges +function isSameRange(a?: DateRange, b?: DateRange): boolean { + if (!a || !b) return a === b; + + const fromA = a.from?.getTime() ?? 0; + const fromB = b.from?.getTime() ?? 0; + const toA = a.to?.getTime() ?? 0; + const toB = b.to?.getTime() ?? 0; + + return fromA === fromB && toA === toB; +} diff --git a/ui/src/components/analytics/model-breakdown-chart.tsx b/ui/src/components/analytics/model-breakdown-chart.tsx new file mode 100644 index 00000000..81fb9576 --- /dev/null +++ b/ui/src/components/analytics/model-breakdown-chart.tsx @@ -0,0 +1,123 @@ +/** + * Model Breakdown Chart Component + * + * Displays usage distribution by model using pie chart. + * Shows tokens, cost, and percentage breakdown. + */ + +import { useMemo } from 'react'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend } from 'recharts'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { ModelUsage } from '@/hooks/use-usage'; +import { cn } from '@/lib/utils'; + +interface ModelBreakdownChartProps { + data: ModelUsage[]; + isLoading?: boolean; + className?: string; +} + +const COLORS = [ + '#0080FF', + '#00C49F', + '#FFBB28', + '#FF8042', + '#8884D8', + '#82CA9D', + '#FFC658', + '#8DD1E1', + '#D084D0', + '#87D068', +]; + +export function ModelBreakdownChart({ data, isLoading, className }: ModelBreakdownChartProps) { + const chartData = useMemo(() => { + if (!data || data.length === 0) return []; + + return data.map((item, index) => ({ + name: item.model, + value: item.tokens, + cost: item.cost, + requests: item.requests, + percentage: item.percentage, + fill: COLORS[index % COLORS.length], + })); + }, [data]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+

No model data available

+
+ ); + } + + const renderTooltip = ({ active, payload }: { active?: boolean; payload?: unknown }) => { + if (!active || !payload) return null; + + const payloadArray = payload as Array<{ + payload: { name: string; value: number; cost: number; requests: number; percentage: number }; + }>; + if (!payloadArray.length) return null; + + const data = payloadArray[0].payload; + return ( +
+

{data.name}

+

+ Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%) +

+

Cost: ${data.cost.toFixed(4)}

+

Requests: {data.requests}

+
+ ); + }; + + const renderLabel = (entry: { percentage: number }) => { + return `${entry.percentage.toFixed(1)}%`; + }; + + return ( +
+ + + + {chartData.map((entry, index) => ( + + ))} + + + {value}} + /> + + +
+ ); +} + +// Helper function to format large numbers +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} diff --git a/ui/src/components/analytics/sessions-table.tsx b/ui/src/components/analytics/sessions-table.tsx new file mode 100644 index 00000000..7385dab3 --- /dev/null +++ b/ui/src/components/analytics/sessions-table.tsx @@ -0,0 +1,269 @@ +/** + * Sessions Table Component + * + * Displays session history with pagination and filtering. + * Shows session duration, tokens, cost, and metadata. + */ + +import { useState, useMemo } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ChevronLeft, ChevronRight, Search, Clock, Zap, DollarSign } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { PaginatedSessions } from '@/hooks/use-usage'; + +interface SessionsTableProps { + data?: PaginatedSessions; + isLoading?: boolean; +} + +export function SessionsTable({ data, isLoading }: SessionsTableProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [currentPage, setCurrentPage] = useState(0); + + // Get sessions array (stable reference for memoization) + const sessions = data?.sessions ?? []; + + // Filter sessions based on search term + const filteredSessions = useMemo(() => { + if (!searchTerm) return sessions; + + const term = searchTerm.toLowerCase(); + return sessions.filter( + (session) => + session.profile.toLowerCase().includes(term) || + session.model.toLowerCase().includes(term) || + session.id.toLowerCase().includes(term) + ); + }, [sessions, searchTerm]); + + // Pagination for filtered data + const pageSize = 10; + const paginatedSessions = useMemo(() => { + if (!filteredSessions) return []; + const start = currentPage * pageSize; + return filteredSessions.slice(start, start + pageSize); + }, [filteredSessions, currentPage]); + + const totalPages = Math.ceil((filteredSessions?.length || 0) / pageSize); + + if (isLoading) { + return ; + } + + if (!data || data.sessions.length === 0) { + return ( +
+ +

No sessions found

+

Start using Claude Code to see session history

+
+ ); + } + + return ( +
+ {/* Search Bar */} +
+
+ + { + setSearchTerm(e.target.value); + setCurrentPage(0); + }} + className="pl-8" + /> +
+
+ + {/* Table */} +
+ + + + Session ID + Profile + Model + Duration + Tokens + Cost + Requests + Last Used + + + + {paginatedSessions.map((session) => ( + + {session.id.slice(0, 8)}... + + {session.profile} + + {session.model} + {session.duration ? formatDuration(session.duration) : '-'} + +
+ + {formatNumber(session.tokens)} +
+
+ +
+ $ + {session.cost.toFixed(4)} +
+
+ {session.requests} + + {formatDistanceToNow(new Date(session.startTime), { addSuffix: true })} + +
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {currentPage * pageSize + 1} to{' '} + {Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '} + {filteredSessions?.length} sessions +

+
+ +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const page = i; + return ( + + ); + })} +
+ +
+
+ )} +
+ ); +} + +// Helper functions +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} + +// Skeleton loading state +function SessionsTableSkeleton() { + return ( +
+
+ +
+
+ + + + Session ID + Profile + Model + Duration + Tokens + Cost + Requests + Last Used + + + + {[1, 2, 3, 4, 5].map((i) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + +
+
+
+ ); +} diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx new file mode 100644 index 00000000..d76aad93 --- /dev/null +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -0,0 +1,108 @@ +/** + * Usage Summary Cards Component + * + * Displays key metrics in a card grid layout. + * Shows total tokens, cost, requests, and average tokens per request. + */ + +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { TrendingUp, DollarSign, Zap, FileText } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { UsageSummary } from '@/hooks/use-usage'; + +interface UsageSummaryCardsProps { + data?: UsageSummary; + isLoading?: boolean; +} + +export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { + if (isLoading) { + return ( +
+ {[1, 2, 3, 4].map((i) => ( + + +
+
+ + +
+ +
+
+
+ ))} +
+ ); + } + + const cards = [ + { + title: 'Total Tokens', + value: data?.totalTokens ?? 0, + icon: FileText, + format: (v: number) => formatNumber(v), + color: 'text-blue-600', + bgColor: 'bg-blue-100 dark:bg-blue-900/20', + }, + { + title: 'Total Cost', + value: data?.totalCost ?? 0, + icon: DollarSign, + format: (v: number) => `$${v.toFixed(2)}`, + color: 'text-green-600', + bgColor: 'bg-green-100 dark:bg-green-900/20', + }, + { + title: 'Total Requests', + value: data?.totalRequests ?? 0, + icon: Zap, + format: (v: number) => formatNumber(v), + color: 'text-purple-600', + bgColor: 'bg-purple-100 dark:bg-purple-900/20', + }, + { + title: 'Avg Tokens/Request', + value: data?.averageTokensPerRequest ?? 0, + icon: TrendingUp, + format: (v: number) => formatNumber(Math.round(v)), + color: 'text-orange-600', + bgColor: 'bg-orange-100 dark:bg-orange-900/20', + }, + ]; + + return ( +
+ {cards.map((card, index) => { + const Icon = card.icon; + return ( + + +
+
+

{card.title}

+

{card.format(card.value)}

+
+
+ +
+
+
+
+ ); + })} +
+ ); +} + +// Helper to format large numbers +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx new file mode 100644 index 00000000..01e8bc7f --- /dev/null +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -0,0 +1,171 @@ +/** + * Usage Trend Chart Component + * + * Displays usage trends over time with tokens and cost. + * Supports daily and monthly granularity with interactive tooltips. + */ + +import { useMemo } from 'react'; +import { + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Area, + AreaChart, +} from 'recharts'; +import { format } from 'date-fns'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { DateRange } from 'react-day-picker'; +import { cn } from '@/lib/utils'; +import type { DailyUsage } from '@/hooks/use-usage'; + +interface UsageTrendChartProps { + data: DailyUsage[]; + isLoading?: boolean; + dateRange?: DateRange; + granularity?: 'daily' | 'monthly'; + className?: string; +} + +export function UsageTrendChart({ + data, + isLoading, + granularity = 'daily', + className, +}: Omit) { + const chartData = useMemo(() => { + if (!data || data.length === 0) return []; + + return data.map((item) => ({ + ...item, + dateFormatted: formatDate(item.date, granularity), + costRounded: Number(item.cost.toFixed(4)), + })); + }, [data, granularity]); + + if (isLoading) { + return ; + } + + if (!data || data.length === 0) { + return ( +
+

No usage data available

+
+ ); + } + + return ( +
+ + + + + + + + + + + + + + + + + + formatNumber(value)} + /> + + `$${value}`} + /> + + { + if (!active || !payload || !payload.length) return null; + + const data = payload[0].payload; + return ( +
+

{label}

+ {payload.map((entry, index) => ( +

+ {entry.name}:{' '} + {entry.name === 'Tokens' + ? formatNumber(Number(entry.value) || 0) + : `$${entry.value}`} +

+ ))} +

Requests: {data.requests}

+
+ ); + }} + /> + + + + +
+
+
+ ); +} + +// Helper functions +function formatDate(dateStr: string, granularity: 'daily' | 'monthly'): string { + const date = new Date(dateStr); + + if (granularity === 'monthly') { + return format(date, 'MMM yyyy'); + } + + // For daily, show shorter format if range is > 30 days + return format(date, 'MMM dd'); +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return `${(num / 1000000).toFixed(1)}M`; + } + if (num >= 1000) { + return `${(num / 1000).toFixed(1)}K`; + } + return num.toLocaleString(); +} diff --git a/ui/src/components/app-sidebar.tsx b/ui/src/components/app-sidebar.tsx index 670570e6..d1bd4cc3 100644 --- a/ui/src/components/app-sidebar.tsx +++ b/ui/src/components/app-sidebar.tsx @@ -1,5 +1,15 @@ import { Link, useLocation } from 'react-router-dom'; -import { Home, Key, Zap, Users, Settings, Activity, FolderOpen, ChevronRight } from 'lucide-react'; +import { + Home, + Key, + Zap, + Users, + Settings, + Activity, + FolderOpen, + ChevronRight, + BarChart3, +} from 'lucide-react'; import { Sidebar, SidebarContent, @@ -24,7 +34,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component const navGroups = [ { title: 'General', - items: [{ path: '/', icon: Home, label: 'Home' }], + items: [ + { path: '/', icon: Home, label: 'Home' }, + { path: '/analytics', icon: BarChart3, label: 'Analytics' }, + ], }, { title: 'Identity & Access', diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts new file mode 100644 index 00000000..8486e3d5 --- /dev/null +++ b/ui/src/hooks/use-usage.ts @@ -0,0 +1,202 @@ +/** + * React Query hooks for usage analytics + * Phase 01: Analytics Page Implementation + */ + +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; + +// Types +export interface UsageSummary { + totalTokens: number; + totalCost: number; + totalRequests: number; + averageTokensPerRequest: number; + dailyUsage: DailyUsage[]; +} + +export interface DailyUsage { + date: string; + tokens: number; + cost: number; + requests: number; +} + +export interface ModelUsage { + model: string; + tokens: number; + cost: number; + requests: number; + percentage: number; +} + +export interface Session { + id: string; + startTime: string; + endTime?: string; + duration?: number; + tokens: number; + cost: number; + requests: number; + profile: string; + model: string; +} + +export interface PaginatedSessions { + sessions: Session[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +export interface MonthlyUsage { + month: string; + tokens: number; + cost: number; + requests: number; +} + +export interface UsageQueryOptions { + startDate?: Date; + endDate?: Date; + profile?: string; + limit?: number; + offset?: number; +} + +// API +const BASE_URL = '/api'; + +/** + * Convert Date to YYYYMMDD format for API + */ +function formatDateForApi(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +export const usageApi = { + summary: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + if (options?.profile) params.append('profile', options.profile); + return request(`/usage/summary?${params}`); + }, + trends: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + if (options?.profile) params.append('profile', options.profile); + return request(`/usage/daily?${params}`); + }, + models: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + if (options?.profile) params.append('profile', options.profile); + return request(`/usage/models?${params}`); + }, + sessions: (options?: UsageQueryOptions) => { + const params = new URLSearchParams(); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + if (options?.profile) params.append('profile', options.profile); + if (options?.limit) params.append('limit', options.limit.toString()); + if (options?.offset) params.append('offset', options.offset.toString()); + return request(`/usage/sessions?${params}`); + }, + monthly: (months?: number, profile?: string) => { + const params = new URLSearchParams(); + if (months) params.append('months', months.toString()); + if (profile) params.append('profile', profile); + return request(`/usage/monthly?${params}`); + }, + /** Clear server-side usage cache and force fresh data fetch */ + refresh: async (): Promise => { + const res = await fetch(`${BASE_URL}/usage/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (!res.ok) { + throw new Error('Failed to refresh usage cache'); + } + }, +}; + +// Helper function to match existing API client pattern +async function request(url: string): Promise { + const BASE_URL = '/api'; + const res = await fetch(`${BASE_URL}${url}`, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + const error = await res.json().catch(() => ({ error: 'Unknown error' })); + throw new Error(error.error || res.statusText); + } + + const result = await res.json(); + return result.data || result; // Extract data property if it exists +} + +// Hooks +export function useUsageSummary(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'summary', options], + queryFn: () => usageApi.summary(options), + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useUsageTrends(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'trends', options], + queryFn: () => usageApi.trends(options), + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useModelUsage(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'models', options], + queryFn: () => usageApi.models(options), + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useSessions(options?: UsageQueryOptions) { + return useQuery({ + queryKey: ['usage', 'sessions', options], + queryFn: () => usageApi.sessions(options), + staleTime: 60 * 1000, // 1 minute + }); +} + +export function useMonthlyUsage(months?: number, profile?: string) { + return useQuery({ + queryKey: ['usage', 'monthly', months, profile], + queryFn: () => usageApi.monthly(months, profile), + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Hook to refresh all usage data + * Clears server-side cache and invalidates React Query cache + */ +export function useRefreshUsage() { + const queryClient = useQueryClient(); + + const refresh = useCallback(async () => { + // Clear server-side cache + await usageApi.refresh(); + // Invalidate all usage queries in React Query + await queryClient.invalidateQueries({ queryKey: ['usage'] }); + }, [queryClient]); + + return refresh; +} diff --git a/ui/src/pages/analytics.tsx b/ui/src/pages/analytics.tsx new file mode 100644 index 00000000..bf98651d --- /dev/null +++ b/ui/src/pages/analytics.tsx @@ -0,0 +1,297 @@ +/** + * Analytics Page + * + * Displays Claude Code usage analytics with charts and tables. + * Features daily/monthly views, trend charts, model breakdown, and session history. + */ + +import { useState } from 'react'; +import type { DateRange } from 'react-day-picker'; +import { startOfMonth, subDays } from 'date-fns'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +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 { SessionsTable } from '@/components/analytics/sessions-table'; +import { TrendingUp, BarChart3, Clock, Calendar, Download, RefreshCw } from 'lucide-react'; +import { + useUsageSummary, + useUsageTrends, + useModelUsage, + useSessions, + useRefreshUsage, +} from '@/hooks/use-usage'; + +type ViewMode = 'daily' | 'monthly' | 'sessions'; + +export function AnalyticsPage() { + // Default to last 30 days + const [dateRange, setDateRange] = useState({ + from: subDays(new Date(), 30), + to: new Date(), + }); + const [viewMode, setViewMode] = useState('daily'); + const [isRefreshing, setIsRefreshing] = useState(false); + + // 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: models, isLoading: isModelsLoading } = useModelUsage(apiOptions); + const { data: sessions, isLoading: isSessionsLoading } = useSessions({ + ...apiOptions, + limit: 50, + }); + + // Loading state + if (isSummaryLoading || isTrendsLoading || isModelsLoading) { + return ; + } + + return ( +
+ {/* Header */} +
+
+

Analytics

+

Track your Claude Code usage and insights

+
+
+ + +
+
+ + {/* Date Range Filter */} + + + {/* Summary Cards */} + + + {/* Main Content Tabs */} + setViewMode(v as ViewMode)}> + + + + Daily + + + + Monthly + + + + Sessions + + + + {/* Daily View */} + +
+ {/* Usage Trend Chart */} + + + + + Usage Trends + + + + + + + + {/* Model Distribution */} + + + + + Model Usage + + + + + + + + {/* Cost Breakdown */} + + + Cost by Model + + + {isModelsLoading ? ( + + ) : ( +
+ {models?.slice(0, 5).map((model) => ( +
+
+
+ {model.model} +
+ + ${model.cost.toFixed(4)} + +
+ ))} +
+ )} + + +
+ + + {/* Monthly View */} + + + + + + Monthly Overview + + + + + + + + + {/* Sessions View */} + + + + + + Session History + + + + + + + + +
+ ); +} + +// Helper function to generate consistent colors for models +function getModelColor(model: string): string { + const colors = [ + '#0080FF', + '#00C49F', + '#FFBB28', + '#FF8042', + '#8884D8', + '#82CA9D', + '#FFC658', + '#8DD1E1', + '#D084D0', + '#87D068', + ]; + + let hash = 0; + for (let i = 0; i < model.length; i++) { + hash = model.charCodeAt(i) + ((hash << 5) - hash); + } + + return colors[Math.abs(hash) % colors.length]; +} + +// Skeleton loading state +function AnalyticsSkeleton() { + return ( +
+ {/* Header */} +
+ + +
+ + {/* Date Filter */} + + + {/* Summary Cards */} +
+ {[1, 2, 3, 4].map((i) => ( + + + + + + + ))} +
+ + {/* Charts */} +
+ + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/ui/src/pages/index.tsx b/ui/src/pages/index.tsx index 52297c6b..d8d447d3 100644 --- a/ui/src/pages/index.tsx +++ b/ui/src/pages/index.tsx @@ -11,3 +11,5 @@ export { SettingsPage } from './settings'; export { HealthPage } from './health'; export { SharedPage } from './shared'; + +export { AnalyticsPage } from './analytics';