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;
}