source dump of claude code
at main 198 lines 5.3 kB view raw
1import chalk from 'chalk' 2import type { DailyActivity } from './stats.js' 3import { toDateString } from './statsCache.js' 4 5export type HeatmapOptions = { 6 terminalWidth?: number // Terminal width in characters 7 showMonthLabels?: boolean 8} 9 10type Percentiles = { 11 p25: number 12 p50: number 13 p75: number 14} 15 16/** 17 * Pre-calculates percentiles from activity data for use in intensity calculations 18 */ 19function calculatePercentiles( 20 dailyActivity: DailyActivity[], 21): Percentiles | null { 22 const counts = dailyActivity 23 .map(a => a.messageCount) 24 .filter(c => c > 0) 25 .sort((a, b) => a - b) 26 27 if (counts.length === 0) return null 28 29 return { 30 p25: counts[Math.floor(counts.length * 0.25)]!, 31 p50: counts[Math.floor(counts.length * 0.5)]!, 32 p75: counts[Math.floor(counts.length * 0.75)]!, 33 } 34} 35 36/** 37 * Generates a GitHub-style activity heatmap for the terminal 38 */ 39export function generateHeatmap( 40 dailyActivity: DailyActivity[], 41 options: HeatmapOptions = {}, 42): string { 43 const { terminalWidth = 80, showMonthLabels = true } = options 44 45 // Day labels take 4 characters ("Mon "), calculate weeks that fit 46 // Cap at 52 weeks (1 year) to match GitHub style 47 const dayLabelWidth = 4 48 const availableWidth = terminalWidth - dayLabelWidth 49 const width = Math.min(52, Math.max(10, availableWidth)) 50 51 // Build activity map by date 52 const activityMap = new Map<string, DailyActivity>() 53 for (const activity of dailyActivity) { 54 activityMap.set(activity.date, activity) 55 } 56 57 // Pre-calculate percentiles once for all intensity lookups 58 const percentiles = calculatePercentiles(dailyActivity) 59 60 // Calculate date range - end at today, go back N weeks 61 const today = new Date() 62 today.setHours(0, 0, 0, 0) 63 64 // Find the Sunday of the current week (start of the week containing today) 65 const currentWeekStart = new Date(today) 66 currentWeekStart.setDate(today.getDate() - today.getDay()) 67 68 // Go back (width - 1) weeks from the current week start 69 const startDate = new Date(currentWeekStart) 70 startDate.setDate(startDate.getDate() - (width - 1) * 7) 71 72 // Generate grid (7 rows for days of week, width columns for weeks) 73 // Also track which week each month starts for labels 74 const grid: string[][] = Array.from({ length: 7 }, () => 75 Array(width).fill(''), 76 ) 77 const monthStarts: { month: number; week: number }[] = [] 78 let lastMonth = -1 79 80 const currentDate = new Date(startDate) 81 for (let week = 0; week < width; week++) { 82 for (let day = 0; day < 7; day++) { 83 // Don't show future dates 84 if (currentDate > today) { 85 grid[day]![week] = ' ' 86 currentDate.setDate(currentDate.getDate() + 1) 87 continue 88 } 89 90 const dateStr = toDateString(currentDate) 91 const activity = activityMap.get(dateStr) 92 93 // Track month changes (on day 0 = Sunday of each week) 94 if (day === 0) { 95 const month = currentDate.getMonth() 96 if (month !== lastMonth) { 97 monthStarts.push({ month, week }) 98 lastMonth = month 99 } 100 } 101 102 // Determine intensity level based on message count 103 const intensity = getIntensity(activity?.messageCount || 0, percentiles) 104 grid[day]![week] = getHeatmapChar(intensity) 105 106 currentDate.setDate(currentDate.getDate() + 1) 107 } 108 } 109 110 // Build output 111 const lines: string[] = [] 112 113 // Month labels - evenly spaced across the grid 114 if (showMonthLabels) { 115 const monthNames = [ 116 'Jan', 117 'Feb', 118 'Mar', 119 'Apr', 120 'May', 121 'Jun', 122 'Jul', 123 'Aug', 124 'Sep', 125 'Oct', 126 'Nov', 127 'Dec', 128 ] 129 130 // Build label line with fixed-width month labels 131 const uniqueMonths = monthStarts.map(m => m.month) 132 const labelWidth = Math.floor(width / Math.max(uniqueMonths.length, 1)) 133 const monthLabels = uniqueMonths 134 .map(month => monthNames[month]!.padEnd(labelWidth)) 135 .join('') 136 137 // 4 spaces for day label column prefix 138 lines.push(' ' + monthLabels) 139 } 140 141 // Day labels 142 const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] 143 144 // Grid 145 for (let day = 0; day < 7; day++) { 146 // Only show labels for Mon, Wed, Fri 147 const label = [1, 3, 5].includes(day) ? dayLabels[day]!.padEnd(3) : ' ' 148 const row = label + ' ' + grid[day]!.join('') 149 lines.push(row) 150 } 151 152 // Legend 153 lines.push('') 154 lines.push( 155 ' Less ' + 156 [ 157 claudeOrange('░'), 158 claudeOrange('▒'), 159 claudeOrange('▓'), 160 claudeOrange('█'), 161 ].join(' ') + 162 ' More', 163 ) 164 165 return lines.join('\n') 166} 167 168function getIntensity( 169 messageCount: number, 170 percentiles: Percentiles | null, 171): number { 172 if (messageCount === 0 || !percentiles) return 0 173 174 if (messageCount >= percentiles.p75) return 4 175 if (messageCount >= percentiles.p50) return 3 176 if (messageCount >= percentiles.p25) return 2 177 return 1 178} 179 180// Claude orange color (hex #da7756) 181const claudeOrange = chalk.hex('#da7756') 182 183function getHeatmapChar(intensity: number): string { 184 switch (intensity) { 185 case 0: 186 return chalk.gray('·') 187 case 1: 188 return claudeOrange('░') 189 case 2: 190 return claudeOrange('▒') 191 case 3: 192 return claudeOrange('▓') 193 case 4: 194 return claudeOrange('█') 195 default: 196 return chalk.gray('·') 197 } 198}