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:
+2
-2
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user