diff --git a/.gitignore b/.gitignore index e21855a..4893023 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,6 @@ dist .DS_Store .vscode .env +.npm-cache *.tsbuildinfo diff --git a/README.md b/README.md index 35e93c9..b0a9b46 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/src/collector.ts b/src/collector.ts index 1cfc447..13bc1bc 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -78,6 +78,27 @@ export async function listCodexSessionFiles(year: number): Promise { return files; } +export async function listCodexSessionFilesAllTime(): Promise { + 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 { try { const raw = await readFile(CODEX_HISTORY_PATH, "utf8"); @@ -100,8 +121,8 @@ export async function getCodexFirstPromptTimestamp(): Promise { } } -export async function collectCodexUsageData(year: number): Promise { - const files = await listCodexSessionFiles(year); +export async function collectCodexUsageData(year?: number): Promise { + const files = typeof year === "number" ? await listCodexSessionFiles(year) : await listCodexSessionFilesAllTime(); const events: CodexUsageEvent[] = []; const dailyActivity = new Map(); const projects = new Set(); diff --git a/src/image/heatmap.tsx b/src/image/heatmap.tsx index 186789c..375fe67 100644 --- a/src/image/heatmap.tsx +++ b/src/image/heatmap.tsx @@ -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; - year: number; maxStreakDays?: Set; -} +} & ({ year: number } | { range: { start: Date; end: Date } }); interface MonthLabel { month: number; @@ -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); diff --git a/src/image/template.tsx b/src/image/template.tsx index db4b924..5bdf6e6 100644 --- a/src/image/template.tsx +++ b/src/image/template.tsx @@ -49,7 +49,7 @@ export function WrappedTemplate({ stats }: { stats: CodexStats }) { borderRadius: layout.radius.full, }} /> -
+
-
- +
+ {"year" in stats.activityHeatmap ? ( + + ) : ( + + )}
- {year} + {label}
diff --git a/src/index.ts b/src/index.ts index 81827da..a89e52d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 Generate wrapped for a specific year (default: current year) + --year 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 `); } @@ -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(); @@ -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}`); @@ -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); } @@ -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..."); @@ -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) { @@ -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)}`); diff --git a/src/stats.ts b/src/stats.ts index 463fad4..0b1c021 100644 --- a/src/stats.ts +++ b/src/stats.ts @@ -11,9 +11,10 @@ type ModelUsageTotals = { totalTokens: number; }; -export async function calculateStats(year: number): Promise { +export async function calculateStats(options?: { year?: number }): Promise { 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]; @@ -103,8 +104,18 @@ export async function calculateStats(year: number): Promise { 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, @@ -198,11 +209,11 @@ async function calculateUsageCost(modelUsageTotals: Map, - year: number + year?: number ): { maxStreak: number; currentStreak: number; maxStreakDays: Set } { // 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) { @@ -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, startDate: Date): number { let streak = 1; diff --git a/src/types.ts b/src/types.ts index 2d9d679..f9fe42e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/utils/dates.ts b/src/utils/dates.ts index e705b71..4170d37 100644 --- a/src/utils/dates.ts +++ b/src/utils/dates.ts @@ -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");