feat: Add GA4 analytics tracking#113
Conversation
Add comprehensive analytics module to answer key business questions: - Game lifecycle: start, complete, abandon, guess tracking - Retention: returning players, streak milestones - Virality: share click/success/fail funnel - PWA: install prompts, installs, dismissals - Frustration: invalid words, rapid backspace detection - Features: settings changes, help/stats modal opens All events include language parameter for per-language analysis. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughA comprehensive Google Analytics 4 module is introduced with type-safe event tracking for game lifecycle, retention, sharing, friction, errors, and PWA features. Integration points are added to game.ts and pwa.ts to track user interactions, settings changes, and app events. Error and abandon tracking initialization hooks are provided. Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai review in depth |
Self PR Review✅ What's Good
|
| Function | Status | Notes |
|---|---|---|
trackRapidBackspace |
Not integrated | Would need to track backspace key presses |
trackHelpOpen |
Not integrated | Modal toggled directly in template |
trackStatsOpen |
Not integrated | Modal toggled directly in template |
trackReturningPlayer |
Exported but unused | game_start already has is_returning |
trackStuckSession |
Not integrated | Would need timer to detect stuck users |
These are defined for future use and don't cause issues - just unused code for now.
📋 Module-Level State
The frustration counters (consecutiveInvalidCount, rapidBackspaceCount) are module-level. This is fine because:
- They reset on page reload (new game = new page)
- Single-page sessions won't span multiple games
✅ Ready to Merge
The core tracking is working. Missing integrations can be added incrementally.
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain
🏁 Script executed: Length of output: 490 🏁 Script executed: Length of output: 99991
🔴 Critical Issues1. Missing User Consent Mechanism (GDPR/CCPA Compliance)The analytics module tracks user behavior without any visible consent mechanism. For EU/California users, you need:
Recommendation: Add a consent check before tracking: let userConsent = false;
export const setAnalyticsConsent = (consent: boolean): void => {
userConsent = consent;
if (consent && typeof window.gtag === 'function') {
window.gtag('consent', 'update', {
analytics_storage: 'granted',
});
}
};
const track = (eventName: string, params?: Record<string, unknown>): void => {
if (!userConsent) return; // Respect user consent
try {
if (typeof window.gtag === 'function') {
window.gtag('event', eventName, params);
}
} catch {
// Silently fail
}
};2. Module-Level State Can Cause IssuesLines 367-410 in let consecutiveInvalidCount = 0;
let lastInvalidTime = 0;
let rapidBackspaceCount = 0;
let lastBackspaceTime = 0;Problems:
Fix: Move this state into the game component or use a class-based approach: export class FrustrationTracker {
private consecutiveInvalidCount = 0;
private lastInvalidTime = 0;
private rapidBackspaceCount = 0;
private lastBackspaceTime = 0;
trackInvalidWord(params: InvalidWordParams): void {
const now = Date.now();
if (now - this.lastInvalidTime > 30000) {
this.consecutiveInvalidCount = 0;
}
// ... rest of logic
}
reset(): void {
this.consecutiveInvalidCount = 0;
this.rapidBackspaceCount = 0;
}
}3. Share Failure May Leak Error DetailsLine 1044 in analytics.trackShareFail(langCode, 'clipboard', error.message);Issue: Fix: analytics.trackShareFail(langCode, 'clipboard', 'clipboard_write_failed');🟡 Major Issues4. Time Tracking Not Implemented
Fix: Add time tracking to game component: // In game.ts data()
gameStartTime: 0,
// In created()
this.gameStartTime = Date.now();
// In gameWon()
const timeSeconds = Math.round((Date.now() - this.gameStartTime) / 1000);
analytics.trackGameComplete({
language: langCode,
won: true,
attempts: this.active_row,
time_seconds: timeSeconds, // Add this
streak_after: this.stats.current_streak,
});5. Many Analytics Functions Not IntegratedThese exported functions are never called:
This means key business questions go unanswered:
🏁 Script executed: Length of output: 859 🏁 Script executed: Length of output: 998 🏁 Script executed: Length of output: 491 🏁 Script executed: Length of output: 1011 🏁 Script executed: Length of output: 395 Now let me complete the comprehensive review: 6.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@frontend/src/analytics.ts`:
- Around line 152-256: The analytics events trackGameComplete, trackShareClick,
and trackShareSuccess send an attempts parameter that can be either number or
string which fragments GA4; update the types GameCompleteParams and ShareParams
to split this into attempts: number | undefined and attempts_label?: string |
undefined, then modify trackGameComplete, trackShareClick, and trackShareSuccess
to send attempts only when numeric and send attempts_label when the value is a
string (preserve existing string values as attempts_label, preserve numeric
values as attempts), updating any callsites to populate the correct field;
ensure event payload keys are attempts (number) and attempts_label (string) so
GA4 receives consistent typed fields.
🧹 Nitpick comments (4)
frontend/src/analytics.ts (2)
85-88: AlignPWAParams.sourceunion with the'unknown'fallback.The fallback currently uses
'unknown', which isn’t included in the type union.♻️ Proposed type alignment
interface PWAParams { platform?: 'ios' | 'android' | 'desktop' | 'unknown'; - source?: 'banner' | 'settings' | 'auto'; + source?: 'banner' | 'settings' | 'auto' | 'unknown'; }Also applies to: 289-293
366-418: Avoid cross-language bleed in frustration counters.Module-level counters can carry between languages/sessions. Consider tracking counts per language.
♻️ Suggested per-language counters
-let consecutiveInvalidCount = 0; -let lastInvalidTime = 0; +const consecutiveInvalidCountByLang: Record<string, number> = {}; +const lastInvalidTimeByLang: Record<string, number> = {}; export const trackInvalidWordWithFrustration = (params: InvalidWordParams): void => { const now = Date.now(); + const prevTime = lastInvalidTimeByLang[params.language] ?? 0; // Reset counter if more than 30 seconds since last invalid - if (now - lastInvalidTime > 30000) { - consecutiveInvalidCount = 0; + if (now - prevTime > 30000) { + consecutiveInvalidCountByLang[params.language] = 0; } - consecutiveInvalidCount++; - lastInvalidTime = now; + consecutiveInvalidCountByLang[params.language] = + (consecutiveInvalidCountByLang[params.language] ?? 0) + 1; + lastInvalidTimeByLang[params.language] = now; // Track frustration if 3+ consecutive invalid attempts - if (consecutiveInvalidCount >= 3) { + if (consecutiveInvalidCountByLang[params.language] >= 3) { trackFrustrationSignal(params.language, 'repeated_invalid_words'); } }; -let rapidBackspaceCount = 0; -let lastBackspaceTime = 0; +const rapidBackspaceCountByLang: Record<string, number> = {}; +const lastBackspaceTimeByLang: Record<string, number> = {}; export const trackRapidBackspace = (language: string): void => { const now = Date.now(); + const prevTime = lastBackspaceTimeByLang[language] ?? 0; // Count rapid backspaces (within 200ms of each other) - if (now - lastBackspaceTime < 200) { - rapidBackspaceCount++; + if (now - prevTime < 200) { + rapidBackspaceCountByLang[language] = (rapidBackspaceCountByLang[language] ?? 0) + 1; } else { - rapidBackspaceCount = 1; + rapidBackspaceCountByLang[language] = 1; } - lastBackspaceTime = now; + lastBackspaceTimeByLang[language] = now; // Track if 5+ rapid backspaces (frustrated deletion) - if (rapidBackspaceCount >= 5) { + if ((rapidBackspaceCountByLang[language] ?? 0) >= 5) { trackFrustrationSignal(language, 'rapid_backspace'); - rapidBackspaceCount = 0; // Reset to avoid duplicate events + rapidBackspaceCountByLang[language] = 0; // Reset to avoid duplicate events } };frontend/src/pwa.ts (1)
49-55: De‑dupe prompt tracking and capture actual install source.Repeated
showBanner()calls can inflatepwa_prompt_shown, andappinstalledisn’t always triggered by the banner. Consider caching the last prompt source and only tracking the first banner impression per session.♻️ Suggested attribution & de‑dup
-let dismissed = isDismissed(); +let dismissed = isDismissed(); +let promptTracked = false; +let lastPromptSource: 'banner' | 'settings' | 'auto' | 'unknown' = 'unknown'; export const showBanner = (): void => { if (dismissed || isStandalone()) return; const banner = getBanner(); if (banner && (deferredPrompt || isIOS())) { banner.style.display = 'flex'; - trackPWAPromptShown('banner'); + if (!promptTracked) { + trackPWAPromptShown('banner'); + promptTracked = true; + } + lastPromptSource = 'banner'; } }; window.addEventListener('appinstalled', () => { console.log('PWA installed'); - trackPWAInstall({ source: 'banner' }); + trackPWAInstall({ source: lastPromptSource }); + lastPromptSource = 'unknown'; deferredPrompt = null; hideBanner(); });Also applies to: 145-148
frontend/src/game.ts (1)
322-342: Track real last-guess validity for abandon events.
lastGuessValidis alwaystrue, which makes the abandon signal less reliable. Consider storing the last guess validity in component state and passing it through.♻️ Suggested wiring
// In GameData interface interface GameData { active_row: number; active_cell: number; full_word_inputted: boolean; + last_guess_valid: boolean; game_over: boolean; ... } // In data() return return { active_row: 0, active_cell: 0, full_word_inputted: false, + last_guess_valid: true, game_over: false, ... }; // In initAbandonTracking analytics.initAbandonTracking(() => ({ language: langCode, activeRow: this.active_row, gameOver: this.game_over, - lastGuessValid: true, // We don't track invalid state, assume valid + lastGuessValid: this.last_guess_valid, })); // In keyDown(), mark validity if (canonicalWord) { + this.last_guess_valid = true; ... } else { + this.last_guess_valid = false; ... }
| export const trackGameComplete = (params: GameCompleteParams): void => { | ||
| track('game_complete', { | ||
| language: params.language, | ||
| won: params.won, | ||
| attempts: params.attempts, | ||
| time_seconds: params.time_seconds, | ||
| streak_after: params.streak_after, | ||
| is_pwa: isStandalone(), | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Track when user leaves mid-game (page unload with incomplete game) | ||
| * Answers: Where do people give up? Which languages frustrate users? | ||
| */ | ||
| export const trackGameAbandon = (params: GameAbandonParams): void => { | ||
| track('game_abandon', { | ||
| language: params.language, | ||
| attempt_number: params.attempt_number, | ||
| last_guess_valid: params.last_guess_valid, | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Track each guess submission | ||
| * Answers: How many guesses per game? Invalid word rate? | ||
| */ | ||
| export const trackGuessSubmit = ( | ||
| language: string, | ||
| attemptNumber: number, | ||
| isValid: boolean | ||
| ): void => { | ||
| track('guess_submit', { | ||
| language, | ||
| attempt_number: attemptNumber, | ||
| is_valid: isValid, | ||
| }); | ||
| }; | ||
|
|
||
| // ============================================================================ | ||
| // RETENTION EVENTS | ||
| // ============================================================================ | ||
|
|
||
| /** | ||
| * Track returning players with context | ||
| * Answers: How often do players return? What brings them back? | ||
| */ | ||
| export const trackReturningPlayer = ( | ||
| language: string, | ||
| daysSinceLast: number, | ||
| currentStreak: number | ||
| ): void => { | ||
| track('returning_player', { | ||
| language, | ||
| days_since_last: daysSinceLast, | ||
| current_streak: currentStreak, | ||
| is_pwa: isStandalone(), | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Track streak milestones (7, 30, 100, etc.) | ||
| * Answers: Are streaks driving retention? | ||
| */ | ||
| export const trackStreakMilestone = (language: string, streakCount: number): void => { | ||
| // Only track meaningful milestones | ||
| const milestones = [7, 14, 30, 50, 100, 200, 365]; | ||
| if (milestones.includes(streakCount)) { | ||
| track('streak_milestone', { | ||
| language, | ||
| streak_count: streakCount, | ||
| is_pwa: isStandalone(), | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| // ============================================================================ | ||
| // SHARE EVENTS | ||
| // ============================================================================ | ||
|
|
||
| /** | ||
| * Track share button click | ||
| * Answers: Do people try to share? | ||
| */ | ||
| export const trackShareClick = (params: ShareParams): void => { | ||
| track('share_click', { | ||
| language: params.language, | ||
| method: params.method, | ||
| won: params.won, | ||
| attempts: params.attempts, | ||
| }); | ||
| }; | ||
|
|
||
| /** | ||
| * Track successful share | ||
| * Answers: Does sharing actually work? | ||
| */ | ||
| export const trackShareSuccess = (params: ShareParams): void => { | ||
| track('share_success', { | ||
| language: params.language, | ||
| method: params.method, | ||
| won: params.won, | ||
| attempts: params.attempts, | ||
| }); | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the analytics.ts file to understand the context better
fd "analytics.ts" frontend/src -x head -n 200 {} | catRepository: Hugo0/wordle
Length of output: 5674
🏁 Script executed:
# Look for the param type definitions
rg "GameCompleteParams|ShareParams" frontend/src -A 5Repository: Hugo0/wordle
Length of output: 1826
🌐 Web query:
GA4 event parameter type consistency mixed number string
💡 Result:
In GA4 you can end up with the same event parameter showing up as both a number and a string, and it’s best to avoid that.
- Collection layer (gtag / GTM / Measurement Protocol): GA4 event parameters are key–value pairs, and many standard parameters are explicitly expected to be either
stringornumber(for examplecurrencyisstring,valueisnumber). If you sometimes send"12.34"(string) and other times12.34(number) for the same key, you’re making the data inconsistent at the source. [1], [2] - Reporting / custom definitions in the GA4 UI: Custom dimensions are text, while numeric analysis should be done via metrics—so if you want to use a parameter numerically in GA4 reporting, define it as a custom metric and send it consistently as a number; if it’s categorical/ID-like, treat it as text and always send a string. [3]
- BigQuery export: Each event parameter’s value is stored in separate typed fields (
string_value,int_value,float_value,double_value), and only one is populated per event—so mixed typing for the same parameter key will fragment your data across those fields and complicate querying (you’ll need logic likeCOALESCE(...)/ casts). [4], [5]
Practical recommendation: Pick one type per parameter key and stick to it. If you truly need both representations, send two different parameters (e.g., price as number and price_text as string), rather than mixing types under one key. [4], [5]
Sources: [1] [2] [3] [4] [5]
Standardize attempts parameter type to avoid GA4 data fragmentation.
GA4 parameters store mixed types (number and string) in separate BigQuery fields, fragmenting your data and complicating queries. The attempts field in GameCompleteParams and ShareParams accepts both types, causing inconsistency across trackGameComplete, trackShareClick, and trackShareSuccess. Define separate parameters: use attempts for numeric values and attempts_label for string values (e.g., "loss").
Proposed fix
+const buildAttemptsPayload = (attempts: number | string): Record<string, unknown> =>
+ typeof attempts === 'number' ? { attempts } : { attempts_label: attempts };
export const trackGameComplete = (params: GameCompleteParams): void => {
track('game_complete', {
language: params.language,
won: params.won,
- attempts: params.attempts,
+ ...buildAttemptsPayload(params.attempts),
time_seconds: params.time_seconds,
streak_after: params.streak_after,
is_pwa: isStandalone(),
});
};
export const trackShareClick = (params: ShareParams): void => {
track('share_click', {
language: params.language,
method: params.method,
won: params.won,
- attempts: params.attempts,
+ ...buildAttemptsPayload(params.attempts),
});
};
export const trackShareSuccess = (params: ShareParams): void => {
track('share_success', {
language: params.language,
method: params.method,
won: params.won,
- attempts: params.attempts,
+ ...buildAttemptsPayload(params.attempts),
});
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const trackGameComplete = (params: GameCompleteParams): void => { | |
| track('game_complete', { | |
| language: params.language, | |
| won: params.won, | |
| attempts: params.attempts, | |
| time_seconds: params.time_seconds, | |
| streak_after: params.streak_after, | |
| is_pwa: isStandalone(), | |
| }); | |
| }; | |
| /** | |
| * Track when user leaves mid-game (page unload with incomplete game) | |
| * Answers: Where do people give up? Which languages frustrate users? | |
| */ | |
| export const trackGameAbandon = (params: GameAbandonParams): void => { | |
| track('game_abandon', { | |
| language: params.language, | |
| attempt_number: params.attempt_number, | |
| last_guess_valid: params.last_guess_valid, | |
| }); | |
| }; | |
| /** | |
| * Track each guess submission | |
| * Answers: How many guesses per game? Invalid word rate? | |
| */ | |
| export const trackGuessSubmit = ( | |
| language: string, | |
| attemptNumber: number, | |
| isValid: boolean | |
| ): void => { | |
| track('guess_submit', { | |
| language, | |
| attempt_number: attemptNumber, | |
| is_valid: isValid, | |
| }); | |
| }; | |
| // ============================================================================ | |
| // RETENTION EVENTS | |
| // ============================================================================ | |
| /** | |
| * Track returning players with context | |
| * Answers: How often do players return? What brings them back? | |
| */ | |
| export const trackReturningPlayer = ( | |
| language: string, | |
| daysSinceLast: number, | |
| currentStreak: number | |
| ): void => { | |
| track('returning_player', { | |
| language, | |
| days_since_last: daysSinceLast, | |
| current_streak: currentStreak, | |
| is_pwa: isStandalone(), | |
| }); | |
| }; | |
| /** | |
| * Track streak milestones (7, 30, 100, etc.) | |
| * Answers: Are streaks driving retention? | |
| */ | |
| export const trackStreakMilestone = (language: string, streakCount: number): void => { | |
| // Only track meaningful milestones | |
| const milestones = [7, 14, 30, 50, 100, 200, 365]; | |
| if (milestones.includes(streakCount)) { | |
| track('streak_milestone', { | |
| language, | |
| streak_count: streakCount, | |
| is_pwa: isStandalone(), | |
| }); | |
| } | |
| }; | |
| // ============================================================================ | |
| // SHARE EVENTS | |
| // ============================================================================ | |
| /** | |
| * Track share button click | |
| * Answers: Do people try to share? | |
| */ | |
| export const trackShareClick = (params: ShareParams): void => { | |
| track('share_click', { | |
| language: params.language, | |
| method: params.method, | |
| won: params.won, | |
| attempts: params.attempts, | |
| }); | |
| }; | |
| /** | |
| * Track successful share | |
| * Answers: Does sharing actually work? | |
| */ | |
| export const trackShareSuccess = (params: ShareParams): void => { | |
| track('share_success', { | |
| language: params.language, | |
| method: params.method, | |
| won: params.won, | |
| attempts: params.attempts, | |
| }); | |
| }; | |
| export const trackGameComplete = (params: GameCompleteParams): void => { | |
| const buildAttemptsPayload = (attempts: number | string): Record<string, unknown> => | |
| typeof attempts === 'number' ? { attempts } : { attempts_label: attempts }; | |
| track('game_complete', { | |
| language: params.language, | |
| won: params.won, | |
| ...buildAttemptsPayload(params.attempts), | |
| time_seconds: params.time_seconds, | |
| streak_after: params.streak_after, | |
| is_pwa: isStandalone(), | |
| }); | |
| }; | |
| /** | |
| * Track when user leaves mid-game (page unload with incomplete game) | |
| * Answers: Where do people give up? Which languages frustrate users? | |
| */ | |
| export const trackGameAbandon = (params: GameAbandonParams): void => { | |
| track('game_abandon', { | |
| language: params.language, | |
| attempt_number: params.attempt_number, | |
| last_guess_valid: params.last_guess_valid, | |
| }); | |
| }; | |
| /** | |
| * Track each guess submission | |
| * Answers: How many guesses per game? Invalid word rate? | |
| */ | |
| export const trackGuessSubmit = ( | |
| language: string, | |
| attemptNumber: number, | |
| isValid: boolean | |
| ): void => { | |
| track('guess_submit', { | |
| language, | |
| attempt_number: attemptNumber, | |
| is_valid: isValid, | |
| }); | |
| }; | |
| // ============================================================================ | |
| // RETENTION EVENTS | |
| // ============================================================================ | |
| /** | |
| * Track returning players with context | |
| * Answers: How often do players return? What brings them back? | |
| */ | |
| export const trackReturningPlayer = ( | |
| language: string, | |
| daysSinceLast: number, | |
| currentStreak: number | |
| ): void => { | |
| track('returning_player', { | |
| language, | |
| days_since_last: daysSinceLast, | |
| current_streak: currentStreak, | |
| is_pwa: isStandalone(), | |
| }); | |
| }; | |
| /** | |
| * Track streak milestones (7, 30, 100, etc.) | |
| * Answers: Are streaks driving retention? | |
| */ | |
| export const trackStreakMilestone = (language: string, streakCount: number): void => { | |
| // Only track meaningful milestones | |
| const milestones = [7, 14, 30, 50, 100, 200, 365]; | |
| if (milestones.includes(streakCount)) { | |
| track('streak_milestone', { | |
| language, | |
| streak_count: streakCount, | |
| is_pwa: isStandalone(), | |
| }); | |
| } | |
| }; | |
| // ============================================================================ | |
| // SHARE EVENTS | |
| // ============================================================================ | |
| /** | |
| * Track share button click | |
| * Answers: Do people try to share? | |
| */ | |
| export const trackShareClick = (params: ShareParams): void => { | |
| const buildAttemptsPayload = (attempts: number | string): Record<string, unknown> => | |
| typeof attempts === 'number' ? { attempts } : { attempts_label: attempts }; | |
| track('share_click', { | |
| language: params.language, | |
| method: params.method, | |
| won: params.won, | |
| ...buildAttemptsPayload(params.attempts), | |
| }); | |
| }; | |
| /** | |
| * Track successful share | |
| * Answers: Does sharing actually work? | |
| */ | |
| export const trackShareSuccess = (params: ShareParams): void => { | |
| const buildAttemptsPayload = (attempts: number | string): Record<string, unknown> => | |
| typeof attempts === 'number' ? { attempts } : { attempts_label: attempts }; | |
| track('share_success', { | |
| language: params.language, | |
| method: params.method, | |
| won: params.won, | |
| ...buildAttemptsPayload(params.attempts), | |
| }); | |
| }; |
🤖 Prompt for AI Agents
In `@frontend/src/analytics.ts` around lines 152 - 256, The analytics events
trackGameComplete, trackShareClick, and trackShareSuccess send an attempts
parameter that can be either number or string which fragments GA4; update the
types GameCompleteParams and ShareParams to split this into attempts: number |
undefined and attempts_label?: string | undefined, then modify
trackGameComplete, trackShareClick, and trackShareSuccess to send attempts only
when numeric and send attempts_label when the value is a string (preserve
existing string values as attempts_label, preserve numeric values as attempts),
updating any callsites to populate the correct field; ensure event payload keys
are attempts (number) and attempts_label (string) so GA4 receives consistent
typed fields.
Add comprehensive analytics module to answer key business questions: - Game lifecycle: start, complete, abandon, guess tracking - Retention: returning players, streak milestones - Virality: share click/success/fail funnel - PWA: install prompts, installs, dismissals - Frustration: invalid words, rapid backspace detection - Features: settings changes, help/stats modal opens All events include language parameter for per-language analysis.
Summary
Add comprehensive GA4 analytics tracking to answer key business questions about user behavior.
Events Added
game_start,game_complete,game_abandon,guess_submitreturning_player,streak_milestoneshare_click,share_success,share_failpwa_install,pwa_prompt_shown,pwa_dismiss,pwa_sessioninvalid_word,frustration_signal,rapid_backspace,stuck_sessionsettings_change,help_open,stats_openpage_error(JS errors, unhandled promises)Business Questions This Answers
languageparameterFiles Changed
frontend/src/analytics.ts- Analytics module (~500 lines)frontend/src/game.ts- Integrated tracking callsfrontend/src/pwa.ts- PWA install trackingTest Plan
pnpm build)🤖 Generated with Claude Code
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.