diff --git a/messages/de.json b/messages/de.json index 2031ee733..f6a9eb483 100644 --- a/messages/de.json +++ b/messages/de.json @@ -157,6 +157,9 @@ "title": "Diese Seite existiert nicht.", "backToDashboard": "Zurück zum Dashboard" }, + "offlineBanner": { + "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist." + }, "nav": { "dashboard": "Dashboard", "measurements": "Messungen", diff --git a/messages/en.json b/messages/en.json index dc1634301..a9b81cf0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -157,6 +157,9 @@ "title": "This page doesn't exist.", "backToDashboard": "Back to dashboard" }, + "offlineBanner": { + "message": "No connection — your changes will save once you're back online." + }, "nav": { "dashboard": "Dashboard", "measurements": "Measurements", diff --git a/messages/es.json b/messages/es.json index 2acb90ec9..60da0a29f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -157,6 +157,9 @@ "title": "Esta página no existe.", "backToDashboard": "Volver al panel" }, + "offlineBanner": { + "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea." + }, "nav": { "dashboard": "Panel", "measurements": "Mediciones", diff --git a/messages/fr.json b/messages/fr.json index ce1d8d908..5b3163a6b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -157,6 +157,9 @@ "title": "Cette page n'existe pas.", "backToDashboard": "Retour au tableau de bord" }, + "offlineBanner": { + "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne." + }, "nav": { "dashboard": "Tableau de bord", "measurements": "Mesures", diff --git a/messages/it.json b/messages/it.json index 27d5157d3..35bd2e174 100644 --- a/messages/it.json +++ b/messages/it.json @@ -157,6 +157,9 @@ "title": "Questa pagina non esiste.", "backToDashboard": "Torna alla dashboard" }, + "offlineBanner": { + "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online." + }, "nav": { "dashboard": "Dashboard", "measurements": "Misurazioni", diff --git a/messages/pl.json b/messages/pl.json index 5309c304f..9cb384b95 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -157,6 +157,9 @@ "title": "Ta strona nie istnieje.", "backToDashboard": "Powrót do panelu" }, + "offlineBanner": { + "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online." + }, "nav": { "dashboard": "Panel", "measurements": "Pomiary", diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx new file mode 100644 index 000000000..78a1ccdae --- /dev/null +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -0,0 +1,112 @@ +/** + * v1.4.43 QoL (M5) — `` SSR contract. + * + * The banner reads `navigator.onLine` + subscribes to `online`/`offline` + * window events inside a `useEffect`. SSR markup is therefore always + * empty (hidden); the effect flips the state once the component + * mounts client-side. Pin both halves of that contract here so a + * future refactor that paints during SSR (and creates a hydration + * mismatch) gets caught. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { OfflineBanner } from "../offline-banner"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders nothing during SSR — hydration starts hidden", () => { + // The component initialises `isOnline === true`, so its first + // render (the SSR pass) returns `null`. The client-side effect + // then reads `navigator.onLine` and flips state if needed. + const html = render(); + expect(html).toBe(""); + }); + + it("ships the EN copy for `offlineBanner.message`", () => { + // The copy itself is module-private (only painted client-side), + // so we verify the message exists in the i18n bundle. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const en = require("../../../../messages/en.json") as { + offlineBanner: { message: string }; + }; + expect(en.offlineBanner.message).toBe( + "No connection — your changes will save once you're back online.", + ); + }); + + it("ships the DE copy for `offlineBanner.message`", () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const de = require("../../../../messages/de.json") as { + offlineBanner: { message: string }; + }; + expect(de.offlineBanner.message).toBe( + "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + ); + }); + + it("ships the message key in every supported locale", () => { + for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const m = require(`../../../../messages/${locale}.json`) as { + offlineBanner?: { message?: string }; + }; + expect(m.offlineBanner?.message?.length, locale).toBeGreaterThan(0); + } + }); + + it("mounts in auth-shell.tsx above the maintainership banner", () => { + // Pin the wiring at the file level so a future refactor that + // removes the import / mount accidentally is caught. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require("node:fs") as typeof import("node:fs"); + const path = require("node:path") as typeof import("node:path"); + const shellPath = path.join( + __dirname, + "..", + "auth-shell.tsx", + ); + const source = fs.readFileSync(shellPath, "utf8"); + expect(source).toContain('from "./offline-banner"'); + expect(source).toContain(""); + }); +}); + +// v1.4.43 QoL (M5) — also pin the client-side branch: when +// `isOnline === false` the banner paints with the i18n message, +// the WifiOff icon, and the `role="status"` aria-live region. We +// can't easily fire window events from a node-environment vitest +// suite, but we can compile the component source and assert the +// branch carries the expected affordances. +describe(" rendered branch (source-level)", () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const fs = require("node:fs") as typeof import("node:fs"); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const path = require("node:path") as typeof import("node:path"); + const source = fs.readFileSync( + path.join(__dirname, "..", "offline-banner.tsx"), + "utf8", + ); + + it("hosts an aria-live polite status region (assistive-tech friendly)", () => { + expect(source).toContain('role="status"'); + expect(source).toContain('aria-live="polite"'); + }); + + it("paints the WifiOff icon (visual signal for sighted users)", () => { + expect(source).toContain("WifiOff"); + }); + + it("subscribes + unsubscribes to both online and offline window events", () => { + expect(source).toContain('window.addEventListener("online"'); + expect(source).toContain('window.addEventListener("offline"'); + expect(source).toContain('window.removeEventListener("online"'); + expect(source).toContain('window.removeEventListener("offline"'); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 9732e7a7e..fb63cfbf0 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -10,6 +10,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; +import { OfflineBanner } from "./offline-banner"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; @@ -160,6 +161,13 @@ export function AuthShell({ children }: { children: React.ReactNode }) {
+ {/* + v1.4.43 QoL (M5) — `` paints only when + `navigator.onLine === false`. Sits above the maintainership + banner + top bar so the connection-status hint is always + the first chrome line a user sees in the offline branch. + */} +
` so every + * authenticated route inherits it. The component is mount-time SSR- + * safe — it never paints during the initial server render (the + * `online`/`offline` window events only fire client-side); a `useEffect` + * subscribes to the events once and toggles the visible state from + * there. Honours `prefers-reduced-motion` via the absence of any + * animation. + * + * Copy lives in `messages/*.json` under `offlineBanner.message`; all + * six locales ship today. + */ +export function OfflineBanner() { + const { t } = useTranslations(); + // Initialise to `true` so the banner stays hidden during SSR + the + // first client render. The effect below flips it true/false after + // mount based on `navigator.onLine`. (We do NOT read navigator on + // the very first render to keep the SSR markup hydration-stable.) + const [isOnline, setIsOnline] = useState(true); + + useEffect(() => { + function handleOnline() { + setIsOnline(true); + } + function handleOffline() { + setIsOnline(false); + } + // Sync once on mount in case we hydrated already-offline. + if (typeof navigator !== "undefined" && navigator.onLine === false) { + setIsOnline(false); + } + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, []); + + if (isOnline) return null; + + return ( +
+
+ ); +}