feat(layout): surface offline status banner across authenticated routes

This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:32:02 +02:00
1 parent 2b5d0e9c68
commit e7a7e576a5
9 files changed
+204

No files matched your search

+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
@@ -0,0 +1,112 @@
/**
* v1.4.43 QoL (M5) — `<OfflineBanner>` 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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<OfflineBanner>", () => {
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(<OfflineBanner />);
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("<OfflineBanner />");
});
});
// 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("<OfflineBanner> 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"');
});
});
+8
View File
@@ -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 }) {
<div className="flex h-dvh flex-col md:flex-row">
<SidebarNav />
<div className="flex min-h-0 flex-1 flex-col">
{/*
v1.4.43 QoL (M5) — `<OfflineBanner>` 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.
*/}
<OfflineBanner />
<MaintainershipBanner />
<TopBar />
<main
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useEffect, useState } from "react";
import { WifiOff } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.43 QoL (M5) — slim banner that surfaces "you're offline" while
* `navigator.onLine` reads false. The PWA service worker already
* serves the read paths from cache (`public/sw.js`), but pre-fix a
* user toggling airplane mode mid-form-fill saw blank tiles and no
* explanation. This banner closes the explanation gap.
*
* Mount strategy: rendered once at the top of `<AuthShell>` 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 (
<div
role="status"
aria-live="polite"
data-slot="offline-banner"
className="bg-warning/15 border-warning/40 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
>
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">{t("offlineBanner.message")}</span>
</div>
);
}