Spaces:
Running
Running
| /** | |
| * Analytics Service | |
| * Frontend client for tracking passage attempts, word difficulty, and hint usage. | |
| * Sends summary data to backend Redis analytics service. | |
| */ | |
| export class AnalyticsService { | |
| constructor() { | |
| // Generate unique session ID for this browser session | |
| this.sessionId = this._generateUUID(); | |
| // Current passage tracking state | |
| this.currentPassage = null; | |
| // Base URL - uses same origin as the app | |
| this.baseUrl = window.location.origin; | |
| } | |
| /** | |
| * Generate a UUID v4 | |
| */ | |
| _generateUUID() { | |
| // Use crypto.randomUUID if available, otherwise fallback | |
| if (typeof crypto !== 'undefined' && crypto.randomUUID) { | |
| return crypto.randomUUID(); | |
| } | |
| // Fallback for older browsers | |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { | |
| const r = Math.random() * 16 | 0; | |
| const v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| /** | |
| * Start tracking a new passage attempt. | |
| * Call this when a new passage is loaded. | |
| * | |
| * @param {Object} book - Book info {title, author} | |
| * @param {Array} blanks - Array of blank objects with originalWord | |
| * @param {number} level - Current game level | |
| * @param {number} round - Current round number | |
| */ | |
| startPassage(book, blanks, level, round) { | |
| this.currentPassage = { | |
| passageId: this._generateUUID(), | |
| sessionId: this.sessionId, | |
| bookTitle: book?.title || 'Unknown', | |
| bookAuthor: book?.author || 'Unknown', | |
| level: level || 1, | |
| round: round || 1, | |
| words: blanks.map(blank => ({ | |
| word: blank.originalWord || '', | |
| length: (blank.originalWord || '').length, | |
| attemptsToCorrect: 0, | |
| hintsUsed: [], | |
| finalCorrect: false | |
| })), | |
| startTime: Date.now() | |
| }; | |
| console.debug('π Analytics: Started passage', { | |
| passageId: this.currentPassage.passageId, | |
| book: this.currentPassage.bookTitle, | |
| blanks: this.currentPassage.words.length, | |
| level, | |
| round | |
| }); | |
| } | |
| /** | |
| * Record an attempt on a specific word. | |
| * Call this each time the user submits an answer for a blank. | |
| * | |
| * @param {number} blankIndex - Index of the blank in the passage | |
| * @param {boolean} correct - Whether the attempt was correct | |
| */ | |
| recordAttempt(blankIndex, correct) { | |
| if (!this.currentPassage) { | |
| console.warn('π Analytics: No active passage to record attempt'); | |
| return; | |
| } | |
| if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) { | |
| console.warn('π Analytics: Invalid blank index', blankIndex); | |
| return; | |
| } | |
| const wordData = this.currentPassage.words[blankIndex]; | |
| wordData.attemptsToCorrect++; | |
| if (correct) { | |
| wordData.finalCorrect = true; | |
| } | |
| console.debug('π Analytics: Recorded attempt', { | |
| word: wordData.word, | |
| attempt: wordData.attemptsToCorrect, | |
| correct | |
| }); | |
| } | |
| /** | |
| * Record all attempts at once (batch mode). | |
| * Use when results come in as an array. | |
| * | |
| * @param {Array} results - Array of {blankIndex, isCorrect} objects | |
| */ | |
| recordAttemptsBatch(results) { | |
| if (!this.currentPassage) { | |
| console.warn('π Analytics: No active passage to record attempts'); | |
| return; | |
| } | |
| results.forEach(result => { | |
| if (result.blankIndex !== undefined) { | |
| this.recordAttempt(result.blankIndex, result.isCorrect); | |
| } | |
| }); | |
| } | |
| /** | |
| * Record a hint request for a specific word. | |
| * | |
| * @param {number} blankIndex - Index of the blank | |
| * @param {string} hintType - Type of hint requested (e.g., 'part_of_speech', 'synonym', 'first_letter') | |
| */ | |
| recordHint(blankIndex, hintType) { | |
| if (!this.currentPassage) { | |
| console.warn('π Analytics: No active passage to record hint'); | |
| return; | |
| } | |
| if (blankIndex < 0 || blankIndex >= this.currentPassage.words.length) { | |
| console.warn('π Analytics: Invalid blank index for hint', blankIndex); | |
| return; | |
| } | |
| const wordData = this.currentPassage.words[blankIndex]; | |
| wordData.hintsUsed.push(hintType || 'unknown'); | |
| console.debug('π Analytics: Recorded hint', { | |
| word: wordData.word, | |
| hintType, | |
| totalHints: wordData.hintsUsed.length | |
| }); | |
| } | |
| /** | |
| * Complete the current passage and send analytics to backend. | |
| * | |
| * @param {boolean} passed - Whether the user passed the passage | |
| * @returns {Promise<Object>} - Response from analytics API | |
| */ | |
| async completePassage(passed) { | |
| if (!this.currentPassage) { | |
| console.warn('π Analytics: No active passage to complete'); | |
| return { success: false, message: 'No active passage' }; | |
| } | |
| // Calculate summary statistics | |
| const totalBlanks = this.currentPassage.words.length; | |
| const correctOnFirstTry = this.currentPassage.words.filter( | |
| w => w.attemptsToCorrect === 1 && w.finalCorrect | |
| ).length; | |
| const totalHintsUsed = this.currentPassage.words.reduce( | |
| (sum, w) => sum + w.hintsUsed.length, 0 | |
| ); | |
| const data = { | |
| passageId: this.currentPassage.passageId, | |
| sessionId: this.currentPassage.sessionId, | |
| bookTitle: this.currentPassage.bookTitle, | |
| bookAuthor: this.currentPassage.bookAuthor, | |
| level: this.currentPassage.level, | |
| round: this.currentPassage.round, | |
| words: this.currentPassage.words, | |
| totalBlanks, | |
| correctOnFirstTry, | |
| totalHintsUsed, | |
| passed, | |
| timestamp: new Date().toISOString() | |
| }; | |
| console.debug('π Analytics: Completing passage', { | |
| passageId: data.passageId, | |
| passed, | |
| correctOnFirstTry, | |
| totalBlanks, | |
| totalHintsUsed | |
| }); | |
| // Clear current passage state | |
| this.currentPassage = null; | |
| // Send to backend | |
| try { | |
| const response = await fetch(`${this.baseUrl}/api/analytics/passage`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(data) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const result = await response.json(); | |
| return result; | |
| } catch (error) { | |
| // Don't throw - analytics failure shouldn't break the game | |
| console.warn('π Analytics: Failed to send (non-critical)', error.message); | |
| return { success: false, message: error.message }; | |
| } | |
| } | |
| /** | |
| * Cancel tracking for current passage without sending. | |
| * Use if the user abandons a passage mid-attempt. | |
| */ | |
| cancelPassage() { | |
| if (this.currentPassage) { | |
| console.debug('π Analytics: Cancelled passage', { | |
| passageId: this.currentPassage.passageId | |
| }); | |
| this.currentPassage = null; | |
| } | |
| } | |
| /** | |
| * Check if there's an active passage being tracked. | |
| * @returns {boolean} | |
| */ | |
| isTrackingPassage() { | |
| return this.currentPassage !== null; | |
| } | |
| /** | |
| * Get current passage statistics (for UI display). | |
| * @returns {Object|null} | |
| */ | |
| getCurrentStats() { | |
| if (!this.currentPassage) return null; | |
| const totalBlanks = this.currentPassage.words.length; | |
| const correctOnFirstTry = this.currentPassage.words.filter( | |
| w => w.attemptsToCorrect === 1 && w.finalCorrect | |
| ).length; | |
| const totalCorrect = this.currentPassage.words.filter(w => w.finalCorrect).length; | |
| const totalHintsUsed = this.currentPassage.words.reduce( | |
| (sum, w) => sum + w.hintsUsed.length, 0 | |
| ); | |
| return { | |
| passageId: this.currentPassage.passageId, | |
| bookTitle: this.currentPassage.bookTitle, | |
| level: this.currentPassage.level, | |
| round: this.currentPassage.round, | |
| totalBlanks, | |
| correctOnFirstTry, | |
| totalCorrect, | |
| totalHintsUsed, | |
| words: this.currentPassage.words.map(w => ({ | |
| word: w.word, | |
| attempts: w.attemptsToCorrect, | |
| hintsUsed: w.hintsUsed.length, | |
| correct: w.finalCorrect | |
| })) | |
| }; | |
| } | |
| // ===== ADMIN API METHODS ===== | |
| /** | |
| * Get analytics summary (admin dashboard data). | |
| * @returns {Promise<Object>} | |
| */ | |
| async getSummary() { | |
| try { | |
| const response = await fetch(`${this.baseUrl}/api/analytics/summary`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('π Analytics: Failed to get summary', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Get recent passage attempts. | |
| * @param {number} count - Number of entries (max 200) | |
| * @returns {Promise<Object>} | |
| */ | |
| async getRecentPassages(count = 50) { | |
| try { | |
| const response = await fetch(`${this.baseUrl}/api/analytics/recent?count=${count}`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('π Analytics: Failed to get recent passages', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Export all analytics data. | |
| * @returns {Promise<Object>} | |
| */ | |
| async exportAll() { | |
| try { | |
| const response = await fetch(`${this.baseUrl}/api/analytics/export`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('π Analytics: Failed to export', error); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Get statistics for a specific word. | |
| * @param {string} word | |
| * @returns {Promise<Object>} | |
| */ | |
| async getWordStats(word) { | |
| try { | |
| const response = await fetch(`${this.baseUrl}/api/analytics/word/${encodeURIComponent(word)}`); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('π Analytics: Failed to get word stats', error); | |
| throw error; | |
| } | |
| } | |
| } | |
| // Export singleton instance | |
| export const analyticsService = new AnalyticsService(); | |