diff --git a/client/src/App.tsx b/client/src/App.tsx index 57dfbec..1d4e337 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 = () => { diff --git a/client/src/components/AuthOnboarding.tsx b/client/src/components/AuthOnboarding.tsx index 9800efa..784b35e 100644 --- a/client/src/components/AuthOnboarding.tsx +++ b/client/src/components/AuthOnboarding.tsx @@ -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 = () => { diff --git a/client/src/components/DemoViewer.tsx b/client/src/components/DemoViewer.tsx index d6e6445..ef64a04 100644 --- a/client/src/components/DemoViewer.tsx +++ b/client/src/components/DemoViewer.tsx @@ -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 diff --git a/client/src/components/InvitationAcceptance.tsx b/client/src/components/InvitationAcceptance.tsx index 702ee7a..4677683 100644 --- a/client/src/components/InvitationAcceptance.tsx +++ b/client/src/components/InvitationAcceptance.tsx @@ -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) { diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index 3324309..fddf242 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -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) diff --git a/client/src/components/ReadOnlyViewer.tsx b/client/src/components/ReadOnlyViewer.tsx index 220b72f..3c07504 100644 --- a/client/src/components/ReadOnlyViewer.tsx +++ b/client/src/components/ReadOnlyViewer.tsx @@ -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) { diff --git a/client/src/services/analytics.ts b/client/src/services/analytics.ts index 3260745..d8a23c0 100644 --- a/client/src/services/analytics.ts +++ b/client/src/services/analytics.ts @@ -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] diff --git a/client/src/utils/i18nLanguages.test.ts b/client/src/utils/i18nLanguages.test.ts index cdf3050..36819e7 100644 --- a/client/src/utils/i18nLanguages.test.ts +++ b/client/src/utils/i18nLanguages.test.ts @@ -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() + 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' + }) + }) }) diff --git a/client/src/utils/i18nLanguages.ts b/client/src/utils/i18nLanguages.ts index f7f15ea..cf0e985 100644 --- a/client/src/utils/i18nLanguages.ts +++ b/client/src/utils/i18nLanguages.ts @@ -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)) +} diff --git a/docs/plausible-events.md b/docs/plausible-events.md index 96b30de..6696bed 100644 --- a/docs/plausible-events.md +++ b/docs/plausible-events.md @@ -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.