diff --git a/apps/web/components/bracket/BracketBuilder.tsx b/apps/web/components/bracket/BracketBuilder.tsx
index 07844e3e..33566cd3 100644
--- a/apps/web/components/bracket/BracketBuilder.tsx
+++ b/apps/web/components/bracket/BracketBuilder.tsx
@@ -107,12 +107,13 @@ import { localUserId, loadDraft, saveDraft } from "@/lib/bracket/storage";
// click; durability is the 30s autosave + manual Save + localStorage
// fallback (the previous per-match save was already best-effort
// fire-and-forget for the same reasons). See BracketAutoSave.tsx.
-import { loadServerBracket, saveFullBracket } from "@/lib/bracket/api";
+import { GAME_API_BASE, loadServerBracket, saveFullBracket } from "@/lib/bracket/api";
import { mergeBrackets } from "@/lib/bracket/merge";
import { submitBracket } from "@/lib/bracket/submit";
import { useUser } from "@/lib/auth/useUser";
import { SignupModal } from "@/components/auth/SignupModal";
import { BracketAutoSave } from "./BracketAutoSave";
+import { CascadeWarnings, type BracketTabId as CascadeTab } from "./CascadeWarnings";
import { shareContent } from "@/lib/native";
import {
buildShareText,
@@ -1283,6 +1284,74 @@ export function BracketBuilder(props: BracketBuilderProps) {
return () => window.clearInterval(id);
}, [auth.status, currentSig, lastSavedSig, doAutoSave]);
+ // Tim 2026-06-05: best-effort save on page exit. Two trigger paths:
+ // 1. window.beforeunload, fires on tab close, reload, external
+ // link, or browser back/forward.
+ // 2. useEffect cleanup, fires on internal Next.js navigation
+ // (clicking Pools, Profile, the app drawer, etc.).
+ // Both call fetch with `keepalive: true` so the request survives
+ // the page tear-down. Body is ~5-10KB so we're well under the 64KB
+ // keepalive cap.
+ // Latest bracket / dirty / auth values come through refs so the
+ // listener (installed once via empty deps) always reads current
+ // state instead of the snapshot at install time.
+ const exitSaveBracketRef = useRef(bracket);
+ const exitSaveIsDirtyRef = useRef(isDirty);
+ const exitSaveAuthRef = useRef(auth.status);
+ const exitSaveUserIdRef = useRef(userLocalId);
+ const exitSaveTournamentIdRef = useRef(tournament.id);
+ useEffect(() => {
+ exitSaveBracketRef.current = bracket;
+ exitSaveIsDirtyRef.current = isDirty;
+ exitSaveAuthRef.current = auth.status;
+ exitSaveUserIdRef.current = userLocalId;
+ exitSaveTournamentIdRef.current = tournament.id;
+ });
+ useEffect(() => {
+ const flushOnExit = (): void => {
+ if (autoSaveInFlightRef.current) return;
+ if (exitSaveAuthRef.current !== "authenticated") return;
+ if (!exitSaveIsDirtyRef.current) return;
+ if (exitSaveUserIdRef.current === "ssr_user") return;
+ try {
+ const submission: Bracket = {
+ ...exitSaveBracketRef.current,
+ lockedAt: new Date().toISOString(),
+ };
+ const base = GAME_API_BASE.replace(/\/+$/, "");
+ // fire-and-forget: browser keeps the fetch alive past unload.
+ void fetch(`${base}/v1/bracket/submit`, {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ tournament_id: exitSaveTournamentIdRef.current,
+ user_id: exitSaveUserIdRef.current,
+ bracket: submission,
+ }),
+ keepalive: true,
+ cache: "no-store",
+ }).catch(() => {
+ // Unloading; can't surface anyway.
+ });
+ } catch {
+ /* swallow, exit path */
+ }
+ };
+ const onBeforeUnload = (): void => {
+ flushOnExit();
+ // Deliberately NOT calling preventDefault, no "leave site?"
+ // prompt; we just save quietly and let the navigation complete.
+ };
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => {
+ window.removeEventListener("beforeunload", onBeforeUnload);
+ // Internal Next.js navigation: this cleanup fires when the
+ // BracketBuilder unmounts (e.g. user clicks Pools).
+ flushOnExit();
+ };
+ }, []);
+
const totalGroupMatches = tournament.group_fixtures.length;
const completedGroupMatches = Object.keys(bracket.matchPredictions).length;
const completedKnockouts = Object.keys(bracket.knockoutPredictions).length;
@@ -1820,18 +1889,16 @@ export function BracketBuilder(props: BracketBuilderProps) {
})}
- {cascaded.warnings.length > 0 && (
-
- {cascaded.warnings.length} cascade warnings
-
- {cascaded.warnings.map((w, i) => (
-
- {w.code} {w.message}
-
- ))}
-
-
- )}
+ {/* Tim 2026-06-05: cascade warnings rendered through a
+ * dedicated component that translates engine codes to plain
+ * English and surfaces a contextual "go back to "
+ * banner when the user is downstream of an incomplete stage. */}
+ setTab(target as TabId)}
+ />
+
{showAutoPickConfirm && (
section) so a curious user can still see what's
+ * outstanding without us writing copy for every conceivable
+ * combination.
+ */
+
+import type { CascadeWarning } from "@tournamental/bracket-engine";
+
+import "./cascade-warnings.css";
+
+export type BracketTabId =
+ | "groups"
+ | "thirds"
+ | "r32"
+ | "r16"
+ | "qf"
+ | "sf"
+ | "tp"
+ | "final";
+
+export interface CascadeWarningsProps {
+ readonly warnings: ReadonlyArray;
+ /** The tab the user is currently looking at. Drives the banner. */
+ readonly currentTab: BracketTabId;
+ /**
+ * Fired when the contextual banner's CTA is clicked. The parent
+ * decides what "go back" means (typically `setTab(targetTab)`).
+ */
+ readonly onJumpToTab: (target: BracketTabId) => void;
+}
+
+/**
+ * Plain-English sentence per warning code. Anything not in this map
+ * falls back to a generic "Something needs your attention upstream"
+ * line so we don't leak an `annex_c_third_pool_incomplete`-shaped
+ * string at the user.
+ */
+function friendlyMessage(code: CascadeWarning["code"]): string {
+ switch (code) {
+ case "missing_group_prediction":
+ case "incomplete_group_order":
+ return "A group still needs every match predicted before the cascade can rank the standings.";
+ case "missing_wildcard_pick":
+ case "annex_c_third_pool_incomplete":
+ return "The Top 8 3rd-placed teams stage needs all 8 picks before the Round of 32 can fill in.";
+ case "annex_c_lookup_missing":
+ case "annex_c_no_third_for_group_winner":
+ return "Your Top 8 3rds combination is rare enough that FIFA's Annex C lookup table doesn't cover it. Try swapping one of the picks.";
+ case "team_not_in_group":
+ case "duplicate_team_in_group":
+ return "A group has a duplicated team. Re-pick that group's matches to fix the ordering.";
+ case "winner_not_in_match":
+ return "A knockout pick references a team that isn't in the matchup any more. Re-pick the winner.";
+ case "withdrawn_team_advancing":
+ return "A team in this matchup has withdrawn from the tournament.";
+ default:
+ return "Something upstream needs picking before this stage can finish resolving.";
+ }
+}
+
+/**
+ * Which earlier tab does this warning belong to? Used to choose the
+ * banner's "Go back to X" CTA target.
+ */
+function originTab(code: CascadeWarning["code"]): BracketTabId {
+ switch (code) {
+ case "missing_wildcard_pick":
+ case "annex_c_third_pool_incomplete":
+ case "annex_c_lookup_missing":
+ case "annex_c_no_third_for_group_winner":
+ return "thirds";
+ case "missing_group_prediction":
+ case "incomplete_group_order":
+ case "team_not_in_group":
+ case "duplicate_team_in_group":
+ return "groups";
+ case "winner_not_in_match":
+ case "withdrawn_team_advancing":
+ default:
+ return "groups";
+ }
+}
+
+const TAB_LABEL: Record = {
+ groups: "Group stage",
+ thirds: "Top 8 3rds",
+ r32: "Round of 32",
+ r16: "Round of 16",
+ qf: "Quarter-finals",
+ sf: "Semi-finals",
+ tp: "Third-place playoff",
+ final: "Final",
+};
+
+const TAB_ORDER: BracketTabId[] = [
+ "groups",
+ "thirds",
+ "r32",
+ "r16",
+ "qf",
+ "sf",
+ "tp",
+ "final",
+];
+
+/**
+ * Pick the most relevant warning for a banner: prefer the earliest-
+ * stage origin, since fixing that upstream usually resolves the
+ * downstream ones. Returns null when none of the warnings sit
+ * upstream of the current tab.
+ */
+function pickBannerWarning(
+ warnings: ReadonlyArray,
+ currentTab: BracketTabId,
+): { target: BracketTabId; message: string } | null {
+ const currentIdx = TAB_ORDER.indexOf(currentTab);
+ if (currentIdx <= 0) return null;
+ // Walk the warnings, find the earliest-stage origin that's strictly
+ // before the current tab.
+ let best: { target: BracketTabId; targetIdx: number } | null = null;
+ for (const w of warnings) {
+ const target = originTab(w.code);
+ const targetIdx = TAB_ORDER.indexOf(target);
+ if (targetIdx < 0 || targetIdx >= currentIdx) continue;
+ if (best === null || targetIdx < best.targetIdx) {
+ best = { target, targetIdx };
+ }
+ }
+ if (!best) return null;
+ const targetLabel = TAB_LABEL[best.target];
+ const currentLabel = TAB_LABEL[currentTab];
+ return {
+ target: best.target,
+ message: `Some slots on the ${currentLabel} aren't filled in yet because the ${targetLabel} stage is incomplete. Head back to finish it and the rest of the bracket will fill in.`,
+ };
+}
+
+export function CascadeWarnings({
+ warnings,
+ currentTab,
+ onJumpToTab,
+}: CascadeWarningsProps): JSX.Element | null {
+ if (warnings.length === 0) return null;
+
+ // Collapse duplicate code+message pairs so eight identical
+ // "annex_c_third_pool_incomplete" lines render as one sentence.
+ const uniqueByCode = new Map();
+ for (const w of warnings) {
+ const key = w.code;
+ if (!uniqueByCode.has(key)) {
+ uniqueByCode.set(key, friendlyMessage(w.code));
+ }
+ }
+ const friendlyList = Array.from(uniqueByCode.entries());
+ const banner = pickBannerWarning(warnings, currentTab);
+
+ return (
+
+ {banner ? (
+
+ {banner.message}
+
+
+ ) : null}
+
+
+
+ {friendlyList.length === 1
+ ? "Heads up about your picks"
+ : `${friendlyList.length} things still need picking`}
+
+