brackt/app/components/draft/DraftGridSection.tsx
Chris Parsons ca2fd288ab
perf: fix draft room lag from excessive re-renders and listener leaks (#54)
Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).

Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.

Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.

socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.

Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 13:18:47 -08:00

380 lines
14 KiB
TypeScript

import { memo, useMemo, useState } from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { MoreVertical } from "lucide-react";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
type MobileSheetData =
| { type: "team"; teamId: string }
| { type: "current-cell"; pickNumber: number; teamId: string }
| { type: "picked-cell"; pickNumber: number; teamId: string };
interface DraftGridSectionProps {
draftSlots: Array<{
id: string;
draftOrder: number;
team: {
id: string;
name: string;
};
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: {
name: string;
};
sport: {
name: string;
};
};
}>
>;
currentPick: number;
teamTimers: Record<string, number | undefined>;
autodraftStatus: Record<string, boolean>;
connectedTeams: Set<string>;
isCommissioner: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onForceAutopick: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
ownerMap?: Record<string, string>;
}
export const DraftGridSection = memo(function DraftGridSection({
draftSlots,
draftGrid,
currentPick,
teamTimers,
autodraftStatus,
connectedTeams,
isCommissioner,
onAdjustTimeBankOpen,
onForceAutopick,
onForceManualPickOpen,
onReplacePick,
onRollbackToPick,
ownerMap = {},
}: DraftGridSectionProps) {
const [mobileSheet, setMobileSheet] = useState<MobileSheetData | null>(null);
const currentTeamId = useMemo(
() => draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId ?? null,
[draftGrid, currentPick]
);
return (
<div className="h-full flex flex-col p-4">
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
<div className="flex-1 overflow-auto">
<div className="inline-block min-w-full min-h-full">
{/* Team Headers */}
<div className="flex gap-2 mb-2 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
{/* Spacer for round column — sticky so it covers the corner when scrolling both axes */}
<div className="w-8 flex-shrink-0 sticky left-0 z-[6] bg-background/95" />
{draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id] || false;
const isConnected = connectedTeams.has(slot.team.id);
const headerInner = (
<div className="relative">
<div
className={`font-semibold text-sm truncate px-2 ${
slot.team.id === currentTeamId
? "text-electric font-bold"
: !isConnected
? "italic text-muted-foreground"
: ""
}`}
>
{ownerMap[slot.team.id] || slot.team.name}
</div>
<div
className={`text-xs font-mono px-2 ${getTimerColorClass(teamTime)}`}
>
{formatClockTime(teamTime)}
{isAutodraft && (
<span className="ml-1 text-muted-foreground">
(auto)
</span>
)}
</div>
{isCommissioner && onAdjustTimeBankOpen && (
<button
className="absolute top-0 right-0 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
</div>
);
if (isCommissioner && onAdjustTimeBankOpen) {
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
<div className="flex-1 min-w-32 text-center cursor-context-menu">
{headerInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => onAdjustTimeBankOpen(slot.team.id)}
>
Adjust Time Bank...
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div key={slot.id} className="flex-1 min-w-32 text-center">
{headerInner}
</div>
);
})}
</div>
{/* Draft Grid Rows */}
<div className="space-y-2">
{draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound
? [...roundPicks].reverse()
: roundPicks;
return (
<div key={roundIndex} className="flex gap-2 items-stretch">
{/* Round label */}
<div className="w-8 flex-shrink-0 sticky left-0 z-[5] bg-background/95 flex items-center justify-center">
<span className="text-xs font-mono text-muted-foreground">R{round}</span>
</div>
{displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick;
const cellClassName = `relative flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40"
: isPicked
? "bg-emerald-500/10 border-emerald-500/30"
: "border-border bg-card"
}`;
const cellInner = (
<>
<div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.pick?.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.pick?.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-sm font-bold text-electric animate-pulse">
On Clock
</div>
) : null}
{/* Mobile commissioner button */}
{isCommissioner && !isPicked && isCurrent && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
{isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
</>
);
// Commissioner context menu on the current unpicked cell
if (isCommissioner && !isPicked && isCurrent) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onForceAutopick(cell.pickNumber, cell.teamId)
}
>
Force Auto Pick
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
onForceManualPickOpen(
cell.pickNumber,
cell.teamId
)
}
>
Force Manual Pick
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
// Commissioner context menu on already-picked cells
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
<ContextMenuItem
onClick={() =>
onReplacePick(cell.pickNumber, cell.teamId)
}
>
Replace Pick
</ContextMenuItem>
)}
{onRollbackToPick && (
<ContextMenuItem
onClick={() => onRollbackToPick(cell.pickNumber)}
className="text-destructive focus:text-destructive"
>
Roll Back to This Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div
key={cell.pickNumber}
className={cellClassName}
title={`Overall Pick #${cell.pickNumber}`}
>
{cellInner}
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
{/* Mobile Commissioner Sheet */}
<Sheet open={!!mobileSheet} onOpenChange={(open) => !open && setMobileSheet(null)}>
<SheetContent side="bottom" className="pb-8">
<SheetHeader>
<SheetTitle>Commissioner Actions</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-3 p-4">
{mobileSheet?.type === "team" && onAdjustTimeBankOpen && (
<Button
onClick={() => {
onAdjustTimeBankOpen(mobileSheet.teamId);
setMobileSheet(null);
}}
>
Adjust Time Bank...
</Button>
)}
{mobileSheet?.type === "current-cell" && (
<>
<Button
onClick={() => {
onForceAutopick(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Force Auto Pick
</Button>
<Button
variant="outline"
onClick={() => {
onForceManualPickOpen(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Force Manual Pick
</Button>
</>
)}
{mobileSheet?.type === "picked-cell" && (
<>
{onReplacePick && (
<Button
onClick={() => {
onReplacePick(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Replace Pick
</Button>
)}
{onRollbackToPick && (
<Button
variant="destructive"
onClick={() => {
onRollbackToPick(mobileSheet.pickNumber);
setMobileSheet(null);
}}
>
Roll Back to This Pick
</Button>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
});