mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Switching is three acts in a fixed order, and the order is the safety argument. Ask the server, so a refused switch leaves the browser where it was. Empty the caches a reload cannot reach, awaited rather than fired, because a wipe racing the navigation is a wipe that sometimes does not happen. Then reload the document, which is what discards the in-memory cache, the rendered React trees, and every request already in flight against the old record. The banner is the feature, not decoration. Someone who forgets they are switched will log their own blood pressure into another person's record, and there is no undo for a reading that was never theirs. So it states whose record is open, that it is read-only, and carries the way out — in the one visual register reserved for "something about this screen is not normal", at the top of the shell above every other strip. A subtle tint is what people stop seeing on the third switch. The switcher lives in one component mounted in both user menus. A way in on the laptop and no way out on the phone is how the two menus have drifted before. Nav entries that sharing does not cover drop out, and a page reached by bookmark says so instead of painting a row of refusals. Both are paint: the server refuses those routes on its own, from a frozen list this code cannot reach. Settings, connected services, notifications and the AI surfaces are the account around a record rather than the record itself. A grant can end while a browser is inside the record. Revocation clears the delegate's sessions in the same transaction; expiry does not, and leaves a session stamped with an account it may no longer act on — correct refusals under a screen that lies. One cache subscriber notices the stable errorCode and leaves, rather than asking two hundred call sites to remember.
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
QueryClient,
|
|
QueryClientProvider,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import { usePathname } from "next/navigation";
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
useSyncExternalStore,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
|
|
import type { Locale } from "@/lib/i18n/config";
|
|
import { toast } from "sonner";
|
|
import { Toaster } from "@/components/ui/sonner";
|
|
import { VersionPoller } from "@/components/version-poller";
|
|
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
|
|
import { SharedRecordGrantLossBridge } from "@/components/layout/shared-record-grant-loss-bridge";
|
|
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
|
|
import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
|
import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications";
|
|
import {
|
|
restorePersistedQueryCache,
|
|
startPersistingQueryCache,
|
|
} from "@/lib/pwa/query-persister";
|
|
import {
|
|
QUERY_CLIENT_DEFAULT_OPTIONS,
|
|
subscribeToMeaningfulVisibilityRefresh,
|
|
} from "@/lib/pwa/query-client-options";
|
|
|
|
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
|
|
|
// ── Theme Context ────────────────────────────────────
|
|
|
|
type Theme = "dark" | "light" | "system";
|
|
|
|
interface ThemeContextValue {
|
|
theme: Theme;
|
|
resolvedTheme: "dark" | "light";
|
|
setTheme: (theme: Theme) => void;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue>({
|
|
theme: "system",
|
|
resolvedTheme: "dark",
|
|
setTheme: () => {},
|
|
});
|
|
|
|
export function useTheme() {
|
|
return useContext(ThemeContext);
|
|
}
|
|
|
|
function getSystemTheme(): "dark" | "light" {
|
|
if (typeof window === "undefined") return "dark";
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
? "dark"
|
|
: "light";
|
|
}
|
|
|
|
function applyTheme(resolved: "dark" | "light") {
|
|
document.documentElement.classList.remove("dark", "light");
|
|
document.documentElement.classList.add(resolved);
|
|
}
|
|
|
|
// ── Theme as an external store ───────────────────────
|
|
//
|
|
// The stored preference lives in localStorage, and the OS preference in
|
|
// matchMedia — both browser-only values that must NOT be read in a `useState`
|
|
// initializer (server renders "dark", a client with a stored "light"/"system"
|
|
// initializes differently → the React #418 hydration seam this repo already
|
|
// hit with the sidebar). `useSyncExternalStore` is the sanctioned fix: it reads
|
|
// the value with an SSR-stable server snapshot ("dark") and reconciles to the
|
|
// real value after hydration without a setState-in-effect. The nonce-bound
|
|
// inline script in layout.tsx stamps the correct class pre-paint, so there is
|
|
// no visual FOUC while React reconciles.
|
|
|
|
const THEME_STORAGE_KEY = "healthlog-theme";
|
|
const themeListeners = new Set<() => void>();
|
|
|
|
function notifyThemeListeners() {
|
|
for (const cb of themeListeners) cb();
|
|
}
|
|
|
|
function subscribeStoredTheme(onChange: () => void): () => void {
|
|
themeListeners.add(onChange);
|
|
const onStorage = (e: StorageEvent) => {
|
|
if (e.key === THEME_STORAGE_KEY) onChange();
|
|
};
|
|
window.addEventListener("storage", onStorage);
|
|
return () => {
|
|
themeListeners.delete(onChange);
|
|
window.removeEventListener("storage", onStorage);
|
|
};
|
|
}
|
|
|
|
function getStoredThemeSnapshot(): Theme {
|
|
const saved = localStorage.getItem(THEME_STORAGE_KEY) as Theme | null;
|
|
// A stored "light"/"dark"/"system" choice wins. With no stored preference the
|
|
// app defaults to dark rather than tracking the OS.
|
|
if (saved === "light" || saved === "dark" || saved === "system") return saved;
|
|
return "dark";
|
|
}
|
|
|
|
function getServerThemeSnapshot(): Theme {
|
|
return "dark";
|
|
}
|
|
|
|
function subscribeSystemTheme(onChange: () => void): () => void {
|
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
mq.addEventListener("change", onChange);
|
|
return () => mq.removeEventListener("change", onChange);
|
|
}
|
|
|
|
function getSystemThemeSnapshot(): "dark" | "light" {
|
|
return getSystemTheme();
|
|
}
|
|
|
|
function getServerSystemSnapshot(): "dark" | "light" {
|
|
return "dark";
|
|
}
|
|
|
|
function ThemeProvider({ children }: { children: ReactNode }) {
|
|
const theme = useSyncExternalStore(
|
|
subscribeStoredTheme,
|
|
getStoredThemeSnapshot,
|
|
getServerThemeSnapshot,
|
|
);
|
|
const systemTheme = useSyncExternalStore(
|
|
subscribeSystemTheme,
|
|
getSystemThemeSnapshot,
|
|
getServerSystemSnapshot,
|
|
);
|
|
const resolvedTheme = theme === "system" ? systemTheme : theme;
|
|
|
|
// Keep the <html> class in sync with the resolved theme. This is a DOM write,
|
|
// not a setState — it runs on mount (reconciling the class the inline script
|
|
// set to any stored preference) and whenever the resolved theme changes.
|
|
useEffect(() => {
|
|
applyTheme(resolvedTheme);
|
|
}, [resolvedTheme]);
|
|
|
|
const setTheme = useCallback((next: Theme) => {
|
|
// Persist every explicit choice, including "system" — the absence of a
|
|
// stored value is reserved for a fresh visitor and defaults to dark.
|
|
// Storing "system" verbatim keeps an explicit OS-tracking choice
|
|
// distinguishable from "never chose", so a reload honours it. The write +
|
|
// notify re-reads the external store; the effect above applies the class.
|
|
localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
notifyThemeListeners();
|
|
}, []);
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ── Dashboard snapshot preloader ─────────────────────
|
|
//
|
|
// v1.16.6 first-load waterfall fix: fire the snapshot fetch the moment
|
|
// the router commits to "/" instead of waiting for the dashboard page
|
|
// chunk to download + mount (~450 ms later on a 4G / 4x-CPU profile).
|
|
// The proxy has already enforced auth + onboarding for "/" before any
|
|
// client code runs here, so the prefetch cannot fire for an
|
|
// unauthenticated visitor; a racing edge case just yields a swallowed
|
|
// 401 prefetch and the mounted cell re-resolves normally.
|
|
function DashboardSnapshotPreloader() {
|
|
const queryClient = useQueryClient();
|
|
const pathname = usePathname();
|
|
useEffect(() => {
|
|
if (pathname === "/" && isDashboardSnapshotEnabled()) {
|
|
prefetchDashboardSnapshot(queryClient);
|
|
}
|
|
// v1.16.7 — same waterfall cut for the medications page: its list
|
|
// query (which carries the per-medication `nextDueAt` the due cells
|
|
// render) used to fire only after the page chunk mounted. Firing it
|
|
// at route commit rides the data hop in parallel with the chunk
|
|
// download; the nav links additionally prefetch on hover/touch
|
|
// intent, so this is the fallback for direct loads + reloads.
|
|
if (pathname === "/medications") {
|
|
prefetchMedicationsList(queryClient);
|
|
}
|
|
}, [pathname, queryClient]);
|
|
return null;
|
|
}
|
|
|
|
// ── Meaningful PWA resume refresh ────────────────────
|
|
|
|
function QueryVisibilityRefreshBridge() {
|
|
const queryClient = useQueryClient();
|
|
useEffect(
|
|
() => subscribeToMeaningfulVisibilityRefresh(queryClient),
|
|
[queryClient],
|
|
);
|
|
return null;
|
|
}
|
|
|
|
// ── Offline query persistence ────────────────────────
|
|
//
|
|
// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the
|
|
// first authenticated paint, then debounce-persist successful reads back.
|
|
// Combined with the service worker's allowlisted stale-while-revalidate API
|
|
// branch, an installed PWA opened offline renders last-known data instead of
|
|
// empty skeletons. Build-version + age gated; cleared on logout.
|
|
function QueryPersistenceBridge() {
|
|
const queryClient = useQueryClient();
|
|
useEffect(() => {
|
|
let stop: (() => void) | undefined;
|
|
let cancelled = false;
|
|
void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => {
|
|
if (!cancelled) {
|
|
stop = startPersistingQueryCache(queryClient, SHELL_VERSION);
|
|
}
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
stop?.();
|
|
};
|
|
}, [queryClient]);
|
|
return null;
|
|
}
|
|
|
|
// ── Offline mutation backstop ────────────────────────
|
|
|
|
/**
|
|
* F-OFF-1 backstop. With mutations in `always` network mode an offline write
|
|
* rejects immediately (rather than pausing forever), so the individual form's
|
|
* `onError` fires and shows its own message. This subscriber is the safety net
|
|
* for any mutation WITHOUT a call-site error handler: it surfaces one honest,
|
|
* de-duplicated toast whenever a mutation errors while the browser is offline,
|
|
* so a write can never vanish silently. Gated on `navigator.onLine === false`
|
|
* so a normal server-side error (which the form already surfaces) is never
|
|
* double-toasted.
|
|
*/
|
|
function OfflineMutationToaster() {
|
|
const { t } = useTranslations();
|
|
const queryClient = useQueryClient();
|
|
useEffect(() => {
|
|
const cache = queryClient.getMutationCache();
|
|
const unsubscribe = cache.subscribe((event) => {
|
|
if (
|
|
event.type === "updated" &&
|
|
event.action.type === "error" &&
|
|
typeof navigator !== "undefined" &&
|
|
navigator.onLine === false
|
|
) {
|
|
toast.error(t("offlineBanner.saveFailed"), {
|
|
id: "offline-mutation-failed",
|
|
});
|
|
}
|
|
});
|
|
return unsubscribe;
|
|
}, [queryClient, t]);
|
|
return null;
|
|
}
|
|
|
|
// ── Root Providers ───────────────────────────────────
|
|
|
|
export function Providers({
|
|
children,
|
|
initialLocale,
|
|
}: {
|
|
children: ReactNode;
|
|
initialLocale?: Locale;
|
|
}) {
|
|
const [queryClient] = useState(
|
|
() => new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }),
|
|
);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider>
|
|
<I18nProvider initialLocale={initialLocale}>
|
|
<QueryPersistenceBridge />
|
|
<QueryVisibilityRefreshBridge />
|
|
<DashboardSnapshotPreloader />
|
|
<OfflineMutationToaster />
|
|
{/* v1.36.0 — a grant that ends while a browser is inside the record
|
|
leaves the session stamped and every read refused. One cache
|
|
subscriber notices the stable errorCode and puts the browser back
|
|
in its own account, rather than asking every call site to. */}
|
|
<SharedRecordGrantLossBridge />
|
|
{children}
|
|
<Toaster position="bottom-right" richColors />
|
|
<VersionPoller />
|
|
<ServiceWorkerRegistrar />
|
|
</I18nProvider>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|