/ utils / heatmap.ts
heatmap.ts
  1  import chalk from 'chalk'
  2  import type { DailyActivity } from './stats.js'
  3  import { toDateString } from './statsCache.js'
  4  
  5  export type HeatmapOptions = {
  6    terminalWidth?: number // Terminal width in characters
  7    showMonthLabels?: boolean
  8  }
  9  
 10  type 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   */
 19  function 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   */
 39  export 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  
168  function 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)
181  const claudeOrange = chalk.hex('#da7756')
182  
183  function 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  }