mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains.
160 lines
5.4 KiB
TypeScript
160 lines
5.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect } from "react";
|
|
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
|
|
/**
|
|
* v1.4.38.4 — runtime self-heal for the stale-shell post-deploy
|
|
* paper-cut.
|
|
*
|
|
* After a release the cached SPA shell still references the old
|
|
* chunk filenames; the running React tree lazy-loads them, the new
|
|
* server 404s them, and the user lands on `ChunkLoadError`. The
|
|
* v1.4.38.3 `AppError` boundary added a one-shot
|
|
* `window.location.reload()` for that case, but it only fires AFTER
|
|
* the user trips the error — typically in the middle of a
|
|
* navigation flow.
|
|
*
|
|
* This component runs the check pro-actively. Every 60 s it fetches
|
|
* `/api/version` and compares the live version string against
|
|
* `NEXT_PUBLIC_APP_VERSION` (injected from `package.json` at build
|
|
* time by `next.config.ts`). When the live version moves ahead of
|
|
* the running shell:
|
|
*
|
|
* 1. Unregister every active service worker so the next page load
|
|
* doesn't reinstall the old SW.
|
|
* 2. Delete every CacheStorage entry so the precached root HTML
|
|
* and the `/_next/static/*` chunks from the previous deploy
|
|
* can't be served back.
|
|
* 3. `window.location.reload()` — fetches the fresh shell + new
|
|
* chunk graph.
|
|
*
|
|
* The reload guard is keyed on the TARGET version: `sessionStorage`
|
|
* records which live version we last reloaded for, and only a repeat
|
|
* mismatch against that SAME version is suppressed (a misset version —
|
|
* server briefly serves a stale image mid-deploy — cannot loop).
|
|
* Polling itself never stops, so on a multi-deploy day a SECOND
|
|
* release moves the live version past the recorded one and the heal
|
|
* flow re-arms. The pre-v1.16.8 guard disabled all polling for the
|
|
* rest of the session after one attempt, which left a stale shell
|
|
* with 404ing chunks stuck forever once the guard was spent.
|
|
*/
|
|
|
|
const POLL_INTERVAL_MS = 60_000;
|
|
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
|
|
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
|
|
|
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
|
|
|
|
/**
|
|
* Pure reload decision so the guard semantics carry direct unit
|
|
* coverage. `lastReloadedFor` is the live version recorded by the most
|
|
* recent heal attempt in this session (`null` when none happened);
|
|
* legacy guard values (the pre-v1.16.8 timestamp) never match a real
|
|
* version string, so a stuck session from before the change heals on
|
|
* its next mismatch.
|
|
*/
|
|
export function resolveVersionPollDecision(
|
|
liveVersion: string | null,
|
|
shellVersion: string,
|
|
lastReloadedFor: string | null,
|
|
): VersionPollDecision {
|
|
if (!liveVersion || liveVersion === shellVersion) return "up-to-date";
|
|
if (lastReloadedFor === liveVersion) return "already-attempted";
|
|
return "reload";
|
|
}
|
|
|
|
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
|
try {
|
|
const data = await apiGet<{ version?: string } | undefined>(
|
|
"/api/version",
|
|
{ cache: "no-store", signal },
|
|
);
|
|
return data?.version ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// In-memory mirror of the session guard: under strict-privacy modes
|
|
// sessionStorage throws, and without a fallback a persistent
|
|
// shell/live mismatch (misconfigured build) would reload every poll.
|
|
// Module scope survives re-renders but not the reload itself — after
|
|
// a reload the storage read is retried first, so the mirror only has
|
|
// to break the loop within one document lifetime.
|
|
let inMemoryReloadGuard: string | null = null;
|
|
|
|
async function evictAndReload(targetVersion: string): Promise<void> {
|
|
inMemoryReloadGuard = targetVersion;
|
|
try {
|
|
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
|
} catch {
|
|
// sessionStorage can throw under strict-privacy modes; the
|
|
// in-memory mirror above still breaks same-document loops.
|
|
}
|
|
|
|
if ("serviceWorker" in navigator) {
|
|
try {
|
|
const regs = await navigator.serviceWorker.getRegistrations();
|
|
await Promise.all(regs.map((r) => r.unregister()));
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
if (typeof caches !== "undefined") {
|
|
try {
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.map((k) => caches.delete(k)));
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
window.location.reload();
|
|
}
|
|
|
|
export function VersionPoller(): null {
|
|
useEffect(() => {
|
|
if (!SHELL_VERSION) return;
|
|
if (typeof window === "undefined") return;
|
|
|
|
const controller = new AbortController();
|
|
|
|
async function checkOnce(): Promise<void> {
|
|
const live = await fetchLiveVersion(controller.signal);
|
|
let lastReloadedFor: string | null = null;
|
|
try {
|
|
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
|
|
} catch {
|
|
/* storage unavailable — the in-memory mirror still applies */
|
|
}
|
|
lastReloadedFor ??= inMemoryReloadGuard;
|
|
const decision = resolveVersionPollDecision(
|
|
live,
|
|
SHELL_VERSION,
|
|
lastReloadedFor,
|
|
);
|
|
if (decision !== "reload") return;
|
|
await evictAndReload(live as string);
|
|
}
|
|
|
|
// First check 5 s after mount — gives the app a chance to settle
|
|
// before we touch caches. Then on the recurring interval.
|
|
const initial = window.setTimeout(() => void checkOnce(), 5_000);
|
|
const interval = window.setInterval(
|
|
() => void checkOnce(),
|
|
POLL_INTERVAL_MS,
|
|
);
|
|
|
|
return () => {
|
|
window.clearTimeout(initial);
|
|
window.clearInterval(interval);
|
|
controller.abort();
|
|
};
|
|
}, []);
|
|
|
|
return null;
|
|
}
|