Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ dist
.DS_Store
.vscode
.env
.npm-cache

*.tsbuildinfo
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ codex-wrapped

| Option | Description |
| --------------- | ------------------------------------ |
| `--year, -y` | Generate wrapped for a specific year |
| `--year, -y` | Generate wrapped for a specific year (default: all-time) |
| `--help, -h` | Show help message |
| `--version, -v` | Show version number |

Expand Down
25 changes: 23 additions & 2 deletions src/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ export async function listCodexSessionFiles(year: number): Promise<string[]> {
return files;
}

export async function listCodexSessionFilesAllTime(): Promise<string[]> {
let yearDirs: string[] = [];
try {
const entries = await readdir(CODEX_SESSIONS_PATH, { withFileTypes: true });
yearDirs = entries
.filter((e) => e.isDirectory() && /^\d{4}$/.test(e.name))
.map((e) => e.name)
.sort();
} catch {
return [];
}

const files: string[] = [];
for (const dir of yearDirs) {
const year = parseInt(dir, 10);
if (!Number.isFinite(year)) continue;
files.push(...(await listCodexSessionFiles(year)));
}
return files;
}

export async function getCodexFirstPromptTimestamp(): Promise<number | null> {
try {
const raw = await readFile(CODEX_HISTORY_PATH, "utf8");
Expand All @@ -100,8 +121,8 @@ export async function getCodexFirstPromptTimestamp(): Promise<number | null> {
}
}

export async function collectCodexUsageData(year: number): Promise<CodexUsageData> {
const files = await listCodexSessionFiles(year);
export async function collectCodexUsageData(year?: number): Promise<CodexUsageData> {
const files = typeof year === "number" ? await listCodexSessionFiles(year) : await listCodexSessionFilesAllTime();
const events: CodexUsageEvent[] = [];
const dailyActivity = new Map<string, number>();
const projects = new Set<string>();
Expand Down
22 changes: 15 additions & 7 deletions src/image/heatmap.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { generateWeeksForYear, getIntensityLevel } from "../utils/dates";
import { generateWeeksForRange, generateWeeksForYear, getIntensityLevel } from "../utils/dates";
import { colors, typography, spacing, components, HEATMAP_COLORS, STREAK_COLORS } from "./design-tokens";

interface HeatmapProps {
type HeatmapProps = {
dailyActivity: Map<string, number>;
year: number;
maxStreakDays?: Set<string>;
}
} & ({ year: number } | { range: { start: Date; end: Date } });

interface MonthLabel {
month: number;
Expand All @@ -21,10 +20,19 @@ const CELL_RADIUS = components.heatmapCell.borderRadius;
const LEGEND_CELL_SIZE = components.legend.cellSize;
const LEGEND_GAP = components.legend.gap;

export function ActivityHeatmap({ dailyActivity, year, maxStreakDays }: HeatmapProps) {
const weeks = generateWeeksForYear(year);
export function ActivityHeatmap(props: HeatmapProps) {
const { dailyActivity, maxStreakDays } = props;
const weeks = "year" in props
? generateWeeksForYear(props.year)
: generateWeeksForRange(props.range.start, props.range.end);

const counts = Array.from(dailyActivity.values());
const counts: number[] = [];
for (const week of weeks) {
for (const dateStr of week) {
if (!dateStr) continue;
counts.push(dailyActivity.get(dateStr) || 0);
}
}
const maxCount = counts.length > 0 ? Math.max(...counts) : 0;

const monthLabels = getMonthLabels(weeks, CELL_SIZE, CELL_GAP);
Expand Down
18 changes: 13 additions & 5 deletions src/image/template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export function WrappedTemplate({ stats }: { stats: CodexStats }) {
borderRadius: layout.radius.full,
}}
/>
<Header year={stats.year} />
<Header label={stats.periodLabel} />

<div style={{ marginTop: spacing[8], display: "flex", flexDirection: "row", gap: spacing[16], alignItems: "flex-start" }}>
<HeroStatItem
Expand Down Expand Up @@ -88,8 +88,16 @@ export function WrappedTemplate({ stats }: { stats: CodexStats }) {
</div>
</div>

<Section title="Activity" marginTop={spacing[8]}>
<ActivityHeatmap dailyActivity={stats.dailyActivity} year={stats.year} maxStreakDays={stats.maxStreakDays} />
<Section title={stats.activityLabel} marginTop={spacing[8]}>
{"year" in stats.activityHeatmap ? (
<ActivityHeatmap dailyActivity={stats.dailyActivity} year={stats.activityHeatmap.year} maxStreakDays={stats.maxStreakDays} />
) : (
<ActivityHeatmap
dailyActivity={stats.dailyActivity}
range={{ start: stats.activityHeatmap.start, end: stats.activityHeatmap.end }}
maxStreakDays={stats.maxStreakDays}
/>
)}
</Section>

<div
Expand All @@ -115,7 +123,7 @@ export function WrappedTemplate({ stats }: { stats: CodexStats }) {
);
}

function Header({ year }: { year: number }) {
function Header({ label }: { label: string }) {
return (
<div
style={{
Expand Down Expand Up @@ -181,7 +189,7 @@ function Header({ year }: { year: number }) {
lineHeight: typography.lineHeight.none,
}}
>
{year}
{label}
</span>
</div>
</div>
Expand Down
42 changes: 25 additions & 17 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,18 @@ function printHelp() {
console.log(`
codex-wrapped v${VERSION}

Generate your Codex year in review stats card.
Generate your Codex wrapped stats card (all-time by default).

USAGE:
codex-wrapped [OPTIONS]

OPTIONS:
--year <YYYY> Generate wrapped for a specific year (default: current year)
--year <YYYY> Generate wrapped for a specific year (default: all-time)
--help, -h Show this help message
--version, -v Show version number

EXAMPLES:
codex-wrapped # Generate current year wrapped
codex-wrapped # Generate all-time wrapped
codex-wrapped --year 2025 # Generate 2025 wrapped
`);
}
Expand Down Expand Up @@ -60,17 +60,25 @@ async function main() {

p.intro("codex wrapped");

const requestedYear = values.year ? parseInt(values.year, 10) : new Date().getFullYear();
const requestedYear = values.year ? parseInt(values.year, 10) : undefined;
if (values.year && !Number.isFinite(requestedYear)) {
p.cancel(`Invalid year: ${values.year}`);
process.exit(1);
}

const availability = isWrappedAvailable(requestedYear);
if (!availability.available) {
if (Array.isArray(availability.message)) {
availability.message.forEach((line) => p.log.warn(line));
} else {
p.log.warn(availability.message || "Wrapped not available yet.");
const periodLabel = typeof requestedYear === "number" ? String(requestedYear) : "All Time";

if (typeof requestedYear === "number") {
const availability = isWrappedAvailable(requestedYear);
if (!availability.available) {
if (Array.isArray(availability.message)) {
availability.message.forEach((line) => p.log.warn(line));
} else {
p.log.warn(availability.message || "Wrapped not available yet.");
}
p.cancel();
process.exit(0);
}
p.cancel();
process.exit(0);
}

const dataExists = await checkCodexDataExists();
Expand All @@ -84,7 +92,7 @@ async function main() {

let stats;
try {
stats = await calculateStats(requestedYear);
stats = await calculateStats({ year: requestedYear });
} catch (error) {
spinner.stop("Failed to collect stats");
p.cancel(`Error: ${error}`);
Expand All @@ -93,7 +101,7 @@ async function main() {

if (stats.totalSessions === 0) {
spinner.stop("No data found");
p.cancel(`No Codex activity found for ${requestedYear}`);
p.cancel(typeof requestedYear === "number" ? `No Codex activity found for ${requestedYear}` : "No Codex activity found");
process.exit(0);
}

Expand All @@ -112,7 +120,7 @@ async function main() {
stats.mostActiveDay && `Most Active: ${stats.mostActiveDay.formattedDate}`,
].filter(Boolean);

p.note(summaryLines.join("\n"), `Your ${requestedYear} in Codex`);
p.note(summaryLines.join("\n"), `Your ${periodLabel} in Codex`);

// Generate image
spinner.start("Generating your wrapped image...");
Expand All @@ -133,7 +141,7 @@ async function main() {
p.log.info(`Terminal (${getTerminalName()}) doesn't support inline images`);
}

const filename = `codex-wrapped-${requestedYear}.png`;
const filename = `codex-wrapped-${typeof requestedYear === "number" ? requestedYear : "all-time"}.png`;
const { success, error } = await copyImageToClipboard(image.fullSize, filename);

if (success) {
Expand Down Expand Up @@ -187,7 +195,7 @@ async function main() {

function generateTweetUrl(stats: CodexStats): string {
const lines: string[] = [];
lines.push(`Codex Wrapped ${stats.year}`);
lines.push(`Codex Wrapped ${stats.periodLabel}`);
lines.push("");
lines.push(`Total Tokens: ${formatNumberFull(stats.totalTokens)}`);
lines.push(`Total Messages: ${formatNumberFull(stats.totalMessages)}`);
Expand Down
30 changes: 27 additions & 3 deletions src/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ type ModelUsageTotals = {
totalTokens: number;
};

export async function calculateStats(year: number): Promise<CodexStats> {
export async function calculateStats(options?: { year?: number }): Promise<CodexStats> {
await fetchModelsData();

const year = options?.year;
const usageData = await collectCodexUsageData(year);
const dailyActivity = usageData.dailyActivity;
const weekdayCounts: [number, number, number, number, number, number, number] = [0, 0, 0, 0, 0, 0, 0];
Expand Down Expand Up @@ -103,8 +104,18 @@ export async function calculateStats(year: number): Promise<CodexStats> {
const daysSinceFirstSession = Math.floor((Date.now() - firstSessionDate.getTime()) / (1000 * 60 * 60 * 24));
const totalCost = await calculateUsageCost(modelUsageTotals);

const periodLabel = typeof year === "number" ? String(year) : "All Time";
const activityLabel = typeof year === "number" ? "Activity" : "Activity (Last 12 months)";

const activityHeatmap = typeof year === "number"
? ({ kind: "year", year } as const)
: ({ kind: "range", ...getLast12MonthsHeatmapRange(firstSessionDate) } as const);

return {
periodLabel,
year,
activityHeatmap,
activityLabel,
firstSessionDate,
daysSinceFirstSession,
totalSessions: usageData.totalSessions,
Expand Down Expand Up @@ -198,11 +209,11 @@ async function calculateUsageCost(modelUsageTotals: Map<string, ModelUsageTotals

function calculateStreaks(
dailyActivity: Map<string, number>,
year: number
year?: number
): { maxStreak: number; currentStreak: number; maxStreakDays: Set<string> } {
// Get all active dates sorted
const activeDates = Array.from(dailyActivity.keys())
.filter((date) => date.startsWith(String(year)))
.filter((date) => (typeof year === "number" ? date.startsWith(String(year)) : true))
.sort();

if (activeDates.length === 0) {
Expand Down Expand Up @@ -255,6 +266,19 @@ function calculateStreaks(
return { maxStreak, currentStreak, maxStreakDays };
}

function getLast12MonthsHeatmapRange(firstSessionDate: Date): { start: Date; end: Date } {
const end = new Date();
end.setHours(0, 0, 0, 0);

const start = new Date(end);
start.setDate(start.getDate() - 364);

const first = new Date(firstSessionDate);
first.setHours(0, 0, 0, 0);

return { start: first > start ? first : start, end };
}

/** Count consecutive days with activity going backwards from startDate (inclusive) */
function countStreakBackwards(dailyActivity: Map<string, number>, startDate: Date): number {
let streak = 1;
Expand Down
10 changes: 9 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ export interface ProviderStats {
}

export interface CodexStats {
year: number;
/** Display label for the wrapped period (e.g. "2025", "All Time"). */
periodLabel: string;
/** Present when generating a single-year wrapped. */
year?: number;

/** Activity heatmap configuration. */
activityHeatmap: { kind: "year"; year: number } | { kind: "range"; start: Date; end: Date };
/** Title label for the activity section. */
activityLabel: string;

// Time-based
firstSessionDate: Date;
Expand Down
35 changes: 35 additions & 0 deletions src/utils/dates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,41 @@ export function generateWeeksForYear(year: number): string[][] {
return weeks;
}

export function generateWeeksForRange(startDate: Date, endDate: Date): string[][] {
const start = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const end = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());

const weeks: string[][] = [];

// Adjust to start from the first Sunday (or the day itself if it's Sunday)
const startDay = start.getDay();
const adjustedStart = new Date(start);
if (startDay !== 0) {
adjustedStart.setDate(start.getDate() - startDay);
}

let currentDate = new Date(adjustedStart);
let currentWeek: string[] = [];

while (currentDate <= end || currentWeek.length > 0) {
const dayOfWeek = currentDate.getDay();
const withinRange = currentDate >= start && currentDate <= end;
currentWeek.push(withinRange ? formatDateKey(currentDate) : "");

if (dayOfWeek === 6) {
weeks.push(currentWeek);
currentWeek = [];
}

currentDate.setDate(currentDate.getDate() + 1);

// Safety: stop if we've gone too far past the end
if (currentDate.getTime() > end.getTime() + 32 * 24 * 60 * 60 * 1000) break;
}

return weeks;
}

export function formatDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
Expand Down