@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,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