Dev notes
An interactive bracket picker with debounced saves, TBD team resolution, a public leaderboard, and responsive layout — built test-first with Vitest, Testing Library, and MSW.
The bracket data comes from ESPN via a BFF proxy route. Picks are stored per-user in the portfolio API and fetched server-side. Both queries run in parallel on mount — bracket structure and saved picks land independently so neither blocks the other.
User edits are tracked in a separate userEdits state object. A useMemo merges server picks with local edits: { ...(serverPicks ?? {}), ...userEdits }. When picks load from the server, they slot in as the base. Every user click layers on top. No effect needed.
The first version initialized picks state from the server response inside a useEffect. ESLint flagged it immediately: react-hooks/set-state-in-effect. Calling setState synchronously inside an effect triggers a second render immediately, which can cascade.
The fix was to stop treating the server data as something to copy into state. It already lives in the TanStack Query cache — a stable reference that updates when the fetch completes. Merging it with local edits in a useMemo means the component always sees the latest combined view with no extra renders and no lint rule violations.
Later-round matchups start with teams listed as TBD — the winner of an earlier series. The bracket needs to show the actual team abbreviation in those slots rather than a blank or "TBD" label, but only if the user has already picked a winner for that preceding series.
A static PRECEDING map encodes which earlier matchup feeds each TBD slot. A resolveTeam function looks up the appropriate entry in the map, reads the user's pick for that matchup, and returns the winner abbreviation or "?" if no pick exists yet. Buttons for unresolved TBD slots are disabled so you can't pick a winner before picking their opponent.
Every time the merged picks object changes, a debounced effect fires a PUT to /api/nba/playoffs/picks after 800ms of quiet. A ref (userHasPickedRef) starts false and flips on first user interaction — the save effect bails early until that flag is set, so loading server data on mount never triggers a spurious write back to the backend.
A SaveIndicator component reads a saveState enum to show "Saving..." or "Saved" — the kind of quiet acknowledgment that lets you keep clicking without wondering whether your picks are persisting.
The bracket is a three-column grid: East rounds on the left, Finals in the center, West rounds on the right. At large breakpoints, the two conference sides sit at opposite ends of the viewport with the Finals column bridging them. On mobile, each conference section is an independently scrollable row with overflow-x-auto and negative horizontal margin bleed so the scroll area extends edge-to-edge.
The West side uses lg:flex-row-reverse to mirror its column order — R1, R2, WCF reads left-to-right on mobile for natural scrolling, but at lg: it flips so WCF is visually closest to the Finals column in the center.
The Finals card adds two extra inputs: a number field for the combined score of the last game (the tiebreaker for equal bracket scores) and a text field for the Finals MVP. Both use local state initialized from the pick prop rather than controlled inputs tied to the parent's state, for the same reason as the series score selector — the mock in tests doesn't update the prop on change, so accumulating multiple keystrokes requires local state that React re-renders with each character.
A public leaderboard sits below the bracket. It proxies to the portfolio API which scores all submitted picks against actual results and returns ranked entries with a per-round breakdown. The current user's row highlights in orange — the component receives currentUserSub from the /api/me query and matches it against entry.sub. Unauthenticated visitors see the full leaderboard, just without any row highlighted.
The leaderboard response is cached at s-maxage=300 — fresh enough to reflect new results within a few minutes, stale enough to avoid hammering the backend during busy playoff windows.
Each component has its own test file: SeriesPickCard, FinalsCard, and PlayoffLeaderboard. Tests render the component, fire user events, and assert on DOM state — no internal implementation details.
The leaderboard tests use MSW to intercept GET /api/nba/playoffs/leaderboard in three scenarios: loading (infinite delay proves the skeleton renders), success (five entries, current user highlight, round badges), and error (error message renders). The wrapper factory creates a fresh QueryClient per test with retry: false so errors surface immediately without TanStack's default three retries.
The initial build had a single debounced PUT that fired automatically as you picked. That works, but it creates an ambiguous UX: users don't know whether their picks are "tentative" or "submitted." An explicit submit button makes the contract clear and mirrors how most bracket contests work — you fill it out, then commit.
Auto-save is kept as an opt-in checkbox (default off). The two modes are mutually exclusive: when auto-save is on, the Submit button is disabled. This avoids double-writing and removes the ambiguity of having both active at once. On a successful explicit submit, the saveStatus is cleared to idle so any lingering "Saving..." from a pending debounce doesn't persist after the request settles.
The first version showed a placeholder message until at least one official result existed. The problem: the leaderboard is part of the social contract of a bracket contest. People want to see who else has submitted even before the first series concludes — it confirms participation and creates anticipation.
The backend was changed to always fetch submitted brackets, scoring them as 0 when no results exist yet. Ties at 0 are broken by submission date ascending — earlier submissions rank higher, which is a mild incentive to lock in picks before the first game tips off. The BFF adds a transformation layer since the portfolio API field names (userSub, maxPossible, flat breakdown) differ from the frontend types (sub, maxScore, roundBreakdown array).