π File detail
utils/heatmap.ts
π§© .tsπ 199 linesπΎ 5,305 bytesπ text
β Back to All Filesπ― Use case
This file lives under βutils/β, which covers cross-cutting helpers (shell, tempfiles, settings, messages, process input, β¦). On the API surface it exposes HeatmapOptions and generateHeatmap β mainly functions, hooks, or classes. Dependencies touch terminal styling. It composes internal code from stats and statsCache (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import chalk from 'chalk' import type { DailyActivity } from './stats.js' import { toDateString } from './statsCache.js' export type HeatmapOptions = {
π€ Exports (heuristic)
HeatmapOptionsgenerateHeatmap
π External import roots
Package roots from from "β¦" (relative paths omitted).
chalk
π₯οΈ Source preview
import chalk from 'chalk'
import type { DailyActivity } from './stats.js'
import { toDateString } from './statsCache.js'
export type HeatmapOptions = {
terminalWidth?: number // Terminal width in characters
showMonthLabels?: boolean
}
type Percentiles = {
p25: number
p50: number
p75: number
}
/**
* Pre-calculates percentiles from activity data for use in intensity calculations
*/
function calculatePercentiles(
dailyActivity: DailyActivity[],
): Percentiles | null {
const counts = dailyActivity
.map(a => a.messageCount)
.filter(c => c > 0)
.sort((a, b) => a - b)
if (counts.length === 0) return null
return {
p25: counts[Math.floor(counts.length * 0.25)]!,
p50: counts[Math.floor(counts.length * 0.5)]!,
p75: counts[Math.floor(counts.length * 0.75)]!,
}
}
/**
* Generates a GitHub-style activity heatmap for the terminal
*/
export function generateHeatmap(
dailyActivity: DailyActivity[],
options: HeatmapOptions = {},
): string {
const { terminalWidth = 80, showMonthLabels = true } = options
// Day labels take 4 characters ("Mon "), calculate weeks that fit
// Cap at 52 weeks (1 year) to match GitHub style
const dayLabelWidth = 4
const availableWidth = terminalWidth - dayLabelWidth
const width = Math.min(52, Math.max(10, availableWidth))
// Build activity map by date
const activityMap = new Map<string, DailyActivity>()
for (const activity of dailyActivity) {
activityMap.set(activity.date, activity)
}
// Pre-calculate percentiles once for all intensity lookups
const percentiles = calculatePercentiles(dailyActivity)
// Calculate date range - end at today, go back N weeks
const today = new Date()
today.setHours(0, 0, 0, 0)
// Find the Sunday of the current week (start of the week containing today)
const currentWeekStart = new Date(today)
currentWeekStart.setDate(today.getDate() - today.getDay())
// Go back (width - 1) weeks from the current week start
const startDate = new Date(currentWeekStart)
startDate.setDate(startDate.getDate() - (width - 1) * 7)
// Generate grid (7 rows for days of week, width columns for weeks)
// Also track which week each month starts for labels
const grid: string[][] = Array.from({ length: 7 }, () =>
Array(width).fill(''),
)
const monthStarts: { month: number; week: number }[] = []
let lastMonth = -1
const currentDate = new Date(startDate)
for (let week = 0; week < width; week++) {
for (let day = 0; day < 7; day++) {
// Don't show future dates
if (currentDate > today) {
grid[day]![week] = ' '
currentDate.setDate(currentDate.getDate() + 1)
continue
}
const dateStr = toDateString(currentDate)
const activity = activityMap.get(dateStr)
// Track month changes (on day 0 = Sunday of each week)
if (day === 0) {
const month = currentDate.getMonth()
if (month !== lastMonth) {
monthStarts.push({ month, week })
lastMonth = month
}
}
// Determine intensity level based on message count
const intensity = getIntensity(activity?.messageCount || 0, percentiles)
grid[day]![week] = getHeatmapChar(intensity)
currentDate.setDate(currentDate.getDate() + 1)
}
}
// Build output
const lines: string[] = []
// Month labels - evenly spaced across the grid
if (showMonthLabels) {
const monthNames = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
]
// Build label line with fixed-width month labels
const uniqueMonths = monthStarts.map(m => m.month)
const labelWidth = Math.floor(width / Math.max(uniqueMonths.length, 1))
const monthLabels = uniqueMonths
.map(month => monthNames[month]!.padEnd(labelWidth))
.join('')
// 4 spaces for day label column prefix
lines.push(' ' + monthLabels)
}
// Day labels
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
// Grid
for (let day = 0; day < 7; day++) {
// Only show labels for Mon, Wed, Fri
const label = [1, 3, 5].includes(day) ? dayLabels[day]!.padEnd(3) : ' '
const row = label + ' ' + grid[day]!.join('')
lines.push(row)
}
// Legend
lines.push('')
lines.push(
' Less ' +
[
claudeOrange('β'),
claudeOrange('β'),
claudeOrange('β'),
claudeOrange('β'),
].join(' ') +
' More',
)
return lines.join('\n')
}
function getIntensity(
messageCount: number,
percentiles: Percentiles | null,
): number {
if (messageCount === 0 || !percentiles) return 0
if (messageCount >= percentiles.p75) return 4
if (messageCount >= percentiles.p50) return 3
if (messageCount >= percentiles.p25) return 2
return 1
}
// Claude orange color (hex #da7756)
const claudeOrange = chalk.hex('#da7756')
function getHeatmapChar(intensity: number): string {
switch (intensity) {
case 0:
return chalk.gray('Β·')
case 1:
return claudeOrange('β')
case 2:
return claudeOrange('β')
case 3:
return claudeOrange('β')
case 4:
return claudeOrange('β')
default:
return chalk.gray('Β·')
}
}