diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 337c02e9..bede08b5 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -94,8 +94,14 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "default": false + }, + "visual": { + "type": "boolean", + "description": "Visual output mode with compact table, sparklines, and heatmap", + "markdownDescription": "Visual output mode with compact table, sparklines, and heatmap", "default": false } }, @@ -191,10 +197,16 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", "default": false }, + "visual": { + "type": "string", + "enum": ["compact", "bar", "spark", "heatmap"], + "description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)", + "markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)" + }, "instances": { "type": "boolean", "description": "Show usage breakdown by project/instance", @@ -299,9 +311,15 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", "default": false + }, + "visual": { + "type": "string", + "enum": ["compact", "bar", "spark", "heatmap"], + "description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)", + "markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)" } }, "additionalProperties": false @@ -391,10 +409,16 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", "default": false }, + "visual": { + "type": "string", + "enum": ["compact", "bar", "spark", "heatmap"], + "description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)", + "markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)" + }, "startOfWeek": { "type": "string", "enum": [ @@ -491,10 +515,16 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", "default": false }, + "visual": { + "type": "string", + "enum": ["compact", "bar", "spark", "heatmap"], + "description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)", + "markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)" + }, "id": { "type": "string", "description": "Load usage data for a specific session ID", @@ -588,10 +618,16 @@ }, "compact": { "type": "boolean", - "description": "Force compact mode for narrow displays (better for screenshots)", - "markdownDescription": "Force compact mode for narrow displays (better for screenshots)", + "description": "[Deprecated: use --visual compact] Force compact mode for narrow displays", + "markdownDescription": "[Deprecated: use --visual compact] Force compact mode for narrow displays", "default": false }, + "visual": { + "type": "string", + "enum": ["compact", "bar", "spark", "heatmap"], + "description": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)", + "markdownDescription": "Visual output mode: compact (dense table with sparklines), bar (horizontal bars), spark (full-width sparkline), heatmap (calendar grid)" + }, "active": { "type": "boolean", "description": "Show only active block with projections", diff --git a/apps/ccusage/src/_pricing-fetcher.ts b/apps/ccusage/src/_pricing-fetcher.ts index 5e66aed8..39009504 100644 --- a/apps/ccusage/src/_pricing-fetcher.ts +++ b/apps/ccusage/src/_pricing-fetcher.ts @@ -1,3 +1,7 @@ +import type { LiteLLMModelPricing } from '@ccusage/internal/pricing'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { LiteLLMPricingFetcher } from '@ccusage/internal/pricing'; import { Result } from '@praha/byethrow'; import { prefetchClaudePricing } from './_macro.ts' with { type: 'macro' }; @@ -13,27 +17,136 @@ const CLAUDE_PROVIDER_PREFIXES = [ const PREFETCHED_CLAUDE_PRICING = prefetchClaudePricing(); +/** Cache TTL in milliseconds (24 hours) */ +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; + +/** Get the cache directory path */ +function getCacheDir(): string { + return path.join(os.homedir(), '.cache', 'ccusage'); +} + +/** Get the pricing cache file path */ +function getCacheFilePath(): string { + return path.join(getCacheDir(), 'pricing.json'); +} + +/** Check if the cache file is still valid (less than 24 hours old) */ +async function isCacheValid(): Promise { + try { + const stats = await fs.stat(getCacheFilePath()); + const age = Date.now() - stats.mtimeMs; + return age < CACHE_TTL_MS; + } catch { + return false; + } +} + +/** Load pricing from disk cache */ +async function loadDiskCache(): Promise | null> { + try { + const data = await fs.readFile(getCacheFilePath(), 'utf-8'); + return JSON.parse(data) as Record; + } catch { + return null; + } +} + +/** Save pricing to disk cache */ +async function saveDiskCache(pricing: Map): Promise { + try { + const cacheDir = getCacheDir(); + await fs.mkdir(cacheDir, { recursive: true }); + const obj = Object.fromEntries(pricing); + await fs.writeFile(getCacheFilePath(), JSON.stringify(obj)); + } catch (error) { + logger.debug('Failed to save pricing cache:', error); + } +} + +export type PricingFetcherOptions = { + /** Use bundled offline pricing (ignores cache and network) */ + offline?: boolean; + /** Force refresh from network (ignores cache) */ + refreshPricing?: boolean; +}; + export class PricingFetcher extends LiteLLMPricingFetcher { - constructor(offline = false) { + private readonly refreshPricing: boolean; + private diskCacheLoaded = false; + + constructor(options: PricingFetcherOptions | boolean = {}) { + // support legacy boolean signature + const opts = typeof options === 'boolean' ? { offline: options } : options; + super({ - offline, + offline: opts.offline ?? false, offlineLoader: async () => PREFETCHED_CLAUDE_PRICING, logger, providerPrefixes: CLAUDE_PROVIDER_PREFIXES, }); + + this.refreshPricing = opts.refreshPricing ?? false; + } + + override async fetchModelPricing(): Result.ResultAsync, Error> { + // if refresh requested, skip cache + if (this.refreshPricing) { + logger.debug('Refresh pricing flag set, fetching fresh data'); + return this.fetchAndCache(); + } + + // try disk cache first (unless offline mode which uses bundled data) + if (!this.diskCacheLoaded) { + this.diskCacheLoaded = true; + const cacheValid = await isCacheValid(); + if (cacheValid) { + const cached = await loadDiskCache(); + if (cached != null) { + const pricing = new Map(Object.entries(cached)); + logger.debug(`Using cached pricing for ${pricing.size} models`); + // set internal cache so subsequent calls use it + this.setCachedPricing(pricing); + return Result.succeed(pricing); + } + } + } + + // fall back to parent implementation (network fetch or offline) + return this.fetchAndCache(); + } + + private async fetchAndCache(): Result.ResultAsync, Error> { + const result = await super.fetchModelPricing(); + if (Result.isSuccess(result)) { + // save to disk cache for next run + await saveDiskCache(result.value); + } + return result; + } + + private setCachedPricing(pricing: Map): void { + // access parent's private cache via type assertion + (this as unknown as { cachedPricing: Map | null }).cachedPricing = + pricing; } } if (import.meta.vitest != null) { describe('PricingFetcher', () => { it('loads offline pricing when offline flag is true', async () => { + using fetcher = new PricingFetcher({ offline: true }); + const pricing = await Result.unwrap(fetcher.fetchModelPricing()); + expect(pricing.size).toBeGreaterThan(0); + }); + + it('supports legacy boolean signature', async () => { using fetcher = new PricingFetcher(true); const pricing = await Result.unwrap(fetcher.fetchModelPricing()); expect(pricing.size).toBeGreaterThan(0); }); it('calculates cost for Claude model tokens', async () => { - using fetcher = new PricingFetcher(true); + using fetcher = new PricingFetcher({ offline: true }); const pricing = await Result.unwrap(fetcher.getModelPricing('claude-sonnet-4-20250514')); const cost = fetcher.calculateCostFromPricing( { diff --git a/apps/ccusage/src/_shared-args.ts b/apps/ccusage/src/_shared-args.ts index 90f9d973..f7bb49f7 100644 --- a/apps/ccusage/src/_shared-args.ts +++ b/apps/ccusage/src/_shared-args.ts @@ -71,7 +71,13 @@ export const sharedArgs = { type: 'boolean', negatable: true, short: 'O', - description: 'Use cached pricing data for Claude models instead of fetching from API', + description: 'Use bundled offline pricing data (ignores disk cache and network)', + default: false, + }, + refreshPricing: { + type: 'boolean', + short: 'R', + description: 'Force refresh pricing data from network (ignores 24-hour disk cache)', default: false, }, color: { @@ -107,7 +113,13 @@ export const sharedArgs = { }, compact: { type: 'boolean', - description: 'Force compact mode for narrow displays (better for screenshots)', + description: '[Deprecated: use --visual] Force compact mode for narrow displays', + default: false, + }, + visual: { + type: 'boolean', + short: 'V', + description: 'Visual output mode with compact table, sparklines, and heatmap', default: false, }, } as const satisfies Args; diff --git a/apps/ccusage/src/commands/daily.ts b/apps/ccusage/src/commands/daily.ts index 16585eb9..12fe4ca2 100644 --- a/apps/ccusage/src/commands/daily.ts +++ b/apps/ccusage/src/commands/daily.ts @@ -1,5 +1,19 @@ +import type { ChartDataEntry } from '@ccusage/terminal/charts'; import type { UsageReportConfig } from '@ccusage/terminal/table'; +import type { DailyUsage } from '../data-loader.ts'; import process from 'node:process'; +import { createHeatmap } from '@ccusage/terminal/charts'; +import { + colors, + getModelColor, + getTrendIndicator, + shortenModelName, +} from '@ccusage/terminal/colors'; +import { + createSparkline, + formatCostCompact, + formatTokensCompact, +} from '@ccusage/terminal/sparkline'; import { addEmptySeparatorRow, createUsageReportTable, @@ -21,6 +35,257 @@ import { loadDailyUsageData } from '../data-loader.ts'; import { detectMismatches, printMismatchReport } from '../debug.ts'; import { log, logger } from '../logger.ts'; +/** + * Convert daily usage data to chart data entries. + */ +function toChartData(dailyData: DailyUsage[]): ChartDataEntry[] { + return dailyData.map((d) => ({ + date: d.date, + cost: d.totalCost, + outputTokens: d.outputTokens, + inputTokens: d.inputTokens, + cacheReadTokens: d.cacheReadTokens, + })); +} + +/** + * Build a model legend from all unique models in the data. + */ +function buildModelLegend(dailyData: DailyUsage[]): Map { + const allModels = new Set(); + for (const day of dailyData) { + for (const model of day.modelsUsed) { + allModels.add(model); + } + } + const legend = new Map(); + let index = 0; + for (const model of [...allModels].sort()) { + legend.set(model, index++); + } + return legend; +} + +/** + * Get short 2-character model identifier like "op", "so", "ha". + */ +function getShortModelName(modelName: string): string { + const nameLower = modelName.toLowerCase(); + if (nameLower.includes('opus')) { + return 'op'; + } + if (nameLower.includes('sonnet')) { + return 'so'; + } + if (nameLower.includes('haiku')) { + return 'ha'; + } + if (nameLower.includes('gpt')) { + return 'gp'; + } + // fallback: first 2 chars of first non-claude part + const parts = nameLower.split('-').filter((p) => p !== 'claude'); + return (parts[0] ?? 'md').slice(0, 2); +} + +/** + * Format date as "Dec 08" style. + */ +function formatDateShort(dateStr: string): string { + const date = new Date(`${dateStr}T12:00:00`); + const month = date.toLocaleDateString('en-US', { month: 'short' }); + const day = String(date.getDate()).padStart(2, '0'); + return `${month} ${day}`; +} + +/** + * Calculate total tokens for a day. + */ +function getTotalTokensForDay(day: DailyUsage): number { + return day.inputTokens + day.outputTokens + day.cacheCreationTokens + day.cacheReadTokens; +} + +/** + * Wrap legend entries to fit within maxWidth. + */ +function wrapLegend(entries: string[], maxWidth: number): string[] { + const prefix = 'Legend: '; + const lines: string[] = []; + let currentLine = prefix; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] ?? ''; + const separator = i > 0 ? ' ' : ''; + const addition = separator + entry; + + // estimate visible width (strip ANSI codes for measurement) + // eslint-disable-next-line no-control-regex + const ansiPattern = /\x1B\[[0-9;]*m/g; + const visibleWidth = currentLine.replace(ansiPattern, '').length; + const additionWidth = addition.replace(ansiPattern, '').length; + + if (visibleWidth + additionWidth > maxWidth && currentLine !== prefix) { + lines.push(currentLine); + currentLine = ` ${entry}`; // indent continuation lines + } else { + currentLine += addition; + } + } + if (currentLine.trim() !== '') { + lines.push(currentLine); + } + return lines; +} + +/** + * Render the compact visual mode table with single-line rows. + * Designed for visual clarity with proper column alignment and breathing room. + */ +function renderCompactVisual( + dailyData: DailyUsage[], + totals: ReturnType, +): string { + const overallAvg = + dailyData.length > 0 + ? dailyData.reduce((sum, d) => sum + d.totalCost, 0) / dailyData.length + : 0; + const legend = buildModelLegend(dailyData); + + const lines: string[] = []; + + // column widths for alignment (with breathing room) + const COL = { + date: 6, // "Dec 09" + models: 9, // "op so ha" (3 models × 2 chars + spaces) + input: 7, + output: 7, + cache: 7, + total: 7, + cost: 8, + trend: 12, + }; + + // header with generous spacing + const headerParts = [ + colors.text.accent('Date'.padEnd(COL.date)), + colors.text.accent('Models'.padEnd(COL.models)), + colors.text.accent('Input'.padStart(COL.input)), + colors.text.accent('Output'.padStart(COL.output)), + colors.text.accent('Cache'.padStart(COL.cache)), + colors.text.accent('Total'.padStart(COL.total)), + colors.text.accent('Cost'.padStart(COL.cost)), + colors.text.accent('vs Avg'), + ]; + lines.push(headerParts.join(' ')); // 3-space gap between columns + lines.push(colors.ui.border('\u2500'.repeat(82))); + + // data rows + for (const day of dailyData) { + const dateStr = formatDateShort(day.date); + + // models as short 2-char colored identifiers + const modelStrs = day.modelsUsed.map((model) => { + const index = legend.get(model) ?? 0; + const color = getModelColor(index); + return color(getShortModelName(model)); + }); + // join with space, pad to fixed width for alignment + const modelsVisible = day.modelsUsed.map(getShortModelName).join(' '); + const modelsPadding = COL.models - modelsVisible.length; + const modelsStr = modelStrs.join(' ') + ' '.repeat(Math.max(0, modelsPadding)); + + // compact token numbers - right aligned + const inputStr = formatTokensCompact(day.inputTokens).padStart(COL.input); + const outputStr = formatTokensCompact(day.outputTokens).padStart(COL.output); + const cacheStr = formatTokensCompact(day.cacheReadTokens).padStart(COL.cache); + const totalStr = formatTokensCompact(getTotalTokensForDay(day)).padStart(COL.total); + // cost rounded to nearest dollar + const costStr = `$${Math.round(day.totalCost)}`.padStart(COL.cost); + + // trend indicator with semantic color + const deviation = overallAvg > 0 ? ((day.totalCost - overallAvg) / overallAvg) * 100 : 0; + const trend = getTrendIndicator(deviation); + const trendStr = trend.color(trend.indicator); + + const rowParts = [ + dateStr.padEnd(COL.date), + modelsStr, + inputStr, + outputStr, + cacheStr, + totalStr, + costStr, + trendStr, + ]; + lines.push(rowParts.join(' ')); + } + + // totals row - use full cost amount for emphasis + lines.push(colors.ui.border('\u2500'.repeat(82))); + const totalTokens = + totals.inputTokens + totals.outputTokens + totals.cacheCreationTokens + totals.cacheReadTokens; + const fullCostStr = `$${totals.totalCost.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + const totalsRow = [ + colors.ui.totals('Total'.padEnd(COL.date)), + ' '.repeat(COL.models), + colors.ui.totals(formatTokensCompact(totals.inputTokens).padStart(COL.input)), + colors.ui.totals(formatTokensCompact(totals.outputTokens).padStart(COL.output)), + colors.ui.totals(formatTokensCompact(totals.cacheReadTokens).padStart(COL.cache)), + colors.ui.totals(formatTokensCompact(totalTokens).padStart(COL.total)), + colors.ui.totals(fullCostStr.padStart(COL.cost)), + ]; + lines.push(totalsRow.join(' ')); + + // model legend with wrapping + lines.push(''); + const legendEntries: string[] = []; + for (const [model, index] of legend) { + const color = getModelColor(index); + legendEntries.push(`${color(getShortModelName(model))}=${shortenModelName(model)}`); + } + const wrappedLegend = wrapLegend(legendEntries, 80); + for (const line of wrappedLegend) { + lines.push(colors.text.secondary(line)); + } + + return lines.join('\n'); +} + +/** + * Render sparkline and heatmap footer. + */ +function renderVisualFooter(dailyData: DailyUsage[]): string { + const chartData = toChartData(dailyData); + const lines: string[] = []; + + // sparkline summary + const costValues = dailyData.map((d) => d.totalCost); + const sparkline = createSparkline(costValues, { width: Math.min(40, dailyData.length) }); + const minCost = Math.min(...costValues); + const maxCost = Math.max(...costValues); + const avgCost = costValues.reduce((a, b) => a + b, 0) / costValues.length; + const totalCost = costValues.reduce((a, b) => a + b, 0); + + // format total cost as full amount (e.g., $6,200 instead of $6.2K) + const fullTotalCostStr = `$${totalCost.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + + lines.push(colors.text.accent('Cost Trend')); + lines.push(`${sparkline} ${formatCostCompact(minCost)}\u2192${formatCostCompact(maxCost)}`); + lines.push( + colors.text.secondary(`avg ${formatCostCompact(avgCost)}/day total ${fullTotalCostStr}`), + ); + lines.push(''); + + // heatmap + const heatmap = createHeatmap(chartData, { + title: 'Usage Heatmap (by cost)', + metric: 'cost', + }); + lines.push(heatmap); + + return lines.join('\n'); +} + export const dailyCommand = define({ name: 'daily', description: 'Show usage report grouped by date', @@ -134,7 +399,21 @@ export const dailyCommand = define({ log(JSON.stringify(jsonOutput, null, 2)); } } else { - // Print header + // Handle visual mode (-V or --visual) + const useVisual = mergedOptions.visual === true || ctx.values.compact === true; + + if (useVisual) { + // Print header + logger.box('Claude Code Token Usage Report - Daily'); + + // compact single-line rows with trend indicators + footer + log(renderCompactVisual(dailyData, totals)); + log(''); + log(renderVisualFooter(dailyData)); + return; + } + + // Print header for standard table mode logger.box('Claude Code Token Usage Report - Daily'); // Create table with compact mode support diff --git a/apps/ccusage/src/commands/day.ts b/apps/ccusage/src/commands/day.ts new file mode 100644 index 00000000..41176ccf --- /dev/null +++ b/apps/ccusage/src/commands/day.ts @@ -0,0 +1,143 @@ +import process from 'node:process'; +import { createDayActivityGrid } from '@ccusage/terminal/charts'; +import { Result } from '@praha/byethrow'; +import { define } from 'gunshi'; +import * as v from 'valibot'; +import { loadConfig, mergeConfigWithArgs } from '../_config-loader-tokens.ts'; +import { processWithJq } from '../_jq-processor.ts'; +import { sharedArgs } from '../_shared-args.ts'; +import { loadDayActivityData } from '../data-loader.ts'; +import { log, logger } from '../logger.ts'; + +/** + * Valibot schema for date in YYYYMMDD format (like --since/--until) + */ +const dateArgSchema = v.pipe( + v.string(), + v.regex(/^\d{8}$/, 'Date must be in YYYYMMDD format'), + v.transform((val) => { + // convert YYYYMMDD to YYYY-MM-DD + return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`; + }), +); + +/** + * Parse date argument from YYYYMMDD to YYYY-MM-DD format + */ +function parseDateArg(value: string): string { + return v.parse(dateArgSchema, value); +} + +/** + * Shorten model name by removing the trailing date suffix. + * e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5" + */ +function shortenModelName(model: string): string { + // match pattern like -YYYYMMDD at the end + return model.replace(/-\d{8}$/, ''); +} + +export const dayCommand = define({ + name: 'day', + description: 'Show activity heatmap for a single day (15-minute windows)', + args: { + ...sharedArgs, + date: { + type: 'custom', + short: 'D', + description: 'Date to display (YYYYMMDD format, defaults to today)', + parse: parseDateArg, + }, + metric: { + type: 'enum', + short: 'M', + description: 'Metric to visualize: cost or output tokens', + default: 'cost' as const, + choices: ['cost', 'output'] as const, + }, + }, + toKebab: true, + async run(ctx) { + // Load configuration and merge with CLI arguments + const config = loadConfig(ctx.values.config, ctx.values.debug); + const mergedOptions = mergeConfigWithArgs(ctx, config, ctx.values.debug); + + // --jq implies --json + const useJson = Boolean(mergedOptions.json) || mergedOptions.jq != null; + if (useJson) { + logger.level = 0; + } + + // Get date (defaults to today in local time) + const now = new Date(); + const localYear = now.getFullYear(); + const localMonth = String(now.getMonth() + 1).padStart(2, '0'); + const localDay = String(now.getDate()).padStart(2, '0'); + const targetDate = ctx.values.date ?? `${localYear}-${localMonth}-${localDay}`; + + // Load entries for the day + const entries = await loadDayActivityData(targetDate, { + mode: mergedOptions.mode, + offline: mergedOptions.offline, + refreshPricing: mergedOptions.refreshPricing, + timezone: mergedOptions.timezone, + }); + + if (useJson) { + // JSON output + const jsonOutput = { + date: targetDate, + metric: ctx.values.metric, + entries: entries.map((e) => ({ + timestamp: e.timestamp, + cost: e.cost, + outputTokens: e.outputTokens, + model: e.model, + })), + summary: { + totalCost: entries.reduce((sum, e) => sum + e.cost, 0), + totalOutputTokens: entries.reduce((sum, e) => sum + e.outputTokens, 0), + entryCount: entries.length, + }, + }; + + // Process with jq if specified + if (mergedOptions.jq != null) { + const jqResult = await processWithJq(jsonOutput, mergedOptions.jq); + if (Result.isFailure(jqResult)) { + logger.error(jqResult.error.message); + process.exit(1); + } + log(jqResult.value); + } else { + log(JSON.stringify(jsonOutput, null, 2)); + } + } else { + // Print header + logger.box('Claude Code Activity Heatmap'); + + // Render the activity grid + const grid = createDayActivityGrid(entries, { + date: targetDate, + timezone: mergedOptions.timezone, + metric: ctx.values.metric, + }); + + log(grid); + + // Show models used (filter synthetic, shorten names) + const models = [ + ...new Set( + entries + .map((e) => e.model) + .filter((m): m is string => m != null && !m.includes('synthetic')) + .map(shortenModelName), + ), + ]; + if (models.length > 0) { + log(''); + log(`Models: ${models.join(', ')}`); + } + } + }, +}); diff --git a/apps/ccusage/src/commands/index.ts b/apps/ccusage/src/commands/index.ts index 75285a84..97e256ca 100644 --- a/apps/ccusage/src/commands/index.ts +++ b/apps/ccusage/src/commands/index.ts @@ -3,6 +3,7 @@ import { cli } from 'gunshi'; import { description, name, version } from '../../package.json'; import { blocksCommand } from './blocks.ts'; import { dailyCommand } from './daily.ts'; +import { dayCommand } from './day.ts'; import { monthlyCommand } from './monthly.ts'; import { sessionCommand } from './session.ts'; import { statuslineCommand } from './statusline.ts'; @@ -12,6 +13,7 @@ import { weeklyCommand } from './weekly.ts'; export { blocksCommand, dailyCommand, + dayCommand, monthlyCommand, sessionCommand, statuslineCommand, @@ -23,6 +25,7 @@ export { */ export const subCommandUnion = [ ['daily', dailyCommand], + ['day', dayCommand], ['monthly', monthlyCommand], ['weekly', weeklyCommand], ['session', sessionCommand], diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 8e22aae2..14097f50 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -8,12 +8,13 @@ * @module data-loader */ +import type { ActivityEntry } from '@ccusage/terminal/charts'; import type { WeekDay } from './_consts.ts'; import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts'; import { Buffer } from 'node:buffer'; import { createReadStream, createWriteStream } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; @@ -21,6 +22,7 @@ import { toArray } from '@antfu/utils'; import { Result } from '@praha/byethrow'; import { groupBy, uniq } from 'es-toolkit'; // TODO: after node20 is deprecated, switch to native Object.groupBy import { createFixture } from 'fs-fixture'; +import pLimit from 'p-limit'; import { isDirectorySync } from 'path-type'; import { glob } from 'tinyglobby'; import * as v from 'valibot'; @@ -594,11 +596,15 @@ export async function getEarliestTimestamp(filePath: string): Promise { + // limit concurrency to avoid I/O saturation when scanning many files + const limit = pLimit(20); const filesWithTimestamps = await Promise.all( - files.map(async (file) => ({ - file, - timestamp: await getEarliestTimestamp(file), - })), + files.map(async (file) => + limit(async () => ({ + file, + timestamp: await getEarliestTimestamp(file), + })), + ), ); return filesWithTimestamps @@ -732,6 +738,7 @@ export type LoadOptions = { mode?: CostMode; // Cost calculation mode order?: SortOrder; // Sort order for dates offline?: boolean; // Use offline mode for pricing + refreshPricing?: boolean; // Force refresh pricing from network (ignores 24-hour disk cache) sessionDurationHours?: number; // Session block duration in hours groupByProject?: boolean; // Group data by project instead of aggregating project?: string; // Filter to specific project name @@ -772,12 +779,68 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise(); - // Collect all valid data entries first + // pre-load pricing data before parallel processing to avoid race conditions + if (fetcher != null) { + await fetcher.fetchModelPricing(); + } + + // limit concurrent file reads to avoid I/O saturation + const fileLimit = pLimit(20); + + // process files in parallel and collect entries with their hash keys + const fileResults = await Promise.all( + sortedFiles.map(async (file) => + fileLimit(async () => { + const project = extractProjectFromPath(file); + const entries: { + data: UsageData; + date: string; + cost: number; + model: string | undefined; + project: string; + hashKey: string; + }[] = []; + + await processJSONLFileByLine(file, async (line) => { + try { + const parsed = JSON.parse(line) as unknown; + const result = v.safeParse(usageDataSchema, parsed); + if (!result.success) { + return; + } + const data = result.output; + + // create hash key for deduplication (null means no deduplication key) + const hashKey = createUniqueHash(data) ?? ''; + + // Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format + const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE); + // If fetcher is available, calculate cost based on mode and tokens + // If fetcher is null, use pre-calculated costUSD or default to 0 + const cost = + fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); + + entries.push({ data, date, cost, model: data.message.model, project, hashKey }); + } catch { + // Skip invalid JSON lines + } + }); + return entries; + }), + ), + ); + + // flatten and deduplicate const allEntries: { data: UsageData; date: string; @@ -786,41 +849,23 @@ export async function loadDailyUsageData(options?: LoadOptions): Promise { - try { - const parsed = JSON.parse(line) as unknown; - const result = v.safeParse(usageDataSchema, parsed); - if (!result.success) { - return; - } - const data = result.output; - - // Check for duplicate message + request ID combination - const uniqueHash = createUniqueHash(data); - if (isDuplicateEntry(uniqueHash, processedHashes)) { - // Skip duplicate message - return; - } - - // Mark this combination as processed - markAsProcessed(uniqueHash, processedHashes); - - // Always use DEFAULT_LOCALE for date grouping to ensure YYYY-MM-DD format - const date = formatDate(data.timestamp, options?.timezone, DEFAULT_LOCALE); - // If fetcher is available, calculate cost based on mode and tokens - // If fetcher is null, use pre-calculated costUSD or default to 0 - const cost = - fetcher != null ? await calculateCostForEntry(data, mode, fetcher) : (data.costUSD ?? 0); - - allEntries.push({ data, date, cost, model: data.message.model, project }); - } catch { - // Skip invalid JSON lines + for (const entries of fileResults) { + for (const entry of entries) { + // empty hashKey means no deduplication key available + if (entry.hashKey !== '' && isDuplicateEntry(entry.hashKey, processedHashes)) { + continue; } - }); + if (entry.hashKey !== '') { + markAsProcessed(entry.hashKey, processedHashes); + } + allEntries.push({ + data: entry.data, + date: entry.date, + cost: entry.cost, + model: entry.model, + project: entry.project, + }); + } } // Group by date, optionally including project @@ -929,7 +974,10 @@ export async function loadSessionData(options?: LoadOptions): Promise(); @@ -1112,11 +1160,12 @@ export async function loadWeeklyUsageData(options?: LoadOptions): Promise { const claudePaths = getClaudePaths(); @@ -1137,7 +1186,10 @@ export async function loadSessionUsageById( } const mode = options?.mode ?? 'auto'; - using fetcher = mode === 'display' ? null : new PricingFetcher(options?.offline); + using fetcher = + mode === 'display' + ? null + : new PricingFetcher({ offline: options?.offline, refreshPricing: options?.refreshPricing }); const entries: UsageData[] = []; let totalCost = 0; @@ -1164,6 +1216,175 @@ export async function loadSessionUsageById( return { totalCost, entries }; } +/** + * Entry with timestamp for activity grid visualization. + * Re-exported from @ccusage/terminal/charts for convenience. + */ +export type { ActivityEntry } from '@ccusage/terminal/charts'; + +/** + * Load entries for a single day with their timestamps for activity grid visualization + * @param date - Date in YYYY-MM-DD format (defaults to today) + * @param options - Options for loading data + * @param options.claudePath - Custom path to Claude data directory + * @param options.mode - Cost calculation mode (auto, calculate, display) + * @param options.offline - Whether to use offline pricing data + * @param options.refreshPricing - Force refresh pricing from network (ignores 24-hour disk cache) + * @param options.timezone - Timezone for date grouping + * @returns Array of entries with timestamps, costs, and output tokens + */ +export async function loadDayActivityData( + date?: string, + options?: { + claudePath?: string; + mode?: CostMode; + offline?: boolean; + refreshPricing?: boolean; + timezone?: string; + }, +): Promise { + const targetDate = date ?? new Date().toISOString().slice(0, 10); + const targetDateTime = new Date(targetDate).getTime(); + const claudePaths = toArray(options?.claudePath ?? getClaudePaths()); + const allFiles = await globUsageFiles(claudePaths); + + if (allFiles.length === 0) { + return []; + } + + // filter files by modification time - only check files modified on or after target date + // use limited concurrency to avoid overwhelming filesystem + const recentFiles: string[] = []; + const oneDayMs = 24 * 60 * 60 * 1000; + const statLimit = pLimit(100); // limit concurrent stat calls + + await Promise.all( + allFiles.map(async ({ file }) => + statLimit(async () => { + try { + const stats = await stat(file); + // include files modified within 2 days of target date (to handle timezone edge cases) + if (stats.mtimeMs >= targetDateTime - oneDayMs) { + recentFiles.push(file); + } + } catch { + // skip files that can't be stat'd + } + }), + ), + ); + + if (recentFiles.length === 0) { + return []; + } + + const mode = options?.mode ?? 'auto'; + using fetcher = + mode === 'display' + ? null + : new PricingFetcher({ offline: options?.offline, refreshPricing: options?.refreshPricing }); + + // pre-load pricing data before parallel processing to avoid race conditions + if (fetcher != null) { + await fetcher.fetchModelPricing(); + } + + const processedHashes = new Set(); + + // regex for fast timestamp extraction - avoid full JSON parse for non-matching dates + const timestampRegex = /"timestamp"\s*:\s*"(\d{4}-\d{2}-\d{2})/; + + // Calculate adjacent UTC dates that could contain entries matching local target date. + // Due to timezone differences (up to ±14 hours), a local date can span 2 UTC dates. + // For example: 4PM Pacific on Jan 13 local = midnight UTC Jan 14 + const prevDate = new Date(targetDateTime - oneDayMs).toISOString().slice(0, 10); + const nextDate = new Date(targetDateTime + oneDayMs).toISOString().slice(0, 10); + const validUtcDates = new Set([targetDate, prevDate, nextDate]); + + // limit concurrent file reads to avoid I/O saturation + const fileLimit = pLimit(20); + + const fileResults = await Promise.all( + recentFiles.map(async (file) => + fileLimit(async () => { + const entries: ActivityEntry[] = []; + await processJSONLFileByLine(file, async (line) => { + try { + // fast-path: extract UTC date with regex before full JSON parse + // Accept ±1 day to handle timezone edge cases + const timestampMatch = timestampRegex.exec(line); + if (timestampMatch == null || !validUtcDates.has(timestampMatch[1] ?? '')) { + return; + } + + // parse and validate the entry + const parsed = JSON.parse(line) as unknown; + const result = v.safeParse(usageDataSchema, parsed); + if (!result.success) { + return; + } + const data = result.output; + + // verify the timestamp matches target date in local timezone + // (the fast-path above accepts ±1 day to handle timezone edge cases) + const entryDate = new Date(data.timestamp); + const localYear = entryDate.getFullYear(); + const localMonth = String(entryDate.getMonth() + 1).padStart(2, '0'); + const localDay = String(entryDate.getDate()).padStart(2, '0'); + const entryLocalDate = `${localYear}-${localMonth}-${localDay}`; + if (entryLocalDate !== targetDate) { + return; + } + + // calculate cost + const cost = + fetcher != null + ? await calculateCostForEntry(data, mode, fetcher) + : (data.costUSD ?? 0); + + entries.push({ + timestamp: data.timestamp, + cost, + outputTokens: data.message.usage.output_tokens, + model: data.message.model, + _messageId: data.message.id, + _requestId: data.requestId, + } as ActivityEntry & { _messageId?: string; _requestId?: string }); + } catch { + // skip invalid JSON lines + } + }); + return entries; + }), + ), + ); + + // flatten and deduplicate + const allEntries: ActivityEntry[] = []; + for (const entries of fileResults) { + for (const entry of entries) { + const e = entry as ActivityEntry & { _messageId?: string; _requestId?: string }; + const messageId = e._messageId ?? ''; + const requestId = e._requestId ?? ''; + const hashKey = `${messageId}:${requestId}`; + if (hashKey !== ':' && processedHashes.has(hashKey)) { + continue; + } + if (hashKey !== ':') { + processedHashes.add(hashKey); + } + allEntries.push({ + timestamp: entry.timestamp, + cost: entry.cost, + outputTokens: entry.outputTokens, + model: entry.model, + }); + } + } + + return allEntries; +} + export async function loadBucketUsageData( groupingFn: (data: DailyUsage) => Bucket, options?: LoadOptions, @@ -1254,6 +1475,7 @@ export async function calculateContextTokens( transcriptPath: string, modelId?: string, offline = false, + refreshPricing = false, ): Promise<{ inputTokens: number; percentage: number; @@ -1299,7 +1521,7 @@ export async function calculateContextTokens( // Get context limit from PricingFetcher let contextLimit = 200_000; // Fallback for when modelId is not provided if (modelId != null && modelId !== '') { - using fetcher = new PricingFetcher(offline); + using fetcher = new PricingFetcher({ offline, refreshPricing }); const contextLimitResult = await fetcher.getModelContextLimit(modelId); if (Result.isSuccess(contextLimitResult) && contextLimitResult.value != null) { contextLimit = contextLimitResult.value; @@ -1374,7 +1596,10 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise(); @@ -1525,9 +1750,7 @@ if (import.meta.vitest != null) { }); }); - describe('loadSessionUsageById', async () => { - const { createFixture } = await import('fs-fixture'); - + describe('loadSessionUsageById', () => { afterEach(() => { vi.unstubAllEnvs(); }); @@ -4234,6 +4457,122 @@ invalid json line }); }); }); + + describe('loadDayActivityData', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('loads activity data for entries matching local date', async () => { + await using fixture = await createFixture({ + '.claude': { + projects: { + 'test-project': { + 'session-123.jsonl': `${JSON.stringify({ + timestamp: '2024-01-15T10:00:00Z', + sessionId: 'session-123', + message: { + id: 'msg_001', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_001', + costUSD: 0.5, + })}\n`, + }, + }, + }, + }); + + vi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude')); + + const result = await loadDayActivityData('2024-01-15', { mode: 'display' }); + + expect(result.length).toBeGreaterThan(0); + expect(result[0]?.cost).toBe(0.5); + }); + + it('handles timezone edge case where UTC date differs from local date', async () => { + // Create an entry with a UTC timestamp that would be filtered out + // if we only checked the UTC date portion from the ISO string. + // This timestamp (Jan 14 at 23:00 UTC) is Jan 15 in UTC+2 timezone, + // but the regex would extract "2024-01-14" from the ISO string. + // After the fix, we accept ±1 day in the fast-path and filter by local date. + const lateNightUtc = '2024-01-14T23:00:00Z'; // Could be Jan 15 locally for UTC+ timezones + + await using fixture = await createFixture({ + '.claude': { + projects: { + 'test-project': { + 'session-456.jsonl': `${JSON.stringify({ + timestamp: lateNightUtc, + sessionId: 'session-456', + message: { + id: 'msg_002', + usage: { + input_tokens: 200, + output_tokens: 100, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_002', + costUSD: 1.0, + })}\n`, + }, + }, + }, + }); + + vi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude')); + + // When run in the test environment (system timezone), the local date + // is derived from the timestamp. For most timezones, this should work. + // The entry should be included if its local date matches target date. + const entryDate = new Date(lateNightUtc); + const localDateStr = `${entryDate.getFullYear()}-${String(entryDate.getMonth() + 1).padStart(2, '0')}-${String(entryDate.getDate()).padStart(2, '0')}`; + + const result = await loadDayActivityData(localDateStr, { mode: 'display' }); + + // The entry should be found when searching for its local date + expect(result.length).toBe(1); + expect(result[0]?.cost).toBe(1.0); + }); + + it('returns empty array when no files match target date', async () => { + await using fixture = await createFixture({ + '.claude': { + projects: { + 'test-project': { + 'session-789.jsonl': `${JSON.stringify({ + timestamp: '2024-01-10T10:00:00Z', + sessionId: 'session-789', + message: { + id: 'msg_003', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + model: 'claude-sonnet-4-20250514', + }, + requestId: 'req_003', + costUSD: 0.5, + })}\n`, + }, + }, + }, + }); + + vi.stubEnv('CLAUDE_CONFIG_DIR', fixture.getPath('.claude')); + + // Search for a date that doesn't exist in the data + const result = await loadDayActivityData('2024-01-20', { mode: 'display' }); + + expect(result).toHaveLength(0); + }); + }); } // duplication functionality tests @@ -4694,12 +5033,12 @@ if (import.meta.vitest != null) { }); // Test for calculateContextTokens - describe('calculateContextTokens', async () => { + describe('calculateContextTokens', () => { it('returns null when transcript cannot be read', async () => { const result = await calculateContextTokens('/nonexistent/path.jsonl'); expect(result).toBeNull(); }); - const { createFixture } = await import('fs-fixture'); + it('parses latest assistant line and excludes output tokens', async () => { await using fixture = await createFixture({ 'transcript.jsonl': [ diff --git a/package.json b/package.json index 1a2a2782..3afbe567 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "runtime": [ { "name": "node", - "version": "^24.11.0", + "version": ">=24.11.0", "onFail": "download" }, { diff --git a/packages/terminal/package.json b/packages/terminal/package.json index a9a5b718..423c5597 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -6,7 +6,10 @@ "description": "Terminal utilities for ccusage", "exports": { "./table": "./src/table.ts", - "./utils": "./src/utils.ts" + "./utils": "./src/utils.ts", + "./colors": "./src/colors.ts", + "./sparkline": "./src/sparkline.ts", + "./charts": "./src/charts.ts" }, "scripts": { "format": "pnpm run lint --fix", diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts new file mode 100644 index 00000000..2c7328ac --- /dev/null +++ b/packages/terminal/src/charts.ts @@ -0,0 +1,773 @@ +import { colors } from './colors.ts'; +import { createSparkline, formatCostCompact, formatTokensCompact } from './sparkline.ts'; + +/** + * Chart utilities for Tufte-style terminal visualizations. + * Provides bar charts, full-width sparklines, and heatmaps. + */ + +/** + * Parse YYYY-MM-DD date string as local timezone date. + * Using new Date('YYYY-MM-DD') treats the string as UTC, which can shift + * dates for users in negative UTC offset timezones. + */ +function parseLocalDate(dateStr: string): Date { + const [year, month, day] = dateStr.split('-').map(Number); + return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1); +} + +/** + * Daily data entry for chart rendering. + */ +export type ChartDataEntry = { + date: string; // YYYY-MM-DD format + cost: number; + outputTokens: number; + inputTokens: number; + cacheReadTokens?: number; +}; + +/** + * Options for bar chart generation. + */ +export type BarChartOptions = { + /** Target width for bars (defaults to terminal width - labels) */ + width?: number; + /** Show values at end of bars */ + showValues?: boolean; + /** Format function for values */ + formatValue?: (value: number) => string; + /** Metric to visualize */ + metric?: 'cost' | 'output' | 'input'; +}; + +/** + * Create a horizontal bar chart from daily data. + * + * @example + * createBarChart(dailyData, { width: 40 }) + * // Returns: + * // Jan 07 ████████████████████████████████████ $786.91 + * // Jan 08 ██████████████████████████ $403.37 + */ +export function createBarChart(data: ChartDataEntry[], options: BarChartOptions = {}): string { + if (data.length === 0) { + return '(no data)'; + } + + const metric = options.metric ?? 'cost'; + const getValue = (entry: ChartDataEntry): number => { + switch (metric) { + case 'cost': + return entry.cost; + case 'output': + return entry.outputTokens; + case 'input': + return entry.inputTokens; + default: + return entry.cost; + } + }; + + const formatValue = + options.formatValue ?? (metric === 'cost' ? formatCostCompact : formatTokensCompact); + + const values = data.map(getValue); + const maxValue = Math.max(...values); + const width = options.width ?? 40; + + const lines: string[] = []; + + for (const entry of data) { + const value = getValue(entry); + const barLength = maxValue > 0 ? Math.round((value / maxValue) * width) : 0; + const bar = '\u2588'.repeat(barLength); + + // format date as "Mon DD" + const date = parseLocalDate(entry.date); + const dateStr = date.toLocaleDateString('en-US', { + month: 'short', + day: '2-digit', + }); + + const valueStr = formatValue(value); + lines.push(`${dateStr} ${bar.padEnd(width)} ${valueStr}`); + } + + return lines.join('\n'); +} + +/** + * Options for full-width sparkline. + */ +export type FullSparklineOptions = { + /** Title for the chart */ + title?: string; + /** Metric to visualize */ + metric?: 'cost' | 'output'; + /** Terminal width */ + terminalWidth?: number; +}; + +/** + * Create a full-width annotated sparkline with peak markers. + * + * @example + * createFullSparkline(dailyData, { title: 'Cost over time' }) + * // Returns: + * // Cost over time (last 30 days) + * // ^ $786 (Jan 07) + * // ....sparkline characters.... + * // v $1.39 (Dec 09) + * // + * // Avg: $189/day Total: $6,197.65 + */ +export function createFullSparkline( + data: ChartDataEntry[], + options: FullSparklineOptions = {}, +): string { + if (data.length === 0) { + return '(no data)'; + } + + const metric = options.metric ?? 'cost'; + const getValue = (entry: ChartDataEntry): number => + metric === 'cost' ? entry.cost : entry.outputTokens; + const formatValue = metric === 'cost' ? formatCostCompact : formatTokensCompact; + + const values = data.map(getValue); + const terminalWidth = options.terminalWidth ?? 80; + const sparklineWidth = Math.min(values.length, terminalWidth - 10); + + const sparkline = createSparkline(values, { width: sparklineWidth }); + + // find min and max with their dates + let minValue = Infinity; + let maxValue = -Infinity; + let minDate = ''; + let maxDate = ''; + + for (const entry of data) { + const value = getValue(entry); + if (value < minValue) { + minValue = value; + minDate = entry.date; + } + if (value > maxValue) { + maxValue = value; + maxDate = entry.date; + } + } + + const formatDate = (dateStr: string): string => { + const date = parseLocalDate(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }); + }; + + // calculate statistics + const total = values.reduce((a, b) => a + b, 0); + const avg = total / values.length; + + const lines: string[] = []; + + // title + const title = options.title ?? `${metric === 'cost' ? 'Cost' : 'Output'} over time`; + lines.push(`${title} (last ${data.length} days)`); + + // max marker (positioned above sparkline) + const maxPosition = data.findIndex((e) => e.date === maxDate); + const maxMarkerPos = Math.floor((maxPosition / data.length) * sparklineWidth); + const maxMarker = `${' '.repeat(maxMarkerPos)}^ ${formatValue(maxValue)} (${formatDate(maxDate)})`; + lines.push(maxMarker); + + // sparkline + lines.push(` ${sparkline}`); + + // min marker (positioned below sparkline) + const minPosition = data.findIndex((e) => e.date === minDate); + const minMarkerPos = Math.floor((minPosition / data.length) * sparklineWidth); + const minMarker = `${' '.repeat(minMarkerPos)}v ${formatValue(minValue)} (${formatDate(minDate)})`; + lines.push(minMarker); + + // empty line + lines.push(''); + + // statistics + const statsLine = + metric === 'cost' + ? `Avg: ${formatValue(avg)}/day Total: ${formatValue(total)}` + : `Avg: ${formatValue(avg)}/day`; + lines.push(statsLine); + + return lines.join('\n'); +} + +/** + * Heatmap intensity levels. + */ +const HEATMAP_CHARS = [ + ' ', // empty + '\u2591', // LIGHT SHADE + '\u2592', // MEDIUM SHADE + '\u2593', // DARK SHADE + '\u2588', // FULL BLOCK +] as const; + +/** + * Options for heatmap generation. + */ +export type HeatmapOptions = { + /** Title for the heatmap */ + title?: string; + /** Metric to visualize */ + metric?: 'cost' | 'output'; + /** Custom thresholds for intensity levels */ + thresholds?: number[]; +}; + +/** + * Create a calendar-style heatmap with 7-column week layout. + * + * @example + * createHeatmap(dailyData) + * // Returns: + * // Usage Heatmap (by cost) + * // Mon Tue Wed Thu Fri Sat Sun + * // Dec 09 - - + * // Dec 16 . : . : # # # + * // ... + * // < $50 . $50-150 : $150-300 # > $300 + */ +export function createHeatmap(data: ChartDataEntry[], options: HeatmapOptions = {}): string { + if (data.length === 0) { + return '(no data)'; + } + + const metric = options.metric ?? 'cost'; + const getValue = (entry: ChartDataEntry): number => + metric === 'cost' ? entry.cost : entry.outputTokens; + + const values = data.map(getValue); + const maxValue = Math.max(...values); + + // calculate thresholds if not provided + const thresholds = options.thresholds ?? [ + maxValue * 0.15, // level 1 + maxValue * 0.35, // level 2 + maxValue * 0.6, // level 3 + maxValue * 0.8, // level 4 + ]; + + const getIntensity = (value: number): number => { + if (value === 0) { + return 0; + } + for (let i = 0; i < thresholds.length; i++) { + const threshold = thresholds[i]; + if (threshold != null && value <= threshold) { + return i + 1; + } + } + return 4; + }; + + // group data by week + const weeks: Map> = new Map(); + + for (const entry of data) { + // parse date parts and construct UTC date for consistent week grouping across timezones + const [year, month, day] = entry.date.split('-').map(Number); + const date = new Date(Date.UTC(year ?? 0, (month ?? 1) - 1, day ?? 1)); + const dayOfWeek = date.getUTCDay(); // 0 = Sunday + + // get the Monday of this week using UTC methods + const monday = new Date(date); + const daysSinceMonday = (dayOfWeek + 6) % 7; // convert Sunday=0 to Monday=0 + monday.setUTCDate(date.getUTCDate() - daysSinceMonday); + const weekKey = monday.toISOString().slice(0, 10); + + if (!weeks.has(weekKey)) { + weeks.set(weekKey, new Map()); + } + weeks.get(weekKey)!.set(dayOfWeek, entry); + } + + const lines: string[] = []; + + // title + const title = options.title ?? `Usage Heatmap (by ${metric})`; + lines.push(title); + + // header - 6-char wide columns, day name centered + const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const headerCells = dayNames.map((d) => d.padStart(4).padEnd(6)); + lines.push(` ${headerCells.join('')}`); + + // sort weeks chronologically + const sortedWeeks = [...weeks.entries()].sort((a, b) => a[0].localeCompare(b[0])); + + for (const [weekKey, days] of sortedWeeks) { + // parse weekKey as UTC to avoid timezone shifts in label + const weekDate = new Date(`${weekKey}T00:00:00Z`); + const weekLabel = weekDate.toLocaleDateString('en-US', { + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }); + + const dayCells: string[] = []; + // iterate Monday (1) to Sunday (0, treated as 7) + for (let d = 1; d <= 7; d++) { + const dayIndex = d % 7; // convert to 0-6 where 0=Sunday + const entry = days.get(dayIndex); + if (entry != null) { + const intensity = getIntensity(getValue(entry)); + const char = HEATMAP_CHARS[intensity] ?? HEATMAP_CHARS[0]; + // center the char in 6-character column (2 spaces before, 3 after for single char) + dayCells.push(` ${char} `); + } else { + dayCells.push(' '); + } + } + + lines.push(`${weekLabel} ${dayCells.join('')}`); + } + + // legend + lines.push(''); + const formatValue = metric === 'cost' ? formatCostCompact : formatTokensCompact; + const t0 = thresholds[0] ?? 0; + const t1 = thresholds[1] ?? 0; + const t2 = thresholds[2] ?? 0; + const legendParts = [ + `${HEATMAP_CHARS[1]} < ${formatValue(t0)}`, + `${HEATMAP_CHARS[2]} ${formatValue(t0)}-${formatValue(t1)}`, + `${HEATMAP_CHARS[3]} ${formatValue(t1)}-${formatValue(t2)}`, + `${HEATMAP_CHARS[4]} > ${formatValue(t2)}`, + ]; + lines.push(legendParts.join(' ')); + + return lines.join('\n'); +} + +/** + * Visual mode type for --visual flag. + */ +export type VisualMode = 'compact' | 'bar' | 'spark' | 'heatmap'; + +/** + * Check if a string is a valid visual mode. + */ +export function isValidVisualMode(mode: string): mode is VisualMode { + return ['compact', 'bar', 'spark', 'heatmap'].includes(mode); +} + +/** + * Entry for 5-minute window activity grid. + */ +export type ActivityEntry = { + timestamp: string; // ISO timestamp + cost: number; + outputTokens: number; + model?: string; +}; + +/** + * Options for day activity grid. + */ +export type DayActivityGridOptions = { + /** Date to display (YYYY-MM-DD format, defaults to today) */ + date?: string; + /** Timezone for display */ + timezone?: string; + /** Current time for indicator (ISO timestamp, defaults to now) */ + currentTime?: string; + /** Metric to visualize */ + metric?: 'cost' | 'output'; +}; + +/** + * Unicode block characters for 5-level activity intensity. + */ +const ACTIVITY_CHARS = [ + '\u00B7', // MIDDLE DOT (no activity) + '\u2591', // LIGHT SHADE + '\u2592', // MEDIUM SHADE + '\u2593', // DARK SHADE + '\u2588', // FULL BLOCK +] as const; + +/** + * Single-hue color for activity blocks. + * Uses cyan for all intensity levels - the block density (░▒▓█) already + * conveys intensity. Adding multiple colors creates visual noise. + * Following Tufte: let the data speak, minimize chartjunk. + */ +const ACTIVITY_COLOR = colors.text.accent; // cyan for all blocks + +/** + * Create a day activity grid showing 1-minute resolution. + * Each row is one hour (0-23), each character is one minute (60 per hour). + * Labels at :00, :15, :30, :45 help with visual orientation. + * + * @example + * createDayActivityGrid(entries, { date: '2025-01-11' }) + * // Returns: + * // Activity Heatmap - Jan 11, 2025 + * // + * // Hour :00 :15 :30 :45 Cost + * // ────────────────────────────────────────────────────────────────────────── + * // 0 ············································································ - + * // 7 ···░░░▒▒▒▓▓▓▒▒▒░░░··············································· $12.34 + * // 8 ▒▒▒▓▓▓███▓▓▓▒▒▒░░░··············································· $45.67 + * // ────────────────────────────────────────────────────────────────────────── + * // Legend: · none ░ low ▒ medium ▓ high █ peak + */ +export function createDayActivityGrid( + entries: ActivityEntry[], + options: DayActivityGridOptions = {}, +): string { + const now = options.currentTime != null ? new Date(options.currentTime) : new Date(); + const targetDate = options.date ?? now.toISOString().slice(0, 10); + const metric = options.metric ?? 'cost'; + + // group entries into 1-minute buckets (24 hours × 60 minutes = 1440 total) + const buckets: number[] = Array.from({ length: 1440 }, () => 0); + // also track cost per hour for the right column + const hourlyCost: number[] = Array.from({ length: 24 }, () => 0); + + for (const entry of entries) { + const entryDate = new Date(entry.timestamp); + // use local date to match local hours (getHours returns local time) + const localYear = entryDate.getFullYear(); + const localMonth = String(entryDate.getMonth() + 1).padStart(2, '0'); + const localDay = String(entryDate.getDate()).padStart(2, '0'); + const entryDateStr = `${localYear}-${localMonth}-${localDay}`; + + // only include entries from the target date (in local time) + if (entryDateStr !== targetDate) { + continue; + } + + const hour = entryDate.getHours(); + const minute = entryDate.getMinutes(); + const bucketIndex = hour * 60 + minute; + + const value = metric === 'cost' ? entry.cost : entry.outputTokens; + const currentValue = buckets[bucketIndex] ?? 0; + buckets[bucketIndex] = currentValue + value; + + // track hourly cost (always cost, not metric) + hourlyCost[hour] = (hourlyCost[hour] ?? 0) + entry.cost; + } + + // find max value for scaling + const maxValue = Math.max(...buckets, 1); // avoid division by zero + + // calculate thresholds for 5 levels + const getIntensity = (value: number): number => { + if (value === 0) { + return 0; + } + const ratio = value / maxValue; + if (ratio <= 0.2) { + return 1; + } + if (ratio <= 0.4) { + return 2; + } + if (ratio <= 0.7) { + return 3; + } + return 4; + }; + + // determine current time position for indicator + // use local date for comparison since targetDate and currentHour/currentMinute are local + const localDateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + const isToday = targetDate === localDateStr; + const currentHour = now.getHours(); + const currentMinute = now.getMinutes(); + + const lines: string[] = []; + + // title with formatted date + const displayDate = new Date(`${targetDate}T12:00:00`); // noon to avoid timezone issues + const formattedDate = displayDate.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + lines.push(`Activity Heatmap - ${formattedDate}`); + lines.push(''); + + // header row with 15-minute markers (60 chars = 60 minutes, labels at 0, 15, 30, 45) + const headerGrid = ' '.repeat(60).split(''); + headerGrid[0] = ':'; + headerGrid[1] = '0'; + headerGrid[2] = '0'; + headerGrid[15] = ':'; + headerGrid[16] = '1'; + headerGrid[17] = '5'; + headerGrid[30] = ':'; + headerGrid[31] = '3'; + headerGrid[32] = '0'; + headerGrid[45] = ':'; + headerGrid[46] = '4'; + headerGrid[47] = '5'; + const header = `Hour ${headerGrid.join('')} Cost`; + lines.push(header); + lines.push('\u2500'.repeat(header.length)); + + // each row is one hour, each character is one minute + for (let hour = 0; hour < 24; hour++) { + const hourLabel = hour.toString().padStart(2, ' '); + let cells = ''; + + for (let minute = 0; minute < 60; minute++) { + const bucketIndex = hour * 60 + minute; + const value = buckets[bucketIndex]; + const intensity = getIntensity(value ?? 0); + const baseChar: string = ACTIVITY_CHARS[intensity] ?? ACTIVITY_CHARS[0]; + + // add current time indicator + if (isToday && hour === currentHour && minute === currentMinute) { + cells += colors.semantic.warning('\u25BC'); // current time marker in yellow + } else if (isToday && hour === currentHour && minute > currentMinute) { + // future minutes in current hour show as dim + cells += colors.text.secondary('\u00B7'); + } else if (isToday && hour > currentHour) { + // future hours show as dim + cells += colors.text.secondary('\u00B7'); + } else if (intensity === 0) { + // no activity - dim dot + cells += colors.text.secondary(baseChar); + } else { + // activity blocks - single cyan color, density shows intensity + cells += ACTIVITY_COLOR(baseChar); + } + } + + // format hourly cost with intensity-based coloring (rounded to nearest dollar) + const hourCost = hourlyCost[hour] ?? 0; + let costStr: string; + if (hourCost > 0) { + const roundedCost = Math.round(hourCost); + const formattedCost = `$${roundedCost}`.padStart(8); + // color cost based on relative value - use same cyan color with bold for high values + const maxHourlyCost = Math.max(...hourlyCost, 1); + const costRatio = hourCost / maxHourlyCost; + if (costRatio >= 0.7) { + // peak hours - bold cyan + costStr = colors.text.emphasis(ACTIVITY_COLOR(formattedCost)); + } else if (costRatio >= 0.3) { + // moderate hours - regular cyan + costStr = ACTIVITY_COLOR(formattedCost); + } else { + // low hours - dim + costStr = colors.text.secondary(formattedCost); + } + } else { + costStr = colors.text.secondary(' -'); + } + + lines.push(` ${hourLabel} ${cells} ${costStr}`); + } + + lines.push('\u2500'.repeat(header.length)); + + // legend with single blocks in single color (and current time if today) + lines.push(''); + const legendParts = [ + `${colors.text.secondary(ACTIVITY_CHARS[0])} none`, + `${ACTIVITY_COLOR(ACTIVITY_CHARS[1])} low`, + `${ACTIVITY_COLOR(ACTIVITY_CHARS[2])} medium`, + `${ACTIVITY_COLOR(ACTIVITY_CHARS[3])} high`, + `${ACTIVITY_COLOR(ACTIVITY_CHARS[4])} peak`, + ]; + const legendText = `Legend: ${legendParts.join(' ')}`; + + if (isToday) { + const timeStr = now.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + const nowText = `Now: ${timeStr} \u25BC`; + // right-align "Now:" to match header width + const padding = header.length - legendText.length - nowText.length; + lines.push(`${legendText}${' '.repeat(Math.max(2, padding))}${nowText}`); + } else { + lines.push(legendText); + } + + // summary stats + const totalValue = buckets.reduce((a, b) => a + b, 0); + const activeCount = buckets.filter((v) => v > 0).length; + // format total - round cost to nearest dollar, tokens use compact format + // highlight total cost with bold cyan + const totalStr = + metric === 'cost' + ? colors.text.emphasis(ACTIVITY_COLOR(`$${Math.round(totalValue)}`)) + : formatTokensCompact(totalValue); + + lines.push(''); + lines.push( + `Total: ${totalStr} Active minutes: ${activeCount}/1440 (${Math.round((activeCount / 1440) * 100)}%)`, + ); + + return lines.join('\n'); +} + +// in-source tests +if (import.meta.vitest != null) { + const { describe, it, expect } = import.meta.vitest; + + const sampleData: ChartDataEntry[] = [ + { date: '2025-01-07', cost: 786, outputTokens: 951000, inputTokens: 1100000 }, + { date: '2025-01-08', cost: 403, outputTokens: 584000, inputTokens: 500000 }, + { date: '2025-01-09', cost: 390, outputTokens: 230000, inputTokens: 620000 }, + ]; + + describe('createBarChart', () => { + it('creates horizontal bar chart', () => { + const result = createBarChart(sampleData, { width: 20 }); + expect(result).toContain('Jan 07'); + expect(result).toContain('Jan 08'); + expect(result).toContain('\u2588'); // bar character + }); + + it('handles empty data', () => { + const result = createBarChart([]); + expect(result).toBe('(no data)'); + }); + + it('respects metric option', () => { + const result = createBarChart(sampleData, { metric: 'output', width: 20 }); + expect(result).toContain('951K'); // output tokens + }); + }); + + describe('createFullSparkline', () => { + it('creates annotated sparkline', () => { + const result = createFullSparkline(sampleData); + expect(result).toContain('Cost over time'); + expect(result).toContain('Avg:'); + expect(result).toContain('Total:'); + }); + + it('shows min and max markers', () => { + const result = createFullSparkline(sampleData); + expect(result).toContain('^'); // max marker + expect(result).toContain('v'); // min marker + }); + + it('handles empty data', () => { + const result = createFullSparkline([]); + expect(result).toBe('(no data)'); + }); + }); + + describe('createHeatmap', () => { + it('creates week-based heatmap', () => { + const result = createHeatmap(sampleData); + expect(result).toContain('Usage Heatmap'); + expect(result).toContain('Mon'); + expect(result).toContain('Sun'); + }); + + it('includes legend', () => { + const result = createHeatmap(sampleData); + expect(result).toContain(HEATMAP_CHARS[1]); + expect(result).toContain(HEATMAP_CHARS[4]); + }); + + it('handles empty data', () => { + const result = createHeatmap([]); + expect(result).toBe('(no data)'); + }); + }); + + describe('isValidVisualMode', () => { + it('validates known modes', () => { + expect(isValidVisualMode('compact')).toBe(true); + expect(isValidVisualMode('bar')).toBe(true); + expect(isValidVisualMode('spark')).toBe(true); + expect(isValidVisualMode('heatmap')).toBe(true); + }); + + it('rejects unknown modes', () => { + expect(isValidVisualMode('unknown')).toBe(false); + expect(isValidVisualMode('')).toBe(false); + }); + }); + + describe('createDayActivityGrid', () => { + const sampleEntries: ActivityEntry[] = [ + { timestamp: '2025-01-11T09:15:00Z', cost: 10, outputTokens: 1000 }, + { timestamp: '2025-01-11T09:20:00Z', cost: 20, outputTokens: 2000 }, + { timestamp: '2025-01-11T14:30:00Z', cost: 50, outputTokens: 5000 }, + ]; + + it('creates 24-row activity grid with 1-minute resolution', () => { + const result = createDayActivityGrid(sampleEntries, { + date: '2025-01-11', + currentTime: '2025-01-10T12:00:00Z', // past date so no "now" indicator + }); + expect(result).toContain('Activity Heatmap'); + expect(result).toContain('Hour'); + expect(result).toContain(':00'); + expect(result).toContain(':15'); + expect(result).toContain(':30'); + expect(result).toContain(':45'); + }); + + it('shows legend with single character blocks', () => { + const result = createDayActivityGrid(sampleEntries, { + date: '2025-01-11', + currentTime: '2025-01-10T12:00:00Z', + }); + expect(result).toContain('Legend:'); + expect(result).toContain('none'); + expect(result).toContain('low'); + expect(result).toContain('peak'); + }); + + it('shows summary stats with 1440 minute windows', () => { + const result = createDayActivityGrid(sampleEntries, { + date: '2025-01-11', + currentTime: '2025-01-10T12:00:00Z', + }); + expect(result).toContain('Total:'); + expect(result).toContain('Active minutes:'); + expect(result).toContain('/1440'); + }); + + it('shows current time indicator for today', () => { + const result = createDayActivityGrid(sampleEntries, { + date: '2025-01-11', + currentTime: '2025-01-11T14:32:00Z', // same day + }); + expect(result).toContain('Now:'); + expect(result).toContain('\u25BC'); // down arrow + }); + + it('handles empty entries', () => { + const result = createDayActivityGrid([], { + date: '2025-01-11', + currentTime: '2025-01-10T12:00:00Z', + }); + expect(result).toContain('Activity Heatmap'); + expect(result).toContain('Active minutes: 0/1440'); + }); + + it('shows hourly cost column', () => { + const result = createDayActivityGrid(sampleEntries, { + date: '2025-01-11', + currentTime: '2025-01-10T12:00:00Z', + }); + expect(result).toContain('Cost'); + }); + }); +} diff --git a/packages/terminal/src/colors.ts b/packages/terminal/src/colors.ts new file mode 100644 index 00000000..1199d815 --- /dev/null +++ b/packages/terminal/src/colors.ts @@ -0,0 +1,375 @@ +import pc from 'picocolors'; + +/** + * Semantic color token system for terminal output. + * Provides consistent, meaningful color usage across all visualizations. + */ + +// Color function type from picocolors +type ColorFn = (text: string) => string; + +/** + * Semantic color tokens organized by purpose. + * Use these instead of raw picocolors for consistent theming. + */ +export const colors = { + /** Text hierarchy colors */ + text: { + /** Main content - default text */ + primary: (s: string) => s, + /** Muted/supporting text */ + secondary: pc.gray, + /** Highlighted values, headers */ + accent: pc.cyan, + /** Strong emphasis */ + emphasis: pc.bold, + }, + + /** Semantic state colors */ + semantic: { + /** Good/success/below threshold */ + success: pc.green, + /** Neutral information */ + info: pc.blue, + /** Elevated/attention needed */ + warning: pc.yellow, + /** Critical/above threshold */ + error: pc.red, + }, + + /** UI element colors */ + ui: { + /** Table borders, dividers */ + border: pc.gray, + /** Totals row highlighting */ + totals: pc.yellow, + /** Sub-rows, breakdowns */ + breakdown: pc.gray, + }, +} as const; + +/** + * Model color palette for legend display. + * Cycles through these colors when displaying multiple models. + */ +export const MODEL_COLORS: ColorFn[] = [pc.magenta, pc.blue, pc.green, pc.cyan, pc.yellow, pc.red]; + +/** + * Get a color for a model based on its index in the list. + * Colors cycle if there are more models than colors. + */ +export function getModelColor(index: number): ColorFn { + return MODEL_COLORS[index % MODEL_COLORS.length] ?? pc.white; +} + +/** + * Threshold configuration for value-based coloring. + */ +export type ValueThresholds = { + /** Value above which color is 'error' (red) */ + critical: number; + /** Value above which color is 'warning' (yellow) */ + high: number; + /** Value below which color is 'success' (green) */ + low: number; +}; + +/** + * Get semantic color based on value relative to thresholds. + * Useful for coloring costs, percentages, or other numeric values. + */ +export function getValueColor(value: number, thresholds: ValueThresholds): ColorFn { + if (value >= thresholds.critical) { + return colors.semantic.error; + } + if (value >= thresholds.high) { + return colors.semantic.warning; + } + if (value <= thresholds.low) { + return colors.semantic.success; + } + return colors.text.primary; +} + +/** + * Threshold configuration for percentage deviation from average. + */ +export type DeviationThresholds = { + /** Percentage above average for critical (e.g., 100 = 100% above) */ + significantlyAbove: number; + /** Percentage above average for warning (e.g., 25 = 25% above) */ + above: number; + /** Percentage below average for success (e.g., 25 = 25% below) */ + below: number; + /** Percentage below average for significant (e.g., 100 = 100% below) */ + significantlyBelow: number; +}; + +/** Default thresholds for cost deviation from average */ +export const DEFAULT_DEVIATION_THRESHOLDS: DeviationThresholds = { + significantlyAbove: 100, + above: 25, + below: 25, + significantlyBelow: 100, +}; + +/** + * Trend indicator characters and their meanings. + */ +export const TREND_INDICATORS = { + significantlyAbove: { + char: String.fromCodePoint(0x25B2) + String.fromCodePoint(0x25B2), + description: '>100% above average', + }, + above: { char: String.fromCodePoint(0x25B2), description: '25-100% above average' }, + neutral: { char: String.fromCodePoint(0x2500), description: 'within 25% of average' }, + below: { char: String.fromCodePoint(0x25BC), description: '25-100% below average' }, + significantlyBelow: { + char: String.fromCodePoint(0x25BC) + String.fromCodePoint(0x25BC), + description: '>100% below average', + }, +} as const; + +/** + * Get trend indicator and color based on percentage deviation from average. + * Positive deviation = above average, negative = below. + * + * @param percentDeviation - Percentage deviation from average (e.g., 50 = 50% above, -30 = 30% below) + * @param thresholds - Optional custom thresholds + * @returns Object with indicator string and color function + */ +export function getTrendIndicator( + percentDeviation: number, + thresholds: DeviationThresholds = DEFAULT_DEVIATION_THRESHOLDS, +): { indicator: string; color: ColorFn; description: string } { + if (percentDeviation >= thresholds.significantlyAbove) { + return { + indicator: `${TREND_INDICATORS.significantlyAbove.char} +${Math.round(percentDeviation)}%`, + color: colors.semantic.error, + description: TREND_INDICATORS.significantlyAbove.description, + }; + } + if (percentDeviation >= thresholds.above) { + return { + indicator: `${TREND_INDICATORS.above.char} +${Math.round(percentDeviation)}%`, + color: colors.semantic.warning, + description: TREND_INDICATORS.above.description, + }; + } + if (percentDeviation <= -thresholds.significantlyBelow) { + return { + indicator: `${TREND_INDICATORS.significantlyBelow.char} ${Math.round(percentDeviation)}%`, + color: colors.semantic.success, + description: TREND_INDICATORS.significantlyBelow.description, + }; + } + if (percentDeviation <= -thresholds.below) { + return { + indicator: `${TREND_INDICATORS.below.char} ${Math.round(percentDeviation)}%`, + color: colors.semantic.success, + description: TREND_INDICATORS.below.description, + }; + } + // Near average + const sign = percentDeviation >= 0 ? '+' : ''; + return { + indicator: `${TREND_INDICATORS.neutral.char} ${sign}${Math.round(percentDeviation)}%`, + color: colors.text.secondary, + description: TREND_INDICATORS.neutral.description, + }; +} + +/** + * Unicode bullet character for model legend. + */ +export const LEGEND_BULLET = String.fromCodePoint(0x25CF); // BLACK CIRCLE + +/** + * Create a colored model identifier for compact display. + * + * @param modelName - Full model name (e.g., 'opus-4-5') + * @param colorIndex - Index into MODEL_COLORS palette + * @returns Formatted string like "●O" with colored bullet + */ +export function createModelIdentifier(modelName: string, colorIndex: number): string { + const color = getModelColor(colorIndex); + // extract model family from names like "claude-sonnet-4-20250514" -> "S" + // or "opus-4-5" -> "O", etc. + const nameLower = modelName.toLowerCase(); + let letter = 'M'; // default for unknown models + if (nameLower.includes('opus')) { + letter = 'O'; + } else if (nameLower.includes('sonnet')) { + letter = 'S'; + } else if (nameLower.includes('haiku')) { + letter = 'H'; + } else if (nameLower.includes('gpt')) { + letter = 'G'; + } else { + // use first non-claude letter + const parts = nameLower.split('-').filter((p) => p !== 'claude'); + letter = (parts[0]?.charAt(0) ?? 'M').toUpperCase(); + } + return `${color(LEGEND_BULLET)}${letter}`; +} + +/** + * Shorten model name by removing the trailing date suffix. + * e.g., "claude-opus-4-5-20251101" -> "claude-opus-4-5" + * + * @param modelName - Full model name with date suffix + * @returns Shortened model name without date + */ +export function shortenModelName(modelName: string): string { + // match pattern like -YYYYMMDD at the end + return modelName.replace(/-\d{8}$/, ''); +} + +/** + * Create a legend entry for a model. + * + * @param modelName - Full model name (e.g., 'opus-4-5') + * @param colorIndex - Index into MODEL_COLORS palette + * @returns Formatted string like "●O opus-4-5" + */ +export function createModelLegendEntry(modelName: string, colorIndex: number): string { + const color = getModelColor(colorIndex); + // extract model family from names like "claude-sonnet-4-20250514" -> "S" + const nameLower = modelName.toLowerCase(); + let letter = 'M'; + if (nameLower.includes('opus')) { + letter = 'O'; + } else if (nameLower.includes('sonnet')) { + letter = 'S'; + } else if (nameLower.includes('haiku')) { + letter = 'H'; + } else if (nameLower.includes('gpt')) { + letter = 'G'; + } else { + const parts = nameLower.split('-').filter((p) => p !== 'claude'); + letter = (parts[0]?.charAt(0) ?? 'M').toUpperCase(); + } + // use shortened model name without date suffix + return `${color(LEGEND_BULLET)}${letter} ${shortenModelName(modelName)}`; +} + +// in-source tests +if (import.meta.vitest != null) { + const { describe, it, expect } = import.meta.vitest; + + describe('colors', () => { + it('has semantic color tokens', () => { + expect(colors.text.primary('test')).toBe('test'); + expect(typeof colors.semantic.success).toBe('function'); + expect(typeof colors.ui.totals).toBe('function'); + }); + }); + + describe('getModelColor', () => { + it('returns colors from palette', () => { + expect(getModelColor(0)).toBe(pc.magenta); + expect(getModelColor(1)).toBe(pc.blue); + expect(getModelColor(2)).toBe(pc.green); + }); + + it('cycles colors for indices beyond palette size', () => { + expect(getModelColor(6)).toBe(pc.magenta); + expect(getModelColor(7)).toBe(pc.blue); + }); + }); + + describe('getValueColor', () => { + const thresholds: ValueThresholds = { + critical: 100, + high: 50, + low: 10, + }; + + it('returns error color for critical values', () => { + expect(getValueColor(100, thresholds)).toBe(colors.semantic.error); + expect(getValueColor(150, thresholds)).toBe(colors.semantic.error); + }); + + it('returns warning color for high values', () => { + expect(getValueColor(50, thresholds)).toBe(colors.semantic.warning); + expect(getValueColor(75, thresholds)).toBe(colors.semantic.warning); + }); + + it('returns success color for low values', () => { + expect(getValueColor(10, thresholds)).toBe(colors.semantic.success); + expect(getValueColor(5, thresholds)).toBe(colors.semantic.success); + }); + + it('returns primary color for middle values', () => { + expect(getValueColor(30, thresholds)).toBe(colors.text.primary); + }); + }); + + describe('getTrendIndicator', () => { + it('returns significantly above for large positive deviation', () => { + const result = getTrendIndicator(150); + expect(result.indicator).toContain('+150%'); + expect(result.color).toBe(colors.semantic.error); + }); + + it('returns above for moderate positive deviation', () => { + const result = getTrendIndicator(50); + expect(result.indicator).toContain('+50%'); + expect(result.color).toBe(colors.semantic.warning); + }); + + it('returns neutral for small deviation', () => { + const result = getTrendIndicator(10); + expect(result.indicator).toContain('+10%'); + expect(result.color).toBe(colors.text.secondary); + }); + + it('returns below for moderate negative deviation', () => { + const result = getTrendIndicator(-50); + expect(result.indicator).toContain('-50%'); + expect(result.color).toBe(colors.semantic.success); + }); + + it('returns significantly below for large negative deviation', () => { + const result = getTrendIndicator(-150); + expect(result.indicator).toContain('-150%'); + expect(result.color).toBe(colors.semantic.success); + }); + }); + + describe('createModelIdentifier', () => { + it('creates colored bullet with first letter', () => { + const result = createModelIdentifier('opus-4-5', 0); + // result contains ANSI codes, so just check it ends with 'O' + expect(result).toContain('O'); + expect(result).toContain(LEGEND_BULLET); + }); + + it('extracts O for opus models', () => { + expect(createModelIdentifier('claude-opus-4-20250514', 0)).toContain('O'); + expect(createModelIdentifier('opus-4-5', 0)).toContain('O'); + }); + + it('extracts S for sonnet models', () => { + expect(createModelIdentifier('claude-sonnet-4-20250514', 1)).toContain('S'); + expect(createModelIdentifier('sonnet-4', 1)).toContain('S'); + }); + + it('extracts H for haiku models', () => { + expect(createModelIdentifier('claude-haiku-4-5-20250901', 2)).toContain('H'); + }); + + it('extracts G for gpt models', () => { + expect(createModelIdentifier('gpt-4o', 3)).toContain('G'); + }); + }); + + describe('createModelLegendEntry', () => { + it('creates full legend entry', () => { + const result = createModelLegendEntry('opus-4-5', 0); + expect(result).toContain('O'); + expect(result).toContain('opus-4-5'); + expect(result).toContain(LEGEND_BULLET); + }); + }); +} diff --git a/packages/terminal/src/sparkline.ts b/packages/terminal/src/sparkline.ts new file mode 100644 index 00000000..c2ca9fc6 --- /dev/null +++ b/packages/terminal/src/sparkline.ts @@ -0,0 +1,418 @@ +import type { DeviationThresholds } from './colors.ts'; +import { colors } from './colors.ts'; + +/** + * Sparkline utilities for Tufte-style terminal visualizations. + * Creates compact, word-sized graphics for data trends. + */ + +/** + * Unicode block characters for 8-level sparklines. + * Ordered from lowest (index 0) to highest (index 7). + */ +export const SPARK_CHARS = [ + '\u2581', // LOWER ONE EIGHTH BLOCK + '\u2582', // LOWER ONE QUARTER BLOCK + '\u2583', // LOWER THREE EIGHTHS BLOCK + '\u2584', // LOWER HALF BLOCK + '\u2585', // LOWER FIVE EIGHTHS BLOCK + '\u2586', // LOWER THREE QUARTERS BLOCK + '\u2587', // LOWER SEVEN EIGHTHS BLOCK + '\u2588', // FULL BLOCK +] as const; + +/** + * Options for sparkline generation. + */ +export type SparklineOptions = { + /** Minimum value for scaling (defaults to min in data) */ + min?: number; + /** Maximum value for scaling (defaults to max in data) */ + max?: number; + /** Target width in characters (defaults to data length) */ + width?: number; + /** Apply color gradient based on value (green to red) */ + colorize?: boolean; + /** Thresholds for colorization */ + thresholds?: DeviationThresholds; +}; + +/** + * Create a sparkline from an array of numeric values. + * + * @param values - Array of numbers to visualize + * @param options - Configuration options + * @returns Sparkline string of block characters + * + * @example + * createSparkline([1, 5, 3, 8, 2]) + * // Returns: "▁▅▃█▂" + */ +export function createSparkline(values: number[], options: SparklineOptions = {}): string { + if (values.length === 0) { + return ''; + } + + // handle single value + if (values.length === 1) { + return SPARK_CHARS[4]; // middle bar + } + + const min = options.min ?? Math.min(...values); + const max = options.max ?? Math.max(...values); + const range = max - min; + + // if all values are the same, show middle bars + if (range === 0) { + return SPARK_CHARS[4].repeat(values.length); + } + + // map each value to a spark character + const chars = values.map((value) => { + // normalize to 0-1 range + const normalized = (value - min) / range; + // map to character index (0-7) + const index = Math.min(7, Math.floor(normalized * 8)); + return SPARK_CHARS[index]; + }); + + // handle width resizing if requested + if (options.width != null && options.width !== values.length) { + return resizeSparkline(chars.join(''), options.width); + } + + return chars.join(''); +} + +/** + * Resize a sparkline to a target width by sampling or interpolating. + */ +function resizeSparkline(sparkline: string, targetWidth: number): string { + const chars = [...sparkline]; + const sourceLength = chars.length; + + if (targetWidth >= sourceLength) { + // expand: repeat characters to fill target width + // baseRepeat is the minimum times each char repeats + // first extraChars positions get one more repeat to fill remaining space + const baseRepeat = Math.floor(targetWidth / sourceLength); + const extraChars = targetWidth - baseRepeat * sourceLength; + return chars + .map((char, i) => char.repeat(i < extraChars ? baseRepeat + 1 : baseRepeat)) + .join(''); + } + + // shrink: sample characters + const result: string[] = []; + for (let i = 0; i < targetWidth; i++) { + const sourceIndex = Math.floor((i / targetWidth) * sourceLength); + const char = chars[sourceIndex]; + if (char != null) { + result.push(char); + } + } + return result.join(''); +} + +/** + * Options for labeled sparklines with annotations. + */ +export type LabeledSparklineOptions = { + /** Label for the metric (e.g., "Cost") */ + label?: string; + /** Format function for values */ + formatValue?: (value: number) => string; + /** Show average value */ + showAverage?: boolean; +} & SparklineOptions; + +/** + * Create a sparkline with min/max labels and optional statistics. + * + * @example + * createLabeledSparkline([1.5, 5.2, 3.1, 8.0, 2.3], { + * label: 'Cost', + * formatValue: (v) => `$${v.toFixed(2)}` + * }) + * // Returns: "Cost ▁▅▃█▂ $1.50->$8.00 avg $4.02" + */ +export function createLabeledSparkline( + values: number[], + options: LabeledSparklineOptions = {}, +): string { + if (values.length === 0) { + return options.label != null && options.label !== '' + ? `${options.label} (no data)` + : '(no data)'; + } + + const sparkline = createSparkline(values, options); + const min = Math.min(...values); + const max = Math.max(...values); + const formatValue = options.formatValue ?? ((v: number) => v.toFixed(2)); + + const parts: string[] = []; + + if (options.label != null && options.label !== '') { + parts.push(options.label.padEnd(8)); + } + + parts.push(sparkline); + parts.push(` ${formatValue(min)}->${formatValue(max)}`); + + if (options.showAverage === true) { + const avg = values.reduce((a, b) => a + b, 0) / values.length; + parts.push(` avg ${formatValue(avg)}`); + } + + return parts.join(''); +} + +/** + * Usage entry with timestamp for intra-day grouping. + */ +export type TimestampedEntry = { + timestamp: string; // ISO timestamp + outputTokens: number; + inputTokens?: number; + cost?: number; +}; + +/** + * Create an intra-day sparkline showing activity across 24 hours. + * Groups data into 2-hour windows (12 bars total). + * + * @param entries - Usage entries with timestamps + * @param metric - Which metric to visualize ('output' | 'cost') + * @returns 12-character sparkline representing midnight to midnight + */ +export function createIntradaySparkline( + entries: TimestampedEntry[], + metric: 'output' | 'cost' = 'output', +): string { + // create 12 buckets for 2-hour windows + const buckets: number[] = Array.from({ length: 12 }, () => 0); + + for (const entry of entries) { + const date = new Date(entry.timestamp); + const hour = date.getHours(); + const bucketIndex = Math.floor(hour / 2); // 0-11 + + const value = metric === 'cost' ? (entry.cost ?? 0) : entry.outputTokens; + const currentBucket = buckets[bucketIndex] ?? 0; + buckets[bucketIndex] = currentBucket + value; + } + + return createSparkline(buckets); +} + +/** + * Sparkline summary row configuration. + */ +export type SparklineSummaryRow = { + label: string; + values: number[]; + formatValue: (value: number) => string; + annotation?: string; +}; + +/** + * Create a multi-row sparkline summary for display after tables. + * + * @param rows - Array of summary row configurations + * @param terminalWidth - Available width for sparklines + * @returns Formatted summary string with multiple sparkline rows + */ +export function createSparklineSummary(rows: SparklineSummaryRow[], terminalWidth = 80): string { + if (rows.length === 0) { + return ''; + } + + // calculate sparkline width + // format: "Label ▁▂▃▄▅▆▇█ min->max annotation" + const labelWidth = 9; + const statsWidth = 30; // space for min->max and annotation + const sparkWidth = Math.max(10, terminalWidth - labelWidth - statsWidth); + + const lines: string[] = []; + + // divider line + lines.push('\u2500'.repeat(terminalWidth)); + + for (const row of rows) { + if (row.values.length === 0) { + continue; + } + + const sparkline = createSparkline(row.values, { width: sparkWidth }); + const min = Math.min(...row.values); + const max = Math.max(...row.values); + const avg = row.values.reduce((a, b) => a + b, 0) / row.values.length; + + let line = row.label.padEnd(labelWidth); + line += sparkline; + line += ` ${row.formatValue(min)}->${row.formatValue(max)}`; + + if (row.annotation != null && row.annotation !== '') { + line += ` ${colors.text.secondary(row.annotation)}`; + } else { + line += ` avg ${row.formatValue(avg)}`; + } + + lines.push(line); + } + + return lines.join('\n'); +} + +/** + * Format a number with compact notation (K, M, B). + */ +export function formatTokensCompact(n: number): string { + if (n >= 1_000_000_000) { + return `${(n / 1_000_000_000).toFixed(1)}B`; + } + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(0)}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(0)}K`; + } + return n.toString(); +} + +/** + * Format a currency value compactly. + */ +export function formatCostCompact(n: number): string { + if (n >= 1000) { + return `$${(n / 1000).toFixed(1)}K`; + } + if (n >= 100) { + return `$${Math.round(n)}`; + } + return `$${n.toFixed(2)}`; +} + +// in-source tests +if (import.meta.vitest != null) { + const { describe, it, expect } = import.meta.vitest; + + describe('SPARK_CHARS', () => { + it('has 8 levels of block characters', () => { + expect(SPARK_CHARS).toHaveLength(8); + expect(SPARK_CHARS[0]).toBe('\u2581'); + expect(SPARK_CHARS[7]).toBe('\u2588'); + }); + }); + + describe('createSparkline', () => { + it('returns empty string for empty array', () => { + expect(createSparkline([])).toBe(''); + }); + + it('returns middle bar for single value', () => { + expect(createSparkline([5])).toBe(SPARK_CHARS[4]); + }); + + it('returns all middle bars for same values', () => { + expect(createSparkline([5, 5, 5])).toBe(SPARK_CHARS[4].repeat(3)); + }); + + it('maps min to lowest bar and max to highest', () => { + const result = createSparkline([0, 100]); + expect(result[0]).toBe(SPARK_CHARS[0]); + expect(result[1]).toBe(SPARK_CHARS[7]); + }); + + it('creates proportional sparkline', () => { + const result = createSparkline([1, 5, 3, 8, 2]); + expect(result).toHaveLength(5); + // verify relative heights make sense + const chars = [...result]; + const char3 = chars[3] as (typeof SPARK_CHARS)[number]; + const char0 = chars[0] as (typeof SPARK_CHARS)[number]; + expect(SPARK_CHARS.indexOf(char3)).toBeGreaterThan(SPARK_CHARS.indexOf(char0)); // 8 > 1 + }); + + it('respects custom min/max', () => { + const result = createSparkline([50], { min: 0, max: 100 }); + expect(result).toBe(SPARK_CHARS[4]); // 50 is middle + }); + }); + + describe('createIntradaySparkline', () => { + it('returns 12 characters for 2-hour windows', () => { + const entries: TimestampedEntry[] = [ + { timestamp: '2025-01-01T08:00:00Z', outputTokens: 100 }, + { timestamp: '2025-01-01T10:00:00Z', outputTokens: 200 }, + { timestamp: '2025-01-01T14:00:00Z', outputTokens: 300 }, + ]; + const result = createIntradaySparkline(entries); + expect(result).toHaveLength(12); + }); + + it('groups tokens into correct 2-hour buckets', () => { + const entries: TimestampedEntry[] = [ + { timestamp: '2025-01-01T00:30:00Z', outputTokens: 100 }, // bucket 0 (00-02) + { timestamp: '2025-01-01T01:30:00Z', outputTokens: 100 }, // bucket 0 (00-02) + { timestamp: '2025-01-01T02:30:00Z', outputTokens: 50 }, // bucket 1 (02-04) + ]; + const result = createIntradaySparkline(entries); + // bucket 0 has 200, bucket 1 has 50, so bucket 0 should be higher + const char0 = result[0] as (typeof SPARK_CHARS)[number]; + const char1 = result[1] as (typeof SPARK_CHARS)[number]; + expect(SPARK_CHARS.indexOf(char0)).toBeGreaterThan(SPARK_CHARS.indexOf(char1)); + }); + }); + + describe('formatTokensCompact', () => { + it('formats billions', () => { + expect(formatTokensCompact(1_500_000_000)).toBe('1.5B'); + }); + + it('formats millions', () => { + expect(formatTokensCompact(585_000_000)).toBe('585M'); + }); + + it('formats thousands', () => { + expect(formatTokensCompact(951_000)).toBe('951K'); + }); + + it('keeps small numbers as-is', () => { + expect(formatTokensCompact(80)).toBe('80'); + }); + }); + + describe('formatCostCompact', () => { + it('formats large costs with K suffix', () => { + expect(formatCostCompact(1500)).toBe('$1.5K'); + }); + + it('formats medium costs without decimals', () => { + expect(formatCostCompact(786)).toBe('$786'); + }); + + it('formats small costs with decimals', () => { + expect(formatCostCompact(3.37)).toBe('$3.37'); + }); + }); + + describe('createLabeledSparkline', () => { + it('creates labeled sparkline with statistics', () => { + const result = createLabeledSparkline([1, 5, 3, 8, 2], { + label: 'Cost', + formatValue: (v) => `$${v.toFixed(0)}`, + showAverage: true, + }); + expect(result).toContain('Cost'); + expect(result).toContain('$1->$8'); + expect(result).toContain('avg'); + }); + + it('handles empty values', () => { + const result = createLabeledSparkline([], { label: 'Cost' }); + expect(result).toContain('no data'); + }); + }); +}