diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts index 6e9b0ecc..bea2605e 100644 --- a/frontend/src/analytics.ts +++ b/frontend/src/analytics.ts @@ -508,7 +508,12 @@ export const initErrorTracking = (language: string): void => { * Track game abandonment on page unload */ export const initAbandonTracking = ( - getState: () => { language: string; activeRow: number; gameOver: boolean; lastGuessValid: boolean } + getState: () => { + language: string; + activeRow: number; + gameOver: boolean; + lastGuessValid: boolean; + } ): void => { window.addEventListener('beforeunload', () => { const state = getState(); diff --git a/frontend/src/game.ts b/frontend/src/game.ts index 5ada9127..3711c49d 100644 --- a/frontend/src/game.ts +++ b/frontend/src/game.ts @@ -697,8 +697,11 @@ export const createGameApp = () => { // Track game completion with session-aggregated frustration state const langCode = this.config?.language_code || 'unknown'; const frustrationState = analytics.resetFrustrationState(); - const gameStartTime = (window as Window & { _gameStartTime?: number })._gameStartTime; - const timeToComplete = gameStartTime ? Math.floor((Date.now() - gameStartTime) / 1000) : undefined; + const gameStartTime = (window as Window & { _gameStartTime?: number }) + ._gameStartTime; + const timeToComplete = gameStartTime + ? Math.floor((Date.now() - gameStartTime) / 1000) + : undefined; analytics.trackGameComplete({ language: langCode, @@ -735,8 +738,11 @@ export const createGameApp = () => { // Track game completion (loss) with session-aggregated frustration state const lossLangCode = this.config?.language_code || 'unknown'; const lossFrustrationState = analytics.resetFrustrationState(); - const lossGameStartTime = (window as Window & { _gameStartTime?: number })._gameStartTime; - const lossTimeToComplete = lossGameStartTime ? Math.floor((Date.now() - lossGameStartTime) / 1000) : undefined; + const lossGameStartTime = (window as Window & { _gameStartTime?: number }) + ._gameStartTime; + const lossTimeToComplete = lossGameStartTime + ? Math.floor((Date.now() - lossGameStartTime) / 1000) + : undefined; analytics.trackGameComplete({ language: lossLangCode, @@ -1190,7 +1196,10 @@ export const createGameApp = () => { } catch { // localStorage unavailable } - analytics.trackSettingsChange({ setting: 'haptics', value: this.hapticsEnabled }); + analytics.trackSettingsChange({ + setting: 'haptics', + value: this.hapticsEnabled, + }); }); }, diff --git a/frontend/src/index-app.ts b/frontend/src/index-app.ts index bc0ef51a..7cc73b1a 100644 --- a/frontend/src/index-app.ts +++ b/frontend/src/index-app.ts @@ -94,6 +94,7 @@ export default function createIndexApp(): App { total_stats: {} as TotalStats, game_results: {} as Record, expandedLanguage: '' as string, // For stats modal expansion + detectedLanguage: null as Language | null, }; }, @@ -125,6 +126,8 @@ export default function createIndexApp(): App { this.total_stats = this.calculateTotalStats(); // Initialize languages with recently played first this.languages_vis = this.getSortedLanguages(); + // Detect browser language for hero CTA + this.detectedLanguage = this.detectBrowserLanguage(); }, mounted() { @@ -144,6 +147,36 @@ export default function createIndexApp(): App { } }, + detectBrowserLanguage(): Language | null { + try { + // Try all preferred languages (navigator.languages), + // falling back to navigator.language for older browsers + const candidates = navigator.languages?.length + ? navigator.languages + : [navigator.language || '']; + + for (const browserLang of candidates) { + const lower = browserLang.toLowerCase(); + // Try exact match first (e.g. "nb" → "nb") + if (this.languages[lower]) { + return this.languages[lower] as Language; + } + // Try prefix match (e.g. "de-AT" → "de", "pt-BR" → "pt") + const prefix = lower.split('-')[0] ?? ''; + if (prefix && this.languages[prefix]) { + return this.languages[prefix] as Language; + } + // Special case: Norwegian "no" → "nb" (Bokmål) + if (prefix === 'no' && this.languages['nb']) { + return this.languages['nb'] as Language; + } + } + } catch { + // navigator.languages unavailable + } + return null; + }, + selectLanguageWithCode(language_code: string): void { window.location.href = '/' + language_code; }, @@ -286,7 +319,18 @@ export default function createIndexApp(): App { return rankA - rankB; }); - return [...playedLanguages, ...unplayedLanguages]; + const sorted = [...playedLanguages, ...unplayedLanguages]; + + // If we detected the browser language, move it to the front + if (this.detectedLanguage) { + const detectedCode = this.detectedLanguage.language_code; + const idx = sorted.findIndex((l) => l.language_code === detectedCode); + if (idx > 0) { + sorted.unshift(sorted.splice(idx, 1)[0]); + } + } + + return sorted; }, hasPlayed(language_code: string): boolean { diff --git a/playwright.config.ts b/playwright.config.ts index e474e73e..28a62a32 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from '@playwright/test'; /** * Playwright E2E test configuration for Wordle Global. @@ -8,32 +8,31 @@ import { defineConfig, devices } from "@playwright/test"; * Run specific test: pnpm test:e2e -g "homepage" */ export default defineConfig({ - testDir: "./e2e", + testDir: './e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, - reporter: "html", + reporter: 'html', use: { - baseURL: "http://127.0.0.1:8000", - trace: "on-first-retry", + baseURL: 'http://127.0.0.1:8000', + trace: 'on-first-retry', }, projects: [ { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, }, // Mobile viewport for responsive testing { - name: "mobile", - use: { ...devices["iPhone 13"] }, + name: 'mobile', + use: { ...devices['iPhone 13'] }, }, ], // Local dev server - start before tests if not already running webServer: { - command: - ". venv/bin/activate && gunicorn --chdir webapp --bind 127.0.0.1:8000 app:app", - url: "http://127.0.0.1:8000", + command: '. venv/bin/activate && gunicorn --chdir webapp --bind 127.0.0.1:8000 app:app', + url: 'http://127.0.0.1:8000', reuseExistingServer: !process.env.CI, timeout: 30000, },