feat(analytics): add usage analytics page with caching layer

- Add Analytics page with usage trends, model breakdown, sessions table
- Add server-side caching layer for better-ccusage data (TTL-based)
- Add request coalescing to prevent duplicate concurrent API calls
- Add /api/usage/refresh endpoint to manually clear cache
- Add date-range filter, summary cards, trend charts components
- Fix API parameter mismatch (since/until in YYYYMMDD format)
- Wire up Refresh button with loading state animation
This commit is contained in:
kaitranntt
2025-12-08 21:47:36 -05:00
parent 57032eec5e
commit a721af3cf3
17 changed files with 1961 additions and 2 deletions
+3
View File
@@ -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=="],
+1
View File
@@ -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",
+75
View File
@@ -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<DailyUsage[]>;
export function loadMonthlyUsageData(options?: DataLoaderOptions): Promise<MonthlyUsage[]>;
export function loadSessionData(options?: DataLoaderOptions): Promise<SessionUsage[]>;
}
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[];
+4
View File
@@ -47,6 +47,10 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
const { overviewRoutes } = await import('./overview-routes');
app.use('/api/overview', overviewRoutes);
// Usage analytics routes
const { usageRoutes } = await import('./usage-routes');
app.use('/api/usage', usageRoutes);
// Dev mode: use Vite middleware for HMR
if (options.dev) {
const { createServer: createViteServer } = await import('vite');
+496
View File
@@ -0,0 +1,496 @@
/**
* Usage Analytics API Routes
*
* Provides REST endpoints for Claude Code usage analytics using better-ccusage library.
* Supports daily, monthly, and session-based usage data aggregation.
*
* Performance optimizations:
* - TTL-based caching to reduce better-ccusage library calls
* - Request coalescing to prevent duplicate concurrent requests
*/
import { Router, Request, Response } from 'express';
import {
loadDailyUsageData,
loadMonthlyUsageData,
loadSessionData,
type DailyUsage,
type MonthlyUsage,
type SessionUsage,
} from 'better-ccusage/data-loader';
export const usageRoutes = Router();
/** Query parameters for usage endpoints */
interface UsageQuery {
since?: string; // YYYYMMDD format
until?: string; // YYYYMMDD format
limit?: string;
offset?: string;
}
// Constants for validation
const MAX_LIMIT = 1000;
const DEFAULT_LIMIT = 50;
const DATE_REGEX = /^\d{8}$/; // YYYYMMDD format
// ============================================================================
// Caching Layer - Reduces better-ccusage library calls
// ============================================================================
interface CacheEntry<T> {
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<string, CacheEntry<unknown>>();
// Pending requests for coalescing (prevents duplicate concurrent calls)
const pendingRequests = new Map<string, Promise<unknown>>();
/**
* Get cached data or fetch from loader with TTL
* Also coalesces concurrent requests to prevent duplicate library calls
*/
async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<T>): Promise<T> {
// Check cache first
const cached = cache.get(key) as CacheEntry<T> | 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<T> | 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<DailyUsage[]> {
return getCachedData('daily', CACHE_TTL.daily, async () => {
return (await loadDailyUsageData()) as DailyUsage[];
});
}
/** Cached loader for monthly usage data */
async function getCachedMonthlyData(): Promise<MonthlyUsage[]> {
return getCachedData('monthly', CACHE_TTL.monthly, async () => {
return (await loadMonthlyUsageData()) as MonthlyUsage[];
});
}
/** Cached loader for session data */
async function getCachedSessionData(): Promise<SessionUsage[]> {
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<T extends { date?: string; month?: string; lastActivity?: string }>(
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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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<object, object, object, UsageQuery>, 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',
});
});
+94
View File
@@ -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=="],
}
}
+4
View File
@@ -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",
+2
View File
@@ -15,6 +15,7 @@ import {
SettingsPage,
HealthPage,
SharedPage,
AnalyticsPage,
} from '@/pages';
function Layout() {
@@ -42,6 +43,7 @@ export default function App() {
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<HomePage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/api" element={<ApiPage />} />
<Route path="/cliproxy" element={<CliproxyPage />} />
<Route path="/accounts" element={<AccountsPage />} />
@@ -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<HTMLInputElement>) => {
const from = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from, to: value?.to });
};
const handleToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const to = e.target.value ? new Date(e.target.value) : undefined;
onChange({ from: value?.from, to });
};
return (
<div className={cn('flex flex-wrap items-center gap-2', className)}>
{/* Preset Buttons */}
{presets.map((preset, index) => (
<Button
key={index}
variant={isSameRange(value, preset.range) ? 'default' : 'outline'}
size="sm"
onClick={() => handlePresetClick(preset.range)}
>
{preset.label}
</Button>
))}
{/* Custom Date Range Inputs */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
<Input
type="date"
value={value?.from ? format(value.from, 'yyyy-MM-dd') : ''}
onChange={handleFromChange}
placeholder="From"
className="w-40"
/>
</div>
<span className="text-muted-foreground">to</span>
<Input
type="date"
value={value?.to ? format(value.to, 'yyyy-MM-dd') : ''}
onChange={handleToChange}
placeholder="To"
className="w-40"
/>
</div>
</div>
);
}
// 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;
}
@@ -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 <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No model data available</p>
</div>
);
}
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 (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{data.name}</p>
<p className="text-sm text-muted-foreground">
Tokens: {formatNumber(data.value)} ({data.percentage.toFixed(1)}%)
</p>
<p className="text-sm text-muted-foreground">Cost: ${data.cost.toFixed(4)}</p>
<p className="text-sm text-muted-foreground">Requests: {data.requests}</p>
</div>
);
};
const renderLabel = (entry: { percentage: number }) => {
return `${entry.percentage.toFixed(1)}%`;
};
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={renderLabel}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip content={renderTooltip} />
<Legend
verticalAlign="bottom"
height={36}
formatter={(value) => <span className="text-sm">{value}</span>}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
}
// 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();
}
@@ -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 <SessionsTableSkeleton />;
}
if (!data || data.sessions.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Clock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-1">No sessions found</h3>
<p className="text-muted-foreground">Start using Claude Code to see session history</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Search Bar */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by profile, model, or session ID..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(0);
}}
className="pl-8"
/>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedSessions.map((session) => (
<TableRow key={session.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">{session.id.slice(0, 8)}...</TableCell>
<TableCell>
<Badge variant="secondary">{session.profile}</Badge>
</TableCell>
<TableCell className="font-medium">{session.model}</TableCell>
<TableCell>{session.duration ? formatDuration(session.duration) : '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
{formatNumber(session.tokens)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<DollarSign className="h-3 w-3 text-muted-foreground" />$
{session.cost.toFixed(4)}
</div>
</TableCell>
<TableCell className="text-right">{session.requests}</TableCell>
<TableCell className="text-muted-foreground">
{formatDistanceToNow(new Date(session.startTime), { addSuffix: true })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {currentPage * pageSize + 1} to{' '}
{Math.min((currentPage + 1) * pageSize, filteredSessions?.length || 0)} of{' '}
{filteredSessions?.length} sessions
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i;
return (
<Button
key={page}
variant={currentPage === page ? 'default' : 'outline'}
size="sm"
className={cn('w-8 h-8 p-0')}
onClick={() => setCurrentPage(page)}
>
{page + 1}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={currentPage === totalPages - 1}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
);
}
// 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 (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Skeleton className="h-10 flex-1" />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Session ID</TableHead>
<TableHead>Profile</TableHead>
<TableHead>Model</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead>Last Used</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{[1, 2, 3, 4, 5].map((i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-[80px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[100px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[80px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[70px]" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-4 w-[60px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[80px]" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-8 w-[80px]" />
</div>
<Skeleton className="h-8 w-8 rounded-lg" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
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 (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{cards.map((card, index) => {
const Icon = card.icon;
return (
<Card key={index} className="hover:shadow-md transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">{card.format(card.value)}</p>
</div>
<div className={cn('p-2 rounded-lg', card.bgColor)}>
<Icon className={cn('h-5 w-5', card.color)} />
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
}
// 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();
}
@@ -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<UsageTrendChartProps, 'dateRange'>) {
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 <Skeleton className={cn('h-[300px] w-full', className)} />;
}
if (!data || data.length === 0) {
return (
<div className={cn('h-[300px] flex items-center justify-center', className)}>
<p className="text-muted-foreground">No usage data available</p>
</div>
);
}
return (
<div className={cn('w-full', className)}>
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#0080FF" stopOpacity={0.8} />
<stop offset="95%" stopColor="#0080FF" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="costGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#00C49F" stopOpacity={0.8} />
<stop offset="95%" stopColor="#00C49F" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="dateFormatted"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
/>
<YAxis
yAxisId="left"
orientation="left"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => formatNumber(value)}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={{ className: 'stroke-muted' }}
tickFormatter={(value) => `$${value}`}
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload || !payload.length) return null;
const data = payload[0].payload;
return (
<div className="rounded-lg border bg-background p-3 shadow-lg">
<p className="font-medium mb-2">{label}</p>
{payload.map((entry, index) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name}:{' '}
{entry.name === 'Tokens'
? formatNumber(Number(entry.value) || 0)
: `$${entry.value}`}
</p>
))}
<p className="text-sm text-muted-foreground mt-1">Requests: {data.requests}</p>
</div>
);
}}
/>
<Area
yAxisId="left"
type="monotone"
dataKey="tokens"
stroke="#0080FF"
strokeWidth={2}
fillOpacity={1}
fill="url(#tokenGradient)"
name="Tokens"
/>
<Area
yAxisId="right"
type="monotone"
dataKey="costRounded"
stroke="#00C49F"
strokeWidth={2}
fillOpacity={1}
fill="url(#costGradient)"
name="Cost"
/>
</AreaChart>
</ResponsiveContainer>
</div>
);
}
// 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();
}
+15 -2
View File
@@ -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',
+202
View File
@@ -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<UsageSummary>(`/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<DailyUsage[]>(`/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<ModelUsage[]>(`/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<PaginatedSessions>(`/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<MonthlyUsage[]>(`/usage/monthly?${params}`);
},
/** Clear server-side usage cache and force fresh data fetch */
refresh: async (): Promise<void> => {
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<T>(url: string): Promise<T> {
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;
}
+297
View File
@@ -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<DateRange | undefined>({
from: subDays(new Date(), 30),
to: new Date(),
});
const [viewMode, setViewMode] = useState<ViewMode>('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 <AnalyticsSkeleton />;
}
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Analytics</h1>
<p className="text-muted-foreground">Track your Claude Code usage and insights</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-2">
<Download className="w-4 h-4" />
Export
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
</div>
{/* Date Range Filter */}
<DateRangeFilter
value={dateRange}
onChange={setDateRange}
presets={[
{ label: 'Last 7 days', range: { from: subDays(new Date(), 7), to: new Date() } },
{ label: 'Last 30 days', range: { from: subDays(new Date(), 30), to: new Date() } },
{ label: 'This month', range: { from: startOfMonth(new Date()), to: new Date() } },
{ label: 'Last 3 months', range: { from: subDays(new Date(), 90), to: new Date() } },
]}
/>
{/* Summary Cards */}
<UsageSummaryCards data={summary} isLoading={isSummaryLoading} />
{/* Main Content Tabs */}
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as ViewMode)}>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="gap-2">
<TrendingUp className="w-4 h-4" />
Daily
</TabsTrigger>
<TabsTrigger value="monthly" className="gap-2">
<BarChart3 className="w-4 h-4" />
Monthly
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-2">
<Clock className="w-4 h-4" />
Sessions
</TabsTrigger>
</TabsList>
{/* Daily View */}
<TabsContent value="daily" className="space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Usage Trend Chart */}
<Card className="xl:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5" />
Usage Trends
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart data={trends || []} isLoading={isTrendsLoading} />
</CardContent>
</Card>
{/* Model Distribution */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
Model Usage
</CardTitle>
</CardHeader>
<CardContent>
<ModelBreakdownChart data={models || []} isLoading={isModelsLoading} />
</CardContent>
</Card>
{/* Cost Breakdown */}
<Card>
<CardHeader>
<CardTitle>Cost by Model</CardTitle>
</CardHeader>
<CardContent>
{isModelsLoading ? (
<Skeleton className="h-[200px]" />
) : (
<div className="space-y-3">
{models?.slice(0, 5).map((model) => (
<div key={model.model} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getModelColor(model.model) }}
/>
<span className="text-sm font-medium">{model.model}</span>
</div>
<span className="text-sm text-muted-foreground">
${model.cost.toFixed(4)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Monthly View */}
<TabsContent value="monthly">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Monthly Overview
</CardTitle>
</CardHeader>
<CardContent>
<UsageTrendChart
data={trends || []}
isLoading={isTrendsLoading}
granularity="monthly"
/>
</CardContent>
</Card>
</TabsContent>
{/* Sessions View */}
<TabsContent value="sessions">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Session History
</CardTitle>
</CardHeader>
<CardContent>
<SessionsTable data={sessions} isLoading={isSessionsLoading} />
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
// 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 (
<div className="p-6 space-y-6">
{/* Header */}
<div>
<Skeleton className="h-8 w-[120px] mb-2" />
<Skeleton className="h-4 w-[300px]" />
</div>
{/* Date Filter */}
<Skeleton className="h-10 w-[300px]" />
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardContent className="p-6">
<Skeleton className="h-4 w-[100px] mb-2" />
<Skeleton className="h-8 w-[60px]" />
</CardContent>
</Card>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<Card className="xl:col-span-2">
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[300px]" />
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-[120px]" />
</CardHeader>
<CardContent>
<Skeleton className="h-[200px]" />
</CardContent>
</Card>
</div>
</div>
);
}
+2
View File
@@ -11,3 +11,5 @@ export { SettingsPage } from './settings';
export { HealthPage } from './health';
export { SharedPage } from './shared';
export { AnalyticsPage } from './analytics';