feat(i18n): add English and French navigation foundation (#62)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-24 08:53:32 -04:00
1 parent 0f0a6ca51c
commit 6d3edbb61b
17 files changed
+552 -32

No files matched your search

+3
View File
@@ -82,6 +82,9 @@ because the root `tsconfig.json` has `files: []`.
## Tests
For UI text, follow the [translation guide](docs/translations.md). Add English
catalog keys and translate display labels without changing route/data identifiers.
- **Practice TDD for bugfixes and features** — write the failing test first,
watch it fail, then implement.
- Tests live in a top-level `tests/` tree that mirrors `src/` (e.g.
+56
View File
@@ -0,0 +1,56 @@
# Translating Beacon
The header language picker starts in English and saves the selected language in
this browser. Its trigger and panel use the same dropdown styling as the region
control. Open it with Enter/Space, Tab to a language and activate that button;
Escape closes it and returns focus to the trigger. French is the first
additional language. This first slice covers
desktop/mobile navigation, the region and theme controls, connection/retry
labels and lazy-page loading text. Detailed feature pages, chart labels, general
dialogs and data formatting remain follow-up work; selecting French does not
mean every page is translated yet.
## Add a language
1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`.
2. Set `name` to its native name, such as `Deutsch`. Translate string values
under `translation`; keep their keys and interpolation placeholders intact.
3. Build and test. Vite discovers every JSON catalog in that directory, and its
native name appears in the language picker automatically. No central language
list, plugin or translation server is needed.
Missing or empty translations fall back to English. Catalogs are bundled with the
application, so a new language requires a web build/deployment. Keep English as
the complete source catalog. Every selectable language must have a native name
and at least one translated string. Use plain text, not HTML, in translations.
## Add translated UI text
```tsx
import { useTranslation } from "react-i18next";
function LoadingMessage() {
const { t } = useTranslation();
return <span>{t("common.loading")}</span>;
}
```
Add the English key/value and translate it in other catalogs where possible.
Use `t("region.count", { count })` with i18next's language-specific plural
suffixes (`_one`, `_other`, and `_many` where applicable). Preserve placeholders
such as `{{seconds}}`; translate the whole phrase rather than joining words.
React escapes interpolated text; do not use `dangerouslySetInnerHTML` for it.
Translate display labels, never the tab IDs (`Packets`, `Analytics`, etc.), URL
parameters, region slugs/IATA codes, query keys or WebSocket subscriptions.
Names and packet contents come from the network and remain unchanged. This slice
does not change date/time/number formatting or measurement units. HTML language
and direction follow the selected catalog; a future right-to-left translation
also needs layout review before being offered to users.
Run `npm run build`, `npm run lint` and `npm test`. Test saved/unknown language
preferences, English fallback and switching without losing the selected view.
Check the picker and menus at narrow widths, including translated status text.
The language preference tolerates unavailable browser storage; the choice then
lasts only for that visit. Tests use the real i18next resources, reset to English
after each test, and keep route/state assertions on canonical identifiers.
+76 -2
View File
@@ -13,9 +13,11 @@
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-virtual": "^3.13.25",
"echarts": "6.1.0",
"i18next": "26.4.2",
"maplibre-gl": "^6.9.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "17.0.15",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
@@ -293,7 +295,6 @@
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2961,6 +2962,43 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
}
},
"node_modules/i18next": {
"version": "26.4.2",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3851,6 +3889,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.15",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz",
"integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -4225,7 +4290,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -4317,6 +4382,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
+2
View File
@@ -21,9 +21,11 @@
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-virtual": "^3.13.25",
"echarts": "6.1.0",
"i18next": "26.4.2",
"maplibre-gl": "^6.9.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "17.0.15",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
+7 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react";
import { BrowserRouter, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion";
import {
@@ -142,6 +143,11 @@ export function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: ()
// tab state and region init
function TabLoading({ tab }: { tab: string }) {
const { t } = useTranslation();
return <EmptyState title={t(`tabs.${tab}`, { defaultValue: tab })} subtitle={t("common.loading")} />;
}
function AppInner() {
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useIsMobile();
@@ -309,7 +315,7 @@ function AppInner() {
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="relative flex flex-1 min-h-0">
<div key={activeTab} className="flex flex-1 min-h-0 min-w-0 fade-in">
<Suspense fallback={<EmptyState title={activeTab} subtitle="Loading…" />}>
<Suspense fallback={<TabLoading tab={activeTab} />}>
{tabContent[activeTab]}
</Suspense>
</div>
+32 -20
View File
@@ -1,4 +1,6 @@
import { type ReactNode, useState, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { ErrorBoundary } from "./ErrorBoundary";
import { useQuery } from "@tanstack/react-query";
import { useRegionSelection, useRegions } from "../hooks/useRegion";
@@ -8,6 +10,7 @@ import { useRateLimit } from "../hooks/useRateLimit";
import { useTheme } from "../hooks/useTheme";
import { Dropdown } from "./Dropdown";
import { BottomNav } from "./BottomNav";
import { LanguagePicker } from "./LanguagePicker";
import { BeaconWordmark } from "./BeaconWordmark";
import { getIatas } from "../api/client";
import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants";
@@ -16,6 +19,7 @@ import type { WsManager } from "../api/ws-manager";
// header widgets: WS status, region picker, theme picker
function LiveBadge({ wsManager }: { wsManager: WsManager }) {
const { t } = useTranslation();
const { status } = useWsStatus(wsManager);
const [staleStr, setStaleStr] = useState("");
@@ -34,7 +38,7 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) {
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-green bg-green/8 border border-green/15 px-2 py-0.5 rounded-sm">
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse" />
LIVE
{t("connection.live")}
</div>
);
}
@@ -42,20 +46,21 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) {
if (status === "connecting") {
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-warn bg-warn/7 border border-warn/15 px-2 py-0.5 rounded-sm">
STALE {staleStr}
{t("connection.stale", { age: staleStr })}
</div>
);
}
return (
<div className="flex items-center gap-1.5 font-mono text-[11px] text-danger bg-danger/8 border border-danger/15 px-2 py-0.5 rounded-sm">
OFFLINE
{t("connection.offline")}
</div>
);
}
// Shown while the API is throttling us; the countdown tells users the blank tables are temporary.
function RateLimitBadge() {
const { t } = useTranslation();
const until = useRateLimit();
const [remaining, setRemaining] = useState(0);
@@ -74,7 +79,7 @@ function RateLimitBadge() {
return (
<div role="status" className="flex items-center gap-1.5 font-mono text-[11px] text-warn bg-warn/7 border border-warn/15 px-2 py-0.5 rounded-sm">
RATE LIMITED {remaining}s
{t("connection.rateLimited", { seconds: remaining })}
</div>
);
}
@@ -95,14 +100,14 @@ function CheckBox({ checked }: { checked: boolean }) {
}
// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 IATA".
function regionSummaryLabel(selection: RegionSelection): string {
if (isAllRegions(selection)) return "ALL";
function regionSummaryLabel(selection: RegionSelection, t: TFunction): string {
if (isAllRegions(selection)) return t("region.allShort");
const parts: string[] = [];
if (selection.regions.length > 0) {
parts.push(`${selection.regions.length} region${selection.regions.length > 1 ? "s" : ""}`);
parts.push(t("region.count", { count: selection.regions.length }));
}
if (selection.iatas.length > 0) {
parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : `${selection.iatas.length} IATA`);
parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : t("region.iataCount", { count: selection.iatas.length }));
}
return parts.join(" · ");
}
@@ -110,6 +115,7 @@ function regionSummaryLabel(selection: RegionSelection): string {
// Grouped multi-select: regions (each expands to its member IATAs) on top, then individual IATAs.
// Toggling keeps the dropdown open so several can be picked; "All Regions" clears the selection.
function RegionSelector() {
const { t } = useTranslation();
const { selection } = useRegionSelection();
return (
@@ -122,8 +128,8 @@ function RegionSelector() {
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
onClick={toggle}
>
<span className="text-text-muted font-normal text-[11px]">REGION</span>
{regionSummaryLabel(selection)}
<span className="text-text-muted font-normal text-[11px]">{t("region.label")}</span>
{regionSummaryLabel(selection, t)}
<span className="text-text-dim text-[11px]">▾</span>
</button>
)}
@@ -135,6 +141,7 @@ function RegionSelector() {
// Split out from RegionSelector so the filter query lives and dies with the open panel.
function RegionSelectorPanel() {
const { t } = useTranslation();
const { selection, setSelection } = useRegionSelection();
const { regions } = useRegions();
const [query, setQuery] = useState("");
@@ -193,7 +200,7 @@ function RegionSelectorPanel() {
);
}, [iatas, q]);
const showAll = !q || "all regions".includes(q);
const showAll = !q || t("region.allRegions").toLowerCase().includes(q);
const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed
const hasRowsAbove = showAll || shownRegions.length > 0;
@@ -213,7 +220,8 @@ function RegionSelectorPanel() {
setQuery("");
}
}}
placeholder="Filter IATA or name…"
aria-label={t("region.filter")}
placeholder={t("region.filter")}
className="w-full text-[16px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim"
/>
</div>
@@ -230,14 +238,14 @@ function RegionSelectorPanel() {
>
{/* spacer matching the checkbox column so ALL/code/name align with the rows below */}
<span className="w-3 shrink-0" aria-hidden="true" />
<span className="font-semibold text-primary w-8 shrink-0">ALL</span>
<span className="text-text-dim">All Regions</span>
<span className="font-semibold text-primary min-w-8 shrink-0">{t("region.allShort")}</span>
<span className="text-text-dim">{t("region.allRegions")}</span>
</button>
)}
{shownRegions.length > 0 && (
<>
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim">Regions</div>
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim">{t("region.group")}</div>
{shownRegions.map(({ region, matched }) => {
const checked = selection.regions.includes(region.slug);
return (
@@ -282,21 +290,22 @@ function RegionSelectorPanel() {
);
})
) : iatasError ? (
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">Failed to load</div>
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">{t("region.failed")}</div>
) : (
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">Loading…</div>
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">{t("common.loading")}</div>
)}
</>
)}
{!hasRowsAbove && !showIataGroup && (
<div className="px-3 py-2 text-[11px] font-mono text-text-dim">No matches</div>
<div className="px-3 py-2 text-[11px] font-mono text-text-dim">{t("region.noMatches")}</div>
)}
</>
);
}
function ThemePicker() {
const { t } = useTranslation();
const { themeId, themes, setThemeId } = useTheme();
const current = themes.find((t) => t.id === themeId);
const list = selectableThemes(themes, ENABLED_THEME_IDS);
@@ -306,6 +315,7 @@ function ThemePicker() {
renderTrigger={({ toggle }) => (
<button
type="button"
aria-label={t("theme.label")}
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-2 py-1 text-text-muted font-mono text-[11px] hover:text-text-normal hover:border-text-dim transition-colors"
onClick={toggle}
>
@@ -356,13 +366,15 @@ interface AppShellProps {
}
export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-dvh">
<header className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-3 py-1.5 min-h-[42px] md:flex md:gap-3 md:px-4 bg-bg-surface border-b border-border shrink-0">
<BeaconWordmark iconSize={22} textClassName="text-sm truncate" className="min-w-0" />
<div className="col-span-2 row-start-2 flex items-center justify-between gap-1.5 min-w-0 md:ml-auto md:justify-start md:gap-3">
<div className="col-span-2 row-start-2 flex flex-wrap items-center justify-between gap-1.5 min-w-0 md:ml-auto md:flex-nowrap md:justify-start md:gap-3">
<RegionSelector />
<ThemePicker />
<LanguagePicker />
</div>
<div className="col-start-2 row-start-1 flex items-center gap-1.5 md:gap-3">
<LiveBadge wsManager={wsManager} />
@@ -395,7 +407,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
}`}
onClick={() => onTabChange(tab)}
>
{tab}
{t(`tabs.${tab}`, { defaultValue: tab })}
</button>
))}
</nav>
+8 -5
View File
@@ -1,4 +1,5 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { BottomSheet } from "./BottomSheet";
import { ENABLED_TABS } from "../lib/constants";
@@ -81,8 +82,9 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
// Bottom sheet listing the overflow tabs.
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
const { t } = useTranslation();
return (
<BottomSheet onClose={onClose} role="menu" label="More tabs">
<BottomSheet onClose={onClose} role="menu" label={t("navigation.moreTabs")}>
{tabs.map((tab) => (
<button
key={tab}
@@ -93,7 +95,7 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
}`}
>
{tab}
{t(`tabs.${tab}`, { defaultValue: tab })}
</button>
))}
</BottomSheet>
@@ -101,6 +103,7 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ
}
export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) {
const { t } = useTranslation();
const [sheetOpen, setSheetOpen] = useState(false);
const enabled = new Set<string>(ENABLED_TABS);
const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t));
@@ -114,11 +117,11 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
return (
<>
<nav className="flex md:hidden shrink-0 bg-bg-surface border-t border-border" role="tablist" aria-label="Primary">
<nav className="flex md:hidden shrink-0 bg-bg-surface border-t border-border" role="tablist" aria-label={t("navigation.primary")}>
{primary.map((tab) => (
<NavButton
key={tab}
label={tab}
label={t(`tabs.${tab}`, { defaultValue: tab })}
icon={<Icon name={tab} />}
active={activeTab === tab}
onClick={() => onTabChange(tab)}
@@ -128,7 +131,7 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
))}
{overflow.length > 0 && (
<NavButton
label="More"
label={t("navigation.more")}
icon={<Icon name="More" />}
active={overflowActive || sheetOpen}
onClick={() => setSheetOpen((v) => !v)}
+67
View File
@@ -0,0 +1,67 @@
import { useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { languages } from "../i18n";
import { Dropdown } from "./Dropdown";
export function LanguagePicker() {
const { t, i18n } = useTranslation();
const triggerRef = useRef<HTMLButtonElement>(null);
const panelId = useId();
const language = i18n.resolvedLanguage ?? "en";
const selectedName = languages.find(({ code }) => code === language)?.name ?? language;
return (
<Dropdown
width="w-48"
renderTrigger={({ open, toggle }) => (
<button
ref={triggerRef}
type="button"
aria-label={`${t("language.label")}: ${selectedName}`}
aria-expanded={open}
aria-controls={open ? panelId : undefined}
title={t("language.help")}
onClick={toggle}
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
>
<span className="text-text-muted font-normal text-[11px] uppercase">{t("language.label")}</span>
{language.toUpperCase()}
<span className="text-text-dim text-[11px]">▾</span>
</button>
)}
>
{(close) => (
<div
id={panelId}
role="group"
aria-label={t("language.label")}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
close();
triggerRef.current?.focus();
}
}}
>
{languages.map(({ code, name }) => (
<button
key={code}
type="button"
aria-pressed={language === code}
onClick={() => {
void i18n.changeLanguage(code);
close();
triggerRef.current?.focus();
}}
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
language === code ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
}`}
>
<span aria-hidden="true" className="font-semibold text-primary min-w-8 shrink-0">{code.toUpperCase()}</span>
<span lang={code}>{name}</span>
</button>
))}
</div>
)}
</Dropdown>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { createInstance } from "i18next";
import { initReactI18next } from "react-i18next";
interface Catalog {
name: string;
translation: Record<string, unknown>;
}
const catalogs = import.meta.glob<Catalog>("./locales/*.json", { eager: true, import: "default" });
const resources = Object.fromEntries(Object.entries(catalogs).map(([path, catalog]) => [
path.slice("./locales/".length, -".json".length), { translation: catalog.translation },
]));
export const languages = Object.entries(catalogs).map(([path, catalog]) => ({
code: path.slice("./locales/".length, -".json".length),
name: catalog.name,
}));
export function readLanguagePreference(): string {
try {
const saved = localStorage.getItem("beacon-language");
if (saved && languages.some(({ code }) => code === saved)) return saved;
} catch {
// Storage can be unavailable; language selection still works for this visit.
}
return "en";
}
const i18n = createInstance();
i18n.on("languageChanged", () => {
const language = i18n.resolvedLanguage ?? "en";
document.documentElement.lang = language;
document.documentElement.dir = i18n.dir(language);
try {
localStorage.setItem("beacon-language", language);
} catch {
// Keep the in-memory choice even when persistence is blocked.
}
});
void i18n.use(initReactI18next).init({
resources,
lng: readLanguagePreference(),
supportedLngs: languages.map(({ code }) => code),
fallbackLng: "en",
initAsync: false,
returnEmptyString: false,
interpolation: { escapeValue: false }, // React escapes text content.
react: { useSuspense: false },
});
export default i18n;
+44
View File
@@ -0,0 +1,44 @@
{
"name": "English",
"translation": {
"language": {
"label": "Language",
"help": "Choose a language. Some pages still use English."
},
"tabs": {
"Packets": "Packets",
"Channels": "Channels",
"Map": "Map",
"Nodes": "Nodes",
"Observers": "Observers",
"Routes": "Routes",
"Traces": "Traces",
"Analytics": "Analytics"
},
"navigation": {
"primary": "Primary",
"more": "More",
"moreTabs": "More tabs"
},
"connection": {
"live": "LIVE",
"stale": "STALE {{age}}",
"offline": "OFFLINE",
"rateLimited": "RATE LIMITED {{seconds}}s"
},
"region": {
"label": "REGION",
"allShort": "ALL",
"allRegions": "All Regions",
"group": "Regions",
"count_one": "{{count}} region",
"count_other": "{{count}} regions",
"iataCount": "{{count}} IATA",
"filter": "Filter IATA or name…",
"failed": "Failed to load",
"noMatches": "No matches"
},
"theme": { "label": "Theme" },
"common": { "loading": "Loading…" }
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "Français",
"translation": {
"language": {
"label": "Langue",
"help": "Choisissez une langue. Certaines pages restent en anglais."
},
"tabs": {
"Packets": "Paquets",
"Channels": "Canaux",
"Map": "Carte",
"Nodes": "Nœuds",
"Observers": "Observateurs",
"Routes": "Routes",
"Traces": "Traces",
"Analytics": "Analyses"
},
"navigation": {
"primary": "Navigation principale",
"more": "Plus",
"moreTabs": "Autres onglets"
},
"connection": {
"live": "EN DIRECT",
"stale": "EN RETARD {{age}}",
"offline": "HORS LIGNE",
"rateLimited": "RÉESSAI DANS {{seconds}} s"
},
"region": {
"label": "RÉGION",
"allShort": "TOUT",
"allRegions": "Toutes les régions",
"group": "Régions",
"count_one": "{{count}} région",
"count_many": "{{count}} régions",
"count_other": "{{count}} régions",
"iataCount": "{{count}} IATA",
"filter": "Filtrer par IATA ou nom…",
"failed": "Échec du chargement",
"noMatches": "Aucun résultat"
},
"theme": { "label": "Thème" },
"common": { "loading": "Chargement…" }
}
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./i18n";
import { App } from "./App";
import "./index.css";
+36 -2
View File
@@ -21,12 +21,12 @@ const wsManager = {
getLastEventTimestamp: () => Date.now(),
} as unknown as WsManager;
function renderShell() {
function renderShell(onTabChange: (tab: string) => void = () => {}) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={client}>
<RegionProvider defaultSelection={ALL_REGIONS}>
<AppShell activeTab="Packets" onTabChange={() => {}} wsManager={wsManager}>
<AppShell activeTab="Packets" onTabChange={onTabChange} wsManager={wsManager}>
<div />
</AppShell>
</RegionProvider>
@@ -41,6 +41,24 @@ beforeEach(() => {
});
describe("AppShell", () => {
it("switches navigation to French while preserving tab identifiers and the active view", async () => {
vi.mocked(getIatas).mockResolvedValue([]);
const onTabChange = vi.fn();
renderShell(onTabChange);
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false"));
expect(screen.getAllByRole("tab", { name: "Paquets" })[0]).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("EN DIRECT")).toBeInTheDocument();
expect(document.documentElement.lang).toBe("fr");
expect(localStorage.getItem("beacon-language")).toBe("fr");
expect(onTabChange).not.toHaveBeenCalled();
fireEvent.click(screen.getAllByRole("tab", { name: "Carte" })[0]!);
expect(onTabChange).toHaveBeenCalledWith("Map");
});
it("footer shows the package.json version", () => {
vi.mocked(getIatas).mockResolvedValue([]);
renderShell();
@@ -88,6 +106,22 @@ describe("region picker filter", () => {
vi.mocked(getRegion).mockImplementation(async (id: number) => REGIONS.find((r) => r.id === id)!);
});
it("searches the translated all-regions label and retains raw IATA values", async () => {
renderShell();
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
fireEvent.click(await screen.findByRole("button", { name: /RÉGION/ }));
await screen.findByText("Western Canada");
const input = screen.getByRole("textbox", { name: "Filtrer par IATA ou nom…" });
fireEvent.change(input, { target: { value: "toutes" } });
expect(screen.getByText("Toutes les régions")).toBeInTheDocument();
fireEvent.change(input, { target: { value: "yvr" } });
expect(screen.queryByText("Toutes les régions")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Vancouver International/ }));
expect(screen.getByRole("button", { name: /RÉGION\s*YVR/ })).toBeInTheDocument();
});
it("focuses the filter input when the picker opens", async () => {
renderShell();
const input = await openPicker();
+14 -1
View File
@@ -1,8 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { BottomNav } from "../../src/components/BottomNav";
import i18n from "../../src/i18n";
describe("BottomNav", () => {
it("translates an open More sheet without changing the selected tab identifier", async () => {
const onTabChange = vi.fn();
render(<BottomNav activeTab="Analytics" onTabChange={onTabChange} />);
fireEvent.click(screen.getByText("More"));
await act(() => i18n.changeLanguage("fr"));
expect(screen.getByRole("menu", { name: "Autres onglets" })).toBeInTheDocument();
expect(screen.getByText("Plus").closest("button")).toHaveAttribute("aria-expanded", "true");
fireEvent.click(screen.getByRole("menuitem", { name: "Analyses" }));
expect(onTabChange).toHaveBeenCalledWith("Analytics");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("marks the active primary tab with aria-selected", () => {
render(<BottomNav activeTab="Map" onTabChange={() => {}} />);
expect(screen.getByRole("tab", { name: "Map" })).toHaveAttribute("aria-selected", "true");
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { LanguagePicker } from "../../src/components/LanguagePicker";
describe("LanguagePicker", () => {
it("opens the language choices, selects French and returns focus to the trigger", async () => {
render(<LanguagePicker />);
const trigger = screen.getByRole("button", { name: "Language: English" });
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("group", { name: "Language" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "English", exact: true })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveFocus());
expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("group")).not.toBeInTheDocument();
expect(localStorage.getItem("beacon-language")).toBe("fr");
});
it("closes with Escape from an option without changing language or losing focus", () => {
render(<LanguagePicker />);
const trigger = screen.getByRole("button", { name: "Language: English" });
fireEvent.click(trigger);
const french = screen.getByRole("button", { name: "Français", exact: true });
french.focus();
fireEvent.keyDown(french, { key: "Escape" });
expect(trigger).toHaveFocus();
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("group")).not.toBeInTheDocument();
});
it("closes on an outside click without changing the choice", () => {
render(<LanguagePicker />);
const trigger = screen.getByRole("button", { name: "Language: English" });
fireEvent.click(trigger);
fireEvent.mouseDown(document.body);
expect(trigger).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("group")).not.toBeInTheDocument();
});
});
+64
View File
@@ -0,0 +1,64 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import i18n, { languages, readLanguagePreference } from "../../src/i18n";
afterEach(() => {
vi.restoreAllMocks();
localStorage.removeItem("beacon-language");
});
describe("language preferences and catalogs", () => {
it("offers the bundled English and French catalogs by their native names", () => {
expect(languages).toEqual(expect.arrayContaining([
{ code: "en", name: "English" },
{ code: "fr", name: "Français" },
]));
});
it("defaults to English and restores a supported saved choice", () => {
localStorage.removeItem("beacon-language");
expect(readLanguagePreference()).toBe("en");
localStorage.setItem("beacon-language", "fr");
expect(readLanguagePreference()).toBe("fr");
});
it.each(["unknown", "constructor", "", "../fr"])("ignores unsupported saved language %j", (value) => {
localStorage.setItem("beacon-language", value);
expect(readLanguagePreference()).toBe("en");
});
it("still changes language when browser storage is unavailable", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
expect(readLanguagePreference()).toBe("en");
await i18n.changeLanguage("fr");
expect(i18n.t("tabs.Packets")).toBe("Paquets");
expect(document.documentElement.lang).toBe("fr");
expect(document.documentElement.dir).toBe("ltr");
});
it("persists only the language preference and restores English document metadata", async () => {
localStorage.setItem("beacon-region", "YVR");
await i18n.changeLanguage("fr");
expect(localStorage.getItem("beacon-language")).toBe("fr");
expect(localStorage.getItem("beacon-region")).toBe("YVR");
await i18n.changeLanguage("en");
expect(document.documentElement.lang).toBe("en");
localStorage.removeItem("beacon-region");
});
it("falls back to English for missing and empty translated strings", async () => {
i18n.addResource("en", "translation", "fallbackTest", "English fallback");
await i18n.changeLanguage("fr");
expect(i18n.t("fallbackTest")).toBe("English fallback");
i18n.addResource("fr", "translation", "fallbackTest", "");
expect(i18n.t("fallbackTest")).toBe("English fallback");
});
it("uses each language's plural forms and interpolates the retry duration", () => {
expect(i18n.t("region.count", { lng: "en", count: 1 })).toBe("1 region");
expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions");
expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région");
expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions");
expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("RÉESSAI DANS 5 s");
});
});
+3 -1
View File
@@ -1,6 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import i18n from "../src/i18n";
// jsdom doesn't implement matchMedia. Provide a default stub so components that read media queries
// mount as "desktop" by default — a hover-capable pointer, not below the mobile width. Individual
@@ -20,6 +21,7 @@ if (!window.matchMedia) {
}));
}
afterEach(() => {
afterEach(async () => {
cleanup();
await i18n.changeLanguage("en");
});