Draft room improvements.

This commit is contained in:
Chris Parsons 2026-04-17 08:47:53 -07:00
parent c78067327d
commit 32bfbf2564
6 changed files with 377 additions and 67 deletions

View file

@ -9,6 +9,7 @@ interface NotificationSettingsProps {
mode: NotificationMode;
onModeChange: (mode: NotificationMode) => void;
permissionState: NotificationPermission | "unsupported";
switchOnly?: boolean;
}
export function NotificationSettings({
@ -17,11 +18,46 @@ export function NotificationSettings({
mode,
onModeChange,
permissionState,
switchOnly = false,
}: NotificationSettingsProps) {
if (permissionState === "unsupported") {
return null;
}
if (switchOnly) {
return (
<>
<Switch
checked={enabled}
onCheckedChange={onEnabledChange}
disabled={permissionState === "denied"}
/>
{enabled && (
<div className="absolute top-full right-0 mt-2 bg-card border border-border rounded-lg shadow-lg p-3 z-50 min-w-[180px]">
<RadioGroup
value={mode}
onValueChange={(value) => onModeChange(value as NotificationMode)}
className="space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="my_turn" id="notif_header_my_turn" />
<Label htmlFor="notif_header_my_turn" className="text-sm cursor-pointer">
My Turn Only
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all_picks" id="notif_header_all_picks" />
<Label htmlFor="notif_header_all_picks" className="text-sm cursor-pointer">
All Picks
</Label>
</div>
</RadioGroup>
</div>
)}
</>
);
}
return (
<div>
<div className="flex items-center justify-between">

View file

@ -29,6 +29,11 @@ const defaultProps = {
onMakePick: vi.fn(),
onAddToQueue: vi.fn(),
onRemoveFromQueue: vi.fn(),
participantRanks: new Map([
["1", { overallRank: 1, sportRank: 1 }],
["2", { overallRank: 2, sportRank: 1 }],
["3", { overallRank: 3, sportRank: 2 }],
]),
};
// Both the mobile Sheet trigger and desktop Popover trigger are rendered in jsdom

View file

@ -2,6 +2,7 @@ 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 { MiniDraftGrid } from "~/components/draft/MiniDraftGrid";
import {
Popover,
PopoverTrigger,
@ -130,6 +131,29 @@ interface AvailableParticipantsSectionProps {
name: string;
};
}>;
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
miniDraftGrid?: {
draftSlots: Array<{
id: string;
draftOrder: number;
team: { id: string; name: string; logoUrl?: string | null };
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: { name: string };
sport: { name: string };
};
}>
>;
currentPick: number;
currentRound: number;
ownerMap?: Record<string, string>;
};
searchQuery: string;
sportFilters: string[];
hideDrafted: boolean;
@ -157,6 +181,8 @@ interface AvailableParticipantsSectionProps {
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
participants,
participantRanks,
miniDraftGrid,
searchQuery,
sportFilters,
hideDrafted,
@ -243,6 +269,11 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
return (
<div className="flex flex-col h-full">
{miniDraftGrid && (
<div className="px-4 pt-4 pb-2 flex-shrink-0 border-b">
<MiniDraftGrid {...miniDraftGrid} />
</div>
)}
<div className="px-4 pt-4 pb-2 flex-shrink-0">
<div className="space-y-2">
{/* Search Input */}
@ -395,6 +426,9 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<Badge variant="outline" className="text-xs">
{participant.sport.name}
</Badge>
<span className="text-xs text-muted-foreground tabular-nums">
OVR {participantRanks.get(participant.id)?.overallRank} · SPR {participantRanks.get(participant.id)?.sportRank}
</span>
{isDrafted && (
<Badge variant="secondary" className="text-xs">
Drafted
@ -473,6 +507,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-center p-3 px-0 font-semibold">OVR</th>
<th className="text-center p-3 px-0 font-semibold">SPR</th>
<th className="text-left p-3 font-semibold">Participant</th>
<th className="text-left p-3 font-semibold">Sport</th>
{hasTeam && (
@ -484,7 +520,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
{participants.length === 0 ? (
<tr>
<td
colSpan={hasTeam ? 3 : 2}
colSpan={hasTeam ? 5 : 4}
className="text-center py-8 text-muted-foreground"
>
{emptyMessage}
@ -509,6 +545,12 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
!isEligible && !isDrafted ? ineligibleReason : undefined
}
>
<td className="p-3 px-0 text-center text-muted-foreground tabular-nums">
{participantRanks.get(participant.id)?.overallRank}
</td>
<td className="p-3 px-0 text-center text-muted-foreground tabular-nums">
{participantRanks.get(participant.id)?.sportRank}
</td>
<td className="p-3">
<div className="flex items-center gap-2">
<span

View file

@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MiniDraftGrid } from "./MiniDraftGrid";
const meta: Meta<typeof MiniDraftGrid> = {
title: "Draft/MiniDraftGrid",
component: MiniDraftGrid,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof MiniDraftGrid>;
const teamNames = ["Alpha", "Bravo", "Charlie", "Delta"];
const ownerMap: Record<string, string> = {};
const draftSlots = teamNames.map((name, i) => {
const id = `team-${i + 1}`;
ownerMap[id] = name;
return { id, draftOrder: i + 1, team: { id, name, logoUrl: null } };
});
function buildRound(round: number, teamCount: number, picks: Array<number | null>) {
const isEvenRound = round % 2 === 0;
return Array.from({ length: teamCount }, (_, i) => {
const teamIndex = isEvenRound ? teamCount - 1 - i : i;
const pickNumber = (round - 1) * teamCount + i + 1;
const pick = picks[i];
return {
pickNumber,
round,
pickInRound: i + 1,
teamId: draftSlots[teamIndex].id,
...(pick !== null
? {
pick: {
participant: { name: `Participant ${pick}` },
sport: { name: ["NFL", "NBA", "NHL", "MLB"][pick % 4] },
},
}
: {}),
};
});
}
const fullGrid = [
buildRound(1, 4, [1, 2, 3, 4]),
buildRound(2, 4, [5, 6, null, null]),
buildRound(3, 4, [null, null, null, null]),
buildRound(4, 4, [null, null, null, null]),
];
export const Round1ShowsFirstTwo: Story = {
name: "Round 1 — Shows Rounds 1 & 2",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 1,
currentRound: 1,
ownerMap,
},
};
export const Round2: Story = {
name: "Round 2 — Shows Rounds 1 & 2",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 5,
currentRound: 2,
ownerMap,
},
};
export const Round3: Story = {
name: "Round 3 — Shows Rounds 2 & 3",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 9,
currentRound: 3,
ownerMap,
},
};
export const Round4: Story = {
name: "Round 4 — Shows Rounds 3 & 4",
args: {
draftSlots,
draftGrid: fullGrid,
currentPick: 13,
currentRound: 4,
ownerMap,
},
};
export const AllPicked: Story = {
name: "Both Rounds Fully Picked",
args: {
draftSlots,
draftGrid: [
buildRound(1, 4, [1, 2, 3, 4]),
buildRound(2, 4, [5, 6, 7, 8]),
buildRound(3, 4, [null, null, null, null]),
],
currentPick: 9,
currentRound: 3,
ownerMap,
},
};
export const SixTeams: Story = {
name: "6 Teams — Snake Draft",
args: (() => {
const names6 = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"];
const ownerMap6: Record<string, string> = {};
const slots6 = names6.map((name, i) => {
const id = `team-${i + 1}`;
ownerMap6[id] = name;
return { id, draftOrder: i + 1, team: { id, name, logoUrl: null } };
});
const grid6 = [
buildRound(1, 6, [1, 2, 3, 4, 5, 6]),
buildRound(2, 6, [7, 8, null, null, null, null]),
];
return {
draftSlots: slots6,
draftGrid: grid6,
currentPick: 9,
currentRound: 2,
ownerMap: ownerMap6,
};
})(),
};
export const EmptyGrid: Story = {
name: "Empty Grid",
args: {
draftSlots,
draftGrid: [],
currentPick: 1,
currentRound: 1,
ownerMap,
},
};

View file

@ -0,0 +1,112 @@
import { memo, useMemo } from "react";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
interface MiniDraftGridProps {
draftSlots: Array<{
id: string;
draftOrder: number;
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: {
name: string;
};
sport: {
name: string;
};
};
}>
>;
currentPick: number;
currentRound: number;
ownerMap?: Record<string, string>;
}
export const MiniDraftGrid = memo(function MiniDraftGrid({
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap = {},
}: MiniDraftGridProps) {
const roundsToShow = useMemo(() => {
if (currentRound <= 1) return [0, 1];
return [currentRound - 2, currentRound - 1];
}, [currentRound]);
if (draftGrid.length === 0) return null;
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
<div className="flex gap-1.5 mb-1.5">
<div className="w-7 flex-shrink-0" />
{draftSlots.map((slot) => (
<div key={slot.id} className="flex-1 min-w-20 text-center">
<div className="text-xs font-medium truncate px-1 text-muted-foreground">
{ownerMap[slot.team.id] || slot.team.name}
</div>
</div>
))}
</div>
<div className="space-y-1.5">
{roundsToShow.map((roundIndex) => {
if (roundIndex >= draftGrid.length) return null;
const roundPicks = draftGrid[roundIndex];
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound
? [...roundPicks].toReversed()
: roundPicks;
return (
<div key={round} className="flex gap-1.5 items-stretch">
<div className="w-7 flex-shrink-0 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 cellState = isPicked
? "picked"
: isCurrent
? "current"
: "upcoming";
return (
<DraftPickCell
key={cell.pickNumber}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={
cell.pick
? {
participant: { name: cell.pick.participant.name },
sport: { name: cell.pick.sport.name },
}
: undefined
}
className="[&>div]:h-10"
/>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
});

View file

@ -643,6 +643,17 @@ export default function DraftRoom() {
);
}, [availableParticipants]);
const participantRanks = useMemo(() => {
const ranks = new Map<string, { overallRank: number; sportRank: number }>();
const sportCounters = new Map<string, number>();
availableParticipants.forEach((p, i) => {
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
sportCounters.set(p.sport.id, sportIdx);
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
});
return ranks;
}, [availableParticipants]);
// Calculate draft eligibility for current user's team
const eligibility = useMemo(() => {
if (!userTeam) return null;
@ -1493,6 +1504,14 @@ export default function DraftRoom() {
const availableParticipantsSectionProps = {
participants: filteredParticipants,
participantRanks,
miniDraftGrid: {
draftSlots,
draftGrid,
currentPick,
currentRound,
ownerMap,
},
searchQuery,
sportFilters,
hideDrafted,
@ -1626,20 +1645,25 @@ export default function DraftRoom() {
</svg>
</Button>
)}
<div>
<h1 className="text-lg md:text-3xl font-bold">
{season.league.name} - {season.year} Draft
</h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {currentRound}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</div>
</div>
<Link to="/">
<img src="/logomark.svg" alt="Brackt" className="h-8" />
</Link>
<h1 className="text-lg md:text-xl font-bold">
{season.league.name} Draft Room
</h1>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Pick Notifications</span>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
switchOnly
/>
</div>
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
{isCommissioner && season.status === "pre_draft" && (
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
@ -1659,16 +1683,6 @@ export default function DraftRoom() {
</div>
)}
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isConnected ? "bg-emerald-500" : "bg-coral-accent"
}`}
/>
<span className="hidden sm:inline text-sm font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
<Button variant="outline" asChild className="hidden md:flex">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
@ -1783,14 +1797,6 @@ export default function DraftRoom() {
<SidebarRecentPicks picks={picks} />
</section>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
/>
<Button variant="outline" asChild className="w-full min-h-[48px]">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
@ -1805,15 +1811,6 @@ export default function DraftRoom() {
onCollapsedChange={setSidebarCollapsed}
queueSection={<QueueSection {...queueSectionProps} />}
recentPicksSection={<SidebarRecentPicks picks={picks} />}
settingsSection={
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
/>
}
/>
)}
@ -1837,33 +1834,6 @@ export default function DraftRoom() {
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList>
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div className={`ml-auto flex items-center gap-2.5 rounded-lg px-3 py-1.5 flex-shrink-0 transition-all ${
isMyTurn
? "bg-electric/30 border-2 border-electric shadow-md shadow-electric/30"
: "border border-border/50"
}`}>
<div className="leading-tight">
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
{isMyTurn ? "Your Turn!" : "On the Clock"}
</div>
<div className={`text-sm font-bold ${isMyTurn ? "text-foreground" : "text-muted-foreground"}`}>
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
</div>
{currentDraftSlotOwnerName && (
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
)}
</div>
{isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span>
) : (
<span className={`text-2xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)}
</span>
)}
</div>
)}
</div>
</div>