@da-trollefsen/claude-wrapped 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,782 @@
1
+ """Rich-based terminal UI for Claude Code Wrapped."""
2
+
3
+ import sys
4
+ import time
5
+ from datetime import datetime, timedelta
6
+
7
+ from rich.align import Align
8
+ from rich.console import Console, Group
9
+ from rich.live import Live
10
+ from rich.panel import Panel
11
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn
12
+ from rich.style import Style
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from .stats import WrappedStats, format_tokens
17
+
18
+ # Minimal color palette
19
+ COLORS = {
20
+ "orange": "#E67E22",
21
+ "purple": "#9B59B6",
22
+ "blue": "#3498DB",
23
+ "green": "#27AE60",
24
+ "white": "#ECF0F1",
25
+ "gray": "#7F8C8D",
26
+ "dark": "#2C3E50",
27
+ }
28
+
29
+ # GitHub-style contribution colors (light to dark green)
30
+ CONTRIB_COLORS = ["#161B22", "#0E4429", "#006D32", "#26A641", "#39D353"]
31
+
32
+
33
+ def wait_for_keypress():
34
+ """Wait for user to press Enter."""
35
+ input()
36
+ return '\n'
37
+
38
+
39
+ def format_year_display(year: int | None) -> str:
40
+ """Format year for display - returns 'All time' if year is None."""
41
+ return "All time" if year is None else str(year)
42
+
43
+
44
+ def create_dramatic_stat(value: str, label: str, subtitle: str = "", color: str = COLORS["orange"], extra_lines: list[tuple[str, str]] = None) -> Text:
45
+ """Create a dramatic full-screen stat reveal."""
46
+ text = Text()
47
+ text.append("\n\n\n\n\n")
48
+ text.append(f"{value}\n", style=Style(color=color, bold=True))
49
+ text.append(f"{label}\n\n", style=Style(color=COLORS["white"], bold=True))
50
+ if subtitle:
51
+ text.append(subtitle, style=Style(color=COLORS["gray"]))
52
+ if extra_lines:
53
+ text.append("\n\n")
54
+ for line, line_color in extra_lines:
55
+ text.append(f"{line}\n", style=Style(color=line_color))
56
+ text.append("\n\n\n\n")
57
+ text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
58
+ return text
59
+
60
+
61
+ def create_title_slide(year: int | None) -> Text:
62
+ """Create the opening title."""
63
+ title = Text()
64
+ title.append("\n\n\n")
65
+ title.append(" ░█████╗░██╗░░░░░░█████╗░██╗░░░██╗██████╗░███████╗\n", style="#C96442")
66
+ title.append(" ██╔══██╗██║░░░░░██╔══██╗██║░░░██║██╔══██╗██╔════╝\n", style="#C96442")
67
+ title.append(" ██║░░╚═╝██║░░░░░███████║██║░░░██║██║░░██║█████╗░░\n", style="#C96442")
68
+ title.append(" ██║░░██╗██║░░░░░██╔══██║██║░░░██║██║░░██║██╔══╝░░\n", style="#C96442")
69
+ title.append(" ╚█████╔╝███████╗██║░░██║╚██████╔╝██████╔╝███████╗\n", style="#C96442")
70
+ title.append(" ░╚════╝░╚══════╝╚═╝░░╚═╝░╚═════╝░╚═════╝░╚══════╝\n", style="#C96442")
71
+ title.append("\n")
72
+ title.append(" C O D E W R A P P E D\n", style=Style(color=COLORS["white"], bold=True))
73
+ year_display = format_year_display(year)
74
+ title.append(f" {year_display}\n\n", style=Style(color=COLORS["purple"], bold=True))
75
+ title.append(" by ", style=Style(color=COLORS["gray"]))
76
+ title.append("Trollefsen", style=Style(color=COLORS["blue"], bold=True, link="https://github.com/da-troll"))
77
+ title.append("\n\n\n")
78
+ title.append(" press [ENTER] to begin", style=Style(color=COLORS["dark"]))
79
+ title.append("\n\n")
80
+ return title
81
+
82
+
83
+ def create_big_stat(value: str, label: str, color: str = COLORS["orange"]) -> Text:
84
+ """Create a big statistic display."""
85
+ text = Text()
86
+ text.append(f"{value}\n", style=Style(color=color, bold=True))
87
+ text.append(label, style=Style(color=COLORS["gray"]))
88
+ return text
89
+
90
+
91
+ def create_contribution_graph(daily_stats: dict, year: int | None) -> Panel:
92
+ """Create a GitHub-style contribution graph for the full year or all-time."""
93
+ if not daily_stats:
94
+ return Panel("No activity data", title="Activity", border_style=COLORS["gray"])
95
+
96
+ # Calculate date range
97
+ if year is None:
98
+ # All-time: use actual date range from daily_stats
99
+ dates = [datetime.strptime(d, "%Y-%m-%d") for d in daily_stats.keys()]
100
+ start_date = min(dates) if dates else datetime.now()
101
+ end_date = max(dates) if dates else datetime.now()
102
+ else:
103
+ # Always show full year: Jan 1 to Dec 31 (or today if current year)
104
+ start_date = datetime(year, 1, 1)
105
+ today = datetime.now()
106
+ if year == today.year:
107
+ end_date = today
108
+ else:
109
+ end_date = datetime(year, 12, 31)
110
+
111
+ max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1
112
+
113
+ weeks = []
114
+ current = start_date - timedelta(days=start_date.weekday())
115
+
116
+ while current <= end_date + timedelta(days=7):
117
+ week = []
118
+ for day in range(7):
119
+ date = current + timedelta(days=day)
120
+ date_str = date.strftime("%Y-%m-%d")
121
+ if date_str in daily_stats:
122
+ count = daily_stats[date_str].message_count
123
+ level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0
124
+ else:
125
+ level = 0
126
+ week.append(level)
127
+ weeks.append(week)
128
+ current += timedelta(days=7)
129
+
130
+ graph = Text()
131
+ days_labels = ["Mon", " ", "Wed", " ", "Fri", " ", " "]
132
+
133
+ for row in range(7):
134
+ graph.append(f"{days_labels[row]} ", style=Style(color=COLORS["gray"]))
135
+ for week in weeks:
136
+ color = CONTRIB_COLORS[week[row]]
137
+ graph.append("■ ", style=Style(color=color))
138
+ graph.append("\n")
139
+
140
+ legend = Text()
141
+ legend.append("\n Less ", style=Style(color=COLORS["gray"]))
142
+ for color in CONTRIB_COLORS:
143
+ legend.append("■ ", style=Style(color=color))
144
+ legend.append("More", style=Style(color=COLORS["gray"]))
145
+
146
+ content = Group(graph, Align.center(legend))
147
+
148
+ # Calculate total days for context
149
+ today = datetime.now()
150
+ if year is None:
151
+ # All-time: calculate total days from date range
152
+ total_days = (end_date - start_date).days + 1
153
+ elif year == today.year:
154
+ total_days = (today - datetime(year, 1, 1)).days + 1
155
+ else:
156
+ total_days = 366 if year % 4 == 0 else 365
157
+ active_count = len([d for d in daily_stats.values() if d.message_count > 0])
158
+
159
+ return Panel(
160
+ Align.center(content),
161
+ title=f"Activity · {active_count} of {total_days} days",
162
+ border_style=Style(color=COLORS["green"]),
163
+ padding=(0, 2),
164
+ )
165
+
166
+
167
+ def create_hour_chart(distribution: list[int]) -> Panel:
168
+ """Create a clean hourly distribution chart."""
169
+ max_val = max(distribution) if any(distribution) else 1
170
+ chars = "▁▂▃▄▅▆▇█"
171
+
172
+ content = Text()
173
+ for i, val in enumerate(distribution):
174
+ idx = int((val / max_val) * (len(chars) - 1)) if max_val > 0 else 0
175
+ if 6 <= i < 12:
176
+ color = COLORS["orange"]
177
+ elif 12 <= i < 18:
178
+ color = COLORS["blue"]
179
+ elif 18 <= i < 24:
180
+ color = COLORS["purple"]
181
+ else:
182
+ color = COLORS["gray"]
183
+ content.append(chars[idx], style=Style(color=color))
184
+
185
+ # Build aligned label (24 chars to match 24 bars)
186
+ # Labels at positions: 0, 6, 12, 18, with end marker
187
+ content.append("\n")
188
+ content.append("0 6 12 18 24", style=Style(color=COLORS["gray"]))
189
+
190
+ return Panel(
191
+ Align.center(content),
192
+ title="Hours",
193
+ border_style=Style(color=COLORS["purple"]),
194
+ padding=(0, 1),
195
+ )
196
+
197
+
198
+ def create_weekday_chart(distribution: list[int]) -> Panel:
199
+ """Create a clean weekday distribution chart."""
200
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
201
+ max_val = max(distribution) if any(distribution) else 1
202
+
203
+ content = Text()
204
+ for i, (day, count) in enumerate(zip(days, distribution)):
205
+ bar_len = int((count / max_val) * 12) if max_val > 0 else 0
206
+ bar = "█" * bar_len + "░" * (12 - bar_len)
207
+ content.append(f"{day} ", style=Style(color=COLORS["gray"]))
208
+ content.append(bar, style=Style(color=COLORS["blue"]))
209
+ content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
210
+
211
+ return Panel(
212
+ content,
213
+ title="Days",
214
+ border_style=Style(color=COLORS["blue"]),
215
+ padding=(0, 1),
216
+ )
217
+
218
+
219
+ def create_top_list(items: list[tuple[str, int]], title: str, color: str) -> Panel:
220
+ """Create a clean top items list."""
221
+ content = Text()
222
+ max_val = max(v for _, v in items) if items else 1
223
+
224
+ for i, (name, count) in enumerate(items[:5], 1):
225
+ content.append(f"{i}. ", style=Style(color=COLORS["gray"]))
226
+ content.append(f"{name[:12]:<12} ", style=Style(color=COLORS["white"]))
227
+ bar_len = int((count / max_val) * 8)
228
+ content.append("▓" * bar_len, style=Style(color=color))
229
+ content.append("░" * (8 - bar_len), style=Style(color=COLORS["dark"]))
230
+ content.append(f" {count:,}\n", style=Style(color=COLORS["gray"]))
231
+
232
+ return Panel(
233
+ content,
234
+ title=title,
235
+ border_style=Style(color=color),
236
+ padding=(0, 1),
237
+ )
238
+
239
+
240
+ def create_personality_card(stats: WrappedStats) -> Panel:
241
+ """Create the personality card."""
242
+ personality = determine_personality(stats)
243
+
244
+ content = Text()
245
+ content.append(f"\n {personality['emoji']} ", style=Style(bold=True))
246
+ content.append(f"{personality['title']}\n\n", style=Style(color=COLORS["purple"], bold=True))
247
+ content.append(f" {personality['description']}\n", style=Style(color=COLORS["gray"]))
248
+
249
+ return Panel(
250
+ content,
251
+ title="Your Type",
252
+ border_style=Style(color=COLORS["purple"]),
253
+ padding=(0, 1),
254
+ )
255
+
256
+
257
+ def determine_personality(stats: WrappedStats) -> dict:
258
+ """Determine user's coding personality based on stats."""
259
+ night_hours = sum(stats.hourly_distribution[22:]) + sum(stats.hourly_distribution[:6])
260
+ day_hours = sum(stats.hourly_distribution[6:22])
261
+ top_tool = stats.top_tools[0][0] if stats.top_tools else None
262
+ weekend_msgs = stats.weekday_distribution[5] + stats.weekday_distribution[6]
263
+ weekday_msgs = sum(stats.weekday_distribution[:5])
264
+
265
+ if night_hours > day_hours * 0.4:
266
+ return {"emoji": "🦉", "title": "Night Owl", "description": "The quiet hours are your sanctuary."}
267
+ elif stats.streak_longest >= 14:
268
+ return {"emoji": "🔥", "title": "Streak Master", "description": f"{stats.streak_longest} days. Unstoppable."}
269
+ elif top_tool == "Edit":
270
+ return {"emoji": "🎨", "title": "The Refactorer", "description": "You see beauty in clean code."}
271
+ elif top_tool == "Bash":
272
+ return {"emoji": "⚡", "title": "Terminal Warrior", "description": "Command line is your domain."}
273
+ elif stats.total_projects >= 5:
274
+ return {"emoji": "🚀", "title": "Empire Builder", "description": f"{stats.total_projects} projects. Legend."}
275
+ elif weekend_msgs > weekday_msgs * 0.5:
276
+ return {"emoji": "🌙", "title": "Weekend Warrior", "description": "Passion fuels your weekends."}
277
+ elif stats.models_used.get("Opus", 0) > stats.models_used.get("Sonnet", 0):
278
+ return {"emoji": "🎯", "title": "Perfectionist", "description": "Only the best will do."}
279
+ else:
280
+ return {"emoji": "💻", "title": "Dedicated Dev", "description": "Steady and reliable."}
281
+
282
+
283
+ def get_fun_facts(stats: WrappedStats) -> list[tuple[str, str]]:
284
+ """Generate fun facts / bloopers based on stats - only 3 key facts."""
285
+ facts = []
286
+
287
+ # Late night coding days (midnight to 5am)
288
+ if stats.late_night_days > 0:
289
+ facts.append(("🌙", f"You coded after midnight on {stats.late_night_days} days. Sleep is overrated."))
290
+
291
+ # Most active day insight
292
+ if stats.most_active_day:
293
+ day_name = stats.most_active_day[0].strftime("%A")
294
+ facts.append(("📅", f"Your biggest day was a {day_name}. {stats.most_active_day[1]:,} messages. Epic."))
295
+
296
+ # Streak fact
297
+ if stats.streak_longest >= 1:
298
+ facts.append(("🔥", f"Your {stats.streak_longest}-day streak was legendary. Consistency wins."))
299
+
300
+ return facts
301
+
302
+
303
+ def create_fun_facts_slide(facts: list[tuple[str, str]]) -> Text:
304
+ """Create a fun facts slide."""
305
+ text = Text()
306
+ text.append("\n\n")
307
+ text.append(" B L O O P E R S & F U N F A C T S\n\n", style=Style(color=COLORS["purple"], bold=True))
308
+
309
+ for emoji, fact in facts:
310
+ text.append(f" {emoji} ", style=Style(bold=True))
311
+ text.append(f"{fact}\n\n", style=Style(color=COLORS["white"]))
312
+
313
+ text.append("\n")
314
+ text.append(" press [ENTER] for credits", style=Style(color=COLORS["dark"]))
315
+ text.append("\n")
316
+ return text
317
+
318
+
319
+ def simplify_model_name(model: str) -> str:
320
+ """Simplify a full model ID to a display name."""
321
+ model_lower = model.lower()
322
+ if 'opus-4-5' in model_lower or 'opus-4.5' in model_lower:
323
+ return 'Opus 4.5'
324
+ elif 'opus-4-1' in model_lower or 'opus-4.1' in model_lower:
325
+ return 'Opus 4.1'
326
+ elif 'opus' in model_lower:
327
+ return 'Opus'
328
+ elif 'sonnet-4-5' in model_lower or 'sonnet-4.5' in model_lower:
329
+ return 'Sonnet 4.5'
330
+ elif 'sonnet' in model_lower:
331
+ return 'Sonnet'
332
+ elif 'haiku-4-5' in model_lower or 'haiku-4.5' in model_lower:
333
+ return 'Haiku 4.5'
334
+ elif 'haiku' in model_lower:
335
+ return 'Haiku'
336
+ return model
337
+
338
+
339
+ def create_monthly_cost_table(stats: WrappedStats) -> Panel:
340
+ """Create a monthly cost breakdown table like ccusage."""
341
+ from .pricing import format_cost
342
+
343
+ table = Table(
344
+ show_header=True,
345
+ header_style=Style(color=COLORS["white"], bold=True),
346
+ border_style=Style(color=COLORS["dark"]),
347
+ box=None,
348
+ padding=(0, 1),
349
+ )
350
+
351
+ table.add_column("Month", style=Style(color=COLORS["gray"]))
352
+ table.add_column("Input", justify="right", style=Style(color=COLORS["blue"]))
353
+ table.add_column("Output", justify="right", style=Style(color=COLORS["orange"]))
354
+ table.add_column("Cache", justify="right", style=Style(color=COLORS["purple"]))
355
+ table.add_column("Cost", justify="right", style=Style(color=COLORS["green"], bold=True))
356
+
357
+ # Sort months chronologically
358
+ sorted_months = sorted(stats.monthly_costs.keys())
359
+
360
+ for month_key in sorted_months:
361
+ cost = stats.monthly_costs.get(month_key, 0)
362
+ tokens = stats.monthly_tokens.get(month_key, {})
363
+
364
+ # Format month name
365
+ try:
366
+ month_date = datetime.strptime(month_key, "%Y-%m")
367
+ month_name = month_date.strftime("%b %Y")
368
+ except ValueError:
369
+ month_name = month_key
370
+
371
+ input_tokens = tokens.get("input", 0)
372
+ output_tokens = tokens.get("output", 0)
373
+ cache_tokens = tokens.get("cache_create", 0) + tokens.get("cache_read", 0)
374
+
375
+ table.add_row(
376
+ month_name,
377
+ format_tokens(input_tokens),
378
+ format_tokens(output_tokens),
379
+ format_tokens(cache_tokens),
380
+ format_cost(cost),
381
+ )
382
+
383
+ # Add total row
384
+ if sorted_months:
385
+ table.add_row("", "", "", "", "") # Separator
386
+ table.add_row(
387
+ "Total",
388
+ format_tokens(stats.total_input_tokens),
389
+ format_tokens(stats.total_output_tokens),
390
+ format_tokens(stats.total_cache_creation_tokens + stats.total_cache_read_tokens),
391
+ format_cost(stats.estimated_cost) if stats.estimated_cost else "N/A",
392
+ style=Style(bold=True),
393
+ )
394
+
395
+ return Panel(
396
+ table,
397
+ title="Monthly Cost Breakdown",
398
+ border_style=Style(color=COLORS["green"]),
399
+ padding=(0, 1),
400
+ )
401
+
402
+
403
+ def create_credits_roll(stats: WrappedStats) -> list[Text]:
404
+ """Create end credits content."""
405
+ from .pricing import format_cost
406
+
407
+ frames = []
408
+
409
+ # Aggregate costs by simplified model name for display
410
+ display_costs: dict[str, float] = {}
411
+ for model, cost in stats.cost_by_model.items():
412
+ display_name = simplify_model_name(model)
413
+ display_costs[display_name] = display_costs.get(display_name, 0) + cost
414
+
415
+ # Frame 1: The Numbers (cost + tokens)
416
+ numbers = Text()
417
+ numbers.append("\n\n\n")
418
+ numbers.append(" T H E N U M B E R S\n\n", style=Style(color=COLORS["green"], bold=True))
419
+ if stats.estimated_cost is not None:
420
+ numbers.append(f" Estimated Cost ", style=Style(color=COLORS["white"], bold=True))
421
+ numbers.append(f"{format_cost(stats.estimated_cost)}\n", style=Style(color=COLORS["green"], bold=True))
422
+ for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]):
423
+ numbers.append(f" {model}: {format_cost(cost)}\n", style=Style(color=COLORS["gray"]))
424
+ numbers.append(f"\n Tokens ", style=Style(color=COLORS["white"], bold=True))
425
+ numbers.append(f"{format_tokens(stats.total_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
426
+ numbers.append(f" Input: {format_tokens(stats.total_input_tokens)}\n", style=Style(color=COLORS["gray"]))
427
+ numbers.append(f" Output: {format_tokens(stats.total_output_tokens)}\n", style=Style(color=COLORS["gray"]))
428
+ numbers.append("\n\n")
429
+ numbers.append(" [ENTER]", style=Style(color=COLORS["dark"]))
430
+ frames.append(numbers)
431
+
432
+ # Frame 2: Timeline (full year context)
433
+ timeline = Text()
434
+ timeline.append("\n\n\n")
435
+ timeline.append(" T I M E L I N E\n\n", style=Style(color=COLORS["orange"], bold=True))
436
+ timeline.append(" Period ", style=Style(color=COLORS["white"], bold=True))
437
+ # Use sentence case for "All time" in timeline
438
+ period_text = "All time" if stats.year is None else str(stats.year)
439
+ timeline.append(f"{period_text}\n", style=Style(color=COLORS["orange"], bold=True))
440
+ if stats.first_message_date:
441
+ timeline.append(" Journey started ", style=Style(color=COLORS["white"], bold=True))
442
+ timeline.append(f"{stats.first_message_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
443
+ # Calculate total days
444
+ today = datetime.now()
445
+ if stats.year is None:
446
+ # All-time: calculate days from first to last message
447
+ if stats.first_message_date and stats.last_message_date:
448
+ total_days = (stats.last_message_date - stats.first_message_date).days + 1
449
+ else:
450
+ total_days = stats.active_days
451
+ elif stats.year == today.year:
452
+ total_days = (today - datetime(stats.year, 1, 1)).days + 1
453
+ else:
454
+ total_days = 366 if stats.year % 4 == 0 else 365
455
+ timeline.append(f"\n Active days ", style=Style(color=COLORS["white"], bold=True))
456
+ timeline.append(f"{stats.active_days}", style=Style(color=COLORS["orange"], bold=True))
457
+ timeline.append(f" of {total_days}\n", style=Style(color=COLORS["gray"]))
458
+ if stats.most_active_hour is not None:
459
+ hour_label = "AM" if stats.most_active_hour < 12 else "PM"
460
+ hour_12 = stats.most_active_hour % 12 or 12
461
+ timeline.append(f" Peak hour ", style=Style(color=COLORS["white"], bold=True))
462
+ timeline.append(f"{hour_12}:00 {hour_label}\n", style=Style(color=COLORS["purple"], bold=True))
463
+ timeline.append("\n\n")
464
+ timeline.append(" [ENTER]", style=Style(color=COLORS["dark"]))
465
+ frames.append(timeline)
466
+
467
+ # Frame 3: Averages
468
+ from .pricing import format_cost
469
+ averages = Text()
470
+ averages.append("\n\n\n")
471
+ averages.append(" A V E R A G E S\n\n", style=Style(color=COLORS["blue"], bold=True))
472
+ averages.append(" Messages\n", style=Style(color=COLORS["white"], bold=True))
473
+ averages.append(f" Per day: {stats.avg_messages_per_day:.1f}\n", style=Style(color=COLORS["gray"]))
474
+ averages.append(f" Per week: {stats.avg_messages_per_week:.1f}\n", style=Style(color=COLORS["gray"]))
475
+ averages.append(f" Per month: {stats.avg_messages_per_month:.1f}\n", style=Style(color=COLORS["gray"]))
476
+ if stats.estimated_cost is not None:
477
+ averages.append("\n Cost\n", style=Style(color=COLORS["white"], bold=True))
478
+ averages.append(f" Per day: {format_cost(stats.avg_cost_per_day)}\n", style=Style(color=COLORS["gray"]))
479
+ averages.append(f" Per week: {format_cost(stats.avg_cost_per_week)}\n", style=Style(color=COLORS["gray"]))
480
+ averages.append(f" Per month: {format_cost(stats.avg_cost_per_month)}\n", style=Style(color=COLORS["gray"]))
481
+ averages.append("\n\n")
482
+ averages.append(" [ENTER]", style=Style(color=COLORS["dark"]))
483
+ frames.append(averages)
484
+
485
+ # Frame 4: Longest Streak (if significant)
486
+ if stats.streak_longest >= 3 and stats.streak_longest_start and stats.streak_longest_end:
487
+ streak = Text()
488
+ streak.append("\n\n\n")
489
+ streak.append(" L O N G E S T S T R E A K\n\n", style=Style(color=COLORS["blue"], bold=True))
490
+ streak.append(f" {stats.streak_longest}", style=Style(color=COLORS["blue"], bold=True))
491
+ streak.append(" days of consistent coding\n\n", style=Style(color=COLORS["white"], bold=True))
492
+ streak.append(f" From ", style=Style(color=COLORS["white"], bold=True))
493
+ streak.append(f"{stats.streak_longest_start.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
494
+ streak.append(f" To ", style=Style(color=COLORS["white"], bold=True))
495
+ streak.append(f"{stats.streak_longest_end.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
496
+ streak.append("\n Consistency is the key to mastery.\n", style=Style(color=COLORS["gray"]))
497
+ if stats.streak_current > 0:
498
+ streak.append(f"\n Current streak: {stats.streak_current} days\n", style=Style(color=COLORS["gray"]))
499
+ streak.append("\n\n")
500
+ streak.append(" [ENTER]", style=Style(color=COLORS["dark"]))
501
+ frames.append(streak)
502
+
503
+ # Frame 5: Longest Conversation
504
+ if stats.longest_conversation_messages > 0:
505
+ longest = Text()
506
+ longest.append("\n\n\n")
507
+ longest.append(" L O N G E S T C O N V E R S A T I O N\n\n", style=Style(color=COLORS["purple"], bold=True))
508
+ longest.append(f" Messages ", style=Style(color=COLORS["white"], bold=True))
509
+ longest.append(f"{stats.longest_conversation_messages:,}\n", style=Style(color=COLORS["purple"], bold=True))
510
+ if stats.longest_conversation_tokens > 0:
511
+ longest.append(f" Tokens ", style=Style(color=COLORS["white"], bold=True))
512
+ longest.append(f"{format_tokens(stats.longest_conversation_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
513
+ if stats.longest_conversation_date:
514
+ longest.append(f" Date ", style=Style(color=COLORS["white"], bold=True))
515
+ longest.append(f"{stats.longest_conversation_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
516
+ longest.append("\n That's one epic coding session!\n", style=Style(color=COLORS["gray"]))
517
+ longest.append("\n\n")
518
+ longest.append(" [ENTER]", style=Style(color=COLORS["dark"]))
519
+ frames.append(longest)
520
+
521
+ # Frame 6: Cast (models)
522
+ cast = Text()
523
+ cast.append("\n\n\n")
524
+ cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True))
525
+ for model, count in stats.models_used.most_common(3):
526
+ cast.append(f" Claude {model}", style=Style(color=COLORS["white"], bold=True))
527
+ cast.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
528
+ cast.append("\n\n\n")
529
+ cast.append(" [ENTER]", style=Style(color=COLORS["dark"]))
530
+ frames.append(cast)
531
+
532
+ # Frame 7: Projects
533
+ if stats.top_projects:
534
+ projects = Text()
535
+ projects.append("\n\n\n")
536
+ projects.append(" P R O J E C T S\n\n", style=Style(color=COLORS["blue"], bold=True))
537
+ for proj, count in stats.top_projects[:5]:
538
+ projects.append(f" {proj}", style=Style(color=COLORS["white"], bold=True))
539
+ projects.append(f" ({count:,} messages)\n", style=Style(color=COLORS["gray"]))
540
+ projects.append("\n\n\n")
541
+ projects.append(" [ENTER]", style=Style(color=COLORS["dark"]))
542
+ frames.append(projects)
543
+
544
+ # Frame 8: Final card
545
+ final = Text()
546
+ final.append("\n\n\n\n")
547
+ if stats.year is not None:
548
+ final.append(" See you in ", style=Style(color=COLORS["gray"]))
549
+ final.append(f"{stats.year + 1}", style=Style(color=COLORS["orange"], bold=True))
550
+ else:
551
+ final.append(" Keep coding!", style=Style(color=COLORS["orange"], bold=True))
552
+ final.append("\n\n\n\n", style=Style(color=COLORS["gray"]))
553
+ final.append(" ", style=Style())
554
+ final.append("Banker.so", style=Style(color=COLORS["blue"], bold=True, link="https://banker.so"))
555
+ final.append(" presents\n\n", style=Style(color=COLORS["gray"]))
556
+ final.append(" [ENTER] to exit", style=Style(color=COLORS["dark"]))
557
+ frames.append(final)
558
+
559
+ return frames
560
+
561
+
562
+ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate: bool = True):
563
+ """Render the complete wrapped experience."""
564
+ if console is None:
565
+ console = Console(style=Style(bgcolor="#2c2c2c"))
566
+
567
+ # === CINEMATIC MODE ===
568
+ if animate:
569
+ # Loading
570
+ console.clear()
571
+ loading_text = "Unwrapping your history..." if stats.year is None else "Unwrapping your year..."
572
+ with Progress(
573
+ SpinnerColumn(style=COLORS["orange"]),
574
+ TextColumn(f"[bold]{loading_text}[/bold]"),
575
+ BarColumn(complete_style=COLORS["orange"], finished_style=COLORS["green"]),
576
+ console=console,
577
+ transient=True,
578
+ ) as progress:
579
+ task = progress.add_task("", total=100)
580
+ for _ in range(100):
581
+ time.sleep(0.012)
582
+ progress.update(task, advance=1)
583
+
584
+ console.clear()
585
+
586
+ # Title slide - wait for keypress
587
+ console.print(Align.center(create_title_slide(stats.year)))
588
+ wait_for_keypress()
589
+ console.clear()
590
+
591
+ # Slide 1: Messages with date range
592
+ first_date = stats.first_message_date.strftime("%d %B") if stats.first_message_date else "the beginning"
593
+ last_date = stats.last_message_date.strftime("%d %B %Y") if stats.last_message_date else "today"
594
+ messages_subtitle = f"From {first_date} to {last_date}"
595
+ console.print(Align.center(create_dramatic_stat(
596
+ f"{stats.total_messages:,}", "MESSAGES", messages_subtitle, COLORS["orange"]
597
+ )))
598
+ wait_for_keypress()
599
+ console.clear()
600
+
601
+ # Slide 2: Averages
602
+ from .pricing import format_cost
603
+ averages_text = Text()
604
+ averages_text.append("\n\n\n\n")
605
+ averages_text.append("On average, you sent\n\n", style=Style(color=COLORS["gray"]))
606
+ averages_text.append(f"{stats.avg_messages_per_day:.0f}", style=Style(color=COLORS["orange"], bold=True))
607
+ averages_text.append(" messages per day\n", style=Style(color=COLORS["white"]))
608
+ averages_text.append(f"{stats.avg_messages_per_week:.0f}", style=Style(color=COLORS["blue"], bold=True))
609
+ averages_text.append(" messages per week\n", style=Style(color=COLORS["white"]))
610
+ averages_text.append(f"{stats.avg_messages_per_month:.0f}", style=Style(color=COLORS["purple"], bold=True))
611
+ averages_text.append(" messages per month\n\n", style=Style(color=COLORS["white"]))
612
+ if stats.estimated_cost is not None:
613
+ averages_text.append("Costing about ", style=Style(color=COLORS["gray"]))
614
+ averages_text.append(f"{format_cost(stats.avg_cost_per_day)}/day", style=Style(color=COLORS["green"], bold=True))
615
+ averages_text.append(f" · {format_cost(stats.avg_cost_per_week)}/week", style=Style(color=COLORS["green"]))
616
+ averages_text.append(f" · {format_cost(stats.avg_cost_per_month)}/month\n", style=Style(color=COLORS["green"]))
617
+ averages_text.append("\n\n\n")
618
+ averages_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
619
+ console.print(Align.center(averages_text))
620
+ wait_for_keypress()
621
+ console.clear()
622
+
623
+ # Slide 3: Tokens
624
+ def format_tokens_dramatic(tokens: int) -> str:
625
+ if tokens >= 1_000_000_000:
626
+ return f"{tokens / 1_000_000_000:.1f} Bn"
627
+ if tokens >= 1_000_000:
628
+ return f"{tokens / 1_000_000:.0f} M"
629
+ if tokens >= 1_000:
630
+ return f"{tokens / 1_000:.0f} K"
631
+ return str(tokens)
632
+
633
+ tokens_text = Text()
634
+ tokens_text.append("\n\n\n\n\n")
635
+ tokens_text.append("That's\n\n", style=Style(color=COLORS["gray"]))
636
+ tokens_text.append(f"{format_tokens_dramatic(stats.total_tokens)}\n", style=Style(color=COLORS["green"], bold=True))
637
+ tokens_text.append("TOKENS\n\n", style=Style(color=COLORS["white"], bold=True))
638
+ tokens_text.append("processed through the AI", style=Style(color=COLORS["gray"]))
639
+ tokens_text.append("\n\n\n\n")
640
+ tokens_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
641
+ console.print(Align.center(tokens_text))
642
+ wait_for_keypress()
643
+ console.clear()
644
+
645
+ # Slide 4: Streak + Personality (merged)
646
+ personality = determine_personality(stats)
647
+ streak_text = Text()
648
+ streak_text.append("\n\n\n\n")
649
+ streak_text.append(f"{stats.streak_longest}\n", style=Style(color=COLORS["blue"], bold=True))
650
+ streak_text.append("DAY STREAK\n\n", style=Style(color=COLORS["white"], bold=True))
651
+ streak_text.append(f"{personality['emoji']} ", style=Style(bold=True))
652
+ streak_text.append(f"{personality['title']}\n", style=Style(color=COLORS["purple"], bold=True))
653
+ streak_text.append(f"{personality['description']}\n", style=Style(color=COLORS["gray"]))
654
+ streak_text.append("\n\n\n")
655
+ streak_text.append("press [ENTER] to continue", style=Style(color=COLORS["dark"]))
656
+ console.print(Align.center(streak_text))
657
+ wait_for_keypress()
658
+ console.clear()
659
+
660
+ # === DASHBOARD VIEW ===
661
+ console.print()
662
+
663
+ # Header
664
+ header = Text()
665
+ header.append("═" * 60 + "\n", style=Style(color=COLORS["orange"]))
666
+ header.append(" CLAUDE CODE WRAPPED ", style=Style(color=COLORS["white"], bold=True))
667
+ header.append(format_year_display(stats.year), style=Style(color=COLORS["orange"], bold=True))
668
+ header.append("\n" + "═" * 60, style=Style(color=COLORS["orange"]))
669
+ console.print(Align.center(header))
670
+ console.print()
671
+
672
+ # Big stats row
673
+ stats_table = Table(show_header=False, box=None, padding=(0, 3), expand=True)
674
+ stats_table.add_column(justify="center")
675
+ stats_table.add_column(justify="center")
676
+ stats_table.add_column(justify="center")
677
+ stats_table.add_column(justify="center")
678
+
679
+ stats_table.add_row(
680
+ create_big_stat(f"{stats.total_messages:,}", "messages", COLORS["orange"]),
681
+ create_big_stat(str(stats.total_sessions), "sessions", COLORS["purple"]),
682
+ create_big_stat(format_tokens(stats.total_tokens), "tokens", COLORS["green"]),
683
+ create_big_stat(f"{stats.streak_longest}d", "best streak", COLORS["blue"]),
684
+ )
685
+ console.print(Align.center(stats_table))
686
+ console.print()
687
+
688
+ # Contribution graph
689
+ console.print(create_contribution_graph(stats.daily_stats, stats.year))
690
+
691
+ # Charts row
692
+ charts = Table(show_header=False, box=None, padding=(0, 1), expand=True)
693
+ charts.add_column(ratio=1)
694
+ charts.add_column(ratio=2)
695
+ charts.add_row(
696
+ create_personality_card(stats),
697
+ create_weekday_chart(stats.weekday_distribution),
698
+ )
699
+ console.print(charts)
700
+
701
+ # Hour chart
702
+ console.print(create_hour_chart(stats.hourly_distribution))
703
+
704
+ # Top lists
705
+ lists = Table(show_header=False, box=None, padding=(0, 1), expand=True)
706
+ lists.add_column(ratio=1)
707
+ lists.add_column(ratio=1)
708
+ lists.add_row(
709
+ create_top_list(stats.top_tools[:5], "Top Tools", COLORS["orange"]),
710
+ create_top_list(stats.top_projects, "Top Projects", COLORS["green"]),
711
+ )
712
+ console.print(lists)
713
+
714
+ # MCPs (if any)
715
+ if stats.top_mcps:
716
+ console.print(create_top_list(stats.top_mcps, "MCP Servers", COLORS["purple"]))
717
+
718
+ # Monthly cost table
719
+ if stats.monthly_costs:
720
+ console.print(create_monthly_cost_table(stats))
721
+
722
+ # Insights
723
+ insights = Text()
724
+ if stats.most_active_day:
725
+ insights.append(" Peak day: ", style=Style(color=COLORS["gray"]))
726
+ insights.append(f"{stats.most_active_day[0].strftime('%b %d')}", style=Style(color=COLORS["orange"], bold=True))
727
+ insights.append(f" ({stats.most_active_day[1]:,} msgs)", style=Style(color=COLORS["gray"]))
728
+ if stats.most_active_hour is not None:
729
+ insights.append(" • Peak hour: ", style=Style(color=COLORS["gray"]))
730
+ insights.append(f"{stats.most_active_hour}:00", style=Style(color=COLORS["purple"], bold=True))
731
+ if stats.primary_model:
732
+ insights.append(" • Favorite: ", style=Style(color=COLORS["gray"]))
733
+ insights.append(f"Claude {stats.primary_model}", style=Style(color=COLORS["blue"], bold=True))
734
+
735
+ console.print()
736
+ console.print(Align.center(insights))
737
+
738
+ # === CREDITS SEQUENCE ===
739
+ if animate:
740
+ console.print()
741
+ continue_text = Text()
742
+ continue_text.append("\n press [ENTER] for fun facts & credits", style=Style(color=COLORS["dark"]))
743
+ console.print(Align.center(continue_text))
744
+ wait_for_keypress()
745
+ console.clear()
746
+
747
+ # Fun facts
748
+ facts = get_fun_facts(stats)
749
+ if facts:
750
+ console.print(Align.center(create_fun_facts_slide(facts)))
751
+ wait_for_keypress()
752
+ console.clear()
753
+
754
+ # Credits roll
755
+ for frame in create_credits_roll(stats):
756
+ console.print(Align.center(frame))
757
+ wait_for_keypress()
758
+ console.clear()
759
+
760
+ # Final footer
761
+ console.print()
762
+ footer = Text()
763
+ footer.append("─" * 60 + "\n\n", style=Style(color=COLORS["dark"]))
764
+ footer.append("Thanks for building with Claude ", style=Style(color=COLORS["gray"]))
765
+ footer.append("✨\n\n", style=Style(color=COLORS["orange"]))
766
+ footer.append("Created by ", style=Style(color=COLORS["gray"]))
767
+ footer.append("Daniel Tollefsen", style=Style(color=COLORS["white"], bold=True, link="https://github.com/da-troll"))
768
+ footer.append(" · ", style=Style(color=COLORS["dark"]))
769
+ footer.append("github.com/da-troll/claude-wrapped", style=Style(color=COLORS["blue"], link="https://github.com/da-troll/claude-wrapped"))
770
+ footer.append("\n")
771
+ console.print(Align.center(footer))
772
+
773
+
774
+ if __name__ == "__main__":
775
+ from .reader import load_all_messages
776
+ from .stats import aggregate_stats
777
+
778
+ print("Loading data...")
779
+ messages = load_all_messages(year=2025)
780
+ stats = aggregate_stats(messages, 2025)
781
+
782
+ render_wrapped(stats)