UNPKG

claude-code-templates

Version:

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

599 lines (523 loc) 20.1 kB
/** * SessionTimer Component * Displays current session information, timing, token usage, and Max plan limits */ class SessionTimer { constructor(container, dataService, stateService) { this.container = container; this.dataService = dataService; this.stateService = stateService; this.sessionData = null; this.updateInterval = null; this.isInitialized = false; this.refreshInterval = 5000; // 5 seconds to reduce server load this.SESSION_DURATION = 5 * 60 * 60 * 1000; // 5 hours in milliseconds this.isTooltipVisible = false; // Track tooltip state globally } /** * Initialize the session timer component */ async initialize() { if (this.isInitialized) return; try { await this.render(); await this.loadSessionData(); this.startAutoUpdate(); this.isInitialized = true; console.log('📊 SessionTimer component initialized'); } catch (error) { console.error('Error initializing SessionTimer:', error); this.showError('Failed to initialize session timer'); } } /** * Render the session timer UI */ async render() { this.container.innerHTML = ` <div class="session-timer-accordion"> <div class="session-timer-header" onclick="window.sessionTimer?.toggleAccordion()"> <div class="session-timer-title-section"> <span class="session-timer-chevron">▼</span> <h3 class="session-timer-title">Current Session</h3> </div> <div class="session-timer-status-inline"> <span class="session-timer-status-dot"></span> <span class="session-timer-status-text">Loading...</span> </div> </div> <div class="session-timer-content" id="session-timer-content"> <div class="session-loading-state"> <div class="session-spinner"></div> <span>Loading session data...</span> </div> <div class="session-display" style="display: none;"> <!-- Session timer display will be populated here --> </div> <div class="session-warnings"> <!-- Warnings will be displayed here --> </div> </div> </div> `; // Make component globally accessible for button clicks window.sessionTimer = this; this.isExpanded = true; // Start expanded } /** * Load session data from API */ async loadSessionData() { try { this.sessionData = await this.dataService.getSessionData(); this.updateDisplay(); } catch (error) { console.error('Error loading session data:', error); this.showError('Failed to load session data'); } } /** * Refresh session data manually */ async refreshSessionData() { const refreshBtn = this.container.querySelector('.session-refresh-btn button'); if (refreshBtn) { refreshBtn.disabled = true; refreshBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; } try { await this.loadSessionData(); } finally { if (refreshBtn) { refreshBtn.disabled = false; refreshBtn.innerHTML = '<i class="fas fa-sync-alt"></i>'; } } } /** * Update the display with current session data */ updateDisplay() { if (!this.sessionData) return; const loadingState = this.container.querySelector('.session-loading-state'); const sessionDisplay = this.container.querySelector('.session-display'); const warningsContainer = this.container.querySelector('.session-warnings'); // Update title with plan name const titleElement = this.container.querySelector('.session-timer-title'); if (titleElement && this.sessionData.limits) { titleElement.textContent = `Current Session - ${this.sessionData.limits.name}`; } if (loadingState) loadingState.style.display = 'none'; if (sessionDisplay) sessionDisplay.style.display = 'block'; // Update session display this.renderSessionInfo(sessionDisplay); // Update warnings this.renderWarnings(warningsContainer); } /** * Load Claude session information */ async loadClaudeSessionInfo() { try { const response = await fetch('/api/claude/session'); if (!response.ok) throw new Error('Failed to fetch session info'); return await response.json(); } catch (error) { console.error('Error loading Claude session info:', error); return null; } } /** * Render session information */ async renderSessionInfo(container) { const { timer, userPlan, monthlyUsage, limits } = this.sessionData; // Load Claude session info const claudeSessionInfo = await this.loadClaudeSessionInfo(); // Update header status this.updateHeaderStatus(timer, claudeSessionInfo); if (!timer.hasActiveSession) { container.innerHTML = ` <div class="session-timer-empty"> <div class="session-timer-empty-text">No active session</div> <div class="session-timer-empty-subtext">Start a conversation to begin tracking</div> </div> `; return; } // Calculate progress colors based on usage const timeProgressPercentage = Math.round(((this.SESSION_DURATION - timer.timeRemaining) / this.SESSION_DURATION) * 100); const getProgressColor = (percentage) => { if (percentage < 50) return '#3fb950'; if (percentage < 80) return '#f97316'; return '#f85149'; }; // For messages, use a relative progress based on typical usage patterns // Since Claude uses dynamic limits, we'll show relative activity level const messageActivityLevel = Math.min(100, (timer.messagesUsed / (timer.messagesEstimate || 45)) * 100); const messageProgressColor = timer.messagesEstimate ? getProgressColor(messageActivityLevel) : '#3fb950'; const timeProgressColor = getProgressColor(timeProgressPercentage); // Format time remaining with better UX const formatTimeRemaining = (ms) => { const hours = Math.floor(ms / (1000 * 60 * 60)); const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((ms % (1000 * 60)) / 1000); if (hours > 0) { return `${hours}h ${minutes}m`; } else if (minutes > 0) { return `${minutes}m ${seconds}s`; } else { return `${seconds}s`; } }; container.innerHTML = ` <div class="session-timer-compact"> <div class="session-timer-row"> <div class="session-timer-time-compact"> <div class="session-timer-time-value">${claudeSessionInfo && claudeSessionInfo.hasSession ? (claudeSessionInfo.estimatedTimeRemaining.isExpired ? 'Expired' : claudeSessionInfo.estimatedTimeRemaining.formatted) : formatTimeRemaining(timer.timeRemaining) }</div> <div class="session-timer-time-label">remaining</div> </div> <div class="session-timer-progress-compact"> <div class="session-timer-progress-item"> <div class="session-timer-progress-header"> <span class="session-timer-progress-label">Messages</span> <span class="session-timer-progress-value"> ${timer.messagesUsed}${timer.messagesEstimate ? `/${timer.messagesEstimate} est.` : ''} <span class="session-timer-info-icon" data-tooltip="message-info" title="Message calculation info"> ℹ️ </span> </span> </div> <div class="session-timer-progress-bar"> <div class="session-timer-progress-fill" style="width: ${messageActivityLevel}%; background-color: ${messageProgressColor};"></div> </div> ${timer.usageDetails && timer.usageDetails.shortMessages > 0 ? ` <div class="session-timer-usage-details"> <small>Short: ${timer.usageDetails.shortMessages}, Long: ${timer.usageDetails.longMessages}</small> </div> ` : ''} </div> <div class="session-timer-progress-item"> <div class="session-timer-progress-header"> <span class="session-timer-progress-label">Session Time</span> <span class="session-timer-progress-value">${claudeSessionInfo && claudeSessionInfo.hasSession ? `${claudeSessionInfo.sessionDuration.formatted}/${claudeSessionInfo.sessionLimit.formatted}` : `${formatTimeRemaining(this.SESSION_DURATION - timer.timeRemaining)}/5h` }</span> </div> <div class="session-timer-progress-bar"> <div class="session-timer-progress-fill" style="width: ${claudeSessionInfo && claudeSessionInfo.hasSession ? Math.min(100, (claudeSessionInfo.sessionDuration.ms / claudeSessionInfo.sessionLimit.ms) * 100) : timeProgressPercentage }%; background-color: ${claudeSessionInfo && claudeSessionInfo.hasSession ? (claudeSessionInfo.estimatedTimeRemaining.isExpired ? '#f85149' : claudeSessionInfo.estimatedTimeRemaining.ms < 600000 ? '#f97316' : '#3fb950') : timeProgressColor };"></div> </div> </div> </div> </div> </div> `; // Add popover to the container this.addPopover(container); // Add popover event listeners this.setupPopoverEvents(container); } /** * Add popover to the container */ addPopover(container) { // Check if popover already exists const existingPopover = document.getElementById('message-info-tooltip'); if (existingPopover) { // Don't recreate if it already exists, just return return; } // Create popover HTML const popoverHTML = ` <div class="session-timer-tooltip" id="message-info-tooltip" style="display: ${this.isTooltipVisible ? 'block' : 'none'};"> <div class="session-timer-tooltip-content"> <h4>Claude Pro Plan Usage</h4> <p>Shows user messages (prompts) sent in this session. Claude Pro doesn't have fixed message limits - usage is based on message complexity, conversation length, and current capacity.</p> <p>The "45 est." is a rough estimate for typical short messages (~200 sentences). You may send more or fewer messages depending on complexity.</p> <p><strong>Projects benefit from caching</strong> - repeated content uses fewer resources, allowing more messages.</p> <div class="session-timer-tooltip-link"> <a href="https://support.anthropic.com/en/articles/9797557-usage-limit-best-practices" target="_blank" rel="noopener noreferrer"> <i class="fas fa-external-link-alt"></i> Usage Limit Best Practices </a> </div> </div> </div> `; // Add popover to document body for better positioning document.body.insertAdjacentHTML('beforeend', popoverHTML); } /** * Setup popover event listeners */ setupPopoverEvents(container) { const infoIcon = container.querySelector('.session-timer-info-icon'); const tooltip = document.getElementById('message-info-tooltip'); if (infoIcon && tooltip) { // Remove existing listeners to prevent duplicates const existingClickHandler = infoIcon.clickHandler; if (existingClickHandler) { infoIcon.removeEventListener('click', existingClickHandler); } // Create new click handler const clickHandler = (e) => { e.stopPropagation(); if (this.isTooltipVisible) { this.hideTooltip(tooltip); this.isTooltipVisible = false; } else { this.showTooltip(tooltip, infoIcon); this.isTooltipVisible = true; } }; // Store handler reference for cleanup infoIcon.clickHandler = clickHandler; // Add click listener infoIcon.addEventListener('click', clickHandler); // Setup document click listener only once if (!this.documentClickSetup) { document.addEventListener('click', (e) => { if (this.isTooltipVisible && !tooltip.contains(e.target) && !infoIcon.contains(e.target)) { this.hideTooltip(tooltip); this.isTooltipVisible = false; } }); this.documentClickSetup = true; } // Prevent tooltip from closing when clicking inside it tooltip.addEventListener('click', (e) => { e.stopPropagation(); }); } } /** * Show tooltip with positioning */ showTooltip(tooltip, trigger) { const rect = trigger.getBoundingClientRect(); tooltip.style.display = 'block'; const tooltipRect = tooltip.getBoundingClientRect(); // Position tooltip below the icon tooltip.style.left = `${rect.left - tooltipRect.width / 2 + rect.width / 2}px`; tooltip.style.top = `${rect.bottom + 10}px`; // Adjust position if tooltip goes off-screen horizontally const viewportWidth = window.innerWidth; const tooltipLeft = parseInt(tooltip.style.left); if (tooltipLeft < 10) { tooltip.style.left = '10px'; } else if (tooltipLeft + tooltipRect.width > viewportWidth - 10) { tooltip.style.left = `${viewportWidth - tooltipRect.width - 10}px`; } // Adjust position if tooltip goes off-screen vertically const viewportHeight = window.innerHeight; const tooltipTop = parseInt(tooltip.style.top); if (tooltipTop + tooltipRect.height > viewportHeight - 10) { // If it goes off-screen below, position it above the trigger tooltip.style.top = `${rect.top - tooltipRect.height - 10}px`; } this.isTooltipVisible = true; } /** * Hide tooltip */ hideTooltip(tooltip) { tooltip.style.display = 'none'; this.isTooltipVisible = false; } /** * Render warnings if any */ renderWarnings(container) { if (!this.sessionData || !this.sessionData.warnings || this.sessionData.warnings.length === 0) { container.innerHTML = ''; return; } const warnings = this.sessionData.warnings .filter(warning => warning.type.includes('session') || warning.type.includes('monthly')) .slice(0, 3); // Show max 3 warnings if (warnings.length === 0) { container.innerHTML = ''; return; } const warningHtml = warnings.map(warning => ` <div class="session-warning ${warning.level}"> <i class="fas ${this.getWarningIcon(warning.level)}"></i> <span>${warning.message}</span> ${warning.timeRemaining ? `<small>Time remaining: ${this.formatTimeRemaining(warning.timeRemaining)}</small>` : ''} </div> `).join(''); container.innerHTML = `<div class="warnings-list">${warningHtml}</div>`; } /** * Get message estimate based on current plan */ getMessageEstimate() { if (!this.sessionData || !this.sessionData.limits) return 45; return this.sessionData.limits.estimatedMessagesPerSession || 45; } /** * Get plan badge CSS class */ getPlanBadgeClass(planType) { const classes = { 'premium': 'plan-premium', 'standard': 'plan-standard', 'pro': 'plan-pro' }; return classes[planType] || 'plan-standard'; } /** * Get warning icon based on level */ getWarningIcon(level) { const icons = { 'error': 'fa-exclamation-triangle', 'warning': 'fa-exclamation-circle', 'info': 'fa-info-circle' }; return icons[level] || 'fa-info-circle'; } /** * Format time remaining for display */ formatTimeRemaining(milliseconds) { if (milliseconds <= 0) return '0m'; const hours = Math.floor(milliseconds / (60 * 60 * 1000)); const minutes = Math.floor((milliseconds % (60 * 60 * 1000)) / (60 * 1000)); if (hours > 0) { return `${hours}h ${minutes}m`; } return `${minutes}m`; } /** * Format numbers with appropriate units */ formatNumber(num) { if (num >= 1000000) { return (num / 1000000).toFixed(1) + 'M'; } if (num >= 1000) { return (num / 1000).toFixed(1) + 'K'; } return num.toString(); } /** * Show error message */ showError(message) { const loadingState = this.container.querySelector('.session-loading-state'); const sessionDisplay = this.container.querySelector('.session-display'); if (loadingState) loadingState.style.display = 'none'; if (sessionDisplay) { sessionDisplay.style.display = 'block'; sessionDisplay.innerHTML = ` <div class="session-timer-error"> <div class="session-timer-error-text">${message}</div> <button class="session-timer-retry-btn" onclick="window.sessionTimer?.refreshSessionData()">Retry</button> </div> `; } } /** * Start automatic updates */ startAutoUpdate() { // Update every 1 second for real-time display this.updateInterval = setInterval(() => { this.loadSessionData(); }, this.refreshInterval); } /** * Stop automatic updates */ stopAutoUpdate() { if (this.updateInterval) { clearInterval(this.updateInterval); this.updateInterval = null; } } /** * Handle real-time updates */ handleRealtimeUpdate(data) { if (data.type === 'session_update' && data.sessionData) { this.sessionData = data.sessionData; this.updateDisplay(); } } /** * Toggle accordion open/closed */ toggleAccordion() { const content = this.container.querySelector('#session-timer-content'); const chevron = this.container.querySelector('.session-timer-chevron'); if (this.isExpanded) { content.style.display = 'none'; chevron.textContent = '▶'; this.isExpanded = false; } else { content.style.display = 'block'; chevron.textContent = '▼'; this.isExpanded = true; } } /** * Update header status display */ updateHeaderStatus(timer, claudeSessionInfo) { const statusDot = this.container.querySelector('.session-timer-status-dot'); const statusText = this.container.querySelector('.session-timer-status-text'); // If we have Claude session info, prioritize that if (claudeSessionInfo && claudeSessionInfo.hasSession) { if (claudeSessionInfo.estimatedTimeRemaining.isExpired) { statusDot.className = 'session-timer-status-dot expired'; statusText.textContent = 'Session Expired'; } else if (claudeSessionInfo.estimatedTimeRemaining.ms < 600000) { // < 10 minutes statusDot.className = 'session-timer-status-dot warning'; statusText.textContent = 'Ending Soon'; } else { statusDot.className = 'session-timer-status-dot active'; statusText.textContent = 'Active'; } } else if (!timer.hasActiveSession) { statusDot.className = 'session-timer-status-dot inactive'; statusText.textContent = 'Inactive'; } else if (timer.timeRemaining < 600000) { statusDot.className = 'session-timer-status-dot warning'; statusText.textContent = 'Ending Soon'; } else { statusDot.className = 'session-timer-status-dot active'; statusText.textContent = 'Active'; } } /** * Cleanup component */ destroy() { this.stopAutoUpdate(); if (window.sessionTimer === this) { delete window.sessionTimer; } this.isInitialized = false; } } // Export for module systems if (typeof module !== 'undefined' && module.exports) { module.exports = SessionTimer; } // Global registration for browser if (typeof window !== 'undefined') { window.SessionTimer = SessionTimer; }