From 47e9e255cfeeb1b7a702e38d90f9b06994445419 Mon Sep 17 00:00:00 2001 From: Nathan Heaps <1282393+nsheaps@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:14:14 -0400 Subject: [PATCH 01/14] fix: correct session id identification from path The code claude implemented for fetching the session ID is incorrect. Previously it looked in `~/.claude/projects/project-name/session-id/xxx.jsonl`, however the actual storage location is `~/.claude/projects/project-name/session-id.jsonl` This change updates the path logic to identify the session by the UUID and properly detect the project name. Worth noting that the paths from the projects folder to the jsonl are relative, so the `projectPath` is really just the name of the folder, not the full absolute path. Since this isn't displayed at the moment, I figured that was fine. This was done directly in github web editing, I expect there to be a CI failure or two. Right now this is untested, but I plan on using this in the `anthropics/claude-code-action` workflow to print out the costs at the end of the workflow. --- src/data-loader.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 3900d03e..81195067 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -917,10 +917,12 @@ export async function loadSessionData( const relativePath = path.relative(baseDir, file); const parts = relativePath.split(path.sep); - // Session ID is the directory name containing the JSONL file - const sessionId = parts[parts.length - 2] ?? 'unknown'; - // Project path is everything before the session ID - const joinedPath = parts.slice(0, -2).join(path.sep); + // Session ID is the name of the jsonl file name containing the JSONL file + const sessionId = parts[parts.length - 1].replace(/.jsonl/g, '') ?? 'unknown'; + // Project path is everything before the session ID. Since it is relative to the + // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) + // so 0,-2 would be empty since there's only 2 elements. + const joinedPath = parts.slice(0, -1).join(path.sep); const projectPath = joinedPath.length > 0 ? joinedPath : 'Unknown Project'; const content = await readFile(file, 'utf-8'); From 9f5ae4c3d712caa8692cd48c94998895abe2fd5f Mon Sep 17 00:00:00 2001 From: Nathan Heaps <1282393+nsheaps@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:14:14 -0400 Subject: [PATCH 02/14] fix: correct session id identification from path The code claude implemented for fetching the session ID is incorrect. Previously it looked in `~/.claude/projects/project-name/session-id/xxx.jsonl`, however the actual storage location is `~/.claude/projects/project-name/session-id.jsonl` This change updates the path logic to identify the session by the UUID and properly detect the project name. Worth noting that the paths from the projects folder to the jsonl are relative, so the `projectPath` is really just the name of the folder, not the full absolute path. Since this isn't displayed at the moment, I figured that was fine. This was done directly in github web editing, I expect there to be a CI failure or two. Right now this is untested, but I plan on using this in the `anthropics/claude-code-action` workflow to print out the costs at the end of the workflow. --- src/data-loader.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 3900d03e..81195067 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -917,10 +917,12 @@ export async function loadSessionData( const relativePath = path.relative(baseDir, file); const parts = relativePath.split(path.sep); - // Session ID is the directory name containing the JSONL file - const sessionId = parts[parts.length - 2] ?? 'unknown'; - // Project path is everything before the session ID - const joinedPath = parts.slice(0, -2).join(path.sep); + // Session ID is the name of the jsonl file name containing the JSONL file + const sessionId = parts[parts.length - 1].replace(/.jsonl/g, '') ?? 'unknown'; + // Project path is everything before the session ID. Since it is relative to the + // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) + // so 0,-2 would be empty since there's only 2 elements. + const joinedPath = parts.slice(0, -1).join(path.sep); const projectPath = joinedPath.length > 0 ? joinedPath : 'Unknown Project'; const content = await readFile(file, 'utf-8'); From dfd88feea67a0839ded8a3877e7735f72c1e7eb7 Mon Sep 17 00:00:00 2001 From: Nathan Heaps <1282393+nsheaps@users.noreply.github.com> Date: Thu, 24 Jul 2025 15:25:12 -0400 Subject: [PATCH 03/14] fix: handle empty paths for session ID better --- src/data-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 81195067..4ffbe1cd 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -918,7 +918,7 @@ export async function loadSessionData( const parts = relativePath.split(path.sep); // Session ID is the name of the jsonl file name containing the JSONL file - const sessionId = parts[parts.length - 1].replace(/.jsonl/g, '') ?? 'unknown'; + const sessionId = (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') || 'unknown'; // Project path is everything before the session ID. Since it is relative to the // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) // so 0,-2 would be empty since there's only 2 elements. From d879bf36228c4cd225d12be046a43e80a451baef Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Tue, 29 Jul 2025 16:48:37 -0400 Subject: [PATCH 04/14] chore: fix ts/strict-boolean-expressions --- src/data-loader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 4ffbe1cd..a9c126e4 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -918,7 +918,7 @@ export async function loadSessionData( const parts = relativePath.split(path.sep); // Session ID is the name of the jsonl file name containing the JSONL file - const sessionId = (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') || 'unknown'; + const sessionId = ((parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '') || 'unknown'; // Project path is everything before the session ID. Since it is relative to the // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) // so 0,-2 would be empty since there's only 2 elements. From 471f551c2729cbbb0ab9e8c4ddfd7cd65a37f7ac Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Tue, 29 Jul 2025 23:25:09 -0400 Subject: [PATCH 05/14] fix: don't sort package json with sort-package-json, sort using eslint --- bun.lock | 11 ----------- package.json | 4 ---- tsdown.config.ts | 2 +- 3 files changed, 1 insertion(+), 16 deletions(-) diff --git a/bun.lock b/bun.lock index 939a9ac3..dfb1f5a3 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,6 @@ "pretty-ms": "^9.2.0", "publint": "^0.3.12", "simple-git-hooks": "^2.13.0", - "sort-package-json": "^3.4.0", "string-width": "^7.2.0", "tinyglobby": "^0.2.14", "tsdown": "^0.13.0", @@ -857,12 +856,8 @@ "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], - "detect-indent": ["detect-indent@7.0.1", "", {}, "sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g=="], - "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], - "detect-newline": ["detect-newline@4.0.1", "", {}, "sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog=="], - "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], @@ -1065,8 +1060,6 @@ "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], - "git-hooks-list": ["git-hooks-list@4.1.1", "", {}, "sha512-cmP497iLq54AZnv4YRAEMnEyQ1eIn4tGKbmswqwmFV4GBnAqE8NLtWxxdXa++AalfgL5EBH4IxTPyquEuGY/jA=="], - "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1589,10 +1582,6 @@ "slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], - "sort-object-keys": ["sort-object-keys@1.1.3", "", {}, "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg=="], - - "sort-package-json": ["sort-package-json@3.4.0", "", { "dependencies": { "detect-indent": "^7.0.1", "detect-newline": "^4.0.1", "git-hooks-list": "^4.0.0", "is-plain-obj": "^4.1.0", "semver": "^7.7.1", "sort-object-keys": "^1.1.3", "tinyglobby": "^0.2.12" }, "bin": { "sort-package-json": "cli.js" } }, "sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], diff --git a/package.json b/package.json index 1022a4fd..01da8edd 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,6 @@ "pretty-ms": "^9.2.0", "publint": "^0.3.12", "simple-git-hooks": "^2.13.0", - "sort-package-json": "^3.4.0", "string-width": "^7.2.0", "tinyglobby": "^0.2.14", "tsdown": "^0.13.0", @@ -105,9 +104,6 @@ "lint-staged": { "*": [ "eslint --cache --fix" - ], - "package.json": [ - "sort-package-json" ] }, "trustedDependencies": [ diff --git a/tsdown.config.ts b/tsdown.config.ts index 182646be..3d90587f 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -29,5 +29,5 @@ export default defineConfig({ define: { 'import.meta.vitest': 'undefined', }, - onSuccess: 'sort-package-json', + onSuccess: 'eslint --cache --fix package.json && echo "package.json sorted by eslint"', }); From 69aff350cbb1c465adf82dc82933fbdba0d0763e Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Tue, 29 Jul 2025 23:25:37 -0400 Subject: [PATCH 06/14] fix: correct fixture data after session jsonl structure change --- src/data-loader.ts | 1082 ++++++++++++++++++++++++-------------------- 1 file changed, 582 insertions(+), 500 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index a9c126e4..694373b2 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -28,11 +28,15 @@ import { createFixture } from 'fs-fixture'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import { z } from 'zod'; -import { CLAUDE_CONFIG_DIR_ENV, CLAUDE_PROJECTS_DIR_NAME, DEFAULT_CLAUDE_CODE_PATH, DEFAULT_CLAUDE_CONFIG_PATH, USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR } from './_consts.ts'; import { - identifySessionBlocks, - -} from './_session-blocks.ts'; + CLAUDE_CONFIG_DIR_ENV, + CLAUDE_PROJECTS_DIR_NAME, + DEFAULT_CLAUDE_CODE_PATH, + DEFAULT_CLAUDE_CONFIG_PATH, + USAGE_DATA_GLOB_PATTERN, + USER_HOME_DIR, +} from './_consts.ts'; +import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, createDailyDate, @@ -55,9 +59,7 @@ import { versionSchema, } from './_types.ts'; import { logger } from './logger.ts'; -import { - PricingFetcher, -} from './pricing-fetcher.ts'; +import { PricingFetcher } from './pricing-fetcher.ts'; /** * Get all Claude data directories to search for usage data @@ -71,11 +73,17 @@ export function getClaudePaths(): string[] { // Check environment variable first (supports comma-separated paths) const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim(); if (envPaths !== '') { - const envPathList = envPaths.split(',').map(p => p.trim()).filter(p => p !== ''); + const envPathList = envPaths + .split(',') + .map(p => p.trim()) + .filter(p => p !== ''); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { - const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); + const projectsPath = path.join( + normalizedPath, + CLAUDE_PROJECTS_DIR_NAME, + ); if (isDirectorySync(projectsPath)) { // Avoid duplicates using normalized paths if (!normalizedPaths.has(normalizedPath)) { @@ -128,14 +136,18 @@ export function extractProjectFromPath(jsonlPath: string): string { // Normalize path separators for cross-platform compatibility const normalizedPath = jsonlPath.replace(/[/\\]/g, path.sep); const segments = normalizedPath.split(path.sep); - const projectsIndex = segments.findIndex(segment => segment === CLAUDE_PROJECTS_DIR_NAME); + const projectsIndex = segments.findIndex( + segment => segment === CLAUDE_PROJECTS_DIR_NAME, + ); if (projectsIndex === -1 || projectsIndex + 1 >= segments.length) { return 'unknown'; } const projectName = segments[projectsIndex + 1]; - return projectName != null && projectName.trim() !== '' ? projectName : 'unknown'; + return projectName != null && projectName.trim() !== '' + ? projectName + : 'unknown'; } /** @@ -153,9 +165,13 @@ export const usageDataSchema = z.object({ }), model: modelNameSchema.optional(), // Model is inside message object id: messageIdSchema.optional(), // Message ID for deduplication - content: z.array(z.object({ - text: z.string().optional(), - })).optional(), + content: z + .array( + z.object({ + text: z.string().optional(), + }), + ) + .optional(), }), costUSD: z.number().optional(), // Made optional for new schema requestId: requestIdSchema.optional(), // Request ID for deduplication @@ -290,8 +306,10 @@ function aggregateByModel( modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), - cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), + cacheCreationTokens: + existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheReadTokens: + existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); } @@ -325,7 +343,8 @@ function aggregateModelBreakdowns( modelAggregates.set(breakdown.modelName, { inputTokens: existing.inputTokens + breakdown.inputTokens, outputTokens: existing.outputTokens + breakdown.outputTokens, - cacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens, + cacheCreationTokens: + existing.cacheCreationTokens + breakdown.cacheCreationTokens, cacheReadTokens: existing.cacheReadTokens + breakdown.cacheReadTokens, cost: existing.cost + breakdown.cost, }); @@ -364,8 +383,10 @@ function calculateTotals( return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), - cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), + cacheCreationTokens: + acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheReadTokens: + acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, }; @@ -456,19 +477,23 @@ function extractUniqueModels( entries: T[], getModel: (entry: T) => string | undefined, ): string[] { - return uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '')); + return uniq( + entries + .map(getModel) + .filter((m): m is string => m != null && m !== ''), + ); } /** * Formats a date string to YYYY-MM-DD format - * @param dateStr - Input date string + * @param dateStr - Input date string (ALWAYS in UTC) * @returns Formatted date string in YYYY-MM-DD format */ export function formatDate(dateStr: string): string { const date = new Date(dateStr); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } @@ -479,9 +504,9 @@ export function formatDate(dateStr: string): string { */ export function formatDateCompact(dateStr: string): string { const date = new Date(dateStr); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); return `${year}\n${month}-${day}`; } @@ -527,7 +552,9 @@ export function createUniqueHash(data: UsageData): string | null { * Extract the earliest timestamp from a JSONL file * Scans through the file until it finds a valid timestamp */ -export async function getEarliestTimestamp(filePath: string): Promise { +export async function getEarliestTimestamp( + filePath: string, +): Promise { try { const content = await readFile(filePath, 'utf-8'); const lines = content.trim().split('\n'); @@ -616,7 +643,10 @@ export async function calculateCostForEntry( if (mode === 'calculate') { // Always calculate from tokens if (data.message.model != null) { - return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); + return Result.unwrap( + fetcher.calculateCostFromTokens(data.message.usage, data.message.model), + 0, + ); } return 0; } @@ -628,7 +658,10 @@ export async function calculateCostForEntry( } if (data.message.model != null) { - return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); + return Result.unwrap( + fetcher.calculateCostFromTokens(data.message.usage, data.message.model), + 0, + ); } return 0; @@ -672,7 +705,9 @@ export type GlobResult = { * @param claudePaths - Array of Claude base paths * @returns Array of file paths with their base directories */ -export async function globUsageFiles(claudePaths: string[]): Promise { +export async function globUsageFiles( + claudePaths: string[], +): Promise { const filePromises = claudePaths.map(async (claudePath) => { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { @@ -741,13 +776,20 @@ export async function loadDailyUsageData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher + = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); // Collect all valid data entries first - const allEntries: { data: UsageData; date: string; cost: number; model: string | undefined; project: string }[] = []; + const allEntries: { + data: UsageData; + date: string; + cost: number; + model: string | undefined; + project: string; + }[] = []; for (const file of sortedFiles) { const content = await readFile(file, 'utf-8'); @@ -785,7 +827,13 @@ export async function loadDailyUsageData( // Extract project name from file path const project = extractProjectFromPath(file); - allEntries.push({ data, date, cost, model: data.message.model, project }); + allEntries.push({ + data, + date, + cost, + model: data.message.model, + project, + }); } catch { // Skip invalid JSON lines @@ -795,10 +843,11 @@ export async function loadDailyUsageData( // Group by date, optionally including project // Automatically enable project grouping when project filter is specified - const needsProjectGrouping = options?.groupByProject === true || options?.project != null; + const needsProjectGrouping + = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping - ? (entry: typeof allEntries[0]) => `${entry.date}\x00${entry.project}` - : (entry: typeof allEntries[0]) => entry.date; + ? (entry: (typeof allEntries)[0]) => `${entry.date}\x00${entry.project}` + : (entry: (typeof allEntries)[0]) => entry.date; const groupedData = groupBy(allEntries, groupingKey); @@ -845,10 +894,19 @@ export async function loadDailyUsageData( .filter(item => item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange(results, item => item.date, options?.since, options?.until); + const dateFiltered = filterByDateRange( + results, + item => item.date, + options?.since, + options?.until, + ); // Filter by project if specified - const finalFiltered = filterByProject(dateFiltered, item => item.project, options?.project); + const finalFiltered = filterByProject( + dateFiltered, + item => item.project, + options?.project, + ); // Sort by date based on order option (default to descending) return sortByDate(finalFiltered, item => item.date, options?.order); @@ -882,7 +940,9 @@ export async function loadSessionData( // Sort files by timestamp to ensure chronological processing // Create a map for O(1) lookup instead of O(N) find operations - const fileToBaseMap = new Map(projectFilteredWithBase.map(f => [f.file, f.baseDir])); + const fileToBaseMap = new Map( + projectFilteredWithBase.map(f => [f.file, f.baseDir]), + ); const sortedFilesWithBase = await sortFilesByTimestamp( projectFilteredWithBase.map(f => f.file), ).then(sortedFiles => @@ -896,7 +956,8 @@ export async function loadSessionData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher += mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); @@ -918,7 +979,16 @@ export async function loadSessionData( const parts = relativePath.split(path.sep); // Session ID is the name of the jsonl file name containing the JSONL file - const sessionId = ((parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '') || 'unknown'; + let sessionId: string; + if ( + parts.length > 0 + && (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '' + ) { + sessionId = (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, ''); + } + else { + sessionId = 'unknown'; + } // Project path is everything before the session ID. Since it is relative to the // projects folder (.../dashed-path-project-name/uuid-for-session.jsonl) // so 0,-2 would be empty since there's only 2 elements. @@ -943,7 +1013,7 @@ export async function loadSessionData( // Check for duplicate message + request ID combination const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) { - // Skip duplicate message + // Skip duplicate message continue; } @@ -972,10 +1042,7 @@ export async function loadSessionData( } // Group by session using Object.groupBy - const groupedBySessions = groupBy( - allEntries, - entry => entry.sessionKey, - ); + const groupedBySessions = groupBy(allEntries, entry => entry.sessionKey); // Aggregate each session group const results = Object.entries(groupedBySessions) @@ -1030,12 +1097,25 @@ export async function loadSessionData( .filter(item => item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange(results, item => item.lastActivity, options?.since, options?.until); + const dateFiltered = filterByDateRange( + results, + item => item.lastActivity, + options?.since, + options?.until, + ); // Filter by project if specified - const sessionFiltered = filterByProject(dateFiltered, item => item.projectPath, options?.project); + const sessionFiltered = filterByProject( + dateFiltered, + item => item.projectPath, + options?.project, + ); - return sortByDate(sessionFiltered, item => item.lastActivity, options?.order); + return sortByDate( + sessionFiltered, + item => item.lastActivity, + options?.order, + ); } /** @@ -1051,10 +1131,12 @@ export async function loadMonthlyUsageData( // Group daily data by month, optionally including project // Automatically enable project grouping when project filter is specified - const needsProjectGrouping = options?.groupByProject === true || options?.project != null; + const needsProjectGrouping + = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping - ? (data: typeof dailyData[0]) => `${data.date.substring(0, 7)}\x00${data.project ?? 'unknown'}` - : (data: typeof dailyData[0]) => data.date.substring(0, 7); + ? (data: (typeof dailyData)[0]) => + `${data.date.substring(0, 7)}\x00${data.project ?? 'unknown'}` + : (data: (typeof dailyData)[0]) => data.date.substring(0, 7); const groupedByMonth = groupBy(dailyData, groupingKey); @@ -1071,7 +1153,9 @@ export async function loadMonthlyUsageData( const project = parts.length > 1 ? parts[1] : undefined; // Aggregate model breakdowns across all days - const allBreakdowns = dailyEntries.flatMap(daily => daily.modelBreakdowns); + const allBreakdowns = dailyEntries.flatMap( + daily => daily.modelBreakdowns, + ); const modelAggregates = aggregateModelBreakdowns(allBreakdowns); // Create model breakdowns @@ -1162,7 +1246,8 @@ export async function loadSessionBlockData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher + = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); @@ -1189,7 +1274,7 @@ export async function loadSessionBlockData( // Check for duplicate message + request ID combination const uniqueHash = createUniqueHash(data); if (isDuplicateEntry(uniqueHash, processedHashes)) { - // Skip duplicate message + // Skip duplicate message continue; } @@ -1208,8 +1293,10 @@ export async function loadSessionBlockData( usage: { inputTokens: data.message.usage.input_tokens, outputTokens: data.message.usage.output_tokens, - cacheCreationInputTokens: data.message.usage.cache_creation_input_tokens ?? 0, - cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0, + cacheCreationInputTokens: + data.message.usage.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: + data.message.usage.cache_read_input_tokens ?? 0, }, costUSD: cost, model: data.message.model ?? 'unknown', @@ -1219,13 +1306,18 @@ export async function loadSessionBlockData( } catch (error) { // Skip invalid JSON lines but log for debugging purposes - logger.debug(`Skipping invalid JSON line in 5-hour blocks: ${error instanceof Error ? error.message : String(error)}`); + logger.debug( + `Skipping invalid JSON line in 5-hour blocks: ${error instanceof Error ? error.message : String(error)}`, + ); } } } // Identify session blocks - const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); + const blocks = identifySessionBlocks( + allEntries, + options?.sessionDurationHours, + ); // Filter by date range if specified const dateFiltered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '') @@ -1248,7 +1340,7 @@ export async function loadSessionBlockData( if (import.meta.vitest != null) { describe('formatDate', () => { it('formats UTC timestamp to local date', () => { - // Test with UTC timestamps - results depend on local timezone + // Test with UTC timestamps - results depend on local timezone expect(formatDate('2024-01-01T00:00:00Z')).toBe('2024-01-01'); expect(formatDate('2024-12-31T23:59:59Z')).toBe('2024-12-31'); }); @@ -1319,12 +1411,10 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file1.jsonl': mockData1.map(d => JSON.stringify(d)).join('\n'), - }, - session2: { - 'file2.jsonl': JSON.stringify(mockData2), - }, + 'session1.jsonl': mockData1 + .map(d => JSON.stringify(d)) + .join('\n'), + 'session2.jsonl': JSON.stringify(mockData2), }, }, }); @@ -1355,9 +1445,7 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': JSON.stringify(mockData), - }, + 'session1.jsonl': JSON.stringify(mockData), }, }, }); @@ -1390,9 +1478,7 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1430,9 +1516,7 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1466,9 +1550,7 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'usage.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1506,9 +1588,7 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'usage.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1536,9 +1616,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData, - }, + 'session1.jsonl': mockData, }, }, }); @@ -1564,9 +1642,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData, - }, + 'session1.jsonl': mockData, }, }, }); @@ -1603,9 +1679,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1622,14 +1696,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.015, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 150, - outputTokens: 75, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.015, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 150, + outputTokens: 75, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.015, + }, + ], }); expect(result[1]).toEqual({ month: '2024-01', @@ -1639,14 +1715,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 300, - outputTokens: 150, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.03, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 300, + outputTokens: 150, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.03, + }, + ], }); }); @@ -1676,9 +1754,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1694,14 +1770,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 300, - outputTokens: 150, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.03, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 300, + outputTokens: 150, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.03, + }, + ], }); }); @@ -1732,9 +1810,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1772,9 +1848,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1815,9 +1889,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1861,9 +1933,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1911,9 +1981,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -1946,14 +2014,10 @@ invalid json line await using fixture = await createFixture({ projects: { 'project1/subfolder': { - session123: { - 'chat.jsonl': JSON.stringify(mockData), - }, + 'session123.jsonl': JSON.stringify(mockData), }, 'project2': { - session456: { - 'chat.jsonl': JSON.stringify(mockData), - }, + 'session456.jsonl': JSON.stringify(mockData), }, }, }); @@ -2000,9 +2064,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'chat.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -2046,9 +2108,7 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'chat.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), }, }, }); @@ -2088,14 +2148,9 @@ invalid json line ]; await using fixture = await createFixture({ - projects: { - project1: Object.fromEntries( - sessions.map(s => [ - s.sessionId, - { 'chat.jsonl': JSON.stringify(s.data) }, - ]), - ), - }, + projects: Object.fromEntries( + sessions.map(s => [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]), + ), }); const result = await loadSessionData({ claudePath: fixture.path }); @@ -2137,8 +2192,8 @@ invalid json line projects: { project1: Object.fromEntries( sessions.map(s => [ - s.sessionId, - { 'chat.jsonl': JSON.stringify(s.data) }, + `${s.sessionId}.jsonl`, + JSON.stringify(s.data), ]), ), }, @@ -2185,10 +2240,9 @@ invalid json line await using fixture = await createFixture({ projects: { project1: Object.fromEntries( - sessions.map(s => [ - s.sessionId, - { 'chat.jsonl': JSON.stringify(s.data) }, - ]), + sessions.map((s) => { + return [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]; + }), ), }, }); @@ -2234,10 +2288,9 @@ invalid json line await using fixture = await createFixture({ projects: { project1: Object.fromEntries( - sessions.map(s => [ - s.sessionId, - { 'chat.jsonl': JSON.stringify(s.data) }, - ]), + sessions.map((s) => { + return [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]; + }), ), }, }); @@ -2270,9 +2323,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-old': { - 'session-old': { - 'usage.jsonl': `${JSON.stringify(oldData)}\n`, - }, + 'session-old.jsonl': `${JSON.stringify(oldData)}\n`, }, }, }); @@ -2287,7 +2338,7 @@ invalid json line }); it('should calculate cost for new schema with claude-sonnet-4-20250514', async () => { - // Use a well-known Claude model + // Use a well-known Claude model const modelName = createModelName('claude-sonnet-4-20250514'); const newData = { @@ -2306,9 +2357,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-new': { - 'session-new': { - 'usage.jsonl': `${JSON.stringify(newData)}\n`, - }, + 'session-new.jsonl': `${JSON.stringify(newData)}\n`, }, }, }); @@ -2327,7 +2376,7 @@ invalid json line }); it('should calculate cost for new schema with claude-opus-4-20250514', async () => { - // Use Claude 4 Opus model + // Use Claude 4 Opus model const modelName = createModelName('claude-opus-4-20250514'); const newData = { @@ -2346,9 +2395,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-opus': { - 'session-opus': { - 'usage.jsonl': `${JSON.stringify(newData)}\n`, - }, + 'session-opus.jsonl': `${JSON.stringify(newData)}\n`, }, }, }); @@ -2384,15 +2431,13 @@ invalid json line const data3 = { timestamp: '2024-01-17T12:00:00Z', message: { usage: { input_tokens: 300, output_tokens: 150 } }, - // No costUSD and no model - should be 0 cost + // No costUSD and no model - should be 0 cost }; await using fixture = await createFixture({ projects: { 'test-project-mixed': { - 'session-mixed': { - 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n${JSON.stringify(data3)}\n`, - }, + 'session-mixed.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n${JSON.stringify(data3)}\n`, }, }, }); @@ -2412,15 +2457,13 @@ invalid json line const data = { timestamp: '2024-01-18T10:00:00Z', message: { usage: { input_tokens: 500, output_tokens: 250 } }, - // No costUSD and no model + // No costUSD and no model }; await using fixture = await createFixture({ projects: { 'test-project-no-cost': { - 'session-no-cost': { - 'usage.jsonl': `${JSON.stringify(data)}\n`, - }, + 'session-no-cost.jsonl': `${JSON.stringify(data)}\n`, }, }, }); @@ -2453,12 +2496,8 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session1: { - 'usage.jsonl': JSON.stringify(session1Data), - }, - session2: { - 'usage.jsonl': JSON.stringify(session2Data), - }, + 'session1.jsonl': JSON.stringify(session1Data), + 'session2.jsonl': JSON.stringify(session2Data), }, }, }); @@ -2490,9 +2529,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-unknown': { - 'session-unknown': { - 'usage.jsonl': `${JSON.stringify(data)}\n`, - }, + 'session-unknown.jsonl': `${JSON.stringify(data)}\n`, }, }, }); @@ -2524,9 +2561,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-cache': { - 'session-cache': { - 'usage.jsonl': `${JSON.stringify(data)}\n`, - }, + 'session-cache.jsonl': `${JSON.stringify(data)}\n`, }, }, }); @@ -2561,9 +2596,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project-opus-cache': { - 'session-opus-cache': { - 'usage.jsonl': `${JSON.stringify(data)}\n`, - }, + 'session-opus-cache.jsonl': `${JSON.stringify(data)}\n`, }, }, }); @@ -2601,9 +2634,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, - }, + 'session.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, }, }, }); @@ -2630,9 +2661,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2663,15 +2692,13 @@ invalid json line usage: { input_tokens: 2000, output_tokens: 1000 }, model: createModelName('claude-4-sonnet-20250514'), }, - // No costUSD - should result in 0 cost + // No costUSD - should result in 0 cost }; await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, - }, + 'session.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, }, }, }); @@ -2698,9 +2725,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session1: { - 'usage.jsonl': JSON.stringify(sessionData), - }, + 'session1.jsonl': JSON.stringify(sessionData), }, }, }); @@ -2735,9 +2760,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2765,9 +2788,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2790,15 +2811,13 @@ invalid json line usage: { input_tokens: 1000, output_tokens: 500 }, model: createModelName('claude-4-sonnet-20250514'), }, - // No costUSD, so auto mode will need to calculate + // No costUSD, so auto mode will need to calculate }; await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2826,9 +2845,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2856,9 +2873,7 @@ invalid json line await using fixture = await createFixture({ projects: { 'test-project': { - session: { - 'usage.jsonl': JSON.stringify(data), - }, + 'session.jsonl': JSON.stringify(data), }, }, }); @@ -2896,7 +2911,11 @@ invalid json line describe('display mode', () => { it('should return costUSD when available', async () => { using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); + const result = await calculateCostForEntry( + mockUsageData, + 'display', + fetcher, + ); expect(result).toBe(0.05); }); @@ -2905,21 +2924,29 @@ invalid json line dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithoutCost, 'display', fetcher); + const result = await calculateCostForEntry( + dataWithoutCost, + 'display', + fetcher, + ); expect(result).toBe(0); }); it('should not use model pricing in display mode', async () => { - // Even with model pricing available, should use costUSD + // Even with model pricing available, should use costUSD using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); + const result = await calculateCostForEntry( + mockUsageData, + 'display', + fetcher, + ); expect(result).toBe(0.05); }); }); describe('calculate mode', () => { it('should calculate cost from tokens when model pricing available', async () => { - // Use the exact same structure as working integration tests + // Use the exact same structure as working integration tests const testData: UsageData = { timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { @@ -2932,7 +2959,11 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(testData, 'calculate', fetcher); + const result = await calculateCostForEntry( + testData, + 'calculate', + fetcher, + ); expect(result).toBeGreaterThan(0); }); @@ -2955,14 +2986,21 @@ invalid json line dataWithoutModel.message.model = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithoutModel, 'calculate', fetcher); + const result = await calculateCostForEntry( + dataWithoutModel, + 'calculate', + fetcher, + ); expect(result).toBe(0); }); it('should return 0 when model pricing not found', async () => { const dataWithUnknownModel = { ...mockUsageData, - message: { ...mockUsageData.message, model: createModelName('unknown-model') }, + message: { + ...mockUsageData.message, + model: createModelName('unknown-model'), + }, }; using fetcher = new PricingFetcher(); @@ -3000,7 +3038,11 @@ invalid json line describe('auto mode', () => { it('should use costUSD when available', async () => { using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); + const result = await calculateCostForEntry( + mockUsageData, + 'auto', + fetcher, + ); expect(result).toBe(0.05); }); @@ -3031,7 +3073,11 @@ invalid json line dataWithoutCostOrModel.message.model = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithoutCostOrModel, 'auto', fetcher); + const result = await calculateCostForEntry( + dataWithoutCostOrModel, + 'auto', + fetcher, + ); expect(result).toBe(0); }); @@ -3040,14 +3086,22 @@ invalid json line dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher); + const result = await calculateCostForEntry( + dataWithoutCost, + 'auto', + fetcher, + ); expect(result).toBe(0); }); it('should prefer costUSD over calculation even when both available', async () => { - // Both costUSD and model pricing available, should use costUSD + // Both costUSD and model pricing available, should use costUSD using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); + const result = await calculateCostForEntry( + mockUsageData, + 'auto', + fetcher, + ); expect(result).toBe(0.05); }); }); @@ -3069,21 +3123,33 @@ invalid json line dataWithZeroTokens.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithZeroTokens, 'calculate', fetcher); + const result = await calculateCostForEntry( + dataWithZeroTokens, + 'calculate', + fetcher, + ); expect(result).toBe(0); }); it('should handle costUSD of 0', async () => { const dataWithZeroCost = { ...mockUsageData, costUSD: 0 }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithZeroCost, 'display', fetcher); + const result = await calculateCostForEntry( + dataWithZeroCost, + 'display', + fetcher, + ); expect(result).toBe(0); }); it('should handle negative costUSD', async () => { const dataWithNegativeCost = { ...mockUsageData, costUSD: -0.01 }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithNegativeCost, 'display', fetcher); + const result = await calculateCostForEntry( + dataWithNegativeCost, + 'display', + fetcher, + ); expect(result).toBe(-0.01); }); }); @@ -3120,52 +3186,52 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': [ - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { - input_tokens: 1000, - output_tokens: 500, - }, - model: createModelName('claude-sonnet-4-20250514'), + 'session1.jsonl': [ + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { + input_tokens: 1000, + output_tokens: 500, }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), + model: createModelName('claude-sonnet-4-20250514'), }, - { - timestamp: laterTime.toISOString(), - message: { - id: 'msg2', - usage: { - input_tokens: 2000, - output_tokens: 1000, - }, - model: createModelName('claude-sonnet-4-20250514'), + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + { + timestamp: laterTime.toISOString(), + message: { + id: 'msg2', + usage: { + input_tokens: 2000, + output_tokens: 1000, }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), + model: createModelName('claude-sonnet-4-20250514'), }, - { - timestamp: muchLaterTime.toISOString(), - message: { - id: 'msg3', - usage: { - input_tokens: 1500, - output_tokens: 750, - }, - model: createModelName('claude-sonnet-4-20250514'), + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), + }, + { + timestamp: muchLaterTime.toISOString(), + message: { + id: 'msg3', + usage: { + input_tokens: 1500, + output_tokens: 750, }, - requestId: 'req3', - costUSD: 0.015, - version: createVersion('1.0.0'), + model: createModelName('claude-sonnet-4-20250514'), }, - ].map(data => JSON.stringify(data)).join('\n'), - }, + requestId: 'req3', + costUSD: 0.015, + version: createVersion('1.0.0'), + }, + ] + .map(data => JSON.stringify(data)) + .join('\n'), }, }, }); @@ -3174,7 +3240,10 @@ invalid json line expect(result.length).toBeGreaterThan(0); // Should have blocks expect(result[0]?.entries).toHaveLength(1); // First block has one entry // Total entries across all blocks should be 3 - const totalEntries = result.reduce((sum, block) => sum + block.entries.length, 0); + const totalEntries = result.reduce( + (sum, block) => sum + block.entries.length, + 0, + ); expect(totalEntries).toBe(3); }); @@ -3184,22 +3253,20 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': JSON.stringify({ - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { - input_tokens: 1000, - output_tokens: 500, - }, - model: createModelName('claude-sonnet-4-20250514'), + 'session1.jsonl': JSON.stringify({ + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { + input_tokens: 1000, + output_tokens: 500, }, - request: { id: 'req1' }, - costUSD: 0.01, - version: createVersion('1.0.0'), - }), - }, + model: createModelName('claude-sonnet-4-20250514'), + }, + request: { id: 'req1' }, + costUSD: 0.01, + version: createVersion('1.0.0'), + }), }, }, }); @@ -3229,43 +3296,43 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': [ - { - timestamp: date1.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), + 'session1.jsonl': [ + { + timestamp: date1.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, - { - timestamp: date2.toISOString(), - message: { - id: 'msg2', - usage: { input_tokens: 2000, output_tokens: 1000 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + { + timestamp: date2.toISOString(), + message: { + id: 'msg2', + usage: { input_tokens: 2000, output_tokens: 1000 }, + model: createModelName('claude-sonnet-4-20250514'), }, - { - timestamp: date3.toISOString(), - message: { - id: 'msg3', - usage: { input_tokens: 1500, output_tokens: 750 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req3', - costUSD: 0.015, - version: createVersion('1.0.0'), + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), + }, + { + timestamp: date3.toISOString(), + message: { + id: 'msg3', + usage: { input_tokens: 1500, output_tokens: 750 }, + model: createModelName('claude-sonnet-4-20250514'), }, - ].map(data => JSON.stringify(data)).join('\n'), - }, + requestId: 'req3', + costUSD: 0.015, + version: createVersion('1.0.0'), + }, + ] + .map(data => JSON.stringify(data)) + .join('\n'), }, }, }); @@ -3285,10 +3352,15 @@ invalid json line }); expect(untilResult.length).toBeGreaterThan(0); // The filter uses formatDate which converts to YYYYMMDD format for comparison - expect(untilResult.every((block) => { - const blockDateStr = block.startTime.toISOString().slice(0, 10).replace(/-/g, ''); - return blockDateStr <= '20240102'; - })).toBe(true); + expect( + untilResult.every((block) => { + const blockDateStr = block.startTime + .toISOString() + .slice(0, 10) + .replace(/-/g, ''); + return blockDateStr <= '20240102'; + }), + ).toBe(true); }); it('sorts blocks by order parameter', async () => { @@ -3298,32 +3370,32 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': [ - { - timestamp: date2.toISOString(), - message: { - id: 'msg2', - usage: { input_tokens: 2000, output_tokens: 1000 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), + 'session1.jsonl': [ + { + timestamp: date2.toISOString(), + message: { + id: 'msg2', + usage: { input_tokens: 2000, output_tokens: 1000 }, + model: createModelName('claude-sonnet-4-20250514'), }, - { - timestamp: date1.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), + }, + { + timestamp: date1.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, - ].map(data => JSON.stringify(data)).join('\n'), - }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + ] + .map(data => JSON.stringify(data)) + .join('\n'), }, }, }); @@ -3349,33 +3421,33 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': [ - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), + 'session1.jsonl': [ + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, - // Duplicate entry - should be filtered out - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + // Duplicate entry - should be filtered out + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, - ].map(data => JSON.stringify(data)).join('\n'), - }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + ] + .map(data => JSON.stringify(data)) + .join('\n'), }, }, }); @@ -3391,23 +3463,21 @@ invalid json line await using fixture = await createFixture({ projects: { project1: { - session1: { - 'conversation1.jsonl': [ - 'invalid json line', - JSON.stringify({ - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }), - 'another invalid line', - ].join('\n'), - }, + 'session1.jsonl': [ + 'invalid json line', + JSON.stringify({ + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }), + 'another invalid line', + ].join('\n'), }, }, }); @@ -3476,16 +3546,27 @@ if (import.meta.vitest != null) { describe('getEarliestTimestamp', () => { it('should extract earliest timestamp from JSONL file', async () => { const content = [ - JSON.stringify({ timestamp: '2025-01-15T12:00:00Z', message: { usage: {} } }), - JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }), - JSON.stringify({ timestamp: '2025-01-12T11:00:00Z', message: { usage: {} } }), + JSON.stringify({ + timestamp: '2025-01-15T12:00:00Z', + message: { usage: {} }, + }), + JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { usage: {} }, + }), + JSON.stringify({ + timestamp: '2025-01-12T11:00:00Z', + message: { usage: {} }, + }), ].join('\n'); await using fixture = await createFixture({ 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); + const timestamp = await getEarliestTimestamp( + fixture.getPath('test.jsonl'), + ); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); @@ -3499,14 +3580,19 @@ if (import.meta.vitest != null) { 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); + const timestamp = await getEarliestTimestamp( + fixture.getPath('test.jsonl'), + ); expect(timestamp).toBeNull(); }); it('should skip invalid JSON lines', async () => { const content = [ 'invalid json', - JSON.stringify({ timestamp: '2025-01-10T10:00:00Z', message: { usage: {} } }), + JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { usage: {} }, + }), '{ broken: json', ].join('\n'); @@ -3514,7 +3600,9 @@ if (import.meta.vitest != null) { 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); + const timestamp = await getEarliestTimestamp( + fixture.getPath('test.jsonl'), + ); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); }); @@ -3558,34 +3646,30 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file1.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + 'session1.jsonl': JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - }, - session2: { - 'file2.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), + 'session2.jsonl': JSON.stringify({ + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), }, }, }); @@ -3605,30 +3689,32 @@ if (import.meta.vitest != null) { it('should process files in chronological order', async () => { await using fixture = await createFixture({ projects: { - 'newer.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 200, - output_tokens: 100, + project1: { + 'newer.jsonl': JSON.stringify({ + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 200, + output_tokens: 100, + }, }, - }, - requestId: 'req_456', - costUSD: 0.002, - }), - 'older.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, + requestId: 'req_456', + costUSD: 0.002, + }), + 'older.jsonl': JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, + }, }, - }, - requestId: 'req_456', - costUSD: 0.001, - }), + requestId: 'req_456', + costUSD: 0.001, + }), + }, }, }); @@ -3650,34 +3736,30 @@ if (import.meta.vitest != null) { await using fixture = await createFixture({ projects: { project1: { - session1: { - 'file1.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + 'session1.jsonl': JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - }, - session2: { - 'file2.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), + 'session2.jsonl': JSON.stringify({ + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), }, }, }); @@ -3700,7 +3782,7 @@ if (import.meta.vitest != null) { expect(session2.outputTokens).toBe(0); } else { - // It's also valid for session2 to not be included if it has no entries + // It's also valid for session2 to not be included if it has no entries expect(sessions.length).toBe(1); } }); @@ -3727,7 +3809,9 @@ if (import.meta.vitest != null) { const normalizedFixture1 = path.resolve(fixture1.path); const normalizedFixture2 = path.resolve(fixture2.path); - expect(paths).toEqual(expect.arrayContaining([normalizedFixture1, normalizedFixture2])); + expect(paths).toEqual( + expect.arrayContaining([normalizedFixture1, normalizedFixture2]), + ); // Environment paths should be prioritized expect(paths[0]).toBe(normalizedFixture1); expect(paths[1]).toBe(normalizedFixture2); @@ -3776,13 +3860,11 @@ if (import.meta.vitest != null) { await using fixture1 = await createFixture({ projects: { project1: { - session1: { - 'usage.jsonl': JSON.stringify({ - timestamp: '2024-01-01T12:00:00Z', - message: { usage: { input_tokens: 100, output_tokens: 50 } }, - costUSD: 0.01, - }), - }, + 'session1.jsonl': JSON.stringify({ + timestamp: '2024-01-01T12:00:00Z', + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }), }, }, }); @@ -3790,13 +3872,11 @@ if (import.meta.vitest != null) { await using fixture2 = await createFixture({ projects: { project2: { - session2: { - 'usage.jsonl': JSON.stringify({ - timestamp: '2024-01-01T13:00:00Z', - message: { usage: { input_tokens: 200, output_tokens: 100 } }, - costUSD: 0.02, - }), - }, + 'session2.jsonl': JSON.stringify({ + timestamp: '2024-01-01T13:00:00Z', + message: { usage: { input_tokens: 200, output_tokens: 100 } }, + costUSD: 0.02, + }), }, }, }); @@ -3877,7 +3957,9 @@ if (import.meta.vitest != null) { const results = await globUsageFiles(paths); expect(results).toHaveLength(3); - expect(results.every(r => r.baseDir.includes('path1/projects'))).toBe(true); + expect(results.every(r => r.baseDir.includes('path1/projects'))).toBe( + true, + ); }); }); } From 74763994144471d3a8d497d5018bafb0e7bcd424 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Fri, 8 Aug 2025 21:48:33 -0400 Subject: [PATCH 07/14] refactor(data-loader): extract test fixtures to dedicated module Improves test organization by moving fixture creation logic to a dedicated module. The change extracts repeated test fixture setup code into specialized helper functions in a new `_fixtures.ts` file, which: - Reduces code duplication across test cases - Makes tests more readable by abstracting complex fixture setup - Enables dynamic importing of fixture utilities only when needed - Improves maintainability by centralizing test data creation This approach also avoids top-level await by using dynamic imports within individual test suites, which helps with module initialization performance. --- src/_fixtures.ts | 245 ++++++++++ src/data-loader.ts | 1102 +++++++++++++++++++------------------------- 2 files changed, 718 insertions(+), 629 deletions(-) create mode 100644 src/_fixtures.ts diff --git a/src/_fixtures.ts b/src/_fixtures.ts new file mode 100644 index 00000000..d9bf6f86 --- /dev/null +++ b/src/_fixtures.ts @@ -0,0 +1,245 @@ +/** + * @fileoverview Test fixture factory functions for data-loader tests + * + * This module provides factory functions for creating test fixtures + * used in data-loader.ts tests. Each function returns a function that + * when called, creates the fixture data structure using fs-fixture. + * + * @module _fixtures + */ + +import type { FsFixture } from 'fs-fixture'; +import { createFixture } from 'fs-fixture'; +import { + createISOTimestamp, + createMessageId, + createModelName, + createRequestId, + createVersion, +} from './_types.ts'; + +type UsageData = { + timestamp: string; + message: { + usage: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + model?: string; + id?: string; + }; + version?: string; + costUSD?: number; + request?: { + id: string; + }; +}; + +/** + * Creates an empty projects fixture + */ +export async function createEmptyProjectsFixture(): Promise { + return createFixture({ + projects: {}, + }); +} + +/** + * Creates a fixture with basic usage data for testing daily aggregation + */ +export async function createDailyUsageFixture(data: { + mockData1?: UsageData[]; + mockData2?: UsageData; + project?: string; + sessions?: Record; +}): Promise { + const project = data.project ?? 'project1'; + const sessions = data.sessions ?? {}; + + if (data.mockData1 != null && data.mockData2 != null) { + sessions['session1.jsonl'] = data.mockData1 + .map(d => JSON.stringify(d)) + .join('\n'); + sessions['session2.jsonl'] = JSON.stringify(data.mockData2); + } + + const projects: Record> = {}; + projects[project] = Object.fromEntries( + Object.entries(sessions).map(([filename, sessionData]) => { + if (typeof sessionData === 'string') { + return [filename, sessionData]; + } + if (Array.isArray(sessionData)) { + return [filename, sessionData.map(d => JSON.stringify(d)).join('\n')]; + } + return [filename, JSON.stringify(sessionData)]; + }), + ); + + return createFixture({ projects }); +} + +/** + * Creates a fixture with session data + */ +export async function createSessionFixture(sessions: Array<{ + project?: string; + sessionId: string; + data: UsageData | UsageData[]; +}>): Promise { + const projects: Record> = {}; + + for (const session of sessions) { + const project = session.project ?? 'project1'; + if (projects[project] == null) { + projects[project] = {}; + } + + const filename = `${session.sessionId}.jsonl`; + if (Array.isArray(session.data)) { + projects[project][filename] = session.data + .map(d => JSON.stringify(d)) + .join('\n'); + } + else { + projects[project][filename] = JSON.stringify(session.data); + } + } + + return createFixture({ projects }); +} + +/** + * Creates a fixture with multiple projects + */ +export async function createMultiProjectFixture(projectData: Record>): Promise { + const projects: Record> = {}; + + for (const [projectPath, sessions] of Object.entries(projectData)) { + projects[projectPath] = Object.fromEntries( + Object.entries(sessions).map(([filename, data]) => { + if (typeof data === 'string') { + return [filename, data]; + } + return [filename, JSON.stringify(data)]; + }), + ); + } + + return createFixture({ projects }); +} + +/** + * Creates a fixture with raw JSONL content (including invalid lines) + */ +export async function createRawJSONLFixture(project: string, sessionFile: string, content: string): Promise { + return createFixture({ + projects: { + [project]: { + [sessionFile]: content.trim(), + }, + }, + }); +} + +/** + * Creates a fixture for testing file timestamp operations + */ +export async function createTimestampTestFixture(files: Record>): Promise { + return createFixture(files); +} + +/** + * Common test data patterns + */ +export const testData = { + basicUsageData: (timestamp: string, inputTokens: number, outputTokens: number, costUSD?: number): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { usage: { input_tokens: inputTokens, output_tokens: outputTokens } }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithCache: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + cacheCreation: number, + cacheRead: number, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreation, + cache_read_input_tokens: cacheRead, + }, + }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithModel: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + model: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + model: createModelName(model), + }, + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithIds: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + messageId: string, + requestId?: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { + id: createMessageId(messageId), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + }, + ...(requestId != null && { request: { id: createRequestId(requestId) } }), + ...(costUSD !== undefined && { costUSD }), + }), + + usageDataWithVersion: ( + timestamp: string, + inputTokens: number, + outputTokens: number, + version: string, + costUSD?: number, + ): UsageData => ({ + timestamp: createISOTimestamp(timestamp), + message: { usage: { input_tokens: inputTokens, output_tokens: outputTokens } }, + version: createVersion(version), + ...(costUSD !== undefined && { costUSD }), + }), + + sessionBlockData: ( + timestamp: string, + messageId: string, + inputTokens: number, + outputTokens: number, + model: string, + costUSD?: number, + ) => ({ + timestamp, + message: { + id: messageId, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + model: createModelName(model), + }, + ...(costUSD !== undefined && { costUSD }), + }), +}; diff --git a/src/data-loader.ts b/src/data-loader.ts index 694373b2..3771a7c9 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -24,7 +24,6 @@ import { unreachable } from '@core/errorutil'; import { Result } from '@praha/byethrow'; import { groupBy, uniq } from 'es-toolkit'; // TODO: after node20 is deprecated, switch to native Object.groupBy import { sort } from 'fast-sort'; -import { createFixture } from 'fs-fixture'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import { z } from 'zod'; @@ -36,6 +35,12 @@ import { USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR, } from './_consts.ts'; +import { + createDailyUsageFixture, + createEmptyProjectsFixture, + createMultiProjectFixture, + createTimestampTestFixture, +} from './_fixtures.ts'; import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, @@ -1338,6 +1343,8 @@ export async function loadSessionBlockData( } if (import.meta.vitest != null) { + // Dynamic imports will be loaded within individual test suites to avoid top-level await + describe('formatDate', () => { it('formats UTC timestamp to local date', () => { // Test with UTC timestamps - results depend on local timezone @@ -1379,9 +1386,7 @@ if (import.meta.vitest != null) { describe('loadDailyUsageData', () => { it('returns empty array when no files found', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); const result = await loadDailyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); @@ -1408,15 +1413,9 @@ if (import.meta.vitest != null) { costUSD: 0.03, }; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData1 - .map(d => JSON.stringify(d)) - .join('\n'), - 'session2.jsonl': JSON.stringify(mockData2), - }, - }, + await using fixture = await createDailyUsageFixture({ + mockData1, + mockData2, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1442,12 +1441,8 @@ if (import.meta.vitest != null) { costUSD: 0.01, }; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': JSON.stringify(mockData), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1475,12 +1470,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1513,12 +1504,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1547,12 +1534,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1585,12 +1568,8 @@ if (import.meta.vitest != null) { }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadDailyUsageData({ @@ -1605,6 +1584,7 @@ if (import.meta.vitest != null) { }); it('handles invalid JSON lines gracefully', async () => { + const { createRawJSONLFixture } = await import('./_fixtures.ts'); const mockData = ` {"timestamp":"2024-01-01T12:00:00Z","message":{"usage":{"input_tokens":100,"output_tokens":50}},"costUSD":0.01} invalid json line @@ -1613,13 +1593,7 @@ invalid json line {"timestamp":"2024-01-01T18:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData, - }, - }, - }); + await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1630,6 +1604,7 @@ invalid json line }); it('skips data without required fields', async () => { + const { createRawJSONLFixture } = await import('./_fixtures.ts'); const mockData = ` {"timestamp":"2024-01-01T12:00:00Z","message":{"usage":{"input_tokens":100,"output_tokens":50}},"costUSD":0.01} {"timestamp":"2024-01-01T14:00:00Z","message":{"usage":{}}} @@ -1639,13 +1614,7 @@ invalid json line {"timestamp":"2024-01-01T22:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData, - }, - }, - }); + await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1676,12 +1645,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); @@ -1729,9 +1694,7 @@ invalid json line }); it('handles empty data', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); expect(result).toEqual([]); @@ -1751,12 +1714,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); @@ -1807,12 +1766,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); @@ -1845,12 +1800,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadMonthlyUsageData({ @@ -1886,12 +1837,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); // Descending order (default) @@ -1930,11 +1877,9 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + await using fixture = await createDailyUsageFixture({ + sessions: { + 'session1.jsonl': mockData, }, }); @@ -1978,12 +1923,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadMonthlyUsageData({ claudePath: fixture.path }); @@ -1996,9 +1937,7 @@ invalid json line describe('loadSessionData', () => { it('returns empty array when no files found', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); const result = await loadSessionData({ claudePath: fixture.path }); expect(result).toEqual([]); @@ -2011,14 +1950,12 @@ invalid json line costUSD: 0.01, }; - await using fixture = await createFixture({ - projects: { - 'project1/subfolder': { - 'session123.jsonl': JSON.stringify(mockData), - }, - 'project2': { - 'session456.jsonl': JSON.stringify(mockData), - }, + await using fixture = await createMultiProjectFixture({ + 'project1/subfolder': { + 'session123.jsonl': mockData, + }, + 'project2': { + 'session456.jsonl': mockData, }, }); @@ -2061,11 +1998,9 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, + await using fixture = await createDailyUsageFixture({ + sessions: { + 'session1.jsonl': mockData, }, }); @@ -2105,12 +2040,8 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': mockData.map(d => JSON.stringify(d)).join('\n'), - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadSessionData({ claudePath: fixture.path }); @@ -2120,6 +2051,7 @@ invalid json line }); it('sorts by last activity descending', async () => { + const { createSessionFixture } = await import('./_fixtures.ts'); const sessions = [ { sessionId: 'session1', @@ -2147,11 +2079,9 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: Object.fromEntries( - sessions.map(s => [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]), - ), - }); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path }); @@ -2161,6 +2091,7 @@ invalid json line }); it('sorts by last activity ascending when order is \'asc\'', async () => { + const { createSessionFixture } = await import('./_fixtures.ts'); const sessions = [ { sessionId: 'session1', @@ -2188,16 +2119,7 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: Object.fromEntries( - sessions.map(s => [ - `${s.sessionId}.jsonl`, - JSON.stringify(s.data), - ]), - ), - }, - }); + await using fixture = await createSessionFixture(sessions); const result = await loadSessionData({ claudePath: fixture.path, @@ -2210,6 +2132,7 @@ invalid json line }); it('sorts by last activity descending when order is \'desc\'', async () => { + const { createSessionFixture } = await import('./_fixtures.ts'); const sessions = [ { sessionId: 'session1', @@ -2237,15 +2160,9 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: Object.fromEntries( - sessions.map((s) => { - return [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]; - }), - ), - }, - }); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path, @@ -2258,6 +2175,7 @@ invalid json line }); it('filters by date range based on last activity', async () => { + const { createSessionFixture } = await import('./_fixtures.ts'); const sessions = [ { sessionId: 'session1', @@ -2285,15 +2203,9 @@ invalid json line }, ]; - await using fixture = await createFixture({ - projects: { - project1: Object.fromEntries( - sessions.map((s) => { - return [`${s.sessionId}.jsonl`, JSON.stringify(s.data)]; - }), - ), - }, - }); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path, @@ -2320,12 +2232,9 @@ invalid json line costUSD: 0.05, // Pre-calculated cost }; - await using fixture = await createFixture({ - projects: { - 'test-project-old': { - 'session-old.jsonl': `${JSON.stringify(oldData)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project-old', + sessions: { 'session-old.jsonl': oldData }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2354,12 +2263,9 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project-new': { - 'session-new.jsonl': `${JSON.stringify(newData)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project-new', + sessions: { 'session-new.jsonl': newData }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2392,12 +2298,9 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project-opus': { - 'session-opus.jsonl': `${JSON.stringify(newData)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project-opus', + sessions: { 'session-opus.jsonl': newData }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2434,12 +2337,9 @@ invalid json line // No costUSD and no model - should be 0 cost }; - await using fixture = await createFixture({ - projects: { - 'test-project-mixed': { - 'session-mixed.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n${JSON.stringify(data3)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project-mixed', + sessions: { 'session-mixed.jsonl': [data1, data2, data3] }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2460,12 +2360,8 @@ invalid json line // No costUSD and no model }; - await using fixture = await createFixture({ - projects: { - 'test-project-no-cost': { - 'session-no-cost.jsonl': `${JSON.stringify(data)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session-no-cost.jsonl': data }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2479,6 +2375,7 @@ invalid json line describe('loadSessionData with mixed schemas', () => { it('should handle mixed cost sources in different sessions', async () => { + const { createSessionFixture } = await import('./_fixtures.ts'); const session1Data = { timestamp: '2024-01-15T10:00:00Z', message: { usage: { input_tokens: 1000, output_tokens: 500 } }, @@ -2493,14 +2390,18 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session1.jsonl': JSON.stringify(session1Data), - 'session2.jsonl': JSON.stringify(session2Data), - }, + await using fixture = await createSessionFixture([ + { + project: 'test-project', + sessionId: 'session1', + data: session1Data, }, - }); + { + project: 'test-project', + sessionId: 'session2', + data: session2Data, + }, + ]); const results = await loadSessionData({ claudePath: fixture.path }); @@ -2526,12 +2427,8 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project-unknown': { - 'session-unknown.jsonl': `${JSON.stringify(data)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session-unknown.jsonl': data }, }); const results = await loadSessionData({ claudePath: fixture.path }); @@ -2558,12 +2455,8 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project-cache': { - 'session-cache.jsonl': `${JSON.stringify(data)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session-cache.jsonl': data }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2593,12 +2486,8 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project-opus-cache': { - 'session-opus-cache.jsonl': `${JSON.stringify(data)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + sessions: { 'session-opus-cache.jsonl': data }, }); const results = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2631,12 +2520,9 @@ invalid json line }, }; - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': [data1, data2] }, }); const results = await loadDailyUsageData({ @@ -2658,12 +2544,9 @@ invalid json line costUSD: 99.99, // This should be ignored }; - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': data }, }); const results = await loadDailyUsageData({ @@ -2677,30 +2560,27 @@ invalid json line }); it('display mode: always uses costUSD, even if undefined', async () => { - const data1 = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, - costUSD: 0.05, - }; + const { testData } = await import('./_fixtures.ts'); + const data1 = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); - const data2 = { - timestamp: '2024-01-01T11:00:00Z', - message: { - usage: { input_tokens: 2000, output_tokens: 1000 }, - model: createModelName('claude-4-sonnet-20250514'), - }, + const data2 = testData.usageDataWithModel( + '2024-01-01T11:00:00Z', + 2000, + 1000, + 'claude-4-sonnet-20250514', // No costUSD - should result in 0 cost - }; + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': `${JSON.stringify(data1)}\n${JSON.stringify(data2)}\n`, - }, - }, + await using fixture = await createDailyUsageFixture({ + mockData1: [data1, data2], + project: 'test-project', + sessions: { 'session.jsonl': [data1, data2] }, }); const results = await loadDailyUsageData({ @@ -2713,22 +2593,22 @@ invalid json line }); it('mode works with session data', async () => { - const sessionData = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, - costUSD: 99.99, - }; + const { testData, createSessionFixture } = await import('./_fixtures.ts'); + const sessionData = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 99.99, + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session1.jsonl': JSON.stringify(sessionData), - }, + await using fixture = await createSessionFixture([ + { + project: 'test-project', + sessionId: 'session1', + data: sessionData, }, - }); + ]); // Test calculate mode const calculateResults = await loadSessionData({ @@ -2748,21 +2628,18 @@ invalid json line describe('pricing data fetching optimization', () => { it('should not require model pricing when mode is display', async () => { - const data = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, - costUSD: 0.05, - }; + const { testData } = await import('./_fixtures.ts'); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': data }, }); // In display mode, only pre-calculated costUSD should be used @@ -2776,21 +2653,18 @@ invalid json line }); it('should fetch pricing data when mode is calculate', async () => { - const data = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, - costUSD: 0.05, - }; + const { testData } = await import('./_fixtures.ts'); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': data }, }); // This should fetch pricing data (will call real fetch) @@ -2805,21 +2679,18 @@ invalid json line }); it('should fetch pricing data when mode is auto', async () => { - const data = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, + const { testData } = await import('./_fixtures.ts'); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', // No costUSD, so auto mode will need to calculate - }; + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': data }, }); // This should fetch pricing data (will call real fetch) @@ -2833,22 +2704,22 @@ invalid json line }); it('session data should not require model pricing when mode is display', async () => { - const data = { - timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), - message: { - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-4-sonnet-20250514'), - }, - costUSD: 0.05, - }; + const { testData, createSessionFixture } = await import('./_fixtures.ts'); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, + await using fixture = await createSessionFixture([ + { + project: 'test-project', + sessionId: 'session', + data, }, - }); + ]); // In display mode, only pre-calculated costUSD should be used const results = await loadSessionData({ @@ -2865,17 +2736,14 @@ invalid json line timestamp: createISOTimestamp('2024-01-01T10:00:00Z'), message: { usage: { input_tokens: 1000, output_tokens: 500 }, - model: 'some-unknown-model', + model: 'some-unknown-model', // Intentionally unknown model for test }, costUSD: 0.05, }; - await using fixture = await createFixture({ - projects: { - 'test-project': { - 'session.jsonl': JSON.stringify(data), - }, - }, + await using fixture = await createDailyUsageFixture({ + project: 'test-project', + sessions: { 'session.jsonl': data }, }); // This test verifies that display mode doesn't try to fetch pricing @@ -3156,7 +3024,7 @@ invalid json line describe('offline mode', () => { it('should pass offline flag through loadDailyUsageData', async () => { - await using fixture = await createFixture({ projects: {} }); + await using fixture = await createEmptyProjectsFixture(); // This test verifies that the offline flag is properly passed through // We can't easily mock the internal behavior, but we can verify it doesn't throw const result = await loadDailyUsageData({ @@ -3173,7 +3041,7 @@ invalid json line describe('loadSessionBlockData', () => { it('returns empty array when no files found', async () => { - await using fixture = await createFixture({ projects: {} }); + await using fixture = await createEmptyProjectsFixture(); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toEqual([]); }); @@ -3183,57 +3051,53 @@ invalid json line const laterTime = new Date(now.getTime() + 1 * 60 * 60 * 1000); // 1 hour later const muchLaterTime = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours later - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': [ - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { - input_tokens: 1000, - output_tokens: 500, - }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }, - { - timestamp: laterTime.toISOString(), - message: { - id: 'msg2', - usage: { - input_tokens: 2000, - output_tokens: 1000, - }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), - }, - { - timestamp: muchLaterTime.toISOString(), - message: { - id: 'msg3', - usage: { - input_tokens: 1500, - output_tokens: 750, - }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req3', - costUSD: 0.015, - version: createVersion('1.0.0'), - }, - ] - .map(data => JSON.stringify(data)) - .join('\n'), + const mockData = [ + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { + input_tokens: 1000, + output_tokens: 500, + }, + model: createModelName('claude-sonnet-4-20250514'), }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), }, + { + timestamp: laterTime.toISOString(), + message: { + id: 'msg2', + usage: { + input_tokens: 2000, + output_tokens: 1000, + }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), + }, + { + timestamp: muchLaterTime.toISOString(), + message: { + id: 'msg3', + usage: { + input_tokens: 1500, + output_tokens: 750, + }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req3', + costUSD: 0.015, + version: createVersion('1.0.0'), + }, + ]; + + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); const result = await loadSessionBlockData({ claudePath: fixture.path }); @@ -3250,25 +3114,23 @@ invalid json line it('handles cost calculation modes correctly', async () => { const now = new Date('2024-01-01T10:00:00Z'); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': JSON.stringify({ - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { - input_tokens: 1000, - output_tokens: 500, - }, - model: createModelName('claude-sonnet-4-20250514'), - }, - request: { id: 'req1' }, - costUSD: 0.01, - version: createVersion('1.0.0'), - }), + const mockData = { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { + input_tokens: 1000, + output_tokens: 500, }, + model: createModelName('claude-sonnet-4-20250514'), }, + request: { id: 'req1' }, + costUSD: 0.01, + version: createVersion('1.0.0'), + }; + + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); // Test display mode @@ -3293,48 +3155,44 @@ invalid json line const date2 = new Date('2024-01-02T10:00:00Z'); const date3 = new Date('2024-01-03T10:00:00Z'); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': [ - { - timestamp: date1.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }, - { - timestamp: date2.toISOString(), - message: { - id: 'msg2', - usage: { input_tokens: 2000, output_tokens: 1000 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), - }, - { - timestamp: date3.toISOString(), - message: { - id: 'msg3', - usage: { input_tokens: 1500, output_tokens: 750 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req3', - costUSD: 0.015, - version: createVersion('1.0.0'), - }, - ] - .map(data => JSON.stringify(data)) - .join('\n'), + const mockData = [ + { + timestamp: date1.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + { + timestamp: date2.toISOString(), + message: { + id: 'msg2', + usage: { input_tokens: 2000, output_tokens: 1000 }, + model: createModelName('claude-sonnet-4-20250514'), }, + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), }, + { + timestamp: date3.toISOString(), + message: { + id: 'msg3', + usage: { input_tokens: 1500, output_tokens: 750 }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req3', + costUSD: 0.015, + version: createVersion('1.0.0'), + }, + ]; + + await using fixture = await createDailyUsageFixture({ + sessions: { 'session1.jsonl': mockData }, }); // Test filtering with since parameter @@ -3367,36 +3225,34 @@ invalid json line const date1 = new Date('2024-01-01T10:00:00Z'); const date2 = new Date('2024-01-02T10:00:00Z'); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': [ - { - timestamp: date2.toISOString(), - message: { - id: 'msg2', - usage: { input_tokens: 2000, output_tokens: 1000 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req2', - costUSD: 0.02, - version: createVersion('1.0.0'), - }, - { - timestamp: date1.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }, - ] - .map(data => JSON.stringify(data)) - .join('\n'), + const mockData = [ + { + timestamp: date2.toISOString(), + message: { + id: 'msg2', + usage: { input_tokens: 2000, output_tokens: 1000 }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req2', + costUSD: 0.02, + version: createVersion('1.0.0'), + }, + { + timestamp: date1.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + ]; + + await using fixture = await createDailyUsageFixture({ + sessions: { + 'session1.jsonl': mockData, }, }); @@ -3418,37 +3274,35 @@ invalid json line it('handles deduplication correctly', async () => { const now = new Date('2024-01-01T10:00:00Z'); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': [ - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }, - // Duplicate entry - should be filtered out - { - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }, - ] - .map(data => JSON.stringify(data)) - .join('\n'), + const mockData = [ + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), + }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + // Duplicate entry - should be filtered out + { + timestamp: now.toISOString(), + message: { + id: 'msg1', + usage: { input_tokens: 1000, output_tokens: 500 }, + model: createModelName('claude-sonnet-4-20250514'), }, + requestId: 'req1', + costUSD: 0.01, + version: createVersion('1.0.0'), + }, + ]; + + await using fixture = await createDailyUsageFixture({ + sessions: { + 'session1.jsonl': mockData, }, }); @@ -3458,29 +3312,30 @@ invalid json line }); it('handles invalid JSON lines gracefully', async () => { + const { testData, createRawJSONLFixture } = await import('./_fixtures.ts'); const now = new Date('2024-01-01T10:00:00Z'); - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': [ - 'invalid json line', - JSON.stringify({ - timestamp: now.toISOString(), - message: { - id: 'msg1', - usage: { input_tokens: 1000, output_tokens: 500 }, - model: createModelName('claude-sonnet-4-20250514'), - }, - requestId: 'req1', - costUSD: 0.01, - version: createVersion('1.0.0'), - }), - 'another invalid line', - ].join('\n'), - }, - }, - }); + const validData = testData.usageDataWithIds( + now.toISOString(), + 1000, + 500, + 'msg1', + 'req1', + 0.01, + ); + validData.version = createVersion('1.0.0'); + + const rawContent = [ + 'invalid json line', + JSON.stringify(validData), + 'another invalid line', + ].join('\n'); + + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + rawContent, + ); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toHaveLength(1); @@ -3560,7 +3415,7 @@ if (import.meta.vitest != null) { }), ].join('\n'); - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'test.jsonl': content, }); @@ -3576,7 +3431,7 @@ if (import.meta.vitest != null) { JSON.stringify({ data: 'no timestamp' }), ].join('\n'); - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'test.jsonl': content, }); @@ -3596,7 +3451,7 @@ if (import.meta.vitest != null) { '{ broken: json', ].join('\n'); - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'test.jsonl': content, }); @@ -3609,7 +3464,7 @@ if (import.meta.vitest != null) { describe('sortFilesByTimestamp', () => { it('should sort files by earliest timestamp', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }), 'file2.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }), 'file3.jsonl': JSON.stringify({ timestamp: '2025-01-12T10:00:00Z' }), @@ -3625,7 +3480,7 @@ if (import.meta.vitest != null) { }); it('should place files without timestamps at the end', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'file1.jsonl': JSON.stringify({ timestamp: '2025-01-15T10:00:00Z' }), 'file2.jsonl': JSON.stringify({ no_timestamp: true }), 'file3.jsonl': JSON.stringify({ timestamp: '2025-01-10T10:00:00Z' }), @@ -3643,34 +3498,32 @@ if (import.meta.vitest != null) { describe('loadDailyUsageData with deduplication', () => { it('should deduplicate entries with same message and request IDs', async () => { - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + await using fixture = await createMultiProjectFixture({ + project1: { + 'session1.jsonl': JSON.stringify({ + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - 'session2.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), + 'session2.jsonl': JSON.stringify({ + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, - requestId: 'req_456', - costUSD: 0.001, - }), - }, + }, + requestId: 'req_456', + costUSD: 0.001, + }), }, }); @@ -3687,35 +3540,36 @@ if (import.meta.vitest != null) { }); it('should process files in chronological order', async () => { - await using fixture = await createFixture({ - projects: { - project1: { - 'newer.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 200, - output_tokens: 100, - }, - }, - requestId: 'req_456', - costUSD: 0.002, - }), - 'older.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - requestId: 'req_456', - costUSD: 0.001, - }), + const newerData = { + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 200, + output_tokens: 100, + }, + }, + requestId: 'req_456', + costUSD: 0.002, + }; + const olderData = { + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, }, + requestId: 'req_456', + costUSD: 0.001, + }; + + await using fixture = await createDailyUsageFixture({ + sessions: { + 'newer.jsonl': newerData, + 'older.jsonl': olderData, + }, }); const data = await loadDailyUsageData({ @@ -3733,35 +3587,36 @@ if (import.meta.vitest != null) { describe('loadSessionData with deduplication', () => { it('should deduplicate entries across sessions', async () => { - await using fixture = await createFixture({ - projects: { - project1: { - 'session1.jsonl': JSON.stringify({ - timestamp: '2025-01-10T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - requestId: 'req_456', - costUSD: 0.001, - }), - 'session2.jsonl': JSON.stringify({ - timestamp: '2025-01-15T10:00:00Z', - message: { - id: 'msg_123', - usage: { - input_tokens: 100, - output_tokens: 50, - }, - }, - requestId: 'req_456', - costUSD: 0.001, - }), + const session1Data = { + timestamp: '2025-01-10T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }, + requestId: 'req_456', + costUSD: 0.001, + }; + const session2Data = { + timestamp: '2025-01-15T10:00:00Z', + message: { + id: 'msg_123', + usage: { + input_tokens: 100, + output_tokens: 50, }, }, + requestId: 'req_456', + costUSD: 0.001, + }; + + await using fixture = await createDailyUsageFixture({ + sessions: { + 'session1.jsonl': session1Data, + 'session2.jsonl': session2Data, + }, }); const sessions = await loadSessionData({ @@ -3796,12 +3651,8 @@ if (import.meta.vitest != null) { }); it('returns paths from environment variable when set', async () => { - await using fixture1 = await createFixture({ - projects: {}, - }); - await using fixture2 = await createFixture({ - projects: {}, - }); + await using fixture1 = await createEmptyProjectsFixture(); + await using fixture2 = await createEmptyProjectsFixture(); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`); @@ -3818,9 +3669,7 @@ if (import.meta.vitest != null) { }); it('filters out non-existent paths from environment variable', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},/nonexistent/path`); @@ -3831,9 +3680,7 @@ if (import.meta.vitest != null) { }); it('removes duplicates from combined paths', async () => { - await using fixture = await createFixture({ - projects: {}, - }); + await using fixture = await createEmptyProjectsFixture(); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture.path},${fixture.path}`); @@ -3857,28 +3704,25 @@ if (import.meta.vitest != null) { describe('multiple paths integration', () => { it('loadDailyUsageData aggregates data from multiple paths', async () => { - await using fixture1 = await createFixture({ - projects: { - project1: { - 'session1.jsonl': JSON.stringify({ - timestamp: '2024-01-01T12:00:00Z', - message: { usage: { input_tokens: 100, output_tokens: 50 } }, - costUSD: 0.01, - }), - }, - }, + const fixture1Data = { + timestamp: '2024-01-01T12:00:00Z', + message: { usage: { input_tokens: 100, output_tokens: 50 } }, + costUSD: 0.01, + }; + const fixture2Data = { + timestamp: '2024-01-01T13:00:00Z', + message: { usage: { input_tokens: 200, output_tokens: 100 } }, + costUSD: 0.02, + }; + + await using fixture1 = await createDailyUsageFixture({ + project: 'project1', + sessions: { 'session1.jsonl': fixture1Data }, }); - await using fixture2 = await createFixture({ - projects: { - project2: { - 'session2.jsonl': JSON.stringify({ - timestamp: '2024-01-01T13:00:00Z', - message: { usage: { input_tokens: 200, output_tokens: 100 } }, - costUSD: 0.02, - }), - }, - }, + await using fixture2 = await createDailyUsageFixture({ + project: 'project2', + sessions: { 'session2.jsonl': fixture2Data }, }); vi.stubEnv('CLAUDE_CONFIG_DIR', `${fixture1.path},${fixture2.path}`); @@ -3895,7 +3739,7 @@ if (import.meta.vitest != null) { describe('globUsageFiles', () => { it('should glob files from multiple paths in parallel with base directories', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'path1/projects/project1/session1/usage.jsonl': 'data1', 'path2/projects/project2/session2/usage.jsonl': 'data2', 'path3/projects/project3/session3/usage.jsonl': 'data3', @@ -3920,7 +3764,7 @@ if (import.meta.vitest != null) { }); it('should handle errors gracefully and return empty array for failed paths', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'valid/projects/project1/session1/usage.jsonl': 'data1', }); @@ -3936,7 +3780,7 @@ if (import.meta.vitest != null) { }); it('should return empty array when no files found', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'empty/projects': {}, // Empty directory }); @@ -3947,7 +3791,7 @@ if (import.meta.vitest != null) { }); it('should handle multiple files from same base directory', async () => { - await using fixture = await createFixture({ + await using fixture = await createTimestampTestFixture({ 'path1/projects/project1/session1/usage.jsonl': 'data1', 'path1/projects/project1/session2/usage.jsonl': 'data2', 'path1/projects/project2/session1/usage.jsonl': 'data3', From ef2d101c69316f8a6c87a36709c705032a12fcd2 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 00:38:04 -0400 Subject: [PATCH 08/14] refactor: simplify code formatting for better readability Reformats multiline statements into single lines to improve code readability and maintainability. This change focuses on: - Simplifying array operations and path manipulations in getClaudePaths() - Cleaning up object definitions in the usage data schema - Improving code structure in extractProjectFromPath() - Fixing line wrapping in PricingFetcher initialization These formatting changes make the code more consistent and easier to scan without altering any functionality. --- src/data-loader.ts | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 5ad267a7..fdd0e8b7 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -86,17 +86,11 @@ export function getClaudePaths(): string[] { // Check environment variable first (supports comma-separated paths) const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim(); if (envPaths !== '') { - const envPathList = envPaths - .split(',') - .map(p => p.trim()) - .filter(p => p !== ''); + const envPathList = envPaths.split(',').map(p => p.trim()).filter(p => p !== ''); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { - const projectsPath = path.join( - normalizedPath, - CLAUDE_PROJECTS_DIR_NAME, - ); + const projectsPath = path.join(normalizedPath, CLAUDE_PROJECTS_DIR_NAME); if (isDirectorySync(projectsPath)) { // Avoid duplicates using normalized paths if (!normalizedPaths.has(normalizedPath)) { @@ -158,18 +152,14 @@ export function extractProjectFromPath(jsonlPath: string): string { // Normalize path separators for cross-platform compatibility const normalizedPath = jsonlPath.replace(/[/\\]/g, path.sep); const segments = normalizedPath.split(path.sep); - const projectsIndex = segments.findIndex( - segment => segment === CLAUDE_PROJECTS_DIR_NAME, - ); + const projectsIndex = segments.findIndex(segment => segment === CLAUDE_PROJECTS_DIR_NAME); if (projectsIndex === -1 || projectsIndex + 1 >= segments.length) { return 'unknown'; } const projectName = segments[projectsIndex + 1]; - return projectName != null && projectName.trim() !== '' - ? projectName - : 'unknown'; + return projectName != null && projectName.trim() !== '' ? projectName : 'unknown'; } /** @@ -187,13 +177,9 @@ export const usageDataSchema = z.object({ }), model: modelNameSchema.optional(), // Model is inside message object id: messageIdSchema.optional(), // Message ID for deduplication - content: z - .array( - z.object({ - text: z.string().optional(), - }), - ) - .optional(), + content: z.array(z.object({ + text: z.string().optional(), + })).optional(), }), costUSD: z.number().optional(), // Made optional for new schema requestId: requestIdSchema.optional(), // Request ID for deduplication @@ -1060,8 +1046,7 @@ export async function loadSessionData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher -= mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); From ae2e8f9353785b1dfce616564480bc17b68f7e92 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 00:42:30 -0400 Subject: [PATCH 09/14] chore: format --- src/data-loader.ts | 432 ++++++++++++++++++++------------------------- 1 file changed, 193 insertions(+), 239 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index fdd0e8b7..3aed9d85 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -86,7 +86,10 @@ export function getClaudePaths(): string[] { // Check environment variable first (supports comma-separated paths) const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim(); if (envPaths !== '') { - const envPathList = envPaths.split(',').map(p => p.trim()).filter(p => p !== ''); + const envPathList = envPaths + .split(',') + .map(p => p.trim()) + .filter(p => p !== ''); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { @@ -177,9 +180,13 @@ export const usageDataSchema = z.object({ }), model: modelNameSchema.optional(), // Model is inside message object id: messageIdSchema.optional(), // Message ID for deduplication - content: z.array(z.object({ - text: z.string().optional(), - })).optional(), + content: z + .array( + z.object({ + text: z.string().optional(), + }), + ) + .optional(), }), costUSD: z.number().optional(), // Made optional for new schema requestId: requestIdSchema.optional(), // Request ID for deduplication @@ -355,9 +362,8 @@ function aggregateByModel( inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: - existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), - cacheReadTokens: - existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), + existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); } @@ -368,9 +374,7 @@ function aggregateByModel( /** * Aggregates model breakdowns from multiple sources */ -function aggregateModelBreakdowns( - breakdowns: ModelBreakdown[], -): Map { +function aggregateModelBreakdowns(breakdowns: ModelBreakdown[]): Map { const modelAggregates = new Map(); const defaultStats: TokenStats = { inputTokens: 0, @@ -391,8 +395,7 @@ function aggregateModelBreakdowns( modelAggregates.set(breakdown.modelName, { inputTokens: existing.inputTokens + breakdown.inputTokens, outputTokens: existing.outputTokens + breakdown.outputTokens, - cacheCreationTokens: - existing.cacheCreationTokens + breakdown.cacheCreationTokens, + cacheCreationTokens: existing.cacheCreationTokens + breakdown.cacheCreationTokens, cacheReadTokens: existing.cacheReadTokens + breakdown.cacheReadTokens, cost: existing.cost + breakdown.cost, }); @@ -404,9 +407,7 @@ function aggregateModelBreakdowns( /** * Converts model aggregates to sorted model breakdowns */ -function createModelBreakdowns( - modelAggregates: Map, -): ModelBreakdown[] { +function createModelBreakdowns(modelAggregates: Map): ModelBreakdown[] { return Array.from(modelAggregates.entries()) .map(([modelName, stats]) => ({ modelName: modelName as ModelName, @@ -432,9 +433,8 @@ function calculateTotals( inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), cacheCreationTokens: - acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), - cacheReadTokens: - acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), + acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, }; @@ -496,10 +496,7 @@ function filterByProject( /** * Checks if an entry is a duplicate based on hash */ -function isDuplicateEntry( - uniqueHash: string | null, - processedHashes: Set, -): boolean { +function isDuplicateEntry(uniqueHash: string | null, processedHashes: Set): boolean { if (uniqueHash == null) { return false; } @@ -509,10 +506,7 @@ function isDuplicateEntry( /** * Marks an entry as processed */ -function markAsProcessed( - uniqueHash: string | null, - processedHashes: Set, -): void { +function markAsProcessed(uniqueHash: string | null, processedHashes: Set): void { if (uniqueHash != null) { processedHashes.add(uniqueHash); } @@ -525,11 +519,7 @@ function extractUniqueModels( entries: T[], getModel: (entry: T) => string | undefined, ): string[] { - return uniq( - entries - .map(getModel) - .filter((m): m is string => m != null && m !== ''), - ); + return uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '')); } /** @@ -553,7 +543,10 @@ function createDateFormatter(timezone: string | undefined, locale: string): Intl * @param locale - Locale to use for formatting * @returns Intl.DateTimeFormat instance */ -function createDatePartsFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat { +function createDatePartsFormatter( + timezone: string | undefined, + locale: string, +): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', @@ -583,7 +576,11 @@ export function formatDate(dateStr: string, timezone?: string, locale?: string): * @param locale - Locale to use for formatting * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ -export function formatDateCompact(dateStr: string, timezone: string | undefined, locale: string): string { +export function formatDateCompact( + dateStr: string, + timezone: string | undefined, + locale: string, +): string { const date = new Date(dateStr); const formatter = createDatePartsFormatter(timezone, locale); const parts = formatter.formatToParts(date); @@ -635,9 +632,7 @@ export function createUniqueHash(data: UsageData): string | null { * Extract the earliest timestamp from a JSONL file * Scans through the file until it finds a valid timestamp */ -export async function getEarliestTimestamp( - filePath: string, -): Promise { +export async function getEarliestTimestamp(filePath: string): Promise { try { const content = await readFile(filePath, 'utf-8'); const lines = content.trim().split('\n'); @@ -762,9 +757,11 @@ export function getUsageLimitResetTime(data: UsageData): Date | null { let resetTime: Date | null = null; if (data.isApiErrorMessage === true) { - const timestampMatch = data.message?.content?.find( - c => c.text != null && c.text.includes('Claude AI usage limit reached'), - )?.text?.match(/\|(\d+)/) ?? null; + const timestampMatch + = data.message?.content + ?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached')) + ?.text + ?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); @@ -788,9 +785,7 @@ export type GlobResult = { * @param claudePaths - Array of Claude base paths * @returns Array of file paths with their base directories */ -export async function globUsageFiles( - claudePaths: string[], -): Promise { +export async function globUsageFiles(claudePaths: string[]): Promise { const filePromises = claudePaths.map(async (claudePath) => { const claudeDir = path.join(claudePath, CLAUDE_PROJECTS_DIR_NAME); const files = await glob([USAGE_DATA_GLOB_PATTERN], { @@ -813,7 +808,7 @@ export type DateFilter = { }; type WeekDay = TupleToUnion; -type DayOfWeek = IntRange<0, typeof WEEK_DAYS['length']>; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday +type DayOfWeek = IntRange<0, (typeof WEEK_DAYS)['length']>; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday /** * Configuration options for loading usage data @@ -837,9 +832,7 @@ export type LoadOptions = { * @param options - Optional configuration for loading and filtering data * @returns Array of daily usage summaries sorted by date */ -export async function loadDailyUsageData( - options?: LoadOptions, -): Promise { +export async function loadDailyUsageData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -865,8 +858,7 @@ export async function loadDailyUsageData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher - = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); @@ -910,9 +902,10 @@ export async function loadDailyUsageData( const date = formatDate(data.timestamp, options?.timezone, 'en-CA'); // If fetcher is available, calculate cost based on mode and tokens // If fetcher is null, use pre-calculated costUSD or default to 0 - const cost = fetcher != null - ? await calculateCostForEntry(data, mode, fetcher) - : data.costUSD ?? 0; + const cost + = fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); // Extract project name from file path const project = extractProjectFromPath(file); @@ -933,8 +926,7 @@ export async function loadDailyUsageData( // Group by date, optionally including project // Automatically enable project grouping when project filter is specified - const needsProjectGrouping - = options?.groupByProject === true || options?.project != null; + const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping ? (entry: (typeof allEntries)[0]) => `${entry.date}\x00${entry.project}` : (entry: (typeof allEntries)[0]) => entry.date; @@ -992,11 +984,7 @@ export async function loadDailyUsageData( ); // Filter by project if specified - const finalFiltered = filterByProject( - dateFiltered, - item => item.project, - options?.project, - ); + const finalFiltered = filterByProject(dateFiltered, item => item.project, options?.project); // Sort by date based on order option (default to descending) return sortByDate(finalFiltered, item => item.date, options?.order); @@ -1008,9 +996,7 @@ export async function loadDailyUsageData( * @param options - Optional configuration for loading and filtering data * @returns Array of session usage summaries sorted by last activity */ -export async function loadSessionData( - options?: LoadOptions, -): Promise { +export async function loadSessionData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -1030,9 +1016,7 @@ export async function loadSessionData( // Sort files by timestamp to ensure chronological processing // Create a map for O(1) lookup instead of O(N) find operations - const fileToBaseMap = new Map( - projectFilteredWithBase.map(f => [f.file, f.baseDir]), - ); + const fileToBaseMap = new Map(projectFilteredWithBase.map(f => [f.file, f.baseDir])); const sortedFilesWithBase = await sortFilesByTimestamp( projectFilteredWithBase.map(f => f.file), ).then(sortedFiles => @@ -1069,10 +1053,7 @@ export async function loadSessionData( // Session ID is the name of the jsonl file name containing the JSONL file let sessionId: string; - if ( - parts.length > 0 - && (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '' - ) { + if (parts.length > 0 && (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, '') !== '') { sessionId = (parts[parts.length - 1] ?? '').replace(/\.jsonl/g, ''); } else { @@ -1110,9 +1091,10 @@ export async function loadSessionData( markAsProcessed(uniqueHash, processedHashes); const sessionKey = `${projectPath}/${sessionId}`; - const cost = fetcher != null - ? await calculateCostForEntry(data, mode, fetcher) - : data.costUSD ?? 0; + const cost + = fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); allEntries.push({ data, @@ -1178,7 +1160,11 @@ export async function loadSessionData( projectPath: createProjectPath(latestEntry.projectPath), ...totals, // Always use en-CA for date storage to ensure YYYY-MM-DD format - lastActivity: formatDate(latestEntry.timestamp, options?.timezone, 'en-CA') as ActivityDate, + lastActivity: formatDate( + latestEntry.timestamp, + options?.timezone, + 'en-CA', + ) as ActivityDate, versions: uniq(versions).sort() as Version[], modelsUsed: modelsUsed as ModelName[], modelBreakdowns, @@ -1201,11 +1187,7 @@ export async function loadSessionData( options?.project, ); - return sortByDate( - sessionFiltered, - item => item.lastActivity, - options?.order, - ); + return sortByDate(sessionFiltered, item => item.lastActivity, options?.order); } /** @@ -1214,14 +1196,16 @@ export async function loadSessionData( * @param options - Optional configuration for loading and filtering data * @returns Array of monthly usage summaries sorted by month */ -export async function loadMonthlyUsageData( - options?: LoadOptions, -): Promise { - return loadBucketUsageData((data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), options) - .then(usages => usages.map(({ bucket, ...rest }) => ({ +export async function loadMonthlyUsageData(options?: LoadOptions): Promise { + return loadBucketUsageData( + (data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), + options, + ).then(usages => + usages.map(({ bucket, ...rest }) => ({ month: createMonthlyDate(bucket.toString()), ...rest, - }))); + })), + ); } /** @@ -1254,16 +1238,19 @@ function getDayNumber(day: WeekDay): DayOfWeek { return dayMap[day]; } -export async function loadWeeklyUsageData( - options?: LoadOptions, -): Promise { - const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); +export async function loadWeeklyUsageData(options?: LoadOptions): Promise { + const startDay + = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); - return loadBucketUsageData((data: DailyUsage) => getDateWeek(new Date(data.date), startDay), options) - .then(usages => usages.map(({ bucket, ...rest }) => ({ + return loadBucketUsageData( + (data: DailyUsage) => getDateWeek(new Date(data.date), startDay), + options, + ).then(usages => + usages.map(({ bucket, ...rest }) => ({ week: createWeeklyDate(bucket.toString()), ...rest, - }))); + })), + ); } export async function loadBucketUsageData( @@ -1274,12 +1261,10 @@ export async function loadBucketUsageData( // Group daily data by week, optionally including project // Automatically enable project grouping when project filter is specified - const needsProjectGrouping - = options?.groupByProject === true || options?.project != null; + const needsProjectGrouping = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping - ? (data: DailyUsage) => - `${groupingFn(data)}\x00${data.project ?? 'unknown'}` + ? (data: DailyUsage) => `${groupingFn(data)}\x00${data.project ?? 'unknown'}` : (data: DailyUsage) => `${groupingFn(data)}`; const grouped = groupBy(dailyData, groupingKey); @@ -1295,9 +1280,7 @@ export async function loadBucketUsageData( const project = parts.length > 1 ? parts[1] : undefined; // Aggregate model breakdowns across all days - const allBreakdowns = dailyEntries.flatMap( - daily => daily.modelBreakdowns, - ); + const allBreakdowns = dailyEntries.flatMap(daily => daily.modelBreakdowns); const modelAggregates = aggregateModelBreakdowns(allBreakdowns); // Create model breakdowns @@ -1352,9 +1335,7 @@ export async function loadBucketUsageData( * @param options - Optional configuration including session duration and filtering * @returns Array of session blocks with usage and cost information */ -export async function loadSessionBlockData( - options?: LoadOptions, -): Promise { +export async function loadSessionBlockData(options?: LoadOptions): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -1387,8 +1368,7 @@ export async function loadSessionBlockData( const mode = options?.mode ?? 'auto'; // Use PricingFetcher with using statement for automatic cleanup - using fetcher - = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); // Track processed message+request combinations for deduplication const processedHashes = new Set(); @@ -1422,9 +1402,10 @@ export async function loadSessionBlockData( // Mark this combination as processed markAsProcessed(uniqueHash, processedHashes); - const cost = fetcher != null - ? await calculateCostForEntry(data, mode, fetcher) - : data.costUSD ?? 0; + const cost + = fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); // Get Claude Code usage limit expiration date const usageLimitResetTime = getUsageLimitResetTime(data); @@ -1435,9 +1416,8 @@ export async function loadSessionBlockData( inputTokens: data.message.usage.input_tokens, outputTokens: data.message.usage.output_tokens, cacheCreationInputTokens: - data.message.usage.cache_creation_input_tokens ?? 0, - cacheReadInputTokens: - data.message.usage.cache_read_input_tokens ?? 0, + data.message.usage.cache_creation_input_tokens ?? 0, + cacheReadInputTokens: data.message.usage.cache_read_input_tokens ?? 0, }, costUSD: cost, model: data.message.model ?? 'unknown', @@ -1455,25 +1435,36 @@ export async function loadSessionBlockData( } // Identify session blocks - const blocks = identifySessionBlocks( - allEntries, - options?.sessionDurationHours, - ); + const blocks = identifySessionBlocks(allEntries, options?.sessionDurationHours); // Filter by date range if specified - const dateFiltered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '') - ? blocks.filter((block) => { - // Always use en-CA for date comparison to ensure YYYY-MM-DD format - const blockDateStr = formatDate(block.startTime.toISOString(), options?.timezone, 'en-CA').replace(/-/g, ''); - if (options.since != null && options.since !== '' && blockDateStr < options.since) { - return false; - } - if (options.until != null && options.until !== '' && blockDateStr > options.until) { - return false; - } - return true; - }) - : blocks; + const dateFiltered + = (options?.since != null && options.since !== '') + || (options?.until != null && options.until !== '') + ? blocks.filter((block) => { + // Always use en-CA for date comparison to ensure YYYY-MM-DD format + const blockDateStr = formatDate( + block.startTime.toISOString(), + options?.timezone, + 'en-CA', + ).replace(/-/g, ''); + if ( + options.since != null + && options.since !== '' + && blockDateStr < options.since + ) { + return false; + } + if ( + options.until != null + && options.until !== '' + && blockDateStr > options.until + ) { + return false; + } + return true; + }) + : blocks; // Sort by start time based on order option return sortByDate(dateFiltered, block => block.startTime, options?.order); @@ -1544,20 +1535,32 @@ if (import.meta.vitest != null) { describe('formatDateCompact', () => { it('formats UTC timestamp to local date with line break', () => { - expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe( + '2024\n01-01', + ); }); it('handles various date formats', () => { - expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\n12-31'); + expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe( + '2024\n12-31', + ); expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe( + '2024\n01-01', + ); + expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe( + '2024\n01-01', + ); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues - expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\n01-05'); - expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\n10-01'); + expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe( + '2024\n01-05', + ); + expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe( + '2024\n10-01', + ); }); it('respects locale parameter', () => { @@ -1780,7 +1783,11 @@ invalid json line {"timestamp":"2024-01-01T18:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + mockData, + ); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1801,7 +1808,11 @@ invalid json line {"timestamp":"2024-01-01T22:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + mockData, + ); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2163,14 +2174,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.015, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 150, - outputTokens: 75, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.015, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 150, + outputTokens: 75, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.015, + }, + ], }); expect(result[1]).toEqual({ week: '2023-12-31', @@ -2180,14 +2193,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 300, - outputTokens: 150, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.03, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 300, + outputTokens: 150, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.03, + }, + ], }); }); @@ -2235,14 +2250,16 @@ invalid json line cacheReadTokens: 0, totalCost: 0.03, modelsUsed: [], - modelBreakdowns: [{ - modelName: 'unknown', - inputTokens: 300, - outputTokens: 150, - cacheCreationTokens: 0, - cacheReadTokens: 0, - cost: 0.03, - }], + modelBreakdowns: [ + { + modelName: 'unknown', + inputTokens: 300, + outputTokens: 150, + cacheCreationTokens: 0, + cacheReadTokens: 0, + cost: 0.03, + }, + ], }); }); @@ -2487,9 +2504,7 @@ invalid json line expect(result).toHaveLength(2); expect(result.find(s => s.sessionId === 'session123')).toBeTruthy(); - expect( - result.find(s => s.projectPath === 'project1/subfolder'), - ).toBeTruthy(); + expect(result.find(s => s.projectPath === 'project1/subfolder')).toBeTruthy(); expect(result.find(s => s.sessionId === 'session456')).toBeTruthy(); expect(result.find(s => s.projectPath === 'project2')).toBeTruthy(); }); @@ -3303,11 +3318,7 @@ invalid json line describe('display mode', () => { it('should return costUSD when available', async () => { using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - mockUsageData, - 'display', - fetcher, - ); + const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); expect(result).toBe(0.05); }); @@ -3316,22 +3327,14 @@ invalid json line dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutCost, - 'display', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutCost, 'display', fetcher); expect(result).toBe(0); }); it('should not use model pricing in display mode', async () => { // Even with model pricing available, should use costUSD using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - mockUsageData, - 'display', - fetcher, - ); + const result = await calculateCostForEntry(mockUsageData, 'display', fetcher); expect(result).toBe(0.05); }); }); @@ -3351,11 +3354,7 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - testData, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(testData, 'calculate', fetcher); expect(result).toBeGreaterThan(0); }); @@ -3363,11 +3362,7 @@ invalid json line it('should ignore costUSD in calculate mode', async () => { using fetcher = new PricingFetcher(); const dataWithHighCost = { ...mockUsageData, costUSD: 99.99 }; - const result = await calculateCostForEntry( - dataWithHighCost, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(dataWithHighCost, 'calculate', fetcher); expect(result).toBeGreaterThan(0); expect(result).toBeLessThan(1); // Much less than 99.99 @@ -3378,11 +3373,7 @@ invalid json line dataWithoutModel.message.model = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutModel, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutModel, 'calculate', fetcher); expect(result).toBe(0); }); @@ -3430,11 +3421,7 @@ invalid json line describe('auto mode', () => { it('should use costUSD when available', async () => { using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - mockUsageData, - 'auto', - fetcher, - ); + const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); expect(result).toBe(0.05); }); @@ -3451,11 +3438,7 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutCost, - 'auto', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher); expect(result).toBeGreaterThan(0); }); @@ -3465,11 +3448,7 @@ invalid json line dataWithoutCostOrModel.message.model = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutCostOrModel, - 'auto', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutCostOrModel, 'auto', fetcher); expect(result).toBe(0); }); @@ -3478,22 +3457,14 @@ invalid json line dataWithoutCost.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutCost, - 'auto', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutCost, 'auto', fetcher); expect(result).toBe(0); }); it('should prefer costUSD over calculation even when both available', async () => { // Both costUSD and model pricing available, should use costUSD using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - mockUsageData, - 'auto', - fetcher, - ); + const result = await calculateCostForEntry(mockUsageData, 'auto', fetcher); expect(result).toBe(0.05); }); }); @@ -3526,11 +3497,7 @@ invalid json line it('should handle costUSD of 0', async () => { const dataWithZeroCost = { ...mockUsageData, costUSD: 0 }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithZeroCost, - 'display', - fetcher, - ); + const result = await calculateCostForEntry(dataWithZeroCost, 'display', fetcher); expect(result).toBe(0); }); @@ -3628,10 +3595,7 @@ invalid json line expect(result.length).toBeGreaterThan(0); // Should have blocks expect(result[0]?.entries).toHaveLength(1); // First block has one entry // Total entries across all blocks should be 3 - const totalEntries = result.reduce( - (sum, block) => sum + block.entries.length, - 0, - ); + const totalEntries = result.reduce((sum, block) => sum + block.entries.length, 0); expect(totalEntries).toBe(3); }); @@ -3943,9 +3907,7 @@ if (import.meta.vitest != null) { 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp( - fixture.getPath('test.jsonl'), - ); + const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); @@ -3959,9 +3921,7 @@ if (import.meta.vitest != null) { 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp( - fixture.getPath('test.jsonl'), - ); + const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toBeNull(); }); @@ -3979,9 +3939,7 @@ if (import.meta.vitest != null) { 'test.jsonl': content, }); - const timestamp = await getEarliestTimestamp( - fixture.getPath('test.jsonl'), - ); + const timestamp = await getEarliestTimestamp(fixture.getPath('test.jsonl')); expect(timestamp).toEqual(new Date('2025-01-10T10:00:00Z')); }); }); @@ -4184,9 +4142,7 @@ if (import.meta.vitest != null) { const normalizedFixture1 = path.resolve(fixture1.path); const normalizedFixture2 = path.resolve(fixture2.path); - expect(paths).toEqual( - expect.arrayContaining([normalizedFixture1, normalizedFixture2]), - ); + expect(paths).toEqual(expect.arrayContaining([normalizedFixture1, normalizedFixture2])); // Environment paths should be prioritized expect(paths[0]).toBe(normalizedFixture1); expect(paths[1]).toBe(normalizedFixture2); @@ -4325,9 +4281,7 @@ if (import.meta.vitest != null) { const results = await globUsageFiles(paths); expect(results).toHaveLength(3); - expect(results.every(r => r.baseDir.includes('path1/projects'))).toBe( - true, - ); + expect(results.every(r => r.baseDir.includes('path1/projects'))).toBe(true); }); }); } From 103dc86de450dc9ffcc5b746e3f05af88c6566af Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 00:45:30 -0400 Subject: [PATCH 10/14] chore: formatting --- src/data-loader.ts | 341 +++++++++------------------------------------ 1 file changed, 62 insertions(+), 279 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 3aed9d85..90273097 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -11,15 +11,7 @@ import type { IntRange, TupleToUnion } from 'type-fest'; import type { WEEK_DAYS } from './_consts.ts'; import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; -import type { - ActivityDate, - Bucket, - CostMode, - ModelName, - SortOrder, - Version, - WeeklyDate, -} from './_types.ts'; +import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version, WeeklyDate } from './_types.ts'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; @@ -39,12 +31,7 @@ import { USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR, } from './_consts.ts'; -import { - createDailyUsageFixture, - createEmptyProjectsFixture, - createMultiProjectFixture, - createTimestampTestFixture, -} from './_fixtures.ts'; +import { createDailyUsageFixture, createEmptyProjectsFixture, createMultiProjectFixture, createTimestampTestFixture } from './_fixtures.ts'; import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, @@ -361,8 +348,7 @@ function aggregateByModel( modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: - existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); @@ -432,8 +418,7 @@ function calculateTotals( return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: - acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, @@ -453,12 +438,7 @@ function calculateTotals( /** * Filters items by date range */ -function filterByDateRange( - items: T[], - getDate: (item: T) => string, - since?: string, - until?: string, -): T[] { +function filterByDateRange(items: T[], getDate: (item: T) => string, since?: string, until?: string): T[] { if (since == null && until == null) { return items; } @@ -478,11 +458,7 @@ function filterByDateRange( /** * Filters items by project name */ -function filterByProject( - items: T[], - getProject: (item: T) => string | undefined, - projectFilter?: string, -): T[] { +function filterByProject(items: T[], getProject: (item: T) => string | undefined, projectFilter?: string): T[] { if (projectFilter == null) { return items; } @@ -515,10 +491,7 @@ function markAsProcessed(uniqueHash: string | null, processedHashes: Set /** * Extracts unique models from entries, excluding synthetic model */ -function extractUniqueModels( - entries: T[], - getModel: (entry: T) => string | undefined, -): string[] { +function extractUniqueModels(entries: T[], getModel: (entry: T) => string | undefined): string[] { return uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '')); } @@ -543,10 +516,7 @@ function createDateFormatter(timezone: string | undefined, locale: string): Intl * @param locale - Locale to use for formatting * @returns Intl.DateTimeFormat instance */ -function createDatePartsFormatter( - timezone: string | undefined, - locale: string, -): Intl.DateTimeFormat { +function createDatePartsFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', @@ -576,11 +546,7 @@ export function formatDate(dateStr: string, timezone?: string, locale?: string): * @param locale - Locale to use for formatting * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ -export function formatDateCompact( - dateStr: string, - timezone: string | undefined, - locale: string, -): string { +export function formatDateCompact(dateStr: string, timezone: string | undefined, locale: string): string { const date = new Date(dateStr); const formatter = createDatePartsFormatter(timezone, locale); const parts = formatter.formatToParts(date); @@ -597,11 +563,7 @@ export function formatDateCompact( * @param order - Sort order (asc or desc) * @returns Sorted array */ -function sortByDate( - items: T[], - getDate: (item: T) => string | Date, - order: SortOrder = 'desc', -): T[] { +function sortByDate(items: T[], getDate: (item: T) => string | Date, order: SortOrder = 'desc'): T[] { const sorted = sort(items); switch (order) { case 'desc': @@ -708,11 +670,7 @@ export async function sortFilesByTimestamp(files: string[]): Promise { * @param fetcher - Pricing fetcher instance for calculating costs from tokens * @returns Calculated cost in USD */ -export async function calculateCostForEntry( - data: UsageData, - mode: CostMode, - fetcher: PricingFetcher, -): Promise { +export async function calculateCostForEntry(data: UsageData, mode: CostMode, fetcher: PricingFetcher): Promise { if (mode === 'display') { // Always use costUSD, even if undefined return data.costUSD ?? 0; @@ -721,10 +679,7 @@ export async function calculateCostForEntry( if (mode === 'calculate') { // Always calculate from tokens if (data.message.model != null) { - return Result.unwrap( - fetcher.calculateCostFromTokens(data.message.usage, data.message.model), - 0, - ); + return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); } return 0; } @@ -736,10 +691,7 @@ export async function calculateCostForEntry( } if (data.message.model != null) { - return Result.unwrap( - fetcher.calculateCostFromTokens(data.message.usage, data.message.model), - 0, - ); + return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); } return 0; @@ -758,10 +710,7 @@ export function getUsageLimitResetTime(data: UsageData): Date | null { if (data.isApiErrorMessage === true) { const timestampMatch - = data.message?.content - ?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached')) - ?.text - ?.match(/\|(\d+)/) ?? null; + = data.message?.content?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached'))?.text?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); @@ -845,11 +794,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise extractProjectFromPath(filePath), - options?.project, - ); + const projectFilteredFiles = filterByProject(fileList, filePath => extractProjectFromPath(filePath), options?.project); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(projectFilteredFiles); @@ -902,10 +847,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange( - results, - item => item.date, - options?.since, - options?.until, - ); + const dateFiltered = filterByDateRange(results, item => item.date, options?.since, options?.until); // Filter by project if specified const finalFiltered = filterByProject(dateFiltered, item => item.project, options?.project); @@ -1008,18 +945,12 @@ export async function loadSessionData(options?: LoadOptions): Promise extractProjectFromPath(item.file), - options?.project, - ); + const projectFilteredWithBase = filterByProject(filesWithBase, item => extractProjectFromPath(item.file), options?.project); // Sort files by timestamp to ensure chronological processing // Create a map for O(1) lookup instead of O(N) find operations const fileToBaseMap = new Map(projectFilteredWithBase.map(f => [f.file, f.baseDir])); - const sortedFilesWithBase = await sortFilesByTimestamp( - projectFilteredWithBase.map(f => f.file), - ).then(sortedFiles => + const sortedFilesWithBase = await sortFilesByTimestamp(projectFilteredWithBase.map(f => f.file)).then(sortedFiles => sortedFiles.map(file => ({ file, baseDir: fileToBaseMap.get(file) ?? '', @@ -1091,10 +1022,7 @@ export async function loadSessionData(options?: LoadOptions): Promise - current.timestamp > latest.timestamp ? current : latest, - ); + const latestEntry = entries.reduce((latest, current) => (current.timestamp > latest.timestamp ? current : latest)); // Collect all unique versions const versions: string[] = []; @@ -1160,11 +1086,7 @@ export async function loadSessionData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange( - results, - item => item.lastActivity, - options?.since, - options?.until, - ); + const dateFiltered = filterByDateRange(results, item => item.lastActivity, options?.since, options?.until); // Filter by project if specified - const sessionFiltered = filterByProject( - dateFiltered, - item => item.projectPath, - options?.project, - ); + const sessionFiltered = filterByProject(dateFiltered, item => item.projectPath, options?.project); return sortByDate(sessionFiltered, item => item.lastActivity, options?.order); } @@ -1197,10 +1110,7 @@ export async function loadSessionData(options?: LoadOptions): Promise { - return loadBucketUsageData( - (data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), - options, - ).then(usages => + return loadBucketUsageData((data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), options).then(usages => usages.map(({ bucket, ...rest }) => ({ month: createMonthlyDate(bucket.toString()), ...rest, @@ -1239,13 +1149,9 @@ function getDayNumber(day: WeekDay): DayOfWeek { } export async function loadWeeklyUsageData(options?: LoadOptions): Promise { - const startDay - = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); + const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); - return loadBucketUsageData( - (data: DailyUsage) => getDateWeek(new Date(data.date), startDay), - options, - ).then(usages => + return loadBucketUsageData((data: DailyUsage) => getDateWeek(new Date(data.date), startDay), options).then(usages => usages.map(({ bucket, ...rest }) => ({ week: createWeeklyDate(bucket.toString()), ...rest, @@ -1253,10 +1159,7 @@ export async function loadWeeklyUsageData(options?: LoadOptions): Promise Bucket, - options?: LoadOptions, -): Promise { +export async function loadBucketUsageData(groupingFn: (data: DailyUsage) => Bucket, options?: LoadOptions): Promise { const dailyData = await loadDailyUsageData(options); // Group daily data by week, optionally including project @@ -1355,11 +1258,7 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise extractProjectFromPath(filePath), - options?.project, - ); + const blocksFilteredFiles = filterByProject(allFiles, filePath => extractProjectFromPath(filePath), options?.project); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(blocksFilteredFiles); @@ -1402,10 +1301,7 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise { // Always use en-CA for date comparison to ensure YYYY-MM-DD format - const blockDateStr = formatDate( - block.startTime.toISOString(), - options?.timezone, - 'en-CA', - ).replace(/-/g, ''); - if ( - options.since != null - && options.since !== '' - && blockDateStr < options.since - ) { + const blockDateStr = formatDate(block.startTime.toISOString(), options?.timezone, 'en-CA').replace(/-/g, ''); + if (options.since != null && options.since !== '' && blockDateStr < options.since) { return false; } - if ( - options.until != null - && options.until !== '' - && blockDateStr > options.until - ) { + if (options.until != null && options.until !== '' && blockDateStr > options.until) { return false; } return true; @@ -1535,32 +1415,20 @@ if (import.meta.vitest != null) { describe('formatDateCompact', () => { it('formats UTC timestamp to local date with line break', () => { - expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe( - '2024\n01-01', - ); + expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('handles various date formats', () => { - expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe( - '2024\n12-31', - ); + expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\n12-31'); expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe( - '2024\n01-01', - ); - expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe( - '2024\n01-01', - ); + expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues - expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe( - '2024\n01-05', - ); - expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe( - '2024\n10-01', - ); + expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\n01-05'); + expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\n10-01'); }); it('respects locale parameter', () => { @@ -1783,11 +1651,7 @@ invalid json line {"timestamp":"2024-01-01T18:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture( - 'project1', - 'session1.jsonl', - mockData, - ); + await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1808,11 +1672,7 @@ invalid json line {"timestamp":"2024-01-01T22:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture( - 'project1', - 'session1.jsonl', - mockData, - ); + await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2618,9 +2478,7 @@ invalid json line }, ]; - await using fixture = await createSessionFixture( - sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), - ); + await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); const result = await loadSessionData({ claudePath: fixture.path }); @@ -2699,9 +2557,7 @@ invalid json line }, ]; - await using fixture = await createSessionFixture( - sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), - ); + await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); const result = await loadSessionData({ claudePath: fixture.path, @@ -2742,9 +2598,7 @@ invalid json line }, ]; - await using fixture = await createSessionFixture( - sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), - ); + await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); const result = await loadSessionData({ claudePath: fixture.path, @@ -3100,13 +2954,7 @@ invalid json line it('display mode: always uses costUSD, even if undefined', async () => { const { testData } = await import('./_fixtures.ts'); - const data1 = testData.usageDataWithModel( - '2024-01-01T10:00:00Z', - 1000, - 500, - 'claude-4-sonnet-20250514', - 0.05, - ); + const data1 = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); const data2 = testData.usageDataWithModel( '2024-01-01T11:00:00Z', @@ -3133,13 +2981,7 @@ invalid json line it('mode works with session data', async () => { const { testData, createSessionFixture } = await import('./_fixtures.ts'); - const sessionData = testData.usageDataWithModel( - '2024-01-01T10:00:00Z', - 1000, - 500, - 'claude-4-sonnet-20250514', - 99.99, - ); + const sessionData = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 99.99); await using fixture = await createSessionFixture([ { @@ -3168,13 +3010,7 @@ invalid json line describe('pricing data fetching optimization', () => { it('should not require model pricing when mode is display', async () => { const { testData } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel( - '2024-01-01T10:00:00Z', - 1000, - 500, - 'claude-4-sonnet-20250514', - 0.05, - ); + const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); await using fixture = await createDailyUsageFixture({ project: 'test-project', @@ -3193,13 +3029,7 @@ invalid json line it('should fetch pricing data when mode is calculate', async () => { const { testData } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel( - '2024-01-01T10:00:00Z', - 1000, - 500, - 'claude-4-sonnet-20250514', - 0.05, - ); + const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); await using fixture = await createDailyUsageFixture({ project: 'test-project', @@ -3244,13 +3074,7 @@ invalid json line it('session data should not require model pricing when mode is display', async () => { const { testData, createSessionFixture } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel( - '2024-01-01T10:00:00Z', - 1000, - 500, - 'claude-4-sonnet-20250514', - 0.05, - ); + const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); await using fixture = await createSessionFixture([ { @@ -3387,11 +3211,7 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithUnknownModel, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(dataWithUnknownModel, 'calculate', fetcher); expect(result).toBe(0); }); @@ -3408,11 +3228,7 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithoutCacheTokens, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(dataWithoutCacheTokens, 'calculate', fetcher); expect(result).toBeGreaterThan(0); }); @@ -3486,11 +3302,7 @@ invalid json line dataWithZeroTokens.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithZeroTokens, - 'calculate', - fetcher, - ); + const result = await calculateCostForEntry(dataWithZeroTokens, 'calculate', fetcher); expect(result).toBe(0); }); @@ -3504,11 +3316,7 @@ invalid json line it('should handle negative costUSD', async () => { const dataWithNegativeCost = { ...mockUsageData, costUSD: -0.01 }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry( - dataWithNegativeCost, - 'display', - fetcher, - ); + const result = await calculateCostForEntry(dataWithNegativeCost, 'display', fetcher); expect(result).toBe(-0.01); }); }); @@ -3700,10 +3508,7 @@ invalid json line // The filter uses formatDate which converts to YYYYMMDD format for comparison expect( untilResult.every((block) => { - const blockDateStr = block.startTime - .toISOString() - .slice(0, 10) - .replace(/-/g, ''); + const blockDateStr = block.startTime.toISOString().slice(0, 10).replace(/-/g, ''); return blockDateStr <= '20240102'; }), ).toBe(true); @@ -3803,27 +3608,12 @@ invalid json line const { testData, createRawJSONLFixture } = await import('./_fixtures.ts'); const now = new Date('2024-01-01T10:00:00Z'); - const validData = testData.usageDataWithIds( - now.toISOString(), - 1000, - 500, - 'msg1', - 'req1', - 0.01, - ); + const validData = testData.usageDataWithIds(now.toISOString(), 1000, 500, 'msg1', 'req1', 0.01); validData.version = createVersion('1.0.0'); - const rawContent = [ - 'invalid json line', - JSON.stringify(validData), - 'another invalid line', - ].join('\n'); + const rawContent = ['invalid json line', JSON.stringify(validData), 'another invalid line'].join('\n'); - await using fixture = await createRawJSONLFixture( - 'project1', - 'session1.jsonl', - rawContent, - ); + await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', rawContent); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toHaveLength(1); @@ -3912,10 +3702,7 @@ if (import.meta.vitest != null) { }); it('should handle files without timestamps', async () => { - const content = [ - JSON.stringify({ message: { usage: {} } }), - JSON.stringify({ data: 'no timestamp' }), - ].join('\n'); + const content = [JSON.stringify({ message: { usage: {} } }), JSON.stringify({ data: 'no timestamp' })].join('\n'); await using fixture = await createTimestampTestFixture({ 'test.jsonl': content, @@ -4225,11 +4012,7 @@ if (import.meta.vitest != null) { 'path3/projects/project3/session3/usage.jsonl': 'data3', }); - const paths = [ - path.join(fixture.path, 'path1'), - path.join(fixture.path, 'path2'), - path.join(fixture.path, 'path3'), - ]; + const paths = [path.join(fixture.path, 'path1'), path.join(fixture.path, 'path2'), path.join(fixture.path, 'path3')]; const results = await globUsageFiles(paths); From 9425674e9e643715c4123e9ebb78a0a222b0d075 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 00:53:24 -0400 Subject: [PATCH 11/14] fix: prettier --- src/data-loader.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 90273097..a2c4bd18 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -31,7 +31,12 @@ import { USAGE_DATA_GLOB_PATTERN, USER_HOME_DIR, } from './_consts.ts'; -import { createDailyUsageFixture, createEmptyProjectsFixture, createMultiProjectFixture, createTimestampTestFixture } from './_fixtures.ts'; +import { + createDailyUsageFixture, + createEmptyProjectsFixture, + createMultiProjectFixture, + createTimestampTestFixture, +} from './_fixtures.ts'; import { identifySessionBlocks } from './_session-blocks.ts'; import { activityDateSchema, @@ -710,7 +715,10 @@ export function getUsageLimitResetTime(data: UsageData): Date | null { if (data.isApiErrorMessage === true) { const timestampMatch - = data.message?.content?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached'))?.text?.match(/\|(\d+)/) ?? null; + = data.message?.content + ?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached')) + ?.text + ?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); @@ -1159,7 +1167,10 @@ export async function loadWeeklyUsageData(options?: LoadOptions): Promise Bucket, options?: LoadOptions): Promise { +export async function loadBucketUsageData( + groupingFn: (data: DailyUsage) => Bucket, + options?: LoadOptions, +): Promise { const dailyData = await loadDailyUsageData(options); // Group daily data by week, optionally including project @@ -1322,7 +1333,9 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise { const { testData, createSessionFixture } = await import('./_fixtures.ts'); - const sessionData = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 99.99); + const sessionData = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 99.99, + ); await using fixture = await createSessionFixture([ { From 2c8f3ac44d7f7ba5dffc447c16d2220ba7398b47 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 00:54:33 -0400 Subject: [PATCH 12/14] fix: formatting --- src/data-loader.ts | 312 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 255 insertions(+), 57 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index a2c4bd18..3aed9d85 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -11,7 +11,15 @@ import type { IntRange, TupleToUnion } from 'type-fest'; import type { WEEK_DAYS } from './_consts.ts'; import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; -import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version, WeeklyDate } from './_types.ts'; +import type { + ActivityDate, + Bucket, + CostMode, + ModelName, + SortOrder, + Version, + WeeklyDate, +} from './_types.ts'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; @@ -353,7 +361,8 @@ function aggregateByModel( modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: + existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); @@ -423,7 +432,8 @@ function calculateTotals( return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: + acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, @@ -443,7 +453,12 @@ function calculateTotals( /** * Filters items by date range */ -function filterByDateRange(items: T[], getDate: (item: T) => string, since?: string, until?: string): T[] { +function filterByDateRange( + items: T[], + getDate: (item: T) => string, + since?: string, + until?: string, +): T[] { if (since == null && until == null) { return items; } @@ -463,7 +478,11 @@ function filterByDateRange(items: T[], getDate: (item: T) => string, since?: /** * Filters items by project name */ -function filterByProject(items: T[], getProject: (item: T) => string | undefined, projectFilter?: string): T[] { +function filterByProject( + items: T[], + getProject: (item: T) => string | undefined, + projectFilter?: string, +): T[] { if (projectFilter == null) { return items; } @@ -496,7 +515,10 @@ function markAsProcessed(uniqueHash: string | null, processedHashes: Set /** * Extracts unique models from entries, excluding synthetic model */ -function extractUniqueModels(entries: T[], getModel: (entry: T) => string | undefined): string[] { +function extractUniqueModels( + entries: T[], + getModel: (entry: T) => string | undefined, +): string[] { return uniq(entries.map(getModel).filter((m): m is string => m != null && m !== '')); } @@ -521,7 +543,10 @@ function createDateFormatter(timezone: string | undefined, locale: string): Intl * @param locale - Locale to use for formatting * @returns Intl.DateTimeFormat instance */ -function createDatePartsFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat { +function createDatePartsFormatter( + timezone: string | undefined, + locale: string, +): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', @@ -551,7 +576,11 @@ export function formatDate(dateStr: string, timezone?: string, locale?: string): * @param locale - Locale to use for formatting * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ -export function formatDateCompact(dateStr: string, timezone: string | undefined, locale: string): string { +export function formatDateCompact( + dateStr: string, + timezone: string | undefined, + locale: string, +): string { const date = new Date(dateStr); const formatter = createDatePartsFormatter(timezone, locale); const parts = formatter.formatToParts(date); @@ -568,7 +597,11 @@ export function formatDateCompact(dateStr: string, timezone: string | undefined, * @param order - Sort order (asc or desc) * @returns Sorted array */ -function sortByDate(items: T[], getDate: (item: T) => string | Date, order: SortOrder = 'desc'): T[] { +function sortByDate( + items: T[], + getDate: (item: T) => string | Date, + order: SortOrder = 'desc', +): T[] { const sorted = sort(items); switch (order) { case 'desc': @@ -675,7 +708,11 @@ export async function sortFilesByTimestamp(files: string[]): Promise { * @param fetcher - Pricing fetcher instance for calculating costs from tokens * @returns Calculated cost in USD */ -export async function calculateCostForEntry(data: UsageData, mode: CostMode, fetcher: PricingFetcher): Promise { +export async function calculateCostForEntry( + data: UsageData, + mode: CostMode, + fetcher: PricingFetcher, +): Promise { if (mode === 'display') { // Always use costUSD, even if undefined return data.costUSD ?? 0; @@ -684,7 +721,10 @@ export async function calculateCostForEntry(data: UsageData, mode: CostMode, fet if (mode === 'calculate') { // Always calculate from tokens if (data.message.model != null) { - return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); + return Result.unwrap( + fetcher.calculateCostFromTokens(data.message.usage, data.message.model), + 0, + ); } return 0; } @@ -696,7 +736,10 @@ export async function calculateCostForEntry(data: UsageData, mode: CostMode, fet } if (data.message.model != null) { - return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); + return Result.unwrap( + fetcher.calculateCostFromTokens(data.message.usage, data.message.model), + 0, + ); } return 0; @@ -802,7 +845,11 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise extractProjectFromPath(filePath), options?.project); + const projectFilteredFiles = filterByProject( + fileList, + filePath => extractProjectFromPath(filePath), + options?.project, + ); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(projectFilteredFiles); @@ -855,7 +902,10 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange(results, item => item.date, options?.since, options?.until); + const dateFiltered = filterByDateRange( + results, + item => item.date, + options?.since, + options?.until, + ); // Filter by project if specified const finalFiltered = filterByProject(dateFiltered, item => item.project, options?.project); @@ -953,12 +1008,18 @@ export async function loadSessionData(options?: LoadOptions): Promise extractProjectFromPath(item.file), options?.project); + const projectFilteredWithBase = filterByProject( + filesWithBase, + item => extractProjectFromPath(item.file), + options?.project, + ); // Sort files by timestamp to ensure chronological processing // Create a map for O(1) lookup instead of O(N) find operations const fileToBaseMap = new Map(projectFilteredWithBase.map(f => [f.file, f.baseDir])); - const sortedFilesWithBase = await sortFilesByTimestamp(projectFilteredWithBase.map(f => f.file)).then(sortedFiles => + const sortedFilesWithBase = await sortFilesByTimestamp( + projectFilteredWithBase.map(f => f.file), + ).then(sortedFiles => sortedFiles.map(file => ({ file, baseDir: fileToBaseMap.get(file) ?? '', @@ -1030,7 +1091,10 @@ export async function loadSessionData(options?: LoadOptions): Promise (current.timestamp > latest.timestamp ? current : latest)); + const latestEntry = entries.reduce((latest, current) => + current.timestamp > latest.timestamp ? current : latest, + ); // Collect all unique versions const versions: string[] = []; @@ -1094,7 +1160,11 @@ export async function loadSessionData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange(results, item => item.lastActivity, options?.since, options?.until); + const dateFiltered = filterByDateRange( + results, + item => item.lastActivity, + options?.since, + options?.until, + ); // Filter by project if specified - const sessionFiltered = filterByProject(dateFiltered, item => item.projectPath, options?.project); + const sessionFiltered = filterByProject( + dateFiltered, + item => item.projectPath, + options?.project, + ); return sortByDate(sessionFiltered, item => item.lastActivity, options?.order); } @@ -1118,7 +1197,10 @@ export async function loadSessionData(options?: LoadOptions): Promise { - return loadBucketUsageData((data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), options).then(usages => + return loadBucketUsageData( + (data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), + options, + ).then(usages => usages.map(({ bucket, ...rest }) => ({ month: createMonthlyDate(bucket.toString()), ...rest, @@ -1157,9 +1239,13 @@ function getDayNumber(day: WeekDay): DayOfWeek { } export async function loadWeeklyUsageData(options?: LoadOptions): Promise { - const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); + const startDay + = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); - return loadBucketUsageData((data: DailyUsage) => getDateWeek(new Date(data.date), startDay), options).then(usages => + return loadBucketUsageData( + (data: DailyUsage) => getDateWeek(new Date(data.date), startDay), + options, + ).then(usages => usages.map(({ bucket, ...rest }) => ({ week: createWeeklyDate(bucket.toString()), ...rest, @@ -1269,7 +1355,11 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise extractProjectFromPath(filePath), options?.project); + const blocksFilteredFiles = filterByProject( + allFiles, + filePath => extractProjectFromPath(filePath), + options?.project, + ); // Sort files by timestamp to ensure chronological processing const sortedFiles = await sortFilesByTimestamp(blocksFilteredFiles); @@ -1312,7 +1402,10 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise { // Always use en-CA for date comparison to ensure YYYY-MM-DD format - const blockDateStr = formatDate(block.startTime.toISOString(), options?.timezone, 'en-CA').replace(/-/g, ''); - if (options.since != null && options.since !== '' && blockDateStr < options.since) { + const blockDateStr = formatDate( + block.startTime.toISOString(), + options?.timezone, + 'en-CA', + ).replace(/-/g, ''); + if ( + options.since != null + && options.since !== '' + && blockDateStr < options.since + ) { return false; } - if (options.until != null && options.until !== '' && blockDateStr > options.until) { + if ( + options.until != null + && options.until !== '' + && blockDateStr > options.until + ) { return false; } return true; @@ -1428,20 +1535,32 @@ if (import.meta.vitest != null) { describe('formatDateCompact', () => { it('formats UTC timestamp to local date with line break', () => { - expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe( + '2024\n01-01', + ); }); it('handles various date formats', () => { - expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\n12-31'); + expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe( + '2024\n12-31', + ); expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe( + '2024\n01-01', + ); + expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe( + '2024\n01-01', + ); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues - expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\n01-05'); - expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\n10-01'); + expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe( + '2024\n01-05', + ); + expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe( + '2024\n10-01', + ); }); it('respects locale parameter', () => { @@ -1664,7 +1783,11 @@ invalid json line {"timestamp":"2024-01-01T18:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + mockData, + ); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -1685,7 +1808,11 @@ invalid json line {"timestamp":"2024-01-01T22:00:00Z","message":{"usage":{"input_tokens":300,"output_tokens":150}},"costUSD":0.03} `.trim(); - await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', mockData); + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + mockData, + ); const result = await loadDailyUsageData({ claudePath: fixture.path }); @@ -2491,7 +2618,9 @@ invalid json line }, ]; - await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path }); @@ -2570,7 +2699,9 @@ invalid json line }, ]; - await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path, @@ -2611,7 +2742,9 @@ invalid json line }, ]; - await using fixture = await createSessionFixture(sessions.map(s => ({ sessionId: s.sessionId, data: s.data }))); + await using fixture = await createSessionFixture( + sessions.map(s => ({ sessionId: s.sessionId, data: s.data })), + ); const result = await loadSessionData({ claudePath: fixture.path, @@ -2967,7 +3100,13 @@ invalid json line it('display mode: always uses costUSD, even if undefined', async () => { const { testData } = await import('./_fixtures.ts'); - const data1 = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); + const data1 = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); const data2 = testData.usageDataWithModel( '2024-01-01T11:00:00Z', @@ -3029,7 +3168,13 @@ invalid json line describe('pricing data fetching optimization', () => { it('should not require model pricing when mode is display', async () => { const { testData } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); await using fixture = await createDailyUsageFixture({ project: 'test-project', @@ -3048,7 +3193,13 @@ invalid json line it('should fetch pricing data when mode is calculate', async () => { const { testData } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); await using fixture = await createDailyUsageFixture({ project: 'test-project', @@ -3093,7 +3244,13 @@ invalid json line it('session data should not require model pricing when mode is display', async () => { const { testData, createSessionFixture } = await import('./_fixtures.ts'); - const data = testData.usageDataWithModel('2024-01-01T10:00:00Z', 1000, 500, 'claude-4-sonnet-20250514', 0.05); + const data = testData.usageDataWithModel( + '2024-01-01T10:00:00Z', + 1000, + 500, + 'claude-4-sonnet-20250514', + 0.05, + ); await using fixture = await createSessionFixture([ { @@ -3230,7 +3387,11 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithUnknownModel, 'calculate', fetcher); + const result = await calculateCostForEntry( + dataWithUnknownModel, + 'calculate', + fetcher, + ); expect(result).toBe(0); }); @@ -3247,7 +3408,11 @@ invalid json line }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithoutCacheTokens, 'calculate', fetcher); + const result = await calculateCostForEntry( + dataWithoutCacheTokens, + 'calculate', + fetcher, + ); expect(result).toBeGreaterThan(0); }); @@ -3321,7 +3486,11 @@ invalid json line dataWithZeroTokens.costUSD = undefined; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithZeroTokens, 'calculate', fetcher); + const result = await calculateCostForEntry( + dataWithZeroTokens, + 'calculate', + fetcher, + ); expect(result).toBe(0); }); @@ -3335,7 +3504,11 @@ invalid json line it('should handle negative costUSD', async () => { const dataWithNegativeCost = { ...mockUsageData, costUSD: -0.01 }; using fetcher = new PricingFetcher(); - const result = await calculateCostForEntry(dataWithNegativeCost, 'display', fetcher); + const result = await calculateCostForEntry( + dataWithNegativeCost, + 'display', + fetcher, + ); expect(result).toBe(-0.01); }); }); @@ -3527,7 +3700,10 @@ invalid json line // The filter uses formatDate which converts to YYYYMMDD format for comparison expect( untilResult.every((block) => { - const blockDateStr = block.startTime.toISOString().slice(0, 10).replace(/-/g, ''); + const blockDateStr = block.startTime + .toISOString() + .slice(0, 10) + .replace(/-/g, ''); return blockDateStr <= '20240102'; }), ).toBe(true); @@ -3627,12 +3803,27 @@ invalid json line const { testData, createRawJSONLFixture } = await import('./_fixtures.ts'); const now = new Date('2024-01-01T10:00:00Z'); - const validData = testData.usageDataWithIds(now.toISOString(), 1000, 500, 'msg1', 'req1', 0.01); + const validData = testData.usageDataWithIds( + now.toISOString(), + 1000, + 500, + 'msg1', + 'req1', + 0.01, + ); validData.version = createVersion('1.0.0'); - const rawContent = ['invalid json line', JSON.stringify(validData), 'another invalid line'].join('\n'); + const rawContent = [ + 'invalid json line', + JSON.stringify(validData), + 'another invalid line', + ].join('\n'); - await using fixture = await createRawJSONLFixture('project1', 'session1.jsonl', rawContent); + await using fixture = await createRawJSONLFixture( + 'project1', + 'session1.jsonl', + rawContent, + ); const result = await loadSessionBlockData({ claudePath: fixture.path }); expect(result).toHaveLength(1); @@ -3721,7 +3912,10 @@ if (import.meta.vitest != null) { }); it('should handle files without timestamps', async () => { - const content = [JSON.stringify({ message: { usage: {} } }), JSON.stringify({ data: 'no timestamp' })].join('\n'); + const content = [ + JSON.stringify({ message: { usage: {} } }), + JSON.stringify({ data: 'no timestamp' }), + ].join('\n'); await using fixture = await createTimestampTestFixture({ 'test.jsonl': content, @@ -4031,7 +4225,11 @@ if (import.meta.vitest != null) { 'path3/projects/project3/session3/usage.jsonl': 'data3', }); - const paths = [path.join(fixture.path, 'path1'), path.join(fixture.path, 'path2'), path.join(fixture.path, 'path3')]; + const paths = [ + path.join(fixture.path, 'path1'), + path.join(fixture.path, 'path2'), + path.join(fixture.path, 'path3'), + ]; const results = await globUsageFiles(paths); From 39453a2daa5d7752b999b508cc3eb604eba28114 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 01:11:07 -0400 Subject: [PATCH 13/14] some formatting reverts --- src/data-loader.ts | 246 +++++++++++++++++---------------------------- 1 file changed, 95 insertions(+), 151 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index 3aed9d85..ba723ec1 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -86,10 +86,7 @@ export function getClaudePaths(): string[] { // Check environment variable first (supports comma-separated paths) const envPaths = (process.env[CLAUDE_CONFIG_DIR_ENV] ?? '').trim(); if (envPaths !== '') { - const envPathList = envPaths - .split(',') - .map(p => p.trim()) - .filter(p => p !== ''); + const envPathList = envPaths.split(',').map(p => p.trim()).filter(p => p !== ''); for (const envPath of envPathList) { const normalizedPath = path.resolve(envPath); if (isDirectorySync(normalizedPath)) { @@ -180,13 +177,9 @@ export const usageDataSchema = z.object({ }), model: modelNameSchema.optional(), // Model is inside message object id: messageIdSchema.optional(), // Message ID for deduplication - content: z - .array( - z.object({ - text: z.string().optional(), - }), - ) - .optional(), + content: z.array(z.object({ + text: z.string().optional(), + })).optional(), }), costUSD: z.number().optional(), // Made optional for new schema requestId: requestIdSchema.optional(), // Request ID for deduplication @@ -361,8 +354,7 @@ function aggregateByModel( modelAggregates.set(modelName, { inputTokens: existing.inputTokens + (usage.input_tokens ?? 0), outputTokens: existing.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: - existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: existing.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: existing.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: existing.cost + cost, }); @@ -374,7 +366,9 @@ function aggregateByModel( /** * Aggregates model breakdowns from multiple sources */ -function aggregateModelBreakdowns(breakdowns: ModelBreakdown[]): Map { +function aggregateModelBreakdowns( + breakdowns: ModelBreakdown[], +): Map { const modelAggregates = new Map(); const defaultStats: TokenStats = { inputTokens: 0, @@ -407,7 +401,9 @@ function aggregateModelBreakdowns(breakdowns: ModelBreakdown[]): Map): ModelBreakdown[] { +function createModelBreakdowns( + modelAggregates: Map, +): ModelBreakdown[] { return Array.from(modelAggregates.entries()) .map(([modelName, stats]) => ({ modelName: modelName as ModelName, @@ -432,8 +428,7 @@ function calculateTotals( return { inputTokens: acc.inputTokens + (usage.input_tokens ?? 0), outputTokens: acc.outputTokens + (usage.output_tokens ?? 0), - cacheCreationTokens: - acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), + cacheCreationTokens: acc.cacheCreationTokens + (usage.cache_creation_input_tokens ?? 0), cacheReadTokens: acc.cacheReadTokens + (usage.cache_read_input_tokens ?? 0), cost: acc.cost + cost, totalCost: acc.totalCost + cost, @@ -496,7 +491,10 @@ function filterByProject( /** * Checks if an entry is a duplicate based on hash */ -function isDuplicateEntry(uniqueHash: string | null, processedHashes: Set): boolean { +function isDuplicateEntry( + uniqueHash: string | null, + processedHashes: Set, +): boolean { if (uniqueHash == null) { return false; } @@ -506,7 +504,10 @@ function isDuplicateEntry(uniqueHash: string | null, processedHashes: Set): void { +function markAsProcessed( + uniqueHash: string | null, + processedHashes: Set, +): void { if (uniqueHash != null) { processedHashes.add(uniqueHash); } @@ -543,10 +544,7 @@ function createDateFormatter(timezone: string | undefined, locale: string): Intl * @param locale - Locale to use for formatting * @returns Intl.DateTimeFormat instance */ -function createDatePartsFormatter( - timezone: string | undefined, - locale: string, -): Intl.DateTimeFormat { +function createDatePartsFormatter(timezone: string | undefined, locale: string): Intl.DateTimeFormat { return new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', @@ -576,11 +574,7 @@ export function formatDate(dateStr: string, timezone?: string, locale?: string): * @param locale - Locale to use for formatting * @returns Formatted date string with newline separator (YYYY\nMM-DD) */ -export function formatDateCompact( - dateStr: string, - timezone: string | undefined, - locale: string, -): string { +export function formatDateCompact(dateStr: string, timezone: string | undefined, locale: string): string { const date = new Date(dateStr); const formatter = createDatePartsFormatter(timezone, locale); const parts = formatter.formatToParts(date); @@ -721,10 +715,7 @@ export async function calculateCostForEntry( if (mode === 'calculate') { // Always calculate from tokens if (data.message.model != null) { - return Result.unwrap( - fetcher.calculateCostFromTokens(data.message.usage, data.message.model), - 0, - ); + return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); } return 0; } @@ -736,10 +727,7 @@ export async function calculateCostForEntry( } if (data.message.model != null) { - return Result.unwrap( - fetcher.calculateCostFromTokens(data.message.usage, data.message.model), - 0, - ); + return Result.unwrap(fetcher.calculateCostFromTokens(data.message.usage, data.message.model), 0); } return 0; @@ -757,11 +745,9 @@ export function getUsageLimitResetTime(data: UsageData): Date | null { let resetTime: Date | null = null; if (data.isApiErrorMessage === true) { - const timestampMatch - = data.message?.content - ?.find(c => c.text != null && c.text.includes('Claude AI usage limit reached')) - ?.text - ?.match(/\|(\d+)/) ?? null; + const timestampMatch = data.message?.content?.find( + c => c.text != null && c.text.includes('Claude AI usage limit reached'), + )?.text?.match(/\|(\d+)/) ?? null; if (timestampMatch?.[1] != null) { const resetTimestamp = Number.parseInt(timestampMatch[1]); @@ -808,7 +794,7 @@ export type DateFilter = { }; type WeekDay = TupleToUnion; -type DayOfWeek = IntRange<0, (typeof WEEK_DAYS)['length']>; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday +type DayOfWeek = IntRange<0, typeof WEEK_DAYS['length']>; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday /** * Configuration options for loading usage data @@ -832,7 +818,9 @@ export type LoadOptions = { * @param options - Optional configuration for loading and filtering data * @returns Array of daily usage summaries sorted by date */ -export async function loadDailyUsageData(options?: LoadOptions): Promise { +export async function loadDailyUsageData( + options?: LoadOptions, +): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -864,13 +852,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise(); // Collect all valid data entries first - const allEntries: { - data: UsageData; - date: string; - cost: number; - model: string | undefined; - project: string; - }[] = []; + const allEntries: { data: UsageData; date: string; cost: number; model: string | undefined; project: string }[] = []; for (const file of sortedFiles) { const content = await readFile(file, 'utf-8'); @@ -902,21 +884,14 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise `${entry.date}\x00${entry.project}` - : (entry: (typeof allEntries)[0]) => entry.date; + ? (entry: typeof allEntries[0]) => `${entry.date}\x00${entry.project}` + : (entry: typeof allEntries[0]) => entry.date; const groupedData = groupBy(allEntries, groupingKey); @@ -976,12 +951,7 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange( - results, - item => item.date, - options?.since, - options?.until, - ); + const dateFiltered = filterByDateRange(results, item => item.date, options?.since, options?.until); // Filter by project if specified const finalFiltered = filterByProject(dateFiltered, item => item.project, options?.project); @@ -996,7 +966,9 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise { +export async function loadSessionData( + options?: LoadOptions, +): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -1083,7 +1055,7 @@ export async function loadSessionData(options?: LoadOptions): Promise entry.sessionKey); + const groupedBySessions = groupBy( + allEntries, + entry => entry.sessionKey, + ); // Aggregate each session group const results = Object.entries(groupedBySessions) @@ -1160,11 +1134,7 @@ export async function loadSessionData(options?: LoadOptions): Promise item != null); // Filter by date range if specified - const dateFiltered = filterByDateRange( - results, - item => item.lastActivity, - options?.since, - options?.until, - ); + const dateFiltered = filterByDateRange(results, item => item.lastActivity, options?.since, options?.until); // Filter by project if specified - const sessionFiltered = filterByProject( - dateFiltered, - item => item.projectPath, - options?.project, - ); + const sessionFiltered = filterByProject(dateFiltered, item => item.projectPath, options?.project); return sortByDate(sessionFiltered, item => item.lastActivity, options?.order); } @@ -1196,16 +1157,14 @@ export async function loadSessionData(options?: LoadOptions): Promise { - return loadBucketUsageData( - (data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), - options, - ).then(usages => - usages.map(({ bucket, ...rest }) => ({ +export async function loadMonthlyUsageData( + options?: LoadOptions, +): Promise { + return loadBucketUsageData((data: DailyUsage) => createMonthlyDate(data.date.substring(0, 7)), options) + .then(usages => usages.map(({ bucket, ...rest }) => ({ month: createMonthlyDate(bucket.toString()), ...rest, - })), - ); + }))); } /** @@ -1238,19 +1197,16 @@ function getDayNumber(day: WeekDay): DayOfWeek { return dayMap[day]; } -export async function loadWeeklyUsageData(options?: LoadOptions): Promise { - const startDay - = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); +export async function loadWeeklyUsageData( + options?: LoadOptions, +): Promise { + const startDay = options?.startOfWeek != null ? getDayNumber(options.startOfWeek) : getDayNumber('sunday'); - return loadBucketUsageData( - (data: DailyUsage) => getDateWeek(new Date(data.date), startDay), - options, - ).then(usages => - usages.map(({ bucket, ...rest }) => ({ + return loadBucketUsageData((data: DailyUsage) => getDateWeek(new Date(data.date), startDay), options) + .then(usages => usages.map(({ bucket, ...rest }) => ({ week: createWeeklyDate(bucket.toString()), ...rest, - })), - ); + }))); } export async function loadBucketUsageData( @@ -1261,10 +1217,12 @@ export async function loadBucketUsageData( // Group daily data by week, optionally including project // Automatically enable project grouping when project filter is specified - const needsProjectGrouping = options?.groupByProject === true || options?.project != null; + const needsProjectGrouping + = options?.groupByProject === true || options?.project != null; const groupingKey = needsProjectGrouping - ? (data: DailyUsage) => `${groupingFn(data)}\x00${data.project ?? 'unknown'}` + ? (data: DailyUsage) => + `${groupingFn(data)}\x00${data.project ?? 'unknown'}` : (data: DailyUsage) => `${groupingFn(data)}`; const grouped = groupBy(dailyData, groupingKey); @@ -1280,7 +1238,9 @@ export async function loadBucketUsageData( const project = parts.length > 1 ? parts[1] : undefined; // Aggregate model breakdowns across all days - const allBreakdowns = dailyEntries.flatMap(daily => daily.modelBreakdowns); + const allBreakdowns = dailyEntries.flatMap( + daily => daily.modelBreakdowns, + ); const modelAggregates = aggregateModelBreakdowns(allBreakdowns); // Create model breakdowns @@ -1335,7 +1295,9 @@ export async function loadBucketUsageData( * @param options - Optional configuration including session duration and filtering * @returns Array of session blocks with usage and cost information */ -export async function loadSessionBlockData(options?: LoadOptions): Promise { +export async function loadSessionBlockData( + options?: LoadOptions, +): Promise { // Get all Claude paths or use the specific one from options const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); @@ -1395,17 +1357,16 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise { - // Always use en-CA for date comparison to ensure YYYY-MM-DD format - const blockDateStr = formatDate( - block.startTime.toISOString(), - options?.timezone, - 'en-CA', - ).replace(/-/g, ''); - if ( - options.since != null - && options.since !== '' - && blockDateStr < options.since - ) { - return false; - } - if ( - options.until != null - && options.until !== '' - && blockDateStr > options.until - ) { - return false; - } - return true; - }) - : blocks; + const dateFiltered = (options?.since != null && options.since !== '') || (options?.until != null && options.until !== '') + ? blocks.filter((block) => { + // Always use en-CA for date comparison to ensure YYYY-MM-DD format + const blockDateStr = formatDate(block.startTime.toISOString(), options?.timezone, 'en-CA').replace(/-/g, ''); + if (options.since != null && options.since !== '' && blockDateStr < options.since) { + return false; + } + if (options.until != null && options.until !== '' && blockDateStr > options.until) { + return false; + } + return true; + }) + : blocks; // Sort by start time based on order option return sortByDate(dateFiltered, block => block.startTime, options?.order); From 77ebbd480152a1c373aa3648e218c535793b3031 Mon Sep 17 00:00:00 2001 From: Nathan Heaps Date: Sat, 9 Aug 2025 01:17:25 -0400 Subject: [PATCH 14/14] more reverts --- src/data-loader.ts | 143 +++++++++++++++++++-------------------------- 1 file changed, 61 insertions(+), 82 deletions(-) diff --git a/src/data-loader.ts b/src/data-loader.ts index ba723ec1..e3d749e3 100644 --- a/src/data-loader.ts +++ b/src/data-loader.ts @@ -1415,11 +1415,9 @@ export async function loadSessionBlockData( } if (import.meta.vitest != null) { - // Dynamic imports will be loaded within individual test suites to avoid top-level await - describe('formatDate', () => { it('formats UTC timestamp to local date', () => { - // Test with UTC timestamps - results depend on local timezone + // Test with UTC timestamps - results depend on local timezone expect(formatDate('2024-01-01T00:00:00Z')).toBe('2024-01-01'); expect(formatDate('2024-12-31T23:59:59Z')).toBe('2024-12-31'); }); @@ -1479,32 +1477,20 @@ if (import.meta.vitest != null) { describe('formatDateCompact', () => { it('formats UTC timestamp to local date with line break', () => { - expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe( - '2024\n01-01', - ); + expect(formatDateCompact('2024-01-01T00:00:00Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('handles various date formats', () => { - expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe( - '2024\n12-31', - ); + expect(formatDateCompact('2024-12-31T23:59:59Z', undefined, 'en-US')).toBe('2024\n12-31'); expect(formatDateCompact('2024-01-01', undefined, 'en-US')).toBe('2024\n01-01'); - expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe( - '2024\n01-01', - ); - expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe( - '2024\n01-01', - ); + expect(formatDateCompact('2024-01-01T12:00:00', undefined, 'en-US')).toBe('2024\n01-01'); + expect(formatDateCompact('2024-01-01T12:00:00.000Z', undefined, 'en-US')).toBe('2024\n01-01'); }); it('pads single digit months and days', () => { // Use UTC noon to avoid timezone issues - expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe( - '2024\n01-05', - ); - expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe( - '2024\n10-01', - ); + expect(formatDateCompact('2024-01-05T12:00:00Z', undefined, 'en-US')).toBe('2024\n01-05'); + expect(formatDateCompact('2024-10-01T12:00:00Z', undefined, 'en-US')).toBe('2024\n10-01'); }); it('respects locale parameter', () => { @@ -1718,6 +1704,9 @@ if (import.meta.vitest != null) { }); it('handles invalid JSON lines gracefully', async () => { + //