refactor: implement phase 6 jsonl/usage modularization with async prefetch tests

This commit is contained in:
Matthew Breedlove committed 2026-03-04 01:28:27 -05:00
1 parent a34a632b82
commit fc09688879
26 files changed
+1550 -1060

No files matched your search

+4
View File
@@ -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
};
+13
View File
@@ -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;
+88
View File
@@ -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();
});
});
+146
View File
@@ -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
});
});
});
+150 -17
View File
@@ -10,9 +10,11 @@ import {
} from 'vitest';
interface UsageProbeResult {
first: { error?: string };
second: { error?: string };
first: Record<string, unknown>;
second: Record<string, unknown>;
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 });
}
@@ -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);
});
});
+233
View File
@@ -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;
}
+133
View File
@@ -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;
}
+27
View File
@@ -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<string[]> {
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;
}
}
+132
View File
@@ -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<string | null> {
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<TokenMetrics> {
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 };
}
}
+11 -496
View File
@@ -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<string | null> {
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<TokenMetrics> {
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;
}
export {
getBlockCachePath,
getCachedBlockMetrics,
readBlockCache,
writeBlockCache
} from './jsonl-cache';
export { getBlockMetrics } from './jsonl-blocks';
export {
getSessionDuration,
getTokenMetrics
} from './jsonl-metrics';
+321
View File
@@ -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<T>(rawJson: string, schema: z.ZodType<T>): 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<string | null> {
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<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 = 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);
}
}
+24
View File
@@ -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<string>([
'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<UsageData | null> {
if (!hasUsageDependentWidgets(lines)) {
return null;
}
return await fetchUsageData();
}
+27
View File
@@ -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<typeof UsageErrorSchema>;
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;
}
+117
View File
@@ -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) + ']';
}
+18 -458
View File
@@ -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<typeof UsageErrorSchema>;
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<T>(rawJson: string, schema: z.ZodType<T>): 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) + ']';
}
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';
+1 -2
View File
@@ -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) {
+1 -2
View File
@@ -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) {
+1 -2
View File
@@ -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)
+1 -2
View File
@@ -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) {
+1 -2
View File
@@ -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)
+5 -13
View File
@@ -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', () => {
+5 -12
View File
@@ -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', () => {
+6 -21
View File
@@ -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', () => {
+5 -13
View File
@@ -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', () => {
+6 -20
View File
@@ -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', () => {