claude-code-wrapped 0.1.4 → 0.1.6

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,9 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "WebFetch(domain:api.github.com)",
5
+ "WebFetch(domain:github.com)",
6
+ "WebFetch(domain:raw.githubusercontent.com)"
7
+ ]
8
+ }
9
+ }
@@ -1,3 +1,3 @@
1
1
  """Claude Code Wrapped - Your year with Claude Code, Spotify Wrapped style."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.6"
@@ -92,6 +92,25 @@ Examples:
92
92
  "weekday_distribution": stats.weekday_distribution,
93
93
  "estimated_cost_usd": stats.estimated_cost,
94
94
  "cost_by_model": stats.cost_by_model,
95
+ # Averages
96
+ "avg_messages_per_day": round(stats.avg_messages_per_day, 1),
97
+ "avg_messages_per_week": round(stats.avg_messages_per_week, 1),
98
+ "avg_messages_per_month": round(stats.avg_messages_per_month, 1),
99
+ "avg_cost_per_day": round(stats.avg_cost_per_day, 2) if stats.avg_cost_per_day else None,
100
+ "avg_cost_per_week": round(stats.avg_cost_per_week, 2) if stats.avg_cost_per_week else None,
101
+ "avg_cost_per_month": round(stats.avg_cost_per_month, 2) if stats.avg_cost_per_month else None,
102
+ # Code activity
103
+ "total_edits": stats.total_edits,
104
+ "total_writes": stats.total_writes,
105
+ "avg_code_changes_per_day": round(stats.avg_edits_per_day, 1),
106
+ "avg_code_changes_per_week": round(stats.avg_edits_per_week, 1),
107
+ # Monthly breakdown
108
+ "monthly_costs": stats.monthly_costs,
109
+ "monthly_tokens": stats.monthly_tokens,
110
+ # Longest conversation
111
+ "longest_conversation_messages": stats.longest_conversation_messages,
112
+ "longest_conversation_tokens": stats.longest_conversation_tokens,
113
+ "longest_conversation_date": stats.longest_conversation_date.isoformat() if stats.longest_conversation_date else None,
95
114
  }
96
115
  print(json.dumps(output, indent=2))
97
116
  else:
@@ -71,14 +71,39 @@ class WrappedStats:
71
71
 
72
72
  # Fun stats
73
73
  longest_conversation_tokens: int = 0
74
- avg_messages_per_day: float = 0.0
75
74
  avg_tokens_per_message: float = 0.0
76
75
 
76
+ # Averages (messages)
77
+ avg_messages_per_day: float = 0.0
78
+ avg_messages_per_week: float = 0.0
79
+ avg_messages_per_month: float = 0.0
80
+
81
+ # Averages (cost)
82
+ avg_cost_per_day: float = 0.0
83
+ avg_cost_per_week: float = 0.0
84
+ avg_cost_per_month: float = 0.0
85
+
86
+ # Code activity (from Edit/Write tools)
87
+ total_edits: int = 0
88
+ total_writes: int = 0
89
+ avg_edits_per_day: float = 0.0
90
+ avg_edits_per_week: float = 0.0
91
+
77
92
  # Cost tracking (per model)
78
93
  model_token_usage: dict[str, dict[str, int]] = field(default_factory=dict)
79
94
  estimated_cost: float | None = None
80
95
  cost_by_model: dict[str, float] = field(default_factory=dict)
81
96
 
97
+ # Monthly breakdown for cost table
98
+ monthly_costs: dict[str, float] = field(default_factory=dict) # "YYYY-MM" -> cost
99
+ monthly_tokens: dict[str, dict[str, int]] = field(default_factory=dict) # "YYYY-MM" -> {input, output, ...}
100
+
101
+ # Longest conversation tracking
102
+ longest_conversation_messages: int = 0
103
+ longest_conversation_tokens: int = 0
104
+ longest_conversation_session: str | None = None
105
+ longest_conversation_date: datetime | None = None
106
+
82
107
  @property
83
108
  def total_tokens(self) -> int:
84
109
  return (
@@ -163,6 +188,19 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
163
188
  projects = Counter()
164
189
  daily = defaultdict(lambda: DailyStats(date=datetime.now()))
165
190
 
191
+ # Track monthly token usage for cost breakdown
192
+ monthly_tokens: dict[str, dict[str, int]] = defaultdict(
193
+ lambda: {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
194
+ )
195
+ monthly_model_tokens: dict[str, dict[str, dict[str, int]]] = defaultdict(
196
+ lambda: defaultdict(lambda: {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0})
197
+ )
198
+
199
+ # Track per-session message counts for longest conversation
200
+ session_messages: dict[str, int] = Counter()
201
+ session_tokens: dict[str, int] = Counter()
202
+ session_first_time: dict[str, datetime] = {}
203
+
166
204
  # Process each message
167
205
  for msg in messages:
168
206
  stats.total_messages += 1
@@ -175,6 +213,10 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
175
213
  # Session tracking
176
214
  if msg.session_id:
177
215
  sessions.add(msg.session_id)
216
+ session_messages[msg.session_id] += 1
217
+ # Track first timestamp for each session
218
+ if msg.session_id not in session_first_time and msg.timestamp:
219
+ session_first_time[msg.session_id] = msg.timestamp
178
220
 
179
221
  # Project tracking
180
222
  project_name = extract_project_name(msg.project)
@@ -218,6 +260,25 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
218
260
  stats.model_token_usage[raw_model]["cache_create"] += msg.usage.cache_creation_tokens
219
261
  stats.model_token_usage[raw_model]["cache_read"] += msg.usage.cache_read_tokens
220
262
 
263
+ # Track monthly token usage for cost breakdown
264
+ if msg.timestamp:
265
+ month_key = msg.timestamp.strftime("%Y-%m")
266
+ monthly_tokens[month_key]["input"] += msg.usage.input_tokens
267
+ monthly_tokens[month_key]["output"] += msg.usage.output_tokens
268
+ monthly_tokens[month_key]["cache_create"] += msg.usage.cache_creation_tokens
269
+ monthly_tokens[month_key]["cache_read"] += msg.usage.cache_read_tokens
270
+
271
+ # Also track per-model per-month for accurate cost calculation
272
+ if raw_model and raw_model != '<synthetic>':
273
+ monthly_model_tokens[month_key][raw_model]["input"] += msg.usage.input_tokens
274
+ monthly_model_tokens[month_key][raw_model]["output"] += msg.usage.output_tokens
275
+ monthly_model_tokens[month_key][raw_model]["cache_create"] += msg.usage.cache_creation_tokens
276
+ monthly_model_tokens[month_key][raw_model]["cache_read"] += msg.usage.cache_read_tokens
277
+
278
+ # Track per-session tokens for longest conversation
279
+ if msg.session_id:
280
+ session_tokens[msg.session_id] += msg.usage.total_tokens
281
+
221
282
  # Tool usage
222
283
  for tool in msg.tool_calls:
223
284
  stats.tool_calls[tool] += 1
@@ -280,20 +341,62 @@ def aggregate_stats(messages: list[Message], year: int) -> WrappedStats:
280
341
  # Streaks
281
342
  stats.streak_longest, stats.streak_current = calculate_streaks(daily, year)
282
343
 
283
- # Averages
284
- if stats.active_days > 0:
285
- stats.avg_messages_per_day = stats.total_messages / stats.active_days
286
-
287
- if stats.total_assistant_messages > 0:
288
- stats.avg_tokens_per_message = stats.total_tokens / stats.total_assistant_messages
289
-
290
- # Calculate estimated cost
344
+ # Calculate estimated cost first (needed for averages)
291
345
  from .pricing import calculate_total_cost_by_model
292
346
  if stats.model_token_usage:
293
347
  stats.estimated_cost, stats.cost_by_model = calculate_total_cost_by_model(
294
348
  stats.model_token_usage
295
349
  )
296
350
 
351
+ # Calculate monthly costs
352
+ stats.monthly_tokens = dict(monthly_tokens)
353
+ for month_key, model_usage in monthly_model_tokens.items():
354
+ month_cost, _ = calculate_total_cost_by_model(dict(model_usage))
355
+ stats.monthly_costs[month_key] = month_cost
356
+
357
+ # Find longest conversation
358
+ if session_messages:
359
+ longest_session = max(session_messages.items(), key=lambda x: x[1])
360
+ stats.longest_conversation_session = longest_session[0]
361
+ stats.longest_conversation_messages = longest_session[1]
362
+ if longest_session[0] in session_tokens:
363
+ stats.longest_conversation_tokens = session_tokens[longest_session[0]]
364
+ if longest_session[0] in session_first_time:
365
+ stats.longest_conversation_date = session_first_time[longest_session[0]]
366
+
367
+ # Calculate time periods for averages
368
+ today = datetime.now()
369
+ if year == today.year:
370
+ total_days = (today - datetime(year, 1, 1)).days + 1
371
+ else:
372
+ total_days = 366 if year % 4 == 0 else 365
373
+ total_weeks = max(1, total_days / 7)
374
+ total_months = max(1, total_days / 30.44) # Average days per month
375
+
376
+ # Message averages (over total time period, not just active days)
377
+ if total_days > 0:
378
+ stats.avg_messages_per_day = stats.total_messages / total_days
379
+ stats.avg_messages_per_week = stats.total_messages / total_weeks
380
+ stats.avg_messages_per_month = stats.total_messages / total_months
381
+
382
+ # Cost averages
383
+ if stats.estimated_cost is not None and total_days > 0:
384
+ stats.avg_cost_per_day = stats.estimated_cost / total_days
385
+ stats.avg_cost_per_week = stats.estimated_cost / total_weeks
386
+ stats.avg_cost_per_month = stats.estimated_cost / total_months
387
+
388
+ # Token averages
389
+ if stats.total_assistant_messages > 0:
390
+ stats.avg_tokens_per_message = stats.total_tokens / stats.total_assistant_messages
391
+
392
+ # Code activity from Edit/Write tools
393
+ stats.total_edits = stats.tool_calls.get("Edit", 0)
394
+ stats.total_writes = stats.tool_calls.get("Write", 0)
395
+ total_code_changes = stats.total_edits + stats.total_writes
396
+ if total_days > 0:
397
+ stats.avg_edits_per_day = total_code_changes / total_days
398
+ stats.avg_edits_per_week = total_code_changes / total_weeks
399
+
297
400
  return stats
298
401
 
299
402
 
@@ -172,14 +172,16 @@ def create_hour_chart(distribution: list[int]) -> Panel:
172
172
  color = COLORS["orange"]
173
173
  elif 12 <= i < 18:
174
174
  color = COLORS["blue"]
175
- elif 18 <= i < 22:
175
+ elif 18 <= i < 24:
176
176
  color = COLORS["purple"]
177
177
  else:
178
178
  color = COLORS["gray"]
179
179
  content.append(chars[idx], style=Style(color=color))
180
180
 
181
+ # Build aligned label (24 chars to match 24 bars)
182
+ # Labels at positions: 0, 6, 12, 18, with end marker
181
183
  content.append("\n")
182
- content.append("0 6 12 18 24", style=Style(color=COLORS["gray"]))
184
+ content.append("0 6 12 18 24", style=Style(color=COLORS["gray"]))
183
185
 
184
186
  return Panel(
185
187
  Align.center(content),
@@ -355,6 +357,70 @@ def simplify_model_name(model: str) -> str:
355
357
  return model
356
358
 
357
359
 
360
+ def create_monthly_cost_table(stats: WrappedStats) -> Panel:
361
+ """Create a monthly cost breakdown table like ccusage."""
362
+ from .pricing import format_cost
363
+
364
+ table = Table(
365
+ show_header=True,
366
+ header_style=Style(color=COLORS["white"], bold=True),
367
+ border_style=Style(color=COLORS["dark"]),
368
+ box=None,
369
+ padding=(0, 1),
370
+ )
371
+
372
+ table.add_column("Month", style=Style(color=COLORS["gray"]))
373
+ table.add_column("Input", justify="right", style=Style(color=COLORS["blue"]))
374
+ table.add_column("Output", justify="right", style=Style(color=COLORS["orange"]))
375
+ table.add_column("Cache", justify="right", style=Style(color=COLORS["purple"]))
376
+ table.add_column("Cost", justify="right", style=Style(color=COLORS["green"], bold=True))
377
+
378
+ # Sort months chronologically
379
+ sorted_months = sorted(stats.monthly_costs.keys())
380
+
381
+ for month_key in sorted_months:
382
+ cost = stats.monthly_costs.get(month_key, 0)
383
+ tokens = stats.monthly_tokens.get(month_key, {})
384
+
385
+ # Format month name
386
+ try:
387
+ month_date = datetime.strptime(month_key, "%Y-%m")
388
+ month_name = month_date.strftime("%b %Y")
389
+ except ValueError:
390
+ month_name = month_key
391
+
392
+ input_tokens = tokens.get("input", 0)
393
+ output_tokens = tokens.get("output", 0)
394
+ cache_tokens = tokens.get("cache_create", 0) + tokens.get("cache_read", 0)
395
+
396
+ table.add_row(
397
+ month_name,
398
+ format_tokens(input_tokens),
399
+ format_tokens(output_tokens),
400
+ format_tokens(cache_tokens),
401
+ format_cost(cost),
402
+ )
403
+
404
+ # Add total row
405
+ if sorted_months:
406
+ table.add_row("", "", "", "", "") # Separator
407
+ table.add_row(
408
+ "Total",
409
+ format_tokens(stats.total_input_tokens),
410
+ format_tokens(stats.total_output_tokens),
411
+ format_tokens(stats.total_cache_creation_tokens + stats.total_cache_read_tokens),
412
+ format_cost(stats.estimated_cost) if stats.estimated_cost else "N/A",
413
+ style=Style(bold=True),
414
+ )
415
+
416
+ return Panel(
417
+ table,
418
+ title="Monthly Cost Breakdown",
419
+ border_style=Style(color=COLORS["green"]),
420
+ padding=(0, 1),
421
+ )
422
+
423
+
358
424
  def create_credits_roll(stats: WrappedStats) -> list[Text]:
359
425
  """Create end credits content."""
360
426
  from .pricing import format_cost
@@ -411,7 +477,59 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]:
411
477
  timeline.append(" [ENTER]", style=Style(color=COLORS["dark"]))
412
478
  frames.append(timeline)
413
479
 
414
- # Frame 3: Cast (models)
480
+ # Frame 3: Averages
481
+ from .pricing import format_cost
482
+ averages = Text()
483
+ averages.append("\n\n\n")
484
+ averages.append(" A V E R A G E S\n\n", style=Style(color=COLORS["blue"], bold=True))
485
+ averages.append(" Messages\n", style=Style(color=COLORS["white"], bold=True))
486
+ averages.append(f" Per day: {stats.avg_messages_per_day:.1f}\n", style=Style(color=COLORS["gray"]))
487
+ averages.append(f" Per week: {stats.avg_messages_per_week:.1f}\n", style=Style(color=COLORS["gray"]))
488
+ averages.append(f" Per month: {stats.avg_messages_per_month:.1f}\n", style=Style(color=COLORS["gray"]))
489
+ if stats.estimated_cost is not None:
490
+ averages.append("\n Cost\n", style=Style(color=COLORS["white"], bold=True))
491
+ averages.append(f" Per day: {format_cost(stats.avg_cost_per_day)}\n", style=Style(color=COLORS["gray"]))
492
+ averages.append(f" Per week: {format_cost(stats.avg_cost_per_week)}\n", style=Style(color=COLORS["gray"]))
493
+ averages.append(f" Per month: {format_cost(stats.avg_cost_per_month)}\n", style=Style(color=COLORS["gray"]))
494
+ averages.append("\n\n")
495
+ averages.append(" [ENTER]", style=Style(color=COLORS["dark"]))
496
+ frames.append(averages)
497
+
498
+ # Frame 4: Code Activity
499
+ code_activity = Text()
500
+ code_activity.append("\n\n\n")
501
+ code_activity.append(" C O D E A C T I V I T Y\n\n", style=Style(color=COLORS["orange"], bold=True))
502
+ total_code_changes = stats.total_edits + stats.total_writes
503
+ code_activity.append(" File Changes\n", style=Style(color=COLORS["white"], bold=True))
504
+ code_activity.append(f" Edits: {stats.total_edits:,}\n", style=Style(color=COLORS["gray"]))
505
+ code_activity.append(f" Writes: {stats.total_writes:,}\n", style=Style(color=COLORS["gray"]))
506
+ code_activity.append(f" Total: {total_code_changes:,}\n", style=Style(color=COLORS["orange"], bold=True))
507
+ code_activity.append("\n Averages\n", style=Style(color=COLORS["white"], bold=True))
508
+ code_activity.append(f" Per day: {stats.avg_edits_per_day:.1f}\n", style=Style(color=COLORS["gray"]))
509
+ code_activity.append(f" Per week: {stats.avg_edits_per_week:.1f}\n", style=Style(color=COLORS["gray"]))
510
+ code_activity.append("\n\n")
511
+ code_activity.append(" [ENTER]", style=Style(color=COLORS["dark"]))
512
+ frames.append(code_activity)
513
+
514
+ # Frame 5: Longest Conversation
515
+ if stats.longest_conversation_messages > 0:
516
+ longest = Text()
517
+ longest.append("\n\n\n")
518
+ 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))
519
+ longest.append(f" Messages ", style=Style(color=COLORS["white"], bold=True))
520
+ longest.append(f"{stats.longest_conversation_messages:,}\n", style=Style(color=COLORS["purple"], bold=True))
521
+ if stats.longest_conversation_tokens > 0:
522
+ longest.append(f" Tokens ", style=Style(color=COLORS["white"], bold=True))
523
+ longest.append(f"{format_tokens(stats.longest_conversation_tokens)}\n", style=Style(color=COLORS["orange"], bold=True))
524
+ if stats.longest_conversation_date:
525
+ longest.append(f" Date ", style=Style(color=COLORS["white"], bold=True))
526
+ longest.append(f"{stats.longest_conversation_date.strftime('%B %d, %Y')}\n", style=Style(color=COLORS["gray"]))
527
+ longest.append("\n That's one epic coding session!\n", style=Style(color=COLORS["gray"]))
528
+ longest.append("\n\n")
529
+ longest.append(" [ENTER]", style=Style(color=COLORS["dark"]))
530
+ frames.append(longest)
531
+
532
+ # Frame 6: Cast (models)
415
533
  cast = Text()
416
534
  cast.append("\n\n\n")
417
535
  cast.append(" S T A R R I N G\n\n", style=Style(color=COLORS["purple"], bold=True))
@@ -422,7 +540,7 @@ def create_credits_roll(stats: WrappedStats) -> list[Text]:
422
540
  cast.append(" [ENTER]", style=Style(color=COLORS["dark"]))
423
541
  frames.append(cast)
424
542
 
425
- # Frame 4: Projects
543
+ # Frame 6: Projects
426
544
  if stats.top_projects:
427
545
  projects = Text()
428
546
  projects.append("\n\n\n")
@@ -557,6 +675,10 @@ def render_wrapped(stats: WrappedStats, console: Console | None = None, animate:
557
675
  )
558
676
  console.print(lists)
559
677
 
678
+ # Monthly cost table
679
+ if stats.monthly_costs:
680
+ console.print(create_monthly_cost_table(stats))
681
+
560
682
  # Insights
561
683
  insights = Text()
562
684
  if stats.most_active_day:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-wrapped",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Your year with Claude Code - Spotify Wrapped style terminal experience",
5
5
  "bin": {
6
6
  "claude-code-wrapped": "./bin/cli.js"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-code-wrapped"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Your year with Claude Code - Spotify Wrapped style terminal experience"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
package/uv.lock CHANGED
@@ -4,7 +4,7 @@ requires-python = ">=3.12"
4
4
 
5
5
  [[package]]
6
6
  name = "claude-code-wrapped"
7
- version = "0.1.3"
7
+ version = "0.1.6"
8
8
  source = { editable = "." }
9
9
  dependencies = [
10
10
  { name = "rich" },