/** * Message Cache Module for Realtime Message Display Synchronization * Feature: 012-realtime-message-display * * Implements optimistic UI updates for instant message display with async backend sync. * Uses sessionStorage for persistence across page reloads. * * Performance targets: * - <50ms optimistic message display * - <2s cache refresh * - <10ms sessionStorage access */ class MessageCache { constructor() { this.CACHE_KEY = 'prepmate_message_cache'; this.cache = null; this.sessionId = null; this.debugMode = localStorage.getItem('DEBUG') === 'true'; } /** * T008: Initialize cache for a session * Loads from sessionStorage or creates empty cache * @param {string} sessionId - Current session UUID */ init(sessionId) { this.sessionId = sessionId; this.cache = this.loadCacheFromBrowser(); // If no cache exists or session changed, create new cache if (!this.cache || this.cache.sessionId !== sessionId) { this.cache = { sessionId: sessionId, lastFetchTimestamp: Date.now(), confirmedMessages: [], pendingMessages: [] }; this.saveCacheToBrowser(); } this.logMetric('cache_init', 1, { session_id: sessionId }); } /** * T007: Load cache from sessionStorage * @returns {Object|null} Parsed cache object or null if not found */ loadCacheFromBrowser() { try { const startTime = performance.now(); const cached = sessionStorage.getItem(this.CACHE_KEY); const duration = performance.now() - startTime; this.logMetric('cache_load_duration_ms', duration); return cached ? JSON.parse(cached) : null; } catch (e) { console.error('Failed to load cache:', e); return null; } } /** * T007: Save cache to sessionStorage * T062: Monitor cache size and warn if approaching quota */ saveCacheToBrowser() { try { const startTime = performance.now(); const cacheJSON = JSON.stringify(this.cache); const sizeKB = (cacheJSON.length / 1024).toFixed(2); // T062: Warn if approaching 5MB limit (sessionStorage typically ~5-10MB) if (cacheJSON.length > 4 * 1024 * 1024) { // 4MB warning threshold console.warn(`Cache size approaching limit: ${sizeKB}KB / ~5120KB`); this.logMetric('cache_size_warning', 1, { size_kb: sizeKB }); } sessionStorage.setItem(this.CACHE_KEY, cacheJSON); const duration = performance.now() - startTime; this.logMetric('cache_save_duration_ms', duration, { size_kb: sizeKB }); } catch (e) { console.error('Failed to save cache:', e); // Check if storage is full if (e.name === 'QuotaExceededError') { console.warn('sessionStorage full, clearing old messages'); // Keep only last 25 confirmed messages this.cache.confirmedMessages = this.cache.confirmedMessages.slice(-25); try { sessionStorage.setItem(this.CACHE_KEY, JSON.stringify(this.cache)); } catch (e2) { console.error('Failed to save cache after cleanup:', e2); } } } } /** * T009: Structured console logging for metrics * @param {string} metric - Metric name * @param {number} value - Metric value * @param {Object} metadata - Additional metadata */ logMetric(metric, value, metadata = {}) { if (this.debugMode) { console.log(JSON.stringify({ timestamp: Date.now(), metric: metric, value: value, session_id: this.sessionId, ...metadata })); } } /** * T010: Emit custom DOM event for cache changes * @param {string} eventName - Event name (cache:message-added, etc.) * @param {Object} detail - Event detail data */ emitCacheEvent(eventName, detail) { const event = new CustomEvent(eventName, { detail: detail, bubbles: true }); document.dispatchEvent(event); } /** * T011: Get current cache * @returns {Object} Current cache object */ getCache() { return this.cache; } /** * T011: Update cache and persist * @param {Object} updates - Partial updates to apply to cache */ updateCache(updates) { this.cache = { ...this.cache, ...updates }; this.saveCacheToBrowser(); } /** * T011: Clear cache (on logout or session switch) */ clearCache() { try { sessionStorage.removeItem(this.CACHE_KEY); this.cache = null; this.logMetric('cache_clear', 1); } catch (e) { console.error('Failed to clear cache:', e); } } /** * T012: Add optimistic message to cache * T063: Prevent duplicates on rapid submission * Displays message immediately and initiates async backend sync * @param {string} content - Message content (markdown) * @param {string} role - "user" or "assistant" * @param {string} type - "fact", "question", or "response" * @returns {Promise} Result with tempId and success status */ async addOptimisticMessage(content, role, type) { const startTime = performance.now(); // T063: Check for duplicate pending messages (same content, syncing/pending) const duplicate = this.cache.pendingMessages.find(m => m.content.trim() === content.trim() && (m.syncStatus === 'pending' || m.syncStatus === 'syncing') ); if (duplicate) { console.warn('Duplicate message detected, skipping:', content.substring(0, 50)); return { success: false, error: 'Duplicate message in flight' }; } // Generate unique temp ID const tempId = crypto.randomUUID(); // Create pending message const pendingMessage = { tempId: tempId, content: content, role: role, type: type, clientTimestamp: Date.now(), syncStatus: 'pending', retryCount: 0, error: null }; // Add to cache immediately this.cache.pendingMessages.push(pendingMessage); this.saveCacheToBrowser(); const displayDuration = performance.now() - startTime; this.logMetric('optimistic_display_duration_ms', displayDuration, { type: type }); // Emit event for UI update this.emitCacheEvent('cache:message-added', pendingMessage); // Initiate async backend POST (T013) this.syncMessageToBackend(tempId, content, role, type); return { tempId: tempId, success: true }; } /** * T013: Async backend POST for message sync * Uses existing Flask form routes for compatibility * @param {string} tempId - Temporary message ID * @param {string} content - Message content * @param {string} role - Message role * @param {string} type - Message type */ async syncMessageToBackend(tempId, content, role, type) { try { // Update sync status to 'syncing' const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); if (pendingIndex >= 0) { this.cache.pendingMessages[pendingIndex].syncStatus = 'syncing'; this.saveCacheToBrowser(); } // Determine route based on type and context let route; if (window.location.pathname.includes('/contacts/')) { // Contact session routes if (type === 'fact') { route = `/contacts/${this.sessionId}/facts`; } else { route = `/contacts/${this.sessionId}/messages`; } } else { // Profile session route route = '/profile/facts/add'; } // POST to backend using form data (matching existing Flask routes) const formData = new FormData(); formData.append('content', content); const response = await fetch(route, { method: 'POST', body: formData, headers: { 'X-Requested-With': 'XMLHttpRequest' // Indicate AJAX request } }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Backend sync failed: ${response.status} ${errorText}`); } // Parse JSON response from backend const result = await response.json(); // Confirm the user message const confirmedMessage = { id: result.message?.id || result.user_message?.id || crypto.randomUUID(), content: content, role: role, type: type, timestamp: result.message?.timestamp || result.user_message?.timestamp || new Date().toISOString(), created_at: result.message?.created_at || result.user_message?.created_at || new Date().toISOString() }; // Success: confirm the message (T014) this.confirmOptimisticMessage(tempId, confirmedMessage); // T038-T039: If there's an assistant response, add it immediately if (result.assistant_message) { this.addAssistantResponse(result.assistant_message); } } catch (error) { console.error('Backend sync error:', error); // Failure: mark as failed (T015) this.markOptimisticMessageFailed(tempId, error.message); } } /** * T038-T039: Add assistant response immediately (already synced from backend) * @param {Object} assistantMessage - Assistant message data from backend */ addAssistantResponse(assistantMessage) { const message = { id: assistantMessage.id || assistantMessage.message_id, content: assistantMessage.content, role: 'assistant', type: assistantMessage.type || 'response', timestamp: new Date(assistantMessage.timestamp || assistantMessage.created_at).getTime(), created_at: assistantMessage.created_at }; // Add directly to confirmed (already synced) this.cache.confirmedMessages.push(message); this.saveCacheToBrowser(); this.logMetric('assistant_response_added', 1); // T041: Emit event for UI update this.emitCacheEvent('cache:message-added', message); } /** * T014: Confirm optimistic message after successful backend sync * @param {string} tempId - Temporary message ID * @param {Object} confirmedMessage - Backend response with id, timestamp, etc. */ confirmOptimisticMessage(tempId, confirmedMessage) { // Find and remove from pending const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); if (pendingIndex >= 0) { this.cache.pendingMessages.splice(pendingIndex, 1); } // Add to confirmed messages const message = { id: confirmedMessage.id || confirmedMessage.message_id, content: confirmedMessage.content, role: confirmedMessage.role, type: confirmedMessage.type, timestamp: new Date(confirmedMessage.timestamp || confirmedMessage.created_at).getTime(), isReference: confirmedMessage.is_reference || false }; this.cache.confirmedMessages.push(message); this.saveCacheToBrowser(); this.logMetric('message_confirmed', 1, { temp_id: tempId }); // Emit event for UI update this.emitCacheEvent('cache:message-confirmed', { tempId: tempId, confirmedMessage: message }); } /** * T015: Mark optimistic message as failed after backend sync error * @param {string} tempId - Temporary message ID * @param {string} error - Error message */ markOptimisticMessageFailed(tempId, error) { const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); if (pendingIndex >= 0) { const message = this.cache.pendingMessages[pendingIndex]; message.syncStatus = 'failed'; message.retryCount += 1; message.error = error; this.saveCacheToBrowser(); this.logMetric('sync_failure', 1, { temp_id: tempId, retry_count: message.retryCount, error: error }); // Emit event for UI update this.emitCacheEvent('cache:message-failed', { tempId: tempId, error: error, retryCount: message.retryCount }); // Schedule automatic retry if under max retries (T057) if (message.retryCount < 3) { const retryDelay = Math.pow(2, message.retryCount) * 1000; // 2s, 4s, 8s setTimeout(() => { this.retryFailedMessage(tempId); }, retryDelay); } } } /** * T056: Retry failed message manually or automatically * @param {string} tempId - Temporary message ID * @returns {Promise} Result with success status */ async retryFailedMessage(tempId) { const pendingIndex = this.cache.pendingMessages.findIndex(m => m.tempId === tempId); if (pendingIndex >= 0) { const message = this.cache.pendingMessages[pendingIndex]; message.syncStatus = 'syncing'; this.saveCacheToBrowser(); this.logMetric('message_retry', 1, { temp_id: tempId, retry_count: message.retryCount }); // Retry backend sync await this.syncMessageToBackend(tempId, message.content, message.role, message.type); return { success: true }; } return { success: false, error: 'Message not found' }; } /** * Get all messages (confirmed + pending) in chronological order * @returns {Array} Sorted array of messages */ getAllMessages() { if (!this.cache) return []; // Combine confirmed and pending messages const allMessages = [ ...this.cache.confirmedMessages.map(m => ({ ...m, syncStatus: 'confirmed' })), ...this.cache.pendingMessages ]; // Sort by timestamp (confirmed) or clientTimestamp (pending) allMessages.sort((a, b) => { const timeA = a.timestamp || a.clientTimestamp; const timeB = b.timestamp || b.clientTimestamp; return timeA - timeB; }); return allMessages; } /** * T046-T047: Refresh cache from backend API and merge with pending messages * Deduplicates by matching content + timestamp proximity (±5s) * @returns {Promise} */ async refreshCache() { if (!this.cache || !this.cache.sessionId) { console.warn('Cannot refresh cache: not initialized'); return; } const startTime = performance.now(); try { // Note: We don't have a GET messages endpoint yet // For now, just mark last fetch timestamp // In production, this would: GET /api/sessions/{id}/messages // and merge with pending using deduplication logic this.cache.lastFetchTimestamp = new Date().toISOString(); this.saveCacheToBrowser(); const duration = performance.now() - startTime; this.logMetric('cache_refresh_duration_ms', duration); // T049: Emit refresh event this.emitCacheEvent('cache:refreshed', { sessionId: this.cache.sessionId, confirmedCount: this.cache.confirmedMessages.length, pendingCount: this.cache.pendingMessages.length, duration: duration }); } catch (error) { console.error('Cache refresh error:', error); throw error; } } /** * T047: Deduplicate messages by content and timestamp proximity * @param {Array} backendMessages - Messages from backend API * @param {Array} pendingMessages - Pending optimistic messages * @returns {Array} Deduplicated confirmed messages */ deduplicateMessages(backendMessages, pendingMessages) { const deduplicated = []; const TIMESTAMP_TOLERANCE_MS = 5000; // ±5 seconds for (const backendMsg of backendMessages) { // Check if this backend message matches any pending message const matchingPending = pendingMessages.find(pending => { const contentMatch = pending.content.trim() === backendMsg.content.trim(); const timestampDiff = Math.abs( new Date(backendMsg.timestamp || backendMsg.created_at).getTime() - pending.clientTimestamp ); return contentMatch && timestampDiff < TIMESTAMP_TOLERANCE_MS; }); // If no match, add to deduplicated list if (!matchingPending) { deduplicated.push({ id: backendMsg.id || backendMsg.message_id, content: backendMsg.content, role: backendMsg.role || backendMsg.sender, type: backendMsg.type || backendMsg.mode, timestamp: new Date(backendMsg.timestamp || backendMsg.created_at).getTime(), created_at: backendMsg.created_at }); } } return deduplicated; } /** * T050: Clear cache and reinitialize for session switch * @param {string} newSessionId - New session ID */ switchSession(newSessionId) { // Clear current cache this.cache = { sessionId: newSessionId, confirmedMessages: [], pendingMessages: [], lastFetchTimestamp: null }; this.saveCacheToBrowser(); this.logMetric('session_switched', 1, { new_session_id: newSessionId }); // Trigger refresh for new session this.refreshCache(); // Emit event this.emitCacheEvent('cache:session-switched', { sessionId: newSessionId }); } /** * T061: Clear cache on logout */ clearCache() { this.cache = null; try { sessionStorage.removeItem(this.STORAGE_KEY); this.logMetric('cache_cleared', 1); } catch (error) { console.error('Error clearing cache:', error); } } /** * Get cache for inspection/debugging * @returns {Object} Current cache state */ getCache() { return this.cache; } } // Export singleton instance const messageCache = new MessageCache(); window.messageCache = messageCache;