@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.
- package/.env.example +42 -0
- package/.python-version +1 -0
- package/LICENSE +21 -0
- package/README.md +397 -0
- package/bin/cli.js +62 -0
- package/claude_code_wrapped/__init__.py +3 -0
- package/claude_code_wrapped/__main__.py +6 -0
- package/claude_code_wrapped/exporters/__init__.py +6 -0
- package/claude_code_wrapped/exporters/html_exporter.py +1032 -0
- package/claude_code_wrapped/exporters/markdown_exporter.py +462 -0
- package/claude_code_wrapped/interactive.py +166 -0
- package/claude_code_wrapped/main.py +203 -0
- package/claude_code_wrapped/pricing.py +179 -0
- package/claude_code_wrapped/reader.py +382 -0
- package/claude_code_wrapped/stats.py +520 -0
- package/claude_code_wrapped/ui.py +782 -0
- package/package.json +39 -0
- package/pyproject.toml +42 -0
- package/uv.lock +105 -0
|
@@ -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)
|