src/features/reviews/provider.tsx
Linked repository file from this build.
"use client";
import { useSession } from "@/lib/auth/client";
import {
createContext,
useContext,
useEffect,
useCallback,
useMemo,
useTransition,
useRef,
useState,
type ReactNode,
type Dispatch,
type SetStateAction,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { createDocumentStorage, type DocumentStorage } from "@/lib/documents/storage";
import { createImportTemplate } from "@/programs/snap-negative/review-template";
import { reviewKey } from "./review-key";
import type { SnapReview } from "@/programs/snap-negative/types";
import { useReviewPreferences } from "./preferences";
import type { DraftMap } from "./computation-grid.helpers";
import { captureReceivedSnapshot } from "./commands";
import { CommandDraft } from "./command-draft";
import { commandSchema } from "./command-schema";
import { execute, type ReviewCommand } from "./commands";
import type { Actor, DemoRole } from "./types";
import type { PendingCommand } from "./persistence";
import { rememberPath } from "./return-path";
import { classifySaveFailure } from "./save-failure";
import type { SaveStatus } from "./save-status-label";
import type { StoredReview } from "./review-store";
interface ReviewContextValue {
isSignedIn: boolean;
sessionVersion: number;
reviewReady: boolean;
review: SnapReview;
actor: Actor;
setRole(role: DemoRole): void;
perform(command: ReviewCommand, resetComputationDrafts?: boolean): boolean;
computationDrafts: DraftMap;
setComputationDrafts: Dispatch<SetStateAction<DraftMap>>;
registerUndo(undo: () => boolean): void;
undo(): boolean;
error: string;
reportError(message: string): void;
dismissError(): void;
storage: DocumentStorage;
flush(): Promise<boolean>;
navigate(href: string, prepare?: () => Promise<string | void>): Promise<boolean>;
navigationTarget: string | null;
saveStatus: SaveStatus;
lastSavedAt: string | null;
retrySave(): void;
}
const ReviewContext = createContext<ReviewContextValue | null>(null);
type NavigationContextValue = Pick<ReviewContextValue, "navigate" | "navigationTarget" | "flush">;
const NavigationContext = createContext<NavigationContextValue | null>(null);
export function ReviewProvider({
children,
initial,
loadReview = true,
}: {
children: ReactNode;
initial?: StoredReview;
loadReview?: boolean;
}) {
const pathname = usePathname();
const router = useRouter();
const [routePending, startRouteTransition] = useTransition();
const [navigation, setNavigation] = useState<{
href: string;
stage: "saving" | "routing";
} | null>(null);
const navigationId = useRef(0);
const navigationTarget =
navigation?.stage === "saving" || routePending ? (navigation?.href ?? null) : null;
const routeKey = decodeURIComponent(pathname.match(/^\/reviews\/([^/]+)\/?$/)?.[1] ?? "");
const needsReview = loadReview && Boolean(routeKey);
const [review, setReview] = useState(() => initial?.review ?? createImportTemplate());
const [sessionVersion, setSessionVersion] = useState(0);
const session = useSession();
const { actor, isSignedIn } = session;
const currentActor = useRef(actor);
currentActor.current = actor;
const [error, setError] = useState("");
const dismissError = useCallback(() => setError(""), []);
const [ready, setReady] = useState(Boolean(initial));
const [saveStatus, setSaveStatus] = useState<SaveStatus>(initial ? "saved" : "loading");
const [lastSavedAt, setLastSavedAt] = useState<string | null>(initial?.savedAt ?? null);
const [storage] = useState(createDocumentStorage);
const current = useRef(review);
const { autosave } = useReviewPreferences();
const autosaveRef = useRef(autosave);
autosaveRef.current = autosave;
const undoStack = useRef<Array<() => boolean>>([]);
const undoing = useRef(false);
const [computationDrafts, setDrafts] = useState<DraftMap>({});
const computationDraftsRef = useRef(computationDrafts);
const registerUndo = useCallback((undo: () => boolean) => {
if (undoing.current) return;
undoStack.current.push(undo);
if (undoStack.current.length > 30) undoStack.current.shift();
}, []);
const setComputationDrafts = useCallback<Dispatch<SetStateAction<DraftMap>>>(
(update) => {
const previous = computationDraftsRef.current;
const next = typeof update === "function" ? update(previous) : update;
if (next === previous) return;
const restoreDrafts = { ...previous };
for (const id of ["eligibility", "compI", "compII"] as const) {
if (next[id] !== previous[id])
restoreDrafts[id] = previous[id] ?? current.current.facts.scenarios[id];
}
registerUndo(() => {
computationDraftsRef.current = restoreDrafts;
setDrafts(restoreDrafts);
return true;
});
computationDraftsRef.current = next;
setDrafts(next);
},
[registerUndo],
);
const undo = useCallback(() => {
const restore = undoStack.current.at(-1);
if (!restore) return false;
undoing.current = true;
try {
if (!restore()) return false;
undoStack.current.pop();
return true;
} finally {
undoing.current = false;
}
}, []);
const loadedStaffId = useRef<string | undefined>(initial ? "initial" : undefined);
const version = useRef(initial?.version ?? 0);
const pending = useRef<PendingCommand[]>([]);
const draft = useRef(new CommandDraft());
function commitDraft() {
const entry = draft.current.take();
if (entry) pending.current.push(entry);
}
const inFlight = useRef<Promise<boolean> | null>(null);
const retry = useRef<{
version: number;
requestId: string;
commands: PendingCommand[];
} | null>(null);
const editTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const readyRef = useRef(Boolean(initial));
const navigating = useRef(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const idleSave = useRef({
armEdit() {
if (editTimer.current) clearTimeout(editTimer.current);
if (!autosaveRef.current) return;
editTimer.current = setTimeout(() => void flushRef.current(false), 60_000);
},
disarmEdit() {
if (editTimer.current) clearTimeout(editTimer.current);
editTimer.current = null;
},
});
function install(stored: StoredReview) {
current.current = stored.review;
version.current = stored.version;
setReview(stored.review);
setLastSavedAt(stored.savedAt);
}
/** Drop all local edits and show the saved server copy with the reason. */
function resetToServer(stored: StoredReview, message: string, status: SaveStatus) {
undoStack.current = [];
computationDraftsRef.current = {};
setDrafts({});
draft.current.clear();
pending.current = [];
retry.current = null;
install(stored);
setSessionVersion((v) => v + 1);
setError(message);
setSaveStatus(status);
idleSave.current.disarmEdit();
}
async function flush(drain = true): Promise<boolean> {
commitDraft();
if (inFlight.current) {
const ok = await inFlight.current;
return ok && drain ? flush() : ok;
}
if (!pending.current.length && !retry.current) return true;
if (!readyRef.current) return false;
const body = retry.current ?? {
version: version.current,
requestId: crypto.randomUUID(),
commands: pending.current.splice(0),
};
retry.current = body;
setSaveStatus("saving");
// The server adds the actor and time, so only IDs and commands go over the wire.
const payload = JSON.stringify({
...body,
commands: body.commands.map(({ id, command }) => ({ id, command })),
});
const work = (async () => {
try {
const response = await fetch(`/api/reviews/${reviewKey(current.current)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload,
keepalive: payload.length < 60000,
});
const data = await response.json().catch(() => ({}));
const failure = response.ok ? null : classifySaveFailure(response.status);
if (failure === "conflict") {
resetToServer(data.current, data.error, "conflict");
return false;
}
if (failure === "rejected") {
// Later edits were built on the refused batch, so they are dropped with it.
const message = data.error || "The server refused these changes.";
const reload = await fetch(`/api/reviews/${reviewKey(current.current)}`, {
cache: "no-store",
});
if (!reload.ok) throw new Error(message);
resetToServer(await reload.json(), message, "refused");
return false;
}
if (!response.ok) throw new Error(data.error || "Unable to save review.");
version.current = data.version;
retry.current = null;
if (!pending.current.length && !draft.current.dirty) {
install(data);
setSaveStatus("saved");
idleSave.current.disarmEdit();
} else {
setSaveStatus("unsaved");
idleSave.current.armEdit();
}
setError("");
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to save review.");
setSaveStatus("save_failed");
return false;
}
})();
inFlight.current = work;
const ok = await work;
inFlight.current = null;
return ok && drain && (pending.current.length || draft.current.dirty) ? flush() : ok;
}
const flushRef = useRef(flush);
useEffect(() => {
flushRef.current = flush;
});
useEffect(() => {
rememberPath(window.location.pathname + window.location.search);
}, [pathname]);
useEffect(() => {
let canceled = false;
if (!session.ready) return;
if (!needsReview) {
navigating.current = false;
void flushRef.current();
return;
}
const target = routeKey;
if (
readyRef.current &&
target === reviewKey(current.current) &&
(loadedStaffId.current === actor.staffId || loadedStaffId.current === "initial")
) {
loadedStaffId.current = actor.staffId;
navigating.current = false;
setReady(true);
return;
}
navigating.current = true;
void (async () => {
setReady(false);
if (readyRef.current && !(await flushRef.current())) return;
if (canceled) return;
readyRef.current = false;
try {
const response = await fetch(`/api/reviews/${target}`, { cache: "no-store" });
const data = await response.json();
if (!response.ok) throw new Error(data.error);
if (!canceled) {
undoStack.current = [];
computationDraftsRef.current = {};
setDrafts({});
install(data);
loadedStaffId.current = actor.staffId;
readyRef.current = true;
navigating.current = false;
setReady(true);
setSaveStatus("saved");
idleSave.current.disarmEdit();
setSessionVersion((v) => v + 1);
}
} catch (cause) {
if (!canceled) {
setError(cause instanceof Error ? cause.message : "Unable to load review.");
setSaveStatus("load_failed");
}
}
})();
return () => {
canceled = true;
};
}, [routeKey, loadAttempt, needsReview, session.ready, actor.staffId]);
useEffect(() => {
const leaving = () => {
void flushRef.current();
};
const hidden = () => {
if (document.visibilityState === "hidden") leaving();
};
const beforeUnload = (event: BeforeUnloadEvent) => {
if (draft.current.dirty || pending.current.length || retry.current || inFlight.current) {
leaving();
event.preventDefault();
}
};
window.addEventListener("pagehide", leaving);
document.addEventListener("visibilitychange", hidden);
window.addEventListener("beforeunload", beforeUnload);
return () => {
if (editTimer.current) clearTimeout(editTimer.current);
window.removeEventListener("pagehide", leaving);
document.removeEventListener("visibilitychange", hidden);
window.removeEventListener("beforeunload", beforeUnload);
};
}, []);
function perform(command: ReviewCommand, resetComputationDrafts = false) {
try {
if (!readyRef.current || navigating.current)
throw new Error("Wait for the saved review to load.");
const entry = {
command,
actor: currentActor.current,
id: crypto.randomUUID(),
at: new Date().toISOString(),
};
commandSchema.parse(command);
if (!CommandDraft.key(command) || !draft.current.accepts(entry)) commitDraft();
const previous = current.current;
const next = CommandDraft.key(command)
? draft.current.stage(current.current, entry)
: execute(current.current, command, entry.actor, { id: entry.id, at: entry.at });
if (
[
"identity",
"household",
"negative_action",
"answers",
"outcome",
"drop_kind",
"issued_benefit",
"apply_final",
"clear_roster",
"restore_facts",
].includes(command.type)
) {
const snapshot = captureReceivedSnapshot(previous);
const previousDrafts = computationDraftsRef.current;
registerUndo(() => {
if (!performRef.current({ type: "restore_facts", ...snapshot })) return false;
computationDraftsRef.current = previousDrafts;
setDrafts(previousDrafts);
return true;
});
}
if (resetComputationDrafts || command.type === "clear_roster") {
computationDraftsRef.current = {};
setDrafts({});
}
current.current = next;
setReview(next);
if (!CommandDraft.key(command)) pending.current.push(entry);
setSaveStatus("unsaved");
idleSave.current.armEdit();
return true;
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Unable to update review.");
return false;
}
}
const setSessionRole = session.setRole;
const changeRole = useCallback(
(value: DemoRole) => {
if (isSignedIn) return;
void flushRef.current().then((ok) => {
if (ok) setSessionRole(value);
});
},
[isSignedIn, setSessionRole],
);
const retrySave = useCallback(() => {
void flushRef.current().then(() => setLoadAttempt((v) => v + 1));
}, []);
const performRef = useRef(perform);
useEffect(() => {
performRef.current = perform;
});
useEffect(() => {
if (!autosave) idleSave.current.disarmEdit();
else if (draft.current.dirty || pending.current.length) idleSave.current.armEdit();
}, [autosave]);
useEffect(() => {
if (!needsReview) return;
const keydown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLElement && event.target.closest('[role="dialog"]')) return;
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && event.key.toLowerCase() === "z") {
if (undo()) event.preventDefault();
}
};
window.addEventListener("keydown", keydown);
return () => window.removeEventListener("keydown", keydown);
}, [needsReview, undo]);
const performStable = useCallback(
(command: ReviewCommand, resetComputationDrafts = false) =>
performRef.current(command, resetComputationDrafts),
[],
);
const flushStable = useCallback(() => flushRef.current(), []);
const navigate = useCallback(
async (href: string, prepare?: () => Promise<string | void>) => {
const target = new URL(href, window.location.href);
if (target.origin !== window.location.origin) return false;
if (target.href === window.location.href && !prepare) return true;
const id = ++navigationId.current;
setNavigation({ href, stage: "saving" });
// Keep the editor mounted behind its skeleton until saves succeed; failures restore it.
if (!(await flushRef.current())) {
if (id === navigationId.current) setNavigation(null);
return false;
}
if (id !== navigationId.current) return false;
let destination = href;
try {
destination = (await prepare?.()) || href;
} catch (cause) {
if (id === navigationId.current) setNavigation(null);
throw cause;
}
if (id !== navigationId.current) return false;
setNavigation({ href: destination, stage: "routing" });
rememberPath(window.location.pathname + window.location.search);
startRouteTransition(() => router.push(destination));
return true;
},
[router],
);
const navigationValue = useMemo(
() => ({ navigate, navigationTarget, flush: flushStable }),
[navigate, navigationTarget, flushStable],
);
const reviewReady = needsReview && session.ready && ready && routeKey === reviewKey(review);
const reviewValue = useMemo(
() => ({
sessionVersion,
reviewReady,
review,
actor,
isSignedIn,
setRole: changeRole,
perform: performStable,
computationDrafts,
setComputationDrafts,
registerUndo,
undo,
error,
reportError: setError,
dismissError,
storage,
flush: flushStable,
navigate,
navigationTarget,
saveStatus,
lastSavedAt,
retrySave,
}),
[
sessionVersion,
review,
actor,
isSignedIn,
changeRole,
performStable,
computationDrafts,
setComputationDrafts,
registerUndo,
undo,
error,
setError,
dismissError,
storage,
flushStable,
navigate,
navigationTarget,
saveStatus,
lastSavedAt,
retrySave,
reviewReady,
],
);
return (
<NavigationContext.Provider value={navigationValue}>
<ReviewContext.Provider value={reviewValue}>{children}</ReviewContext.Provider>
</NavigationContext.Provider>
);
}
export function useReview() {
const context = useContext(ReviewContext);
if (!context) throw new Error("ReviewProvider is required.");
return context;
}
export function useReviewNavigation() {
const context = useContext(NavigationContext);
if (!context) throw new Error("ReviewProvider is required.");
return context;
}