From fc0968887964e1346ad632f0032236addc2de8b9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Wed, 4 Mar 2026 01:28:27 -0500 Subject: [PATCH] refactor: implement phase 6 jsonl/usage modularization with async prefetch tests --- src/ccstatusline.ts | 4 + src/types/RenderContext.ts | 13 + src/utils/__tests__/jsonl-blocks.test.ts | 88 +++ src/utils/__tests__/jsonl-metrics.test.ts | 146 +++++ src/utils/__tests__/usage-fetch.test.ts | 167 +++++- src/utils/__tests__/usage-prefetch.test.ts | 74 +++ src/utils/jsonl-blocks.ts | 233 ++++++++ src/utils/jsonl-cache.ts | 133 +++++ src/utils/jsonl-lines.ts | 27 + src/utils/jsonl-metrics.ts | 132 +++++ src/utils/jsonl.ts | 507 +----------------- src/utils/usage-fetch.ts | 321 +++++++++++ src/utils/usage-prefetch.ts | 24 + src/utils/usage-types.ts | 27 + src/utils/usage-windows.ts | 117 ++++ src/utils/usage.ts | 476 +--------------- src/widgets/BlockResetTimer.ts | 3 +- src/widgets/BlockTimer.ts | 3 +- src/widgets/SessionUsage.ts | 3 +- src/widgets/WeeklyResetTimer.ts | 3 +- src/widgets/WeeklyUsage.ts | 3 +- src/widgets/__tests__/BlockResetTimer.test.ts | 18 +- src/widgets/__tests__/BlockTimer.test.ts | 17 +- src/widgets/__tests__/SessionUsage.test.ts | 27 +- .../__tests__/WeeklyResetTimer.test.ts | 18 +- src/widgets/__tests__/WeeklyUsage.test.ts | 26 +- 26 files changed, 1550 insertions(+), 1060 deletions(-) create mode 100644 src/utils/__tests__/jsonl-blocks.test.ts create mode 100644 src/utils/__tests__/jsonl-metrics.test.ts create mode 100644 src/utils/__tests__/usage-prefetch.test.ts create mode 100644 src/utils/jsonl-blocks.ts create mode 100644 src/utils/jsonl-cache.ts create mode 100644 src/utils/jsonl-lines.ts create mode 100644 src/utils/jsonl-metrics.ts create mode 100644 src/utils/usage-fetch.ts create mode 100644 src/utils/usage-prefetch.ts create mode 100644 src/utils/usage-types.ts create mode 100644 src/utils/usage-windows.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index e48dca2..fee23e7 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -22,6 +22,7 @@ import { renderStatusLine } from './utils/renderer'; import { advanceGlobalSeparatorIndex } from './utils/separator-index'; +import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch'; function hasSessionDurationInStatusJson(data: StatusJSON): boolean { const durationMs = data.cost?.total_duration_ms; @@ -95,10 +96,13 @@ async function renderMultipleLines(data: StatusJSON) { sessionDuration = await getSessionDuration(data.transcript_path); } + const usageData = await prefetchUsageDataIfNeeded(lines); + // Create render context const context: RenderContext = { data, tokenMetrics, + usageData, sessionDuration, isPreview: false }; diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e088c9..dde9f97 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -3,9 +3,22 @@ import type { BlockMetrics } from '../types'; import type { StatusJSON } from './StatusJSON'; import type { TokenMetrics } from './TokenMetrics'; +export interface RenderUsageData { + sessionUsage?: number; + sessionResetAt?: string; + weeklyUsage?: number; + weeklyResetAt?: string; + extraUsageEnabled?: boolean; + extraUsageLimit?: number; + extraUsageUsed?: number; + extraUsageUtilization?: number; + error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error'; +} + export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; + usageData?: RenderUsageData | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; terminalWidth?: number | null; diff --git a/src/utils/__tests__/jsonl-blocks.test.ts b/src/utils/__tests__/jsonl-blocks.test.ts new file mode 100644 index 0000000..ff74eb1 --- /dev/null +++ b/src/utils/__tests__/jsonl-blocks.test.ts @@ -0,0 +1,88 @@ +import * as fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { getBlockMetrics } from '../jsonl'; + +function floorToHourUtc(timestamp: Date): Date { + const floored = new Date(timestamp); + floored.setUTCMinutes(0, 0, 0); + return floored; +} + +function makeUsageLine(timestamp: Date): string { + return JSON.stringify({ + timestamp: timestamp.toISOString(), + message: { + usage: { + input_tokens: 100, + output_tokens: 50 + } + } + }); +} + +describe('jsonl block metrics integration', () => { + let tempClaudeDir: string; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-')); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tempClaudeDir; + }); + + afterEach(() => { + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + fs.rmSync(tempClaudeDir, { recursive: true, force: true }); + }); + + it('returns the current block start for recent activity after an older session gap', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'session.jsonl'); + + const now = new Date(); + const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000)); + const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000)); + const recentActivity = new Date(now.getTime() - (40 * 60 * 1000)); + + fs.writeFileSync(transcriptPath, [ + makeUsageLine(oldActivity), + makeUsageLine(currentBlockStartSource), + makeUsageLine(recentActivity) + ].join('\n')); + + const metrics = getBlockMetrics(); + + expect(metrics).not.toBeNull(); + expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString()); + expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString()); + }); + + it('returns null when the most recent activity is older than the session window', () => { + const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a'); + fs.mkdirSync(projectsDir, { recursive: true }); + const transcriptPath = path.join(projectsDir, 'stale-session.jsonl'); + + const now = new Date(); + const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000)); + + fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity)); + + const metrics = getBlockMetrics(); + + expect(metrics).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/jsonl-metrics.test.ts b/src/utils/__tests__/jsonl-metrics.test.ts new file mode 100644 index 0000000..c997008 --- /dev/null +++ b/src/utils/__tests__/jsonl-metrics.test.ts @@ -0,0 +1,146 @@ +import * as fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { + afterEach, + describe, + expect, + it +} from 'vitest'; + +import { + getSessionDuration, + getTokenMetrics +} from '../jsonl'; + +function makeUsageLine(params: { + timestamp: string; + input: number; + output: number; + cacheRead?: number; + cacheCreate?: number; + isSidechain?: boolean; + isApiErrorMessage?: boolean; +}): string { + return JSON.stringify({ + timestamp: params.timestamp, + isSidechain: params.isSidechain, + isApiErrorMessage: params.isApiErrorMessage, + message: { + usage: { + input_tokens: params.input, + output_tokens: params.output, + cache_read_input_tokens: params.cacheRead, + cache_creation_input_tokens: params.cacheCreate + } + } + }); +} + +describe('jsonl transcript metrics', () => { + const tempRoots: string[] = []; + + afterEach(() => { + while (tempRoots.length > 0) { + const root = tempRoots.pop(); + if (root) { + fs.rmSync(root, { recursive: true, force: true }); + } + } + }); + + it('formats session duration as <1m for sub-minute transcripts', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'short.jsonl'); + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }), + JSON.stringify({ timestamp: '2026-01-01T10:00:30.000Z' }) + ].join('\n')); + + const duration = await getSessionDuration(transcriptPath); + + expect(duration).toBe('<1m'); + }); + + it('formats multi-hour session durations', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'long.jsonl'); + fs.writeFileSync(transcriptPath, [ + JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }), + JSON.stringify({ timestamp: '2026-01-01T12:05:00.000Z' }) + ].join('\n')); + + const duration = await getSessionDuration(transcriptPath); + + expect(duration).toBe('2hr 5m'); + }); + + it('returns null for missing transcript files', async () => { + const duration = await getSessionDuration('/tmp/ccstatusline-jsonl-metrics-missing.jsonl'); + expect(duration).toBeNull(); + }); + + it('aggregates token totals and computes context length from the latest main-chain non-error entry', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-')); + tempRoots.push(root); + const transcriptPath = path.join(root, 'tokens.jsonl'); + + const lines = [ + makeUsageLine({ + timestamp: '2026-01-01T10:00:00.000Z', + input: 100, + output: 50, + cacheRead: 20, + cacheCreate: 10 + }), + makeUsageLine({ + timestamp: '2026-01-01T11:00:00.000Z', + input: 200, + output: 80, + cacheRead: 30, + cacheCreate: 20 + }), + makeUsageLine({ + timestamp: '2026-01-01T11:30:00.000Z', + input: 500, + output: 10, + cacheRead: 5, + cacheCreate: 5, + isSidechain: true + }), + makeUsageLine({ + timestamp: '2026-01-01T11:45:00.000Z', + input: 999, + output: 1, + cacheRead: 1, + cacheCreate: 1, + isApiErrorMessage: true + }) + ]; + + fs.writeFileSync(transcriptPath, lines.join('\n')); + + const metrics = await getTokenMetrics(transcriptPath); + + expect(metrics).toEqual({ + inputTokens: 1799, + outputTokens: 141, + cachedTokens: 92, + totalTokens: 2032, + contextLength: 250 + }); + }); + + it('returns zeroed token metrics when file is missing', async () => { + const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl'); + expect(metrics).toEqual({ + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0 + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/usage-fetch.test.ts b/src/utils/__tests__/usage-fetch.test.ts index aacb18e..22081de 100644 --- a/src/utils/__tests__/usage-fetch.test.ts +++ b/src/utils/__tests__/usage-fetch.test.ts @@ -10,9 +10,11 @@ import { } from 'vitest'; interface UsageProbeResult { - first: { error?: string }; - second: { error?: string }; + first: Record; + second: Record; lockExists: boolean; + cacheExists: boolean; + requestCount: number; } describe('fetchUsageData error handling', () => { @@ -25,42 +27,125 @@ describe('fetchUsageData error handling', () => { const apiErrorHome = path.join(tempRoot, 'home-api-error'); const apiErrorBin = path.join(tempRoot, 'bin-api-error'); const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error'); + const successHome = path.join(tempRoot, 'home-success'); + const successBin = path.join(tempRoot, 'bin-success'); + const successClaudeConfig = path.join(tempRoot, 'claude-success'); const securityScript = path.join(apiErrorBin, 'security'); + const successSecurityScript = path.join(successBin, 'security'); const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json'); + const successCredentialsFile = path.join(successClaudeConfig, '.credentials.json'); + const successResponseBody = JSON.stringify({ + five_hour: { + utilization: 42, + resets_at: '2030-01-01T00:00:00.000Z' + }, + seven_day: { + utilization: 17, + resets_at: '2030-01-07T00:00:00.000Z' + } + }); fs.mkdirSync(noCredentialsHome, { recursive: true }); fs.mkdirSync(apiErrorHome, { recursive: true }); fs.mkdirSync(apiErrorBin, { recursive: true }); fs.mkdirSync(apiErrorClaudeConfig, { recursive: true }); + fs.mkdirSync(successHome, { recursive: true }); + fs.mkdirSync(successBin, { recursive: true }); + fs.mkdirSync(successClaudeConfig, { recursive: true }); fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); fs.chmodSync(securityScript, 0o755); fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); + fs.writeFileSync(successSecurityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n'); + fs.chmodSync(successSecurityScript, 0o755); + fs.writeFileSync(successCredentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } })); const probeScript = ` import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { fetchUsageData } from ${JSON.stringify(usageModulePath)}; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const https = require('https'); +const mode = process.env.TEST_REQUEST_MODE || 'success'; +const responseBody = process.env.TEST_RESPONSE_BODY || ''; +let requestCount = 0; + +https.request = (...args) => { + requestCount += 1; + const callback = args.find(value => typeof value === 'function'); + const requestHandlers = new Map(); + const responseHandlers = new Map(); + + const response = { + statusCode: mode === 'success' ? 200 : 500, + setEncoding() {}, + on(event, handler) { + const existing = responseHandlers.get(event) || []; + existing.push(handler); + responseHandlers.set(event, existing); + return response; + } + }; + + const request = { + on(event, handler) { + const existing = requestHandlers.get(event) || []; + existing.push(handler); + requestHandlers.set(event, existing); + return request; + }, + destroy() {}, + end() { + if (mode === 'error') { + const handlers = requestHandlers.get('error') || []; + for (const handler of handlers) { + handler(new Error('mock request failure')); + } + return; + } + + if (mode === 'success') { + if (callback) { + callback(response); + } + const dataHandlers = responseHandlers.get('data') || []; + for (const handler of dataHandlers) { + handler(Buffer.from(responseBody)); + } + const endHandlers = responseHandlers.get('end') || []; + for (const handler of endHandlers) { + handler(); + } + return; + } + + const handlers = requestHandlers.get('error') || []; + for (const handler of handlers) { + handler(new Error('unexpected request')); + } + } + }; + + return request; +}; + +const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)}); const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock'); -const nowMs = Date.now() + (10 * 365 * 24 * 60 * 60 * 1000); +const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json'); +const nowMs = Number(process.env.TEST_NOW_MS || Date.now()); Date.now = () => nowMs; -if (process.env.TEST_FORCE_BAD_EXEC_PATH === '1') { - Object.defineProperty(process, 'execPath', { - value: '/nonexistent-runtime', - writable: true, - configurable: true - }); -} - -const first = fetchUsageData(); -const second = fetchUsageData(); +const first = await fetchUsageData(); +const second = await fetchUsageData(); process.stdout.write(JSON.stringify({ first, second, - lockExists: fs.existsSync(lockFile) + lockExists: fs.existsSync(lockFile), + cacheExists: fs.existsSync(cacheFile), + requestCount })); `; @@ -73,7 +158,8 @@ process.stdout.write(JSON.stringify({ ...process.env, HOME: noCredentialsHome, PATH: '/nonexistent', - TEST_FORCE_BAD_EXEC_PATH: '0' + TEST_REQUEST_MODE: 'unexpected', + TEST_NOW_MS: '2200000000000' } }); @@ -81,6 +167,8 @@ process.stdout.write(JSON.stringify({ expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' }); expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' }); expect(noCredentialsResult.lockExists).toBe(false); + expect(noCredentialsResult.cacheExists).toBe(false); + expect(noCredentialsResult.requestCount).toBe(0); const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], { encoding: 'utf8', @@ -89,13 +177,58 @@ process.stdout.write(JSON.stringify({ HOME: apiErrorHome, PATH: apiErrorBin, CLAUDE_CONFIG_DIR: apiErrorClaudeConfig, - TEST_FORCE_BAD_EXEC_PATH: '1' + TEST_REQUEST_MODE: 'error', + TEST_NOW_MS: '2200000000000' } }); const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult; expect(apiErrorResult.first).toEqual({ error: 'api-error' }); expect(apiErrorResult.second).toEqual({ error: 'api-error' }); + expect(apiErrorResult.cacheExists).toBe(false); + expect(apiErrorResult.requestCount).toBe(1); + + const successOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: successHome, + PATH: successBin, + CLAUDE_CONFIG_DIR: successClaudeConfig, + TEST_REQUEST_MODE: 'success', + TEST_RESPONSE_BODY: successResponseBody, + TEST_NOW_MS: '2200000000000' + } + }); + + const successResult = JSON.parse(successOutput) as UsageProbeResult; + expect(successResult.first).toEqual({ + sessionUsage: 42, + sessionResetAt: '2030-01-01T00:00:00.000Z', + weeklyUsage: 17, + weeklyResetAt: '2030-01-07T00:00:00.000Z' + }); + expect(successResult.second).toEqual(successResult.first); + expect(successResult.cacheExists).toBe(true); + expect(successResult.requestCount).toBe(1); + + const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], { + encoding: 'utf8', + env: { + ...process.env, + HOME: successHome, + PATH: successBin, + CLAUDE_CONFIG_DIR: successClaudeConfig, + TEST_REQUEST_MODE: 'unexpected', + TEST_NOW_MS: '2200000000000' + } + }); + + const cachedSuccessResult = JSON.parse(cachedSuccessOutput) as UsageProbeResult; + expect(cachedSuccessResult.first).toEqual(successResult.first); + expect(cachedSuccessResult.second).toEqual(successResult.first); + expect(cachedSuccessResult.cacheExists).toBe(true); + expect(cachedSuccessResult.requestCount).toBe(1); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts new file mode 100644 index 0000000..f07c8ca --- /dev/null +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -0,0 +1,74 @@ +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import type { WidgetItem } from '../../types/Widget'; +import { fetchUsageData } from '../usage'; +import { + hasUsageDependentWidgets, + prefetchUsageDataIfNeeded +} from '../usage-prefetch'; + +vi.mock('../usage', () => ({ fetchUsageData: vi.fn() })); + +const mockFetchUsageData = fetchUsageData as unknown as { + mockClear: () => void; + mockResolvedValue: (value: unknown) => void; + mock: { calls: unknown[][] }; +}; + +function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] { + return lineItems; +} + +describe('usage prefetch', () => { + it('detects when usage widgets are present', () => { + const lines = makeLines( + [{ id: '1', type: 'model' }], + [{ id: '2', type: 'block-timer' }] + ); + + expect(hasUsageDependentWidgets(lines)).toBe(true); + }); + + it('does not detect usage requirement for non-usage widgets', () => { + const lines = makeLines( + [{ id: '1', type: 'model' }], + [{ id: '2', type: 'git-branch' }] + ); + + expect(hasUsageDependentWidgets(lines)).toBe(false); + }); + + it('fetches usage data once when at least one usage widget exists', async () => { + mockFetchUsageData.mockClear(); + mockFetchUsageData.mockResolvedValue({ sessionUsage: 12.3 }); + + const lines = makeLines( + [{ id: '1', type: 'model' }], + [{ id: '2', type: 'session-usage' }, { id: '3', type: 'weekly-usage' }] + ); + + const usageData = await prefetchUsageDataIfNeeded(lines); + + expect(usageData).toEqual({ sessionUsage: 12.3 }); + expect(mockFetchUsageData.mock.calls.length).toBe(1); + }); + + it('does not fetch usage data when no usage widgets exist', async () => { + mockFetchUsageData.mockClear(); + + const lines = makeLines( + [{ id: '1', type: 'model' }], + [{ id: '2', type: 'git-branch' }] + ); + + const usageData = await prefetchUsageDataIfNeeded(lines); + + expect(usageData).toBeNull(); + expect(mockFetchUsageData.mock.calls.length).toBe(0); + }); +}); \ No newline at end of file diff --git a/src/utils/jsonl-blocks.ts b/src/utils/jsonl-blocks.ts new file mode 100644 index 0000000..98d4d55 --- /dev/null +++ b/src/utils/jsonl-blocks.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs'; +import path from 'node:path'; +import { globSync } from 'tinyglobby'; + +import type { BlockMetrics } from '../types'; + +import { getClaudeConfigDir } from './claude-settings'; +import { + parseJsonlLine, + readJsonlLinesSync +} from './jsonl-lines'; + +const statSync = fs.statSync; + +/** + * Gets block metrics for the current 5-hour block from JSONL files + */ +export function getBlockMetrics(): BlockMetrics | null { + const claudeDir: string | null = getClaudeConfigDir(); + + if (!claudeDir) + return null; + + try { + return findMostRecentBlockStartTime(claudeDir); + } catch { + return null; + } +} + +/** + * Efficiently finds the most recent 5-hour block start time from JSONL files + * Uses file modification times as hints to avoid unnecessary reads + */ +function findMostRecentBlockStartTime( + rootDir: string, + sessionDurationHours = 5 +): BlockMetrics | null { + const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; + const now = new Date(); + + // Step 1: Find all JSONL files with their modification times + // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) + const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); + const files = globSync([pattern], { + absolute: true, // Ensure we get absolute paths + cwd: rootDir // Set working directory to rootDir + }); + + if (files.length === 0) + return null; + + // Step 2: Get file stats and sort by modification time (most recent first) + const filesWithStats = files.map((file) => { + const stats = statSync(file); + return { file, mtime: stats.mtime }; + }); + + filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + + // Step 3: Progressive lookback - start small and expand if needed + // Start with 2x session duration (10 hours), expand to 48 hours if needed + const lookbackChunks = [ + 10, // 2x session duration - catches most cases + 20, // 4x session duration - catches longer sessions + 48 // Maximum lookback for marathon sessions + ]; + + let timestamps: Date[] = []; + let mostRecentTimestamp: Date | null = null; + let continuousWorkStart: Date | null = null; + let foundSessionGap = false; + + for (const lookbackHours of lookbackChunks) { + const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); + timestamps = []; + + // Collect timestamps for this lookback period + for (const { file, mtime } of filesWithStats) { + if (mtime.getTime() < cutoffTime.getTime()) { + break; + } + const fileTimestamps = getAllTimestampsFromFile(file); + timestamps.push(...fileTimestamps); + } + + if (timestamps.length === 0) { + continue; // Try next chunk + } + + // Sort timestamps (most recent first) + timestamps.sort((a, b) => b.getTime() - a.getTime()); + + // Get most recent timestamp (only set once) + if (!mostRecentTimestamp && timestamps[0]) { + mostRecentTimestamp = timestamps[0]; + + // Check if the most recent activity is within the current session period + const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime(); + if (timeSinceLastActivity > sessionDurationMs) { + // No activity within the current session period + return null; + } + } + + // Look for a session gap in this chunk + continuousWorkStart = mostRecentTimestamp; + for (let i = 1; i < timestamps.length; i++) { + const currentTimestamp = timestamps[i]; + const previousTimestamp = timestamps[i - 1]; + + if (!currentTimestamp || !previousTimestamp) + continue; + + const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); + + if (gap >= sessionDurationMs) { + // Found a true session boundary + foundSessionGap = true; + break; + } + + continuousWorkStart = currentTimestamp; + } + + // If we found a gap, we're done + if (foundSessionGap) { + break; + } + + // If this was our last chunk, use what we have + if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { + break; + } + } + + if (!mostRecentTimestamp || !continuousWorkStart) { + return null; + } + + // Build actual blocks from timestamps going forward + const blocks: { start: Date; end: Date }[] = []; + const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime()); + + let currentBlockStart: Date | null = null; + let currentBlockEnd: Date | null = null; + + for (const timestamp of sortedTimestamps) { + if (timestamp.getTime() < continuousWorkStart.getTime()) + continue; + + if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) { + // Start new block + currentBlockStart = floorToHour(timestamp); + currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs); + blocks.push({ start: currentBlockStart, end: currentBlockEnd }); + } + } + + // Find current block + for (const block of blocks) { + if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) { + // Verify we have activity in this block + const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime() + && t.getTime() <= block.end.getTime() + ); + + if (hasActivity) { + return { + startTime: block.start, + lastActivity: mostRecentTimestamp + }; + } + } + } + + return null; +} + +/** + * Gets all timestamps from a JSONL file + */ +function getAllTimestampsFromFile(filePath: string): Date[] { + const timestamps: Date[] = []; + try { + const lines = readJsonlLinesSync(filePath); + + for (const line of lines) { + const json = parseJsonlLine(line) as { + timestamp?: string; + isSidechain?: boolean; + message?: { usage?: { input_tokens?: number; output_tokens?: number } }; + } | null; + if (!json) { + continue; + } + + // Only treat entries with real token usage as block activity + const usage = json.message?.usage; + if (!usage) + continue; + + const hasInputTokens = typeof usage.input_tokens === 'number'; + const hasOutputTokens = typeof usage.output_tokens === 'number'; + if (!hasInputTokens || !hasOutputTokens) + continue; + + if (json.isSidechain === true) + continue; + + const timestamp = json.timestamp; + if (typeof timestamp !== 'string') + continue; + + const date = new Date(timestamp); + if (!Number.isNaN(date.getTime())) + timestamps.push(date); + } + + return timestamps; + } catch { + return []; + } +} + +/** + * Floors a timestamp to the beginning of the hour (matching existing logic) + */ +function floorToHour(timestamp: Date): Date { + const floored = new Date(timestamp); + floored.setUTCMinutes(0, 0, 0); + return floored; +} \ No newline at end of file diff --git a/src/utils/jsonl-cache.ts b/src/utils/jsonl-cache.ts new file mode 100644 index 0000000..2c65bab --- /dev/null +++ b/src/utils/jsonl-cache.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import { createHash } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; + +import type { BlockMetrics } from '../types'; + +import { getClaudeConfigDir } from './claude-settings'; +import { getBlockMetrics } from './jsonl-blocks'; + +const readFileSync = fs.readFileSync; +const writeFileSync = fs.writeFileSync; +const mkdirSync = fs.mkdirSync; +const existsSync = fs.existsSync; + +interface BlockCache { + startTime: string; + configDir?: string; +} + +function normalizeConfigDir(configDir: string): string { + return path.resolve(configDir); +} + +/** + * Returns the path to the block cache file for a specific Claude config directory + */ +export function getBlockCachePath(configDir = getClaudeConfigDir()): string { + const normalizedConfigDir = normalizeConfigDir(configDir); + const configHash = createHash('sha256') + .update(normalizedConfigDir) + .digest('hex') + .slice(0, 16); + + return path.join( + os.homedir(), + '.cache', + 'ccstatusline', + `block-cache-${configHash}.json` + ); +} + +/** + * Reads the block cache file and returns the cached start time + * Returns null if cache doesn't exist or is invalid + */ +export function readBlockCache(expectedConfigDir?: string): Date | null { + try { + const normalizedExpectedConfigDir = expectedConfigDir !== undefined + ? normalizeConfigDir(expectedConfigDir) + : undefined; + const cachePath = getBlockCachePath(normalizedExpectedConfigDir); + if (!existsSync(cachePath)) { + return null; + } + const content = readFileSync(cachePath, 'utf-8'); + const cache = JSON.parse(content) as BlockCache; + if (typeof cache.startTime !== 'string') { + return null; + } + if (normalizedExpectedConfigDir !== undefined) { + if (typeof cache.configDir !== 'string') { + return null; + } + if (cache.configDir !== normalizedExpectedConfigDir) { + return null; + } + } + const date = new Date(cache.startTime); + if (Number.isNaN(date.getTime())) { + return null; + } + return date; + } catch { + return null; + } +} + +/** + * Writes the block start time to the cache file + * Creates the cache directory if it doesn't exist + */ +export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void { + try { + const normalizedConfigDir = normalizeConfigDir(configDir); + const cachePath = getBlockCachePath(normalizedConfigDir); + const cacheDir = path.dirname(cachePath); + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } + const cache: BlockCache = { + startTime: startTime.toISOString(), + configDir: normalizedConfigDir + }; + writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); + } catch { + // Silently fail - caching is best-effort + } +} + +/** + * Gets block metrics with caching support + * Returns cached result if still valid, otherwise recalculates + */ +export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null { + const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; + const now = new Date(); + const activeConfigDir = getClaudeConfigDir(); + + // Check cache first + const cachedStartTime = readBlockCache(activeConfigDir); + if (cachedStartTime) { + const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs); + if (now.getTime() <= blockEndTime.getTime()) { + // Cache is valid - return cached result + return { + startTime: cachedStartTime, + lastActivity: now // We don't cache lastActivity, use current time + }; + } + // Cache expired - need to recalculate + } + + // Cache miss or expired - run full calculation + const metrics = getBlockMetrics(); + + // Write to cache if we found a valid block + if (metrics) { + writeBlockCache(metrics.startTime, activeConfigDir); + } + + return metrics; +} \ No newline at end of file diff --git a/src/utils/jsonl-lines.ts b/src/utils/jsonl-lines.ts new file mode 100644 index 0000000..18395c6 --- /dev/null +++ b/src/utils/jsonl-lines.ts @@ -0,0 +1,27 @@ +import * as fs from 'fs'; +import { promisify } from 'util'; + +const readFile = promisify(fs.readFile); +const readFileSync = fs.readFileSync; + +function splitJsonlContent(content: string): string[] { + return content.trim().split('\n').filter(line => line.length > 0); +} + +export async function readJsonlLines(filePath: string): Promise { + const content = await readFile(filePath, 'utf-8'); + return splitJsonlContent(content); +} + +export function readJsonlLinesSync(filePath: string): string[] { + const content = readFileSync(filePath, 'utf-8'); + return splitJsonlContent(content); +} + +export function parseJsonlLine(line: string): unknown { + try { + return JSON.parse(line) as unknown; + } catch { + return null; + } +} \ No newline at end of file diff --git a/src/utils/jsonl-metrics.ts b/src/utils/jsonl-metrics.ts new file mode 100644 index 0000000..90425dc --- /dev/null +++ b/src/utils/jsonl-metrics.ts @@ -0,0 +1,132 @@ +import * as fs from 'fs'; + +import type { + TokenMetrics, + TranscriptLine +} from '../types'; + +import { + parseJsonlLine, + readJsonlLines +} from './jsonl-lines'; + +export async function getSessionDuration(transcriptPath: string): Promise { + try { + if (!fs.existsSync(transcriptPath)) { + return null; + } + + const lines = await readJsonlLines(transcriptPath); + + if (lines.length === 0) { + return null; + } + + let firstTimestamp: Date | null = null; + let lastTimestamp: Date | null = null; + + // Find first valid timestamp + for (const line of lines) { + const data = parseJsonlLine(line) as { timestamp?: string } | null; + if (data?.timestamp) { + firstTimestamp = new Date(data.timestamp); + break; + } + } + + // Find last valid timestamp (iterate backwards) + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) { + continue; + } + + const data = parseJsonlLine(line) as { timestamp?: string } | null; + if (data?.timestamp) { + lastTimestamp = new Date(data.timestamp); + break; + } + } + + if (!firstTimestamp || !lastTimestamp) { + return null; + } + + // Calculate duration in milliseconds + const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); + + // Convert to minutes + const totalMinutes = Math.floor(durationMs / (1000 * 60)); + + if (totalMinutes < 1) { + return '<1m'; + } + + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours === 0) { + return `${minutes}m`; + } else if (minutes === 0) { + return `${hours}hr`; + } else { + return `${hours}hr ${minutes}m`; + } + } catch { + return null; + } +} + +export async function getTokenMetrics(transcriptPath: string): Promise { + try { + // Use Node.js-compatible file reading + if (!fs.existsSync(transcriptPath)) { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } + + const lines = await readJsonlLines(transcriptPath); + + let inputTokens = 0; + let outputTokens = 0; + let cachedTokens = 0; + let contextLength = 0; + + // Parse each line and sum up token usage for totals + let mostRecentMainChainEntry: TranscriptLine | null = null; + let mostRecentTimestamp: Date | null = null; + + for (const line of lines) { + const data = parseJsonlLine(line) as TranscriptLine | null; + if (data?.message?.usage) { + inputTokens += data.message.usage.input_tokens || 0; + outputTokens += data.message.usage.output_tokens || 0; + cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; + cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; + + // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) + // Also skip API error messages (synthetic messages with 0 tokens) + if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) { + const entryTime = new Date(data.timestamp); + if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { + mostRecentTimestamp = entryTime; + mostRecentMainChainEntry = data; + } + } + } + } + + // Calculate context length from the most recent main chain message + if (mostRecentMainChainEntry?.message?.usage) { + const usage = mostRecentMainChainEntry.message.usage; + contextLength = (usage.input_tokens || 0) + + (usage.cache_read_input_tokens ?? 0) + + (usage.cache_creation_input_tokens ?? 0); + } + + const totalTokens = inputTokens + outputTokens + cachedTokens; + + return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; + } catch { + return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; + } +} \ No newline at end of file diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index 6d35a1d..39a04c8 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -1,496 +1,11 @@ -import * as fs from 'fs'; -import { createHash } from 'node:crypto'; -import os from 'node:os'; -import path from 'node:path'; -import { globSync } from 'tinyglobby'; -import { promisify } from 'util'; - -import type { - BlockMetrics, - TokenMetrics, - TranscriptLine -} from '../types'; - -import { getClaudeConfigDir } from './claude-settings'; - -// Ensure fs.promises compatibility for older Node versions -const readFile = promisify(fs.readFile); -const readFileSync = fs.readFileSync; -const statSync = fs.statSync; -const writeFileSync = fs.writeFileSync; -const mkdirSync = fs.mkdirSync; -const existsSync = fs.existsSync; - -// --- Block Cache Functions --- - -interface BlockCache { - startTime: string; - configDir?: string; -} - -function normalizeConfigDir(configDir: string): string { - return path.resolve(configDir); -} - -/** - * Returns the path to the block cache file for a specific Claude config directory - */ -export function getBlockCachePath(configDir = getClaudeConfigDir()): string { - const normalizedConfigDir = normalizeConfigDir(configDir); - const configHash = createHash('sha256') - .update(normalizedConfigDir) - .digest('hex') - .slice(0, 16); - - return path.join( - os.homedir(), - '.cache', - 'ccstatusline', - `block-cache-${configHash}.json` - ); -} - -/** - * Reads the block cache file and returns the cached start time - * Returns null if cache doesn't exist or is invalid - */ -export function readBlockCache(expectedConfigDir?: string): Date | null { - try { - const normalizedExpectedConfigDir = expectedConfigDir !== undefined - ? normalizeConfigDir(expectedConfigDir) - : undefined; - const cachePath = getBlockCachePath(normalizedExpectedConfigDir); - if (!existsSync(cachePath)) { - return null; - } - const content = readFileSync(cachePath, 'utf-8'); - const cache = JSON.parse(content) as BlockCache; - if (typeof cache.startTime !== 'string') { - return null; - } - if (normalizedExpectedConfigDir !== undefined) { - if (typeof cache.configDir !== 'string') { - return null; - } - if (cache.configDir !== normalizedExpectedConfigDir) { - return null; - } - } - const date = new Date(cache.startTime); - if (Number.isNaN(date.getTime())) { - return null; - } - return date; - } catch { - return null; - } -} - -/** - * Writes the block start time to the cache file - * Creates the cache directory if it doesn't exist - */ -export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void { - try { - const normalizedConfigDir = normalizeConfigDir(configDir); - const cachePath = getBlockCachePath(normalizedConfigDir); - const cacheDir = path.dirname(cachePath); - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }); - } - const cache: BlockCache = { - startTime: startTime.toISOString(), - configDir: normalizedConfigDir - }; - writeFileSync(cachePath, JSON.stringify(cache), 'utf-8'); - } catch { - // Silently fail - caching is best-effort - } -} - -/** - * Gets block metrics with caching support - * Returns cached result if still valid, otherwise recalculates - */ -export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null { - const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; - const now = new Date(); - const activeConfigDir = getClaudeConfigDir(); - - // Check cache first - const cachedStartTime = readBlockCache(activeConfigDir); - if (cachedStartTime) { - const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs); - if (now.getTime() <= blockEndTime.getTime()) { - // Cache is valid - return cached result - return { - startTime: cachedStartTime, - lastActivity: now // We don't cache lastActivity, use current time - }; - } - // Cache expired - need to recalculate - } - - // Cache miss or expired - run full calculation - const metrics = getBlockMetrics(); - - // Write to cache if we found a valid block - if (metrics) { - writeBlockCache(metrics.startTime, activeConfigDir); - } - - return metrics; -} - -export async function getSessionDuration(transcriptPath: string): Promise { - try { - if (!fs.existsSync(transcriptPath)) { - return null; - } - - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n').filter((line: string) => line.trim()); - - if (lines.length === 0) { - return null; - } - - let firstTimestamp: Date | null = null; - let lastTimestamp: Date | null = null; - - // Find first valid timestamp - for (const line of lines) { - try { - const data = JSON.parse(line) as { timestamp?: string }; - if (data.timestamp) { - firstTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } - - // Find last valid timestamp (iterate backwards) - for (let i = lines.length - 1; i >= 0; i--) { - try { - const data = JSON.parse(lines[i] ?? '') as { timestamp?: string }; - if (data.timestamp) { - lastTimestamp = new Date(data.timestamp); - break; - } - } catch { - // Skip invalid lines - } - } - - if (!firstTimestamp || !lastTimestamp) { - return null; - } - - // Calculate duration in milliseconds - const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime(); - - // Convert to minutes - const totalMinutes = Math.floor(durationMs / (1000 * 60)); - - if (totalMinutes < 1) { - return '<1m'; - } - - const hours = Math.floor(totalMinutes / 60); - const minutes = totalMinutes % 60; - - if (hours === 0) { - return `${minutes}m`; - } else if (minutes === 0) { - return `${hours}hr`; - } else { - return `${hours}hr ${minutes}m`; - } - } catch { - return null; - } -} - -export async function getTokenMetrics(transcriptPath: string): Promise { - try { - // Use Node.js-compatible file reading - if (!fs.existsSync(transcriptPath)) { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } - - const content = await readFile(transcriptPath, 'utf-8'); - const lines = content.trim().split('\n'); - - let inputTokens = 0; - let outputTokens = 0; - let cachedTokens = 0; - let contextLength = 0; - - // Parse each line and sum up token usage for totals - let mostRecentMainChainEntry: TranscriptLine | null = null; - let mostRecentTimestamp: Date | null = null; - - for (const line of lines) { - try { - const data = JSON.parse(line) as TranscriptLine; - if (data.message?.usage) { - inputTokens += data.message.usage.input_tokens || 0; - outputTokens += data.message.usage.output_tokens || 0; - cachedTokens += data.message.usage.cache_read_input_tokens ?? 0; - cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0; - - // Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain) - // Also skip API error messages (synthetic messages with 0 tokens) - if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) { - const entryTime = new Date(data.timestamp); - if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) { - mostRecentTimestamp = entryTime; - mostRecentMainChainEntry = data; - } - } - } - } catch { - // Skip invalid JSON lines - } - } - - // Calculate context length from the most recent main chain message - if (mostRecentMainChainEntry?.message?.usage) { - const usage = mostRecentMainChainEntry.message.usage; - contextLength = (usage.input_tokens || 0) - + (usage.cache_read_input_tokens ?? 0) - + (usage.cache_creation_input_tokens ?? 0); - } - - const totalTokens = inputTokens + outputTokens + cachedTokens; - - return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength }; - } catch { - return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 }; - } -} - -/** - * Gets block metrics for the current 5-hour block from JSONL files - */ -export function getBlockMetrics(): BlockMetrics | null { - const claudeDir: string | null = getClaudeConfigDir(); - - if (!claudeDir) - return null; - - try { - return findMostRecentBlockStartTime(claudeDir); - } catch { - return null; - } -} - -/** - * Efficiently finds the most recent 5-hour block start time from JSONL files - * Uses file modification times as hints to avoid unnecessary reads - */ -function findMostRecentBlockStartTime( - rootDir: string, - sessionDurationHours = 5 -): BlockMetrics | null { - const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000; - const now = new Date(); - - // Step 1: Find all JSONL files with their modification times - // Use forward slashes for glob patterns on all platforms (tinyglobby requirement) - const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); - const files = globSync([pattern], { - absolute: true, // Ensure we get absolute paths - cwd: rootDir // Set working directory to rootDir - }); - - if (files.length === 0) - return null; - - // Step 2: Get file stats and sort by modification time (most recent first) - const filesWithStats = files.map((file) => { - const stats = statSync(file); - return { file, mtime: stats.mtime }; - }); - - filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); - - // Step 3: Progressive lookback - start small and expand if needed - // Start with 2x session duration (10 hours), expand to 48 hours if needed - const lookbackChunks = [ - 10, // 2x session duration - catches most cases - 20, // 4x session duration - catches longer sessions - 48 // Maximum lookback for marathon sessions - ]; - - let timestamps: Date[] = []; - let mostRecentTimestamp: Date | null = null; - let continuousWorkStart: Date | null = null; - let foundSessionGap = false; - - for (const lookbackHours of lookbackChunks) { - const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); - timestamps = []; - - // Collect timestamps for this lookback period - for (const { file, mtime } of filesWithStats) { - if (mtime.getTime() < cutoffTime.getTime()) { - break; - } - const fileTimestamps = getAllTimestampsFromFile(file); - timestamps.push(...fileTimestamps); - } - - if (timestamps.length === 0) { - continue; // Try next chunk - } - - // Sort timestamps (most recent first) - timestamps.sort((a, b) => b.getTime() - a.getTime()); - - // Get most recent timestamp (only set once) - if (!mostRecentTimestamp && timestamps[0]) { - mostRecentTimestamp = timestamps[0]; - - // Check if the most recent activity is within the current session period - const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime(); - if (timeSinceLastActivity > sessionDurationMs) { - // No activity within the current session period - return null; - } - } - - // Look for a session gap in this chunk - continuousWorkStart = mostRecentTimestamp; - for (let i = 1; i < timestamps.length; i++) { - const currentTimestamp = timestamps[i]; - const previousTimestamp = timestamps[i - 1]; - - if (!currentTimestamp || !previousTimestamp) - continue; - - const gap = previousTimestamp.getTime() - currentTimestamp.getTime(); - - if (gap >= sessionDurationMs) { - // Found a true session boundary - foundSessionGap = true; - break; - } - - continuousWorkStart = currentTimestamp; - } - - // If we found a gap, we're done - if (foundSessionGap) { - break; - } - - // If this was our last chunk, use what we have - if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) { - break; - } - } - - if (!mostRecentTimestamp || !continuousWorkStart) { - return null; - } - - // Build actual blocks from timestamps going forward - const blocks: { start: Date; end: Date }[] = []; - const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime()); - - let currentBlockStart: Date | null = null; - let currentBlockEnd: Date | null = null; - - for (const timestamp of sortedTimestamps) { - if (timestamp.getTime() < continuousWorkStart.getTime()) - continue; - - if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) { - // Start new block - currentBlockStart = floorToHour(timestamp); - currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs); - blocks.push({ start: currentBlockStart, end: currentBlockEnd }); - } - } - - // Find current block - for (const block of blocks) { - if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) { - // Verify we have activity in this block - const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime() - && t.getTime() <= block.end.getTime() - ); - - if (hasActivity) { - return { - startTime: block.start, - lastActivity: mostRecentTimestamp - }; - } - } - } - - return null; -} - -/** - * Gets all timestamps from a JSONL file - */ -function getAllTimestampsFromFile(filePath: string): Date[] { - const timestamps: Date[] = []; - try { - const content = readFileSync(filePath, 'utf-8'); - const lines = content.trim().split('\n').filter(line => line.length > 0); - - for (const line of lines) { - try { - const json = JSON.parse(line) as { - timestamp?: string; - isSidechain?: boolean; - message?: { usage?: { input_tokens?: number; output_tokens?: number } }; - }; - - // Only treat entries with real token usage as block activity - const usage = json.message?.usage; - if (!usage) - continue; - - const hasInputTokens = typeof usage.input_tokens === 'number'; - const hasOutputTokens = typeof usage.output_tokens === 'number'; - if (!hasInputTokens || !hasOutputTokens) - continue; - - if (json.isSidechain === true) - continue; - - const timestamp = json.timestamp; - if (typeof timestamp !== 'string') - continue; - - const date = new Date(timestamp); - if (!Number.isNaN(date.getTime())) - timestamps.push(date); - } catch { - // Skip invalid JSON lines - continue; - } - } - - return timestamps; - } catch { - return []; - } -} - -/** - * Floors a timestamp to the beginning of the hour (matching existing logic) - */ -function floorToHour(timestamp: Date): Date { - const floored = new Date(timestamp); - floored.setUTCMinutes(0, 0, 0); - return floored; -} \ No newline at end of file +export { + getBlockCachePath, + getCachedBlockMetrics, + readBlockCache, + writeBlockCache +} from './jsonl-cache'; +export { getBlockMetrics } from './jsonl-blocks'; +export { + getSessionDuration, + getTokenMetrics +} from './jsonl-metrics'; \ No newline at end of file diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts new file mode 100644 index 0000000..678bf34 --- /dev/null +++ b/src/utils/usage-fetch.ts @@ -0,0 +1,321 @@ +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as https from 'https'; +import * as os from 'os'; +import * as path from 'path'; +import { z } from 'zod'; + +import { getClaudeConfigDir } from './claude-settings'; +import type { + UsageData, + UsageError +} from './usage-types'; +import { UsageErrorSchema } from './usage-types'; + +// Cache configuration +const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline'); +const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); +const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock'); +const CACHE_MAX_AGE = 180; // seconds +const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds +const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour + +const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); + +const CachedUsageDataSchema = z.object({ + sessionUsage: z.number().nullable().optional(), + sessionResetAt: z.string().nullable().optional(), + weeklyUsage: z.number().nullable().optional(), + weeklyResetAt: z.string().nullable().optional(), + extraUsageEnabled: z.boolean().nullable().optional(), + extraUsageLimit: z.number().nullable().optional(), + extraUsageUsed: z.number().nullable().optional(), + extraUsageUtilization: z.number().nullable().optional(), + error: z.string().nullable().optional() +}); + +const UsageApiResponseSchema = z.object({ + five_hour: z.object({ + utilization: z.number().nullable().optional(), + resets_at: z.string().nullable().optional() + }).optional(), + seven_day: z.object({ + utilization: z.number().nullable().optional(), + resets_at: z.string().nullable().optional() + }).optional(), + extra_usage: z.object({ + is_enabled: z.boolean().nullable().optional(), + monthly_limit: z.number().nullable().optional(), + used_credits: z.number().nullable().optional(), + utilization: z.number().nullable().optional() + }).optional() +}); + +function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null { + try { + const parsed = schema.safeParse(JSON.parse(rawJson)); + return parsed.success ? parsed.data : null; + } catch { + return null; + } +} + +function parseUsageAccessToken(rawJson: string): string | null { + const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema); + return parsed?.claudeAiOauth?.accessToken ?? null; +} + +function parseCachedUsageData(rawJson: string): UsageData | null { + const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema); + if (!parsed) { + return null; + } + + const parsedError = UsageErrorSchema.safeParse(parsed.error); + + return { + sessionUsage: parsed.sessionUsage ?? undefined, + sessionResetAt: parsed.sessionResetAt ?? undefined, + weeklyUsage: parsed.weeklyUsage ?? undefined, + weeklyResetAt: parsed.weeklyResetAt ?? undefined, + extraUsageEnabled: parsed.extraUsageEnabled ?? undefined, + extraUsageLimit: parsed.extraUsageLimit ?? undefined, + extraUsageUsed: parsed.extraUsageUsed ?? undefined, + extraUsageUtilization: parsed.extraUsageUtilization ?? undefined, + error: parsedError.success ? parsedError.data : undefined + }; +} + +function parseUsageApiResponse(rawJson: string): UsageData | null { + const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema); + if (!parsed) { + return null; + } + + return { + sessionUsage: parsed.five_hour?.utilization ?? undefined, + sessionResetAt: parsed.five_hour?.resets_at ?? undefined, + weeklyUsage: parsed.seven_day?.utilization ?? undefined, + weeklyResetAt: parsed.seven_day?.resets_at ?? undefined, + extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined, + extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined, + extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined, + extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined + }; +} + +// Memory caches +let cachedUsageData: UsageData | null = null; +let usageCacheTime = 0; +let cachedUsageToken: string | null = null; +let usageTokenCacheTime = 0; + +function setCachedUsageError(error: UsageError, now: number): UsageData { + const errorData: UsageData = { error }; + cachedUsageData = errorData; + usageCacheTime = now; + return errorData; +} + +function getStaleUsageOrError(error: UsageError, now: number): UsageData { + const stale = readStaleUsageCache(); + if (stale && !stale.error) { + cachedUsageData = stale; + usageCacheTime = now; + return stale; + } + return setCachedUsageError(error, now); +} + +function getUsageToken(): string | null { + const now = Math.floor(Date.now() / 1000); + + // Return cached token if still valid + if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) { + return cachedUsageToken; + } + + try { + const isMac = process.platform === 'darwin'; + if (isMac) { + // macOS: read from keychain + const result = execSync( + 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', + { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } + ).trim(); + const token = parseUsageAccessToken(result); + if (token) { + cachedUsageToken = token; + usageTokenCacheTime = now; + } + return token; + } + + // Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR + const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); + const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8')); + if (token) { + cachedUsageToken = token; + usageTokenCacheTime = now; + } + return token; + } catch { + return null; + } +} + +function readStaleUsageCache(): UsageData | null { + try { + return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); + } catch { + return null; + } +} + +const USAGE_API_HOST = 'api.anthropic.com'; +const USAGE_API_PATH = '/api/oauth/usage'; + +async function fetchFromUsageApi(token: string): Promise { + return new Promise((resolve) => { + let settled = false; + + const finish = (value: string | null) => { + if (settled) { + return; + } + settled = true; + resolve(value); + }; + + const request = https.request({ + hostname: USAGE_API_HOST, + path: USAGE_API_PATH, + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'anthropic-beta': 'oauth-2025-04-20' + }, + timeout: 5000 + }, (response) => { + let data = ''; + response.setEncoding('utf8'); + + response.on('data', (chunk: string) => { + data += chunk; + }); + + response.on('end', () => { + if (response.statusCode === 200 && data) { + finish(data); + return; + } + finish(null); + }); + }); + + request.on('error', () => { finish(null); }); + request.on('timeout', () => { + request.destroy(); + finish(null); + }); + request.end(); + }); +} + +export async function fetchUsageData(): Promise { + const now = Math.floor(Date.now() / 1000); + + // Check memory cache (fast path) + if (cachedUsageData) { + const cacheAge = now - usageCacheTime; + if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) { + return cachedUsageData; + } + if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) { + return cachedUsageData; + } + } + + // Check file cache + try { + const stat = fs.statSync(CACHE_FILE); + const fileAge = now - Math.floor(stat.mtimeMs / 1000); + if (fileAge < CACHE_MAX_AGE) { + const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); + if (fileData && !fileData.error) { + cachedUsageData = fileData; + usageCacheTime = now; + return fileData; + } + } + } catch { + // File doesn't exist or read error - continue to API call + } + + // Get token before lock/rate-limit checks so auth failures are not masked as timeout. + const token = getUsageToken(); + if (!token) { + return getStaleUsageOrError('no-credentials', now); + } + + // Rate limit: only try API once per 30 seconds + try { + const lockStat = fs.statSync(LOCK_FILE); + const lockAge = now - Math.floor(lockStat.mtimeMs / 1000); + if (lockAge < LOCK_MAX_AGE) { + // Rate limited - return stale cache or timeout error + const stale = readStaleUsageCache(); + if (stale && !stale.error) + return stale; + return { error: 'timeout' }; + } + } catch { + // Lock file doesn't exist - OK to proceed + } + + // Touch lock file + try { + const lockDir = path.dirname(LOCK_FILE); + if (!fs.existsSync(lockDir)) { + fs.mkdirSync(lockDir, { recursive: true }); + } + fs.writeFileSync(LOCK_FILE, ''); + } catch { + // Ignore lock file errors + } + + // Fetch from API using Node's https module + try { + const response = await fetchFromUsageApi(token); + + if (!response) { + return getStaleUsageOrError('api-error', now); + } + + const usageData = parseUsageApiResponse(response); + if (!usageData) { + return getStaleUsageOrError('parse-error', now); + } + + // Validate we got actual data + if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) { + return getStaleUsageOrError('parse-error', now); + } + + // Save to cache + try { + if (!fs.existsSync(CACHE_DIR)) { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + } + fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData)); + } catch { + // Ignore cache write errors + } + + cachedUsageData = usageData; + usageCacheTime = now; + return usageData; + } catch { + return getStaleUsageOrError('parse-error', now); + } +} \ No newline at end of file diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts new file mode 100644 index 0000000..9b6e9d9 --- /dev/null +++ b/src/utils/usage-prefetch.ts @@ -0,0 +1,24 @@ +import type { WidgetItem } from '../types/Widget'; + +import type { UsageData } from './usage'; +import { fetchUsageData } from './usage'; + +const USAGE_WIDGET_TYPES = new Set([ + 'session-usage', + 'weekly-usage', + 'block-timer', + 'reset-timer', + 'weekly-reset-timer' +]); + +export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean { + return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type))); +} + +export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][]): Promise { + if (!hasUsageDependentWidgets(lines)) { + return null; + } + + return await fetchUsageData(); +} \ No newline at end of file diff --git a/src/utils/usage-types.ts b/src/utils/usage-types.ts new file mode 100644 index 0000000..7b22a35 --- /dev/null +++ b/src/utils/usage-types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000; +export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +export const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']); +export type UsageError = z.infer; + +export interface UsageData { + sessionUsage?: number; // five_hour.utilization (percentage) + sessionResetAt?: string; // five_hour.resets_at + weeklyUsage?: number; // seven_day.utilization (percentage) + weeklyResetAt?: string; // seven_day.resets_at + extraUsageEnabled?: boolean; + extraUsageLimit?: number; // in cents + extraUsageUsed?: number; // in cents + extraUsageUtilization?: number; + error?: UsageError; +} + +export interface UsageWindowMetrics { + sessionDurationMs: number; + elapsedMs: number; + remainingMs: number; + elapsedPercent: number; + remainingPercent: number; +} \ No newline at end of file diff --git a/src/utils/usage-windows.ts b/src/utils/usage-windows.ts new file mode 100644 index 0000000..8d36edb --- /dev/null +++ b/src/utils/usage-windows.ts @@ -0,0 +1,117 @@ +import type { BlockMetrics } from '../types'; + +import { getCachedBlockMetrics } from './jsonl'; +import { + FIVE_HOUR_BLOCK_MS, + SEVEN_DAY_WINDOW_MS, + type UsageData, + type UsageError, + type UsageWindowMetrics +} from './usage-types'; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null { + if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) { + return null; + } + + const startAtMs = resetAtMs - durationMs; + const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs); + const remainingMs = durationMs - elapsedMs; + const elapsedPercent = (elapsedMs / durationMs) * 100; + + return { + sessionDurationMs: durationMs, + elapsedMs, + remainingMs, + elapsedPercent, + remainingPercent: 100 - elapsedPercent + }; +} + +export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null { + if (!sessionResetAt) { + return null; + } + + const resetAtMs = Date.parse(sessionResetAt); + if (Number.isNaN(resetAtMs)) { + return null; + } + + return buildUsageWindow(resetAtMs, nowMs, FIVE_HOUR_BLOCK_MS); +} + +export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null { + const startAtMs = blockMetrics.startTime.getTime(); + if (Number.isNaN(startAtMs)) { + return null; + } + + return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs, FIVE_HOUR_BLOCK_MS); +} + +export function resolveUsageWindowWithFallback( + usageData: UsageData, + blockMetrics?: BlockMetrics | null, + nowMs = Date.now() +): UsageWindowMetrics | null { + const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs); + if (usageWindow) { + return usageWindow; + } + + const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics(); + if (!fallbackMetrics) { + return null; + } + + return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs); +} + +export function getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null { + if (!weeklyResetAt) { + return null; + } + + const resetAtMs = Date.parse(weeklyResetAt); + if (Number.isNaN(resetAtMs)) { + return null; + } + + return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS); +} + +export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null { + return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs); +} + +export function formatUsageDuration(durationMs: number): string { + const clampedMs = Math.max(0, durationMs); + const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60)); + const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60)); + + if (elapsedMinutes === 0) { + return `${elapsedHours}hr`; + } + + return `${elapsedHours}hr ${elapsedMinutes}m`; +} + +export function getUsageErrorMessage(error: UsageError): string { + switch (error) { + case 'no-credentials': return '[No credentials]'; + case 'timeout': return '[Timeout]'; + case 'api-error': return '[API Error]'; + case 'parse-error': return '[Parse Error]'; + } +} + +export function makeUsageProgressBar(percent: number, width = 15): string { + const filled = Math.round((percent / 100) * width); + const empty = width - filled; + return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'; +} \ No newline at end of file diff --git a/src/utils/usage.ts b/src/utils/usage.ts index bd9ffc5..b67247a 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1,458 +1,18 @@ -import { - execSync, - spawnSync -} from 'child_process'; -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { z } from 'zod'; - -import type { BlockMetrics } from '../types'; - -import { getClaudeConfigDir } from './claude-settings'; -import { getCachedBlockMetrics } from './jsonl'; - -// Cache configuration -const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline'); -const CACHE_FILE = path.join(CACHE_DIR, 'usage.json'); -const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock'); -const CACHE_MAX_AGE = 180; // seconds -const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds -const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour -export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000; -export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; - -// Error types matching shell script -const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']); -export type UsageError = z.infer; - -export interface UsageData { - sessionUsage?: number; // five_hour.utilization (percentage) - sessionResetAt?: string; // five_hour.resets_at - weeklyUsage?: number; // seven_day.utilization (percentage) - weeklyResetAt?: string; // seven_day.resets_at - extraUsageEnabled?: boolean; - extraUsageLimit?: number; // in cents - extraUsageUsed?: number; // in cents - extraUsageUtilization?: number; - error?: UsageError; -} - -export interface UsageWindowMetrics { - sessionDurationMs: number; - elapsedMs: number; - remainingMs: number; - elapsedPercent: number; - remainingPercent: number; -} - -const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() }); - -const CachedUsageDataSchema = z.object({ - sessionUsage: z.number().nullable().optional(), - sessionResetAt: z.string().nullable().optional(), - weeklyUsage: z.number().nullable().optional(), - weeklyResetAt: z.string().nullable().optional(), - extraUsageEnabled: z.boolean().nullable().optional(), - extraUsageLimit: z.number().nullable().optional(), - extraUsageUsed: z.number().nullable().optional(), - extraUsageUtilization: z.number().nullable().optional(), - error: z.string().nullable().optional() -}); - -const UsageApiResponseSchema = z.object({ - five_hour: z.object({ - utilization: z.number().nullable().optional(), - resets_at: z.string().nullable().optional() - }).optional(), - seven_day: z.object({ - utilization: z.number().nullable().optional(), - resets_at: z.string().nullable().optional() - }).optional(), - extra_usage: z.object({ - is_enabled: z.boolean().nullable().optional(), - monthly_limit: z.number().nullable().optional(), - used_credits: z.number().nullable().optional(), - utilization: z.number().nullable().optional() - }).optional() -}); - -function parseJsonWithSchema(rawJson: string, schema: z.ZodType): T | null { - try { - const parsed = schema.safeParse(JSON.parse(rawJson)); - return parsed.success ? parsed.data : null; - } catch { - return null; - } -} - -function parseUsageAccessToken(rawJson: string): string | null { - const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema); - return parsed?.claudeAiOauth?.accessToken ?? null; -} - -function parseCachedUsageData(rawJson: string): UsageData | null { - const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema); - if (!parsed) { - return null; - } - - const parsedError = UsageErrorSchema.safeParse(parsed.error); - - return { - sessionUsage: parsed.sessionUsage ?? undefined, - sessionResetAt: parsed.sessionResetAt ?? undefined, - weeklyUsage: parsed.weeklyUsage ?? undefined, - weeklyResetAt: parsed.weeklyResetAt ?? undefined, - extraUsageEnabled: parsed.extraUsageEnabled ?? undefined, - extraUsageLimit: parsed.extraUsageLimit ?? undefined, - extraUsageUsed: parsed.extraUsageUsed ?? undefined, - extraUsageUtilization: parsed.extraUsageUtilization ?? undefined, - error: parsedError.success ? parsedError.data : undefined - }; -} - -function parseUsageApiResponse(rawJson: string): UsageData | null { - const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema); - if (!parsed) { - return null; - } - - return { - sessionUsage: parsed.five_hour?.utilization ?? undefined, - sessionResetAt: parsed.five_hour?.resets_at ?? undefined, - weeklyUsage: parsed.seven_day?.utilization ?? undefined, - weeklyResetAt: parsed.seven_day?.resets_at ?? undefined, - extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined, - extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined, - extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined, - extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined - }; -} - -// Memory caches -let cachedUsageData: UsageData | null = null; -let usageCacheTime = 0; -let cachedUsageToken: string | null = null; -let usageTokenCacheTime = 0; - -function setCachedUsageError(error: UsageError, now: number): UsageData { - const errorData: UsageData = { error }; - cachedUsageData = errorData; - usageCacheTime = now; - return errorData; -} - -function getStaleUsageOrError(error: UsageError, now: number): UsageData { - const stale = readStaleUsageCache(); - if (stale && !stale.error) { - cachedUsageData = stale; - usageCacheTime = now; - return stale; - } - return setCachedUsageError(error, now); -} - -function getUsageToken(): string | null { - const now = Math.floor(Date.now() / 1000); - - // Return cached token if still valid - if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) { - return cachedUsageToken; - } - - try { - const isMac = process.platform === 'darwin'; - if (isMac) { - // macOS: read from keychain - const result = execSync( - 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', - { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } - ).trim(); - const token = parseUsageAccessToken(result); - if (token) { - cachedUsageToken = token; - usageTokenCacheTime = now; - } - return token; - } - - // Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR - const credFile = path.join(getClaudeConfigDir(), '.credentials.json'); - const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8')); - if (token) { - cachedUsageToken = token; - usageTokenCacheTime = now; - } - return token; - } catch { - return null; - } -} - -function readStaleUsageCache(): UsageData | null { - try { - return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); - } catch { - return null; - } -} - -function fetchFromUsageApi(token: string): string | null { - // Use the active runtime to make a synchronous API call via spawnSync. - const script = ` - const token = process.env.TOKEN; - if (!token) { - process.exit(1); - } - - const https = require('https'); - const options = { - hostname: 'api.anthropic.com', - path: '/api/oauth/usage', - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + token, - 'anthropic-beta': 'oauth-2025-04-20' - }, - timeout: 5000 - }; - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { - if (res.statusCode === 200 && data) { - process.stdout.write(data); - } else { - process.exit(1); - } - }); - }); - - req.on('error', () => process.exit(1)); - req.on('timeout', () => { - req.destroy(); - process.exit(1); - }); - req.end(); - `; - - const runtimePath = process.execPath || 'node'; - const result = spawnSync(runtimePath, ['-e', script], { - encoding: 'utf8', - timeout: 6000, - env: { ...process.env, TOKEN: token } - }); - - if (result.error || result.status !== 0 || !result.stdout) { - return null; - } - - return result.stdout; -} - -export function fetchUsageData(): UsageData { - const now = Math.floor(Date.now() / 1000); - - // Check memory cache (fast path) - if (cachedUsageData) { - const cacheAge = now - usageCacheTime; - if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) { - return cachedUsageData; - } - if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) { - return cachedUsageData; - } - } - - // Check file cache - try { - const stat = fs.statSync(CACHE_FILE); - const fileAge = now - Math.floor(stat.mtimeMs / 1000); - if (fileAge < CACHE_MAX_AGE) { - const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8')); - if (fileData && !fileData.error) { - cachedUsageData = fileData; - usageCacheTime = now; - return fileData; - } - } - } catch { - // File doesn't exist or read error - continue to API call - } - - // Get token before lock/rate-limit checks so auth failures are not masked as timeout. - const token = getUsageToken(); - if (!token) { - return getStaleUsageOrError('no-credentials', now); - } - - // Rate limit: only try API once per 30 seconds - try { - const lockStat = fs.statSync(LOCK_FILE); - const lockAge = now - Math.floor(lockStat.mtimeMs / 1000); - if (lockAge < LOCK_MAX_AGE) { - // Rate limited - return stale cache or timeout error - const stale = readStaleUsageCache(); - if (stale && !stale.error) - return stale; - return { error: 'timeout' }; - } - } catch { - // Lock file doesn't exist - OK to proceed - } - - // Touch lock file - try { - const lockDir = path.dirname(LOCK_FILE); - if (!fs.existsSync(lockDir)) { - fs.mkdirSync(lockDir, { recursive: true }); - } - fs.writeFileSync(LOCK_FILE, ''); - } catch { - // Ignore lock file errors - } - - // Fetch from API using Node's https module - try { - const response = fetchFromUsageApi(token); - - if (!response) { - return getStaleUsageOrError('api-error', now); - } - - const usageData = parseUsageApiResponse(response); - if (!usageData) { - return getStaleUsageOrError('parse-error', now); - } - - // Validate we got actual data - if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) { - return getStaleUsageOrError('parse-error', now); - } - - // Save to cache - try { - if (!fs.existsSync(CACHE_DIR)) { - fs.mkdirSync(CACHE_DIR, { recursive: true }); - } - fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData)); - } catch { - // Ignore cache write errors - } - - cachedUsageData = usageData; - usageCacheTime = now; - return usageData; - } catch { - return getStaleUsageOrError('parse-error', now); - } -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null { - if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) { - return null; - } - - const startAtMs = resetAtMs - durationMs; - const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs); - const remainingMs = durationMs - elapsedMs; - const elapsedPercent = (elapsedMs / durationMs) * 100; - - return { - sessionDurationMs: durationMs, - elapsedMs, - remainingMs, - elapsedPercent, - remainingPercent: 100 - elapsedPercent - }; -} - -export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null { - if (!sessionResetAt) { - return null; - } - - const resetAtMs = Date.parse(sessionResetAt); - if (Number.isNaN(resetAtMs)) { - return null; - } - - return buildUsageWindow(resetAtMs, nowMs, FIVE_HOUR_BLOCK_MS); -} - -export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null { - const startAtMs = blockMetrics.startTime.getTime(); - if (Number.isNaN(startAtMs)) { - return null; - } - - return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs, FIVE_HOUR_BLOCK_MS); -} - -export function resolveUsageWindowWithFallback( - usageData: UsageData, - blockMetrics?: BlockMetrics | null, - nowMs = Date.now() -): UsageWindowMetrics | null { - const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs); - if (usageWindow) { - return usageWindow; - } - - const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics(); - if (!fallbackMetrics) { - return null; - } - - return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs); -} - -export function getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null { - if (!weeklyResetAt) { - return null; - } - - const resetAtMs = Date.parse(weeklyResetAt); - if (Number.isNaN(resetAtMs)) { - return null; - } - - return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS); -} - -export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null { - return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs); -} - -export function formatUsageDuration(durationMs: number): string { - const clampedMs = Math.max(0, durationMs); - const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60)); - const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60)); - - if (elapsedMinutes === 0) { - return `${elapsedHours}hr`; - } - - return `${elapsedHours}hr ${elapsedMinutes}m`; -} - -export function getUsageErrorMessage(error: UsageError): string { - switch (error) { - case 'no-credentials': return '[No credentials]'; - case 'timeout': return '[Timeout]'; - case 'api-error': return '[API Error]'; - case 'parse-error': return '[Parse Error]'; - } -} - -export function makeUsageProgressBar(percent: number, width = 15): string { - const filled = Math.round((percent / 100) * width); - const empty = width - filled; - return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'; -} \ No newline at end of file +export { fetchUsageData } from './usage-fetch'; +export { + formatUsageDuration, + getUsageErrorMessage, + getUsageWindowFromBlockMetrics, + getUsageWindowFromResetAt, + getWeeklyUsageWindowFromResetAt, + makeUsageProgressBar, + resolveUsageWindowWithFallback, + resolveWeeklyUsageWindow +} from './usage-windows'; +export { + FIVE_HOUR_BLOCK_MS, + SEVEN_DAY_WINDOW_MS, + type UsageData, + type UsageError, + type UsageWindowMetrics +} from './usage-types'; \ No newline at end of file diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 86b2e38..8453696 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -7,7 +7,6 @@ import type { WidgetItem } from '../types/Widget'; import { - fetchUsageData, formatUsageDuration, getUsageErrorMessage, resolveUsageWindowWithFallback @@ -72,7 +71,7 @@ export class BlockResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Reset: ', '4hr 30m'); } - const usageData = fetchUsageData(); + const usageData = context.usageData ?? {}; const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 777cafd..8387592 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -7,7 +7,6 @@ import type { WidgetItem } from '../types/Widget'; import { - fetchUsageData, formatUsageDuration, resolveUsageWindowWithFallback } from '../utils/usage'; @@ -71,7 +70,7 @@ export class BlockTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Block: ', '3hr 45m'); } - const usageData = fetchUsageData(); + const usageData = context.usageData ?? {}; const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index a79a007..32ed6dd 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -7,7 +7,6 @@ import type { WidgetItem } from '../types/Widget'; import { - fetchUsageData, getUsageErrorMessage, makeUsageProgressBar } from '../utils/usage'; @@ -65,7 +64,7 @@ export class SessionUsageWidget implements Widget { return formatRawOrLabeledValue(item, 'Session: ', `${previewPercent.toFixed(1)}%`); } - const data = fetchUsageData(); + const data = context.usageData ?? {}; if (data.error) return getUsageErrorMessage(data.error); if (data.sessionUsage === undefined) diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 6d7a158..166db52 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -7,7 +7,6 @@ import type { WidgetItem } from '../types/Widget'; import { - fetchUsageData, formatUsageDuration, getUsageErrorMessage, resolveWeeklyUsageWindow @@ -72,7 +71,7 @@ export class WeeklyResetTimerWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly Reset: ', '36hr 30m'); } - const usageData = fetchUsageData(); + const usageData = context.usageData ?? {}; const window = resolveWeeklyUsageWindow(usageData); if (!window) { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 9085e36..161f755 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -7,7 +7,6 @@ import type { WidgetItem } from '../types/Widget'; import { - fetchUsageData, getUsageErrorMessage, makeUsageProgressBar } from '../utils/usage'; @@ -65,7 +64,7 @@ export class WeeklyUsageWidget implements Widget { return formatRawOrLabeledValue(item, 'Weekly: ', `${previewPercent.toFixed(1)}%`); } - const data = fetchUsageData(); + const data = context.usageData ?? {}; if (data.error) return getUsageErrorMessage(data.error); if (data.weeklyUsage === undefined) diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index d2c3990..fc06588 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -10,7 +10,6 @@ import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { - fetchUsageData, formatUsageDuration, getUsageErrorMessage, resolveUsageWindowWithFallback @@ -18,13 +17,11 @@ import { import { BlockResetTimerWidget } from '../BlockResetTimer'; vi.mock('../../utils/usage', () => ({ - fetchUsageData: vi.fn(), formatUsageDuration: vi.fn(), getUsageErrorMessage: vi.fn(), resolveUsageWindowWithFallback: vi.fn() })); -const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void }; const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; const mockResolveUsageWindowWithFallback = resolveUsageWindowWithFallback as unknown as { mockReturnValue: (value: unknown) => void }; @@ -58,7 +55,6 @@ describe('BlockResetTimerWidget', () => { it('renders remaining time in time mode', () => { const widget = new BlockResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 3600000, @@ -68,7 +64,7 @@ describe('BlockResetTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('4hr'); - expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('Reset: 4hr'); + expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBe('Reset: 4hr'); }); it('renders short progress bar with inverted fill', () => { @@ -82,7 +78,6 @@ describe('BlockResetTimerWidget', () => { } }; - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 14400000, @@ -91,32 +86,29 @@ describe('BlockResetTimerWidget', () => { remainingPercent: 20 }); - expect(render(widget, item)).toBe('Reset [███░░░░░░░░░░░░░] 20.0%'); + expect(render(widget, item, { usageData: {} })).toBe('Reset [███░░░░░░░░░░░░░] 20.0%'); }); it('returns usage error when no timer data is available', () => { const widget = new BlockResetTimerWidget(); - mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockResolveUsageWindowWithFallback.mockReturnValue(null); mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); - expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('[Timeout]'); + expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); it('returns null when neither timer data nor usage error exists', () => { const widget = new BlockResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue(null); - expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBeNull(); + expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBeNull(); }); it('shows raw value without label in time mode', () => { const widget = new BlockResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 4500000, @@ -126,7 +118,7 @@ describe('BlockResetTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('3hr 45m'); - expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true })).toBe('3hr 45m'); + expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('3hr 45m'); }); it('clears invert metadata when cycling back to time mode', () => { diff --git a/src/widgets/__tests__/BlockTimer.test.ts b/src/widgets/__tests__/BlockTimer.test.ts index f6dae36..1cc7c0d 100644 --- a/src/widgets/__tests__/BlockTimer.test.ts +++ b/src/widgets/__tests__/BlockTimer.test.ts @@ -10,19 +10,16 @@ import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { - fetchUsageData, formatUsageDuration, resolveUsageWindowWithFallback } from '../../utils/usage'; import { BlockTimerWidget } from '../BlockTimer'; vi.mock('../../utils/usage', () => ({ - fetchUsageData: vi.fn(), formatUsageDuration: vi.fn(), resolveUsageWindowWithFallback: vi.fn() })); -const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void }; const mockResolveUsageWindowWithFallback = resolveUsageWindowWithFallback as unknown as { mockReturnValue: (value: unknown) => void }; @@ -49,7 +46,6 @@ describe('BlockTimerWidget', () => { const widget = new BlockTimerWidget(); const item: WidgetItem = { id: 'block', type: 'block-timer' }; - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 13500000, @@ -59,7 +55,7 @@ describe('BlockTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('3hr 45m'); - expect(render(widget, item)).toBe('Block: 3hr 45m'); + expect(render(widget, item, { usageData: {} })).toBe('Block: 3hr 45m'); }); it('renders short progress bar with inverted fill', () => { @@ -73,7 +69,6 @@ describe('BlockTimerWidget', () => { } }; - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 13500000, @@ -82,27 +77,25 @@ describe('BlockTimerWidget', () => { remainingPercent: 25 }); - expect(render(widget, item)).toBe('Block [████░░░░░░░░░░░░] 25.0%'); + expect(render(widget, item, { usageData: {} })).toBe('Block [████░░░░░░░░░░░░] 25.0%'); }); it('renders empty values when no usage or fallback data exists', () => { const widget = new BlockTimerWidget(); - mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockResolveUsageWindowWithFallback.mockReturnValue(null); - expect(render(widget, { id: 'block', type: 'block-timer' })).toBe('Block: 0hr 0m'); + expect(render(widget, { id: 'block', type: 'block-timer' }, { usageData: { error: 'timeout' } })).toBe('Block: 0hr 0m'); expect(render(widget, { id: 'block', type: 'block-timer', metadata: { display: 'progress' } - })).toBe(`Block [${'░'.repeat(32)}] 0.0%`); + }, { usageData: { error: 'timeout' } })).toBe(`Block [${'░'.repeat(32)}] 0.0%`); }); it('shows raw value without label in time mode', () => { const widget = new BlockTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveUsageWindowWithFallback.mockReturnValue({ sessionDurationMs: 18000000, elapsedMs: 7200000, @@ -112,7 +105,7 @@ describe('BlockTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('2hr'); - expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true })).toBe('2hr'); + expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true }, { usageData: {} })).toBe('2hr'); }); it('clears invert metadata when cycling back to time mode', () => { diff --git a/src/widgets/__tests__/SessionUsage.test.ts b/src/widgets/__tests__/SessionUsage.test.ts index 1b80d85..12d7ad5 100644 --- a/src/widgets/__tests__/SessionUsage.test.ts +++ b/src/widgets/__tests__/SessionUsage.test.ts @@ -9,19 +9,14 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; -import { - fetchUsageData, - getUsageErrorMessage -} from '../../utils/usage'; +import { getUsageErrorMessage } from '../../utils/usage'; import { SessionUsageWidget } from '../SessionUsage'; vi.mock('../../utils/usage', () => ({ - fetchUsageData: vi.fn(), getUsageErrorMessage: vi.fn(), makeUsageProgressBar: vi.fn((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`) })); -const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; function render(widget: SessionUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null { @@ -45,10 +40,7 @@ describe('SessionUsageWidget', () => { it('renders percentage text in time mode', () => { const widget = new SessionUsageWidget(); - - mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 }); - - expect(render(widget, { id: 'session', type: 'session-usage' })).toBe('Session: 23.4%'); + expect(render(widget, { id: 'session', type: 'session-usage' }, { usageData: { sessionUsage: 23.45 } })).toBe('Session: 23.4%'); }); it('renders short inverted progress mode', () => { @@ -62,17 +54,13 @@ describe('SessionUsageWidget', () => { } }; - mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 }); - - expect(render(widget, item)).toBe('Session: [bar:76.5:16] 76.5%'); + expect(render(widget, item, { usageData: { sessionUsage: 23.45 } })).toBe('Session: [bar:76.5:16] 76.5%'); }); it('renders raw text mode without label', () => { const widget = new SessionUsageWidget(); - mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 }); - - expect(render(widget, { id: 'session', type: 'session-usage', rawValue: true })).toBe('23.4%'); + expect(render(widget, { id: 'session', type: 'session-usage', rawValue: true }, { usageData: { sessionUsage: 23.45 } })).toBe('23.4%'); }); it('renders raw progress mode without label', () => { @@ -84,18 +72,15 @@ describe('SessionUsageWidget', () => { metadata: { display: 'progress' } }; - mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 }); - - expect(render(widget, item)).toBe('[bar:23.4:32] 23.4%'); + expect(render(widget, item, { usageData: { sessionUsage: 23.45 } })).toBe('[bar:23.4:32] 23.4%'); }); it('shows usage error text when API call fails', () => { const widget = new SessionUsageWidget(); - mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); - expect(render(widget, { id: 'session', type: 'session-usage' })).toBe('[Timeout]'); + expect(render(widget, { id: 'session', type: 'session-usage' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); it('clears invert metadata when cycling back to time mode', () => { diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index c4b3d5a..ff60e2e 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -10,7 +10,6 @@ import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { - fetchUsageData, formatUsageDuration, getUsageErrorMessage, resolveWeeklyUsageWindow @@ -18,13 +17,11 @@ import { import { WeeklyResetTimerWidget } from '../WeeklyResetTimer'; vi.mock('../../utils/usage', () => ({ - fetchUsageData: vi.fn(), formatUsageDuration: vi.fn(), getUsageErrorMessage: vi.fn(), resolveWeeklyUsageWindow: vi.fn() })); -const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void }; const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; const mockResolveWeeklyUsageWindow = resolveWeeklyUsageWindow as unknown as { mockReturnValue: (value: unknown) => void }; @@ -58,7 +55,6 @@ describe('WeeklyResetTimerWidget', () => { it('renders remaining time in time mode', () => { const widget = new WeeklyResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveWeeklyUsageWindow.mockReturnValue({ sessionDurationMs: 604800000, elapsedMs: 120000000, @@ -68,7 +64,7 @@ describe('WeeklyResetTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('134hr 40m'); - expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBe('Weekly Reset: 134hr 40m'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBe('Weekly Reset: 134hr 40m'); }); it('renders short progress bar with inverted fill', () => { @@ -82,7 +78,6 @@ describe('WeeklyResetTimerWidget', () => { } }; - mockFetchUsageData.mockReturnValue({}); mockResolveWeeklyUsageWindow.mockReturnValue({ sessionDurationMs: 604800000, elapsedMs: 483840000, @@ -91,32 +86,29 @@ describe('WeeklyResetTimerWidget', () => { remainingPercent: 20 }); - expect(render(widget, item)).toBe('Weekly Reset [███░░░░░░░░░░░░░] 20.0%'); + expect(render(widget, item, { usageData: {} })).toBe('Weekly Reset [███░░░░░░░░░░░░░] 20.0%'); }); it('returns usage error when no weekly reset data is available', () => { const widget = new WeeklyResetTimerWidget(); - mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockResolveWeeklyUsageWindow.mockReturnValue(null); mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); - expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBe('[Timeout]'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); it('returns null when neither weekly reset data nor usage error exists', () => { const widget = new WeeklyResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveWeeklyUsageWindow.mockReturnValue(null); - expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBeNull(); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBeNull(); }); it('shows raw value without label in time mode', () => { const widget = new WeeklyResetTimerWidget(); - mockFetchUsageData.mockReturnValue({}); mockResolveWeeklyUsageWindow.mockReturnValue({ sessionDurationMs: 604800000, elapsedMs: 171900000, @@ -126,7 +118,7 @@ describe('WeeklyResetTimerWidget', () => { }); mockFormatUsageDuration.mockReturnValue('120hr 15m'); - expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true })).toBe('120hr 15m'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('120hr 15m'); }); it('clears invert metadata when cycling back to time mode', () => { diff --git a/src/widgets/__tests__/WeeklyUsage.test.ts b/src/widgets/__tests__/WeeklyUsage.test.ts index 8a52ec0..6892b7b 100644 --- a/src/widgets/__tests__/WeeklyUsage.test.ts +++ b/src/widgets/__tests__/WeeklyUsage.test.ts @@ -9,19 +9,14 @@ import { import type { RenderContext } from '../../types/RenderContext'; import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; -import { - fetchUsageData, - getUsageErrorMessage -} from '../../utils/usage'; +import { getUsageErrorMessage } from '../../utils/usage'; import { WeeklyUsageWidget } from '../WeeklyUsage'; vi.mock('../../utils/usage', () => ({ - fetchUsageData: vi.fn(), getUsageErrorMessage: vi.fn(), makeUsageProgressBar: vi.fn((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`) })); -const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void }; const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void }; function render(widget: WeeklyUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null { @@ -46,9 +41,7 @@ describe('WeeklyUsageWidget', () => { it('renders percentage text in time mode', () => { const widget = new WeeklyUsageWidget(); - mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 }); - - expect(render(widget, { id: 'weekly', type: 'weekly-usage' })).toBe('Weekly: 42.1%'); + expect(render(widget, { id: 'weekly', type: 'weekly-usage' }, { usageData: { weeklyUsage: 42.06 } })).toBe('Weekly: 42.1%'); }); it('renders full inverted progress mode', () => { @@ -62,17 +55,13 @@ describe('WeeklyUsageWidget', () => { } }; - mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 }); - - expect(render(widget, item)).toBe('Weekly: [bar:57.9:32] 57.9%'); + expect(render(widget, item, { usageData: { weeklyUsage: 42.06 } })).toBe('Weekly: [bar:57.9:32] 57.9%'); }); it('renders raw text mode without label', () => { const widget = new WeeklyUsageWidget(); - mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 }); - - expect(render(widget, { id: 'weekly', type: 'weekly-usage', rawValue: true })).toBe('42.1%'); + expect(render(widget, { id: 'weekly', type: 'weekly-usage', rawValue: true }, { usageData: { weeklyUsage: 42.06 } })).toBe('42.1%'); }); it('renders raw progress mode without label', () => { @@ -84,18 +73,15 @@ describe('WeeklyUsageWidget', () => { metadata: { display: 'progress-short' } }; - mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 }); - - expect(render(widget, item)).toBe('[bar:42.1:16] 42.1%'); + expect(render(widget, item, { usageData: { weeklyUsage: 42.06 } })).toBe('[bar:42.1:16] 42.1%'); }); it('shows usage error text when API call fails', () => { const widget = new WeeklyUsageWidget(); - mockFetchUsageData.mockReturnValue({ error: 'timeout' }); mockGetUsageErrorMessage.mockReturnValue('[Timeout]'); - expect(render(widget, { id: 'weekly', type: 'weekly-usage' })).toBe('[Timeout]'); + expect(render(widget, { id: 'weekly', type: 'weekly-usage' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]'); }); it('clears invert metadata when cycling back to time mode', () => {