Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 75 additions & 14 deletions apps/ccusage/src/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts';
import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts';
import { Buffer } from 'node:buffer';
import { createReadStream, createWriteStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { createInterface } from 'node:readline';
Expand Down Expand Up @@ -716,6 +716,34 @@ export async function globUsageFiles(claudePaths: string[]): Promise<GlobResult[
return (await Promise.all(filePromises)).flat();
}

async function filterFilesBySince(
files: string[],
since?: string,
timezone?: string,
): Promise<string[]> {
if (since == null || since.trim() === '') {
return files;
}

const sinceKey = since.replace(/-/g, '');
return (
await Promise.all(
files.map(async (file) => {
try {
const fileStat = await stat(file);
const dateKey = formatDate(new Date(fileStat.mtimeMs).toISOString(), timezone).replace(
/-/g,
'',
);
return dateKey >= sinceKey ? file : null;
} catch {
return file;
}
}),
)
).filter((file): file is string => file != null);
}

/**
* Date range filter for limiting usage data by date
*/
Expand Down Expand Up @@ -765,8 +793,11 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise<DailyUs
options?.project,
);

// Sort files by timestamp to ensure chronological processing
const sortedFiles = await sortFilesByTimestamp(projectFilteredFiles);
const sortedFiles = await filterFilesBySince(
projectFilteredFiles,
options?.since,
options?.timezone,
);

// Fetch pricing data for cost calculation only when needed
const mode = options?.mode ?? 'auto';
Expand All @@ -775,7 +806,10 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise<DailyUs
using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline);

// Track processed message+request combinations for deduplication
const processedHashes = new Set<string>();
const processedEntries = new Map<string, { timestamp: number; index: number }>();
const since = options?.since?.replace(/-/g, '');
const until = options?.until?.replace(/-/g, '');
const hasDateFilter = since != null || until != null;

// Collect all valid data entries first
const allEntries: {
Expand All @@ -799,24 +833,51 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise<DailyUs
}
const data = result.output;

// Check for duplicate message + request ID combination
const uniqueHash = createUniqueHash(data);
if (isDuplicateEntry(uniqueHash, processedHashes)) {
// Skip duplicate message
return;
// Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format
const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE);
if (hasDateFilter) {
const dateKey = date.replace(/-/g, '');
if (since != null && dateKey < since) {
return;
}
if (until != null && dateKey > until) {
return;
}
}

// Mark this combination as processed
markAsProcessed(uniqueHash, processedHashes);
const uniqueHash = createUniqueHash(data);
const timestampMs = new Date(data.timestamp).getTime();
if (uniqueHash != null) {
const existing = processedEntries.get(uniqueHash);
if (existing != null) {
// Keep the oldest entry for this message+request combination
if (!Number.isNaN(timestampMs) && timestampMs < existing.timestamp) {
const cost =
fetcher != null
? await calculateCostForEntry(data, mode, fetcher)
: (data.costUSD ?? 0);
allEntries[existing.index] = {
data,
date,
cost,
model: data.message.model,
project,
};
processedEntries.set(uniqueHash, { timestamp: timestampMs, index: existing.index });
}
return;
}
}

// Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format
const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE);
// 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);

allEntries.push({ data, date, cost, model: data.message.model, project });
const index = allEntries.push({ data, date, cost, model: data.message.model, project }) - 1;
if (uniqueHash != null && !Number.isNaN(timestampMs)) {
processedEntries.set(uniqueHash, { timestamp: timestampMs, index });
}
} catch {
// Skip invalid JSON lines
}
Expand Down
20 changes: 20 additions & 0 deletions apps/codex/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@
"bugs": {
"url": "https://github.com/ryoppippi/ccusage/issues"
},
"exports": {
".": "./src/index.ts",
"./data-loader": "./src/data-loader.ts",
"./daily-report": "./src/daily-report.ts",
"./monthly-report": "./src/monthly-report.ts",
"./session-report": "./src/session-report.ts",
"./pricing": "./src/pricing.ts",
"./types": "./src/_types.ts",
"./package.json": "./package.json"
},
"main": "./dist/index.js",
"module": "./dist/index.js",
"bin": {
Expand All @@ -26,6 +36,16 @@
"publishConfig": {
"bin": {
"ccusage-codex": "./dist/index.js"
},
"exports": {
".": "./dist/index.js",
"./data-loader": "./dist/data-loader.js",
"./daily-report": "./dist/daily-report.js",
"./monthly-report": "./dist/monthly-report.js",
"./session-report": "./dist/session-report.js",
"./pricing": "./dist/pricing.js",
"./types": "./dist/_types.js",
"./package.json": "./package.json"
}
},
"engines": {
Expand Down
4 changes: 3 additions & 1 deletion apps/codex/src/daily-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type DailyReportOptions = {
since?: string;
until?: string;
pricingSource: PricingSource;
formatDate?: boolean;
};

function createSummary(date: string, initialTimestamp: string): DailyUsageSummary {
Expand All @@ -40,6 +41,7 @@ export async function buildDailyReport(
const since = options.since;
const until = options.until;
const pricingSource = options.pricingSource;
const formatDate = options.formatDate ?? true;

const summaries = new Map<string, DailyUsageSummary>();

Expand Down Expand Up @@ -107,7 +109,7 @@ export async function buildDailyReport(
}

rows.push({
date: formatDisplayDate(summary.date, locale, timezone),
date: formatDate ? formatDisplayDate(summary.date, locale, timezone) : summary.date,
inputTokens: summary.inputTokens,
cachedInputTokens: summary.cachedInputTokens,
outputTokens: summary.outputTokens,
Expand Down
41 changes: 41 additions & 0 deletions apps/codex/src/data-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
DEFAULT_SESSION_SUBDIR,
SESSION_GLOB,
} from './_consts.ts';
import { toDateKey } from './date-utils.ts';
import { logger } from './logger.ts';

type RawUsage = {
Expand Down Expand Up @@ -177,6 +178,9 @@ function asNonEmptyString(value: unknown): string | undefined {

export type LoadOptions = {
sessionDirs?: string[];
since?: string; // YYYY-MM-DD or YYYYMMDD
until?: string; // YYYY-MM-DD or YYYYMMDD
timezone?: string;
};

export type LoadResult = {
Expand All @@ -185,6 +189,21 @@ export type LoadResult = {
};

export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise<LoadResult> {
const normalizeDateInput = (value?: string): string | undefined => {
if (value == null) {
return undefined;
}
const trimmed = value.trim();
if (trimmed === '') {
return undefined;
}
const compact = trimmed.replace(/-/g, '');
return /^\d{8}$/.test(compact) ? compact : undefined;
};

const since = normalizeDateInput(options.since);
const until = normalizeDateInput(options.until);

const providedDirs =
options.sessionDirs != null && options.sessionDirs.length > 0
? options.sessionDirs.map((dir) => path.resolve(dir))
Expand Down Expand Up @@ -222,6 +241,21 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise<L
});

for (const file of files) {
if (since != null) {
try {
const fileStat = await stat(file);
const dateKey = toDateKey(
new Date(fileStat.mtimeMs).toISOString(),
options.timezone,
).replace(/-/g, '');
if (dateKey < since) {
continue;
}
} catch {
// Continue when file stat fails.
}
}

const relativeSessionPath = path.relative(directoryPath, file);
const normalizedSessionPath = relativeSessionPath.split(path.sep).join('/');
const sessionId = normalizedSessionPath.replace(/\.jsonl$/i, '');
Expand Down Expand Up @@ -287,6 +321,13 @@ export async function loadTokenUsageEvents(options: LoadOptions = {}): Promise<L
if (timestamp == null) {
continue;
}
const dateKey = toDateKey(timestamp, options.timezone).replace(/-/g, '');
if (since != null && dateKey < since) {
continue;
}
if (until != null && dateKey > until) {
continue;
}

const info = tokenPayloadResult.output.info;
const lastUsage = normalizeRawUsage(info?.last_token_usage);
Expand Down
59 changes: 46 additions & 13 deletions apps/codex/src/date-utils.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,66 @@
const DEFAULT_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
const TIMEZONE_CACHE = new Map<string, string>();
const DATE_KEY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
const MONTH_KEY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();

function safeTimeZone(timezone?: string): string {
if (timezone == null || timezone.trim() === '') {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
return DEFAULT_TIMEZONE;
}

const trimmed = timezone.trim();
const cached = TIMEZONE_CACHE.get(trimmed);
if (cached != null) {
return cached;
}

try {
// Validate timezone by creating a formatter
Intl.DateTimeFormat('en-US', { timeZone: timezone });
return timezone;
Intl.DateTimeFormat('en-US', { timeZone: trimmed });
TIMEZONE_CACHE.set(trimmed, trimmed);
return trimmed;
} catch {
TIMEZONE_CACHE.set(trimmed, 'UTC');
return 'UTC';
}
}

export function toDateKey(timestamp: string, timezone?: string): string {
function getDateKeyFormatter(timezone?: string): Intl.DateTimeFormat {
const tz = safeTimeZone(timezone);
const date = new Date(timestamp);
const cached = DATE_KEY_FORMATTER_CACHE.get(tz);
if (cached != null) {
return cached;
}

const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: tz,
});
return formatter.format(date);
DATE_KEY_FORMATTER_CACHE.set(tz, formatter);
return formatter;
}

function getMonthKeyFormatter(timezone?: string): Intl.DateTimeFormat {
const tz = safeTimeZone(timezone);
const cached = MONTH_KEY_FORMATTER_CACHE.get(tz);
if (cached != null) {
return cached;
}

const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
timeZone: tz,
});
MONTH_KEY_FORMATTER_CACHE.set(tz, formatter);
return formatter;
}

export function toDateKey(timestamp: string, timezone?: string): string {
const date = new Date(timestamp);
return getDateKeyFormatter(timezone).format(date);
}

export function normalizeFilterDate(value?: string): string | undefined {
Expand Down Expand Up @@ -71,14 +110,8 @@ export function formatDisplayDate(dateKey: string, locale?: string, _timezone?:
}

export function toMonthKey(timestamp: string, timezone?: string): string {
const tz = safeTimeZone(timezone);
const date = new Date(timestamp);
const formatter = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
timeZone: tz,
});
const [year, month] = formatter.format(date).split('-');
const [year, month] = getMonthKeyFormatter(timezone).format(date).split('-');
return `${year}-${month}`;
}

Expand Down
4 changes: 3 additions & 1 deletion apps/codex/src/monthly-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type MonthlyReportOptions = {
since?: string;
until?: string;
pricingSource: PricingSource;
formatDate?: boolean;
};

function createSummary(month: string, initialTimestamp: string): MonthlyUsageSummary {
Expand All @@ -40,6 +41,7 @@ export async function buildMonthlyReport(
const since = options.since;
const until = options.until;
const pricingSource = options.pricingSource;
const formatDate = options.formatDate ?? true;

const summaries = new Map<string, MonthlyUsageSummary>();

Expand Down Expand Up @@ -108,7 +110,7 @@ export async function buildMonthlyReport(
}

rows.push({
month: formatDisplayMonth(summary.month, locale, timezone),
month: formatDate ? formatDisplayMonth(summary.month, locale, timezone) : summary.month,
inputTokens: summary.inputTokens,
cachedInputTokens: summary.cachedInputTokens,
outputTokens: summary.outputTokens,
Expand Down
10 changes: 9 additions & 1 deletion apps/codex/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { defineConfig } from 'tsdown';
import Macros from 'unplugin-macros/rolldown';

export default defineConfig({
entry: ['src/index.ts'],
entry: [
'src/index.ts',
'src/data-loader.ts',
'src/daily-report.ts',
'src/monthly-report.ts',
'src/session-report.ts',
'src/pricing.ts',
'src/_types.ts',
],
outDir: 'dist',
format: 'esm',
clean: true,
Expand Down
Loading