diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c6163d..8d21955 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 0000000..cd06148 --- /dev/null +++ b/docs/translations.md @@ -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 {t("common.loading")}; +} +``` + +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. diff --git a/package-lock.json b/package-lock.json index a57c914..ed18e9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 45276a2..284061c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.tsx b/src/App.tsx index 573822e..28fcb79 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ; +} + function AppInner() { const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); @@ -309,7 +315,7 @@ function AppInner() {
- }> + }> {tabContent[activeTab]}
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index cc0d9e9..7745088 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -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 (
- LIVE + {t("connection.live")}
); } @@ -42,20 +46,21 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { if (status === "connecting") { return (
- STALE {staleStr} + {t("connection.stale", { age: staleStr })}
); } return (
- OFFLINE + {t("connection.offline")}
); } // 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 (
- RATE LIMITED {remaining}s + {t("connection.rateLimited", { seconds: remaining })}
); } @@ -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} > - REGION - {regionSummaryLabel(selection)} + {t("region.label")} + {regionSummaryLabel(selection, t)} ▾ )} @@ -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" />
@@ -230,14 +238,14 @@ function RegionSelectorPanel() { > {/* spacer matching the checkbox column so ALL/code/name align with the rows below */}