From 2b3c88e02ac579605f62c57ca35be54c9c843651 Mon Sep 17 00:00:00 2001 From: Tim Thomas <0800tim@gmail.com> Date: Fri, 5 Jun 2026 11:58:35 +1200 Subject: [PATCH] feat(bracket): save on page exit + human-friendly cascade warnings Two related UX safety nets on the bracket page. Save on page exit ----------------- Adds a beforeunload + unmount listener that flushes the bracket to /v1/bracket/submit via fetch keepalive when the user navigates away with unsaved changes. Covers tab close, reload, external link, back/forward AND internal Next.js route changes (Pools, Profile, the drawer, etc.). Gated on authenticated + dirty + no in-flight save, so anonymous users and clean brackets don't generate spurious traffic. Payload is the same ~5-10KB JSON the 30s autosave already POSTs; well under keepalive's 64KB cap. Refs are used to bridge the latest bracket/isDirty/auth values into the listener so the empty-deps useEffect can register once and still see current state. Human-friendly cascade warnings ------------------------------- The previous render dumped engine codes at the user: 'annex_c_third_pool_incomplete FIFA Annex C routing requires exactly 8 best-third picks (got 6).' Tim spotted this and asked for plain English plus, when the user sits on a later tab (R32 onward) with empty slots because an earlier stage is incomplete, a banner pointing them back. New CascadeWarnings component renders three things: 1. Contextual banner at the top of late tabs with a 'Go to ' gold-pill CTA when the cascade warnings' origin is upstream of the current tab. Click jumps to that tab via setTab. 2. Single plain-English summary line per distinct warning code (collapses the 8 duplicate annex_c_third_pool_incomplete lines to one). 3. A
with the friendly list so a curious user can expand without us writing copy for every combo. All engine codes mapped: missing_group_prediction / incomplete_group_order -> 'A group still needs every match predicted...' missing_wildcard_pick / annex_c_third_pool_incomplete -> 'The Top 8 3rd-placed teams stage needs all 8 picks before the Round of 32 can fill in.' annex_c_lookup_missing / annex_c_no_third_for_group_winner -> '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.' team_not_in_group / duplicate_team_in_group -> 'A group has a duplicated team. Re-pick that group's matches to fix the ordering.' winner_not_in_match -> 'A knockout pick references a team that isn't in the matchup any more. Re-pick the winner.' withdrawn_team_advancing -> 'A team in this matchup has withdrawn from the tournament.' Unknown codes fall back to a generic 'Something upstream needs picking before this stage can finish resolving.' line so a new engine code never leaks raw at the user. The old
markup is gone; the CSS class is left in place in bracket.css for any legacy selectors (no styled rules referenced it directly). Tim 2026-06-05. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Tim Thomas <0800tim@gmail.com> --- .../web/components/bracket/BracketBuilder.tsx | 93 ++++++-- .../components/bracket/CascadeWarnings.tsx | 209 ++++++++++++++++++ .../components/bracket/cascade-warnings.css | 108 +++++++++ 3 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 apps/web/components/bracket/CascadeWarnings.tsx create mode 100644 apps/web/components/bracket/cascade-warnings.css 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`} + +
    + {friendlyList.map(([code, msg]) => ( +
  • {msg}
  • + ))} +
+
+
+ ); +} diff --git a/apps/web/components/bracket/cascade-warnings.css b/apps/web/components/bracket/cascade-warnings.css new file mode 100644 index 00000000..5286abe3 --- /dev/null +++ b/apps/web/components/bracket/cascade-warnings.css @@ -0,0 +1,108 @@ +/* Human-friendly cascade warnings surface. Replaces the previous + * `details > summary` block that just dumped engine codes. + * Tim 2026-06-05. */ + +.bracket-cascade-warnings { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +/* Top banner, shown on later tabs (R32 onward) when a prior stage + * is the actual cause of the empty slots. Calmer styling than the + * old red error block; we want it informative, not alarming. */ +.bracket-cascade-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(220, 169, 75, 0.08); + border: 1px solid rgba(220, 169, 75, 0.32); + border-radius: 10px; + color: #f5e1a9; +} +.bracket-cascade-banner-text { + flex: 1; + font-size: 14px; + line-height: 1.45; +} +.bracket-cascade-banner-cta { + flex-shrink: 0; + display: inline-flex; + align-items: center; + padding: 8px 14px; + border-radius: 999px; + border: none; + background: linear-gradient(180deg, #fcd34d 0%, #f59e0b 100%); + color: #0a0a0e; + font-family: inherit; + font-weight: 700; + font-size: 13px; + letter-spacing: 0.02em; + cursor: pointer; + box-shadow: + 0 8px 18px -8px rgba(245, 158, 11, 0.55), + inset 0 1px 0 rgba(255, 255, 255, 0.4); + transition: transform 120ms ease, box-shadow 120ms ease; + white-space: nowrap; +} +.bracket-cascade-banner-cta:hover { + transform: translateY(-1px); + box-shadow: + 0 12px 22px -10px rgba(245, 158, 11, 0.65), + inset 0 1px 0 rgba(255, 255, 255, 0.5); +} + +/* Collapsible details list. Sits below the banner; lets a curious + * user expand to see each distinct outstanding action without us + * shouting at every page render. */ +.bracket-cascade-details { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 10px; + padding: 8px 12px; + font-size: 13px; + color: #cbd5e1; +} +.bracket-cascade-details > summary { + cursor: pointer; + list-style: none; + font-weight: 600; + color: #e7ecf7; + padding: 4px 0; +} +.bracket-cascade-details > summary::-webkit-details-marker { + display: none; +} +.bracket-cascade-details > summary::before { + content: "▸ "; + display: inline-block; + width: 1em; + transition: transform 120ms ease; +} +.bracket-cascade-details[open] > summary::before { + content: "▾ "; +} +.bracket-cascade-details ul { + margin: 8px 0 4px; + padding: 0 0 0 22px; + display: flex; + flex-direction: column; + gap: 6px; + line-height: 1.45; +} +.bracket-cascade-details li { + list-style: disc; +} + +@media (max-width: 640px) { + .bracket-cascade-banner { + flex-direction: column; + align-items: stretch; + text-align: left; + } + .bracket-cascade-banner-cta { + justify-content: center; + } +}