diff --git a/build/check-bundle-size.ts b/build/check-bundle-size.ts index cad65857..cb3df2be 100644 --- a/build/check-bundle-size.ts +++ b/build/check-bundle-size.ts @@ -42,7 +42,13 @@ const BUDGET_BYTES: Record = { // Raised to 800 kB for the ATK ZERO driver (AtkCards.tsx, device/atk.ts) // and the mouse-reported lift-off range plumbing: the measured aggregate is // 790.6 kB, leaving ~9 kB of headroom. - ".js": 800_000, + // Raised to 895 kB for the Portuguese (pt) localization: the full + // en+pt UI dictionary adds ~85 kB of strings to the measured aggregate + // (883.2 kB, on top of the 800 kB budget's own ~790.8 kB baseline). The pt + // table ships as its own lazy chunk (i18n-pt-*.js, loaded only when a + // non-English locale is selected), so the initial load is unaffected — the + // aggregate counts it because the check sums every emitted chunk. + ".js": 895_000, }; const ASSETS = join("dist", "assets"); diff --git a/package-lock.json b/package-lock.json index 4c9611a7..6baf2a25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -481,7 +481,7 @@ }, "node_modules/@openmouse/protocol": { "version": "0.1.0", - "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#eb9de5d9574b626fbb40a445edeb4aeafbdfe2bf", + "resolved": "git+ssh://git@github.com/OpenMouse-Project/mouse-protocol.git#9d85a0ed416b6b1a2013c80e6a5c4c1ab9b71e02", "engines": { "node": ">=20" } diff --git a/src/admin.tsx b/src/admin.tsx index acb1a047..d1dcf535 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useState } from "react"; import { createRoot } from "react-dom/client"; import "./admin.css"; +import { t, tp } from "./i18n"; +import type { InterfaceLocale } from "./interface-preferences"; +import { PageLocaleToggle, usePageLocale } from "./app/page-locale"; type DailyPoint = { day: string; view_count: number; peak_concurrent: number }; type CountRow = { country?: string; mouse_model?: string; view_count?: number; uses?: number }; @@ -15,13 +18,23 @@ type Stats = { const LIVE_POLL_MS = 5000; const RANGE_OPTIONS = [7, 30, 90] as const; -const WEEKDAY_LABELS = ["S", "M", "T", "W", "T", "F", "S"]; -const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; -function formatDay(day: string) { +/** 2024-01-07 was a Sunday; used as the anchor for locale weekday names. */ +function weekdayName(tag: string, index: number): string { + return new Date(2024, 0, 7 + index).toLocaleDateString(tag, { weekday: "long" }); +} + +function weekdayNarrow(tag: string): string[] { + return Array.from( + { length: 7 }, + (_, index) => new Date(2024, 0, 7 + index).toLocaleDateString(tag, { weekday: "narrow" }), + ); +} + +function formatDay(day: string, tag: string) { const d = new Date(day); if (Number.isNaN(d.getTime())) return day; - return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return d.toLocaleDateString(tag, { month: "short", day: "numeric" }); } function pctChange(current: number, previous: number): number | null { if (previous <= 0) return null; @@ -37,7 +50,7 @@ const IconTrend = () => ( const IconLogout = () => (); /* ---------- login ---------- */ -function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { +function LoginForm({ onLoggedIn, locale }: { onLoggedIn: () => void; locale: InterfaceLocale }) { const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -54,12 +67,12 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { }); if (!response.ok) { const body = await response.json().catch(() => ({})); - setError(body.message ?? "Login failed."); + setError(body.message ?? t(locale, "adm.loginFailed")); return; } onLoggedIn(); } catch { - setError("Network error."); + setError(t(locale, "adm.networkError")); } finally { setBusy(false); } @@ -67,17 +80,17 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { return (
-

OpenMouse Admin

+

{t(locale, "adm.title")}

setPassword((e.target as HTMLInputElement).value)} className="adm-login-input" /> {error &&

{error}

} @@ -85,12 +98,12 @@ function LoginForm({ onLoggedIn }: { onLoggedIn: () => void }) { } /* ---------- chart ---------- */ -function DailyChart({ points }: { points: DailyPoint[] }) { +function DailyChart({ points, locale, tag }: { points: DailyPoint[]; locale: InterfaceLocale; tag: string }) { const [hover, setHover] = useState(null); const width = 640; const height = 168; const max = Math.max(1, ...points.map((p) => p.view_count)); - if (!points.length) return

No history yet.

; + if (!points.length) return

{t(locale, "adm.noHistory")}

; const activeIndex = hover ?? points.length - 1; const active = points[activeIndex]; @@ -103,7 +116,7 @@ function DailyChart({ points }: { points: DailyPoint[] }) {
{active.view_count.toLocaleString()} - views · {formatDay(active.day)} · peak concurrent {active.peak_concurrent} + {t(locale, "adm.viewsWord")} · {formatDay(active.day, tag)} · {tp(locale, "adm.peakConcurrent", { n: active.peak_concurrent })}
setHover(null)}> @@ -114,15 +127,15 @@ function DailyChart({ points }: { points: DailyPoint[] }) { {coords.map(([x, y], i) => (i === activeIndex ? : null))}
- {formatDay(points[0].day).toUpperCase()} - {formatDay(points[points.length - 1].day).toUpperCase()} + {formatDay(points[0].day, tag).toUpperCase()} + {formatDay(points[points.length - 1].day, tag).toUpperCase()}
); } /** GitHub-style intensity heatmap of views by weekday, one row per week. */ -function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { +function WeekdayHeatmap({ points, locale, tag }: { points: DailyPoint[]; locale: InterfaceLocale; tag: string }) { const weeks = useMemo(() => { const chunks: (DailyPoint | null)[][] = []; for (let i = 0; i < points.length; i += 7) { @@ -136,7 +149,7 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { } return chunks; }, [points]); - if (!points.length) return

No history yet.

; + if (!points.length) return

{t(locale, "adm.noHistory")}

; const max = Math.max(1, ...points.map((p) => p.view_count)); return ( @@ -147,7 +160,7 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) { W{wi + 1} {week.map((cell, di) => cell ? ( -
+
) : (
), @@ -157,17 +170,18 @@ function WeekdayHeatmap({ points }: { points: DailyPoint[] }) {
- {WEEKDAY_LABELS.map((l, i) => {l})} + {weekdayNarrow(tag).map((l, i) => {l})}
); } -function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { +function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader, locale }: { rows: CountRow[]; labelKey: "country" | "mouse_model"; valueKey: "view_count" | "uses"; nameHeader: string; valueHeader: string; + locale: InterfaceLocale; }) { const max = Math.max(1, ...rows.map((r) => Number(r[valueKey]) || 0)); - if (!rows.length) return

No data yet.

; + if (!rows.length) return

{t(locale, "adm.noData")}

; return ( @@ -180,7 +194,7 @@ function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { @@ -194,6 +208,8 @@ function DataTable({ rows, labelKey, valueKey, nameHeader, valueHeader }: { /* ---------- dashboard ---------- */ function Dashboard() { + const [locale, setLocale] = usePageLocale(); + const tag = locale === "pt" ? "pt-BR" : "en-US"; const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [range, setRange] = useState(30); @@ -202,7 +218,7 @@ function Dashboard() { const load = async (days: number) => { const response = await fetch(`/api/admin/stats?days=${days}`); if (response.status === 401) { setError("session-expired"); return; } - if (!response.ok) { setError("Could not load stats."); return; } + if (!response.ok) { setError(t(locale, "adm.loadFail")); return; } setStats(await response.json()); setLastUpdated(new Date()); setError(null); @@ -227,7 +243,7 @@ function Dashboard() { const sums = new Array(7).fill(0); daily.forEach((d) => { const dt = new Date(d.day); if (!Number.isNaN(dt.getTime())) sums[dt.getUTCDay()] += d.view_count; }); const bestIndex = sums.reduce((best, v, i) => (v > sums[best] ? i : best), 0); - return WEEKDAY_NAMES[bestIndex]; + return weekdayName(tag, bestIndex); }, [daily]); const weekTrend = useMemo(() => { if (daily.length < 14) return null; @@ -241,9 +257,9 @@ function Dashboard() { const liveVal = stats?.live ?? 0; const peakVal = stats?.allTimePeak ?? 0; const concurrencyRatio = peakVal > 0 ? Math.min(100, (liveVal / peakVal) * 100) : 0; - const trackedSince = daily.length ? formatDay(daily[0].day) : "—"; + const trackedSince = daily.length ? formatDay(daily[0].day, tag) : "—"; - if (error === "session-expired") return load(range)} />; + if (error === "session-expired") return load(range)} locale={locale} />; return (
@@ -251,17 +267,18 @@ function Dashboard() {
-
OpenMouse Admin
-
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : "Loading…"}
+
{t(locale, "adm.title")}
+
{lastUpdated ? tp(locale, "adm.updated", { time: lastUpdated.toLocaleTimeString(tag) }) : t(locale, "adm.loading")}
+
{RANGE_OPTIONS.map((opt) => ( ))}
-
@@ -272,70 +289,70 @@ function Dashboard() {
-
Live right now
+
{t(locale, "adm.live")}
{stats?.live ?? "—"}
-
All-time peak {stats?.allTimePeak ?? "—"}{stats?.allTimePeakAt ? ` · ${new Date(stats.allTimePeakAt).toLocaleDateString()}` : ""}
+
{t(locale, "adm.peak")} {stats?.allTimePeak ?? "—"}{stats?.allTimePeakAt ? ` · ${new Date(stats.allTimePeakAt).toLocaleDateString(tag)}` : ""}
-
{concurrencyRatio.toFixed(0)}% of peak
+
{tp(locale, "adm.ofPeak", { n: concurrencyRatio.toFixed(0) })}
-

Daily views

- LAST {daily.length}D · AVG {avgDaily.toFixed(1)} +

{t(locale, "adm.dailyViews")}

+ {tp(locale, "adm.lastAvg", { n: daily.length, avg: avgDaily.toFixed(1) })}
- +
-
Views, {daily.length}d
+
{tp(locale, "adm.viewsDays", { n: daily.length })}
{totalViews.toLocaleString()}
{weekTrend != null && ( = 0 ? "is-up" : "is-down"}`}>{weekTrend >= 0 ? "▲" : "▼"} {Math.abs(weekTrend).toFixed(1)}% )}
-
Avg / day
+
{t(locale, "adm.avgDay")}
{avgDaily.toFixed(1)}
-
median-adjacent
+
{t(locale, "adm.medianAdj")}
-
Best day
+
{t(locale, "adm.bestDay")}
{peakDay?.view_count ?? "—"}
-
{peakDay ? formatDay(peakDay.day) : "—"}
+
{peakDay ? formatDay(peakDay.day, tag) : "—"}
-
Best weekday
+
{t(locale, "adm.bestWeekday")}
{bestWeekday ?? "—"}
-
by total views
+
{t(locale, "adm.byViews")}
-

Regions

- {regionTotal.toLocaleString()} views · {regions.length} regions - +

{t(locale, "adm.regions")}

+ {tp(locale, "adm.regionsSub", { views: regionTotal.toLocaleString(), n: regions.length })} +
-

Most used mice

- {mouseTotal.toLocaleString()} connects · {mice.length} models - +

{t(locale, "adm.mice")}

+ {tp(locale, "adm.miceSub", { n: mouseTotal.toLocaleString(), m: mice.length })} +
-

Traffic by weekday

- Views per day, by week - +

{t(locale, "adm.traffic")}

+ {t(locale, "adm.trafficSub")} +
-

Session detail

- Derived from the current range +

{t(locale, "adm.session")}

+ {t(locale, "adm.sessionSub")}
-
Tracked since
{trackedSince}
-
Days tracked
{daily.length}
-
Quietest day
{quietDay ? `${quietDay.view_count} · ${formatDay(quietDay.day)}` : "—"}
-
7d trend
{weekTrend != null ? `${weekTrend >= 0 ? "+" : ""}${weekTrend.toFixed(1)}%` : "—"}
+
{t(locale, "adm.trackedSince")}
{trackedSince}
+
{t(locale, "adm.daysTracked")}
{daily.length}
+
{t(locale, "adm.quietest")}
{quietDay ? `${quietDay.view_count} · ${formatDay(quietDay.day, tag)}` : "—"}
+
{t(locale, "adm.trend7d")}
{weekTrend != null ? `${weekTrend >= 0 ? "+" : ""}${weekTrend.toFixed(1)}%` : "—"}
diff --git a/src/app/App.tsx b/src/app/App.tsx index 605c5693..c10c6f3e 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import * as control from "../device/controller"; import { WORKSPACE_TAB_ORDER, type ControlSnapshot, type WorkspaceTab } from "../device/types"; +import { t, connectLabelText, connectionText, ensureLocale, type I18nKey } from "../i18n"; import { interfaceThemeSlug } from "../interface-preferences"; import { CaptureDialog } from "./CaptureDialog"; import { Diagnostics, LogitechDetails } from "./Diagnostics"; @@ -50,6 +51,7 @@ function on(tab: WorkspaceTab, tabs: readonly WorkspaceTab[]): boolean { function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode { const status = snapshot.status; if (!status) return null; + const locale = snapshot.preferences.locale; const isWired = status.connectionType === "Wired"; const showBattery = !snapshot.traits.eggControls && (status.ui?.forceShowBattery || !isWired || status.batteryPercent !== null); @@ -69,37 +71,39 @@ function DeviceOverview({ snapshot }: { snapshot: ControlSnapshot }): ReactNode > {showBattery ? (
- BATTERY + {t(locale, "ov.battery")} {status.batteryPercent === null ? "—" : `${status.batteryPercent}%`} - {control.batteryDetail(status)} + {control.batteryDetail(status, locale)}
) : null}
- FIRMWARE + {t(locale, "ov.firmware")} {status.firmware[0] ?? "—"} {status.firmware.length > 1 ? status.firmware.slice(1).join(" · ") : status.firmware.length === 1 - ? "Firmware reported by mouse" - : "Not reported"} + ? t(locale, "ov.fwSingle") + : t(locale, "ov.fwNone")}
- CONNECTION - {status.connectionType ?? "Wireless"} + {t(locale, "ov.connection")} + {connectionText(locale, status.connectionType)} {status.connectionDetail - ?? (status.activeProfile ? `2.4 GHz · Profile ${status.activeProfile}` : "2.4 GHz receiver")} + ?? (status.activeProfile + ? `${t(locale, "ov.conn24")} · ${t(locale, "ov.profile")} ${status.activeProfile}` + : t(locale, "ov.connReceiver"))} {supportsDongleLed ? ( ) : null}
@@ -117,6 +121,7 @@ function Workspace({ const status = snapshot.status; const tab = snapshot.workspaceTab; if (!status) return null; + const locale = snapshot.preferences.locale; const has = cardAvailability(snapshot); const show = (available: boolean, tabs: readonly WorkspaceTab[]): boolean => available && on(tab, tabs); @@ -256,11 +261,10 @@ function Workspace({ ) : null} {on(tab, ["advanced"]) ? ( -
{nameHeader}{valueHeader}
- {row[labelKey] || "Unknown"} + {row[labelKey] || t(locale, "adm.unknown")}
{value.toLocaleString()}