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>
168 lines
5.4 KiB
TypeScript
168 lines
5.4 KiB
TypeScript
import { memo, useMemo } from "react";
|
|
import { Badge } from "~/components/ui/badge";
|
|
|
|
interface TeamsDraftedGridProps {
|
|
draftSlots: Array<{
|
|
id: string;
|
|
team: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
ownerMap?: Record<string, string>;
|
|
picks: Array<{
|
|
id: string;
|
|
team: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
participant: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
sports: Array<{
|
|
id: string;
|
|
name: string;
|
|
}>;
|
|
season: {
|
|
numFlexPicks: number;
|
|
};
|
|
}
|
|
|
|
export const TeamsDraftedGrid = memo(function TeamsDraftedGrid({
|
|
draftSlots,
|
|
picks,
|
|
sports,
|
|
season,
|
|
ownerMap = {},
|
|
}: TeamsDraftedGridProps) {
|
|
// Calculate picks by team and sport
|
|
const picksByTeamAndSport = useMemo(() => {
|
|
const map = new Map<string, Map<string, typeof picks>>();
|
|
|
|
picks.forEach((pick) => {
|
|
if (!map.has(pick.team.id)) {
|
|
map.set(pick.team.id, new Map());
|
|
}
|
|
const teamMap = map.get(pick.team.id)!;
|
|
if (!teamMap.has(pick.sport.id)) {
|
|
teamMap.set(pick.sport.id, []);
|
|
}
|
|
teamMap.get(pick.sport.id)!.push(pick);
|
|
});
|
|
|
|
return map;
|
|
}, [picks]);
|
|
|
|
// Calculate flex picks used by each team
|
|
const flexPicksByTeam = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
|
|
draftSlots.forEach((slot) => {
|
|
const teamPicks = picks.filter((p) => p.team.id === slot.team.id);
|
|
const uniqueSportsPicked = new Set(teamPicks.map((p) => p.sport.id)).size;
|
|
const flexUsed = Math.max(0, teamPicks.length - uniqueSportsPicked);
|
|
map.set(slot.team.id, flexUsed);
|
|
});
|
|
|
|
return map;
|
|
}, [picks, draftSlots]);
|
|
|
|
if (sports.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full p-8 text-muted-foreground">
|
|
<p>No sports have been configured for this season.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full h-full overflow-auto">
|
|
<table className="w-full border-collapse">
|
|
<thead className="sticky top-0 bg-background z-10">
|
|
<tr>
|
|
<th className="border-r border-b border-border p-2 text-left font-semibold min-w-[150px] bg-muted/50 sticky left-0 z-20">
|
|
Sport
|
|
</th>
|
|
{draftSlots.map((slot, index) => {
|
|
const flexUsed = flexPicksByTeam.get(slot.team.id) || 0;
|
|
const isLast = index === draftSlots.length - 1;
|
|
return (
|
|
<th
|
|
key={slot.id}
|
|
className={`border-b border-border p-2 text-left font-semibold min-w-[180px] bg-muted/50 ${!isLast ? 'border-r' : ''}`}
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<div className="font-semibold text-sm">
|
|
{ownerMap[slot.team.id] || slot.team.name}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground font-normal">
|
|
{flexUsed} of {season.numFlexPicks} flex
|
|
</div>
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="border-b border-border">
|
|
{sports.map((sport, sportIndex) => {
|
|
const isLastRow = sportIndex === sports.length - 1;
|
|
return (
|
|
<tr key={sport.id}>
|
|
<td className={`border-r border-border p-2 font-medium bg-muted/30 sticky left-0 z-10 ${!isLastRow ? 'border-b' : ''}`}>
|
|
{sport.name}
|
|
</td>
|
|
{draftSlots.map((slot, slotIndex) => {
|
|
const teamSportPicks =
|
|
picksByTeamAndSport
|
|
.get(slot.team.id)
|
|
?.get(sport.id) || [];
|
|
const hasMultiplePicks = teamSportPicks.length > 1;
|
|
const isLastCol = slotIndex === draftSlots.length - 1;
|
|
|
|
return (
|
|
<td
|
|
key={`${slot.team.id}-${sport.id}`}
|
|
className={`border-border p-2 ${
|
|
hasMultiplePicks
|
|
? "bg-accent/30 font-semibold"
|
|
: "bg-background"
|
|
} ${!isLastRow ? 'border-b' : ''} ${!isLastCol ? 'border-r' : ''}`}
|
|
>
|
|
{teamSportPicks.length > 0 ? (
|
|
<div className="flex flex-col gap-1">
|
|
{teamSportPicks.map((pick, i) => (
|
|
<div
|
|
key={pick.id}
|
|
className="text-sm flex items-center gap-1"
|
|
>
|
|
<span>{pick.participant.name}</span>
|
|
{hasMultiplePicks && (
|
|
<Badge
|
|
variant="secondary"
|
|
className="text-xs px-1 py-0"
|
|
>
|
|
{i + 1}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
});
|