diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index b6d2f74b..c99dc5dd 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -82,6 +82,18 @@ function toNonNegativeNumber(value: unknown): number { return numeric; } +function parseRequiredNonNegativeNumber(value: unknown): number | null { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return null; + } + return numeric; +} + +function isValidTimestamp(value: unknown): value is string { + return typeof value === 'string' && Number.isFinite(Date.parse(value)); +} + export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { // Strip UTF-8 BOM if present (can occur on first line of some files) const cleanLine = line.replace(/^\uFEFF/, '').trim(); @@ -97,16 +109,25 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr const usage = entry.message.usage; const assistant = entry as JsonlAssistantEntry; + const inputTokens = parseRequiredNonNegativeNumber(usage.input_tokens); + const outputTokens = parseRequiredNonNegativeNumber(usage.output_tokens); + if (inputTokens === null || outputTokens === null || !isValidTimestamp(assistant.timestamp)) { + return null; + } + const normalizedProjectPath = + typeof assistant.cwd === 'string' && assistant.cwd.trim().length > 0 + ? assistant.cwd + : projectPath; return { - inputTokens: toNonNegativeNumber(usage.input_tokens), - outputTokens: toNonNegativeNumber(usage.output_tokens), + inputTokens, + outputTokens, cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens), cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens), model: assistant.message.model, sessionId: assistant.sessionId || '', - timestamp: assistant.timestamp || new Date().toISOString(), - projectPath, + timestamp: assistant.timestamp, + projectPath: normalizedProjectPath, version: assistant.version, target: typeof (entry as { target?: unknown }).target === 'string' diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 93da148b..2c1e6766 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -828,9 +828,9 @@ function getDirectOrAliasPricing(model: string): ModelPricing | undefined { } /** - * Get pricing for a model with fuzzy matching fallback - * @param model - Model name (exact or with provider prefix) - * @returns ModelPricing for the model or fallback pricing + * Get pricing for a model with narrow fuzzy matching fallback. + * Unknown future model families should fall back instead of inheriting the + * first known family tier that happens to share a prefix. */ export function getModelPricing(model: string): ModelPricing { const directOrAliasPricing = getDirectOrAliasPricing(model); @@ -839,17 +839,9 @@ export function getModelPricing(model: string): ModelPricing { } for (const candidate of getLookupCandidates(model)) { - // Try suffix matching (e.g., "claude-sonnet-4-5" matches "*-claude-sonnet-4-5") + // Allow provider/routing wrappers to suffix a canonical model ID. for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { - if (candidate.endsWith(key) || key.endsWith(candidate)) { - return pricing; - } - } - - // Try partial matching for model families - for (const [key, pricing] of Object.entries(NORMALIZED_PRICING_REGISTRY)) { - // Match by model family prefix - if (candidate.startsWith(key.split('-').slice(0, 2).join('-'))) { + if (candidate.endsWith(key)) { return pricing; } } diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 8e9fa3c0..4f971696 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -19,6 +19,7 @@ import { } from './disk-cache'; import { ok, info, fail } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeConfigDir, getDefaultClaudeConfigDir } from '../../utils/claude-config-path'; import { loadCachedCliproxyData, startCliproxySync, @@ -35,6 +36,21 @@ function getCcsInstancesDir() { return path.join(getCcsDir(), 'instances'); } +function isPathWithinDir(childPath: string, parentPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function getDefaultProjectsDirForAnalytics(): string { + const activeClaudeConfigDir = getClaudeConfigDir(); + const instancesDir = getCcsInstancesDir(); + const claudeConfigDir = isPathWithinDir(activeClaudeConfigDir, instancesDir) + ? getDefaultClaudeConfigDir() + : activeClaudeConfigDir; + + return path.join(claudeConfigDir, 'projects'); +} + /** * Get list of CCS instance paths that have usage data * Only returns instances with existing projects/ directory @@ -149,8 +165,26 @@ export function mergeMonthlyData(sources: MonthlyUsage[][]): MonthlyUsage[] { existing.totalCost += month.totalCost; const modelSet = new Set([...existing.modelsUsed, ...month.modelsUsed]); existing.modelsUsed = Array.from(modelSet); + for (const breakdown of month.modelBreakdowns) { + const existingBreakdown = existing.modelBreakdowns.find( + (item) => item.modelName === breakdown.modelName + ); + if (existingBreakdown) { + existingBreakdown.inputTokens += breakdown.inputTokens; + existingBreakdown.outputTokens += breakdown.outputTokens; + existingBreakdown.cacheCreationTokens += breakdown.cacheCreationTokens; + existingBreakdown.cacheReadTokens += breakdown.cacheReadTokens; + existingBreakdown.cost += breakdown.cost; + } else { + existing.modelBreakdowns.push({ ...breakdown }); + } + } } else { - monthMap.set(month.month, { ...month, modelsUsed: [...month.modelsUsed] }); + monthMap.set(month.month, { + ...month, + modelsUsed: [...month.modelsUsed], + modelBreakdowns: month.modelBreakdowns.map((breakdown) => ({ ...breakdown })), + }); } } } @@ -174,6 +208,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { existing.cacheCreationTokens += hour.cacheCreationTokens; existing.cacheReadTokens += hour.cacheReadTokens; existing.totalCost += hour.totalCost; + existing.requestCount = (existing.requestCount ?? 0) + (hour.requestCount ?? 0); const modelSet = new Set([...existing.modelsUsed, ...hour.modelsUsed]); existing.modelsUsed = Array.from(modelSet); // Merge model breakdowns @@ -196,6 +231,7 @@ export function mergeHourlyData(sources: HourlyUsage[][]): HourlyUsage[] { ...hour, modelsUsed: [...hour.modelsUsed], modelBreakdowns: hour.modelBreakdowns.map((b) => ({ ...b })), + requestCount: hour.requestCount ?? 0, }); } } @@ -251,6 +287,10 @@ export function getLastFetchTimestamp(): number | null { return lastFetchTimestamp; } +export function getUsageCacheSize(): number { + return cache.size; +} + // In-memory cache const cache = new Map>(); @@ -300,8 +340,8 @@ async function refreshFromSource(): Promise<{ // Non-fatal: syncer handles unavailability and stale fallback. await syncCliproxyUsage(); - // Load default data (from ~/.claude/projects/ or CLAUDE_CONFIG_DIR) - const defaultData = await loadAllUsageData(); + // Load canonical default data and avoid counting the active instance twice + const defaultData = await loadAllUsageData({ projectsDir: getDefaultProjectsDirForAnalytics() }); // Load data from all CCS instances sequentially const instancePaths = getInstancePaths(); diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index f3249089..9dd3d5a1 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -34,7 +34,8 @@ interface CliproxyUsageSnapshot { type FetchCliproxyUsageRaw = typeof fetchCliproxyUsageRaw; -const SNAPSHOT_VERSION = 1; +const SNAPSHOT_VERSION = 2; +const SNAPSHOT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ const SYNC_INTERVAL_MS = Math.max( @@ -44,6 +45,7 @@ const SYNC_INTERVAL_MS = Math.max( // Module-level interval ID let syncIntervalId: ReturnType | null = null; +let snapshotTimestampOrdinal = 0; // --------------------------------------------------------------------------- // Cache directory helpers @@ -64,6 +66,44 @@ function ensureCliproxyCacheDir(): void { } } +function getSnapshotTimestamp(): number { + snapshotTimestampOrdinal = (snapshotTimestampOrdinal + 1) % 1000; + return Date.now() + snapshotTimestampOrdinal / 1000; +} + +function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { + try { + const snapshotPath = getLatestSnapshotPath(); + if (!fs.existsSync(snapshotPath)) { + return null; + } + + const raw = fs.readFileSync(snapshotPath, 'utf-8'); + const snapshot = JSON.parse(raw) as CliproxyUsageSnapshot; + + if (snapshot.version !== SNAPSHOT_VERSION) { + if (emitWarnings) { + console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); + } + return null; + } + + if (!Number.isFinite(snapshot.timestamp)) { + if (emitWarnings) { + console.log(info('CLIProxy snapshot timestamp invalid, will refresh on next sync')); + } + return null; + } + + return snapshot; + } catch (err) { + if (emitWarnings) { + console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + } + return null; + } +} + // --------------------------------------------------------------------------- // Load cached data // --------------------------------------------------------------------------- @@ -79,25 +119,17 @@ export async function loadCachedCliproxyData(): Promise<{ }> { const empty = { daily: [], hourly: [], monthly: [] }; - try { - const snapshotPath = getLatestSnapshotPath(); - if (!fs.existsSync(snapshotPath)) { - return empty; - } - - const raw = fs.readFileSync(snapshotPath, 'utf-8'); - const snapshot: CliproxyUsageSnapshot = JSON.parse(raw); - - if (snapshot.version !== SNAPSHOT_VERSION) { - console.log(info('CLIProxy snapshot version mismatch, will refresh on next sync')); - return empty; - } - - return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; - } catch (err) { - console.log(warn('Failed to read CLIProxy snapshot:') + ` ${(err as Error).message}`); + const snapshot = readSnapshot(); + if (!snapshot) { return empty; } + + const age = Date.now() - snapshot.timestamp; + if (age > SNAPSHOT_MAX_AGE_MS) { + console.log(info('Using stale CLIProxy snapshot while proxy sync is unavailable')); + } + + return { daily: snapshot.daily, hourly: snapshot.hourly, monthly: snapshot.monthly }; } // --------------------------------------------------------------------------- @@ -111,6 +143,7 @@ export async function loadCachedCliproxyData(): Promise<{ export async function syncCliproxyUsage( fetchRaw: FetchCliproxyUsageRaw = fetchCliproxyUsageRaw ): Promise { + const syncStartedAt = getSnapshotTimestamp(); const raw = await fetchRaw(); if (raw === null) { @@ -127,16 +160,28 @@ export async function syncCliproxyUsage( const snapshot: CliproxyUsageSnapshot = { version: SNAPSHOT_VERSION, - timestamp: Date.now(), + timestamp: syncStartedAt, daily, hourly, monthly, }; - // Atomic write: temp file + rename const snapshotPath = getLatestSnapshotPath(); - const tempFile = snapshotPath + '.tmp'; + const tempFile = `${snapshotPath}.${process.pid}.${syncStartedAt}.tmp`; + const currentSnapshot = readSnapshot(false); + if (currentSnapshot && currentSnapshot.timestamp > snapshot.timestamp) { + console.log(info('Skipping stale CLIProxy snapshot write')); + return; + } + fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + const latestSnapshot = readSnapshot(false); + if (latestSnapshot && latestSnapshot.timestamp > snapshot.timestamp) { + fs.rmSync(tempFile, { force: true }); + console.log(info('Skipping stale CLIProxy snapshot write')); + return; + } + fs.renameSync(tempFile, snapshotPath); console.log(ok('CLIProxy usage snapshot updated')); diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index aff50311..cdd4ecb7 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -40,9 +40,19 @@ function buildModelBreakdown(modelName: string, acc: ModelAccumulator): ModelBre // FLATTEN // ============================================================================ +function hasTrackedUsage(detail: CliproxyRequestDetail): boolean { + const tokens = detail.tokens; + return ( + (tokens?.input_tokens ?? 0) > 0 || + (tokens?.output_tokens ?? 0) > 0 || + (tokens?.cached_tokens ?? 0) > 0 + ); +} + /** * Flatten the nested response.usage.apis[provider].models[model].details[] - * structure into a flat array. Failed requests are skipped. + * structure into a flat array. Failed requests are retained only when they + * still report tracked token usage that analytics can account for. */ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): FlatDetail[] { const apis = response?.usage?.apis; @@ -56,7 +66,7 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat const details = modelData?.details; if (!details) continue; for (const detail of details) { - if (detail.failed) continue; + if (detail.failed && !hasTrackedUsage(detail)) continue; results.push({ model, detail }); } } @@ -72,15 +82,17 @@ export function flattenCliproxyDetails(response: CliproxyUsageApiResponse): Flat function aggregateByKey( flat: FlatDetail[], keyFn: (timestamp: string) => string, - buildRecord: (key: string, breakdowns: ModelBreakdown[]) => T, + buildRecord: (key: string, breakdowns: ModelBreakdown[], requestCount: number) => T, sortFn: (a: T, b: T) => number ): T[] { // bucket: timeKey -> modelName -> accumulator const buckets = new Map>(); + const requestCounts = new Map(); for (const { model, detail } of flat) { const key = keyFn(detail.timestamp); if (!buckets.has(key)) buckets.set(key, new Map()); + requestCounts.set(key, (requestCounts.get(key) ?? 0) + 1); const modelMap = buckets.get(key) as Map; if (!modelMap.has(model)) { modelMap.set(model, { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }); @@ -96,7 +108,7 @@ function aggregateByKey( const breakdowns = Array.from(modelMap.entries()).map(([name, acc]) => buildModelBreakdown(name, acc) ); - records.push(buildRecord(key, breakdowns)); + records.push(buildRecord(key, breakdowns, requestCounts.get(key) ?? 0)); }); return records.sort(sortFn); @@ -146,7 +158,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons const hour = ts.slice(11, 13) || '00'; return `${date} ${hour}:00`; }, - (hour, breakdowns) => { + (hour, breakdowns, requestCount) => { const totalCost = sumField(breakdowns, 'cost'); return { hour, @@ -159,6 +171,7 @@ export function transformCliproxyToHourlyUsage(response: CliproxyUsageApiRespons totalCost, modelsUsed: breakdowns.map((b) => b.modelName), modelBreakdowns: breakdowns, + requestCount, }; }, (a, b) => b.hour.localeCompare(a.hour) diff --git a/src/web-server/usage/data-aggregator.ts b/src/web-server/usage/data-aggregator.ts index b0f4fb5f..ba664576 100644 --- a/src/web-server/usage/data-aggregator.ts +++ b/src/web-server/usage/data-aggregator.ts @@ -244,6 +244,7 @@ export function aggregateHourlyUsage( totalCost, modelsUsed: Array.from(modelMap.keys()), modelBreakdowns, + requestCount: hourEntries.length, }); } diff --git a/src/web-server/usage/handlers.ts b/src/web-server/usage/handlers.ts index e9e34668..489a1825 100644 --- a/src/web-server/usage/handlers.ts +++ b/src/web-server/usage/handlers.ts @@ -13,6 +13,7 @@ import { getCachedMonthlyData, getCachedSessionData, getCachedHourlyData, + getUsageCacheSize, getLastFetchTimestamp, refreshUsageCache, } from './aggregator'; @@ -87,6 +88,13 @@ export function validateOffset(offset?: string): number { return num; } +export function validateDateRangeOrder(since?: string, until?: string): void { + if (!since || !until) return; + if (since > until) { + throw new Error('The "since" date must be earlier than or equal to "until"'); + } +} + export function filterByDateRange< T extends { date?: string; month?: string; lastActivity?: string }, >(data: T[] | undefined, since?: string, until?: string): T[] { @@ -120,6 +128,66 @@ export function errorResponse(res: Response, error: unknown, defaultMessage: str }); } +function roundToCurrency(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + +function calculateUsageTotalTokens( + input: number, + output: number, + cacheCreation: number, + cacheRead: number +): number { + return input + output + cacheCreation + cacheRead; +} + +function parseDateKey(dateString: string): Date { + return new Date( + Date.UTC( + Number(dateString.slice(0, 4)), + Number(dateString.slice(4, 6)) - 1, + Number(dateString.slice(6, 8)) + ) + ); +} + +function getInclusiveDayCount(start: Date, end: Date): number { + const dayMs = 24 * 60 * 60 * 1000; + return Math.floor((end.getTime() - start.getTime()) / dayMs) + 1; +} + +function countCalendarDays(data: DailyUsage[], since?: string, until?: string): number { + const sortedDates = [...data] + .map((item) => item.date.replace(/-/g, '')) + .sort((a, b) => a.localeCompare(b)); + const earliestDate = sortedDates[0]; + const latestDate = sortedDates[sortedDates.length - 1]; + + if (since && until) { + return getInclusiveDayCount(parseDateKey(since), parseDateKey(until)); + } + + if (since) { + const end = until + ? parseDateKey(until) + : latestDate + ? parseDateKey(latestDate) + : parseDateKey(since); + return getInclusiveDayCount(parseDateKey(since), end); + } + + if (until) { + const start = earliestDate ? parseDateKey(earliestDate) : parseDateKey(until); + return getInclusiveDayCount(start, parseDateKey(until)); + } + + if (data.length === 0) { + return 0; + } + + return getInclusiveDayCount(parseDateKey(earliestDate), parseDateKey(latestDate)); +} + // ============================================================================ // Cost Calculation Helpers // ============================================================================ @@ -150,10 +218,10 @@ export function calculateTokenBreakdownCosts(dailyData: DailyUsage[]): TokenBrea } return { - input: { tokens: inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: outputTokens, cost: Math.round(outputCost * 100) / 100 }, - cacheCreation: { tokens: cacheCreationTokens, cost: Math.round(cacheCreationCost * 100) / 100 }, - cacheRead: { tokens: cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + input: { tokens: inputTokens, cost: roundToCurrency(inputCost) }, + output: { tokens: outputTokens, cost: roundToCurrency(outputCost) }, + cacheCreation: { tokens: cacheCreationTokens, cost: roundToCurrency(cacheCreationCost) }, + cacheRead: { tokens: cacheReadTokens, cost: roundToCurrency(cacheReadCost) }, }; } @@ -334,6 +402,7 @@ export async function handleSummary( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); @@ -351,8 +420,15 @@ export async function handleSummary( totalCost += day.totalCost; } - const totalTokens = totalInputTokens + totalOutputTokens; + const totalTokens = calculateUsageTotalTokens( + totalInputTokens, + totalOutputTokens, + totalCacheCreationTokens, + totalCacheReadTokens + ); const tokenBreakdown = calculateTokenBreakdownCosts(filtered); + const totalDays = countCalendarDays(filtered, since, until); + const activeDays = filtered.length; res.json({ success: true, @@ -363,12 +439,14 @@ export async function handleSummary( totalCacheTokens: totalCacheCreationTokens + totalCacheReadTokens, totalCacheCreationTokens, totalCacheReadTokens, - totalCost: Math.round(totalCost * 100) / 100, + totalCost: roundToCurrency(totalCost), tokenBreakdown, - 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, + totalDays, + activeDays, + averageTokensPerDay: totalDays > 0 ? Math.round(totalTokens / totalDays) : 0, + averageTokensPerActiveDay: activeDays > 0 ? Math.round(totalTokens / activeDays) : 0, + averageCostPerDay: totalDays > 0 ? roundToCurrency(totalCost / totalDays) : 0, + averageCostPerActiveDay: activeDays > 0 ? roundToCurrency(totalCost / activeDays) : 0, }, }); } catch (error) { @@ -383,16 +461,22 @@ export async function handleDaily( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); const trends = filtered.map((day) => ({ date: day.date, - tokens: day.inputTokens + day.outputTokens, + tokens: calculateUsageTotalTokens( + day.inputTokens, + day.outputTokens, + day.cacheCreationTokens, + day.cacheReadTokens + ), inputTokens: day.inputTokens, outputTokens: day.outputTokens, cacheTokens: day.cacheCreationTokens + day.cacheReadTokens, - cost: Math.round(day.totalCost * 100) / 100, + cost: roundToCurrency(day.totalCost), modelsUsed: day.modelsUsed.length, })); @@ -409,6 +493,7 @@ export async function handleHourly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const hourlyData = await getCachedHourlyData(); const filtered = (hourlyData || []).filter((h) => { @@ -420,13 +505,18 @@ export async function handleHourly( const trends = filtered.map((hour) => ({ hour: hour.hour, - tokens: hour.inputTokens + hour.outputTokens, + tokens: calculateUsageTotalTokens( + hour.inputTokens, + hour.outputTokens, + hour.cacheCreationTokens, + hour.cacheReadTokens + ), inputTokens: hour.inputTokens, outputTokens: hour.outputTokens, cacheTokens: hour.cacheCreationTokens + hour.cacheReadTokens, - cost: Math.round(hour.totalCost * 100) / 100, + cost: roundToCurrency(hour.totalCost), modelsUsed: hour.modelsUsed.length, - requests: hour.modelBreakdowns.length, + requests: hour.requestCount ?? hour.modelBreakdowns.length, })); const filledTrends = fillHourlyGaps(trends, since, until); @@ -443,6 +533,7 @@ export async function handleModels( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); @@ -478,7 +569,17 @@ export async function handleModels( } const models = Array.from(modelMap.values()); - const totalTokens = models.reduce((sum, m) => sum + m.inputTokens + m.outputTokens, 0); + const totalTokens = models.reduce( + (sum, model) => + sum + + calculateUsageTotalTokens( + model.inputTokens, + model.outputTokens, + model.cacheCreationTokens, + model.cacheReadTokens + ), + 0 + ); const result = models .map((m) => { @@ -489,28 +590,32 @@ export async function handleModels( (m.cacheCreationTokens / 1_000_000) * pricing.cacheCreationPerMillion; const cacheReadCost = (m.cacheReadTokens / 1_000_000) * pricing.cacheReadPerMillion; const ioRatio = m.outputTokens > 0 ? m.inputTokens / m.outputTokens : 0; + const totalModelTokens = calculateUsageTotalTokens( + m.inputTokens, + m.outputTokens, + m.cacheCreationTokens, + m.cacheReadTokens + ); return { model: m.model, - tokens: m.inputTokens + m.outputTokens, + tokens: totalModelTokens, inputTokens: m.inputTokens, outputTokens: m.outputTokens, cacheCreationTokens: m.cacheCreationTokens, cacheReadTokens: m.cacheReadTokens, cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.cost * 100) / 100, + cost: roundToCurrency(m.cost), percentage: - totalTokens > 0 - ? Math.round(((m.inputTokens + m.outputTokens) / totalTokens) * 1000) / 10 - : 0, + totalTokens > 0 ? Math.round((totalModelTokens / totalTokens) * 1000) / 10 : 0, costBreakdown: { - input: { tokens: m.inputTokens, cost: Math.round(inputCost * 100) / 100 }, - output: { tokens: m.outputTokens, cost: Math.round(outputCost * 100) / 100 }, + input: { tokens: m.inputTokens, cost: roundToCurrency(inputCost) }, + output: { tokens: m.outputTokens, cost: roundToCurrency(outputCost) }, cacheCreation: { tokens: m.cacheCreationTokens, - cost: Math.round(cacheCreationCost * 100) / 100, + cost: roundToCurrency(cacheCreationCost), }, - cacheRead: { tokens: m.cacheReadTokens, cost: Math.round(cacheReadCost * 100) / 100 }, + cacheRead: { tokens: m.cacheReadTokens, cost: roundToCurrency(cacheReadCost) }, }, ioRatio: Math.round(ioRatio * 10) / 10, }; @@ -530,6 +635,7 @@ export async function handleSessions( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const limit = validateLimit(req.query.limit); const offset = validateOffset(req.query.offset); @@ -543,10 +649,15 @@ export async function handleSessions( const sessions = paginated.map((s) => ({ sessionId: s.sessionId, projectPath: s.projectPath, - tokens: s.inputTokens + s.outputTokens, + tokens: calculateUsageTotalTokens( + s.inputTokens, + s.outputTokens, + s.cacheCreationTokens, + s.cacheReadTokens + ), inputTokens: s.inputTokens, outputTokens: s.outputTokens, - cost: Math.round(s.totalCost * 100) / 100, + cost: roundToCurrency(s.totalCost), lastActivity: s.lastActivity, modelsUsed: s.modelsUsed, target: s.target || 'claude', @@ -574,25 +685,83 @@ export async function handleMonthly( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); - const monthlyData = await getCachedMonthlyData(); + validateDateRangeOrder(since, until); + let filtered: Array<{ + month: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: string[]; + }>; - 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; + if (since || until) { + const dailyData = filterByDateRange(await getCachedDailyData(), since, until); + const monthMap = new Map< + string, + { + month: string; + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalCost: number; + modelsUsed: Set; + } + >(); + + for (const day of dailyData) { + const month = day.date.slice(0, 7); + const existing = monthMap.get(month) ?? { + month, + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 0, + modelsUsed: new Set(), + }; + + existing.inputTokens += day.inputTokens; + existing.outputTokens += day.outputTokens; + existing.cacheCreationTokens += day.cacheCreationTokens; + existing.cacheReadTokens += day.cacheReadTokens; + existing.totalCost += day.totalCost; + for (const model of day.modelsUsed) { + existing.modelsUsed.add(model); + } + + monthMap.set(month, existing); + } + + filtered = Array.from(monthMap.values()) + .map((month) => ({ + month: month.month, + inputTokens: month.inputTokens, + outputTokens: month.outputTokens, + cacheCreationTokens: month.cacheCreationTokens, + cacheReadTokens: month.cacheReadTokens, + totalCost: month.totalCost, + modelsUsed: Array.from(month.modelsUsed), + })) + .sort((a, b) => a.month.localeCompare(b.month)); + } else { + filtered = await getCachedMonthlyData(); + } const result = filtered.map((m) => ({ month: m.month, - tokens: m.inputTokens + m.outputTokens, + tokens: calculateUsageTotalTokens( + m.inputTokens, + m.outputTokens, + m.cacheCreationTokens, + m.cacheReadTokens + ), inputTokens: m.inputTokens, outputTokens: m.outputTokens, cacheTokens: m.cacheCreationTokens + m.cacheReadTokens, - cost: Math.round(m.totalCost * 100) / 100, + cost: roundToCurrency(m.totalCost), modelsUsed: m.modelsUsed.length, })); @@ -612,10 +781,9 @@ export async function handleRefresh(_req: Request, res: Response): Promise } export function handleStatus(_req: Request, res: Response): void { - const cache = new Map(); // Note: this is a placeholder, actual cache is in aggregator res.json({ success: true, - data: { lastFetch: getLastFetchTimestamp(), cacheSize: cache.size }, + data: { lastFetch: getLastFetchTimestamp(), cacheSize: getUsageCacheSize() }, }); } @@ -626,6 +794,7 @@ export async function handleInsights( try { const since = validateDate(req.query.since); const until = validateDate(req.query.until); + validateDateRangeOrder(since, until); const dailyData = await getCachedDailyData(); const filtered = filterByDateRange(dailyData, since, until); const anomalies = detectAnomalies(filtered); diff --git a/src/web-server/usage/types.ts b/src/web-server/usage/types.ts index 1866e783..9348479d 100644 --- a/src/web-server/usage/types.ts +++ b/src/web-server/usage/types.ts @@ -49,6 +49,11 @@ export interface HourlyUsage { totalCost: number; modelsUsed: string[]; modelBreakdowns: ModelBreakdown[]; + /** + * Raw request count for this hour bucket. + * Optional for backward compatibility with previously persisted snapshots. + */ + requestCount?: number; } /** Monthly usage aggregation (YYYY-MM) */ diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index f4f9e402..6980eba1 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -146,6 +146,16 @@ describe('parseUsageEntry', () => { test('includes project path in result', () => { const result = parseUsageEntry(VALID_ASSISTANT_ENTRY, '/custom/project/path'); + expect(result!.projectPath).toBe('/home/user/project'); + }); + + test('falls back to the decoded project path when cwd is missing', () => { + const withoutCwd = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + cwd: undefined, + }); + + const result = parseUsageEntry(withoutCwd, '/custom/project/path'); expect(result!.projectPath).toBe('/custom/project/path'); }); @@ -169,7 +179,7 @@ describe('parseUsageEntry', () => { expect(result!.target).toBeUndefined(); }); - test('coerces token fields to non-negative numbers', () => { + test('returns null when required token fields are invalid', () => { const withInvalidUsage = JSON.stringify({ ...JSON.parse(VALID_ASSISTANT_ENTRY), message: { @@ -184,11 +194,16 @@ describe('parseUsageEntry', () => { }); const result = parseUsageEntry(withInvalidUsage, '/test'); - expect(result).not.toBeNull(); - expect(result!.inputTokens).toBe(1500); - expect(result!.outputTokens).toBe(0); - expect(result!.cacheCreationTokens).toBe(0); - expect(result!.cacheReadTokens).toBe(0); + expect(result).toBeNull(); + }); + + test('returns null when timestamp is missing', () => { + const withoutTimestamp = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + timestamp: undefined, + }); + + expect(parseUsageEntry(withoutTimestamp, '/test')).toBeNull(); }); }); @@ -314,8 +329,12 @@ describe('parseProjectDirectory', () => { test('sanitizes derived projectPath from dashed directory names', async () => { const projectDir = path.join(tempDir, '-..-etc-passwd'); + const withoutCwd = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + cwd: undefined, + }); fs.mkdirSync(projectDir); - fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), withoutCwd); const entries = await parseProjectDirectory(projectDir); diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 2770439f..d74bdd56 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -160,6 +160,13 @@ describe('model-pricing', () => { expect(opus47dated.inputPerMillion).toBe(5.0); expect(opus47dated.outputPerMillion).toBe(25.0); }); + + it('should not map unknown future model families onto known family pricing', () => { + const fallback = getModelPricing('unknown-model-xyz'); + + expect(getModelPricing('claude-opus-5-20270101')).toEqual(fallback); + expect(getModelPricing('gemini-3.2-pro')).toEqual(fallback); + }); }); describe('calculateCost', () => { diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 699bcd25..e2077bdb 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -20,6 +20,48 @@ function fetchRawResponse(): Promise { return Promise.resolve(rawResponse); } +function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse { + return { + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp, + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: inputTokens, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 10, + total_tokens: inputTokens + 30, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }; +} + +function createDeferredFetch(response: CliproxyUsageApiResponse | null) { + let resolveFetch: ((value: CliproxyUsageApiResponse | null) => void) | undefined; + return { + fetch: () => + new Promise((resolve) => { + fetchCalls++; + resolveFetch = resolve; + }), + resolve: () => resolveFetch?.(response), + }; +} + beforeEach(() => { ccsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-syncer-')); fetchCalls = 0; @@ -92,4 +134,43 @@ describe('cliproxy usage syncer', () => { stopCliproxySync(); intervalSpy.mockRestore(); }); + + it('keeps stale cached snapshots available for historical analytics reads', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + await syncCliproxyUsage(fetchRawResponse); + + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + timestamp: number; + }; + + snapshot.timestamp = Date.now() - 8 * 24 * 60 * 60 * 1000; + fs.writeFileSync(snapshotPath, JSON.stringify(snapshot), 'utf-8'); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.hourly).toHaveLength(1); + expect(cached.monthly).toHaveLength(1); + }); + }); + + it('keeps the newer overlapping snapshot when an older sync finishes last', async () => { + const olderSync = createDeferredFetch(buildResponse(100, '2026-03-02T10:00:00.000Z')); + const newerSync = createDeferredFetch(buildResponse(200, '2026-03-02T11:00:00.000Z')); + + await runWithScopedConfigDir(ccsDir, async () => { + const olderWrite = syncCliproxyUsage(olderSync.fetch); + const newerWrite = syncCliproxyUsage(newerSync.fetch); + + newerSync.resolve(); + await Promise.resolve(); + olderSync.resolve(); + + await Promise.all([olderWrite, newerWrite]); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.daily[0].inputTokens).toBe(200); + }); + }); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index ac2e6eec..cf5eecbf 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -40,6 +40,19 @@ const sampleResponse: CliproxyUsageApiResponse = { }, failed: true, }, + { + timestamp: '2026-03-01T12:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0, + }, + failed: true, + }, { timestamp: '2026-03-01T10:45:00.000Z', source: 'account-a', @@ -83,10 +96,25 @@ const sampleResponse: CliproxyUsageApiResponse = { }; describe('cliproxy usage transformer', () => { - it('flattens nested API details and skips failed requests', () => { + it('retains failed requests when they carry usage and skips zero-usage failures', () => { const flat = flattenCliproxyDetails(sampleResponse); - expect(flat).toHaveLength(3); - expect(flat.every((entry) => entry.detail.failed === false)).toBe(true); + expect(flat).toHaveLength(4); + expect( + flat.some( + (entry) => + entry.detail.failed === true && + entry.detail.tokens.input_tokens === 40 && + entry.detail.tokens.output_tokens === 10 + ) + ).toBe(true); + expect( + flat.some( + (entry) => + entry.detail.failed === true && + entry.detail.tokens.input_tokens === 0 && + entry.detail.tokens.output_tokens === 0 + ) + ).toBe(false); }); it('transforms daily usage with aggregated model totals', () => { @@ -98,21 +126,25 @@ describe('cliproxy usage transformer', () => { expect(daily[1].date).toBe('2026-03-01'); const marchFirst = daily.find((d) => d.date === '2026-03-01'); - expect(marchFirst?.inputTokens).toBe(130); - expect(marchFirst?.outputTokens).toBe(70); - expect(marchFirst?.cacheReadTokens).toBe(30); + expect(marchFirst?.inputTokens).toBe(170); + expect(marchFirst?.outputTokens).toBe(80); + expect(marchFirst?.cacheReadTokens).toBe(35); expect(marchFirst?.modelsUsed).toContain('gemini-2.5-pro'); }); it('transforms hourly usage with hour buckets', () => { const hourly = transformCliproxyToHourlyUsage(sampleResponse); - expect(hourly).toHaveLength(2); + expect(hourly).toHaveLength(3); expect(hourly[0].hour).toBe('2026-03-02 01:00'); const tenAm = hourly.find((h) => h.hour === '2026-03-01 10:00'); expect(tenAm?.inputTokens).toBe(130); expect(tenAm?.outputTokens).toBe(70); + + const elevenAm = hourly.find((h) => h.hour === '2026-03-01 11:00'); + expect(elevenAm?.inputTokens).toBe(40); + expect(elevenAm?.outputTokens).toBe(10); }); it('transforms monthly usage with cliproxy source', () => { @@ -121,8 +153,8 @@ describe('cliproxy usage transformer', () => { expect(monthly).toHaveLength(1); expect(monthly[0].month).toBe('2026-03'); expect(monthly[0].source).toBe('cliproxy'); - expect(monthly[0].inputTokens).toBe(200); - expect(monthly[0].outputTokens).toBe(100); - expect(monthly[0].cacheReadTokens).toBe(30); + expect(monthly[0].inputTokens).toBe(240); + expect(monthly[0].outputTokens).toBe(110); + expect(monthly[0].cacheReadTokens).toBe(35); }); }); diff --git a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts index 3b5cd6cf..1e887ee1 100644 --- a/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts +++ b/tests/unit/web-server/usage-aggregator-cliproxy-integration.test.ts @@ -9,6 +9,7 @@ let claudeDir = ''; let aggregator: typeof import('../../../src/web-server/usage/aggregator'); let originalCcsDir: string | undefined; let originalClaudeConfigDir: string | undefined; +let originalCcsHome: string | undefined; function writeClaudeJsonlFixture(): void { const projectDir = path.join(claudeDir, 'projects', 'project-one'); @@ -37,7 +38,7 @@ function writeCliproxySnapshotFixture(): void { fs.mkdirSync(snapshotDir, { recursive: true }); const snapshot = { - version: 1, + version: 2, timestamp: Date.now(), daily: [ { @@ -143,7 +144,9 @@ beforeEach(async () => { originalCcsDir = process.env.CCS_DIR; originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalCcsHome = process.env.CCS_HOME; process.env.CCS_DIR = ccsDir; + process.env.CCS_HOME = tempRoot; process.env.CLAUDE_CONFIG_DIR = claudeDir; writeUnifiedConfigFixture(); @@ -170,6 +173,12 @@ afterEach(() => { delete process.env.CLAUDE_CONFIG_DIR; } + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); }); @@ -193,4 +202,108 @@ describe('usage aggregator cliproxy integration', () => { aggregator.clearUsageCache(); expect(aggregator.getLastFetchTimestamp()).toBeNull(); }); + + it('avoids double counting when CLAUDE_CONFIG_DIR points at a CCS instance', async () => { + const instancePath = path.join(ccsDir, 'instances', 'work-profile'); + const instanceProjectDir = path.join(instancePath, 'projects', 'profile-one'); + fs.mkdirSync(instanceProjectDir, { recursive: true }); + + const globalLine = JSON.stringify({ + type: 'assistant', + sessionId: 'session-global', + timestamp: '2026-03-02T10:00:00.000Z', + cwd: '/tmp/global', + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: 10, + output_tokens: 1, + }, + }, + }); + fs.writeFileSync(path.join(claudeDir, 'projects', 'project-one', 'global.jsonl'), `${globalLine}\n`); + + const instanceLine = JSON.stringify({ + type: 'assistant', + sessionId: 'session-instance', + timestamp: '2026-03-02T11:00:00.000Z', + cwd: '/tmp/instance', + message: { + model: 'gemini-2.5-pro', + usage: { + input_tokens: 100, + output_tokens: 10, + }, + }, + }); + fs.writeFileSync(path.join(instanceProjectDir, 'usage.jsonl'), `${instanceLine}\n`); + + process.env.CLAUDE_CONFIG_DIR = instancePath; + aggregator.clearUsageCache(); + + const daily = await aggregator.getCachedDailyData(); + + expect(daily).toHaveLength(1); + expect(daily[0].inputTokens).toBe(260); + expect(daily[0].outputTokens).toBe(61); + expect(daily[0].modelsUsed).toContain('claude-sonnet-4-5'); + expect(daily[0].modelsUsed).toContain('gemini-2.5-pro'); + }); + + it('merges monthly model breakdowns when sources collide', () => { + const result = aggregator.mergeMonthlyData([ + [ + { + month: '2026-03', + source: 'default', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + totalCost: 1, + modelsUsed: ['claude-sonnet-4-5'], + modelBreakdowns: [ + { + modelName: 'claude-sonnet-4-5', + inputTokens: 10, + outputTokens: 1, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 1, + }, + ], + }, + ], + [ + { + month: '2026-03', + source: 'instance', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 5, + cacheReadTokens: 3, + totalCost: 2, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [ + { + modelName: 'gemini-2.5-pro', + inputTokens: 20, + outputTokens: 2, + cacheCreationTokens: 5, + cacheReadTokens: 3, + cost: 2, + }, + ], + }, + ], + ]); + + expect(result).toHaveLength(1); + expect(result[0].inputTokens).toBe(30); + expect(result[0].outputTokens).toBe(3); + expect(result[0].cacheCreationTokens).toBe(5); + expect(result[0].cacheReadTokens).toBe(3); + expect(result[0].totalCost).toBe(3); + expect(result[0].modelBreakdowns).toHaveLength(2); + }); }); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts new file mode 100644 index 00000000..2e1b9c70 --- /dev/null +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +type HandlersModule = typeof import('../../../src/web-server/usage/handlers'); +type AggregatorModule = typeof import('../../../src/web-server/usage/aggregator'); + +interface AssistantFixture { + project: string; + sessionId: string; + timestamp: string; + model: string; + inputTokens?: number; + outputTokens?: number; + cacheCreationTokens?: number; + cacheReadTokens?: number; +} + +interface MockResponse { + payload: unknown; + statusCode: number; + status: (code: number) => MockResponse; + json: (body: unknown) => MockResponse; +} + +let tempHome = ''; +let claudeDir = ''; +let handlers: HandlersModule; +let aggregator: AggregatorModule; +let originalCcsHome: string | undefined; +let originalClaudeConfigDir: string | undefined; + +function writeUnifiedConfigFixture(): void { + const yaml = `version: 2 +accounts: {} +profiles: {} +preferences: + theme: system + telemetry: false + auto_update: true +cliproxy: + oauth_accounts: {} + providers: + - gemini + - codex + - agy + variants: {} +cliproxy_server: + local: + port: 65534 +`; + + fs.mkdirSync(path.join(tempHome, '.ccs'), { recursive: true }); + fs.writeFileSync(path.join(tempHome, '.ccs', 'config.yaml'), yaml, 'utf-8'); +} + +function writeAssistantEntries(entries: AssistantFixture[]): void { + for (const entry of entries) { + const projectDir = path.join(claudeDir, 'projects', entry.project); + fs.mkdirSync(projectDir, { recursive: true }); + + const line = JSON.stringify({ + type: 'assistant', + sessionId: entry.sessionId, + timestamp: entry.timestamp, + version: '1.0.0', + cwd: `/tmp/${entry.project}`, + message: { + model: entry.model, + usage: { + input_tokens: entry.inputTokens ?? 0, + output_tokens: entry.outputTokens ?? 0, + cache_creation_input_tokens: entry.cacheCreationTokens ?? 0, + cache_read_input_tokens: entry.cacheReadTokens ?? 0, + }, + }, + }); + + fs.writeFileSync( + path.join(projectDir, `${entry.sessionId}.jsonl`), + `${line}\n`, + 'utf-8' + ); + } +} + +function createMockResponse(): MockResponse { + return { + payload: undefined, + statusCode: 200, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.payload = body; + return this; + }, + }; +} + +beforeEach(async () => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-usage-handlers-')); + claudeDir = path.join(tempHome, '.claude'); + + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CCS_HOME = tempHome; + process.env.CLAUDE_CONFIG_DIR = claudeDir; + + writeUnifiedConfigFixture(); + + handlers = await import('../../../src/web-server/usage/handlers'); + aggregator = await import('../../../src/web-server/usage/aggregator'); + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); +}); + +afterEach(() => { + aggregator.shutdownUsageAggregator(); + aggregator.clearUsageCache(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + + fs.rmSync(tempHome, { recursive: true, force: true }); +}); + +describe('usage handlers semantics', () => { + it('includes cache tokens in summary totals and uses calendar-day averages', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 1_000_000, + outputTokens: 100_000, + cacheCreationTokens: 100_000, + cacheReadTokens: 200_000, + }, + ]); + + const res = createMockResponse(); + await handlers.handleSummary( + { query: { since: '20260301', until: '20260303' } } as never, + res as never + ); + + expect(res.statusCode).toBe(200); + expect(res.payload).toMatchObject({ + success: true, + data: { + totalTokens: 1_400_000, + totalCacheTokens: 300_000, + totalDays: 3, + activeDays: 1, + averageTokensPerDay: 466_667, + averageTokensPerActiveDay: 1_400_000, + averageCostPerDay: 1.65, + averageCostPerActiveDay: 4.93, + }, + }); + }); + + it('counts hourly requests from raw entries instead of distinct models', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:05:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + { + project: 'project-two', + sessionId: 'session-b', + timestamp: '2026-03-02T10:15:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 120, + outputTokens: 15, + }, + { + project: 'project-three', + sessionId: 'session-c', + timestamp: '2026-03-02T10:30:00.000Z', + model: 'gemini-2.5-pro', + inputTokens: 80, + outputTokens: 20, + }, + ]); + + const res = createMockResponse(); + await handlers.handleHourly( + { query: { since: '20260302', until: '20260302' } } as never, + res as never + ); + + const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> }; + const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00'); + + expect(targetHour?.requests).toBe(3); + }); + + it('uses overlapping months for monthly filtering', async () => { + writeAssistantEntries([ + { + project: 'march-project', + sessionId: 'session-march', + timestamp: '2026-03-20T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + { + project: 'april-project', + sessionId: 'session-april', + timestamp: '2026-04-05T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 200, + outputTokens: 20, + }, + ]); + + const res = createMockResponse(); + await handlers.handleMonthly( + { query: { since: '20260315', until: '20260410' } } as never, + res as never + ); + + expect(res.payload).toMatchObject({ + success: true, + data: [ + expect.objectContaining({ month: '2026-03' }), + expect.objectContaining({ month: '2026-04' }), + ], + }); + }); + + it('reports actual cache size after warming the usage cache', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 10, + }, + ]); + + await aggregator.getCachedDailyData(); + + const res = createMockResponse(); + handlers.handleStatus({} as never, res as never); + + const payload = res.payload as { + success: boolean; + data: { lastFetch: number | null; cacheSize: unknown }; + }; + expect(payload.success).toBe(true); + expect(payload.data.lastFetch).not.toBeNull(); + expect(payload.data.cacheSize).toBe(aggregator.getUsageCacheSize()); + expect(aggregator.getUsageCacheSize()).toBeGreaterThan(0); + }); + + it('includes cache-only model activity in model token percentages', async () => { + writeAssistantEntries([ + { + project: 'project-one', + sessionId: 'session-a', + timestamp: '2026-03-02T10:00:00.000Z', + model: 'claude-sonnet-4-5', + inputTokens: 100, + outputTokens: 0, + }, + { + project: 'project-two', + sessionId: 'session-b', + timestamp: '2026-03-02T11:00:00.000Z', + model: 'gemini-2.5-pro', + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 100, + }, + ]); + + const res = createMockResponse(); + await handlers.handleModels( + { query: { since: '20260302', until: '20260302' } } as never, + res as never + ); + + expect(res.payload).toMatchObject({ + success: true, + data: expect.arrayContaining([ + expect.objectContaining({ model: 'claude-sonnet-4-5', tokens: 100, percentage: 50 }), + expect.objectContaining({ model: 'gemini-2.5-pro', tokens: 100, percentage: 50 }), + ]), + }); + }); + + it('rejects reversed date ranges before computing summary totals', async () => { + const res = createMockResponse(); + + await handlers.handleSummary( + { query: { since: '20260410', until: '20260401' } } as never, + res as never + ); + + expect(res.statusCode).toBe(400); + expect(res.payload).toMatchObject({ + success: false, + error: 'The "since" date must be earlier than or equal to "until"', + }); + }); +}); diff --git a/ui/src/components/analytics/model-details-content.tsx b/ui/src/components/analytics/model-details-content.tsx index b360b3da..ede1773b 100644 --- a/ui/src/components/analytics/model-details-content.tsx +++ b/ui/src/components/analytics/model-details-content.tsx @@ -13,6 +13,8 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) { const { privacyMode } = usePrivacy(); const { t } = useTranslation(); const ioRatioStatus = getIoRatioStatus(model.ioRatio); + const cacheTokens = model.cacheCreationTokens + model.cacheReadTokens; + const totalTokensLabel = cacheTokens > 0 ? 'All Tokens' : t('analyticsCards.totalTokens'); return (
@@ -49,7 +51,7 @@ export function ModelDetailsContent({ model }: ModelDetailsContentProps) { {formatCompactNumber(model.tokens)}

- {t('analyticsCards.totalTokens')} + {totalTokensLabel}

diff --git a/ui/src/components/analytics/session-stats-card.tsx b/ui/src/components/analytics/session-stats-card.tsx index dbe6ec21..7f53eba0 100644 --- a/ui/src/components/analytics/session-stats-card.tsx +++ b/ui/src/components/analytics/session-stats-card.tsx @@ -31,26 +31,16 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar const sessions = data.sessions; const totalSessions = data.total; - - // Calculate average tokens per session - const totalTokens = sessions.reduce((sum, s) => sum + (s.inputTokens + s.outputTokens), 0); - const avgTokens = Math.round(totalTokens / sessions.length); + const hasPartialSample = data.hasMore || data.offset > 0; // Calculate total cost for visible sessions const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0); const avgCost = totalCost / sessions.length; - // Most recent session - const lastSession = sessions[0]; - const lastActive = lastSession - ? formatDistanceToNow(new Date(lastSession.lastActivity), { addSuffix: true }) - : 'N/A'; - return { totalSessions, - avgTokens, avgCost, - lastActive, + hasPartialSample, recentSessions: sessions.slice(0, 3), }; }, [data]); @@ -122,8 +112,7 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar

- {/* TODO i18n: missing key for "Avg Cost/Session" */} - Avg Cost/Session + {stats.hasPartialSample ? 'Recent Avg Cost' : 'Avg Cost/Session'}

@@ -159,7 +148,8 @@ export function SessionStatsCard({ data, isLoading, className }: SessionStatsCar
${session.cost.toFixed(2)}
- {formatCompact(session.inputTokens + session.outputTokens)} toks + {formatCompact(session.tokens ?? session.inputTokens + session.outputTokens)}{' '} + toks
diff --git a/ui/src/components/analytics/usage-summary-cards.tsx b/ui/src/components/analytics/usage-summary-cards.tsx index 0ca03eee..98be74e0 100644 --- a/ui/src/components/analytics/usage-summary-cards.tsx +++ b/ui/src/components/analytics/usage-summary-cards.tsx @@ -22,6 +22,11 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const { privacyMode } = usePrivacy(); const { t } = useTranslation(); + const totalTokens = data?.totalTokens ?? 0; + const totalInputTokens = data?.totalInputTokens ?? 0; + const totalOutputTokens = data?.totalOutputTokens ?? 0; + const totalCacheTokens = data?.totalCacheTokens ?? 0; + const totalIoTokens = totalInputTokens + totalOutputTokens; if (isLoading) { return ( @@ -47,19 +52,27 @@ export function UsageSummaryCards({ data, isLoading }: UsageSummaryCardsProps) { const cacheCost = (data?.tokenBreakdown?.cacheCreation?.cost ?? 0) + (data?.tokenBreakdown?.cacheRead?.cost ?? 0); const cacheCostPercent = data?.totalCost ? Math.round((cacheCost / data.totalCost) * 100) : 0; + const totalTokensIncludesCache = totalCacheTokens > 0 && totalTokens > totalIoTokens; + const totalTokensTitle = + totalCacheTokens > 0 && !totalTokensIncludesCache + ? 'Total Tokens (I/O)' + : t('analyticsSummary.totalTokens'); + const totalTokensSubtitle = totalTokensIncludesCache + ? `${formatNumber(totalInputTokens)} in / ${formatNumber(totalOutputTokens)} out / ${formatNumber(totalCacheTokens)} cache` + : t('analyticsSummary.totalTokensSubtitle', { + input: formatNumber(totalInputTokens), + output: formatNumber(totalOutputTokens), + }); const cards = [ { - title: t('analyticsSummary.totalTokens'), - value: data?.totalTokens ?? 0, + title: totalTokensTitle, + value: totalTokens, icon: FileText, format: (v: number) => formatNumber(v), color: 'text-blue-600', bgColor: 'bg-blue-100 dark:bg-blue-900/20', - subtitle: t('analyticsSummary.totalTokensSubtitle', { - input: formatNumber(data?.totalInputTokens ?? 0), - output: formatNumber(data?.totalOutputTokens ?? 0), - }), + subtitle: totalTokensSubtitle, }, { title: t('analyticsSummary.totalCost'), diff --git a/ui/src/components/analytics/usage-trend-chart.tsx b/ui/src/components/analytics/usage-trend-chart.tsx index e20d0303..5b031a2d 100644 --- a/ui/src/components/analytics/usage-trend-chart.tsx +++ b/ui/src/components/analytics/usage-trend-chart.tsx @@ -146,7 +146,6 @@ export function UsageTrendChart({ content={({ active, payload, label }) => { if (!active || !payload || !payload.length) return null; - const tooltipData = payload[0].payload; return (

{label}

@@ -162,16 +161,6 @@ export function UsageTrendChart({ : `$${entry.value}`}

))} - {'requests' in tooltipData && ( -

- Requests: {tooltipData.requests} -

- )}
); }} diff --git a/ui/src/hooks/use-usage.ts b/ui/src/hooks/use-usage.ts index 709da6b6..b0f6dcb3 100644 --- a/ui/src/hooks/use-usage.ts +++ b/ui/src/hooks/use-usage.ts @@ -95,6 +95,7 @@ export interface UsageInsights { export interface Session { sessionId: string; projectPath: string; + tokens?: number; inputTokens: number; outputTokens: number; cost: number; @@ -145,48 +146,49 @@ function formatDateForApi(date: Date): string { return `${year}${month}${day}`; } +function buildUsageUrl(path: string, params: URLSearchParams): string { + const query = params.toString(); + return query ? `${path}?${query}` : path; +} + 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}`); + return request(buildUsageUrl('/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}`); + return request(buildUsageUrl('/usage/daily', params)); }, hourly: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); - return request(`/usage/hourly?${params}`); + return request(buildUsageUrl('/usage/hourly', 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}`); + return request(buildUsageUrl('/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}`); + return request(buildUsageUrl('/usage/sessions', params)); }, - monthly: (months?: number, profile?: string) => { + monthly: (options?: UsageQueryOptions) => { const params = new URLSearchParams(); - if (months) params.append('months', months.toString()); - if (profile) params.append('profile', profile); - return request(`/usage/monthly?${params}`); + if (options?.startDate) params.append('since', formatDateForApi(options.startDate)); + if (options?.endDate) params.append('until', formatDateForApi(options.endDate)); + return request(buildUsageUrl('/usage/monthly', params)); }, /** Clear server-side usage cache and force fresh data fetch */ refresh: async (): Promise => { @@ -205,8 +207,7 @@ export const usageApi = { 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/insights?${params}`); + return request(buildUsageUrl('/usage/insights', params)); }, }; diff --git a/ui/src/pages/analytics/hooks.ts b/ui/src/pages/analytics/hooks.ts index f4fc73ef..359e769b 100644 --- a/ui/src/pages/analytics/hooks.ts +++ b/ui/src/pages/analytics/hooks.ts @@ -18,6 +18,8 @@ import { type ModelUsage, } from '@/hooks/use-usage'; +const RECENT_SESSION_SAMPLE_LIMIT = 50; + export function useAnalyticsPage() { // Default to last 30 days const [dateRange, setDateRange] = useState({ @@ -55,7 +57,10 @@ export function useAnalyticsPage() { const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions); const { data: hourlyData, isLoading: isHourlyLoading } = useHourlyUsage(apiOptions); const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions); - const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 }); + const { data: sessions, isLoading: isSessionsLoading } = useSessions({ + ...apiOptions, + limit: RECENT_SESSION_SAMPLE_LIMIT, + }); const { data: status } = useUsageStatus(); // Handle "24H" preset click diff --git a/ui/tests/unit/components/analytics/session-stats-card.test.tsx b/ui/tests/unit/components/analytics/session-stats-card.test.tsx index e09776c1..eee51d93 100644 --- a/ui/tests/unit/components/analytics/session-stats-card.test.tsx +++ b/ui/tests/unit/components/analytics/session-stats-card.test.tsx @@ -1,15 +1,9 @@ -/** - * Session Stats Card Tests - * Unit tests for SessionStatsCard component with project name formatting - */ - -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { SessionStatsCard } from '../../../../src/components/analytics/session-stats-card'; -import { AllProviders } from '../../../setup/test-utils'; -import type { PaginatedSessions } from '../../../../src/hooks/use-usage'; +import { SessionStatsCard } from '@/components/analytics/session-stats-card'; +import type { PaginatedSessions, Session } from '@/hooks/use-usage'; +import { AllProviders } from '@tests/setup/test-utils'; -// Mock date-fns to return consistent dates vi.mock('date-fns', async () => { const actual = await vi.importActual('date-fns'); return { @@ -18,288 +12,132 @@ vi.mock('date-fns', async () => { }; }); +function buildSession(overrides: Partial = {}): Session { + return { + sessionId: 'session-1', + projectPath: '/Users/kaitran/CloudPersonal/worktrees/ccs-cli/feature-branch', + inputTokens: 1_500, + outputTokens: 2_500, + cost: 0.08, + lastActivity: '2026-04-26T14:00:00.000Z', + modelsUsed: ['claude-sonnet-4'], + ...overrides, + }; +} + +function buildPaginatedSessions(overrides: Partial = {}): PaginatedSessions { + return { + sessions: [buildSession()], + total: 1, + limit: 50, + offset: 0, + hasMore: false, + ...overrides, + }; +} + describe('SessionStatsCard', () => { beforeEach(() => { - // Reset all mocks vi.clearAllMocks(); }); - describe('Loading and Empty States', () => { - it('renders loading skeleton when isLoading is true', () => { - const { container } = render(, { - wrapper: AllProviders, - }); - - // Should have skeleton loading elements - expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument(); + it('renders a loading skeleton', () => { + const { container } = render(, { + wrapper: AllProviders, }); - it('shows empty state when no data available', () => { - render(, { wrapper: AllProviders }); - - expect(screen.getByText('Session Stats')).toBeInTheDocument(); - expect(screen.getByText('No session data available')).toBeInTheDocument(); - }); - - it('shows empty state when sessions array is empty', () => { - const emptyData: PaginatedSessions = { - sessions: [], - total: 0, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - expect(screen.getByText('Session Stats')).toBeInTheDocument(); - expect(screen.getByText('No session data available')).toBeInTheDocument(); - }); + expect(container.querySelector('[data-slot="skeleton"]')).toBeInTheDocument(); }); - describe('Session Stats Display', () => { - const createMockSession = ( - projectPath: string, - inputTokens: number, - outputTokens: number, - cost: number - ) => ({ - sessionId: `session-${Math.random()}`, - projectPath, - inputTokens, - outputTokens, - cost, - lastActivity: new Date().toISOString(), + it('renders the empty state with the live paginated contract', () => { + render(, { + wrapper: AllProviders, }); - const mockData: PaginatedSessions = { + expect(screen.getByText('Session Stats')).toBeInTheDocument(); + expect(screen.getByText('No session data available')).toBeInTheDocument(); + }); + + it('uses the API total for overall session count while labeling subset averages as recent', () => { + const data = buildPaginatedSessions({ sessions: [ - createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08), - createMockSession( - '/home/user/workspaces/repo-name/worktrees/feature-branch', - 2000, - 3000, - 0.12 - ), - createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05), + buildSession({ sessionId: 'session-1', cost: 0.08 }), + buildSession({ + sessionId: 'session-2', + projectPath: '/Users/kaitran/projects/platform/worktrees/kai-fix', + inputTokens: 2_000, + outputTokens: 3_000, + cost: 0.12, + target: 'codex', + }), + buildSession({ + sessionId: 'session-3', + projectPath: '/Users/kaitran/projects/share-pi', + inputTokens: 1_000, + outputTokens: 2_000, + cost: 0.05, + }), ], - total: 3, - page: 1, - pageSize: 10, - }; - - beforeEach(() => { - mockData.sessions = [ - createMockSession('/home/user/projects/my-app', 1500, 2500, 0.08), - createMockSession( - '/home/user/workspaces/repo-name/worktrees/feature-branch', - 2000, - 3000, - 0.12 - ), - createMockSession('/Users/joe/Developer/share-pi', 1000, 2000, 0.05), - ]; + total: 9, + hasMore: true, }); - it('displays session stats header', () => { - render(, { wrapper: AllProviders }); + render(, { wrapper: AllProviders }); - expect(screen.getByText('Session Stats')).toBeInTheDocument(); - }); - - it('shows total sessions count', () => { - render(, { wrapper: AllProviders }); - - expect(screen.getByText('3')).toBeInTheDocument(); - expect(screen.getByText('Total Sessions')).toBeInTheDocument(); - }); - - it('calculates and displays average cost per session', () => { - render(, { wrapper: AllProviders }); - - // Average cost: (0.08 + 0.12 + 0.05) / 3 = 0.0833 → $0.08 - // Use getAllByText since cost may appear multiple times (per session + average) - const costElements = screen.getAllByText('$0.08'); - expect(costElements.length).toBeGreaterThan(0); - expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument(); - }); - - it('shows recent activity section', () => { - render(, { wrapper: AllProviders }); - - expect(screen.getByText('Recent Activity')).toBeInTheDocument(); - }); + expect(screen.getByText('Total Sessions')).toBeInTheDocument(); + expect(screen.getByText('9')).toBeInTheDocument(); + expect(screen.getByText('Recent Avg Cost')).toBeInTheDocument(); + expect(screen.getAllByText('$0.08').length).toBeGreaterThan(0); + expect(screen.getByText('codex')).toBeInTheDocument(); }); - describe('Project Name Formatting', () => { - it('displays correct project name for simple path', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '/home/user/projects/my-app', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - // Should show "my-app" instead of just "app" - expect(screen.getByTitle('/home/user/projects/my-app')).toHaveTextContent('my-app'); + it('keeps the overall average label when the full result set is loaded', () => { + const data = buildPaginatedSessions({ + sessions: [ + buildSession({ sessionId: 'session-1', cost: 0.04 }), + buildSession({ sessionId: 'session-2', cost: 0.06 }), + ], + total: 2, + hasMore: false, }); - it('displays correct project name for worktree path', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: - '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; + render(, { wrapper: AllProviders }); - render(, { wrapper: AllProviders }); - - // Should show "2026-01-08" instead of just "08" - expect( - screen.getByTitle( - '/Users/joe/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08' - ) - ).toHaveTextContent('2026-01-08'); - }); - - it('displays correct project name for shared project', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '/Users/joe/Developer/share-pi', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - // Should show "share-pi" instead of just "pi" - expect(screen.getByTitle('/Users/joe/Developer/share-pi')).toHaveTextContent('share-pi'); - }); - - it('handles empty project path gracefully', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - // Should not crash and show empty string - expect(screen.getByTitle('')).toHaveTextContent(''); - }); - - it('handles project path with only slashes', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '///', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - // Should handle gracefully and show empty string - expect(screen.getByTitle('///')).toHaveTextContent(''); - }); + expect(screen.getByText('Avg Cost/Session')).toBeInTheDocument(); + expect(screen.queryByText('Recent Avg Cost')).not.toBeInTheDocument(); }); - describe('Token Count Display', () => { - it('displays token counts in compact format', () => { - const mockData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '/project/test', - inputTokens: 1500000, // 1.5M - outputTokens: 500000, // 500K - cost: 0.1, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - render(, { wrapper: AllProviders }); - - expect(screen.getByText('2.0M toks')).toBeInTheDocument(); + it('formats project names from worktree paths without regressing the live fixture shape', () => { + const data = buildPaginatedSessions({ + sessions: [ + buildSession({ + projectPath: + '/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08', + }), + ], }); + + render(, { wrapper: AllProviders }); + + expect( + screen.getByTitle( + '/Users/kaitran/Developer/ExaDev/Clients/Architect/repositories/architect/worktrees/2026-01-08' + ) + ).toHaveTextContent('2026-01-08'); }); - describe('Privacy Mode', () => { - it('blurs cost information when privacy mode is enabled', () => { - // This would require mocking the privacy context - // For now, just ensure the component renders with privacy mode - const testData: PaginatedSessions = { - sessions: [ - { - sessionId: '1', - projectPath: '/home/user/project', - inputTokens: 1000, - outputTokens: 2000, - cost: 0.05, - lastActivity: new Date().toISOString(), - }, - ], - total: 1, - page: 1, - pageSize: 10, - }; - - const { container } = render(, { wrapper: AllProviders }); - - // Component should render without errors - expect(container).toBeInTheDocument(); + it('formats visible token counts from input plus output only', () => { + const data = buildPaginatedSessions({ + sessions: [ + buildSession({ + inputTokens: 1_500_000, + outputTokens: 500_000, + }), + ], }); + + render(, { wrapper: AllProviders }); + + expect(screen.getByText('2.0M toks')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx new file mode 100644 index 00000000..c9bcfb5e --- /dev/null +++ b/ui/tests/unit/components/analytics/usage-summary-cards.test.tsx @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards'; +import type { UsageSummary } from '@/hooks/use-usage'; +import { AllProviders } from '@tests/setup/test-utils'; + +function buildSummary(overrides: Partial = {}): UsageSummary { + return { + totalTokens: 3_000, + totalInputTokens: 2_000, + totalOutputTokens: 1_000, + totalCacheTokens: 500, + totalCacheCreationTokens: 300, + totalCacheReadTokens: 200, + totalCost: 2, + tokenBreakdown: { + input: { tokens: 2_000, cost: 0.8 }, + output: { tokens: 1_000, cost: 0.7 }, + cacheCreation: { tokens: 300, cost: 0.3 }, + cacheRead: { tokens: 200, cost: 0.2 }, + }, + totalDays: 2, + averageTokensPerDay: 1_500, + averageCostPerDay: 1, + ...overrides, + }; +} + +describe('UsageSummaryCards', () => { + it('labels total tokens as I/O-only when cache is excluded from the backend total', () => { + render(, { wrapper: AllProviders }); + + expect(screen.getByText('Total Tokens (I/O)')).toBeInTheDocument(); + expect(screen.getByText('2.0K in / 1.0K out')).toBeInTheDocument(); + expect(screen.getByText('Cache Tokens')).toBeInTheDocument(); + expect(screen.getByText('$0.50 (25% of cost)')).toBeInTheDocument(); + }); + + it('falls back to an inclusive total label when total tokens already include cache', () => { + render( + , + { wrapper: AllProviders } + ); + + expect(screen.getByText('Total Tokens')).toBeInTheDocument(); + expect(screen.getByText('2.0K in / 1.0K out / 500 cache')).toBeInTheDocument(); + expect(screen.queryByText('Total Tokens (I/O)')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx new file mode 100644 index 00000000..29da8057 --- /dev/null +++ b/ui/tests/unit/components/analytics/usage-trend-chart.test.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { UsageTrendChart } from '@/components/analytics/usage-trend-chart'; +import { AllProviders } from '@tests/setup/test-utils'; + +vi.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) =>
{children}
, + AreaChart: ({ children }: { children: ReactNode }) =>
{children}
, + Area: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: ({ content }: { content: (args: unknown) => ReactNode }) => ( +
+ {content({ + active: true, + label: '14:00', + payload: [ + { + name: 'Tokens', + value: 1_200, + color: '#0080FF', + payload: { requests: 4 }, + }, + { + name: 'Cost', + value: 1.5, + color: '#00C49F', + payload: { requests: 4 }, + }, + ], + })} +
+ ), + defs: ({ children }: { children: ReactNode }) => <>{children}, + linearGradient: ({ children }: { children: ReactNode }) => <>{children}, + stop: () => null, +})); + +describe('UsageTrendChart', () => { + it('does not overclaim hourly request semantics in the tooltip', () => { + render( + , + { wrapper: AllProviders } + ); + + expect(screen.getByText('Tokens: 1.2K')).toBeInTheDocument(); + expect(screen.getByText('Cost: $1.5')).toBeInTheDocument(); + expect(screen.queryByText(/Requests:/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/pages/analytics/hooks.test.tsx b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx new file mode 100644 index 00000000..eb4f4455 --- /dev/null +++ b/ui/tests/unit/ui/pages/analytics/hooks.test.tsx @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useAnalyticsPage } from '@/pages/analytics/hooks'; +import { AllProviders } from '@tests/setup/test-utils'; + +const usageMocks = vi.hoisted(() => ({ + useUsageSummary: vi.fn(() => ({ data: undefined, isLoading: false })), + useUsageTrends: vi.fn(() => ({ data: undefined, isLoading: false })), + useHourlyUsage: vi.fn(() => ({ data: undefined, isLoading: false })), + useModelUsage: vi.fn(() => ({ data: undefined, isLoading: false })), + useRefreshUsage: vi.fn(() => vi.fn()), + useUsageStatus: vi.fn(() => ({ data: { lastFetch: null } })), + useSessions: vi.fn(() => ({ data: undefined, isLoading: false })), +})); + +vi.mock('@/hooks/use-usage', () => usageMocks); + +describe('useAnalyticsPage', () => { + it('requests a broader recent session sample instead of the old 3-session slice', () => { + renderHook(() => useAnalyticsPage(), { + wrapper: AllProviders, + }); + + expect(usageMocks.useSessions).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 50, + }) + ); + expect(usageMocks.useSessions).not.toHaveBeenCalledWith( + expect.objectContaining({ + limit: 3, + }) + ); + }); +}); diff --git a/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts new file mode 100644 index 00000000..96a62a2d --- /dev/null +++ b/ui/tests/unit/ui/pages/analytics/usage-api-contract.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { usageApi } from '@/hooks/use-usage'; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('analytics usage API contract', () => { + beforeEach(() => { + global.fetch = vi + .fn() + .mockImplementation(() => Promise.resolve(jsonResponse({ data: {} }))) as typeof fetch; + }); + + it('omits unsupported profile and months query params while keeping supported filters', async () => { + const startDate = new Date(2026, 3, 1); + const endDate = new Date(2026, 3, 30); + + await usageApi.summary({ startDate, endDate, profile: 'work' }); + await usageApi.trends({ startDate, endDate, profile: 'work' }); + await usageApi.models({ startDate, endDate, profile: 'work' }); + await usageApi.sessions({ startDate, endDate, profile: 'work', limit: 50, offset: 10 }); + await usageApi.insights({ startDate, endDate, profile: 'work' }); + await usageApi.monthly({ startDate, endDate, profile: 'work' }); + + const urls = vi.mocked(global.fetch).mock.calls.map(([url]) => String(url)); + + expect(urls).toContain('/api/usage/summary?since=20260401&until=20260430'); + expect(urls).toContain('/api/usage/daily?since=20260401&until=20260430'); + expect(urls).toContain('/api/usage/models?since=20260401&until=20260430'); + expect(urls).toContain('/api/usage/sessions?since=20260401&until=20260430&limit=50&offset=10'); + expect(urls).toContain('/api/usage/insights?since=20260401&until=20260430'); + expect(urls).toContain('/api/usage/monthly?since=20260401&until=20260430'); + expect(urls.every((url) => !url.includes('profile='))).toBe(true); + expect(urls.every((url) => !url.includes('months='))).toBe(true); + }); +});