Skip to content

feat: Add GA4 analytics tracking#113

Merged
Hugo0 merged 1 commit into
mainfrom
feat/analytics-tracking
Jan 26, 2026
Merged

feat: Add GA4 analytics tracking#113
Hugo0 merged 1 commit into
mainfrom
feat/analytics-tracking

Conversation

@Hugo0

@Hugo0 Hugo0 commented Jan 26, 2026

Copy link
Copy Markdown
Owner

Summary

Add comprehensive GA4 analytics tracking to answer key business questions about user behavior.

Events Added

Category Events
Game Lifecycle game_start, game_complete, game_abandon, guess_submit
Retention returning_player, streak_milestone
Virality share_click, share_success, share_fail
PWA pwa_install, pwa_prompt_shown, pwa_dismiss, pwa_session
Frustration invalid_word, frustration_signal, rapid_backspace, stuck_session
Features settings_change, help_open, stats_open
Errors page_error (JS errors, unhandled promises)

Business Questions This Answers

  1. Which languages perform best? - All events include language parameter
  2. Is sharing broken? - Full share funnel tracking
  3. Are word lists frustrating users? - Invalid word + frustration detection
  4. Do PWA users retain better? - PWA session tracking
  5. Where do users abandon? - Game abandon by attempt number

Files Changed

  • New: frontend/src/analytics.ts - Analytics module (~500 lines)
  • Modified: frontend/src/game.ts - Integrated tracking calls
  • Modified: frontend/src/pwa.ts - PWA install tracking

Test Plan

  • Build passes (pnpm build)
  • Events fire correctly in GA4 DebugView
  • No console errors in production
  • Share tracking captures all methods (native, clipboard, fallback)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Integrated comprehensive analytics to track game events, user engagement, and player sessions for improved app insights and personalization.
    • Enhanced error tracking and session monitoring to proactively identify and resolve app stability issues.
    • Added PWA installation tracking to optimize progressive web app experience.

✏️ Tip: You can customize this high-level summary in your review settings.

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>
@coderabbitai

coderabbitai Bot commented Jan 26, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

A 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

Cohort / File(s) Summary
Analytics Module Foundation
frontend/src/analytics.ts
New 607-line analytics module with 8 interfaces (GameStartParams, GameCompleteParams, GameAbandonParams, ShareParams, InvalidWordParams, SettingsChangeParams, PWAParams, ErrorParams) and 23+ exported tracking functions. Includes type-safe gtag wrapper with graceful degradation, platform detection, PWA-standalone awareness, and initialization hooks for error and abandon tracking.
Game Flow Integration
frontend/src/game.ts
Integrates analytics across game lifecycle: tracks game start (with returning player and streak data), guess submissions (valid/invalid with frustration signals), game completions (win/loss with streak milestones), share flows (success/fail per method), settings changes (dark mode, haptics, sound), and initializes error and abandon tracking.
PWA Integration
frontend/src/pwa.ts
Adds three analytics tracking points: PWA prompt display (banner source), PWA install (with source metadata), and PWA dismiss events.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 Hops through the code with joy so bright,
Events tracked from left to right,
Game starts, guesses, shares take flight,
Analytics bundled, wrapped up tight!
PWA prompts now shining light!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: Add GA4 analytics tracking' is concise, clear, and directly summarizes the main change—introducing Google Analytics 4 tracking to the application.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Hugo0

Hugo0 commented Jan 26, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review in depth

@Hugo0

Hugo0 commented Jan 26, 2026

Copy link
Copy Markdown
Owner Author

Self PR Review

✅ What's Good

  1. Type Safety - All event parameters are typed interfaces
  2. Privacy Respecting - Doesn't log actual words users type
  3. Graceful Degradation - track() wrapper silently fails if gtag missing
  4. Comprehensive Coverage - Game lifecycle, retention, virality, PWA, frustration
  5. Well Documented - Header comments explain business questions each event answers
  6. No Breaking Changes - Adds tracking without modifying core game logic

⚠️ Known Gaps (can address in follow-up)

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.

@Hugo0
Hugo0 merged commit 6c7e119 into main Jan 26, 2026
2 of 4 checks passed
@coderabbitai

coderabbitai Bot commented Jan 26, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

@Hugo0, I'll perform an in-depth review of this PR. Let me gather the code changes first.


🏁 Script executed:

#!/bin/bash

# Get the list of changed files in the PR
echo "=== Changed Files ==="
gh pr view 113 --json files --jq '.files[] | "\(.path) (\(.additions)+/\(.deletions)-)"'

echo -e "\n=== PR Diff Stats ==="
gh pr diff 113 --stat

Length of output: 490


🏁 Script executed:

#!/bin/bash

# Get the full PR diff
echo "=== Full PR Diff ==="
gh pr diff 113

echo -e "\n\n=== Reading analytics.ts file ==="
cat frontend/src/analytics.ts

echo -e "\n\n=== Reading game.ts changes ==="
cat frontend/src/game.ts

echo -e "\n\n=== Reading pwa.ts changes ==="
cat frontend/src/pwa.ts

Length of output: 99991


@Hugo0, I'll provide a comprehensive in-depth review of this GA4 analytics implementation. This is an impressive piece of work with thoughtful event design, but there are several critical issues and opportunities for improvement.

🔴 Critical Issues

1. Missing User Consent Mechanism (GDPR/CCPA Compliance)

The analytics module tracks user behavior without any visible consent mechanism. For EU/California users, you need:

  • Cookie/tracking consent before initializing GA4
  • Ability to opt-out
  • Privacy policy disclosure

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 Issues

Lines 367-410 in analytics.ts use module-level variables for frustration tracking:

let consecutiveInvalidCount = 0;
let lastInvalidTime = 0;
let rapidBackspaceCount = 0;
let lastBackspaceTime = 0;

Problems:

  • Not reset when language changes
  • Shared across all potential game instances
  • No cleanup mechanism

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 Details

Line 1044 in game.ts:

analytics.trackShareFail(langCode, 'clipboard', error.message);

Issue: error.message could contain sensitive information or implementation details.

Fix:

analytics.trackShareFail(langCode, 'clipboard', 'clipboard_write_failed');

🟡 Major Issues

4. Time Tracking Not Implemented

trackGameComplete accepts time_seconds parameter but it's never populated in game.ts (lines 688-696). This loses valuable engagement data.

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 Integrated

These exported functions are never called:

  • trackReturningPlayer (has days_since_last calculation built in!)
  • trackHelpOpen
  • trackStatsOpen
  • trackLanguageSelect
  • trackRapidBackspace
  • trackIssuesClick
  • trackRepeatedNotValidNotification
  • trackStuckSession
  • trackKeyboardMissingChar

This means key business questions go unanswered:

  • "Are people confused?" (help opens)
  • "Do people engage with stats?" (stats opens)
  • "Which languages do people seek out?" (language selection)

🏁 Script executed:

#!/bin/bash

# Check which analytics functions are actually called in the codebase
echo "=== Analytics functions DEFINED ==="
grep -E "^export const track[A-Z]" frontend/src/analytics.ts | sed 's/export const //' | sed 's/ =.*//' | sort

echo -e "\n=== Analytics functions CALLED ==="
# Search for analytics.track* calls in TypeScript files
rg -n --type=ts 'analytics\.track[A-Z]\w+' -g '!analytics.ts' | sed 's/.*analytics\.//' | sed 's/(.*//' | sort -u

echo -e "\n=== Unused analytics functions ==="
comm -23 \
  <(grep -E "^export const track[A-Z]" frontend/src/analytics.ts | sed 's/export const //' | sed 's/ =.*//' | sort) \
  <(rg --type=ts 'analytics\.track[A-Z]\w+' -g '!analytics.ts' | sed 's/.*analytics\.//' | sed 's/(.*//' | sort -u)

Length of output: 859


🏁 Script executed:

#!/bin/bash

# Get PWA tracking calls
echo "=== PWA tracking in pwa.ts ==="
rg -n "track" frontend/src/pwa.ts

# Check if help/stats modals exist in the codebase
echo -e "\n=== Help modal references ==="
rg -n "showHelpModal|show_help" frontend/src/game.ts | head -5

echo -e "\n=== Stats modal references ==="
rg -n "show_stats_modal" frontend/src/game.ts | head -5

# Check for backspace handling
echo -e "\n=== Backspace handling ==="
rg -n "Backspace|Delete|⌫" frontend/src/game.ts -A2 | head -15

Length of output: 998


🏁 Script executed:

#!/bin/bash

# Check for days_since_last calculation (should exist for returning player tracking)
echo "=== Checking for days_since_last logic ==="
rg -n "days_since_last|daysSinceLast" frontend/src/game.ts

# Check if there are modal open handlers where tracking could be added
echo -e "\n=== Modal handlers that could track analytics ==="
rg -n "@click.*[Hh]elp|@click.*[Ss]tats" frontend/src/ --type=vue --type=html 2>/dev/null || echo "No .vue/.html files in frontend/src"

# Look for any existing template/HTML files
echo -e "\n=== Template files ==="
fd -e html -e vue . frontend/

# Check game.ts for methods that open modals
echo -e "\n=== Methods that set modal visibility ==="
rg -n "show_stats_modal.*=.*true|showHelpModal.*=.*true" frontend/src/game.ts -B2

Length of output: 491


🏁 Script executed:

#!/bin/bash

# Check beforeunload reliability concerns
echo "=== beforeunload usage ==="
rg -n "beforeunload" frontend/src/analytics.ts -A5

# Check for any GA4 measurement ID or gtag initialization
echo -e "\n=== Looking for GA4 initialization ==="
rg -n "gtag|GA_MEASUREMENT_ID|G-[A-Z0-9]+" frontend/ --type=html --type=js --type=ts 2>/dev/null | head -10

# Check if there are any tests
echo -e "\n=== Test files ==="
fd -e test.ts -e spec.ts . frontend/
fd test . frontend/ --type d

# Check package.json for GA4 dependencies
echo -e "\n=== Analytics dependencies ==="
cat package.json 2>/dev/null | grep -A5 -B5 "gtag\|analytics\|ga4" || echo "No package.json in current directory"

Length of output: 1011


🏁 Script executed:

#!/bin/bash

# Check the actual attempts value type
echo "=== Attempts handling ==="
rg -n "this\.attempts\s*=" frontend/src/game.ts | head -10

# Check if there's localStorage for last visit tracking
echo -e "\n=== Last visit tracking ==="
rg -n "last_visit|lastVisit|last_played" frontend/src/game.ts

# Look for existing error handlers
echo -e "\n=== Existing error handling ==="
rg -n "window\.addEventListener.*error|window\.onerror" frontend/src/ -g '!analytics.ts'

Length of output: 395


Now let me complete the comprehensive review:

6. beforeunload Unreliable for Game Abandonment

Lines 553-561 in analytics.ts use beforeunload to track game abandonment:

window.addEventListener('beforeunload', () => {
    const state = getState();
    if (!state.gameOver && state.activeRow > 0) {
        trackGameAbandon({...});
    }
});

Problems:

  • beforeunload events can be canceled by browsers (especially mobile Safari)
  • Events sent during beforeunload often don't complete before page unloads
  • Google Analytics recommends using visibilitychange instead

Better approach:

export const initAbandonTracking = (getState: () => {...}): void => {
    let lastVisibilityState = document.visibilityState;
    
    document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden' && lastVisibilityState === 'visible') {
            const state = getState();
            if (!state.gameOver && state.activeRow > 0) {
                // Use sendBeacon for reliability
                const data = {
                    language: state.language,
                    attempt_number: state.activeRow,
                    last_guess_valid: state.lastGuessValid,
                };
                navigator.sendBeacon('/api/track-abandon', JSON.stringify(data));
            }
        }
        lastVisibilityState = document.visibilityState;
    });
};

7. Returning Player Tracking Not Implemented

trackReturningPlayer is defined with days_since_last logic but never called! This is one of your key retention metrics.

Add to game.ts created() hook:

created() {
    // ... existing code ...
    
    // Track returning players
    const lastVisit = localStorage.getItem('last_visit_date');
    const today = new Date().toISOString().split('T')[0];
    
    if (lastVisit && lastVisit !== today) {
        const daysSince = Math.floor(
            (Date.now() - new Date(lastVisit).getTime()) / (1000 * 60 * 60 * 24)
        );
        analytics.trackReturningPlayer(
            langCode,
            daysSince,
            this.stats.current_streak
        );
    }
    
    localStorage.setItem('last_visit_date', today);
}

🟢 Code Quality Issues

8. Missing Integration Points

Based on the code analysis, these valuable events are not tracked:

Missing in game.ts:

// Add to help modal open handler
trackHelpOpen(this.config?.language_code || 'unknown');

// Add to stats modal open handler  
trackStatsOpen(
    this.config?.language_code || 'unknown',
    trigger === 'auto' ? 'game_end' : 'manual'
);

// Add to backspace handler (line 636)
else if (['Backspace', 'Delete', '⌫'].includes(key) && this.active_cell > 0) {
    analytics.trackRapidBackspace(this.config?.language_code || 'unknown');
    // ... existing code
}

9. Type Safety Issues

Line 60 in analytics.ts:

attempts: number | string;

This dual type is error-prone. The value is always a number (1-6) or 'X'.

Better:

attempts: 1 | 2 | 3 | 4 | 5 | 6 | 'X';

10. Error Details Truncation Inconsistent

Lines 538-539 truncate error details to 100 chars, but this still might be too long for GA4 quotas and could include sensitive data.

Recommendation:

export const initErrorTracking = (language: string): void => {
    window.addEventListener('error', (event) => {
        trackError({
            error_type: 'javascript_error',
            language,
            // Only track error type, not full messages
            details: event.error?.name || 'unknown',
        });
    });

    window.addEventListener('unhandledrejection', (event) => {
        trackError({
            error_type: 'unhandled_promise',
            language,
            details: event.reason?.name || 'promise_rejection',
        });
    });
};

11. No GA4 Initialization Visible

The PR doesn't show where window.gtag is initialized. Make sure you:

  • Load GA4 script asynchronously
  • Set up consent mode BEFORE loading
  • Use a valid Measurement ID

Expected in HTML (not in PR):

<!-- Google tag (gtag.js) with consent -->
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  
  // Set default consent before loading GA4
  gtag('consent', 'default', {
    'analytics_storage': 'denied'
  });
  
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');
</script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>

12. No Tests for Analytics Module

You have tests for other modules (diacritics.test.ts, game-logic.test.ts) but none for analytics.

Add tests:

// frontend/src/__tests__/analytics.test.ts
import { describe, it, expect, vi } from 'vitest';
import { trackGameStart, trackGameComplete } from '../analytics';

describe('Analytics', () => {
    it('should not crash when gtag is missing', () => {
        delete (window as any).gtag;
        expect(() => trackGameStart({
            language: 'en',
            is_returning: false,
        })).not.toThrow();
    });

    it('should call gtag when available', () => {
        const mockGtag = vi.fn();
        (window as any).gtag = mockGtag;
        
        trackGameComplete({
            language: 'en',
            won: true,
            attempts: 3,
            streak_after: 5,
        });
        
        expect(mockGtag).toHaveBeenCalledWith('event', 'game_complete', 
            expect.objectContaining({
                language: 'en',
                won: true,
                attempts: 3,
            })
        );
    });
});

📊 Performance Considerations

13. High Event Volume

You're tracking A LOT of events. Consider:

  • GA4 has quotas (500 events per session recommended)
  • Each guess fires 2 events (guess_submit + potentially invalid_word)
  • A single game could fire 20+ events

Recommendation: Sample frustration events:

export const trackFrustrationSignal = (language: string, type: string): void => {
    // Only track 10% of frustration events to reduce noise
    if (Math.random() < 0.1) {
        track('frustration_signal', {
            language,
            frustration_type: type,
        });
    }
};

14. Module Loading

The analytics module is imported at the top level of game.ts, so it's always loaded even if GA4 isn't initialized.

Consider lazy loading:

// Only import when needed
const analytics = await import('./analytics');

🎯 Architecture Recommendations

15. Consider an Analytics Service Class

Instead of module-level state, use a class:

export class AnalyticsService {
    private frustrationTracker = new FrustrationTracker();
    private consentGiven = false;
    
    setConsent(consent: boolean): void {
        this.consentGiven = consent;
    }
    
    trackGameStart(params: GameStartParams): void {
        if (!this.consentGiven) return;
        track('game_start', {...});
    }
    
    reset(): void {
        this.frustrationTracker.reset();
    }
}

export const analytics = new AnalyticsService();

16. Event Naming Convention

Your events mix naming styles:

  • game_start (snake_case)
  • pwa_session (snake_case)
  • Some parameters: is_pwa, is_returning (snake_case)

✅ This is actually consistent and follows GA4 best practices. Good job!

📝 Documentation Gaps

17. Missing README/Documentation

Add a README explaining:

  • How to enable/disable analytics
  • Privacy considerations
  • How to test in GA4 DebugView
  • What each event means for the business

Create: docs/ANALYTICS.md

✅ What You Did Well

  1. Excellent event design - Each event answers specific business questions
  2. Strong documentation - Comments explain the "why" behind events
  3. Type safety - Good use of TypeScript interfaces
  4. Privacy-conscious - Not tracking actual words (line 79)
  5. Graceful degradation - Won't break if gtag is missing
  6. Comprehensive coverage - Tracks entire user journey

🎬 Summary & Action Items

Must Fix Before Merge:

  1. ⚠️ Add consent mechanism (GDPR/CCPA)
  2. ⚠️ Implement time tracking for game completion
  3. ⚠️ Add returning player tracking with days_since_last
  4. ⚠️ Fix module-level state (use class or move to component)
  5. ⚠️ Replace beforeunload with visibilitychange
  6. ⚠️ Sanitize error messages before tracking

Should Add:

  1. 📊 Integrate missing tracking calls (help, stats, backspace, keyboard)
  2. 🧪 Add unit tests for analytics module
  3. 📚 Add analytics documentation
  4. 🔒 Use more conservative error details (names only, not messages)

Nice to Have:

  1. 🎯 Sample high-frequency events to reduce quota usage
  2. 🏗️ Refactor to class-based architecture
  3. ⚡ Consider lazy loading analytics module

This is solid work overall! The event taxonomy is well thought out and will answer real business questions. The main gaps are around privacy compliance, missing integrations, and reliability of abandonment tracking.

Would you like me to help implement any of these fixes?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: Align PWAParams.source union 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 inflate pwa_prompt_shown, and appinstalled isn’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.

lastGuessValid is always true, 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;
     ...
 }

Comment thread frontend/src/analytics.ts
Comment on lines +152 to +256
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,
});
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 {} | cat

Repository: Hugo0/wordle

Length of output: 5674


🏁 Script executed:

# Look for the param type definitions
rg "GameCompleteParams|ShareParams" frontend/src -A 5

Repository: 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 string or number (for example currency is string, value is number). If you sometimes send "12.34" (string) and other times 12.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 like COALESCE(...) / 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.

Suggested change
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.

Hugo0 added a commit that referenced this pull request Feb 22, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant