claude-code-templates 1.20.2 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,782 @@
1
+ /**
2
+ * ActivityHeatmap - GitHub-style contribution calendar for Claude Code activity
3
+ * Shows daily Claude Code usage over the last year with orange theme
4
+ */
5
+ class ActivityHeatmap {
6
+ constructor(container, dataService) {
7
+ this.container = container;
8
+ this.dataService = dataService;
9
+ this.activityData = null;
10
+ this.tooltip = null;
11
+ this.currentYear = new Date().getFullYear();
12
+ this.currentMetric = 'messages'; // Default metric
13
+
14
+ console.log('🔥 ActivityHeatmap initialized');
15
+ }
16
+
17
+ /**
18
+ * Initialize the heatmap component
19
+ */
20
+ async initialize() {
21
+ try {
22
+ console.log('🔥 Initializing ActivityHeatmap...');
23
+ await this.render();
24
+ await this.loadActivityData();
25
+ console.log('✅ ActivityHeatmap initialized successfully');
26
+ } catch (error) {
27
+ console.error('❌ Failed to initialize ActivityHeatmap:', error);
28
+ this.showErrorState();
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Render the heatmap structure
34
+ */
35
+ async render() {
36
+ this.container.innerHTML = `
37
+ <div class="activity-heatmap-container">
38
+ <div class="heatmap-loading">
39
+ <div class="heatmap-loading-spinner"></div>
40
+ <span>Loading activity...</span>
41
+ </div>
42
+ </div>
43
+ <div class="heatmap-tooltip" id="heatmap-tooltip">
44
+ <div class="heatmap-tooltip-date"></div>
45
+ <div class="heatmap-tooltip-activity"></div>
46
+ </div>
47
+ `;
48
+
49
+ this.tooltip = document.getElementById('heatmap-tooltip');
50
+ }
51
+
52
+ /**
53
+ * Load activity data from the API
54
+ */
55
+ async loadActivityData() {
56
+ try {
57
+ console.log('🔥 Loading activity data...');
58
+
59
+ // Get complete activity data from backend (pre-processed with tools)
60
+ const response = await this.dataService.cachedFetch('/api/activity');
61
+ if (response && response.dailyActivity) {
62
+ console.log(`🔥 Loaded pre-processed activity data: ${response.dailyActivity.length} active days`);
63
+
64
+ // Use pre-processed data from backend instead of processing raw conversations
65
+ const dailyActivityMap = new Map();
66
+ response.dailyActivity.forEach(day => {
67
+ dailyActivityMap.set(day.date, day);
68
+ });
69
+
70
+ this.activityData = this.processPrecomputedActivityData(dailyActivityMap);
71
+ await this.renderHeatmap();
72
+ this.updateTitle();
73
+ } else {
74
+ throw new Error('No activity data available');
75
+ }
76
+ } catch (error) {
77
+ console.error('❌ Error loading activity data:', error);
78
+ this.showErrorState();
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Process pre-computed activity data from backend
84
+ */
85
+ processPrecomputedActivityData(dailyActivityMap) {
86
+ console.log(`🔥 Processing ${dailyActivityMap.size} days of pre-computed data...`);
87
+
88
+ // Calculate thresholds based on current metric
89
+ const metricCounts = Array.from(dailyActivityMap.values())
90
+ .map(activity => activity[this.currentMetric] || 0)
91
+ .filter(count => count > 0)
92
+ .sort((a, b) => a - b);
93
+
94
+ const thresholds = this.calculateDynamicThresholds(metricCounts);
95
+
96
+ // Calculate total activity for current metric
97
+ let totalActivity = 0;
98
+ let totalTools = 0;
99
+ let totalMessages = 0;
100
+ dailyActivityMap.forEach(activity => {
101
+ totalActivity += activity[this.currentMetric] || 0;
102
+ totalTools += activity.tools || 0;
103
+ totalMessages += activity.messages || 0;
104
+ });
105
+
106
+ console.log(`🔥 Pre-computed data stats: ${totalMessages} messages, ${totalTools} tools`);
107
+ console.log(`🔥 Current metric (${this.currentMetric}): ${totalActivity} total`);
108
+ console.log(`🔥 Dynamic thresholds:`, thresholds);
109
+ console.log(`🔥 Sample ${this.currentMetric} counts:`, metricCounts.slice(0, 10), '...', metricCounts.slice(-10));
110
+
111
+ return {
112
+ dailyActivity: dailyActivityMap,
113
+ totalActivity,
114
+ activeDays: dailyActivityMap.size,
115
+ thresholds
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Process conversation data into daily activity counts (legacy method)
121
+ */
122
+ processActivityData(conversations) {
123
+ const dailyActivity = new Map();
124
+ const oneYearAgo = new Date();
125
+ oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
126
+ const today = new Date();
127
+
128
+ console.log(`🔥 Processing ${conversations.length} conversations for activity data...`);
129
+ console.log(`🔥 Date range: ${oneYearAgo.toLocaleDateString()} to ${today.toLocaleDateString()}`);
130
+ console.log(`🔥 Cutoff timestamp: ${oneYearAgo.getTime()}`);
131
+
132
+ let validConversations = 0;
133
+ let latestDate = null;
134
+ let oldestDate = null;
135
+ let beforeOneYearCount = 0;
136
+
137
+ // Sample some conversations to see their date formats and data structure
138
+ console.log(`🔥 Sampling first 5 conversations:`);
139
+ conversations.slice(0, 5).forEach((conv, i) => {
140
+ console.log(` ${i+1}: ${conv.filename} - lastModified: ${conv.lastModified} (${new Date(conv.lastModified).toLocaleDateString()})`);
141
+ console.log(` messages: ${conv.messageCount || 0}, tokens: ${conv.tokens || 0}, toolUsage: ${conv.toolUsage?.totalToolCalls || 0}`);
142
+ });
143
+
144
+ conversations.forEach((conversation, index) => {
145
+ if (!conversation.lastModified) {
146
+ if (index < 5) console.log(`⚠️ Conversation ${index} has no lastModified:`, conversation);
147
+ return;
148
+ }
149
+
150
+ const date = new Date(conversation.lastModified);
151
+
152
+ // Track date range
153
+ if (!latestDate || date > latestDate) latestDate = date;
154
+ if (!oldestDate || date < oldestDate) oldestDate = date;
155
+
156
+ if (date < oneYearAgo) {
157
+ beforeOneYearCount++;
158
+ if (beforeOneYearCount <= 5) {
159
+ console.log(`⚠️ Excluding old conversation: ${date.toISOString()} (${conversation.filename})`);
160
+ }
161
+ return;
162
+ }
163
+
164
+ validConversations++;
165
+ const dateKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
166
+
167
+ const current = dailyActivity.get(dateKey) || {
168
+ conversations: 0,
169
+ tokens: 0,
170
+ messages: 0,
171
+ tools: 0
172
+ };
173
+
174
+ current.conversations += 1;
175
+ current.tokens += conversation.tokens || 0;
176
+ current.messages += conversation.messageCount || 0;
177
+ current.tools += (conversation.toolUsage?.totalToolCalls || 0);
178
+
179
+ dailyActivity.set(dateKey, current);
180
+ });
181
+
182
+ console.log(`🔥 Valid conversations in last year: ${validConversations}`);
183
+ console.log(`🔥 Excluded conversations (older than 1 year): ${beforeOneYearCount}`);
184
+ console.log(`🔥 Complete date range in data: ${oldestDate?.toLocaleDateString()} to ${latestDate?.toLocaleDateString()}`);
185
+ console.log(`🔥 One year ago cutoff: ${oneYearAgo.toLocaleDateString()}`);
186
+
187
+ // Show first and last few conversations for debugging
188
+ const sortedDates = Array.from(dailyActivity.keys()).sort();
189
+ console.log(`🔥 First 5 activity dates:`, sortedDates.slice(0, 5));
190
+ console.log(`🔥 Last 5 activity dates:`, sortedDates.slice(-5));
191
+ console.log(`🔥 Total activity days: ${sortedDates.length}`);
192
+
193
+ // Calculate total activity for the year (based on current metric)
194
+ let totalActivity = 0;
195
+ dailyActivity.forEach(activity => {
196
+ totalActivity += activity[this.currentMetric] || 0;
197
+ });
198
+
199
+ // Calculate dynamic thresholds based on data distribution
200
+ const messageCounts = Array.from(dailyActivity.values())
201
+ .map(activity => activity[this.currentMetric] || 0)
202
+ .filter(count => count > 0)
203
+ .sort((a, b) => a - b);
204
+
205
+ const thresholds = this.calculateDynamicThresholds(messageCounts);
206
+
207
+ // Calculate total tools for debugging
208
+ let totalTools = 0;
209
+ let totalMessages = 0;
210
+ dailyActivity.forEach(activity => {
211
+ totalTools += activity.tools || 0;
212
+ totalMessages += activity.messages || 0;
213
+ });
214
+
215
+ console.log(`🔥 Processed activity data: ${dailyActivity.size} active days, ${totalActivity} total ${this.currentMetric}`);
216
+ console.log(`🔥 Debug totals: ${totalMessages} messages, ${totalTools} tools`);
217
+ console.log(`🔥 ${this.currentMetric} range: ${Math.min(...messageCounts)} to ${Math.max(...messageCounts)} ${this.currentMetric} per day`);
218
+ console.log(`🔥 Dynamic thresholds:`, thresholds);
219
+ console.log(`🔥 Sample ${this.currentMetric} counts:`, messageCounts.slice(0, 10), '...', messageCounts.slice(-10));
220
+
221
+ return {
222
+ dailyActivity,
223
+ totalActivity,
224
+ activeDays: dailyActivity.size,
225
+ thresholds
226
+ };
227
+ }
228
+
229
+ /**
230
+ * Calculate dynamic thresholds based on data distribution
231
+ * Ensures that even 1 message shows visible color (like GitHub)
232
+ */
233
+ calculateDynamicThresholds(messageCounts) {
234
+ if (messageCounts.length === 0) {
235
+ return { level1: 1, level2: 5, level3: 15, level4: 30 };
236
+ }
237
+
238
+ const len = messageCounts.length;
239
+ const max = messageCounts[len - 1];
240
+ const min = messageCounts[0];
241
+
242
+ // ALWAYS start level1 at 1 so any activity shows color
243
+ let level1 = 1;
244
+ let level2, level3, level4;
245
+
246
+ if (len <= 4) {
247
+ // Very few data points - simple distribution
248
+ level2 = Math.max(2, Math.ceil(max * 0.3));
249
+ level3 = Math.max(level2 + 1, Math.ceil(max * 0.6));
250
+ level4 = Math.max(level3 + 1, Math.ceil(max * 0.8));
251
+ } else {
252
+ // Use percentiles but ensure good visual distribution
253
+ const p33 = messageCounts[Math.floor(len * 0.33)] || 2;
254
+ const p66 = messageCounts[Math.floor(len * 0.66)] || 3;
255
+ const p85 = messageCounts[Math.floor(len * 0.85)] || 4;
256
+
257
+ // Ensure reasonable spacing between levels
258
+ level2 = Math.max(2, Math.min(p33, max * 0.2));
259
+ level3 = Math.max(level2 + 1, Math.min(p66, max * 0.5));
260
+ level4 = Math.max(level3 + 1, Math.min(p85, max * 0.75));
261
+ }
262
+
263
+ return { level1, level2, level3, level4 };
264
+ }
265
+
266
+ /**
267
+ * Render the heatmap calendar
268
+ */
269
+ async renderHeatmap() {
270
+ if (!this.activityData) return;
271
+
272
+ const { dailyActivity, totalActivity } = this.activityData;
273
+
274
+ // Generate calendar structure
275
+ const calendarData = this.generateCalendarData(dailyActivity);
276
+
277
+ const container = this.container.querySelector('.activity-heatmap-container');
278
+ const modeClass = this.currentMetric === 'tools' ? 'tools-mode' : '';
279
+ container.className = `activity-heatmap-container ${modeClass}`;
280
+ container.innerHTML = `
281
+ <div class="heatmap-header">
282
+ <div class="heatmap-legend">
283
+ <span class="heatmap-legend-text">Less</span>
284
+ <div class="heatmap-legend-scale">
285
+ <div class="heatmap-legend-square level-0"></div>
286
+ <div class="heatmap-legend-square level-1"></div>
287
+ <div class="heatmap-legend-square level-2"></div>
288
+ <div class="heatmap-legend-square level-3"></div>
289
+ <div class="heatmap-legend-square level-4"></div>
290
+ </div>
291
+ <span class="heatmap-legend-more">More</span>
292
+ </div>
293
+ </div>
294
+ <div class="heatmap-grid">
295
+ <div class="heatmap-months" id="heatmap-months-container">
296
+ ${calendarData.months.map((month, index) =>
297
+ `<div class="heatmap-month" data-week-index="${index}">${month}</div>`
298
+ ).join('')}
299
+ </div>
300
+ <div class="heatmap-weekdays">
301
+ <div class="heatmap-weekday">Mon</div>
302
+ <div class="heatmap-weekday"></div>
303
+ <div class="heatmap-weekday">Wed</div>
304
+ <div class="heatmap-weekday"></div>
305
+ <div class="heatmap-weekday">Fri</div>
306
+ <div class="heatmap-weekday"></div>
307
+ <div class="heatmap-weekday"></div>
308
+ </div>
309
+ <div class="heatmap-weeks">
310
+ ${calendarData.weeks.map(week => this.renderWeek(week, dailyActivity)).join('')}
311
+ </div>
312
+ </div>
313
+ `;
314
+
315
+ this.attachEventListeners();
316
+ this.attachSettingsListeners();
317
+ this.positionMonthLabels();
318
+
319
+ // Re-position months on window resize
320
+ this.resizeHandler = () => this.positionMonthLabels();
321
+ window.addEventListener('resize', this.resizeHandler);
322
+ }
323
+
324
+ /**
325
+ * Position month labels based on actual week positions
326
+ */
327
+ positionMonthLabels() {
328
+ setTimeout(() => {
329
+ const weeksContainer = this.container.querySelector('.heatmap-weeks');
330
+ const monthsContainer = this.container.querySelector('#heatmap-months-container');
331
+ const monthElements = monthsContainer?.querySelectorAll('.heatmap-month');
332
+ const weekElements = weeksContainer?.children;
333
+
334
+ if (!weeksContainer || !monthsContainer || !monthElements || !weekElements) return;
335
+
336
+ // Calculate the actual width and position of each week column
337
+ Array.from(monthElements).forEach((monthEl, index) => {
338
+ if (index < weekElements.length && monthEl.textContent.trim()) {
339
+ const weekEl = weekElements[index];
340
+ const weekRect = weekEl.getBoundingClientRect();
341
+ const containerRect = monthsContainer.getBoundingClientRect();
342
+
343
+ // Position month label at the start of its corresponding week
344
+ const leftPosition = weekRect.left - containerRect.left;
345
+ monthEl.style.left = `${leftPosition}px`;
346
+ }
347
+ });
348
+ }, 50); // Small delay to ensure DOM is fully rendered
349
+ }
350
+
351
+ /**
352
+ * Generate calendar structure for the last year
353
+ */
354
+ generateCalendarData(dailyActivity) {
355
+ const today = new Date();
356
+ const todayEnd = new Date(today);
357
+ todayEnd.setHours(23, 59, 59, 999); // End of today
358
+
359
+ const oneYearAgo = new Date(today);
360
+ oneYearAgo.setFullYear(today.getFullYear() - 1);
361
+ oneYearAgo.setDate(today.getDate() + 1);
362
+
363
+ // Find the start of the week (Sunday) for the start date
364
+ const startDate = new Date(oneYearAgo);
365
+ startDate.setDate(startDate.getDate() - startDate.getDay());
366
+
367
+ const weeks = [];
368
+ const months = [];
369
+ const current = new Date(startDate);
370
+
371
+ // Generate weeks first - include today completely
372
+ while (current <= todayEnd) {
373
+ const week = [];
374
+
375
+ for (let day = 0; day < 7; day++) {
376
+ if (current <= todayEnd) {
377
+ const dayDate = new Date(current);
378
+ week.push(dayDate);
379
+ } else {
380
+ week.push(null);
381
+ }
382
+ current.setDate(current.getDate() + 1);
383
+ }
384
+
385
+ weeks.push(week);
386
+ }
387
+
388
+ // Generate month labels - show when month changes
389
+ let lastDisplayedMonth = -1;
390
+
391
+ for (let i = 0; i < weeks.length; i++) {
392
+ const week = weeks[i];
393
+ let monthName = '';
394
+
395
+ if (week && week.length > 0) {
396
+ // Get the most representative day of the week (middle of week)
397
+ const middleDay = week[3] || week[2] || week[1] || week[0];
398
+
399
+ if (middleDay) {
400
+ const currentMonth = middleDay.getMonth();
401
+
402
+ // Show month name if it's the first occurrence or if month changed
403
+ if (currentMonth !== lastDisplayedMonth) {
404
+ monthName = middleDay.toLocaleDateString('en-US', { month: 'short' });
405
+ lastDisplayedMonth = currentMonth;
406
+ }
407
+ }
408
+ }
409
+
410
+ months.push(monthName);
411
+ }
412
+
413
+ return { weeks, months };
414
+ }
415
+
416
+ /**
417
+ * Render a week column
418
+ */
419
+ renderWeek(week, dailyActivity) {
420
+ const weekHtml = week.map(date => {
421
+ if (!date) return '<div class="heatmap-day empty"></div>';
422
+
423
+ const dateKey = date.toISOString().split('T')[0];
424
+ const activity = dailyActivity.get(dateKey);
425
+ const level = this.getActivityLevel(activity);
426
+ const modeClass = this.currentMetric === 'tools' ? 'tools-mode' : '';
427
+
428
+
429
+ return `
430
+ <div class="heatmap-day level-${level} ${modeClass}"
431
+ data-date="${dateKey}"
432
+ data-activity='${JSON.stringify(activity || { conversations: 0, tokens: 0, messages: 0, tools: 0 })}'>
433
+ </div>
434
+ `;
435
+ }).join('');
436
+
437
+ return `<div class="heatmap-week">${weekHtml}</div>`;
438
+ }
439
+
440
+ /**
441
+ * Calculate activity level based on current metric using dynamic thresholds
442
+ */
443
+ getActivityLevel(activity) {
444
+ if (!activity) return 0;
445
+
446
+ const metricValue = activity[this.currentMetric] || 0;
447
+ if (metricValue === 0) return 0;
448
+
449
+ const thresholds = this.activityData?.thresholds;
450
+
451
+ if (!thresholds) {
452
+ // Fallback to static levels if thresholds not available
453
+ if (metricValue >= 50) return 4;
454
+ if (metricValue >= 30) return 3;
455
+ if (metricValue >= 15) return 2;
456
+ if (metricValue >= 1) return 1;
457
+ return 0;
458
+ }
459
+
460
+ // Use dynamic thresholds for better distribution
461
+ if (metricValue >= thresholds.level4) return 4;
462
+ if (metricValue >= thresholds.level3) return 3;
463
+ if (metricValue >= thresholds.level2) return 2;
464
+ if (metricValue >= thresholds.level1) return 1;
465
+
466
+ return 0;
467
+ }
468
+
469
+ /**
470
+ * Attach event listeners for tooltips and interactions
471
+ */
472
+ attachEventListeners() {
473
+ const days = this.container.querySelectorAll('.heatmap-day');
474
+
475
+ days.forEach(day => {
476
+ day.addEventListener('mouseenter', (e) => this.showTooltip(e));
477
+ day.addEventListener('mouseleave', () => this.hideTooltip());
478
+ day.addEventListener('mousemove', (e) => this.updateTooltipPosition(e));
479
+ });
480
+ }
481
+
482
+ /**
483
+ * Attach event listeners for settings dropdown
484
+ */
485
+ attachSettingsListeners() {
486
+ // Remove existing listeners first to prevent duplicates
487
+ this.removeSettingsListeners();
488
+
489
+ const settingsButton = document.querySelector('.heatmap-settings');
490
+ const dropdown = document.getElementById('heatmap-settings-dropdown');
491
+ const metricOptions = dropdown?.querySelectorAll('.heatmap-metric-option');
492
+
493
+ console.log('🔥 Attaching settings listeners:', {
494
+ settingsButton: !!settingsButton,
495
+ dropdown: !!dropdown,
496
+ metricOptions: metricOptions?.length || 0
497
+ });
498
+
499
+ if (settingsButton && dropdown) {
500
+ // Store references to handlers for cleanup
501
+ this.settingsClickHandler = (e) => {
502
+ e.stopPropagation();
503
+ dropdown.classList.toggle('show');
504
+ console.log('🔥 Settings dropdown toggled:', dropdown.classList.contains('show'));
505
+ };
506
+
507
+ this.documentClickHandler = (e) => {
508
+ if (!settingsButton.contains(e.target) && !dropdown.contains(e.target)) {
509
+ dropdown.classList.remove('show');
510
+ }
511
+ };
512
+
513
+ // Add event listeners
514
+ settingsButton.addEventListener('click', this.settingsClickHandler);
515
+ document.addEventListener('click', this.documentClickHandler);
516
+ }
517
+
518
+ // Handle metric selection
519
+ if (metricOptions) {
520
+ this.metricHandlers = [];
521
+ metricOptions.forEach(option => {
522
+ const handler = (e) => {
523
+ const metric = e.target.dataset.metric;
524
+ this.changeMetric(metric);
525
+
526
+ // Update active state
527
+ metricOptions.forEach(opt => opt.classList.remove('active'));
528
+ e.target.classList.add('active');
529
+
530
+ // Close dropdown
531
+ dropdown.classList.remove('show');
532
+ };
533
+
534
+ option.addEventListener('click', handler);
535
+ this.metricHandlers.push({ element: option, handler });
536
+ });
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Remove existing settings event listeners to prevent duplicates
542
+ */
543
+ removeSettingsListeners() {
544
+ const settingsButton = document.querySelector('.heatmap-settings');
545
+
546
+ if (this.settingsClickHandler && settingsButton) {
547
+ settingsButton.removeEventListener('click', this.settingsClickHandler);
548
+ }
549
+
550
+ if (this.documentClickHandler) {
551
+ document.removeEventListener('click', this.documentClickHandler);
552
+ }
553
+
554
+ if (this.metricHandlers) {
555
+ this.metricHandlers.forEach(({ element, handler }) => {
556
+ element.removeEventListener('click', handler);
557
+ });
558
+ this.metricHandlers = [];
559
+ }
560
+ }
561
+
562
+ /**
563
+ * Change the activity metric (messages or tools)
564
+ */
565
+ async changeMetric(metric) {
566
+ console.log(`🔥 Changing metric to: ${metric}`);
567
+ this.currentMetric = metric;
568
+
569
+ // Recalculate thresholds based on new metric
570
+ if (this.activityData) {
571
+ await this.recalculateForMetric(metric);
572
+ await this.renderHeatmap();
573
+ this.updateTitle();
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Recalculate activity data for the new metric
579
+ */
580
+ async recalculateForMetric(metric) {
581
+ const { dailyActivity } = this.activityData;
582
+
583
+ // Recalculate thresholds based on the new metric
584
+ const metricCounts = Array.from(dailyActivity.values())
585
+ .map(activity => activity[metric] || 0)
586
+ .filter(count => count > 0)
587
+ .sort((a, b) => a - b);
588
+
589
+ const thresholds = this.calculateDynamicThresholds(metricCounts);
590
+
591
+ // Calculate new total activity
592
+ let totalActivity = 0;
593
+ dailyActivity.forEach(activity => {
594
+ totalActivity += activity[metric] || 0;
595
+ });
596
+
597
+ this.activityData.thresholds = thresholds;
598
+ this.activityData.totalActivity = totalActivity;
599
+
600
+ console.log(`🔥 Recalculated for ${metric}:`, thresholds);
601
+ console.log(`🔥 New total: ${totalActivity}`);
602
+ }
603
+
604
+ /**
605
+ * Show tooltip on day hover
606
+ */
607
+ showTooltip(event) {
608
+ const day = event.target;
609
+ const date = day.dataset.date;
610
+ const activity = JSON.parse(day.dataset.activity || '{}');
611
+
612
+ if (!date) return;
613
+
614
+ // Fix timezone issue: parse date as local instead of UTC
615
+ const [year, month, dayNum] = date.split('-').map(Number);
616
+ const dateObj = new Date(year, month - 1, dayNum); // month is 0-indexed
617
+ const formattedDate = dateObj.toLocaleDateString('en-US', {
618
+ weekday: 'short',
619
+ month: 'short',
620
+ day: 'numeric',
621
+ year: 'numeric'
622
+ });
623
+
624
+ const currentValue = activity[this.currentMetric] || 0;
625
+ const otherMetric = this.currentMetric === 'messages' ? 'conversations' : 'messages';
626
+ const otherValue = activity[otherMetric] || 0;
627
+
628
+ let activityText = `No ${this.currentMetric}`;
629
+ if (currentValue > 0) {
630
+ const suffix = currentValue === 1 ? '' : 's';
631
+ activityText = `${currentValue} ${this.currentMetric.slice(0, -1)}${suffix}`;
632
+
633
+ if (otherValue > 0) {
634
+ const otherSuffix = otherValue === 1 ? '' : 's';
635
+ const otherLabel = otherMetric === 'conversations' ? 'conversation' : 'message';
636
+ activityText += ` • ${otherValue} ${otherLabel}${otherSuffix}`;
637
+ }
638
+ }
639
+
640
+ this.tooltip.querySelector('.heatmap-tooltip-date').textContent = formattedDate;
641
+ this.tooltip.querySelector('.heatmap-tooltip-activity').textContent = activityText;
642
+
643
+ this.tooltip.classList.add('show');
644
+ this.updateTooltipPosition(event);
645
+ }
646
+
647
+ /**
648
+ * Hide tooltip
649
+ */
650
+ hideTooltip() {
651
+ this.tooltip.classList.remove('show');
652
+ }
653
+
654
+ /**
655
+ * Update tooltip position
656
+ */
657
+ updateTooltipPosition(event) {
658
+ const tooltip = this.tooltip;
659
+ const rect = tooltip.getBoundingClientRect();
660
+
661
+ let x = event.pageX + 10;
662
+ let y = event.pageY - rect.height - 10;
663
+
664
+ // Adjust if tooltip would go off screen
665
+ if (x + rect.width > window.innerWidth) {
666
+ x = event.pageX - rect.width - 10;
667
+ }
668
+
669
+ if (y < window.scrollY) {
670
+ y = event.pageY + 10;
671
+ }
672
+
673
+ tooltip.style.left = `${x}px`;
674
+ tooltip.style.top = `${y}px`;
675
+ }
676
+
677
+ /**
678
+ * Update the title with total activity count
679
+ */
680
+ updateTitle() {
681
+ if (!this.activityData) return;
682
+
683
+ const { totalActivity } = this.activityData;
684
+ const titleElement = document.getElementById('activity-total');
685
+
686
+ if (titleElement) {
687
+ // Ensure totalActivity is a number
688
+ const activityCount = totalActivity || 0;
689
+
690
+ if (this.currentMetric === 'messages') {
691
+ titleElement.innerHTML = `${this.formatNumber(activityCount)} <span style="color: #ff7f50;">Claude Code</span> ${this.currentMetric} in the last year`;
692
+ } else if (this.currentMetric === 'tools') {
693
+ titleElement.innerHTML = `${this.formatNumber(activityCount)} <span style="color: #ff7f50;">Claude Code</span> ${this.currentMetric} in the last year`;
694
+ } else {
695
+ titleElement.innerHTML = `${this.formatNumber(activityCount)} ${this.currentMetric} in the last year`;
696
+ }
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Format large numbers with commas
702
+ */
703
+ formatNumber(num) {
704
+ // Handle undefined, null, or non-numeric values
705
+ if (num == null || typeof num !== 'number' || isNaN(num)) {
706
+ return '0';
707
+ }
708
+
709
+ if (num >= 1000) {
710
+ return (num / 1000).toFixed(1) + 'k';
711
+ }
712
+ return num.toLocaleString();
713
+ }
714
+
715
+ /**
716
+ * Show error state
717
+ */
718
+ showErrorState() {
719
+ const container = this.container.querySelector('.activity-heatmap-container');
720
+ container.innerHTML = `
721
+ <div class="heatmap-empty-state">
722
+ <div class="heatmap-empty-icon">📊</div>
723
+ <div class="heatmap-empty-text">Unable to load activity data</div>
724
+ <div class="heatmap-empty-subtext">Please try refreshing the page</div>
725
+ </div>
726
+ `;
727
+ }
728
+
729
+ /**
730
+ * Clear cache and refresh the heatmap data
731
+ */
732
+ async clearCacheAndRefresh() {
733
+ try {
734
+ console.log('🔥 Clearing cache and refreshing heatmap data...');
735
+
736
+ // Clear frontend cache
737
+ this.dataService.clearCache();
738
+
739
+ // Clear backend cache
740
+ await fetch('/api/clear-cache', { method: 'POST' });
741
+
742
+ // Force reload activity data
743
+ await this.loadActivityData();
744
+ this.positionMonthLabels();
745
+
746
+ console.log('✅ Cache cleared and data refreshed');
747
+ } catch (error) {
748
+ console.error('❌ Error clearing cache:', error);
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Refresh the heatmap data
754
+ */
755
+ async refresh() {
756
+ console.log('🔥 Refreshing heatmap data...');
757
+ await this.loadActivityData();
758
+ this.positionMonthLabels();
759
+ }
760
+
761
+ /**
762
+ * Cleanup resources
763
+ */
764
+ destroy() {
765
+ if (this.tooltip && this.tooltip.parentNode) {
766
+ this.tooltip.parentNode.removeChild(this.tooltip);
767
+ }
768
+
769
+ // Remove resize listener
770
+ if (this.resizeHandler) {
771
+ window.removeEventListener('resize', this.resizeHandler);
772
+ }
773
+
774
+ // Remove settings listeners
775
+ this.removeSettingsListeners();
776
+
777
+ console.log('🔥 ActivityHeatmap destroyed');
778
+ }
779
+ }
780
+
781
+ // Make it globally available
782
+ window.ActivityHeatmap = ActivityHeatmap;