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>
380 lines
14 KiB
TypeScript
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>
|
|
);
|
|
});
|