diff --git a/README.md b/README.md
index 7c07447..b35e298 100644
--- a/README.md
+++ b/README.md
@@ -45,9 +45,15 @@ bun dev
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
-Sign in and sign up from the header, or continue browsing as a guest. Accounts currently
-provide identity only; brackets still save to this browser's localStorage and do not sync
-between devices or become private to a signed-in account.
+Sign in from the header and open **My brackets** to create, save, and reopen private
+brackets across devices. Guest brackets remain in browser storage. Use **Import browser
+brackets** to copy those saves into your account; the originals remain on this device.
+
+Saved brackets can publish a read-only snapshot with **Create share link**. Links expose
+the bracket name, display name, subtitle, and picks to anyone holding the link. Further
+edits stay private until published again. Stop sharing to permanently revoke that link.
+Friends can compare their account brackets on the shared page. Correct-pick counts are
+informal because predictions remain editable. Account saves currently support the 2025 postseason.
For CI and deployment, configure `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and
`CLERK_SECRET_KEY` in the environment. Use development keys for local testing and configure
diff --git a/e2e/tests/shared-brackets.spec.ts b/e2e/tests/shared-brackets.spec.ts
new file mode 100644
index 0000000..6e7e1b4
--- /dev/null
+++ b/e2e/tests/shared-brackets.spec.ts
@@ -0,0 +1,41 @@
+import { test, expect } from "../fixtures/test-fixtures";
+import { createInitialBracket } from "../../src/lib/playoff-rules";
+import { PLAYOFF_SEASON_YEAR } from "../../src/data/teams";
+
+const token = "b705e8b6-73cf-43c4-a8cf-9fc4a567a92a";
+
+test("guests can view a shared snapshot without editing it", async ({
+ page,
+ mockEspnApi: _mock,
+}) => {
+ const state = createInitialBracket("Public Fan");
+ state.name = "Friends playoff picks";
+ state.afc.wildCard[0].winner = state.afc.wildCard[0].homeTeam;
+ await page.route(`**/api/shared-brackets/${token}`, (route) =>
+ route.fulfill({
+ json: { seasonYear: PLAYOFF_SEASON_YEAR, state, sharedAt: "2026-01-10T12:00:00Z" },
+ }),
+ );
+ await page.goto(`/s/${token}`);
+ await expect(page.getByRole("heading", { name: state.name })).toBeVisible();
+ await expect(page.getByText("1 of 13 picks made")).toBeVisible();
+ await expect(page.getByRole("link", { name: "Make my own bracket" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Save bracket" })).toHaveCount(0);
+ await expect(page.getByText("to compare your saved picks.")).toBeVisible();
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth > window.innerWidth,
+ );
+ expect(overflow).toBe(false);
+});
+
+test("revoked shared links show a recovery path", async ({ page }) => {
+ await page.route(`**/api/shared-brackets/${token}`, (route) =>
+ route.fulfill({
+ status: 404,
+ json: { error: "This shared bracket is unavailable or its owner stopped sharing it." },
+ }),
+ );
+ await page.goto(`/s/${token}`);
+ await expect(page.getByRole("heading", { name: "Bracket unavailable" })).toBeVisible();
+ await expect(page.getByRole("link", { name: "Back to games" })).toBeVisible();
+});
diff --git a/playwright.config.ts b/playwright.config.ts
index 0d3b988..7d9f23f 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -11,7 +11,7 @@ export default defineConfig({
: [["html", { open: "on-failure" }]],
use: {
- baseURL: "http://localhost:3000",
+ baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "on-first-retry",
@@ -45,7 +45,7 @@ export default defineConfig({
webServer: {
command: process.env.CI ? "bun run start" : "bun run build && bun run start",
- url: "http://localhost:3000",
+ url: process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
diff --git a/src/app/api/brackets/[id]/share/route.ts b/src/app/api/brackets/[id]/share/route.ts
new file mode 100644
index 0000000..6b85355
--- /dev/null
+++ b/src/app/api/brackets/[id]/share/route.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+import { readJson, withAccount } from "@/lib/bracket-api";
+import { bracketId } from "@/lib/bracket-document";
+import { publishBracket, unpublishBracket } from "@/lib/db/brackets";
+type Context = { params: Promise<{ id: string }> };
+const input = z.object({ revision: z.number().int().positive() });
+export function POST(request: Request, context: Context) {
+ return withAccount(request, async (ownerId) =>
+ publishBracket(
+ ownerId,
+ bracketId.parse((await context.params).id),
+ input.parse(await readJson(request)).revision,
+ ),
+ );
+}
+export function DELETE(request: Request, context: Context) {
+ return withAccount(request, async (ownerId) =>
+ unpublishBracket(
+ ownerId,
+ bracketId.parse((await context.params).id),
+ input.parse(await readJson(request)).revision,
+ ),
+ );
+}
diff --git a/src/app/api/shared-brackets/[token]/route.ts b/src/app/api/shared-brackets/[token]/route.ts
new file mode 100644
index 0000000..8d3abda
--- /dev/null
+++ b/src/app/api/shared-brackets/[token]/route.ts
@@ -0,0 +1,14 @@
+import { z } from "zod";
+import { json } from "@/lib/bracket-api";
+import { BracketStoreError, getSharedBracket } from "@/lib/db/brackets";
+export const dynamic = "force-dynamic";
+export async function GET(_request: Request, { params }: { params: Promise<{ token: string }> }) {
+ const token = z.uuid().safeParse((await params).token);
+ if (!token.success) return json({ error: "Shared bracket not found." }, 404);
+ try {
+ return json(await getSharedBracket(token.data));
+ } catch (error) {
+ if (error instanceof BracketStoreError) return json({ error: error.message }, error.status);
+ return json({ error: "Unable to load this bracket. Please retry." }, 503);
+ }
+}
diff --git a/src/app/s/[token]/page.tsx b/src/app/s/[token]/page.tsx
new file mode 100644
index 0000000..2862880
--- /dev/null
+++ b/src/app/s/[token]/page.tsx
@@ -0,0 +1,37 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { Suspense } from "react";
+import { AccountControls } from "@/components/AccountControls";
+import { SharedBracket } from "@/components/account/SharedBracket";
+export const metadata: Metadata = {
+ title: "Shared playoff bracket | bracket.build",
+ robots: { index: false, follow: false },
+};
+export default async function Page({ params }: { params: Promise<{ token: string }> }) {
+ return (
+ <>
+
+ Skip to content
+
+
+
+
+ Loading shared bracket…}>
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/account/AccountEditor.tsx b/src/components/account/AccountEditor.tsx
index 6f29b23..05176e9 100644
--- a/src/components/account/AccountEditor.tsx
+++ b/src/components/account/AccountEditor.tsx
@@ -9,6 +9,7 @@ import { nanoid } from "nanoid";
import { GameDialogProvider } from "@/contexts/GameDialogContext";
import { BracketProvider, useBracket } from "@/contexts/BracketContext";
import { Bracket } from "@/components/bracket/Bracket";
+import { ShareBracket } from "./ShareBracket";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -76,6 +77,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
const [saved, setSaved] = useState(initial);
const [savedFingerprint, setSavedFingerprint] = useState(() => pickFingerprint(bracket));
const [busy, setBusy] = useState(false);
+ const [sharing, setSharing] = useState(false);
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const [notice, setNotice] = useState("");
@@ -110,6 +112,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
}, [error]);
async function save(copy = false) {
+ if (busy || sharing) return;
setBusy(true);
setError("");
setNotice("");
@@ -167,6 +170,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
setBracketName(event.target.value)}
@@ -180,6 +184,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
id="account-display-name"
name="displayName"
autoComplete="nickname"
+ disabled={busy || sharing}
maxLength={80}
value={bracket.userName}
onChange={(event) => setUserName(event.target.value)}
@@ -192,6 +197,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
setSubtitle(event.target.value || null)}
@@ -200,7 +206,7 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
/>
-
+
{busy && (
save(true)}
>
Save as a copy
@@ -244,7 +250,16 @@ function Editor({ initial }: { initial: AccountBracket | null }) {
)}
-
diff --git a/src/components/account/AccountLibrary.tsx b/src/components/account/AccountLibrary.tsx
index ba56d7e..281392e 100644
--- a/src/components/account/AccountLibrary.tsx
+++ b/src/components/account/AccountLibrary.tsx
@@ -20,7 +20,7 @@ function Library() {
const { userId } = useAuth();
const router = useRouter();
const params = useSearchParams();
- const page = Math.max(0, Math.min(10000, Number(params.get("page")) || 0));
+ const page = Math.max(0, Math.min(10000, Math.floor(Number(params.get("page"))) || 0));
const [data, setData] = useState<{ brackets: AccountBracket[]; hasMore: boolean } | null>(null);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
diff --git a/src/components/account/ShareBracket.tsx b/src/components/account/ShareBracket.tsx
new file mode 100644
index 0000000..e938b6b
--- /dev/null
+++ b/src/components/account/ShareBracket.tsx
@@ -0,0 +1,143 @@
+"use client";
+import { useAuth } from "@clerk/nextjs";
+import { useState } from "react";
+import Link from "next/link";
+import { Loader2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { accountRequest } from "@/lib/account-client";
+import type { AccountBracket } from "@/lib/bracket-document";
+
+export function ShareBracket({
+ saved,
+ dirty,
+ saving,
+ onChange,
+ onBusyChange,
+}: {
+ saved: AccountBracket;
+ dirty: boolean;
+ saving: boolean;
+ onBusyChange: (busy: boolean) => void;
+ onChange: (saved: AccountBracket) => void;
+}) {
+ const { userId } = useAuth();
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState("");
+ const [error, setError] = useState("");
+ const path = saved.shareToken ? `/s/${saved.shareToken}` : null;
+ const url = path && typeof window !== "undefined" ? `${window.location.origin}${path}` : "";
+ async function changeSharing(remove = false) {
+ if (busy || saving) return;
+ if (
+ !window.confirm(
+ remove
+ ? "Stop sharing? Anyone with this link will lose access. A future shared link will have a new address."
+ : "Publish these saved picks, bracket name, subtitle, and display name? Anyone with the link can view them. You can revoke access later.",
+ )
+ )
+ return;
+ setBusy(true);
+ onBusyChange(true);
+ setError("");
+ setMessage("");
+ try {
+ const result = await accountRequest(
+ `/api/brackets/${saved.id}/share`,
+ remove ? "DELETE" : "POST",
+ { revision: saved.revision },
+ undefined,
+ userId,
+ );
+ onChange(result);
+ setMessage(
+ remove
+ ? "Sharing stopped. The old link is no longer available."
+ : "Your saved picks are published. Copy the link below.",
+ );
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : "Sharing failed. Please retry.");
+ } finally {
+ setBusy(false);
+ onBusyChange(false);
+ }
+ }
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(url);
+ setMessage("Link copied.");
+ } catch {
+ setMessage("Select and copy the link below.");
+ }
+ }
+ return (
+
+
+ Share with friends
+
+
+ {path
+ ? "Your link shows the last published snapshot. Private edits stay private until you publish again."
+ : "This bracket is private. Publish a read-only snapshot when you’re ready."}
+
+ {dirty && Save your changes before publishing.
}
+
+ changeSharing()}
+ >
+ {busy && (
+
+ )}
+ {path ? "Publish saved changes" : "Create share link"}
+
+ {path && (
+ <>
+
+ View shared bracket
+
+ changeSharing(true)}
+ >
+ Stop sharing
+
+ >
+ )}
+
+ {path && (
+
+ event.target.select()}
+ className="min-w-0 min-h-11 text-base"
+ />
+
+ Copy link
+
+
+ )}
+ {message && (
+
+ {message}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/components/account/SharedBracket.tsx b/src/components/account/SharedBracket.tsx
new file mode 100644
index 0000000..bace8db
--- /dev/null
+++ b/src/components/account/SharedBracket.tsx
@@ -0,0 +1,302 @@
+"use client";
+import { useAuth } from "@clerk/nextjs";
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { accountRequest } from "@/lib/account-client";
+import { allMatchups, type AccountBracket, type BracketDocument } from "@/lib/bracket-document";
+import { compareBrackets, countCorrectPicks } from "@/lib/bracket-comparison";
+import { PLAYOFF_SEASON_YEAR } from "@/data/teams";
+import type { LiveResults } from "@/types";
+
+export function SharedBracket({ token }: { token: string }) {
+ const { userId } = useAuth();
+ return ;
+}
+
+function SharedView({ token }: { token: string }) {
+ const { isSignedIn } = useAuth();
+ const router = useRouter();
+ const params = useSearchParams();
+ const compareId = params.get("compare");
+ const [shared, setShared] = useState<(BracketDocument & { sharedAt: string }) | null>(null);
+ const [mine, setMine] = useState(null);
+ const [list, setList] = useState([]);
+ const [page, setPage] = useState(0);
+ const [hasMore, setHasMore] = useState(false);
+ const [error, setError] = useState("");
+ const [compareError, setCompareError] = useState("");
+ const [retry, setRetry] = useState(0);
+ const [results, setResults] = useState(null);
+ useEffect(() => {
+ const controller = new AbortController();
+ setError("");
+ setShared(null);
+ accountRequest(
+ `/api/shared-brackets/${token}`,
+ "GET",
+ undefined,
+ controller.signal,
+ )
+ .then(setShared)
+ .catch((reason) => {
+ if (!controller.signal.aborted) setError(reason.message);
+ });
+ return () => controller.abort();
+ }, [token, retry]);
+ useEffect(() => {
+ if (!isSignedIn) return;
+ const controller = new AbortController();
+ setCompareError("");
+ setList([]);
+ accountRequest<{ brackets: AccountBracket[]; hasMore: boolean }>(
+ `/api/brackets?page=${page}`,
+ "GET",
+ undefined,
+ controller.signal,
+ )
+ .then((data) => {
+ setList(data.brackets);
+ setHasMore(data.hasMore);
+ })
+ .catch((reason) => {
+ if (!controller.signal.aborted) setCompareError(reason.message);
+ });
+ return () => controller.abort();
+ }, [isSignedIn, page, retry]);
+ useEffect(() => {
+ setMine(null);
+ if (!isSignedIn || !compareId) return;
+ const controller = new AbortController();
+ setCompareError("");
+ accountRequest(
+ `/api/brackets/${encodeURIComponent(compareId)}`,
+ "GET",
+ undefined,
+ controller.signal,
+ )
+ .then(setMine)
+ .catch((reason) => {
+ if (!controller.signal.aborted) setCompareError(reason.message);
+ });
+ return () => controller.abort();
+ }, [compareId, isSignedIn, retry]);
+ useEffect(() => {
+ if (!shared || shared.seasonYear !== PLAYOFF_SEASON_YEAR) return;
+ const controller = new AbortController();
+ fetch("/api/standings", { signal: controller.signal })
+ .then((response) => (response.ok ? response.json() : null))
+ .then(setResults)
+ .catch(() => {});
+ return () => controller.abort();
+ }, [shared]);
+ if (error)
+ return (
+
+
Bracket unavailable
+
{error}
+
setRetry((value) => value + 1)}>
+ Retry
+
+
+ Back to games
+
+
+ );
+ if (!shared) return Loading shared bracket…
;
+ const groups = compareBrackets(shared, shared);
+ const comparison = mine?.seasonYear === shared.seasonYear ? compareBrackets(shared, mine) : null;
+ const completedGames = results
+ ? [
+ ...results.afc.wildCard,
+ ...results.nfc.wildCard,
+ ...results.afc.divisional,
+ ...results.nfc.divisional,
+ results.afc.championship,
+ results.nfc.championship,
+ results.superBowl,
+ ].filter((game) => game?.isComplete).length
+ : 0;
+ return (
+ <>
+
+ {shared.seasonYear} playoffs · Shared snapshot
+
+ {shared.state.name || "Playoff predictions"}
+
+
+ By {shared.state.userName || "Football fan"}
+
+ {shared.state.subtitle && (
+ {shared.state.subtitle}
+ )}
+
+ Published{" "}
+ {new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(
+ new Date(shared.sharedAt),
+ )}
+
+
+
+ Super Bowl pick
+
+ {shared.state.superBowl?.winner
+ ? `${shared.state.superBowl.winner.city} ${shared.state.superBowl.winner.name}`
+ : "Still deciding"}
+
+
+ {allMatchups(shared.state).filter((game) => game.winner).length} of 13 picks made
+
+
+ Make my own bracket
+
+
+
+
+ Compare with mine
+
+ {!isSignedIn ? (
+
+
+ Sign in
+ {" "}
+ to compare your saved picks.
+
+ ) : (
+
+
+ Your bracket
+
+
+ router.push(
+ `/s/${token}${event.target.value ? `?compare=${encodeURIComponent(event.target.value)}` : ""}`,
+ { scroll: false },
+ )
+ }
+ className="min-h-11 w-full rounded-md border border-gray-600 bg-gray-900 px-3 text-base text-white"
+ >
+ Choose a saved bracket…
+ {mine && !list.some((saved) => saved.id === mine.id) && (
+ {mine.state.name || "Untitled bracket"}
+ )}
+ {list
+ .filter((saved) => saved.seasonYear === shared.seasonYear)
+ .map((saved) => (
+
+ {saved.state.name || "Untitled bracket"}
+
+ ))}
+
+
+ setPage((value) => value - 1)}
+ >
+ Previous saves
+
+ setPage((value) => value + 1)}
+ >
+ More saves
+
+
+ Manage or import brackets
+
+
+ {compareError && (
+
+ {compareError}{" "}
+ setRetry((value) => value + 1)}
+ >
+ Retry
+
+
+ )}
+ {mine && !comparison && (
+
Choose a bracket from the {shared.seasonYear} season.
+ )}
+
+ )}
+ {comparison && (
+
+
+ {comparison.reduce((sum, group) => sum + group.agreed.length, 0)} matching picks
+
+
+ Comparing teams advancing in each round, including reseeded matchups. Unpicked teams
+ are shown as incomplete, not losses. This is a friendly comparison: picks can be
+ edited after games finish.
+
+ {completedGames > 0 && mine && results && (
+
+ Correct picks: {shared.state.userName || "Shared bracket"}{" "}
+ {countCorrectPicks(shared, results)} · You {countCorrectPicks(mine, results)}{" "}
+ ({completedGames} final games)
+
+ )}
+
+ )}
+
+
+ {(comparison ?? groups).map((group) => (
+
+ {group.label}
+
+ {[group.left, ...(comparison ? [group.right] : [])].map((teams, index) => (
+
+ {comparison && (
+
+ {index === 0 ? shared.state.userName || "Shared bracket" : "You"}
+
+ )}
+ {teams.length ? (
+
+ {teams.map((team) => (
+
+
+ {team.city} {team.name}
+
+ {comparison && (
+ match.id === team.id) ? "text-green-300" : "text-amber-200"}`}
+ >
+ {group.agreed.some((match) => match.id === team.id)
+ ? "Match"
+ : "Different pick"}
+
+ )}
+
+ ))}
+
+ ) : (
+
No picks yet
+ )}
+
+ ))}
+
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/lib/bracket-comparison.test.ts b/src/lib/bracket-comparison.test.ts
new file mode 100644
index 0000000..3e02757
--- /dev/null
+++ b/src/lib/bracket-comparison.test.ts
@@ -0,0 +1,54 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { compareBrackets, countCorrectPicks } from "./bracket-comparison";
+import { createInitialBracket } from "./playoff-rules";
+import { PLAYOFF_SEASON_YEAR } from "@/data/teams";
+import type { LiveResults } from "@/types";
+
+const document = () => ({ seasonYear: PLAYOFF_SEASON_YEAR, state: createInitialBracket("Fan") });
+
+test("comparisons match advancing teams even when reseeding changes slots", () => {
+ const left = document();
+ const right = document();
+ const a = left.state.afc.wildCard[0].homeTeam!;
+ const b = left.state.afc.wildCard[1].homeTeam!;
+ left.state.afc.divisional[0].winner = a;
+ left.state.afc.divisional[1].winner = b;
+ right.state.afc.divisional[0].winner = b;
+ right.state.afc.divisional[1].winner = a;
+ const round = compareBrackets(left, right).find((group) => group.key === "AFC-divisional")!;
+ assert.equal(round.agreed.length, 2);
+ assert.equal(round.different, 0);
+ right.state.afc.divisional[1].winner = null;
+ assert.equal(
+ compareBrackets(left, right).find((group) => group.key === round.key)!.agreed.length,
+ 1,
+ );
+});
+
+test("empty picks are not agreements and seasons cannot be mixed", () => {
+ assert.equal(compareBrackets(document(), document()).flatMap((group) => group.agreed).length, 0);
+ assert.throws(() => compareBrackets(document(), { ...document(), seasonYear: 2000 }));
+});
+
+test("correct picks count only completed results in the same conference and round", () => {
+ const picks = document();
+ const winner = picks.state.afc.wildCard[0].homeTeam!;
+ picks.state.afc.divisional[0].winner = winner;
+ const results: LiveResults = {
+ afc: { wildCard: [], divisional: [], championship: null },
+ nfc: { wildCard: [], divisional: [], championship: null },
+ superBowl: null,
+ fetchedAt: Date.now(),
+ };
+ const result = {
+ winnerId: winner.id,
+ isComplete: true,
+ } as LiveResults["afc"]["divisional"][number];
+ results.afc.wildCard.push(result);
+ assert.equal(countCorrectPicks(picks, results), 0);
+ results.afc.divisional.push({ ...result, isComplete: false });
+ assert.equal(countCorrectPicks(picks, results), 0);
+ results.afc.divisional[0].isComplete = true;
+ assert.equal(countCorrectPicks(picks, results), 1);
+});
diff --git a/src/lib/bracket-comparison.ts b/src/lib/bracket-comparison.ts
new file mode 100644
index 0000000..98846b1
--- /dev/null
+++ b/src/lib/bracket-comparison.ts
@@ -0,0 +1,59 @@
+import { allMatchups, type BracketDocument } from "./bracket-document";
+import type { Matchup, RoundName, LiveResults } from "@/types";
+
+const labels: Record = {
+ wildCard: "Wild Card",
+ divisional: "Divisional",
+ conference: "Conference championship",
+ superBowl: "Super Bowl",
+};
+
+export function compareBrackets(left: BracketDocument, right: BracketDocument) {
+ if (left.seasonYear !== right.seasonYear)
+ throw new Error("Choose a bracket from the same season.");
+ const leftGames = allMatchups(left.state);
+ const rightGames = allMatchups(right.state);
+ // Divisional pairings are reseeded independently in each prediction. Compare the
+ // teams advancing in each conference/round rather than matching arbitrary slots.
+ return (["wildCard", "divisional", "conference", "superBowl"] as RoundName[]).flatMap((round) => {
+ const conferences = round === "superBowl" ? ["superBowl"] : ["AFC", "NFC"];
+ return conferences.map((conference) => {
+ const select = (games: Matchup[]) =>
+ games
+ .filter((game) => game.round === round && game.conference === conference)
+ .flatMap((game) => (game.winner ? [game.winner] : []));
+ const a = select(leftGames),
+ b = select(rightGames);
+ const agreed = a.filter((team) => b.some((other) => other.id === team.id));
+ return {
+ key: `${conference}-${round}`,
+ label: `${conference === "superBowl" ? "" : conference + " · "}${labels[round]}`,
+ left: a,
+ right: b,
+ agreed,
+ different:
+ a.filter((team) => !b.some((other) => other.id === team.id)).length +
+ b.filter((team) => !a.some((other) => other.id === team.id)).length,
+ };
+ });
+ });
+}
+
+export function countCorrectPicks(document: BracketDocument, results: LiveResults) {
+ let correct = 0;
+ for (const game of allMatchups(document.state)) {
+ const conf = game.conference === "AFC" ? results.afc : results.nfc;
+ const actual =
+ game.round === "superBowl"
+ ? [results.superBowl]
+ : game.round === "conference"
+ ? [conf.championship]
+ : conf[game.round];
+ if (
+ game.winner &&
+ actual.some((result) => result?.isComplete && result.winnerId === game.winner?.id)
+ )
+ correct++;
+ }
+ return correct;
+}