mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(layout): surface offline status banner across authenticated routes
This commit is contained in:
1 parent
2b5d0e9c68
commit
e7a7e576a5
9 files changed
+204
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user