From accabdc5a3ffc1b25282129298ab0a3855cffd42 Mon Sep 17 00:00:00 2001 From: Neil Goldader Date: Sun, 13 Sep 2026 20:09:13 -0400 Subject: [PATCH] feat(sharing): publish and compare bracket picks --- README.md | 12 +- e2e/tests/shared-brackets.spec.ts | 41 +++ playwright.config.ts | 4 +- src/app/api/brackets/[id]/share/route.ts | 24 ++ src/app/api/shared-brackets/[token]/route.ts | 14 + src/app/s/[token]/page.tsx | 37 +++ src/components/account/AccountEditor.tsx | 21 +- src/components/account/AccountLibrary.tsx | 2 +- src/components/account/ShareBracket.tsx | 143 +++++++++ src/components/account/SharedBracket.tsx | 302 +++++++++++++++++++ src/lib/bracket-comparison.test.ts | 54 ++++ src/lib/bracket-comparison.ts | 59 ++++ 12 files changed, 704 insertions(+), 9 deletions(-) create mode 100644 e2e/tests/shared-brackets.spec.ts create mode 100644 src/app/api/brackets/[id]/share/route.ts create mode 100644 src/app/api/shared-brackets/[token]/route.ts create mode 100644 src/app/s/[token]/page.tsx create mode 100644 src/components/account/ShareBracket.tsx create mode 100644 src/components/account/SharedBracket.tsx create mode 100644 src/lib/bracket-comparison.test.ts create mode 100644 src/lib/bracket-comparison.ts 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 + +
+
+ + bracket.build + + +
+
+ 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 }) { />
-
)} -
+ {saved && ( + + )} +
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.

} +
+ + {path && ( + <> + + + + )} +
+ {path && ( +
+ event.target.select()} + className="min-w-0 min-h-11 text-base" + /> + +
+ )} + {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}

+ + + 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 +

+ +
+
+

+ Compare with mine +

+ {!isSignedIn ? ( +

+ + Sign in + {" "} + to compare your saved picks. +

+ ) : ( +
+ + +
+ + + + Manage or import brackets + +
+ {compareError && ( +

+ {compareError}{" "} + +

+ )} + {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; +}