diff --git a/README.md b/README.md index 6539ae1..b6b0744 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,39 @@ cc-wrapped ## Usage Options -| Option | Description | -| --------------- | ------------------------------------ | -| `--year, -y` | Generate wrapped for a specific year | -| `--help, -h` | Show help message | -| `--version, -v` | Show version number | +| Option | Description | +| --------------- | ------------------------------------------------ | +| `--year, -y` | Generate wrapped for a specific year | +| `--month, -m` | Generate wrapped for a specific month (1-12) | +| `--all, -a` | Generate all 12 monthly wraps + yearly summary | +| `--help, -h` | Show help message | +| `--version, -v` | Show version number | + +### Examples + +```bash +# Generate yearly wrapped (default) +cc-wrapped + +# Generate wrapped for a specific year +cc-wrapped --year 2025 + +# Generate wrapped for a specific month +cc-wrapped --month 12 # December of current year +cc-wrapped -m 6 --year 2025 # June 2025 + +# Generate ALL monthly + yearly wrapped images (13 total) +cc-wrapped --all # All months + yearly for current year +cc-wrapped --all --year 2025 # All months + yearly for 2025 +``` ## Features - Sessions, messages, tokens, projects, and streaks - GitHub-style activity heatmap +- **Monthly activity bar chart** showing usage across all 12 months +- **Generate monthly or yearly summaries** — view stats for any specific month +- **Batch generation** — create all 12 monthly images + yearly in one command - Top models and providers breakdown - Usage cost (when available) - Shareable PNG image @@ -82,9 +105,17 @@ The wrapped image displays natively in terminals that support inline images: The tool generates: 1. **Terminal Summary** — Quick stats overview in your terminal -2. **PNG Image** — A beautiful, shareable wrapped card saved to your home directory +2. **PNG Image(s)** — Beautiful, shareable wrapped cards saved to your home directory 3. **Clipboard** — Automatically copies the image to your clipboard +### Output Files + +| Mode | Filename(s) | +| ---- | ----------- | +| Yearly (default) | `cc-wrapped-2025.png` | +| Single month | `cc-wrapped-2025-12.png` (December) | +| All (`--all`) | `cc-wrapped-2025-01.png` through `cc-wrapped-2025-12.png` + `cc-wrapped-2025.png` | + ## Data Source Claude Code Wrapped reads data from your local Claude Code installation: diff --git a/src/image/template.tsx b/src/image/template.tsx index c3ba417..550cce3 100644 --- a/src/image/template.tsx +++ b/src/image/template.tsx @@ -1,4 +1,4 @@ -import type { ClaudeCodeStats, WeekdayActivity } from "../types"; +import type { ClaudeCodeStats, MonthlyActivity, WeekdayActivity } from "../types"; import { formatNumberFull, formatCostFull, formatDate } from "../utils/format"; import { ActivityHeatmap } from "./heatmap"; import { colors, typography, spacing, layout, components } from "./design-tokens"; @@ -49,7 +49,7 @@ export function WrappedTemplate({ stats }: { stats: ClaudeCodeStats }) { borderRadius: layout.radius.full, }} /> -
+
+
+ + Monthly + + +
+
- {year} + {displayLabel}
@@ -309,6 +336,74 @@ function WeeklyBarChart({ weekdayActivity }: { weekdayActivity: WeekdayActivity ); } +const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +const MONTHLY_BAR_HEIGHT = 80; +const MONTHLY_BAR_WIDTH = 28; +const MONTHLY_BAR_GAP = 6; + +function MonthlyBarChart({ monthlyActivity }: { monthlyActivity: MonthlyActivity }) { + const { counts, mostActiveMonth, maxCount } = monthlyActivity; + + return ( +
+
+ {counts.map((count, i) => { + const heightPercent = maxCount > 0 ? count / maxCount : 0; + const barHeight = Math.max(6, Math.round(heightPercent * MONTHLY_BAR_HEIGHT)); + const isHighlighted = i === mostActiveMonth; + + return ( +
+ ); + })} +
+ +
+ {MONTH_LABELS.map((label, i) => { + const isHighlighted = i === mostActiveMonth; + return ( +
+ {label} +
+ ); + })} +
+
+ ); +} + function Section({ title, marginTop = 0, children }: { title: string; marginTop?: number; children: React.ReactNode }) { return (
Generate wrapped for a specific year (default: current year) + --month <1-12> Generate wrapped for a specific month (1=January, 12=December) + --all Generate all 12 monthly wraps + yearly summary --help, -h Show this help message --version, -v Show version number EXAMPLES: - cc-wrapped # Generate current year wrapped - cc-wrapped --year 2025 # Generate 2025 wrapped + cc-wrapped # Generate current year wrapped + cc-wrapped --year 2025 # Generate 2025 wrapped + cc-wrapped --month 12 # Generate December wrapped + cc-wrapped --all # Generate all months + yearly + cc-wrapped --year 2025 --all # Generate all months + yearly for 2025 `); } @@ -41,6 +48,8 @@ async function main() { args: process.argv.slice(2), options: { year: { type: "string", short: "y" }, + month: { type: "string", short: "m" }, + all: { type: "boolean", short: "a" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" }, }, @@ -61,6 +70,14 @@ async function main() { p.intro("claude code wrapped"); const requestedYear = values.year ? parseInt(values.year, 10) : new Date().getFullYear(); + const requestedMonth = values.month ? parseInt(values.month, 10) : undefined; + const generateAll = values.all ?? false; + + // Validate month if provided + if (requestedMonth !== undefined && (requestedMonth < 1 || requestedMonth > 12)) { + p.cancel("Month must be between 1 and 12"); + process.exit(1); + } const availability = isWrappedAvailable(requestedYear); if (!availability.available) { @@ -79,31 +96,93 @@ async function main() { process.exit(0); } + // Determine which periods to generate + interface GenerationPeriod { + month?: number; + label: string; + filename: string; + } + + const periods: GenerationPeriod[] = []; + + if (generateAll) { + // Generate all 12 months + yearly + for (let m = 1; m <= 12; m++) { + periods.push({ + month: m, + label: `${MONTH_NAMES[m - 1]} ${requestedYear}`, + filename: `cc-wrapped-${requestedYear}-${String(m).padStart(2, "0")}.png`, + }); + } + periods.push({ + month: undefined, + label: `${requestedYear} (yearly)`, + filename: `cc-wrapped-${requestedYear}.png`, + }); + } else if (requestedMonth !== undefined) { + // Generate specific month only + periods.push({ + month: requestedMonth, + label: `${MONTH_NAMES[requestedMonth - 1]} ${requestedYear}`, + filename: `cc-wrapped-${requestedYear}-${String(requestedMonth).padStart(2, "0")}.png`, + }); + } else { + // Generate yearly only (default) + periods.push({ + month: undefined, + label: String(requestedYear), + filename: `cc-wrapped-${requestedYear}.png`, + }); + } + const spinner = p.spinner(); - spinner.start("Scanning your Claude Code history..."); + const generatedImages: Array<{ stats: ClaudeCodeStats; image: { fullSize: Buffer; displaySize: Buffer }; period: GenerationPeriod }> = []; - let stats; - try { - stats = await calculateStats(requestedYear); - } catch (error) { - spinner.stop("Failed to collect stats"); - p.cancel(`Error: ${error}`); - process.exit(1); + for (const period of periods) { + spinner.start(`Generating wrapped for ${period.label}...`); + + let stats; + try { + stats = await calculateStats(requestedYear, period.month); + } catch (error) { + spinner.stop("Failed to collect stats"); + p.cancel(`Error: ${error}`); + process.exit(1); + } + + if (stats.totalMessages === 0) { + spinner.stop(`No data for ${period.label}`); + p.log.warn(`No Claude Code activity found for ${period.label}`); + continue; + } + + let image: { fullSize: Buffer; displaySize: Buffer }; + try { + image = await generateImage(stats); + } catch (error) { + spinner.stop("Failed to generate image"); + p.cancel(`Error generating image: ${error}`); + process.exit(1); + } + + generatedImages.push({ stats, image, period }); + spinner.stop(`Generated ${period.label}`); } - if (stats.totalSessions === 0) { - spinner.stop("No data found"); + if (generatedImages.length === 0) { p.cancel(`No Claude Code activity found for ${requestedYear}`); process.exit(0); } - spinner.stop("Found your stats!"); + // Show summary for the primary/last generated image + const primary = generatedImages[generatedImages.length - 1]; + const { stats } = primary; const activityDates = Array.from(stats.dailyActivity.keys()) .map((d) => new Date(d)) .filter((d) => !Number.isNaN(d.getTime())) .sort((a, b) => a.getTime() - b.getTime()); - if (activityDates.length > 1 && requestedYear === new Date().getFullYear()) { + if (activityDates.length > 1 && requestedYear === new Date().getFullYear() && !stats.month) { const spanDays = Math.ceil( (activityDates[activityDates.length - 1].getTime() - activityDates[0].getTime()) / (1000 * 60 * 60 * 24) @@ -126,41 +205,34 @@ async function main() { stats.mostActiveDay && `Most Active: ${stats.mostActiveDay.formattedDate}`, ].filter(Boolean); - p.note(summaryLines.join("\n"), `Your ${requestedYear} in Claude Code`); + const summaryTitle = stats.monthName + ? `Your ${stats.monthName} ${requestedYear} in Claude Code` + : `Your ${requestedYear} in Claude Code`; + p.note(summaryLines.join("\n"), summaryTitle); - // Generate image - spinner.start("Generating your wrapped image..."); - - let image: { fullSize: Buffer; displaySize: Buffer }; - try { - image = await generateImage(stats); - } catch (error) { - spinner.stop("Failed to generate image"); - p.cancel(`Error generating image: ${error}`); - process.exit(1); - } - - spinner.stop("Image generated!"); - - const displayed = await displayInTerminal(image.displaySize); + // Display the primary image in terminal + const displayed = await displayInTerminal(primary.image.displaySize); if (!displayed) { p.log.info(`Terminal (${getTerminalName()}) doesn't support inline images`); } - const filename = `cc-wrapped-${requestedYear}.png`; - const { success, error } = await copyImageToClipboard(image.fullSize, filename); + // Copy primary image to clipboard + const { success, error } = await copyImageToClipboard(primary.image.fullSize, primary.period.filename); if (success) { p.log.success("Automatically copied image to clipboard!"); } else { p.log.warn(`Clipboard unavailable: ${error}`); - p.log.info("You can save the image to disk instead."); + p.log.info("You can save the images to disk instead."); } - const defaultPath = join(process.env.HOME || "~", filename); + // Ask to save images + const saveMessage = generatedImages.length > 1 + ? `Save ${generatedImages.length} images to home directory?` + : `Save image to ~/${primary.period.filename}?`; const shouldSave = await p.confirm({ - message: `Save image to ~/${filename}?`, + message: saveMessage, initialValue: true, }); @@ -170,11 +242,14 @@ async function main() { } if (shouldSave) { - try { - await Bun.write(defaultPath, image.fullSize); - p.log.success(`Saved to ${defaultPath}`); - } catch (error) { - p.log.error(`Failed to save: ${error}`); + for (const { image, period } of generatedImages) { + const defaultPath = join(process.env.HOME || "~", period.filename); + try { + await Bun.write(defaultPath, image.fullSize); + p.log.success(`Saved ${period.filename}`); + } catch (err) { + p.log.error(`Failed to save ${period.filename}: ${err}`); + } } } diff --git a/src/stats.ts b/src/stats.ts index 56fec40..7d76673 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -1,8 +1,10 @@ -import type { ClaudeCodeStats, ModelStats, ProviderStats, WeekdayActivity } from "./types"; +import type { ClaudeCodeStats, ModelStats, MonthlyActivity, ProviderStats, WeekdayActivity } from "./types"; import { collectClaudeProjects, collectClaudeUsageSummary, loadClaudeStatsCache } from "./collector"; import { fetchModelsData, getModelDisplayName, getModelProvider, getProviderDisplayName } from "./models"; -export async function calculateStats(year: number): Promise { +const MONTH_NAMES_FULL = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +export async function calculateStats(year: number, month?: number): Promise { const [, statsCache, projects, usageSummary] = await Promise.all([ fetchModelsData(), loadClaudeStatsCache(), @@ -12,6 +14,7 @@ export async function calculateStats(year: number): Promise { const dailyActivity = new Map(); const weekdayCounts: [number, number, number, number, number, number, number] = [0, 0, 0, 0, 0, 0, 0]; + const monthlyCounts: [number, number, number, number, number, number, number, number, number, number, number, number] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; let totalMessages = 0; let totalSessions = 0; @@ -20,30 +23,45 @@ export async function calculateStats(year: number): Promise { const usageDailyActivity = usageSummary.dailyActivity; if (usageDailyActivity.size > 0) { for (const [entryDate, messageCount] of usageDailyActivity.entries()) { - const entryYear = new Date(entryDate).getFullYear(); + const dateObj = new Date(entryDate); + const entryYear = dateObj.getFullYear(); if (entryYear !== year) continue; + + const entryMonth = dateObj.getMonth(); + // If filtering by month, skip entries not in that month (month is 1-12, getMonth() is 0-11) + if (month !== undefined && entryMonth !== month - 1) continue; + dailyActivity.set(entryDate, messageCount); totalMessages += messageCount; - const weekday = new Date(entryDate).getDay(); + const weekday = dateObj.getDay(); weekdayCounts[weekday] += messageCount; + + monthlyCounts[entryMonth] += messageCount; } totalSessions = usageSummary.totalSessions; } else { for (const entry of statsCache.dailyActivity ?? []) { const entryDate = entry?.date; if (!entryDate) continue; - const entryYear = new Date(entryDate).getFullYear(); + const dateObj = new Date(entryDate); + const entryYear = dateObj.getFullYear(); if (entryYear !== year) continue; + const entryMonth = dateObj.getMonth(); + // If filtering by month, skip entries not in that month (month is 1-12, getMonth() is 0-11) + if (month !== undefined && entryMonth !== month - 1) continue; + const messageCount = entry.messageCount ?? 0; dailyActivity.set(entryDate, messageCount); totalMessages += messageCount; totalSessions += entry.sessionCount ?? 0; totalToolCalls += entry.toolCallCount ?? 0; - const weekday = new Date(entryDate).getDay(); + const weekday = dateObj.getDay(); weekdayCounts[weekday] += messageCount; + + monthlyCounts[entryMonth] += messageCount; } } @@ -183,6 +201,7 @@ export async function calculateStats(year: number): Promise { const mostActiveDay = findMostActiveDay(dailyActivity); const weekdayActivity = buildWeekdayActivity(weekdayCounts); + const monthlyActivity = buildMonthlyActivity(monthlyCounts); const cacheDenominator = totalCacheReadTokens + totalCacheWriteTokens; const cacheHitRate = cacheDenominator > 0 ? (totalCacheReadTokens / cacheDenominator) * 100 : 0; @@ -196,6 +215,8 @@ export async function calculateStats(year: number): Promise { return { year, + month, + monthName: month !== undefined ? MONTH_NAMES_FULL[month - 1] : undefined, firstSessionDate, daysSinceFirstSession, totalSessions, @@ -220,6 +241,7 @@ export async function calculateStats(year: number): Promise { dailyActivity, mostActiveDay, weekdayActivity, + monthlyActivity, }; } @@ -373,3 +395,21 @@ function buildWeekdayActivity(counts: [number, number, number, number, number, n maxCount, }; } + +function buildMonthlyActivity(counts: [number, number, number, number, number, number, number, number, number, number, number, number]): MonthlyActivity { + let mostActiveMonth = 0; + let maxCount = 0; + for (let i = 0; i < 12; i++) { + if (counts[i] > maxCount) { + maxCount = counts[i]; + mostActiveMonth = i; + } + } + + return { + counts, + mostActiveMonth, + mostActiveMonthName: MONTH_NAMES_FULL[mostActiveMonth], + maxCount, + }; +} diff --git a/src/types.ts b/src/types.ts index 4a47130..0575742 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,8 @@ export interface ProviderStats { export interface ClaudeCodeStats { year: number; + month?: number; // 1-12 if showing a specific month, undefined for yearly + monthName?: string; // Full month name if showing a specific month // Time-based firstSessionDate: Date; @@ -120,6 +122,9 @@ export interface ClaudeCodeStats { // Weekday activity distribution (0=Sunday, 6=Saturday) weekdayActivity: WeekdayActivity; + + // Monthly activity distribution (0=January, 11=December) + monthlyActivity: MonthlyActivity; } export interface WeekdayActivity { @@ -129,7 +134,16 @@ export interface WeekdayActivity { maxCount: number; } +export interface MonthlyActivity { + counts: [number, number, number, number, number, number, number, number, number, number, number, number]; // Jan-Dec + mostActiveMonth: number; + mostActiveMonthName: string; + maxCount: number; +} + export interface CliArgs { year?: number; + month?: number; // 1-12 for specific month, undefined for yearly + all?: boolean; // Generate all 12 months + yearly help?: boolean; }