UNPKG

claude-code-templates

Version:

CLI tool to setup Claude Code configurations with framework-specific commands, automation hooks and MCP Servers for your projects

782 lines (651 loc) 26.1 kB
/** * ActivityHeatmap - GitHub-style contribution calendar for Claude Code activity * Shows daily Claude Code usage over the last year with orange theme */ class ActivityHeatmap { constructor(container, dataService) { this.container = container; this.dataService = dataService; this.activityData = null; this.tooltip = null; this.currentYear = new Date().getFullYear(); this.currentMetric = 'messages'; // Default metric console.log('🔥 ActivityHeatmap initialized'); } /** * Initialize the heatmap component */ async initialize() { try { console.log('🔥 Initializing ActivityHeatmap...'); await this.render(); await this.loadActivityData(); console.log('✅ ActivityHeatmap initialized successfully'); } catch (error) { console.error('❌ Failed to initialize ActivityHeatmap:', error); this.showErrorState(); } } /** * Render the heatmap structure */ async render() { this.container.innerHTML = ` <div class="activity-heatmap-container"> <div class="heatmap-loading"> <div class="heatmap-loading-spinner"></div> <span>Loading activity...</span> </div> </div> <div class="heatmap-tooltip" id="heatmap-tooltip"> <div class="heatmap-tooltip-date"></div> <div class="heatmap-tooltip-activity"></div> </div> `; this.tooltip = document.getElementById('heatmap-tooltip'); } /** * Load activity data from the API */ async loadActivityData() { try { console.log('🔥 Loading activity data...'); // Get complete activity data from backend (pre-processed with tools) const response = await this.dataService.cachedFetch('/api/activity'); if (response && response.dailyActivity) { console.log(`🔥 Loaded pre-processed activity data: ${response.dailyActivity.length} active days`); // Use pre-processed data from backend instead of processing raw conversations const dailyActivityMap = new Map(); response.dailyActivity.forEach(day => { dailyActivityMap.set(day.date, day); }); this.activityData = this.processPrecomputedActivityData(dailyActivityMap); await this.renderHeatmap(); this.updateTitle(); } else { throw new Error('No activity data available'); } } catch (error) { console.error('❌ Error loading activity data:', error); this.showErrorState(); } } /** * Process pre-computed activity data from backend */ processPrecomputedActivityData(dailyActivityMap) { console.log(`🔥 Processing ${dailyActivityMap.size} days of pre-computed data...`); // Calculate thresholds based on current metric const metricCounts = Array.from(dailyActivityMap.values()) .map(activity => activity[this.currentMetric] || 0) .filter(count => count > 0) .sort((a, b) => a - b); const thresholds = this.calculateDynamicThresholds(metricCounts); // Calculate total activity for current metric let totalActivity = 0; let totalTools = 0; let totalMessages = 0; dailyActivityMap.forEach(activity => { totalActivity += activity[this.currentMetric] || 0; totalTools += activity.tools || 0; totalMessages += activity.messages || 0; }); console.log(`🔥 Pre-computed data stats: ${totalMessages} messages, ${totalTools} tools`); console.log(`🔥 Current metric (${this.currentMetric}): ${totalActivity} total`); console.log(`🔥 Dynamic thresholds:`, thresholds); console.log(`🔥 Sample ${this.currentMetric} counts:`, metricCounts.slice(0, 10), '...', metricCounts.slice(-10)); return { dailyActivity: dailyActivityMap, totalActivity, activeDays: dailyActivityMap.size, thresholds }; } /** * Process conversation data into daily activity counts (legacy method) */ processActivityData(conversations) { const dailyActivity = new Map(); const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const today = new Date(); console.log(`🔥 Processing ${conversations.length} conversations for activity data...`); console.log(`🔥 Date range: ${oneYearAgo.toLocaleDateString()} to ${today.toLocaleDateString()}`); console.log(`🔥 Cutoff timestamp: ${oneYearAgo.getTime()}`); let validConversations = 0; let latestDate = null; let oldestDate = null; let beforeOneYearCount = 0; // Sample some conversations to see their date formats and data structure console.log(`🔥 Sampling first 5 conversations:`); conversations.slice(0, 5).forEach((conv, i) => { console.log(` ${i+1}: ${conv.filename} - lastModified: ${conv.lastModified} (${new Date(conv.lastModified).toLocaleDateString()})`); console.log(` messages: ${conv.messageCount || 0}, tokens: ${conv.tokens || 0}, toolUsage: ${conv.toolUsage?.totalToolCalls || 0}`); }); conversations.forEach((conversation, index) => { if (!conversation.lastModified) { if (index < 5) console.log(`⚠️ Conversation ${index} has no lastModified:`, conversation); return; } const date = new Date(conversation.lastModified); // Track date range if (!latestDate || date > latestDate) latestDate = date; if (!oldestDate || date < oldestDate) oldestDate = date; if (date < oneYearAgo) { beforeOneYearCount++; if (beforeOneYearCount <= 5) { console.log(`⚠️ Excluding old conversation: ${date.toISOString()} (${conversation.filename})`); } return; } validConversations++; const dateKey = date.toISOString().split('T')[0]; // YYYY-MM-DD const current = dailyActivity.get(dateKey) || { conversations: 0, tokens: 0, messages: 0, tools: 0 }; current.conversations += 1; current.tokens += conversation.tokens || 0; current.messages += conversation.messageCount || 0; current.tools += (conversation.toolUsage?.totalToolCalls || 0); dailyActivity.set(dateKey, current); }); console.log(`🔥 Valid conversations in last year: ${validConversations}`); console.log(`🔥 Excluded conversations (older than 1 year): ${beforeOneYearCount}`); console.log(`🔥 Complete date range in data: ${oldestDate?.toLocaleDateString()} to ${latestDate?.toLocaleDateString()}`); console.log(`🔥 One year ago cutoff: ${oneYearAgo.toLocaleDateString()}`); // Show first and last few conversations for debugging const sortedDates = Array.from(dailyActivity.keys()).sort(); console.log(`🔥 First 5 activity dates:`, sortedDates.slice(0, 5)); console.log(`🔥 Last 5 activity dates:`, sortedDates.slice(-5)); console.log(`🔥 Total activity days: ${sortedDates.length}`); // Calculate total activity for the year (based on current metric) let totalActivity = 0; dailyActivity.forEach(activity => { totalActivity += activity[this.currentMetric] || 0; }); // Calculate dynamic thresholds based on data distribution const messageCounts = Array.from(dailyActivity.values()) .map(activity => activity[this.currentMetric] || 0) .filter(count => count > 0) .sort((a, b) => a - b); const thresholds = this.calculateDynamicThresholds(messageCounts); // Calculate total tools for debugging let totalTools = 0; let totalMessages = 0; dailyActivity.forEach(activity => { totalTools += activity.tools || 0; totalMessages += activity.messages || 0; }); console.log(`🔥 Processed activity data: ${dailyActivity.size} active days, ${totalActivity} total ${this.currentMetric}`); console.log(`🔥 Debug totals: ${totalMessages} messages, ${totalTools} tools`); console.log(`🔥 ${this.currentMetric} range: ${Math.min(...messageCounts)} to ${Math.max(...messageCounts)} ${this.currentMetric} per day`); console.log(`🔥 Dynamic thresholds:`, thresholds); console.log(`🔥 Sample ${this.currentMetric} counts:`, messageCounts.slice(0, 10), '...', messageCounts.slice(-10)); return { dailyActivity, totalActivity, activeDays: dailyActivity.size, thresholds }; } /** * Calculate dynamic thresholds based on data distribution * Ensures that even 1 message shows visible color (like GitHub) */ calculateDynamicThresholds(messageCounts) { if (messageCounts.length === 0) { return { level1: 1, level2: 5, level3: 15, level4: 30 }; } const len = messageCounts.length; const max = messageCounts[len - 1]; const min = messageCounts[0]; // ALWAYS start level1 at 1 so any activity shows color let level1 = 1; let level2, level3, level4; if (len <= 4) { // Very few data points - simple distribution level2 = Math.max(2, Math.ceil(max * 0.3)); level3 = Math.max(level2 + 1, Math.ceil(max * 0.6)); level4 = Math.max(level3 + 1, Math.ceil(max * 0.8)); } else { // Use percentiles but ensure good visual distribution const p33 = messageCounts[Math.floor(len * 0.33)] || 2; const p66 = messageCounts[Math.floor(len * 0.66)] || 3; const p85 = messageCounts[Math.floor(len * 0.85)] || 4; // Ensure reasonable spacing between levels level2 = Math.max(2, Math.min(p33, max * 0.2)); level3 = Math.max(level2 + 1, Math.min(p66, max * 0.5)); level4 = Math.max(level3 + 1, Math.min(p85, max * 0.75)); } return { level1, level2, level3, level4 }; } /** * Render the heatmap calendar */ async renderHeatmap() { if (!this.activityData) return; const { dailyActivity, totalActivity } = this.activityData; // Generate calendar structure const calendarData = this.generateCalendarData(dailyActivity); const container = this.container.querySelector('.activity-heatmap-container'); const modeClass = this.currentMetric === 'tools' ? 'tools-mode' : ''; container.className = `activity-heatmap-container ${modeClass}`; container.innerHTML = ` <div class="heatmap-header"> <div class="heatmap-legend"> <span class="heatmap-legend-text">Less</span> <div class="heatmap-legend-scale"> <div class="heatmap-legend-square level-0"></div> <div class="heatmap-legend-square level-1"></div> <div class="heatmap-legend-square level-2"></div> <div class="heatmap-legend-square level-3"></div> <div class="heatmap-legend-square level-4"></div> </div> <span class="heatmap-legend-more">More</span> </div> </div> <div class="heatmap-grid"> <div class="heatmap-months" id="heatmap-months-container"> ${calendarData.months.map((month, index) => `<div class="heatmap-month" data-week-index="${index}">${month}</div>` ).join('')} </div> <div class="heatmap-weekdays"> <div class="heatmap-weekday">Mon</div> <div class="heatmap-weekday"></div> <div class="heatmap-weekday">Wed</div> <div class="heatmap-weekday"></div> <div class="heatmap-weekday">Fri</div> <div class="heatmap-weekday"></div> <div class="heatmap-weekday"></div> </div> <div class="heatmap-weeks"> ${calendarData.weeks.map(week => this.renderWeek(week, dailyActivity)).join('')} </div> </div> `; this.attachEventListeners(); this.attachSettingsListeners(); this.positionMonthLabels(); // Re-position months on window resize this.resizeHandler = () => this.positionMonthLabels(); window.addEventListener('resize', this.resizeHandler); } /** * Position month labels based on actual week positions */ positionMonthLabels() { setTimeout(() => { const weeksContainer = this.container.querySelector('.heatmap-weeks'); const monthsContainer = this.container.querySelector('#heatmap-months-container'); const monthElements = monthsContainer?.querySelectorAll('.heatmap-month'); const weekElements = weeksContainer?.children; if (!weeksContainer || !monthsContainer || !monthElements || !weekElements) return; // Calculate the actual width and position of each week column Array.from(monthElements).forEach((monthEl, index) => { if (index < weekElements.length && monthEl.textContent.trim()) { const weekEl = weekElements[index]; const weekRect = weekEl.getBoundingClientRect(); const containerRect = monthsContainer.getBoundingClientRect(); // Position month label at the start of its corresponding week const leftPosition = weekRect.left - containerRect.left; monthEl.style.left = `${leftPosition}px`; } }); }, 50); // Small delay to ensure DOM is fully rendered } /** * Generate calendar structure for the last year */ generateCalendarData(dailyActivity) { const today = new Date(); const todayEnd = new Date(today); todayEnd.setHours(23, 59, 59, 999); // End of today const oneYearAgo = new Date(today); oneYearAgo.setFullYear(today.getFullYear() - 1); oneYearAgo.setDate(today.getDate() + 1); // Find the start of the week (Sunday) for the start date const startDate = new Date(oneYearAgo); startDate.setDate(startDate.getDate() - startDate.getDay()); const weeks = []; const months = []; const current = new Date(startDate); // Generate weeks first - include today completely while (current <= todayEnd) { const week = []; for (let day = 0; day < 7; day++) { if (current <= todayEnd) { const dayDate = new Date(current); week.push(dayDate); } else { week.push(null); } current.setDate(current.getDate() + 1); } weeks.push(week); } // Generate month labels - show when month changes let lastDisplayedMonth = -1; for (let i = 0; i < weeks.length; i++) { const week = weeks[i]; let monthName = ''; if (week && week.length > 0) { // Get the most representative day of the week (middle of week) const middleDay = week[3] || week[2] || week[1] || week[0]; if (middleDay) { const currentMonth = middleDay.getMonth(); // Show month name if it's the first occurrence or if month changed if (currentMonth !== lastDisplayedMonth) { monthName = middleDay.toLocaleDateString('en-US', { month: 'short' }); lastDisplayedMonth = currentMonth; } } } months.push(monthName); } return { weeks, months }; } /** * Render a week column */ renderWeek(week, dailyActivity) { const weekHtml = week.map(date => { if (!date) return '<div class="heatmap-day empty"></div>'; const dateKey = date.toISOString().split('T')[0]; const activity = dailyActivity.get(dateKey); const level = this.getActivityLevel(activity); const modeClass = this.currentMetric === 'tools' ? 'tools-mode' : ''; return ` <div class="heatmap-day level-${level} ${modeClass}" data-date="${dateKey}" data-activity='${JSON.stringify(activity || { conversations: 0, tokens: 0, messages: 0, tools: 0 })}'> </div> `; }).join(''); return `<div class="heatmap-week">${weekHtml}</div>`; } /** * Calculate activity level based on current metric using dynamic thresholds */ getActivityLevel(activity) { if (!activity) return 0; const metricValue = activity[this.currentMetric] || 0; if (metricValue === 0) return 0; const thresholds = this.activityData?.thresholds; if (!thresholds) { // Fallback to static levels if thresholds not available if (metricValue >= 50) return 4; if (metricValue >= 30) return 3; if (metricValue >= 15) return 2; if (metricValue >= 1) return 1; return 0; } // Use dynamic thresholds for better distribution if (metricValue >= thresholds.level4) return 4; if (metricValue >= thresholds.level3) return 3; if (metricValue >= thresholds.level2) return 2; if (metricValue >= thresholds.level1) return 1; return 0; } /** * Attach event listeners for tooltips and interactions */ attachEventListeners() { const days = this.container.querySelectorAll('.heatmap-day'); days.forEach(day => { day.addEventListener('mouseenter', (e) => this.showTooltip(e)); day.addEventListener('mouseleave', () => this.hideTooltip()); day.addEventListener('mousemove', (e) => this.updateTooltipPosition(e)); }); } /** * Attach event listeners for settings dropdown */ attachSettingsListeners() { // Remove existing listeners first to prevent duplicates this.removeSettingsListeners(); const settingsButton = document.querySelector('.heatmap-settings'); const dropdown = document.getElementById('heatmap-settings-dropdown'); const metricOptions = dropdown?.querySelectorAll('.heatmap-metric-option'); console.log('🔥 Attaching settings listeners:', { settingsButton: !!settingsButton, dropdown: !!dropdown, metricOptions: metricOptions?.length || 0 }); if (settingsButton && dropdown) { // Store references to handlers for cleanup this.settingsClickHandler = (e) => { e.stopPropagation(); dropdown.classList.toggle('show'); console.log('🔥 Settings dropdown toggled:', dropdown.classList.contains('show')); }; this.documentClickHandler = (e) => { if (!settingsButton.contains(e.target) && !dropdown.contains(e.target)) { dropdown.classList.remove('show'); } }; // Add event listeners settingsButton.addEventListener('click', this.settingsClickHandler); document.addEventListener('click', this.documentClickHandler); } // Handle metric selection if (metricOptions) { this.metricHandlers = []; metricOptions.forEach(option => { const handler = (e) => { const metric = e.target.dataset.metric; this.changeMetric(metric); // Update active state metricOptions.forEach(opt => opt.classList.remove('active')); e.target.classList.add('active'); // Close dropdown dropdown.classList.remove('show'); }; option.addEventListener('click', handler); this.metricHandlers.push({ element: option, handler }); }); } } /** * Remove existing settings event listeners to prevent duplicates */ removeSettingsListeners() { const settingsButton = document.querySelector('.heatmap-settings'); if (this.settingsClickHandler && settingsButton) { settingsButton.removeEventListener('click', this.settingsClickHandler); } if (this.documentClickHandler) { document.removeEventListener('click', this.documentClickHandler); } if (this.metricHandlers) { this.metricHandlers.forEach(({ element, handler }) => { element.removeEventListener('click', handler); }); this.metricHandlers = []; } } /** * Change the activity metric (messages or tools) */ async changeMetric(metric) { console.log(`🔥 Changing metric to: ${metric}`); this.currentMetric = metric; // Recalculate thresholds based on new metric if (this.activityData) { await this.recalculateForMetric(metric); await this.renderHeatmap(); this.updateTitle(); } } /** * Recalculate activity data for the new metric */ async recalculateForMetric(metric) { const { dailyActivity } = this.activityData; // Recalculate thresholds based on the new metric const metricCounts = Array.from(dailyActivity.values()) .map(activity => activity[metric] || 0) .filter(count => count > 0) .sort((a, b) => a - b); const thresholds = this.calculateDynamicThresholds(metricCounts); // Calculate new total activity let totalActivity = 0; dailyActivity.forEach(activity => { totalActivity += activity[metric] || 0; }); this.activityData.thresholds = thresholds; this.activityData.totalActivity = totalActivity; console.log(`🔥 Recalculated for ${metric}:`, thresholds); console.log(`🔥 New total: ${totalActivity}`); } /** * Show tooltip on day hover */ showTooltip(event) { const day = event.target; const date = day.dataset.date; const activity = JSON.parse(day.dataset.activity || '{}'); if (!date) return; // Fix timezone issue: parse date as local instead of UTC const [year, month, dayNum] = date.split('-').map(Number); const dateObj = new Date(year, month - 1, dayNum); // month is 0-indexed const formattedDate = dateObj.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); const currentValue = activity[this.currentMetric] || 0; const otherMetric = this.currentMetric === 'messages' ? 'conversations' : 'messages'; const otherValue = activity[otherMetric] || 0; let activityText = `No ${this.currentMetric}`; if (currentValue > 0) { const suffix = currentValue === 1 ? '' : 's'; activityText = `${currentValue} ${this.currentMetric.slice(0, -1)}${suffix}`; if (otherValue > 0) { const otherSuffix = otherValue === 1 ? '' : 's'; const otherLabel = otherMetric === 'conversations' ? 'conversation' : 'message'; activityText += ` • ${otherValue} ${otherLabel}${otherSuffix}`; } } this.tooltip.querySelector('.heatmap-tooltip-date').textContent = formattedDate; this.tooltip.querySelector('.heatmap-tooltip-activity').textContent = activityText; this.tooltip.classList.add('show'); this.updateTooltipPosition(event); } /** * Hide tooltip */ hideTooltip() { this.tooltip.classList.remove('show'); } /** * Update tooltip position */ updateTooltipPosition(event) { const tooltip = this.tooltip; const rect = tooltip.getBoundingClientRect(); let x = event.pageX + 10; let y = event.pageY - rect.height - 10; // Adjust if tooltip would go off screen if (x + rect.width > window.innerWidth) { x = event.pageX - rect.width - 10; } if (y < window.scrollY) { y = event.pageY + 10; } tooltip.style.left = `${x}px`; tooltip.style.top = `${y}px`; } /** * Update the title with total activity count */ updateTitle() { if (!this.activityData) return; const { totalActivity } = this.activityData; const titleElement = document.getElementById('activity-total'); if (titleElement) { // Ensure totalActivity is a number const activityCount = totalActivity || 0; if (this.currentMetric === 'messages') { titleElement.innerHTML = `${this.formatNumber(activityCount)} <span style="color: #ff7f50;">Claude Code</span> ${this.currentMetric} in the last year`; } else if (this.currentMetric === 'tools') { titleElement.innerHTML = `${this.formatNumber(activityCount)} <span style="color: #ff7f50;">Claude Code</span> ${this.currentMetric} in the last year`; } else { titleElement.innerHTML = `${this.formatNumber(activityCount)} ${this.currentMetric} in the last year`; } } } /** * Format large numbers with commas */ formatNumber(num) { // Handle undefined, null, or non-numeric values if (num == null || typeof num !== 'number' || isNaN(num)) { return '0'; } if (num >= 1000) { return (num / 1000).toFixed(1) + 'k'; } return num.toLocaleString(); } /** * Show error state */ showErrorState() { const container = this.container.querySelector('.activity-heatmap-container'); container.innerHTML = ` <div class="heatmap-empty-state"> <div class="heatmap-empty-icon">📊</div> <div class="heatmap-empty-text">Unable to load activity data</div> <div class="heatmap-empty-subtext">Please try refreshing the page</div> </div> `; } /** * Clear cache and refresh the heatmap data */ async clearCacheAndRefresh() { try { console.log('🔥 Clearing cache and refreshing heatmap data...'); // Clear frontend cache this.dataService.clearCache(); // Clear backend cache await fetch('/api/clear-cache', { method: 'POST' }); // Force reload activity data await this.loadActivityData(); this.positionMonthLabels(); console.log('✅ Cache cleared and data refreshed'); } catch (error) { console.error('❌ Error clearing cache:', error); } } /** * Refresh the heatmap data */ async refresh() { console.log('🔥 Refreshing heatmap data...'); await this.loadActivityData(); this.positionMonthLabels(); } /** * Cleanup resources */ destroy() { if (this.tooltip && this.tooltip.parentNode) { this.tooltip.parentNode.removeChild(this.tooltip); } // Remove resize listener if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); } // Remove settings listeners this.removeSettingsListeners(); console.log('🔥 ActivityHeatmap destroyed'); } } // Make it globally available window.ActivityHeatmap = ActivityHeatmap;