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>
607 lines
22 KiB
TypeScript
607 lines
22 KiB
TypeScript
import { memo, useCallback, useMemo } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "~/components/ui/popover";
|
|
import {
|
|
Sheet,
|
|
SheetTrigger,
|
|
SheetContent,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
SheetClose,
|
|
} from "~/components/ui/sheet";
|
|
import { ListPlus, ListX, ChevronDown } from "lucide-react";
|
|
|
|
function getParticipantState(
|
|
participant: { id: string; sport: { id: string } },
|
|
draftedParticipantIds: Set<string>,
|
|
queueMap: Map<string, string>,
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, string>;
|
|
} | null
|
|
) {
|
|
const isDrafted = draftedParticipantIds.has(participant.id);
|
|
const isInQueue = queueMap.has(participant.id);
|
|
const isEligible = eligibility
|
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
|
|
return { isDrafted, isInQueue, isEligible, ineligibleReason };
|
|
}
|
|
|
|
interface SportFilterContentProps {
|
|
sportsForDropdown: Array<{ name: string; isDrafted: boolean }>;
|
|
sportFilterSet: Set<string>;
|
|
hideCompletedSports: boolean;
|
|
hasTeam: boolean;
|
|
variant: "sheet" | "popover";
|
|
onToggleSport: (sport: string, checked: boolean | "indeterminate") => void;
|
|
onHideCompletedSportsChange: (hide: boolean) => void;
|
|
}
|
|
|
|
function SportFilterContent({
|
|
sportsForDropdown,
|
|
sportFilterSet,
|
|
hideCompletedSports,
|
|
hasTeam,
|
|
variant,
|
|
onToggleSport,
|
|
onHideCompletedSportsChange,
|
|
}: SportFilterContentProps) {
|
|
const isSheet = variant === "sheet";
|
|
const idPrefix = isSheet ? "sheet-sport-filter-" : "popover-sport-filter-";
|
|
const listClass = isSheet
|
|
? "overflow-y-auto flex-1 px-4"
|
|
: "max-h-64 overflow-y-auto p-2 space-y-0.5";
|
|
const itemBaseClass = isSheet
|
|
? "flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
|
|
: "flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm";
|
|
const toggleLabelClass = isSheet
|
|
? "flex items-center gap-4 py-2 cursor-pointer text-base"
|
|
: "flex items-center gap-2 px-4 py-3 cursor-pointer text-sm";
|
|
|
|
return (
|
|
<>
|
|
<div className={listClass}>
|
|
{sportsForDropdown
|
|
.filter(({ isDrafted }) => !isDrafted || !hideCompletedSports)
|
|
.map(({ name, isDrafted }) => {
|
|
const checkboxId = `${idPrefix}${name.replace(/\s+/g, "-").toLowerCase()}`;
|
|
return (
|
|
<label
|
|
key={name}
|
|
htmlFor={checkboxId}
|
|
className={`${itemBaseClass}${isDrafted ? " text-muted-foreground" : ""}`}
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={sportFilterSet.has(name)}
|
|
onCheckedChange={(checked) => onToggleSport(name, checked)}
|
|
/>
|
|
{name}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
{hasTeam && (
|
|
isSheet ? (
|
|
<div className="px-4 border-t pt-3">
|
|
<label className={toggleLabelClass}>
|
|
<Checkbox
|
|
checked={!hideCompletedSports}
|
|
onCheckedChange={(checked) =>
|
|
onHideCompletedSportsChange(checked === false)
|
|
}
|
|
/>
|
|
<span>Show drafted sports</span>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<hr className="mx-2 border-border" />
|
|
<label className={toggleLabelClass}>
|
|
<Checkbox
|
|
checked={!hideCompletedSports}
|
|
onCheckedChange={(checked) =>
|
|
onHideCompletedSportsChange(checked === false)
|
|
}
|
|
/>
|
|
<span>Show drafted sports</span>
|
|
</label>
|
|
</>
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface AvailableParticipantsSectionProps {
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
searchQuery: string;
|
|
sportFilters: string[];
|
|
hideDrafted: boolean;
|
|
hideIneligible: boolean;
|
|
hideCompletedSports: boolean;
|
|
userDraftedSportNames: Set<string>;
|
|
uniqueSports: string[];
|
|
draftedParticipantIds: Set<string>;
|
|
queue: Array<{ id: string; participantId: string }>;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, string>;
|
|
} | null;
|
|
canPick: boolean;
|
|
hasTeam: boolean;
|
|
onSearchChange: (query: string) => void;
|
|
onSportFiltersChange: (sports: string[]) => void;
|
|
onHideDraftedChange: (hide: boolean) => void;
|
|
onHideIneligibleChange: (hide: boolean) => void;
|
|
onHideCompletedSportsChange: (hide: boolean) => void;
|
|
onMakePick: (participantId: string) => void;
|
|
onAddToQueue: (participantId: string) => void;
|
|
onRemoveFromQueue: (queueId: string) => void;
|
|
}
|
|
|
|
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
|
participants,
|
|
searchQuery,
|
|
sportFilters,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
hideCompletedSports,
|
|
userDraftedSportNames,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam,
|
|
onSearchChange,
|
|
onSportFiltersChange,
|
|
onHideDraftedChange,
|
|
onHideIneligibleChange,
|
|
onHideCompletedSportsChange,
|
|
onMakePick,
|
|
onAddToQueue,
|
|
onRemoveFromQueue,
|
|
}: AvailableParticipantsSectionProps) {
|
|
const queueMap = useMemo(
|
|
() => new Map(queue.map((item) => [item.participantId, item.id])),
|
|
[queue]
|
|
);
|
|
|
|
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
|
|
|
|
const sportsForDropdown = useMemo(() => {
|
|
return uniqueSports.map((s) => ({
|
|
name: s,
|
|
isDrafted: hasTeam && userDraftedSportNames.has(s),
|
|
}));
|
|
}, [uniqueSports, userDraftedSportNames, hasTeam]);
|
|
|
|
const triggerText = useMemo(
|
|
() =>
|
|
sportFilters.length === 0
|
|
? "All Sports"
|
|
: sportFilters.length === 1
|
|
? sportFilters[0]
|
|
: `${sportFilters.length} sports`,
|
|
[sportFilters]
|
|
);
|
|
|
|
const triggerAriaLabel = useMemo(
|
|
() =>
|
|
sportFilters.length === 0
|
|
? "Filter by sport: All Sports"
|
|
: sportFilters.length === 1
|
|
? `Filter by sport: ${sportFilters[0]}`
|
|
: `Filter by sport: ${sportFilters.length} sports selected`,
|
|
[sportFilters]
|
|
);
|
|
|
|
const handleToggleSport = useCallback(
|
|
(sport: string, checked: boolean | "indeterminate") => {
|
|
if (checked === true) {
|
|
onSportFiltersChange([...sportFilters, sport]);
|
|
} else {
|
|
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
|
|
}
|
|
},
|
|
[sportFilters, onSportFiltersChange]
|
|
);
|
|
|
|
const handleReset = useCallback(() => {
|
|
onSportFiltersChange([]);
|
|
onHideCompletedSportsChange(false);
|
|
}, [onSportFiltersChange, onHideCompletedSportsChange]);
|
|
|
|
const emptyMessage = useMemo(() => {
|
|
if (participants.length > 0) return null;
|
|
const active: string[] = [];
|
|
if (hideDrafted) active.push("drafted players");
|
|
if (hideIneligible && eligibility) active.push("ineligible players");
|
|
if (hideCompletedSports) active.push("drafted sports");
|
|
if (sportFilters.length > 0) active.push("other sports");
|
|
if (active.length === 0) return "No participants found.";
|
|
return `No participants found. Try showing ${active.join(", ")}.`;
|
|
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]);
|
|
|
|
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="px-4 pt-4 pb-2 flex-shrink-0">
|
|
<div className="space-y-2">
|
|
{/* Search Input */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
/>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{/* Sport Filter Multi-Select */}
|
|
<div className="flex-1 min-w-[120px]">
|
|
{/* Mobile: bottom sheet */}
|
|
<div className="md:hidden">
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="w-full justify-between text-base font-normal"
|
|
>
|
|
<span className="truncate">{triggerText}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
|
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
|
<SportFilterContent
|
|
sportsForDropdown={sportsForDropdown}
|
|
sportFilterSet={sportFilterSet}
|
|
hideCompletedSports={hideCompletedSports}
|
|
hasTeam={hasTeam}
|
|
variant="sheet"
|
|
onToggleSport={handleToggleSport}
|
|
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
|
/>
|
|
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={handleReset}
|
|
>
|
|
Reset
|
|
</Button>
|
|
)}
|
|
<SheetClose asChild>
|
|
<Button className="flex-1">Done</Button>
|
|
</SheetClose>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
{/* Desktop: popover */}
|
|
<div className="hidden md:block">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="w-full justify-between text-sm font-normal"
|
|
>
|
|
<span className="truncate">{triggerText}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 p-0" align="start">
|
|
{hasActiveFilters && (
|
|
<div className="flex justify-end px-2 pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto px-2 py-1 text-xs"
|
|
onClick={handleReset}
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<SportFilterContent
|
|
sportsForDropdown={sportsForDropdown}
|
|
sportFilterSet={sportFilterSet}
|
|
hideCompletedSports={hideCompletedSports}
|
|
hasTeam={hasTeam}
|
|
variant="popover"
|
|
onToggleSport={handleToggleSport}
|
|
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show/Hide Drafted Toggle */}
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<Checkbox
|
|
checked={!hideDrafted}
|
|
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
|
|
/>
|
|
<span>Show Drafted</span>
|
|
</label>
|
|
|
|
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
|
|
{hasTeam && eligibility && (
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<Checkbox
|
|
checked={!hideIneligible}
|
|
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
|
|
/>
|
|
<span>Show Ineligible</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Card List - visible on mobile only */}
|
|
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2">
|
|
{participants.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
participants.map((participant) => {
|
|
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
|
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
|
|
|
return (
|
|
<div
|
|
key={participant.id}
|
|
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<span
|
|
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
<div className="flex flex-wrap gap-1 items-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{participant.sport.name}
|
|
</Badge>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!isDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !isDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{hasTeam && !isDrafted && (
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={!isEligible ? ineligibleReason : "Add to queue"}
|
|
disabled={!isEligible}
|
|
>
|
|
<ListPlus className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => {
|
|
const queueId = queueMap.get(participant.id);
|
|
if (queueId) onRemoveFromQueue(queueId);
|
|
}}
|
|
title="Remove from queue"
|
|
>
|
|
<ListX className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="min-h-[44px]"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop Table - hidden on mobile */}
|
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
|
<div className="h-full w-full overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="text-left p-3 font-semibold">Participant</th>
|
|
<th className="text-left p-3 font-semibold">Sport</th>
|
|
{hasTeam && (
|
|
<th className="text-right p-3 font-semibold">Queue</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{participants.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={hasTeam ? 3 : 2}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
{emptyMessage}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
participants.map((participant) => {
|
|
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
|
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
title={
|
|
!isEligible && !isDrafted ? ineligibleReason : undefined
|
|
}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!isDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !isDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
{hasTeam && (
|
|
<td className="p-3 text-right">
|
|
<div className="flex gap-2 justify-end items-center">
|
|
{!isDrafted && (
|
|
<>
|
|
{/* Queue icon button */}
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={
|
|
!isEligible
|
|
? ineligibleReason
|
|
: "Add to queue"
|
|
}
|
|
disabled={!isEligible}
|
|
>
|
|
<ListPlus className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const queueId = queueMap.get(participant.id);
|
|
if (queueId) onRemoveFromQueue(queueId);
|
|
}}
|
|
title="Remove from queue"
|
|
>
|
|
<ListX className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
{/* Draft button - always visible, disabled when not your turn */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|