@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,1032 @@
|
|
|
1
|
+
"""HTML export for Claude Code Wrapped."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..stats import WrappedStats, format_tokens
|
|
7
|
+
from ..pricing import format_cost
|
|
8
|
+
from ..ui import COLORS, CONTRIB_COLORS, determine_personality, get_fun_facts, simplify_model_name, format_year_display
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_to_html(stats: WrappedStats, year: int | None, output_path: Path) -> None:
|
|
12
|
+
"""Export wrapped stats to a nicely formatted HTML file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
stats: Wrapped statistics dataclass
|
|
16
|
+
year: Year being wrapped
|
|
17
|
+
output_path: Path to output HTML file
|
|
18
|
+
"""
|
|
19
|
+
personality = determine_personality(stats)
|
|
20
|
+
fun_facts = get_fun_facts(stats)
|
|
21
|
+
|
|
22
|
+
# Calculate date range
|
|
23
|
+
if year is None:
|
|
24
|
+
# All-time: use first and last message dates
|
|
25
|
+
start_date = stats.first_message_date or datetime.now()
|
|
26
|
+
end_date = stats.last_message_date or datetime.now()
|
|
27
|
+
else:
|
|
28
|
+
start_date = datetime(year, 1, 1)
|
|
29
|
+
today = datetime.now()
|
|
30
|
+
end_date = today if year == today.year else datetime(year, 12, 31)
|
|
31
|
+
|
|
32
|
+
# Build HTML
|
|
33
|
+
html = _build_html_document(stats, year, personality, fun_facts, start_date, end_date)
|
|
34
|
+
|
|
35
|
+
# Write to file
|
|
36
|
+
output_path.write_text(html, encoding='utf-8')
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _build_html_document(stats: WrappedStats, year: int, personality: dict,
|
|
40
|
+
fun_facts: list, start_date: datetime, end_date: datetime) -> str:
|
|
41
|
+
"""Build the complete HTML document."""
|
|
42
|
+
|
|
43
|
+
return f"""<!DOCTYPE html>
|
|
44
|
+
<html lang="en">
|
|
45
|
+
<head>
|
|
46
|
+
<meta charset="UTF-8">
|
|
47
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
48
|
+
<title>Claude Code Wrapped {year}</title>
|
|
49
|
+
{_get_css()}
|
|
50
|
+
</head>
|
|
51
|
+
<body>
|
|
52
|
+
<div class="container">
|
|
53
|
+
{_build_title_section(year)}
|
|
54
|
+
{_build_dramatic_reveals(stats, start_date, end_date)}
|
|
55
|
+
{_build_dashboard(stats, year, personality, fun_facts)}
|
|
56
|
+
{_build_credits(stats, year)}
|
|
57
|
+
</div>
|
|
58
|
+
</body>
|
|
59
|
+
</html>"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_css() -> str:
|
|
63
|
+
"""Get embedded CSS styles."""
|
|
64
|
+
return f"""<style>
|
|
65
|
+
* {{
|
|
66
|
+
margin: 0;
|
|
67
|
+
padding: 0;
|
|
68
|
+
box-sizing: border-box;
|
|
69
|
+
}}
|
|
70
|
+
|
|
71
|
+
:root {{
|
|
72
|
+
--orange: {COLORS["orange"]};
|
|
73
|
+
--purple: {COLORS["purple"]};
|
|
74
|
+
--blue: {COLORS["blue"]};
|
|
75
|
+
--green: {COLORS["green"]};
|
|
76
|
+
--white: {COLORS["white"]};
|
|
77
|
+
--gray: {COLORS["gray"]};
|
|
78
|
+
--dark: {COLORS["dark"]};
|
|
79
|
+
--bg: #0D1117;
|
|
80
|
+
--fg: #E6EDF3;
|
|
81
|
+
}}
|
|
82
|
+
|
|
83
|
+
body {{
|
|
84
|
+
background: var(--bg);
|
|
85
|
+
color: var(--fg);
|
|
86
|
+
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
|
87
|
+
line-height: 1.6;
|
|
88
|
+
padding: 20px;
|
|
89
|
+
}}
|
|
90
|
+
|
|
91
|
+
.container {{
|
|
92
|
+
max-width: 1200px;
|
|
93
|
+
margin: 0 auto;
|
|
94
|
+
}}
|
|
95
|
+
|
|
96
|
+
.section {{
|
|
97
|
+
margin: 80px 0;
|
|
98
|
+
padding: 40px;
|
|
99
|
+
background: #161B22;
|
|
100
|
+
border-radius: 12px;
|
|
101
|
+
border: 1px solid #30363D;
|
|
102
|
+
}}
|
|
103
|
+
|
|
104
|
+
.section-title {{
|
|
105
|
+
text-align: center;
|
|
106
|
+
font-size: 2.5em;
|
|
107
|
+
font-weight: bold;
|
|
108
|
+
margin-bottom: 40px;
|
|
109
|
+
letter-spacing: 0.2em;
|
|
110
|
+
}}
|
|
111
|
+
|
|
112
|
+
/* Title Section */
|
|
113
|
+
.title-section {{
|
|
114
|
+
text-align: center;
|
|
115
|
+
padding: 100px 40px;
|
|
116
|
+
}}
|
|
117
|
+
|
|
118
|
+
.title-logo {{
|
|
119
|
+
font-size: 3em;
|
|
120
|
+
font-weight: bold;
|
|
121
|
+
color: var(--purple);
|
|
122
|
+
margin-bottom: 20px;
|
|
123
|
+
}}
|
|
124
|
+
|
|
125
|
+
.title-year {{
|
|
126
|
+
font-size: 2em;
|
|
127
|
+
color: var(--orange);
|
|
128
|
+
margin: 20px 0;
|
|
129
|
+
}}
|
|
130
|
+
|
|
131
|
+
.title-credit {{
|
|
132
|
+
color: var(--gray);
|
|
133
|
+
margin-top: 40px;
|
|
134
|
+
}}
|
|
135
|
+
|
|
136
|
+
/* Dramatic Reveals */
|
|
137
|
+
.reveal {{
|
|
138
|
+
text-align: center;
|
|
139
|
+
padding: 60px 40px;
|
|
140
|
+
}}
|
|
141
|
+
|
|
142
|
+
.reveal-value {{
|
|
143
|
+
font-size: 4em;
|
|
144
|
+
font-weight: bold;
|
|
145
|
+
margin: 20px 0;
|
|
146
|
+
}}
|
|
147
|
+
|
|
148
|
+
.reveal-label {{
|
|
149
|
+
font-size: 1.8em;
|
|
150
|
+
font-weight: bold;
|
|
151
|
+
color: var(--white);
|
|
152
|
+
letter-spacing: 0.1em;
|
|
153
|
+
}}
|
|
154
|
+
|
|
155
|
+
.reveal-subtitle {{
|
|
156
|
+
color: var(--gray);
|
|
157
|
+
margin-top: 10px;
|
|
158
|
+
}}
|
|
159
|
+
|
|
160
|
+
.reveal-extra {{
|
|
161
|
+
margin-top: 30px;
|
|
162
|
+
font-size: 1.1em;
|
|
163
|
+
}}
|
|
164
|
+
|
|
165
|
+
/* Dashboard Grid */
|
|
166
|
+
.dashboard-grid {{
|
|
167
|
+
display: grid;
|
|
168
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
169
|
+
gap: 30px;
|
|
170
|
+
margin-top: 40px;
|
|
171
|
+
}}
|
|
172
|
+
|
|
173
|
+
.panel {{
|
|
174
|
+
background: #0D1117;
|
|
175
|
+
border: 1px solid;
|
|
176
|
+
border-radius: 8px;
|
|
177
|
+
padding: 20px;
|
|
178
|
+
}}
|
|
179
|
+
|
|
180
|
+
.panel-title {{
|
|
181
|
+
font-weight: bold;
|
|
182
|
+
margin-bottom: 15px;
|
|
183
|
+
font-size: 1.1em;
|
|
184
|
+
}}
|
|
185
|
+
|
|
186
|
+
/* Stats Table */
|
|
187
|
+
.stats-table {{
|
|
188
|
+
width: 100%;
|
|
189
|
+
border-collapse: collapse;
|
|
190
|
+
margin: 20px 0;
|
|
191
|
+
}}
|
|
192
|
+
|
|
193
|
+
.stats-table th {{
|
|
194
|
+
padding: 15px;
|
|
195
|
+
text-align: center;
|
|
196
|
+
font-size: 0.9em;
|
|
197
|
+
color: var(--gray);
|
|
198
|
+
border-bottom: 1px solid #30363D;
|
|
199
|
+
}}
|
|
200
|
+
|
|
201
|
+
.stats-table td {{
|
|
202
|
+
padding: 15px;
|
|
203
|
+
text-align: center;
|
|
204
|
+
font-size: 1.5em;
|
|
205
|
+
font-weight: bold;
|
|
206
|
+
}}
|
|
207
|
+
|
|
208
|
+
/* Personality Card */
|
|
209
|
+
.personality {{
|
|
210
|
+
text-align: center;
|
|
211
|
+
padding: 30px;
|
|
212
|
+
}}
|
|
213
|
+
|
|
214
|
+
.personality-emoji {{
|
|
215
|
+
font-size: 4em;
|
|
216
|
+
margin-bottom: 15px;
|
|
217
|
+
}}
|
|
218
|
+
|
|
219
|
+
.personality-title {{
|
|
220
|
+
font-size: 1.5em;
|
|
221
|
+
font-weight: bold;
|
|
222
|
+
color: var(--purple);
|
|
223
|
+
margin-bottom: 10px;
|
|
224
|
+
}}
|
|
225
|
+
|
|
226
|
+
.personality-desc {{
|
|
227
|
+
color: var(--gray);
|
|
228
|
+
}}
|
|
229
|
+
|
|
230
|
+
/* Bar Charts */
|
|
231
|
+
.bar-chart {{
|
|
232
|
+
margin: 10px 0;
|
|
233
|
+
}}
|
|
234
|
+
|
|
235
|
+
.bar-item {{
|
|
236
|
+
display: flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
margin: 8px 0;
|
|
239
|
+
}}
|
|
240
|
+
|
|
241
|
+
.bar-label {{
|
|
242
|
+
width: 120px;
|
|
243
|
+
text-align: right;
|
|
244
|
+
padding-right: 10px;
|
|
245
|
+
color: var(--gray);
|
|
246
|
+
}}
|
|
247
|
+
|
|
248
|
+
.bar {{
|
|
249
|
+
height: 20px;
|
|
250
|
+
background: var(--purple);
|
|
251
|
+
border-radius: 3px;
|
|
252
|
+
transition: width 0.3s;
|
|
253
|
+
}}
|
|
254
|
+
|
|
255
|
+
.bar-value {{
|
|
256
|
+
margin-left: 10px;
|
|
257
|
+
color: var(--white);
|
|
258
|
+
}}
|
|
259
|
+
|
|
260
|
+
/* Monthly Cost Table */
|
|
261
|
+
.cost-table {{
|
|
262
|
+
width: 100%;
|
|
263
|
+
border-collapse: collapse;
|
|
264
|
+
margin: 20px 0;
|
|
265
|
+
}}
|
|
266
|
+
|
|
267
|
+
.cost-table th {{
|
|
268
|
+
padding: 12px;
|
|
269
|
+
text-align: left;
|
|
270
|
+
border-bottom: 2px solid #30363D;
|
|
271
|
+
font-weight: bold;
|
|
272
|
+
}}
|
|
273
|
+
|
|
274
|
+
.cost-table td {{
|
|
275
|
+
padding: 12px;
|
|
276
|
+
border-bottom: 1px solid #30363D;
|
|
277
|
+
}}
|
|
278
|
+
|
|
279
|
+
.cost-table tr:last-child td {{
|
|
280
|
+
border-bottom: none;
|
|
281
|
+
font-weight: bold;
|
|
282
|
+
}}
|
|
283
|
+
|
|
284
|
+
/* Fun Facts */
|
|
285
|
+
.fun-facts {{
|
|
286
|
+
margin: 30px 0;
|
|
287
|
+
}}
|
|
288
|
+
|
|
289
|
+
.fun-fact {{
|
|
290
|
+
margin: 15px 0;
|
|
291
|
+
padding: 15px;
|
|
292
|
+
background: #0D1117;
|
|
293
|
+
border-left: 3px solid var(--purple);
|
|
294
|
+
border-radius: 4px;
|
|
295
|
+
}}
|
|
296
|
+
|
|
297
|
+
.fun-fact-emoji {{
|
|
298
|
+
font-size: 1.5em;
|
|
299
|
+
margin-right: 10px;
|
|
300
|
+
}}
|
|
301
|
+
|
|
302
|
+
/* Credits */
|
|
303
|
+
.credits-frame {{
|
|
304
|
+
text-align: center;
|
|
305
|
+
padding: 60px 40px;
|
|
306
|
+
margin: 40px 0;
|
|
307
|
+
}}
|
|
308
|
+
|
|
309
|
+
.credits-title {{
|
|
310
|
+
font-size: 2em;
|
|
311
|
+
font-weight: bold;
|
|
312
|
+
letter-spacing: 0.3em;
|
|
313
|
+
margin-bottom: 30px;
|
|
314
|
+
}}
|
|
315
|
+
|
|
316
|
+
.credits-item {{
|
|
317
|
+
margin: 20px 0;
|
|
318
|
+
}}
|
|
319
|
+
|
|
320
|
+
.credits-label {{
|
|
321
|
+
color: var(--white);
|
|
322
|
+
font-weight: bold;
|
|
323
|
+
margin-right: 10px;
|
|
324
|
+
}}
|
|
325
|
+
|
|
326
|
+
.credits-value {{
|
|
327
|
+
font-weight: bold;
|
|
328
|
+
font-size: 1.2em;
|
|
329
|
+
}}
|
|
330
|
+
|
|
331
|
+
.credits-subitem {{
|
|
332
|
+
color: var(--gray);
|
|
333
|
+
margin-left: 40px;
|
|
334
|
+
}}
|
|
335
|
+
|
|
336
|
+
/* Responsive */
|
|
337
|
+
@media (max-width: 768px) {{
|
|
338
|
+
.reveal-value {{
|
|
339
|
+
font-size: 2.5em;
|
|
340
|
+
}}
|
|
341
|
+
|
|
342
|
+
.section-title {{
|
|
343
|
+
font-size: 1.8em;
|
|
344
|
+
}}
|
|
345
|
+
|
|
346
|
+
.dashboard-grid {{
|
|
347
|
+
grid-template-columns: 1fr;
|
|
348
|
+
}}
|
|
349
|
+
}}
|
|
350
|
+
|
|
351
|
+
/* Print Styles */
|
|
352
|
+
@media print {{
|
|
353
|
+
body {{
|
|
354
|
+
background: white;
|
|
355
|
+
color: black;
|
|
356
|
+
}}
|
|
357
|
+
|
|
358
|
+
.section {{
|
|
359
|
+
page-break-inside: avoid;
|
|
360
|
+
border: 1px solid #ccc;
|
|
361
|
+
}}
|
|
362
|
+
}}
|
|
363
|
+
</style>"""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _build_title_section(year: int | None) -> str:
|
|
367
|
+
"""Build the title section."""
|
|
368
|
+
year_display = format_year_display(year)
|
|
369
|
+
return f"""
|
|
370
|
+
<div class="section title-section">
|
|
371
|
+
<div class="title-logo">🎬 CLAUDE CODE WRAPPED</div>
|
|
372
|
+
<div class="title-year">Your {year_display}</div>
|
|
373
|
+
<div class="title-credit">
|
|
374
|
+
A year in review · Generated {datetime.now().strftime('%B %d, %Y')}
|
|
375
|
+
</div>
|
|
376
|
+
</div>"""
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _build_dramatic_reveals(stats: WrappedStats, start_date: datetime, end_date: datetime) -> str:
|
|
380
|
+
"""Build the dramatic reveal sections."""
|
|
381
|
+
date_range = f"{start_date.strftime('%B %d')} - {end_date.strftime('%B %d, %Y')}"
|
|
382
|
+
|
|
383
|
+
from ..pricing import format_cost
|
|
384
|
+
|
|
385
|
+
reveals = f"""
|
|
386
|
+
<div class="section reveal">
|
|
387
|
+
<div class="reveal-value" style="color: var(--orange);">{stats.total_messages:,}</div>
|
|
388
|
+
<div class="reveal-label">TOTAL MESSAGES</div>
|
|
389
|
+
<div class="reveal-subtitle">{date_range}</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
<div class="section reveal">
|
|
393
|
+
<div class="reveal-label">YOUR AVERAGES</div>
|
|
394
|
+
<div class="reveal-extra">
|
|
395
|
+
<div style="color: var(--blue); margin: 10px 0;">
|
|
396
|
+
<strong>{stats.avg_messages_per_day:.1f}</strong> messages per day
|
|
397
|
+
</div>
|
|
398
|
+
<div style="color: var(--purple); margin: 10px 0;">
|
|
399
|
+
<strong>{stats.avg_messages_per_week:.1f}</strong> messages per week
|
|
400
|
+
</div>
|
|
401
|
+
<div style="color: var(--orange); margin: 10px 0;">
|
|
402
|
+
<strong>{stats.avg_messages_per_month:.1f}</strong> messages per month
|
|
403
|
+
</div>"""
|
|
404
|
+
|
|
405
|
+
if stats.estimated_cost is not None:
|
|
406
|
+
reveals += f"""
|
|
407
|
+
<div style="margin-top: 30px; color: var(--gray);">━━━━━━━━━━━━━━━━</div>
|
|
408
|
+
<div style="color: var(--green); margin: 10px 0;">
|
|
409
|
+
<strong>{format_cost(stats.avg_cost_per_day)}</strong> per day
|
|
410
|
+
</div>
|
|
411
|
+
<div style="color: var(--green); margin: 10px 0;">
|
|
412
|
+
<strong>{format_cost(stats.avg_cost_per_week)}</strong> per week
|
|
413
|
+
</div>
|
|
414
|
+
<div style="color: var(--green); margin: 10px 0;">
|
|
415
|
+
<strong>{format_cost(stats.avg_cost_per_month)}</strong> per month
|
|
416
|
+
</div>"""
|
|
417
|
+
|
|
418
|
+
reveals += """
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<div class="section reveal">
|
|
423
|
+
<div class="reveal-value" style="color: var(--green);">{:,}</div>
|
|
424
|
+
<div class="reveal-label">TOTAL TOKENS</div>
|
|
425
|
+
<div class="reveal-subtitle">
|
|
426
|
+
{}<br>
|
|
427
|
+
<span style="color: var(--gray);">
|
|
428
|
+
Input: {} · Output: {}
|
|
429
|
+
</span>
|
|
430
|
+
</div>
|
|
431
|
+
</div>""".format(
|
|
432
|
+
stats.total_tokens,
|
|
433
|
+
format_tokens(stats.total_tokens),
|
|
434
|
+
format_tokens(stats.total_input_tokens),
|
|
435
|
+
format_tokens(stats.total_output_tokens)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return reveals
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _build_dashboard(stats: WrappedStats, year: int | None, personality: dict, fun_facts: list) -> str:
|
|
442
|
+
"""Build the main dashboard section."""
|
|
443
|
+
|
|
444
|
+
year_display = format_year_display(year).upper()
|
|
445
|
+
html = f"""
|
|
446
|
+
<div class="section">
|
|
447
|
+
<div class="section-title" style="color: var(--purple);">YOUR {year_display} DASHBOARD</div>
|
|
448
|
+
|
|
449
|
+
<table class="stats-table">
|
|
450
|
+
<thead>
|
|
451
|
+
<tr>
|
|
452
|
+
<th>Messages</th>
|
|
453
|
+
<th>Sessions</th>
|
|
454
|
+
<th>Tokens</th>
|
|
455
|
+
<th>Streak</th>
|
|
456
|
+
</tr>
|
|
457
|
+
</thead>
|
|
458
|
+
<tbody>
|
|
459
|
+
<tr>
|
|
460
|
+
<td style="color: var(--orange);">{stats.total_messages:,}</td>
|
|
461
|
+
<td style="color: var(--purple);">{stats.total_sessions:,}</td>
|
|
462
|
+
<td style="color: var(--green);">{format_tokens(stats.total_tokens)}</td>
|
|
463
|
+
<td style="color: var(--blue);">{stats.streak_longest}</td>
|
|
464
|
+
</tr>
|
|
465
|
+
</tbody>
|
|
466
|
+
</table>
|
|
467
|
+
|
|
468
|
+
{_build_contribution_graph(stats.daily_stats, year)}
|
|
469
|
+
|
|
470
|
+
<div class="dashboard-grid">
|
|
471
|
+
<div class="panel personality" style="border-color: var(--purple);">
|
|
472
|
+
<div class="personality-emoji">{personality['emoji']}</div>
|
|
473
|
+
<div class="personality-title">{personality['title']}</div>
|
|
474
|
+
<div class="personality-desc">{personality['description']}</div>
|
|
475
|
+
</div>
|
|
476
|
+
|
|
477
|
+
<div class="panel" style="border-color: var(--blue);">
|
|
478
|
+
<div class="panel-title" style="color: var(--blue);">Weekday Activity</div>
|
|
479
|
+
{_build_weekday_chart(stats.weekday_distribution)}
|
|
480
|
+
</div>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<div style="margin-top: 40px;">
|
|
484
|
+
<div class="panel" style="border-color: var(--orange);">
|
|
485
|
+
<div class="panel-title" style="color: var(--orange);">Hourly Activity</div>
|
|
486
|
+
{_build_hourly_chart(stats.hourly_distribution)}
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
|
|
490
|
+
{_build_tools_and_projects(stats)}
|
|
491
|
+
{_build_mcp_section(stats)}
|
|
492
|
+
{_build_monthly_costs(stats)}
|
|
493
|
+
{_build_fun_facts_section(fun_facts)}
|
|
494
|
+
</div>"""
|
|
495
|
+
|
|
496
|
+
return html
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _build_contribution_graph(daily_stats: dict, year: int | None) -> str:
|
|
500
|
+
"""Build SVG contribution graph."""
|
|
501
|
+
if not daily_stats:
|
|
502
|
+
return '<div style="text-align: center; color: var(--gray); padding: 40px;">No activity data</div>'
|
|
503
|
+
|
|
504
|
+
# Calculate date range
|
|
505
|
+
if year is None:
|
|
506
|
+
# All-time: use actual date range from daily_stats
|
|
507
|
+
dates = [datetime.strptime(d, "%Y-%m-%d") for d in daily_stats.keys()]
|
|
508
|
+
start_date = min(dates) if dates else datetime.now()
|
|
509
|
+
end_date = max(dates) if dates else datetime.now()
|
|
510
|
+
else:
|
|
511
|
+
start_date = datetime(year, 1, 1)
|
|
512
|
+
today = datetime.now()
|
|
513
|
+
end_date = today if year == today.year else datetime(year, 12, 31)
|
|
514
|
+
|
|
515
|
+
# Calculate max count for color scaling
|
|
516
|
+
max_count = max(s.message_count for s in daily_stats.values()) if daily_stats else 1
|
|
517
|
+
|
|
518
|
+
# Build weeks grid
|
|
519
|
+
weeks = []
|
|
520
|
+
current = start_date - timedelta(days=start_date.weekday())
|
|
521
|
+
|
|
522
|
+
while current <= end_date + timedelta(days=7):
|
|
523
|
+
week = []
|
|
524
|
+
for day in range(7):
|
|
525
|
+
date = current + timedelta(days=day)
|
|
526
|
+
date_str = date.strftime("%Y-%m-%d")
|
|
527
|
+
|
|
528
|
+
if date < start_date or date > end_date:
|
|
529
|
+
week.append(None)
|
|
530
|
+
elif date_str in daily_stats:
|
|
531
|
+
count = daily_stats[date_str].message_count
|
|
532
|
+
level = min(4, 1 + int((count / max_count) * 3)) if count > 0 else 0
|
|
533
|
+
week.append((level, count, date_str))
|
|
534
|
+
else:
|
|
535
|
+
week.append((0, 0, date_str))
|
|
536
|
+
|
|
537
|
+
weeks.append(week)
|
|
538
|
+
current += timedelta(days=7)
|
|
539
|
+
|
|
540
|
+
# SVG dimensions
|
|
541
|
+
cell_size = 12
|
|
542
|
+
cell_gap = 3
|
|
543
|
+
label_width = 40
|
|
544
|
+
graph_width = len(weeks) * (cell_size + cell_gap) + label_width
|
|
545
|
+
graph_height = 7 * (cell_size + cell_gap) + 40
|
|
546
|
+
|
|
547
|
+
# Build SVG
|
|
548
|
+
svg = f'<svg width="{graph_width}" height="{graph_height}" style="margin: 40px auto; display: block;">\n'
|
|
549
|
+
|
|
550
|
+
# Day labels
|
|
551
|
+
days_labels = ["Mon", "", "Wed", "", "Fri", "", ""]
|
|
552
|
+
for i, label in enumerate(days_labels):
|
|
553
|
+
if label:
|
|
554
|
+
y = i * (cell_size + cell_gap) + cell_size
|
|
555
|
+
svg += f'<text x="0" y="{y}" fill="{COLORS["gray"]}" font-size="10">{label}</text>\n'
|
|
556
|
+
|
|
557
|
+
# Cells
|
|
558
|
+
for week_idx, week in enumerate(weeks):
|
|
559
|
+
for day_idx, cell in enumerate(week):
|
|
560
|
+
if cell is None:
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
level, count, date_str = cell
|
|
564
|
+
x = label_width + week_idx * (cell_size + cell_gap)
|
|
565
|
+
y = day_idx * (cell_size + cell_gap)
|
|
566
|
+
color = CONTRIB_COLORS[level]
|
|
567
|
+
|
|
568
|
+
svg += f'<rect x="{x}" y="{y}" width="{cell_size}" height="{cell_size}" fill="{color}" rx="2">\n'
|
|
569
|
+
svg += f'<title>{date_str}: {count} messages</title>\n'
|
|
570
|
+
svg += '</rect>\n'
|
|
571
|
+
|
|
572
|
+
# Legend
|
|
573
|
+
legend_y = graph_height - 20
|
|
574
|
+
legend_x = label_width
|
|
575
|
+
svg += f'<text x="{legend_x}" y="{legend_y}" fill="{COLORS["gray"]}" font-size="10">Less</text>\n'
|
|
576
|
+
|
|
577
|
+
for i, color in enumerate(CONTRIB_COLORS):
|
|
578
|
+
x = legend_x + 40 + i * (cell_size + cell_gap)
|
|
579
|
+
svg += f'<rect x="{x}" y="{legend_y - 10}" width="{cell_size}" height="{cell_size}" fill="{color}" rx="2"></rect>\n'
|
|
580
|
+
|
|
581
|
+
svg += f'<text x="{legend_x + 40 + len(CONTRIB_COLORS) * (cell_size + cell_gap) + 5}" y="{legend_y}" fill="{COLORS["gray"]}" font-size="10">More</text>\n'
|
|
582
|
+
|
|
583
|
+
svg += '</svg>'
|
|
584
|
+
|
|
585
|
+
# Activity count
|
|
586
|
+
active_count = len([d for d in daily_stats.values() if d.message_count > 0])
|
|
587
|
+
total_days = (end_date - start_date).days + 1
|
|
588
|
+
|
|
589
|
+
return f'''
|
|
590
|
+
<div style="margin: 40px 0;">
|
|
591
|
+
<div style="text-align: center; color: var(--green); font-weight: bold; margin-bottom: 20px;">
|
|
592
|
+
Activity · {active_count} of {total_days} days
|
|
593
|
+
</div>
|
|
594
|
+
{svg}
|
|
595
|
+
</div>'''
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _build_weekday_chart(weekday_dist: list[int]) -> str:
|
|
599
|
+
"""Build weekday activity bar chart."""
|
|
600
|
+
days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
601
|
+
max_val = max(weekday_dist) if weekday_dist else 1
|
|
602
|
+
|
|
603
|
+
html = '<div class="bar-chart">'
|
|
604
|
+
for i, (day, count) in enumerate(zip(days, weekday_dist)):
|
|
605
|
+
width = int((count / max_val) * 100) if max_val > 0 else 0
|
|
606
|
+
html += f'''
|
|
607
|
+
<div class="bar-item">
|
|
608
|
+
<div class="bar-label">{day}</div>
|
|
609
|
+
<div class="bar" style="width: {width}%; background: var(--blue);"></div>
|
|
610
|
+
<div class="bar-value">{count:,}</div>
|
|
611
|
+
</div>'''
|
|
612
|
+
html += '</div>'
|
|
613
|
+
return html
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _build_hourly_chart(hourly_dist: list[int]) -> str:
|
|
617
|
+
"""Build hourly activity chart."""
|
|
618
|
+
max_val = max(hourly_dist) if hourly_dist else 1
|
|
619
|
+
|
|
620
|
+
html = '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(60px, 1fr)); gap: 10px; margin-top: 20px;">'
|
|
621
|
+
|
|
622
|
+
for hour, count in enumerate(hourly_dist):
|
|
623
|
+
# Determine color by time of day
|
|
624
|
+
if 6 <= hour < 12:
|
|
625
|
+
color = "var(--orange)"
|
|
626
|
+
elif 12 <= hour < 18:
|
|
627
|
+
color = "var(--blue)"
|
|
628
|
+
elif 18 <= hour < 22:
|
|
629
|
+
color = "var(--purple)"
|
|
630
|
+
else:
|
|
631
|
+
color = "var(--gray)"
|
|
632
|
+
|
|
633
|
+
height = int((count / max_val) * 100) if max_val > 0 else 0
|
|
634
|
+
|
|
635
|
+
html += f'''
|
|
636
|
+
<div style="text-align: center;">
|
|
637
|
+
<div style="height: 100px; display: flex; align-items: flex-end; justify-content: center;">
|
|
638
|
+
<div style="width: 100%; height: {height}%; background: {color}; border-radius: 3px;" title="{hour}:00 - {count:,} messages"></div>
|
|
639
|
+
</div>
|
|
640
|
+
<div style="font-size: 0.8em; color: var(--gray); margin-top: 5px;">{hour}</div>
|
|
641
|
+
</div>'''
|
|
642
|
+
|
|
643
|
+
html += '</div>'
|
|
644
|
+
return html
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _build_tools_and_projects(stats: WrappedStats) -> str:
|
|
648
|
+
"""Build tools and projects panels."""
|
|
649
|
+
html = '<div class="dashboard-grid" style="margin-top: 40px;">'
|
|
650
|
+
|
|
651
|
+
# Top Tools
|
|
652
|
+
if stats.top_tools:
|
|
653
|
+
max_tool_count = stats.top_tools[0][1] if stats.top_tools else 1
|
|
654
|
+
html += '''
|
|
655
|
+
<div class="panel" style="border-color: var(--purple);">
|
|
656
|
+
<div class="panel-title" style="color: var(--purple);">Top Tools</div>
|
|
657
|
+
<div class="bar-chart">'''
|
|
658
|
+
|
|
659
|
+
for tool, count in stats.top_tools[:5]:
|
|
660
|
+
width = int((count / max_tool_count) * 100)
|
|
661
|
+
html += f'''
|
|
662
|
+
<div class="bar-item">
|
|
663
|
+
<div class="bar-label">{tool}</div>
|
|
664
|
+
<div class="bar" style="width: {width}%; background: var(--purple);"></div>
|
|
665
|
+
<div class="bar-value">{count:,}</div>
|
|
666
|
+
</div>'''
|
|
667
|
+
|
|
668
|
+
html += '</div></div>'
|
|
669
|
+
|
|
670
|
+
# Top Projects
|
|
671
|
+
if stats.top_projects:
|
|
672
|
+
max_proj_count = stats.top_projects[0][1] if stats.top_projects else 1
|
|
673
|
+
html += '''
|
|
674
|
+
<div class="panel" style="border-color: var(--blue);">
|
|
675
|
+
<div class="panel-title" style="color: var(--blue);">Top Projects</div>
|
|
676
|
+
<div class="bar-chart">'''
|
|
677
|
+
|
|
678
|
+
for proj, count in stats.top_projects[:5]:
|
|
679
|
+
width = int((count / max_proj_count) * 100)
|
|
680
|
+
# Truncate long project names
|
|
681
|
+
display_name = proj if len(proj) <= 20 else proj[:17] + "..."
|
|
682
|
+
html += f'''
|
|
683
|
+
<div class="bar-item">
|
|
684
|
+
<div class="bar-label" title="{proj}">{display_name}</div>
|
|
685
|
+
<div class="bar" style="width: {width}%; background: var(--blue);"></div>
|
|
686
|
+
<div class="bar-value">{count:,}</div>
|
|
687
|
+
</div>'''
|
|
688
|
+
|
|
689
|
+
html += '</div></div>'
|
|
690
|
+
|
|
691
|
+
html += '</div>'
|
|
692
|
+
return html
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _build_mcp_section(stats: WrappedStats) -> str:
|
|
696
|
+
"""Build MCP servers section if any."""
|
|
697
|
+
if not stats.top_mcps:
|
|
698
|
+
return ""
|
|
699
|
+
|
|
700
|
+
max_mcp_count = stats.top_mcps[0][1] if stats.top_mcps else 1
|
|
701
|
+
|
|
702
|
+
html = '''
|
|
703
|
+
<div style="margin-top: 40px;">
|
|
704
|
+
<div class="panel" style="border-color: var(--green);">
|
|
705
|
+
<div class="panel-title" style="color: var(--green);">MCP Servers</div>
|
|
706
|
+
<div class="bar-chart">'''
|
|
707
|
+
|
|
708
|
+
for mcp, count in stats.top_mcps[:3]:
|
|
709
|
+
width = int((count / max_mcp_count) * 100)
|
|
710
|
+
html += f'''
|
|
711
|
+
<div class="bar-item">
|
|
712
|
+
<div class="bar-label">{mcp}</div>
|
|
713
|
+
<div class="bar" style="width: {width}%; background: var(--green);"></div>
|
|
714
|
+
<div class="bar-value">{count:,}</div>
|
|
715
|
+
</div>'''
|
|
716
|
+
|
|
717
|
+
html += '</div></div></div>'
|
|
718
|
+
return html
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _build_monthly_costs(stats: WrappedStats) -> str:
|
|
722
|
+
"""Build monthly cost breakdown table."""
|
|
723
|
+
if not stats.monthly_costs:
|
|
724
|
+
return ""
|
|
725
|
+
|
|
726
|
+
html = '''
|
|
727
|
+
<div style="margin-top: 40px;">
|
|
728
|
+
<div class="panel" style="border-color: var(--green);">
|
|
729
|
+
<div class="panel-title" style="color: var(--green);">Monthly Cost Breakdown</div>
|
|
730
|
+
<table class="cost-table">
|
|
731
|
+
<thead>
|
|
732
|
+
<tr>
|
|
733
|
+
<th>Month</th>
|
|
734
|
+
<th style="color: var(--blue);">Input</th>
|
|
735
|
+
<th style="color: var(--orange);">Output</th>
|
|
736
|
+
<th style="color: var(--purple);">Cache</th>
|
|
737
|
+
<th style="color: var(--green);">Cost</th>
|
|
738
|
+
</tr>
|
|
739
|
+
</thead>
|
|
740
|
+
<tbody>'''
|
|
741
|
+
|
|
742
|
+
total_cost = 0
|
|
743
|
+
total_input = 0
|
|
744
|
+
total_output = 0
|
|
745
|
+
total_cache = 0
|
|
746
|
+
|
|
747
|
+
for month_str in sorted(stats.monthly_costs.keys()):
|
|
748
|
+
cost = stats.monthly_costs[month_str]
|
|
749
|
+
total_cost += cost
|
|
750
|
+
|
|
751
|
+
# Get tokens for this month
|
|
752
|
+
if month_str in stats.monthly_tokens:
|
|
753
|
+
tokens = stats.monthly_tokens[month_str]
|
|
754
|
+
input_tokens = tokens.get('input', 0)
|
|
755
|
+
output_tokens = tokens.get('output', 0)
|
|
756
|
+
cache_tokens = tokens.get('cache_create', 0) + tokens.get('cache_read', 0)
|
|
757
|
+
|
|
758
|
+
total_input += input_tokens
|
|
759
|
+
total_output += output_tokens
|
|
760
|
+
total_cache += cache_tokens
|
|
761
|
+
|
|
762
|
+
# Format month
|
|
763
|
+
month_date = datetime.strptime(month_str, "%Y-%m")
|
|
764
|
+
month_label = month_date.strftime("%b %Y")
|
|
765
|
+
|
|
766
|
+
html += f'''
|
|
767
|
+
<tr>
|
|
768
|
+
<td>{month_label}</td>
|
|
769
|
+
<td style="color: var(--blue);">{format_tokens(input_tokens)}</td>
|
|
770
|
+
<td style="color: var(--orange);">{format_tokens(output_tokens)}</td>
|
|
771
|
+
<td style="color: var(--purple);">{format_tokens(cache_tokens)}</td>
|
|
772
|
+
<td style="color: var(--green);">{format_cost(cost)}</td>
|
|
773
|
+
</tr>'''
|
|
774
|
+
|
|
775
|
+
html += f'''
|
|
776
|
+
<tr style="border-top: 2px solid #30363D;">
|
|
777
|
+
<td><strong>Total</strong></td>
|
|
778
|
+
<td style="color: var(--blue);"><strong>{format_tokens(total_input)}</strong></td>
|
|
779
|
+
<td style="color: var(--orange);"><strong>{format_tokens(total_output)}</strong></td>
|
|
780
|
+
<td style="color: var(--purple);"><strong>{format_tokens(total_cache)}</strong></td>
|
|
781
|
+
<td style="color: var(--green);"><strong>{format_cost(total_cost)}</strong></td>
|
|
782
|
+
</tr>
|
|
783
|
+
</tbody>
|
|
784
|
+
</table>
|
|
785
|
+
</div>
|
|
786
|
+
</div>'''
|
|
787
|
+
|
|
788
|
+
return html
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _build_fun_facts_section(fun_facts: list) -> str:
|
|
792
|
+
"""Build fun facts section."""
|
|
793
|
+
if not fun_facts:
|
|
794
|
+
return ""
|
|
795
|
+
|
|
796
|
+
html = '''
|
|
797
|
+
<div style="margin-top: 40px;">
|
|
798
|
+
<div class="panel" style="border-color: var(--purple);">
|
|
799
|
+
<div class="panel-title" style="color: var(--purple);">Insights</div>
|
|
800
|
+
<div class="fun-facts">'''
|
|
801
|
+
|
|
802
|
+
for emoji, fact in fun_facts:
|
|
803
|
+
html += f'''
|
|
804
|
+
<div class="fun-fact">
|
|
805
|
+
<span class="fun-fact-emoji">{emoji}</span>
|
|
806
|
+
<span>{fact}</span>
|
|
807
|
+
</div>'''
|
|
808
|
+
|
|
809
|
+
html += '</div></div></div>'
|
|
810
|
+
return html
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _build_credits(stats: WrappedStats, year: int | None) -> str:
|
|
814
|
+
"""Build credits section."""
|
|
815
|
+
|
|
816
|
+
# Aggregate costs by simplified model name
|
|
817
|
+
display_costs = {}
|
|
818
|
+
for model, cost in stats.cost_by_model.items():
|
|
819
|
+
display_name = simplify_model_name(model)
|
|
820
|
+
display_costs[display_name] = display_costs.get(display_name, 0) + cost
|
|
821
|
+
|
|
822
|
+
html = '<div class="section">'
|
|
823
|
+
|
|
824
|
+
# Frame 1: The Numbers
|
|
825
|
+
html += '''
|
|
826
|
+
<div class="credits-frame">
|
|
827
|
+
<div class="credits-title" style="color: var(--green);">THE NUMBERS</div>'''
|
|
828
|
+
|
|
829
|
+
if stats.estimated_cost is not None:
|
|
830
|
+
html += f'''
|
|
831
|
+
<div class="credits-item">
|
|
832
|
+
<span class="credits-label">Estimated Cost</span>
|
|
833
|
+
<span class="credits-value" style="color: var(--green);">{format_cost(stats.estimated_cost)}</span>
|
|
834
|
+
</div>'''
|
|
835
|
+
|
|
836
|
+
for model, cost in sorted(display_costs.items(), key=lambda x: -x[1]):
|
|
837
|
+
html += f'''
|
|
838
|
+
<div class="credits-subitem">{model}: {format_cost(cost)}</div>'''
|
|
839
|
+
|
|
840
|
+
html += f'''
|
|
841
|
+
<div class="credits-item" style="margin-top: 30px;">
|
|
842
|
+
<span class="credits-label">Tokens</span>
|
|
843
|
+
<span class="credits-value" style="color: var(--orange);">{format_tokens(stats.total_tokens)}</span>
|
|
844
|
+
</div>
|
|
845
|
+
<div class="credits-subitem">Input: {format_tokens(stats.total_input_tokens)}</div>
|
|
846
|
+
<div class="credits-subitem">Output: {format_tokens(stats.total_output_tokens)}</div>
|
|
847
|
+
</div>'''
|
|
848
|
+
|
|
849
|
+
# Frame 2: Timeline
|
|
850
|
+
today = datetime.now()
|
|
851
|
+
if year is None:
|
|
852
|
+
# All-time: calculate days from first to last message
|
|
853
|
+
if stats.first_message_date and stats.last_message_date:
|
|
854
|
+
total_days = (stats.last_message_date - stats.first_message_date).days + 1
|
|
855
|
+
else:
|
|
856
|
+
total_days = stats.active_days
|
|
857
|
+
elif year == today.year:
|
|
858
|
+
total_days = (today - datetime(year, 1, 1)).days + 1
|
|
859
|
+
else:
|
|
860
|
+
total_days = 366 if year % 4 == 0 else 365
|
|
861
|
+
|
|
862
|
+
year_display = format_year_display(year)
|
|
863
|
+
# Use sentence case for "All time" in Period field
|
|
864
|
+
period_text = "All time" if year is None else year_display
|
|
865
|
+
html += f'''
|
|
866
|
+
<div class="credits-frame">
|
|
867
|
+
<div class="credits-title" style="color: var(--orange);">TIMELINE</div>
|
|
868
|
+
<div class="credits-item">
|
|
869
|
+
<span class="credits-label">Period</span>
|
|
870
|
+
<span class="credits-value" style="color: var(--orange);">{period_text}</span>
|
|
871
|
+
</div>'''
|
|
872
|
+
|
|
873
|
+
if stats.first_message_date:
|
|
874
|
+
html += f'''
|
|
875
|
+
<div class="credits-item">
|
|
876
|
+
<span class="credits-label">Journey started</span>
|
|
877
|
+
<span class="credits-value" style="color: var(--gray);">{stats.first_message_date.strftime('%B %d')}</span>
|
|
878
|
+
</div>'''
|
|
879
|
+
|
|
880
|
+
html += f'''
|
|
881
|
+
<div class="credits-item">
|
|
882
|
+
<span class="credits-label">Active days</span>
|
|
883
|
+
<span class="credits-value" style="color: var(--orange);">{stats.active_days}</span>
|
|
884
|
+
<span style="color: var(--gray);"> of {total_days}</span>
|
|
885
|
+
</div>'''
|
|
886
|
+
|
|
887
|
+
if stats.most_active_hour is not None:
|
|
888
|
+
hour_label = "AM" if stats.most_active_hour < 12 else "PM"
|
|
889
|
+
hour_12 = stats.most_active_hour % 12 or 12
|
|
890
|
+
html += f'''
|
|
891
|
+
<div class="credits-item">
|
|
892
|
+
<span class="credits-label">Peak hour</span>
|
|
893
|
+
<span class="credits-value" style="color: var(--purple);">{hour_12}:00 {hour_label}</span>
|
|
894
|
+
</div>'''
|
|
895
|
+
|
|
896
|
+
html += '</div>'
|
|
897
|
+
|
|
898
|
+
# Frame 3: Averages
|
|
899
|
+
html += f'''
|
|
900
|
+
<div class="credits-frame">
|
|
901
|
+
<div class="credits-title" style="color: var(--blue);">AVERAGES</div>
|
|
902
|
+
<div class="credits-item">
|
|
903
|
+
<span class="credits-label">Messages</span>
|
|
904
|
+
</div>
|
|
905
|
+
<div class="credits-subitem">Per day: {stats.avg_messages_per_day:.1f}</div>
|
|
906
|
+
<div class="credits-subitem">Per week: {stats.avg_messages_per_week:.1f}</div>
|
|
907
|
+
<div class="credits-subitem">Per month: {stats.avg_messages_per_month:.1f}</div>'''
|
|
908
|
+
|
|
909
|
+
if stats.estimated_cost is not None:
|
|
910
|
+
html += f'''
|
|
911
|
+
<div class="credits-item" style="margin-top: 20px;">
|
|
912
|
+
<span class="credits-label">Cost</span>
|
|
913
|
+
</div>
|
|
914
|
+
<div class="credits-subitem">Per day: {format_cost(stats.avg_cost_per_day)}</div>
|
|
915
|
+
<div class="credits-subitem">Per week: {format_cost(stats.avg_cost_per_week)}</div>
|
|
916
|
+
<div class="credits-subitem">Per month: {format_cost(stats.avg_cost_per_month)}</div>'''
|
|
917
|
+
|
|
918
|
+
html += '</div>'
|
|
919
|
+
|
|
920
|
+
# Frame 4: Longest Streak (if significant)
|
|
921
|
+
if stats.streak_longest >= 3 and stats.streak_longest_start and stats.streak_longest_end:
|
|
922
|
+
html += f'''
|
|
923
|
+
<div class="credits-frame">
|
|
924
|
+
<div class="credits-title" style="color: var(--blue);">LONGEST STREAK</div>
|
|
925
|
+
<div class="credits-item">
|
|
926
|
+
<span class="credits-value" style="color: var(--blue);">{stats.streak_longest} days</span>
|
|
927
|
+
<span style="color: var(--white);"> of consistent coding</span>
|
|
928
|
+
</div>
|
|
929
|
+
<div class="credits-item" style="margin-top: 20px;">
|
|
930
|
+
<span class="credits-label">From</span>
|
|
931
|
+
<span class="credits-value">{stats.streak_longest_start.strftime('%B %d, %Y')}</span>
|
|
932
|
+
</div>
|
|
933
|
+
<div class="credits-item">
|
|
934
|
+
<span class="credits-label">To</span>
|
|
935
|
+
<span class="credits-value">{stats.streak_longest_end.strftime('%B %d, %Y')}</span>
|
|
936
|
+
</div>
|
|
937
|
+
<div style="margin-top: 20px; color: var(--gray); font-style: italic;">
|
|
938
|
+
Consistency is the key to mastery.'''
|
|
939
|
+
|
|
940
|
+
if stats.streak_current > 0:
|
|
941
|
+
html += f'''<br><br>Current streak: {stats.streak_current} days'''
|
|
942
|
+
|
|
943
|
+
html += '''
|
|
944
|
+
</div>
|
|
945
|
+
</div>'''
|
|
946
|
+
|
|
947
|
+
# Frame 5: Longest Conversation
|
|
948
|
+
if stats.longest_conversation_messages > 0:
|
|
949
|
+
html += f'''
|
|
950
|
+
<div class="credits-frame">
|
|
951
|
+
<div class="credits-title" style="color: var(--purple);">LONGEST CONVERSATION</div>
|
|
952
|
+
<div class="credits-item">
|
|
953
|
+
<span class="credits-label">Messages</span>
|
|
954
|
+
<span class="credits-value" style="color: var(--purple);">{stats.longest_conversation_messages:,}</span>
|
|
955
|
+
</div>'''
|
|
956
|
+
|
|
957
|
+
if stats.longest_conversation_tokens > 0:
|
|
958
|
+
html += f'''
|
|
959
|
+
<div class="credits-item">
|
|
960
|
+
<span class="credits-label">Tokens</span>
|
|
961
|
+
<span class="credits-value" style="color: var(--orange);">{format_tokens(stats.longest_conversation_tokens)}</span>
|
|
962
|
+
</div>'''
|
|
963
|
+
|
|
964
|
+
if stats.longest_conversation_date:
|
|
965
|
+
html += f'''
|
|
966
|
+
<div class="credits-item">
|
|
967
|
+
<span class="credits-label">Date</span>
|
|
968
|
+
<span class="credits-value" style="color: var(--gray);">{stats.longest_conversation_date.strftime('%B %d, %Y')}</span>
|
|
969
|
+
</div>'''
|
|
970
|
+
|
|
971
|
+
html += '''
|
|
972
|
+
<div style="margin-top: 20px; color: var(--gray);">That's one epic coding session!</div>
|
|
973
|
+
</div>'''
|
|
974
|
+
|
|
975
|
+
# Frame 5: Starring (Models)
|
|
976
|
+
html += '''
|
|
977
|
+
<div class="credits-frame">
|
|
978
|
+
<div class="credits-title" style="color: var(--purple);">STARRING</div>'''
|
|
979
|
+
|
|
980
|
+
for model, count in stats.models_used.most_common(3):
|
|
981
|
+
html += f'''
|
|
982
|
+
<div class="credits-item">
|
|
983
|
+
<span class="credits-label">Claude {model}</span>
|
|
984
|
+
<span style="color: var(--gray);">({count:,} messages)</span>
|
|
985
|
+
</div>'''
|
|
986
|
+
|
|
987
|
+
html += '</div>'
|
|
988
|
+
|
|
989
|
+
# Frame 6: Projects
|
|
990
|
+
if stats.top_projects:
|
|
991
|
+
html += '''
|
|
992
|
+
<div class="credits-frame">
|
|
993
|
+
<div class="credits-title" style="color: var(--blue);">PROJECTS</div>'''
|
|
994
|
+
|
|
995
|
+
for proj, count in stats.top_projects[:5]:
|
|
996
|
+
html += f'''
|
|
997
|
+
<div class="credits-item">
|
|
998
|
+
<span class="credits-label">{proj}</span>
|
|
999
|
+
<span style="color: var(--gray);">({count:,} messages)</span>
|
|
1000
|
+
</div>'''
|
|
1001
|
+
|
|
1002
|
+
html += '</div>'
|
|
1003
|
+
|
|
1004
|
+
# Final card
|
|
1005
|
+
if year is not None:
|
|
1006
|
+
farewell_text = f'See you in <span style="color: var(--orange); font-weight: bold;">{year + 1}</span>'
|
|
1007
|
+
else:
|
|
1008
|
+
farewell_text = '<span style="color: var(--orange); font-weight: bold;">Keep coding!</span>'
|
|
1009
|
+
|
|
1010
|
+
html += f'''
|
|
1011
|
+
<div class="credits-frame">
|
|
1012
|
+
<div style="font-size: 1.5em; color: var(--gray); margin-bottom: 20px;">
|
|
1013
|
+
{farewell_text}
|
|
1014
|
+
</div>
|
|
1015
|
+
<div style="margin-top: 40px;">
|
|
1016
|
+
<div>
|
|
1017
|
+
<span style="color: var(--gray);">Created by </span>
|
|
1018
|
+
<a href="https://github.com/da-troll" style="color: var(--white); text-decoration: none; font-weight: bold;">
|
|
1019
|
+
Daniel Tollefsen
|
|
1020
|
+
</a>
|
|
1021
|
+
<span style="color: var(--gray);"> · </span>
|
|
1022
|
+
<a href="https://github.com/da-troll/claude-wrapped" style="color: var(--blue); text-decoration: none;">
|
|
1023
|
+
github.com/da-troll/claude-wrapped
|
|
1024
|
+
</a>
|
|
1025
|
+
</div>
|
|
1026
|
+
</div>
|
|
1027
|
+
</div>
|
|
1028
|
+
'''
|
|
1029
|
+
|
|
1030
|
+
html += '</div>' # Close credits section
|
|
1031
|
+
|
|
1032
|
+
return html
|