Track language selection with Plausible Language Changed event.

Centralize UI language switches in cycleAppLanguage and document the event in plausible-events.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 16:38:10 +02:00
parent ffe6b19818
commit bd1edd89f3
10 changed files with 99 additions and 15 deletions
+2 -2
View File
@@ -45,7 +45,7 @@ import { Ship, LogOut, ChevronLeft, Users, FileText, Settings, Wifi, WifiOff, La
import DisclaimerHeaderButton from './components/DisclaimerHeaderButton.tsx'
import FeedbackHeaderButton from './components/FeedbackHeaderButton.tsx'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from './utils/i18nLanguages.js'
import { cycleAppLanguage } from './utils/i18nLanguages.js'
import {
resolveTourLogbookContext,
seedDemoLogbookIfNeeded
@@ -497,7 +497,7 @@ function App() {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const handleExitDemo = () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import {
registerUser,
loginUser,
@@ -210,7 +210,7 @@ export default function AuthOnboarding({ onAuthenticated, onOpenDemo }: AuthOnbo
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const copyToClipboard = () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import VesselForm from './VesselForm.tsx'
import CrewForm from './CrewForm.tsx'
import LogEntriesList from './LogEntriesList.tsx'
@@ -49,7 +49,7 @@ export default function DemoViewer({ onExit }: DemoViewerProps) {
}, [registerNavigation, registerDemoTourContext, startTour, fixture.firstEntryId])
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const { title, yacht, crews, entries, gpsTracks, photos, firstEntryId } = fixture
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage } from '../utils/i18nLanguages.js'
import { Ship, LogIn, UserPlus, AlertTriangle, ShieldCheck, Languages, ArrowRight, KeyRound } from 'lucide-react'
import {
getActiveMasterKey,
@@ -309,7 +309,7 @@ export default function InvitationAcceptance({ onAccepted, onCancel }: Invitatio
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
if (recoveryPhrase) {
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage } from '../utils/i18nLanguages.js'
import { cycleAppLanguage } from '../utils/i18nLanguages.js'
import { useSyncIndicator } from '../hooks/useSyncIndicator.js'
import { fetchLogbooks, createLogbook, deleteLogbook, updateLogbookTitle, type DecryptedLogbook } from '../services/logbook.js'
import LogbookRoleBadge from './LogbookRoleBadge.tsx'
@@ -194,7 +194,7 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout, onOpenProf
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
const ownedLogbooks = logbooks.filter((lb) => !lb.isShared)
+2 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { cycleAppLanguage, getNextLanguage, isGermanLocale } from '../utils/i18nLanguages.js'
import { decryptJson } from '../services/crypto.js'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
import VesselForm from './VesselForm.tsx'
@@ -137,7 +137,7 @@ export default function ReadOnlyViewer({ token, hexKey }: ReadOnlyViewerProps) {
}
const toggleLanguage = () => {
i18n.changeLanguage(getNextLanguage(i18n.language))
cycleAppLanguage(i18n)
}
if (loading) {
+2 -1
View File
@@ -34,7 +34,8 @@ export const PlausibleEvents = {
LOCAL_PIN_SET: 'Local PIN Set',
LOCAL_PIN_REMOVED: 'Local PIN Removed',
DEVICE_FORGOTTEN: 'Device Forgotten',
RECOVERY_ROTATED: 'Recovery Rotated'
RECOVERY_ROTATED: 'Recovery Rotated',
LANGUAGE_CHANGED: 'Language Changed'
} as const
export type PlausibleEventName = (typeof PlausibleEvents)[keyof typeof PlausibleEvents]
+64 -2
View File
@@ -1,7 +1,40 @@
import { describe, expect, it } from 'vitest'
import { getNextLanguage, normalizeAppLanguage, SUPPORTED_LANGUAGES } from './i18nLanguages.js'
import type { i18n as I18nInstance } from 'i18next'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PlausibleEvents } from '../services/analytics.js'
import {
changeAppLanguage,
cycleAppLanguage,
getNextLanguage,
normalizeAppLanguage,
SUPPORTED_LANGUAGES
} from './i18nLanguages.js'
const trackPlausibleEvent = vi.fn()
vi.mock('../services/analytics.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../services/analytics.js')>()
return {
...actual,
trackPlausibleEvent: (...args: unknown[]) => trackPlausibleEvent(...args)
}
})
function createMockI18n(language: string): I18nInstance {
let current = language
return {
language: current,
changeLanguage: vi.fn(async (lng: string) => {
current = lng
;(this as { language: string }).language = lng
})
} as unknown as I18nInstance
}
describe('i18nLanguages', () => {
beforeEach(() => {
trackPlausibleEvent.mockReset()
})
it('normalizes regional tags to supported base codes', () => {
expect(normalizeAppLanguage('de-DE')).toBe('de')
expect(normalizeAppLanguage('nb-NO')).toBe('nb')
@@ -18,4 +51,33 @@ describe('i18nLanguages', () => {
expect(seen.size).toBe(SUPPORTED_LANGUAGES.length)
expect(current).toBe('de')
})
it('tracks explicit language changes', () => {
const i18n = createMockI18n('de')
changeAppLanguage(i18n, 'sv')
expect(i18n.changeLanguage).toHaveBeenCalledWith('sv')
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'de',
to: 'sv'
})
})
it('does not track when language stays the same', () => {
const i18n = createMockI18n('en')
changeAppLanguage(i18n, 'en')
expect(i18n.changeLanguage).not.toHaveBeenCalled()
expect(trackPlausibleEvent).not.toHaveBeenCalled()
})
it('cycleAppLanguage tracks the next language', () => {
const i18n = createMockI18n('nb')
cycleAppLanguage(i18n)
expect(trackPlausibleEvent).toHaveBeenCalledWith(PlausibleEvents.LANGUAGE_CHANGED, {
from: 'nb',
to: 'de'
})
})
})
+17
View File
@@ -1,3 +1,6 @@
import type { i18n as I18nInstance } from 'i18next'
import { PlausibleEvents, trackPlausibleEvent } from '../services/analytics.js'
/** Supported UI languages (ISO 639-1, language-only). */
export const SUPPORTED_LANGUAGES = ['de', 'en', 'da', 'sv', 'nb'] as const
@@ -20,3 +23,17 @@ export function getNextLanguage(current?: string): AppLanguage {
export function isGermanLocale(language?: string): boolean {
return normalizeAppLanguage(language) === 'de'
}
/** Switch UI language and track explicit user choice (not auto-detection). */
export function changeAppLanguage(i18n: I18nInstance, language: AppLanguage): void {
const from = normalizeAppLanguage(i18n.language)
const to = normalizeAppLanguage(language)
if (from === to) return
void i18n.changeLanguage(to)
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from, to })
}
export function cycleAppLanguage(i18n: I18nInstance): void {
changeAppLanguage(i18n, getNextLanguage(i18n.language))
}
+4
View File
@@ -49,6 +49,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
| Local PIN Removed | Lokaler PIN entfernt (`UserProfilePage.tsx`) | — |
| Device Forgotten | Account aus Schnell-Login-Liste dieses Geräts entfernt (`UserProfilePage.tsx`) | — |
| Recovery Rotated | Neuer 12-Wörter-Wiederherstellungsschlüssel erstellt (`UserProfilePage.tsx`) | — |
| Language Changed | Sprache über UI-Wechsler gewählt (`i18nLanguages.ts` via Sprach-Button in App, Dashboard, Auth, Demo, Einladung, Share-Viewer) | `from`, `to`: ISO 639-1 (`de`, `en`, `da`, `sv`, `nb`) |
## Bewusst nicht getrackt
@@ -56,6 +57,7 @@ Kapteins Daagbok nutzt [Plausible Analytics](https://plausible.io/) mit dem Scri
- **Manuelle Signaturen:** Nur Passkey-Signaturen lösen `Entry Signed` aus.
- **PII:** Keine Inhalte aus verschlüsselten Logbüchern in Properties.
- **Profil-KPIs:** Statistik-Karten und User-ID-Kopieren werden nicht getrackt (reine Anzeige bzw. zu granular).
- **Sprache bei Erstbesuch:** Automatische Browser-/URL-Erkennung (`i18next-browser-languagedetector`, `?lng=`) löst kein `Language Changed` aus — nur explizite Klicks auf den Sprach-Button.
- **Kontolöschung:** `Account Deleted` bleibt in `auth.ts` — unabhängig davon, ob die Gefahrenzone auf der Profilseite oder früher in den Einstellungen genutzt wurde.
## Typische Funnels (Plausible Goals)
@@ -69,6 +71,7 @@ Empfohlene Goal-Ketten für Auswertung:
5. **Export:** Travel Day Saved → PDF Exported / CSV Exported
6. **Datensicherung:** Backup Exported → Backup Restored
7. **Kontosicherheit:** Profile Opened → Passkey Added / Local PIN Set / Recovery Rotated; Last Passkey Remove Hinted → Account Deleted (selten, aber aussagekräftig)
8. **Internationalisierung:** Language Changed (Verteilung `to`, Pfade mit Übersetzungs-Feedback)
## Entwicklung
@@ -77,6 +80,7 @@ import { PlausibleEvents, trackPlausibleEvent } from './services/analytics.js'
trackPlausibleEvent(PlausibleEvents.TRAVEL_DAY_SAVED)
trackPlausibleEvent(PlausibleEvents.ENTRY_SIGNED, { role: 'skipper' })
trackPlausibleEvent(PlausibleEvents.LANGUAGE_CHANGED, { from: 'de', to: 'da' })
```
Lokal ohne Plausible-Script ist `trackPlausibleEvent` ein No-Op. In Production im Browser-Netzwerk-Tab auf Requests an die Plausible-Instanz prüfen.