From 8081599f0459dd443093b906518018a7517e0672 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 11 Jan 2026 08:21:27 -0800 Subject: [PATCH 1/8] feat(ccusage): add Tufte-style visual mode for daily reports Add --visual / -V flag for enhanced terminal visualizations: - Compact table with 2-char model codes (op, so, ha) - Trend-to-average indicators with semantic colors - Compact token formatting (951K, 5M, 1.2B) - Cost sparkline footer with min/max and averages - Calendar-style heatmap with intensity shading New terminal utilities: - colors.ts: semantic color token system with model color palette - sparkline.ts: 8-level Unicode block sparklines, compact formatters - charts.ts: heatmap generator with weekly calendar grid Performance improvements: - Concurrent file processing with pLimit - Dramatically faster on large data directories (1GB+) UX refinements: - Model names shortened to 2 chars in visual mode - Costs rounded to nearest dollar - Full total cost shown at bottom ($6,194 vs $6.2K) - Column alignment with consistent padding - Legend wrapping for many models - Heatmap columns centered under day names Co-Authored-By: SageOx --- apps/ccusage/config-schema.json | 60 ++- apps/ccusage/src/_pricing-fetcher.ts | 119 ++++- apps/ccusage/src/_shared-args.ts | 16 +- apps/ccusage/src/commands/daily.ts | 281 ++++++++++- apps/ccusage/src/commands/day.ts | 151 ++++++ apps/ccusage/src/commands/index.ts | 3 + apps/ccusage/src/data-loader.ts | 300 +++++++++-- package.json | 2 +- packages/terminal/package.json | 5 +- packages/terminal/src/charts.ts | 719 +++++++++++++++++++++++++++ packages/terminal/src/colors.ts | 375 ++++++++++++++ packages/terminal/src/sparkline.ts | 420 ++++++++++++++++ 12 files changed, 2386 insertions(+), 65 deletions(-) create mode 100644 apps/ccusage/src/commands/day.ts create mode 100644 packages/terminal/src/charts.ts create mode 100644 packages/terminal/src/colors.ts create mode 100644 packages/terminal/src/sparkline.ts diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index 337c02e9..c928b3fb 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -94,9 +94,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, @@ -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..beb39380 --- /dev/null +++ b/apps/ccusage/src/commands/day.ts @@ -0,0 +1,151 @@ +import type { ActivityEntry as ChartActivityEntry } from '@ccusage/terminal/charts'; +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'); + + // Convert to chart format + const chartEntries: ChartActivityEntry[] = entries.map((e) => ({ + timestamp: e.timestamp, + cost: e.cost, + outputTokens: e.outputTokens, + })); + + // Render the activity grid + const grid = createDayActivityGrid(chartEntries, { + 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..f6104044 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -21,6 +21,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 +595,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 +737,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 +778,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 +848,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 +973,10 @@ export async function loadSessionData(options?: LoadOptions): Promise(); @@ -1112,11 +1159,12 @@ export async function loadWeeklyUsageData(options?: LoadOptions): Promise { const claudePaths = getClaudePaths(); @@ -1137,7 +1185,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 +1215,161 @@ export async function loadSessionUsageById( return { totalCost, entries }; } +/** + * Entry with timestamp for activity grid visualization + */ +export type ActivityEntry = { + timestamp: string; + cost: number; + outputTokens: number; + model?: string; +}; + +/** + * 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 { stat } = await import('node:fs/promises'); + 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})/; + + // 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 timestamp with regex before full JSON parse + const timestampMatch = timestampRegex.exec(line); + if (timestampMatch == null || timestampMatch[1] !== targetDate) { + return; + } + + // only parse and validate lines that match the target date + const parsed = JSON.parse(line) as unknown; + const result = v.safeParse(usageDataSchema, parsed); + if (!result.success) { + return; + } + const data = result.output; + + // 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 +1460,7 @@ export async function calculateContextTokens( transcriptPath: string, modelId?: string, offline = false, + refreshPricing = false, ): Promise<{ inputTokens: number; percentage: number; @@ -1299,7 +1506,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 +1581,10 @@ export async function loadSessionBlockData(options?: LoadOptions): Promise(); 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..198de68a --- /dev/null +++ b/packages/terminal/src/charts.ts @@ -0,0 +1,719 @@ +import { createSparkline, formatCostCompact, formatTokensCompact } from './sparkline.ts'; + +/** + * Chart utilities for Tufte-style terminal visualizations. + * Provides bar charts, full-width sparklines, and heatmaps. + */ + +/** + * 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 = new Date(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 = new Date(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) { + const date = new Date(entry.date); + const dayOfWeek = date.getDay(); // 0 = Sunday + + // get the Monday of this week + const monday = new Date(date); + const daysSinceMonday = (dayOfWeek + 6) % 7; // convert Sunday=0 to Monday=0 + monday.setDate(date.getDate() - 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) { + const weekDate = new Date(weekKey); + const weekLabel = weekDate.toLocaleDateString('en-US', { + month: 'short', + day: '2-digit', + }); + + 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; +}; + +/** + * 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; + +/** + * 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 + const isToday = targetDate === now.toISOString().slice(0, 10); + 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); + let char: string = ACTIVITY_CHARS[intensity] ?? ACTIVITY_CHARS[0]; + + // add current time indicator + if (isToday && hour === currentHour && minute === currentMinute) { + char = '\u25BC'; // BLACK DOWN-POINTING TRIANGLE + } else if (isToday && hour === currentHour && minute > currentMinute) { + // future minutes in current hour show as dim + char = '\u00B7'; // MIDDLE DOT + } else if (isToday && hour > currentHour) { + // future hours show as dim + char = '\u00B7'; + } + + cells += char; + } + + // format hourly cost + const hourCost = hourlyCost[hour] ?? 0; + const costStr = hourCost > 0 ? formatCostCompact(hourCost).padStart(8) : ' -'; + + lines.push(` ${hourLabel} ${cells} ${costStr}`); + } + + lines.push('\u2500'.repeat(header.length)); + + // legend with single blocks (and current time if today) + lines.push(''); + const legendParts = [ + `${ACTIVITY_CHARS[0]} none`, + `${ACTIVITY_CHARS[1]} low`, + `${ACTIVITY_CHARS[2]} medium`, + `${ACTIVITY_CHARS[3]} high`, + `${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; + const formatValue = metric === 'cost' ? formatCostCompact : formatTokensCompact; + + lines.push(''); + lines.push( + `Total: ${formatValue(totalValue)} 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..b636850b --- /dev/null +++ b/packages/terminal/src/sparkline.ts @@ -0,0 +1,420 @@ +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 + return chars + .map((char, i) => { + const repeatCount = Math.ceil(targetWidth / sourceLength); + return i < targetWidth % sourceLength + ? char.repeat(repeatCount) + : char.repeat(repeatCount - 1 || 1); + }) + .join('') + .slice(0, targetWidth); + } + + // 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'); + }); + }); +} From 867f124bed7e1225e011dbbe1334c5e780229ff6 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 11 Jan 2026 19:24:33 -0800 Subject: [PATCH 2/8] feat(terminal): add single-hue coloring to day heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply cyan accent color to activity blocks in day heatmap. Block density (░▒▓█) already conveys intensity - using a single color avoids visual noise while still distinguishing activity from empty periods. Changes: - Activity blocks use cyan accent color - Empty periods use dim gray - Cost column uses semantic colors (green/yellow/red by relative value) - Costs rounded to nearest dollar Co-Authored-By: SageOx --- packages/terminal/src/charts.ts | 65 +++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts index 198de68a..2a7d17cd 100644 --- a/packages/terminal/src/charts.ts +++ b/packages/terminal/src/charts.ts @@ -1,3 +1,4 @@ +import { colors } from './colors.ts'; import { createSparkline, formatCostCompact, formatTokensCompact } from './sparkline.ts'; /** @@ -381,6 +382,14 @@ const ACTIVITY_CHARS = [ '\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). @@ -503,39 +512,61 @@ export function createDayActivityGrid( const bucketIndex = hour * 60 + minute; const value = buckets[bucketIndex]; const intensity = getIntensity(value ?? 0); - let char: string = ACTIVITY_CHARS[intensity] ?? ACTIVITY_CHARS[0]; + const baseChar: string = ACTIVITY_CHARS[intensity] ?? ACTIVITY_CHARS[0]; // add current time indicator if (isToday && hour === currentHour && minute === currentMinute) { - char = '\u25BC'; // BLACK DOWN-POINTING TRIANGLE + 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 - char = '\u00B7'; // MIDDLE DOT + cells += colors.text.secondary('\u00B7'); } else if (isToday && hour > currentHour) { // future hours show as dim - char = '\u00B7'; + 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); } - - cells += char; } - // format hourly cost + // format hourly cost with semantic coloring (rounded to nearest dollar) const hourCost = hourlyCost[hour] ?? 0; - const costStr = hourCost > 0 ? formatCostCompact(hourCost).padStart(8) : ' -'; + let costStr: string; + if (hourCost > 0) { + const roundedCost = Math.round(hourCost); + const formattedCost = `$${roundedCost}`.padStart(8); + // color cost based on relative value to max hourly cost + const maxHourlyCost = Math.max(...hourlyCost, 1); + const costRatio = hourCost / maxHourlyCost; + if (costRatio >= 0.8) { + costStr = colors.semantic.error(formattedCost); + } else if (costRatio >= 0.5) { + costStr = colors.semantic.warning(formattedCost); + } else if (costRatio >= 0.25) { + costStr = colors.semantic.info(formattedCost); + } else { + costStr = colors.semantic.success(formattedCost); + } + } else { + costStr = colors.text.secondary(' -'); + } lines.push(` ${hourLabel} ${cells} ${costStr}`); } lines.push('\u2500'.repeat(header.length)); - // legend with single blocks (and current time if today) + // legend with single blocks in single color (and current time if today) lines.push(''); const legendParts = [ - `${ACTIVITY_CHARS[0]} none`, - `${ACTIVITY_CHARS[1]} low`, - `${ACTIVITY_CHARS[2]} medium`, - `${ACTIVITY_CHARS[3]} high`, - `${ACTIVITY_CHARS[4]} peak`, + `${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(' ')}`; @@ -556,11 +587,13 @@ export function createDayActivityGrid( // summary stats const totalValue = buckets.reduce((a, b) => a + b, 0); const activeCount = buckets.filter((v) => v > 0).length; - const formatValue = metric === 'cost' ? formatCostCompact : formatTokensCompact; + // format total - round cost to nearest dollar, tokens use compact format + const totalStr = + metric === 'cost' ? `$${Math.round(totalValue)}` : formatTokensCompact(totalValue); lines.push(''); lines.push( - `Total: ${formatValue(totalValue)} Active minutes: ${activeCount}/1440 (${Math.round((activeCount / 1440) * 100)}%)`, + `Total: ${totalStr} Active minutes: ${activeCount}/1440 (${Math.round((activeCount / 1440) * 100)}%)`, ); return lines.join('\n'); From 3756b7e7fcdab58070a1f4cb9844cdbdd63d8415 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 11 Jan 2026 19:25:36 -0800 Subject: [PATCH 3/8] refactor(terminal): use single-hue shading for day heatmap costs Replace semantic colors (green/yellow/red) with intensity-based shading using the same cyan accent color: - Low costs: dim gray - Medium costs: regular cyan - Peak costs: bold cyan - Total: bold cyan (highlighted) This creates a calmer, more cohesive visual while still showing relative cost intensity through brightness/boldness. Co-Authored-By: SageOx --- packages/terminal/src/charts.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts index 2a7d17cd..debd15f6 100644 --- a/packages/terminal/src/charts.ts +++ b/packages/terminal/src/charts.ts @@ -532,23 +532,24 @@ export function createDayActivityGrid( } } - // format hourly cost with semantic coloring (rounded to nearest dollar) + // 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 to max hourly cost + // 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.8) { - costStr = colors.semantic.error(formattedCost); - } else if (costRatio >= 0.5) { - costStr = colors.semantic.warning(formattedCost); - } else if (costRatio >= 0.25) { - costStr = colors.semantic.info(formattedCost); + 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 { - costStr = colors.semantic.success(formattedCost); + // low hours - dim + costStr = colors.text.secondary(formattedCost); } } else { costStr = colors.text.secondary(' -'); @@ -588,8 +589,11 @@ export function createDayActivityGrid( 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' ? `$${Math.round(totalValue)}` : formatTokensCompact(totalValue); + metric === 'cost' + ? colors.text.emphasis(ACTIVITY_COLOR(`$${Math.round(totalValue)}`)) + : formatTokensCompact(totalValue); lines.push(''); lines.push( From 00bd1fa79cf66c8347c48beb9d6bc763b9581b75 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 11 Jan 2026 19:31:57 -0800 Subject: [PATCH 4/8] fix: address code review feedback for visual mode - config-schema.json: change visual field from string enum to boolean to match CLI argument definition in _shared-args.ts - data-loader.ts: remove unnecessary dynamic import of stat, use top-level import instead - charts.ts: add parseLocalDate helper to avoid timezone issues when parsing YYYY-MM-DD dates (treats as local timezone, not UTC) - sparkline.ts: fix expansion logic bug that failed when targetWidth is an exact multiple of sourceLength Co-Authored-By: SageOx --- apps/ccusage/config-schema.json | 8 ++++---- apps/ccusage/src/data-loader.ts | 3 +-- packages/terminal/src/charts.ts | 14 ++++++++++++-- packages/terminal/src/sparkline.ts | 16 +++++++--------- 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/ccusage/config-schema.json b/apps/ccusage/config-schema.json index c928b3fb..bede08b5 100644 --- a/apps/ccusage/config-schema.json +++ b/apps/ccusage/config-schema.json @@ -99,10 +99,10 @@ "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)" + "type": "boolean", + "description": "Visual output mode with compact table, sparklines, and heatmap", + "markdownDescription": "Visual output mode with compact table, sparklines, and heatmap", + "default": false } }, "additionalProperties": false, diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index f6104044..09490435 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -13,7 +13,7 @@ import type { LoadedUsageEntry, SessionBlock } from './_session-blocks.ts'; import type { ActivityDate, Bucket, CostMode, ModelName, SortOrder, Version } from './_types.ts'; import { Buffer } from 'node:buffer'; import { createReadStream, createWriteStream } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { createInterface } from 'node:readline'; @@ -1257,7 +1257,6 @@ export async function loadDayActivityData( // filter files by modification time - only check files modified on or after target date // use limited concurrency to avoid overwhelming filesystem - const { stat } = await import('node:fs/promises'); const recentFiles: string[] = []; const oneDayMs = 24 * 60 * 60 * 1000; const statLimit = pLimit(100); // limit concurrent stat calls diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts index debd15f6..363990e6 100644 --- a/packages/terminal/src/charts.ts +++ b/packages/terminal/src/charts.ts @@ -6,6 +6,16 @@ import { createSparkline, formatCostCompact, formatTokensCompact } from './spark * 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. */ @@ -74,7 +84,7 @@ export function createBarChart(data: ChartDataEntry[], options: BarChartOptions const bar = '\u2588'.repeat(barLength); // format date as "Mon DD" - const date = new Date(entry.date); + const date = parseLocalDate(entry.date); const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', @@ -265,7 +275,7 @@ export function createHeatmap(data: ChartDataEntry[], options: HeatmapOptions = const weeks: Map> = new Map(); for (const entry of data) { - const date = new Date(entry.date); + const date = parseLocalDate(entry.date); const dayOfWeek = date.getDay(); // 0 = Sunday // get the Monday of this week diff --git a/packages/terminal/src/sparkline.ts b/packages/terminal/src/sparkline.ts index b636850b..c2ca9fc6 100644 --- a/packages/terminal/src/sparkline.ts +++ b/packages/terminal/src/sparkline.ts @@ -92,16 +92,14 @@ function resizeSparkline(sparkline: string, targetWidth: number): string { const sourceLength = chars.length; if (targetWidth >= sourceLength) { - // expand: repeat characters + // 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) => { - const repeatCount = Math.ceil(targetWidth / sourceLength); - return i < targetWidth % sourceLength - ? char.repeat(repeatCount) - : char.repeat(repeatCount - 1 || 1); - }) - .join('') - .slice(0, targetWidth); + .map((char, i) => char.repeat(i < extraChars ? baseRepeat + 1 : baseRepeat)) + .join(''); } // shrink: sample characters From 957c691fcb766cba427c8997ef55648ca23295c7 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Sun, 11 Jan 2026 19:37:03 -0800 Subject: [PATCH 5/8] fix(terminal): comprehensive timezone fixes in charts.ts - formatDate: use parseLocalDate helper to avoid UTC interpretation - createHeatmap week grouping: use Date.UTC() and getUTCDay/setUTCDate for consistent week boundaries across timezones - createHeatmap week labels: add timeZone: 'UTC' to toLocaleDateString for consistent display - createDayHeatmap isToday: use local date string comparison instead of UTC-based toISOString to match local currentHour/currentMinute Co-Authored-By: SageOx --- packages/terminal/src/charts.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts index 363990e6..d58b253e 100644 --- a/packages/terminal/src/charts.ts +++ b/packages/terminal/src/charts.ts @@ -160,7 +160,7 @@ export function createFullSparkline( } const formatDate = (dateStr: string): string => { - const date = new Date(dateStr); + const date = parseLocalDate(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit' }); }; @@ -275,13 +275,15 @@ export function createHeatmap(data: ChartDataEntry[], options: HeatmapOptions = const weeks: Map> = new Map(); for (const entry of data) { - const date = parseLocalDate(entry.date); - const dayOfWeek = date.getDay(); // 0 = Sunday + // 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 + // 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.setDate(date.getDate() - daysSinceMonday); + monday.setUTCDate(date.getUTCDate() - daysSinceMonday); const weekKey = monday.toISOString().slice(0, 10); if (!weeks.has(weekKey)) { @@ -305,10 +307,12 @@ export function createHeatmap(data: ChartDataEntry[], options: HeatmapOptions = const sortedWeeks = [...weeks.entries()].sort((a, b) => a[0].localeCompare(b[0])); for (const [weekKey, days] of sortedWeeks) { - const weekDate = new Date(weekKey); + // 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[] = []; @@ -478,7 +482,9 @@ export function createDayActivityGrid( }; // determine current time position for indicator - const isToday = targetDate === now.toISOString().slice(0, 10); + // 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(); From 9e09154b972de18ca12647c1bcd0a7700410e7e0 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 12 Jan 2026 15:17:25 -0800 Subject: [PATCH 6/8] refactor(terminal): unify ActivityEntry type across packages Extract canonical ActivityEntry type definition to @ccusage/terminal/charts with optional model field to satisfy both chart rendering and data loading use cases. Remove duplicate type in data-loader.ts (re-export instead) and eliminate ad-hoc aliasing/conversion in day.ts. Co-Authored-By: SageOx --- apps/ccusage/src/commands/day.ts | 10 +--------- apps/ccusage/src/data-loader.ts | 11 ++++------- packages/terminal/src/charts.ts | 1 + 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/apps/ccusage/src/commands/day.ts b/apps/ccusage/src/commands/day.ts index beb39380..41176ccf 100644 --- a/apps/ccusage/src/commands/day.ts +++ b/apps/ccusage/src/commands/day.ts @@ -1,4 +1,3 @@ -import type { ActivityEntry as ChartActivityEntry } from '@ccusage/terminal/charts'; import process from 'node:process'; import { createDayActivityGrid } from '@ccusage/terminal/charts'; import { Result } from '@praha/byethrow'; @@ -117,15 +116,8 @@ export const dayCommand = define({ // Print header logger.box('Claude Code Activity Heatmap'); - // Convert to chart format - const chartEntries: ChartActivityEntry[] = entries.map((e) => ({ - timestamp: e.timestamp, - cost: e.cost, - outputTokens: e.outputTokens, - })); - // Render the activity grid - const grid = createDayActivityGrid(chartEntries, { + const grid = createDayActivityGrid(entries, { date: targetDate, timezone: mergedOptions.timezone, metric: ctx.values.metric, diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index 09490435..fd3bb649 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -8,6 +8,7 @@ * @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'; @@ -1216,14 +1217,10 @@ export async function loadSessionUsageById( } /** - * Entry with timestamp for activity grid visualization + * Entry with timestamp for activity grid visualization. + * Re-exported from @ccusage/terminal/charts for convenience. */ -export type ActivityEntry = { - timestamp: string; - cost: number; - outputTokens: number; - model?: string; -}; +export type { ActivityEntry } from '@ccusage/terminal/charts'; /** * Load entries for a single day with their timestamps for activity grid visualization diff --git a/packages/terminal/src/charts.ts b/packages/terminal/src/charts.ts index d58b253e..2c7328ac 100644 --- a/packages/terminal/src/charts.ts +++ b/packages/terminal/src/charts.ts @@ -369,6 +369,7 @@ export type ActivityEntry = { timestamp: string; // ISO timestamp cost: number; outputTokens: number; + model?: string; }; /** From 2f8b38648bab48cfe3b6633af2479d5cc88a897e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Mon, 12 Jan 2026 19:09:02 -0800 Subject: [PATCH 7/8] fix(ccusage): remove redundant dynamic imports in test blocks Remove duplicate await import('fs-fixture') calls since createFixture is already imported statically at file top. Dynamic imports in tests violate coding guidelines and cause tree-shaking issues. Co-Authored-By: SageOx --- apps/ccusage/src/data-loader.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index fd3bb649..c720fe50 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -1731,9 +1731,7 @@ if (import.meta.vitest != null) { }); }); - describe('loadSessionUsageById', async () => { - const { createFixture } = await import('fs-fixture'); - + describe('loadSessionUsageById', () => { afterEach(() => { vi.unstubAllEnvs(); }); @@ -4900,12 +4898,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': [ From 5319fe22cb045bffdbba1d622728b1560c2ea20e Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Wed, 14 Jan 2026 00:28:50 -0800 Subject: [PATCH 8/8] fix(ccusage): resolve timezone bug in day activity heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loadDayActivityData function used a regex to extract the date from ISO timestamps, comparing the UTC date against the local target date. This caused events from late local time (after 4PM Pacific, etc.) to be filtered out because their UTC timestamps have the next day's date. Changes: - Expand fast-path filter to accept ±1 day in UTC time - Add local-timezone verification after JSON parsing - Add tests for timezone edge cases This ensures the heatmap shows all usage data regardless of timezone, matching the behavior of other daily reports. --- apps/ccusage/src/data-loader.ts | 141 +++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 3 deletions(-) diff --git a/apps/ccusage/src/data-loader.ts b/apps/ccusage/src/data-loader.ts index c720fe50..14097f50 100644 --- a/apps/ccusage/src/data-loader.ts +++ b/apps/ccusage/src/data-loader.ts @@ -1294,6 +1294,13 @@ export async function loadDayActivityData( // 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); @@ -1303,13 +1310,14 @@ export async function loadDayActivityData( const entries: ActivityEntry[] = []; await processJSONLFileByLine(file, async (line) => { try { - // fast-path: extract timestamp with regex before full JSON parse + // 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 || timestampMatch[1] !== targetDate) { + if (timestampMatch == null || !validUtcDates.has(timestampMatch[1] ?? '')) { return; } - // only parse and validate lines that match the target date + // parse and validate the entry const parsed = JSON.parse(line) as unknown; const result = v.safeParse(usageDataSchema, parsed); if (!result.success) { @@ -1317,6 +1325,17 @@ export async function loadDayActivityData( } 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 @@ -4438,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