diff --git a/packages/cli/README.md b/packages/cli/README.md index a1b2113..d85ea24 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -132,7 +132,7 @@ The CLI automatically detects and parses usage data from: | **Droid** | `~/.factory/sessions/*.settings.json` | Session files | | **Gemini CLI** | `~/.gemini/tmp/*/chats/session-*.json` | Session files | | **Kilo Code** | VS Code `globalStorage/kilocode.kilo-code/` | VS Code extension | -| **OpenCode** | `~/.local/share/opencode/storage/message/` | Message storage | +| **OpenCode** | `~/.local/share/opencode/opencode.db` | SQLite (fallback JSON) | | **Roo Code** | VS Code `globalStorage/rooveterinaryinc.roo-cline/` | VS Code extension | Don't see your tool? [Open an issue](https://github.com/agusmdev/burntop/issues) and we'll add it! diff --git a/packages/cli/package.json b/packages/cli/package.json index c5cf282..bcdd091 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,7 @@ "dev": "bun run --watch src/index.ts", "build": "bun build src/index.ts --outfile dist/index.js --target bun --format esm && chmod +x dist/index.js", "typecheck": "tsc --noEmit", + "test": "bun test", "prepublishOnly": "bun run build", "link": "bun run build && bun link", "unlink": "bun unlink", diff --git a/packages/cli/src/commands/stats.ts b/packages/cli/src/commands/stats.ts index cdb20b2..9b2c268 100644 --- a/packages/cli/src/commands/stats.ts +++ b/packages/cli/src/commands/stats.ts @@ -292,7 +292,7 @@ export async function statsCommand(options: StatsOptions): Promise { console.log(' - Droid (~/.factory/sessions/*.settings.json)'); console.log(' - Gemini CLI (~/.gemini/tmp/*/chats/session-*.json)'); console.log(' - Kilo Code (VS Code globalStorage/kilocode.kilo-code/)'); - console.log(' - OpenCode (~/.local/share/opencode/storage/message/)'); + console.log(' - OpenCode (~/.local/share/opencode/opencode.db, fallback: storage/message/)'); console.log(' - Roo Code (VS Code globalStorage/rooveterinaryinc.roo-cline/)\n'); console.log('Get started by using one of these AI tools, then run `burntop stats` again.'); return; diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts index fb707d9..7c34462 100644 --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -517,7 +517,7 @@ export async function syncCommand(options: SyncOptions): Promise { console.log(' - Droid (~/.factory/sessions/*.settings.json)'); console.log(' - Gemini CLI (~/.gemini/tmp/*/chats/session-*.json)'); console.log(' - Kilo Code (VS Code globalStorage/kilocode.kilo-code/)'); - console.log(' - OpenCode (~/.local/share/opencode/storage/message/)'); + console.log(' - OpenCode (~/.local/share/opencode/opencode.db, fallback: storage/message/)'); console.log(' - Roo Code (VS Code globalStorage/rooveterinaryinc.roo-cline/)'); return; } diff --git a/packages/cli/src/config/machine-id.ts b/packages/cli/src/config/machine-id.ts index 71e69ad..71f66bb 100644 --- a/packages/cli/src/config/machine-id.ts +++ b/packages/cli/src/config/machine-id.ts @@ -10,11 +10,15 @@ */ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { hostname, networkInterfaces } from 'node:os'; import { join } from 'node:path'; -const CONFIG_DIR = join(process.env['HOME'] || process.env['USERPROFILE'] || '', '.config', 'burntop'); +const CONFIG_DIR = join( + process.env['HOME'] || process.env['USERPROFILE'] || '', + '.config', + 'burntop' +); const MACHINE_ID_FILE = join(CONFIG_DIR, 'machine-id'); /** @@ -125,7 +129,6 @@ export function getMachineIdPath(): string { export function resetMachineId(): void { try { if (existsSync(MACHINE_ID_FILE)) { - const { unlinkSync } = require('node:fs'); unlinkSync(MACHINE_ID_FILE); } } catch { diff --git a/packages/cli/src/config/sync-checkpoint.ts b/packages/cli/src/config/sync-checkpoint.ts index f379b34..727c6f1 100644 --- a/packages/cli/src/config/sync-checkpoint.ts +++ b/packages/cli/src/config/sync-checkpoint.ts @@ -8,12 +8,16 @@ * Checkpoint data is stored in ~/.config/burntop/sync-checkpoint.json */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { getMachineId } from './machine-id'; -const CONFIG_DIR = join(process.env['HOME'] || process.env['USERPROFILE'] || '', '.config', 'burntop'); +const CONFIG_DIR = join( + process.env['HOME'] || process.env['USERPROFILE'] || '', + '.config', + 'burntop' +); const CHECKPOINT_FILE = join(CONFIG_DIR, 'sync-checkpoint.json'); /** @@ -108,11 +112,7 @@ export function loadCheckpoint(): SyncCheckpoint | null { const checkpoint = JSON.parse(content) as SyncCheckpoint; // Validate checkpoint structure - if ( - !checkpoint || - checkpoint.version !== '1.0' || - typeof checkpoint.sources !== 'object' - ) { + if (!checkpoint || checkpoint.version !== '1.0' || typeof checkpoint.sources !== 'object') { return null; } @@ -189,7 +189,6 @@ export function createEmptyCheckpoint(): SyncCheckpoint { export function clearCheckpoint(): void { try { if (existsSync(CHECKPOINT_FILE)) { - const { unlinkSync } = require('node:fs'); unlinkSync(CHECKPOINT_FILE); } } catch { diff --git a/packages/cli/src/parsers/opencode.integration.test.ts b/packages/cli/src/parsers/opencode.integration.test.ts new file mode 100644 index 0000000..518df0a --- /dev/null +++ b/packages/cli/src/parsers/opencode.integration.test.ts @@ -0,0 +1,217 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { Database } from 'bun:sqlite'; +import { afterEach, describe, expect, it } from 'bun:test'; + +import { OpenCodeParser } from './opencode'; + +function setupTempDataHome(): { cleanup: () => void; opencodeRoot: string } { + const originalXdg = process.env['XDG_DATA_HOME']; + const tempDir = mkdtempSync(join(tmpdir(), 'opencode-parser-')); + const opencodeRoot = join(tempDir, 'opencode'); + mkdirSync(opencodeRoot, { recursive: true }); + process.env['XDG_DATA_HOME'] = tempDir; + + return { + opencodeRoot, + cleanup: () => { + if (originalXdg) { + process.env['XDG_DATA_HOME'] = originalXdg; + } else { + delete process.env['XDG_DATA_HOME']; + } + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + }; +} + +function createOpenCodeDb(dbPath: string): Database { + const db = new Database(dbPath); + db.run( + 'CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, time_created INTEGER NOT NULL, time_updated INTEGER NOT NULL, data TEXT NOT NULL)' + ); + return db; +} + +describe('OpenCodeParser integration', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + const cleanup = cleanups.pop(); + cleanup?.(); + } + }); + + it('parses assistant messages from SQLite storage', async () => { + const { cleanup, opencodeRoot } = setupTempDataHome(); + cleanups.push(cleanup); + + const dbPath = join(opencodeRoot, 'opencode.db'); + const db = createOpenCodeDb(dbPath); + + const createdAt = Date.UTC(2026, 0, 15, 10, 30, 0); + db.query( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'msg_sqlite_1', + 'session_sqlite', + createdAt, + createdAt, + JSON.stringify({ + role: 'assistant', + modelID: 'gpt-5.3-codex', + time: { created: createdAt }, + tokens: { + input: 150, + output: 40, + reasoning: 10, + cache: { read: 75, write: 5 }, + }, + }) + ); + db.query( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'msg_sqlite_user', + 'session_sqlite', + createdAt + 1, + createdAt + 1, + JSON.stringify({ + role: 'user', + time: { created: createdAt + 1 }, + tokens: { input: 1, output: 1, cache: { read: 0, write: 0 } }, + }) + ); + db.close(); + + const parser = new OpenCodeParser(); + const result = await parser.parse(); + + expect(result.records).toHaveLength(1); + expect(result.records[0]).toEqual({ + id: 'msg_sqlite_1', + sessionId: 'session_sqlite', + source: 'opencode', + model: 'gpt-5.3-codex', + timestamp: new Date(createdAt).toISOString(), + inputTokens: 150, + outputTokens: 50, + cacheCreationTokens: 5, + cacheReadTokens: 75, + }); + expect(result.stats.messageCount).toBe(1); + expect(result.stats.sessionCount).toBe(1); + }); + + it('keeps legacy JSON message parsing', async () => { + const { cleanup, opencodeRoot } = setupTempDataHome(); + cleanups.push(cleanup); + + const messageDir = join(opencodeRoot, 'storage', 'message', 'session-json'); + mkdirSync(messageDir, { recursive: true }); + + const createdAt = Date.UTC(2026, 0, 20, 14, 0, 0); + writeFileSync( + join(messageDir, 'msg_json_1.json'), + JSON.stringify({ + id: 'msg_json_1', + sessionID: 'session_json', + role: 'assistant', + modelID: 'gpt-4.1', + time: { created: createdAt }, + tokens: { + input: 90, + output: 30, + reasoning: 5, + cache: { read: 0, write: 2 }, + }, + }) + ); + + const parser = new OpenCodeParser(); + const result = await parser.parse(); + + expect(result.records).toHaveLength(1); + expect(result.records[0]?.id).toBe('msg_json_1'); + expect(result.stats.totalInputTokens).toBe(90); + expect(result.stats.totalOutputTokens).toBe(35); + expect(result.stats.totalCacheCreationTokens).toBe(2); + }); + + it('parses SQLite and JSON together and avoids duplicate message IDs', async () => { + const { cleanup, opencodeRoot } = setupTempDataHome(); + cleanups.push(cleanup); + + const dbPath = join(opencodeRoot, 'opencode.db'); + const db = createOpenCodeDb(dbPath); + + const sqliteTimestamp = Date.UTC(2026, 1, 1, 9, 0, 0); + db.query( + 'INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?, ?, ?, ?, ?)' + ).run( + 'msg_shared_id', + 'session_shared', + sqliteTimestamp, + sqliteTimestamp, + JSON.stringify({ + role: 'assistant', + modelID: 'gpt-5.3-codex', + time: { created: sqliteTimestamp }, + tokens: { + input: 200, + output: 100, + cache: { read: 50, write: 0 }, + }, + }) + ); + db.close(); + + const jsonDir = join(opencodeRoot, 'storage', 'message', 'session-shared'); + mkdirSync(jsonDir, { recursive: true }); + writeFileSync( + join(jsonDir, 'msg_shared_id.json'), + JSON.stringify({ + id: 'msg_shared_id', + sessionID: 'session_shared', + role: 'assistant', + modelID: 'gpt-5.3-codex', + time: { created: sqliteTimestamp }, + tokens: { + input: 999, + output: 999, + cache: { read: 999, write: 999 }, + }, + }) + ); + writeFileSync( + join(jsonDir, 'msg_json_unique.json'), + JSON.stringify({ + id: 'msg_json_unique', + sessionID: 'session_json_unique', + role: 'assistant', + modelID: 'gpt-4.1', + time: { created: sqliteTimestamp + 1000 }, + tokens: { + input: 10, + output: 5, + cache: { read: 0, write: 0 }, + }, + }) + ); + + const parser = new OpenCodeParser(); + const result = await parser.parse(); + + expect(result.records).toHaveLength(2); + expect(result.stats.messageCount).toBe(2); + expect(result.stats.totalInputTokens).toBe(210); + expect(result.stats.totalOutputTokens).toBe(105); + expect(result.stats.totalCacheReadTokens).toBe(50); + expect(result.stats.totalCacheCreationTokens).toBe(0); + }); +}); diff --git a/packages/cli/src/parsers/opencode.ts b/packages/cli/src/parsers/opencode.ts index dd5e74c..240562d 100644 --- a/packages/cli/src/parsers/opencode.ts +++ b/packages/cli/src/parsers/opencode.ts @@ -1,23 +1,17 @@ /** * OpenCode Parser * - * Parses usage data from OpenCode CLI (~/.local/share/opencode/storage/message/) - * - * OpenCode stores individual JSON files per message in subdirectories. - * Messages with role "assistant" contain token usage information. - * - * Data structure: - * - Each JSON file contains a single message - * - `role: "assistant"` messages have token data in the `tokens` field - * - `tokens.input`, `tokens.output` for main token counts - * - `tokens.cache.read`, `tokens.cache.write` for cache tokens - * - `tokens.reasoning` for reasoning tokens (optional) + * Supports both OpenCode storage formats: + * - Current SQLite database: ~/.local/share/opencode/opencode.db + * - Legacy JSON messages: ~/.local/share/opencode/storage/message/ */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { Database } from 'bun:sqlite'; + import type { IncrementalParseResult, ParseOptions, @@ -28,34 +22,38 @@ import type { } from './types.js'; import type { FileCheckpoint, SourceCheckpoint } from '../config/sync-checkpoint.js'; -/** Structure of an OpenCode message JSON file */ -interface OpenCodeMessage { - id: string; - sessionID: string; - role: string; - modelID?: string; - providerID?: string; - cost?: number; - tokens?: { - input: number; - output: number; - reasoning?: number; - cache: { - read: number; - write: number; - }; +interface OpenCodeMessageTokens { + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; }; - time: { - created: number; // Unix timestamp in milliseconds (as float) +} + +interface OpenCodeMessagePayload { + role?: string; + modelID?: string; + tokens?: OpenCodeMessageTokens; + time?: { + created?: number; completed?: number; }; - agent?: string; - mode?: string; } -/** - * Create empty stats object - */ +interface OpenCodeLegacyMessage extends OpenCodeMessagePayload { + id?: string; + sessionID?: string; +} + +interface ParseContext { + seenRecordIds: Set; + records: UsageRecord[]; + errors: Array<{ file: string; error: string }>; + filesProcessed: number; +} + function createEmptyStats(): UsageStats { return { totalInputTokens: 0, @@ -69,43 +67,34 @@ function createEmptyStats(): UsageStats { }; } -/** - * Extract date from Unix timestamp (milliseconds) - */ -function extractDate(timestamp: number): string { - return new Date(timestamp).toISOString().split('T')[0] || 'unknown'; +function extractDate(timestamp: string): string { + return timestamp.split('T')[0] || 'unknown'; } -/** - * Get the OpenCode data path based on platform - */ -function getOpenCodeDataPath(): string { +function getOpenCodeRootPath(): string { const home = homedir(); - - // Check XDG_DATA_HOME first (Linux standard) const xdgDataHome = process.env['XDG_DATA_HOME']; + if (xdgDataHome) { - return join(xdgDataHome, 'opencode', 'storage', 'message'); + return join(xdgDataHome, 'opencode'); } - const platform = process.platform; - - if (platform === 'darwin') { - // macOS: ~/.local/share/opencode/storage/message - return join(home, '.local', 'share', 'opencode', 'storage', 'message'); - } else if (platform === 'win32') { - // Windows: %LOCALAPPDATA%\opencode\storage\message + if (process.platform === 'win32') { const localAppData = process.env['LOCALAPPDATA'] || join(home, 'AppData', 'Local'); - return join(localAppData, 'opencode', 'storage', 'message'); - } else { - // Linux: ~/.local/share/opencode/storage/message - return join(home, '.local', 'share', 'opencode', 'storage', 'message'); + return join(localAppData, 'opencode'); } + + return join(home, '.local', 'share', 'opencode'); +} + +function getOpenCodePaths(): { dbPath: string; messageDir: string } { + const root = getOpenCodeRootPath(); + return { + dbPath: join(root, 'opencode.db'), + messageDir: join(root, 'storage', 'message'), + }; } -/** - * Recursively find all .json files in a directory - */ function findJsonFiles(dir: string): string[] { const files: string[] = []; @@ -115,10 +104,8 @@ function findJsonFiles(dir: string): string[] { try { const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { files.push(...findJsonFiles(fullPath)); } else if (entry.isFile() && entry.name.endsWith('.json')) { @@ -126,52 +113,299 @@ function findJsonFiles(dir: string): string[] { } } } catch { - // Skip directories we can't read + return files; } return files; } -/** - * Parse a single OpenCode message JSON file - */ -function parseMessageFile(filePath: string): UsageRecord | null { +function parseMessagePayload( + payload: OpenCodeMessagePayload, + messageId: string, + sessionId: string +): UsageRecord | null { + if (!payload || payload.role !== 'assistant' || !payload.tokens) { + return null; + } + + const inputTokens = payload.tokens.input || 0; + const outputTokens = (payload.tokens.output || 0) + (payload.tokens.reasoning || 0); + const cacheCreationTokens = payload.tokens.cache?.write || 0; + const cacheReadTokens = payload.tokens.cache?.read || 0; + + if (!inputTokens && !outputTokens && !cacheCreationTokens && !cacheReadTokens) { + return null; + } + + const createdAt = payload.time?.created; + if (createdAt === undefined || createdAt === null) { + return null; + } + + return { + id: messageId, + sessionId, + source: 'opencode', + model: payload.modelID || 'unknown', + timestamp: new Date(createdAt).toISOString(), + inputTokens, + outputTokens, + cacheCreationTokens, + cacheReadTokens, + }; +} + +function parseLegacyMessageFile(filePath: string): UsageRecord | null { try { const content = readFileSync(filePath, 'utf-8'); - const msg = JSON.parse(content) as OpenCodeMessage; + const msg = JSON.parse(content) as OpenCodeLegacyMessage; - // Only process assistant messages with token data - if (msg.role !== 'assistant') { + if (!msg.id || !msg.sessionID) { return null; } - if (!msg.tokens) { - return null; + return parseMessagePayload(msg, msg.id, msg.sessionID); + } catch { + return null; + } +} + +function addRecord(record: UsageRecord, context: ParseContext): void { + if (context.seenRecordIds.has(record.id)) { + return; + } + context.seenRecordIds.add(record.id); + context.records.push(record); +} + +function buildStats(records: UsageRecord[]): UsageStats { + const stats = createEmptyStats(); + const sessions = new Set(); + const sessionsByDate = new Map>(); + + for (const record of records) { + sessions.add(record.sessionId); + + stats.totalInputTokens += record.inputTokens; + stats.totalOutputTokens += record.outputTokens; + stats.totalCacheCreationTokens += record.cacheCreationTokens; + stats.totalCacheReadTokens += record.cacheReadTokens; + stats.messageCount++; + + let modelStats = stats.byModel[record.model]; + if (!modelStats) { + modelStats = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + messageCount: 0, + }; + stats.byModel[record.model] = modelStats; + } + modelStats.inputTokens += record.inputTokens; + modelStats.outputTokens += record.outputTokens; + modelStats.cacheCreationTokens += record.cacheCreationTokens; + modelStats.cacheReadTokens += record.cacheReadTokens; + modelStats.messageCount++; + + const date = extractDate(record.timestamp); + let dateStats = stats.byDate[date]; + if (!dateStats) { + dateStats = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + messageCount: 0, + sessionCount: 0, + }; + stats.byDate[date] = dateStats; } + dateStats.inputTokens += record.inputTokens; + dateStats.outputTokens += record.outputTokens; + dateStats.cacheCreationTokens += record.cacheCreationTokens; + dateStats.cacheReadTokens += record.cacheReadTokens; + dateStats.messageCount++; + + let dateSessions = sessionsByDate.get(date); + if (!dateSessions) { + dateSessions = new Set(); + sessionsByDate.set(date, dateSessions); + } + dateSessions.add(record.sessionId); + } - const { input, output, reasoning, cache } = msg.tokens; + for (const [date, dateSessions] of sessionsByDate) { + const dateStats = stats.byDate[date]; + if (dateStats) { + dateStats.sessionCount = dateSessions.size; + } + } - // Skip if no actual token usage - if (!input && !output && !cache.read && !cache.write) { - return null; + stats.sessionCount = sessions.size; + return stats; +} + +function parseFromLegacyJson( + messageDir: string, + context: ParseContext, + limit: number, + onProgress?: (processed: number, total: number) => void, + fileCheckpoints?: Record, + newFileCheckpoints?: Record, + incremental = false +): { skippedFiles: number } { + const jsonFiles = findJsonFiles(messageDir); + const totalFiles = limit > 0 ? Math.min(jsonFiles.length, limit) : jsonFiles.length; + let skippedFiles = 0; + + for (const filePath of jsonFiles) { + if (limit > 0 && context.filesProcessed >= limit) { + break; + } + + if (incremental) { + const stat = statSync(filePath); + const prevCheckpoint = fileCheckpoints?.[filePath]; + if ( + prevCheckpoint && + prevCheckpoint.mtime === stat.mtimeMs && + prevCheckpoint.size === stat.size + ) { + if (newFileCheckpoints) { + newFileCheckpoints[filePath] = prevCheckpoint; + } + skippedFiles++; + context.filesProcessed++; + continue; + } + + if (newFileCheckpoints) { + newFileCheckpoints[filePath] = { mtime: stat.mtimeMs, size: stat.size }; + } } - const model = msg.modelID || 'unknown'; - const timestamp = new Date(msg.time.created).toISOString(); + context.filesProcessed++; + if (onProgress && context.filesProcessed % 100 === 0) { + onProgress(context.filesProcessed, totalFiles); + } + try { + const record = parseLegacyMessageFile(filePath); + if (record) { + addRecord(record, context); + } + } catch (err) { + context.errors.push({ + file: filePath, + error: err instanceof Error ? err.message : 'Unknown error', + }); + } + } + + return { skippedFiles }; +} + +function parseFromSqlite( + dbPath: string, + context: ParseContext, + limit: number, + sqliteCheckpoint: SourceCheckpoint['sqlite'] | undefined, + incremental: boolean +): { + skippedFiles: number; + sqlite: SourceCheckpoint['sqlite']; +} { + if (!existsSync(dbPath)) { return { - id: msg.id, - sessionId: msg.sessionID, - source: 'opencode', - model, - timestamp, - inputTokens: input || 0, - outputTokens: (output || 0) + (reasoning || 0), // Include reasoning in output - cacheCreationTokens: cache.write || 0, - cacheReadTokens: cache.read || 0, + skippedFiles: 0, + sqlite: sqliteCheckpoint, }; - } catch { - return null; + } + + if (limit > 0 && context.filesProcessed >= limit) { + return { + skippedFiles: 0, + sqlite: sqliteCheckpoint, + }; + } + + const stat = statSync(dbPath); + const sqliteResult: SourceCheckpoint['sqlite'] = { + dbMtime: stat.mtimeMs, + dbSize: stat.size, + }; + + if ( + incremental && + sqliteCheckpoint?.dbMtime === stat.mtimeMs && + sqliteCheckpoint?.dbSize === stat.size + ) { + context.filesProcessed++; + return { + skippedFiles: 1, + sqlite: sqliteCheckpoint, + }; + } + + let db: Database | null = null; + + try { + db = new Database(dbPath, { readonly: true }); + + const tableExists = db + .query< + { count: number }, + [] + >("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'message'") + .get(); + + context.filesProcessed++; + + if (!tableExists || Number(tableExists.count) === 0) { + return { + skippedFiles: 0, + sqlite: sqliteResult, + }; + } + + const rows = db + .query< + { id: string; session_id: string; data: string }, + [] + >('SELECT id, session_id, data FROM message ORDER BY time_created ASC') + .all(); + + for (const row of rows) { + try { + const payload = JSON.parse(row.data) as OpenCodeMessagePayload; + const record = parseMessagePayload(payload, row.id, row.session_id); + if (record) { + addRecord(record, context); + } + } catch { + continue; + } + } + + return { + skippedFiles: 0, + sqlite: sqliteResult, + }; + } catch (err) { + context.errors.push({ + file: dbPath, + error: err instanceof Error ? err.message : 'Unknown error reading database', + }); + return { + skippedFiles: 0, + sqlite: sqliteResult, + }; + } finally { + if (db) { + db.close(); + } } } @@ -179,129 +413,40 @@ export class OpenCodeParser implements Parser { readonly name = 'opencode'; readonly displayName = 'OpenCode'; readonly defaultPaths: string[]; + private readonly dbPath: string; + private readonly messageDir: string; constructor() { - this.defaultPaths = [getOpenCodeDataPath()]; + const paths = getOpenCodePaths(); + this.dbPath = paths.dbPath; + this.messageDir = paths.messageDir; + this.defaultPaths = [this.dbPath, this.messageDir]; } async exists(): Promise { - return this.defaultPaths.some((p) => existsSync(p) && statSync(p).isDirectory()); + const hasDb = existsSync(this.dbPath) && statSync(this.dbPath).isFile(); + const hasJson = existsSync(this.messageDir) && statSync(this.messageDir).isDirectory(); + return hasDb || hasJson; } async parse(options?: ParseOptions): Promise { const limit = options?.limit || 0; - const onProgress = options?.onProgress; - const records: UsageRecord[] = []; - const errors: Array<{ file: string; error: string }> = []; - const stats = createEmptyStats(); - const processedSessions = new Set(); - - let filesProcessed = 0; - - for (const basePath of this.defaultPaths) { - const jsonFiles = findJsonFiles(basePath); - const totalFiles = limit > 0 ? Math.min(jsonFiles.length, limit) : jsonFiles.length; - - for (const filePath of jsonFiles) { - // Check limit - if (limit > 0 && filesProcessed >= limit) { - break; - } - - filesProcessed++; - - // Report progress every 100 files - if (onProgress && filesProcessed % 100 === 0) { - onProgress(filesProcessed, totalFiles); - } - - try { - const record = parseMessageFile(filePath); - - if (record) { - records.push(record); - processedSessions.add(record.sessionId); - - // Update stats - stats.totalInputTokens += record.inputTokens; - stats.totalOutputTokens += record.outputTokens; - stats.totalCacheCreationTokens += record.cacheCreationTokens; - stats.totalCacheReadTokens += record.cacheReadTokens; - stats.messageCount++; - - // By model - let modelStats = stats.byModel[record.model]; - if (!modelStats) { - modelStats = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - messageCount: 0, - }; - stats.byModel[record.model] = modelStats; - } - modelStats.inputTokens += record.inputTokens; - modelStats.outputTokens += record.outputTokens; - modelStats.cacheCreationTokens += record.cacheCreationTokens; - modelStats.cacheReadTokens += record.cacheReadTokens; - modelStats.messageCount++; - - // By date - const date = extractDate(new Date(record.timestamp).getTime()); - let dateStats = stats.byDate[date]; - if (!dateStats) { - dateStats = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - messageCount: 0, - sessionCount: 0, - }; - stats.byDate[date] = dateStats; - } - dateStats.inputTokens += record.inputTokens; - dateStats.outputTokens += record.outputTokens; - dateStats.cacheCreationTokens += record.cacheCreationTokens; - dateStats.cacheReadTokens += record.cacheReadTokens; - dateStats.messageCount++; - } - } catch (err) { - errors.push({ - file: filePath, - error: err instanceof Error ? err.message : 'Unknown error', - }); - } - } - } - - // Update session counts per date - const sessionsPerDate = new Map>(); - for (const record of records) { - const date = extractDate(new Date(record.timestamp).getTime()); - let sessions = sessionsPerDate.get(date); - if (!sessions) { - sessions = new Set(); - sessionsPerDate.set(date, sessions); - } - sessions.add(record.sessionId); - } - for (const [date, sessions] of sessionsPerDate) { - const dateStats = stats.byDate[date]; - if (dateStats) { - dateStats.sessionCount = sessions.size; - } - } + const context: ParseContext = { + seenRecordIds: new Set(), + records: [], + errors: [], + filesProcessed: 0, + }; - stats.sessionCount = processedSessions.size; + parseFromSqlite(this.dbPath, context, limit, undefined, false); + parseFromLegacyJson(this.messageDir, context, limit, options?.onProgress); return { source: this.name, - records, - stats, - filesProcessed, - errors, + records: context.records, + stats: buildStats(context.records), + filesProcessed: context.filesProcessed, + errors: context.errors, }; } @@ -310,132 +455,43 @@ export class OpenCodeParser implements Parser { options?: ParseOptions ): Promise { const limit = options?.limit || 0; - const onProgress = options?.onProgress; - const records: UsageRecord[] = []; - const errors: Array<{ file: string; error: string }> = []; - const stats = createEmptyStats(); - const processedSessions = new Set(); + const context: ParseContext = { + seenRecordIds: new Set(), + records: [], + errors: [], + filesProcessed: 0, + }; const fileCheckpoints = checkpoint?.files || {}; const newFileCheckpoints: Record = {}; - let skippedFiles = 0; - let filesProcessed = 0; - - for (const basePath of this.defaultPaths) { - const jsonFiles = findJsonFiles(basePath); - const totalFiles = limit > 0 ? Math.min(jsonFiles.length, limit) : jsonFiles.length; - - for (const filePath of jsonFiles) { - if (limit > 0 && filesProcessed >= limit) { - break; - } - const stat = statSync(filePath); - const prevCheckpoint = fileCheckpoints[filePath]; - - if ( - prevCheckpoint && - prevCheckpoint.mtime === stat.mtimeMs && - prevCheckpoint.size === stat.size - ) { - newFileCheckpoints[filePath] = prevCheckpoint; - skippedFiles++; - filesProcessed++; - continue; - } - - filesProcessed++; - - if (onProgress && filesProcessed % 100 === 0) { - onProgress(filesProcessed, totalFiles); - } - - newFileCheckpoints[filePath] = { mtime: stat.mtimeMs, size: stat.size }; - - try { - const record = parseMessageFile(filePath); - - if (record) { - records.push(record); - processedSessions.add(record.sessionId); - - stats.totalInputTokens += record.inputTokens; - stats.totalOutputTokens += record.outputTokens; - stats.totalCacheCreationTokens += record.cacheCreationTokens; - stats.totalCacheReadTokens += record.cacheReadTokens; - stats.messageCount++; - - let modelStats = stats.byModel[record.model]; - if (!modelStats) { - modelStats = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - messageCount: 0, - }; - stats.byModel[record.model] = modelStats; - } - modelStats.inputTokens += record.inputTokens; - modelStats.outputTokens += record.outputTokens; - modelStats.cacheCreationTokens += record.cacheCreationTokens; - modelStats.cacheReadTokens += record.cacheReadTokens; - modelStats.messageCount++; - - const date = extractDate(new Date(record.timestamp).getTime()); - let dateStats = stats.byDate[date]; - if (!dateStats) { - dateStats = { - inputTokens: 0, - outputTokens: 0, - cacheCreationTokens: 0, - cacheReadTokens: 0, - messageCount: 0, - sessionCount: 0, - }; - stats.byDate[date] = dateStats; - } - dateStats.inputTokens += record.inputTokens; - dateStats.outputTokens += record.outputTokens; - dateStats.cacheCreationTokens += record.cacheCreationTokens; - dateStats.cacheReadTokens += record.cacheReadTokens; - dateStats.messageCount++; - } - } catch (err) { - errors.push({ - file: filePath, - error: err instanceof Error ? err.message : 'Unknown error', - }); - } - } - } + let skippedFiles = 0; - const sessionsPerDate = new Map>(); - for (const record of records) { - const date = extractDate(new Date(record.timestamp).getTime()); - let sessions = sessionsPerDate.get(date); - if (!sessions) { - sessions = new Set(); - sessionsPerDate.set(date, sessions); - } - sessions.add(record.sessionId); - } - for (const [date, sessions] of sessionsPerDate) { - const dateStats = stats.byDate[date]; - if (dateStats) { - dateStats.sessionCount = sessions.size; - } - } + const sqliteResult = parseFromSqlite(this.dbPath, context, limit, checkpoint?.sqlite, true); + skippedFiles += sqliteResult.skippedFiles; - stats.sessionCount = processedSessions.size; + const jsonResult = parseFromLegacyJson( + this.messageDir, + context, + limit, + options?.onProgress, + fileCheckpoints, + newFileCheckpoints, + true + ); + skippedFiles += jsonResult.skippedFiles; return { source: this.name, - records, - stats, - filesProcessed, - errors, - checkpoint: { lastSyncedAt: new Date().toISOString(), files: newFileCheckpoints }, + records: context.records, + stats: buildStats(context.records), + filesProcessed: context.filesProcessed, + errors: context.errors, + checkpoint: { + lastSyncedAt: new Date().toISOString(), + files: newFileCheckpoints, + ...(sqliteResult.sqlite ? { sqlite: sqliteResult.sqlite } : {}), + }, isIncremental: !!checkpoint, skippedFiles, }; diff --git a/packages/cli/src/utils/cronjob.ts b/packages/cli/src/utils/cronjob.ts index 4bfee38..a45813a 100644 --- a/packages/cli/src/utils/cronjob.ts +++ b/packages/cli/src/utils/cronjob.ts @@ -6,9 +6,10 @@ */ import { execSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; +import { createInterface } from 'node:readline'; const BURNTOP_DIR = join(homedir(), '.burntop'); const SYNC_LOG_FILE = join(BURNTOP_DIR, 'sync.log'); @@ -92,8 +93,7 @@ function writeCrontab(content: string): void { } async function promptYesNo(question: string): Promise { - const readline = require('node:readline'); - const rl = readline.createInterface({ + const rl = createInterface({ input: process.stdin, output: process.stdout, }); diff --git a/packages/frontend/src/components/dashboard-insights.test.tsx b/packages/frontend/src/components/dashboard-insights.test.tsx index 1a86333..4147cba 100644 --- a/packages/frontend/src/components/dashboard-insights.test.tsx +++ b/packages/frontend/src/components/dashboard-insights.test.tsx @@ -180,9 +180,7 @@ describe('DashboardInsights', () => { expect( screen.getByText(/You've used 1,500,000 tokens, which is above the community average!/i) ).toBeInTheDocument(); - expect( - screen.getByText(/Your 7 day streak is impressive/i) - ).toBeInTheDocument(); + expect(screen.getByText(/Your 7 day streak is impressive/i)).toBeInTheDocument(); }); it('shows percentile badges', () => { diff --git a/packages/frontend/src/components/dashboard-models.test.tsx b/packages/frontend/src/components/dashboard-models.test.tsx index 0a7e0a7..89e8441 100644 --- a/packages/frontend/src/components/dashboard-models.test.tsx +++ b/packages/frontend/src/components/dashboard-models.test.tsx @@ -144,7 +144,13 @@ describe('DashboardModels', () => { status: 200, data: { models: [ - { model: 'claude-3-5-sonnet', tokens: 600000, cost: 20.0, percentage: 50, days_active: 25 }, + { + model: 'claude-3-5-sonnet', + tokens: 600000, + cost: 20.0, + percentage: 50, + days_active: 25, + }, { model: 'gpt-4o', tokens: 500000, cost: 15.0, percentage: 50, days_active: 20 }, ], }, diff --git a/packages/frontend/src/components/dashboard-models.tsx b/packages/frontend/src/components/dashboard-models.tsx index af2e5b3..295ce57 100644 --- a/packages/frontend/src/components/dashboard-models.tsx +++ b/packages/frontend/src/components/dashboard-models.tsx @@ -116,7 +116,9 @@ function BarTooltip({ active, payload }: BarTooltipProps) { return (
-
{formatModelName(data.payload.model)}
+
+ {formatModelName(data.payload.model)} +
${formatCost(data.value)} @@ -155,13 +157,11 @@ export function DashboardModels() { })); // Prepare cost bar chart data (top 6 models) - const costData = data.models - .slice(0, 6) - .map((model: ModelUsageData) => ({ - model: model.model, - cost: model.cost, - displayName: formatModelName(model.model).slice(0, 12), - })); + const costData = data.models.slice(0, 6).map((model: ModelUsageData) => ({ + model: model.model, + cost: model.cost, + displayName: formatModelName(model.model).slice(0, 12), + })); return { pieData, costData, totalTokens, totalCost }; }, [data]); @@ -361,9 +361,7 @@ export function DashboardModels() { - - ${formatCost(model.cost)} - + ${formatCost(model.cost)}
diff --git a/packages/frontend/src/components/dashboard-tools.test.tsx b/packages/frontend/src/components/dashboard-tools.test.tsx index 54b5315..be5de56 100644 --- a/packages/frontend/src/components/dashboard-tools.test.tsx +++ b/packages/frontend/src/components/dashboard-tools.test.tsx @@ -64,9 +64,7 @@ describe('DashboardTools', () => { render(, { wrapper: createWrapper() }); expect(screen.getByText('No tool usage data available')).toBeInTheDocument(); - expect( - screen.getByText('Start using AI tools to see your tool breakdown') - ).toBeInTheDocument(); + expect(screen.getByText('Start using AI tools to see your tool breakdown')).toBeInTheDocument(); }); it('renders tool distribution chart with data', () => { @@ -76,7 +74,13 @@ describe('DashboardTools', () => { data: { tools: [ { source: 'cursor', tokens: 800000, cost: 25.0, percentage: 53.3, days_active: 25 }, - { source: 'claude-code', tokens: 500000, cost: 15.0, percentage: 33.3, days_active: 20 }, + { + source: 'claude-code', + tokens: 500000, + cost: 15.0, + percentage: 33.3, + days_active: 20, + }, { source: 'chatgpt', tokens: 200000, cost: 5.67, percentage: 13.4, days_active: 10 }, ], }, @@ -100,7 +104,13 @@ describe('DashboardTools', () => { data: { tools: [ { source: 'cursor', tokens: 800000, cost: 25.0, percentage: 53.3, days_active: 25 }, - { source: 'claude-code', tokens: 500000, cost: 15.0, percentage: 33.3, days_active: 20 }, + { + source: 'claude-code', + tokens: 500000, + cost: 15.0, + percentage: 33.3, + days_active: 20, + }, ], }, }, @@ -143,7 +153,13 @@ describe('DashboardTools', () => { data: { tools: [ { source: 'cursor', tokens: 800000, cost: 25.0, percentage: 53.3, days_active: 25 }, - { source: 'claude-code', tokens: 500000, cost: 15.0, percentage: 33.3, days_active: 20 }, + { + source: 'claude-code', + tokens: 500000, + cost: 15.0, + percentage: 33.3, + days_active: 20, + }, ], }, }, diff --git a/packages/frontend/src/components/dashboard-tools.tsx b/packages/frontend/src/components/dashboard-tools.tsx index 78f4aa7..d45c3fc 100644 --- a/packages/frontend/src/components/dashboard-tools.tsx +++ b/packages/frontend/src/components/dashboard-tools.tsx @@ -82,9 +82,7 @@ function PieTooltip({ active, payload }: PieTooltipProps) {
Share - - {data.payload.percentage.toFixed(1)}% - + {data.payload.percentage.toFixed(1)}%
@@ -359,9 +357,7 @@ export function DashboardTools() { - - ${formatCost(tool.cost)} - + ${formatCost(tool.cost)}
diff --git a/packages/frontend/src/components/dashboard-trends.test.tsx b/packages/frontend/src/components/dashboard-trends.test.tsx index d0dc6a9..9386f10 100644 --- a/packages/frontend/src/components/dashboard-trends.test.tsx +++ b/packages/frontend/src/components/dashboard-trends.test.tsx @@ -63,12 +63,8 @@ describe('DashboardTrends', () => { render(, { wrapper: createWrapper() }); - expect( - screen.getByText('No usage data available for the past 30 days') - ).toBeInTheDocument(); - expect( - screen.getByText('Start using AI tools to see your trends here') - ).toBeInTheDocument(); + expect(screen.getByText('No usage data available for the past 30 days')).toBeInTheDocument(); + expect(screen.getByText('Start using AI tools to see your trends here')).toBeInTheDocument(); }); it('renders usage over time chart with data', () => { @@ -77,9 +73,27 @@ describe('DashboardTrends', () => { status: 200, data: { daily_data: [ - { date: '2024-01-01', tokens: 50000, cost: 1.5, input_tokens: 30000, output_tokens: 20000 }, - { date: '2024-01-02', tokens: 75000, cost: 2.25, input_tokens: 45000, output_tokens: 30000 }, - { date: '2024-01-03', tokens: 100000, cost: 3.0, input_tokens: 60000, output_tokens: 40000 }, + { + date: '2024-01-01', + tokens: 50000, + cost: 1.5, + input_tokens: 30000, + output_tokens: 20000, + }, + { + date: '2024-01-02', + tokens: 75000, + cost: 2.25, + input_tokens: 45000, + output_tokens: 30000, + }, + { + date: '2024-01-03', + tokens: 100000, + cost: 3.0, + input_tokens: 60000, + output_tokens: 40000, + }, ], }, }, diff --git a/packages/frontend/src/components/optimized-image.test.tsx b/packages/frontend/src/components/optimized-image.test.tsx index e3cc900..36e5237 100644 --- a/packages/frontend/src/components/optimized-image.test.tsx +++ b/packages/frontend/src/components/optimized-image.test.tsx @@ -34,9 +34,7 @@ describe('OptimizedImage', () => { }); it('applies width and height when provided', () => { - render( - - ); + render(); const img = screen.getByRole('img'); expect(img).toHaveAttribute('width', '800'); @@ -52,34 +50,21 @@ describe('OptimizedImage', () => { }); it('applies custom className', () => { - render( - - ); + render(); const img = screen.getByRole('img'); expect(img).toHaveClass('custom-image'); }); it('handles external URLs', () => { - render( - - ); + render(); const img = screen.getByRole('img'); expect(img).toHaveAttribute('src', 'https://example.com/image.jpg'); }); it('combines multiple classNames', () => { - render( - - ); + render(); const img = screen.getByRole('img'); expect(img).toHaveClass('class1'); diff --git a/packages/frontend/src/components/profile/followers-avatar-stack.test.tsx b/packages/frontend/src/components/profile/followers-avatar-stack.test.tsx index 1416aea..7eb1c6c 100644 --- a/packages/frontend/src/components/profile/followers-avatar-stack.test.tsx +++ b/packages/frontend/src/components/profile/followers-avatar-stack.test.tsx @@ -67,18 +67,14 @@ describe('FollowersAvatarStack', () => { }); it('shows remaining count when there are more followers', () => { - render( - - ); + render(); // Should show "+7" (10 - 3) expect(screen.getByText('+7')).toBeInTheDocument(); }); it('caps remaining count at 99+', () => { - render( - - ); + render(); // Should show "+99" not "+147" expect(screen.getByText('+99')).toBeInTheDocument(); @@ -108,9 +104,7 @@ describe('FollowersAvatarStack', () => { it('shows ? for empty name', () => { render( - + ); // Should show "?" @@ -120,10 +114,7 @@ describe('FollowersAvatarStack', () => { describe('size variants', () => { it('applies small size styles', () => { const { container } = render( - + ); const avatars = container.querySelectorAll('.h-6.w-6'); @@ -132,10 +123,7 @@ describe('FollowersAvatarStack', () => { it('applies medium size styles (default)', () => { const { container } = render( - + ); const avatars = container.querySelectorAll('.h-8.w-8'); @@ -144,10 +132,7 @@ describe('FollowersAvatarStack', () => { it('applies large size styles', () => { const { container } = render( - + ); const avatars = container.querySelectorAll('.h-10.w-10'); @@ -157,10 +142,7 @@ describe('FollowersAvatarStack', () => { it('applies custom className', () => { const { container } = render( - + ); expect(container.firstChild).toHaveClass('custom-stack'); diff --git a/packages/frontend/src/components/profile/leaderboard-position-widget.test.tsx b/packages/frontend/src/components/profile/leaderboard-position-widget.test.tsx index aa9caa5..fdc6d32 100644 --- a/packages/frontend/src/components/profile/leaderboard-position-widget.test.tsx +++ b/packages/frontend/src/components/profile/leaderboard-position-widget.test.tsx @@ -37,9 +37,7 @@ describe('LeaderboardPositionWidget', () => { render(); expect(screen.getByText('Not ranked yet')).toBeInTheDocument(); - expect( - screen.getByText('Sync your usage to appear on the leaderboard') - ).toBeInTheDocument(); + expect(screen.getByText('Sync your usage to appear on the leaderboard')).toBeInTheDocument(); }); it('displays rank with # prefix', () => { @@ -112,27 +110,21 @@ describe('LeaderboardPositionWidget', () => { describe('rank styling', () => { it('applies gold styling for rank 1', () => { - const { container } = render( - - ); + const { container } = render(); const link = container.querySelector('a'); expect(link).toHaveClass('text-amber-400'); }); it('applies silver styling for rank 2', () => { - const { container } = render( - - ); + const { container } = render(); const link = container.querySelector('a'); expect(link).toHaveClass('text-gray-300'); }); it('applies bronze styling for rank 3', () => { - const { container } = render( - - ); + const { container } = render(); const link = container.querySelector('a'); expect(link).toHaveClass('text-amber-600'); @@ -175,9 +167,7 @@ describe('LeaderboardPositionWidgetSkeleton', () => { }); it('applies custom className', () => { - const { container } = render( - - ); + const { container } = render(); expect(container.firstChild).toHaveClass('custom-skeleton'); }); diff --git a/packages/frontend/src/components/profile/project-card.tsx b/packages/frontend/src/components/profile/project-card.tsx index 87bf2bc..54b976d 100644 --- a/packages/frontend/src/components/profile/project-card.tsx +++ b/packages/frontend/src/components/profile/project-card.tsx @@ -13,7 +13,6 @@ import { } from 'lucide-react'; import { useMemo, useState } from 'react'; - import type { ProjectResponse } from '@/api/generated.schemas'; import type { ElementType } from 'react'; diff --git a/packages/frontend/src/components/stats-card.tsx b/packages/frontend/src/components/stats-card.tsx index d179657..9ce8f6a 100644 --- a/packages/frontend/src/components/stats-card.tsx +++ b/packages/frontend/src/components/stats-card.tsx @@ -164,9 +164,7 @@ export function StatsCard({
{/* Sublabel */} - {sublabel && ( -

{sublabel}

- )} + {sublabel &&

{sublabel}

} {/* Corner accent for ember variant */} diff --git a/packages/frontend/src/components/tool-icon.test.tsx b/packages/frontend/src/components/tool-icon.test.tsx index 6877681..4315901 100644 --- a/packages/frontend/src/components/tool-icon.test.tsx +++ b/packages/frontend/src/components/tool-icon.test.tsx @@ -1,13 +1,7 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { - formatToolName, - getToolColor, - getToolConfig, - ToolBadge, - ToolIcon, -} from './tool-icon'; +import { formatToolName, getToolColor, getToolConfig, ToolBadge, ToolIcon } from './tool-icon'; describe('getToolConfig', () => { it('returns config for known tools', () => { @@ -124,9 +118,7 @@ describe('ToolIcon', () => { }); it('applies custom className', () => { - const { container } = render( - - ); + const { container } = render(); expect(container.firstChild).toHaveClass('custom-icon'); }); @@ -173,9 +165,7 @@ describe('ToolBadge', () => { }); it('applies custom className', () => { - const { container } = render( - - ); + const { container } = render(); expect(container.firstChild).toHaveClass('custom-badge'); }); diff --git a/packages/frontend/src/lib/og/ERROR_HANDLING.md b/packages/frontend/src/lib/og/ERROR_HANDLING.md index 246ac7b..adf72c0 100644 --- a/packages/frontend/src/lib/og/ERROR_HANDLING.md +++ b/packages/frontend/src/lib/og/ERROR_HANDLING.md @@ -9,42 +9,51 @@ The OG image generation system handles various error scenarios gracefully by ren ## Error Types ### 1. User Not Found (404) + **Trigger:** User doesn't exist in the database or hasn't created a profile yet. **Response:** + - HTTP Status: 404 - Error Card: "User Not Found" with 🔍 emoji - Message: "The user @username doesn't exist or hasn't set up their profile yet." - Cache: 5 minutes **Example:** + ``` GET /api/og/nonexistent-user/stats → Returns PNG with "User Not Found" error card ``` ### 2. Private Profile (403) + **Trigger:** User's profile is marked as private and cannot be shared publicly. **Response:** + - HTTP Status: 403 - Error Card: "Private Profile" with 🔒 emoji - Message: "@username's profile is private and cannot be shared publicly." - Cache: 5 minutes ### 3. Server Error (500) + **Trigger:** Backend API error, network failure, or unexpected server issue. **Response:** + - HTTP Status: 500 - Error Card: "Something Went Wrong" with ⚠️ emoji - Message: "Unable to generate stats image. Please try again later." - Cache: 1 minute ### 4. Invalid Data + **Trigger:** User has no activity data, or data fails validation checks. **Response:** + - HTTP Status: 200 (user exists but has no data) or 500 (validation failed) - Error Card: "No Data Available" with 📊 emoji - Message: "@username doesn't have enough activity data to display stats yet." @@ -55,12 +64,14 @@ GET /api/og/nonexistent-user/stats ### Stats Data Validation The `validateStatsData()` function checks: + - ✅ Required fields are present (username, total_tokens, etc.) - ✅ Numeric fields are non-negative - ✅ Cache efficiency is in valid range (0-100) if present - ✅ No NaN or Infinity values **Example:** + ```typescript const validation = validateStatsData(statsData); if (!validation.isValid) { @@ -71,10 +82,12 @@ if (!validation.isValid) { ### Minimum Activity Check The `hasMinimumActivityData()` function ensures users have: + - At least 1 token used, OR - At least 1 unique activity day **Example:** + ```typescript if (!hasMinimumActivityData(statsData)) { // Show "No Data Available" error card @@ -84,6 +97,7 @@ if (!hasMinimumActivityData(statsData)) { ### Weekly Data Validation For weekly recap images: + - `validateWeeklyEstimates()`: Ensures weekly tokens don't exceed monthly - `validateDaysActive()`: Clamps days to 0-7 range @@ -92,6 +106,7 @@ For weekly recap images: The `ErrorCardTemplate` component renders beautiful, branded error states: **Features:** + - Consistent branding with burntop.dev design system - Large emoji icon for visual clarity - Clear error title and message @@ -99,6 +114,7 @@ The `ErrorCardTemplate` component renders beautiful, branded error states: - Matches OG image dimensions (1200x630) **Usage:** + ```typescript const errorCard = ErrorCardTemplate({ errorType: 'not_found', @@ -120,11 +136,13 @@ The system implements a multi-level fallback approach: ## HTTP Response Headers All OG image responses include: + - `Content-Type`: `image/png` (or `image/svg+xml` for fallback) - `Cache-Control`: Appropriate caching based on error type - `X-Error-Type`: Custom header indicating error category (for debugging) **Cache Duration:** + - Success: 1 hour (3600s) - Not Found/Private: 5 minutes (300s) - Server Error: 1 minute (60s) @@ -133,6 +151,7 @@ All OG image responses include: ## Safe Fallback Utilities ### `safeNumber(value, fallback = 0)` + Returns a valid number or fallback value. ```typescript @@ -141,6 +160,7 @@ const cost = safeNumber(statsData.total_cost, 0); ``` ### `safeString(value, fallback = '')` + Returns a valid string or fallback value. ```typescript @@ -149,6 +169,7 @@ const username = safeString(statsData.username, 'unknown'); ``` ### `getSafeCacheEfficiency(statsData)` + Returns cache efficiency if valid (0-100) or undefined. ```typescript @@ -167,9 +188,12 @@ const statsResponse = await fetch(`${API_BASE_URL}/api/v1/users/${username}/stat if (!statsResponse.ok) { // Render appropriate error card based on status code const errorCard = ErrorCardTemplate({ - errorType: statsResponse.status === 404 ? 'not_found' : - statsResponse.status === 403 ? 'private' : - 'server_error', + errorType: + statsResponse.status === 404 + ? 'not_found' + : statsResponse.status === 403 + ? 'private' + : 'server_error', username, }); @@ -207,12 +231,14 @@ const daysValidation = validateDaysActive(rawDaysActive); ## Testing Comprehensive test coverage includes: + - Unit tests for validation functions (`validate-data.test.ts`) - Component tests for error card template (`error-card-template.test.tsx`) - Edge cases: negative values, NaN, Infinity, null, undefined - Boundary conditions: cache efficiency 0-100, days active 0-7 **Run tests:** + ```bash cd packages/frontend npm test -- src/lib/og/validate-data.test.ts @@ -235,6 +261,7 @@ To debug OG image errors: ## Future Enhancements Potential improvements: + - [ ] Rate limiting error cards for abuse prevention - [ ] Telemetry/analytics for error tracking - [ ] Retry logic with exponential backoff diff --git a/packages/frontend/src/lib/og/error-card-template.tsx b/packages/frontend/src/lib/og/error-card-template.tsx index 918d018..a1c3909 100644 --- a/packages/frontend/src/lib/og/error-card-template.tsx +++ b/packages/frontend/src/lib/og/error-card-template.tsx @@ -67,7 +67,7 @@ function getErrorDetails( title: 'No Data Available', message: username ? `@${username} doesn't have enough activity data to display stats yet.` - : "Not enough activity data to display stats yet.", + : 'Not enough activity data to display stats yet.', emoji: '📊', }; default: diff --git a/packages/frontend/src/routes/api/og/$username.weekly.ts b/packages/frontend/src/routes/api/og/$username.weekly.ts index 8c5f7ad..bb4b03b 100644 --- a/packages/frontend/src/routes/api/og/$username.weekly.ts +++ b/packages/frontend/src/routes/api/og/$username.weekly.ts @@ -66,7 +66,12 @@ export const Route = createFileRoute('/api/og/$username/weekly')({ status: statsResponse.status, headers: { ...createImageHeaders(300), // Cache errors for 5 minutes - 'X-Error-Type': statsResponse.status === 404 ? 'not_found' : statsResponse.status === 403 ? 'private' : 'server_error', + 'X-Error-Type': + statsResponse.status === 404 + ? 'not_found' + : statsResponse.status === 403 + ? 'private' + : 'server_error', }, }); } @@ -131,7 +136,10 @@ export const Route = createFileRoute('/api/og/$username/weekly')({ ); if (!weeklyValidation.isValid || !weeklyValidation.data) { - console.error(`[OG] Invalid weekly estimates for user ${username}:`, weeklyValidation.error); + console.error( + `[OG] Invalid weekly estimates for user ${username}:`, + weeklyValidation.error + ); const errorCard = ErrorCardTemplate({ errorType: 'invalid_data', @@ -191,10 +199,7 @@ export const Route = createFileRoute('/api/og/$username/weekly')({ // Calculate week-over-week growth (estimate as positive trend if streak is increasing) // Without historical data, we'll show modest growth if they have an active streak - const weekOverWeekGrowth = safeNumber( - validatedStats.current_streak > 0 ? 15.5 : 0, - 0 - ); + const weekOverWeekGrowth = safeNumber(validatedStats.current_streak > 0 ? 15.5 : 0, 0); // Calculate week date range const weekEnd = new Date(); diff --git a/packages/frontend/tests/QUICK-START.md b/packages/frontend/tests/QUICK-START.md index 6038cf1..184545d 100644 --- a/packages/frontend/tests/QUICK-START.md +++ b/packages/frontend/tests/QUICK-START.md @@ -27,6 +27,7 @@ npm run test:e2e ## Test Results Location After running tests: + - **Console Output**: Shows pass/fail for each test - **HTML Report**: Run `npx playwright show-report` to view detailed results - **Screenshots**: Failures are captured in `test-results/` @@ -34,16 +35,20 @@ After running tests: ## Troubleshooting ### "Cannot find test user" + → Create a test user in your database or change the username in the test file: + ```typescript // In tests/sharing-verification.spec.ts const testUsername = 'your-actual-username'; ``` ### "Connection refused" + → Make sure both backend (port 8000) and frontend (port 3000) are running ### "Playwright not found" + → Run the setup script: `./tests/setup-playwright.sh` ## Next Task (T009) diff --git a/packages/frontend/tests/README.md b/packages/frontend/tests/README.md index dd61562..bf73deb 100644 --- a/packages/frontend/tests/README.md +++ b/packages/frontend/tests/README.md @@ -61,6 +61,7 @@ The Playwright configuration is in `playwright.config.ts`. Key settings: Before running the tests, ensure: 1. **Backend is running**: The tests expect the FastAPI backend to be running at `http://localhost:8000` + ```bash cd packages/backend uv run uvicorn src.app.main:app --reload --port 8000