refactor: address code review findings in Roster/Summary draft views (#57)
- Extract groupPicksByTeamAndSport into shared picks-utils.ts to remove duplication between TeamRosterView and DraftSummaryView - Fix TeamRosterView: make teamPicks a proper useMemo dep on selectedTeamId, add useEffect to reset stale selection if draftSlots changes - Add accessible label/htmlFor to TeamRosterView team Select - Remove dead `season` prop from DraftSummaryView (was inherited from TeamsDraftedGrid but never used) - Fix DraftSummaryView: sport name cells are now <th scope="row">, column headers have scope="col", Total header uses solid bg-muted to match Sport - Replace || null with explicit > 0 check in Total cell for clarity - Consolidate to summaryViewProps/rosterViewProps in route for consistency Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
46332ea735
commit
599bba8949
4 changed files with 309 additions and 14 deletions
114
app/components/draft/DraftSummaryView.tsx
Normal file
114
app/components/draft/DraftSummaryView.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||||
|
|
||||||
|
interface DraftSummaryViewProps {
|
||||||
|
draftSlots: Array<{ id: string; team: { id: string; name: string } }>;
|
||||||
|
ownerMap?: Record<string, string>;
|
||||||
|
picks: DraftPick[];
|
||||||
|
sports: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DraftSummaryView = memo(function DraftSummaryView({
|
||||||
|
draftSlots,
|
||||||
|
ownerMap = {},
|
||||||
|
picks,
|
||||||
|
sports,
|
||||||
|
}: DraftSummaryViewProps) {
|
||||||
|
const picksByTeamAndSport = useMemo(
|
||||||
|
() => groupPicksByTeamAndSport(picks),
|
||||||
|
[picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPicksBySport = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
sports.forEach((sport) => {
|
||||||
|
let count = 0;
|
||||||
|
picksByTeamAndSport.forEach((teamMap) => {
|
||||||
|
count += teamMap.get(sport.id)?.length ?? 0;
|
||||||
|
});
|
||||||
|
map.set(sport.id, count);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [picksByTeamAndSport, sports]);
|
||||||
|
|
||||||
|
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
|
||||||
|
scope="col"
|
||||||
|
className="border-r border-b border-border p-2 text-left font-semibold bg-muted sticky left-0 z-20 min-w-[120px]"
|
||||||
|
>
|
||||||
|
Sport
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="border-r border-b border-border p-2 text-center font-semibold bg-muted min-w-[48px]"
|
||||||
|
>
|
||||||
|
Total
|
||||||
|
</th>
|
||||||
|
{draftSlots.map((slot, index) => {
|
||||||
|
const displayName = ownerMap[slot.team.id] || slot.team.name;
|
||||||
|
const isLast = index === draftSlots.length - 1;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={slot.id}
|
||||||
|
scope="col"
|
||||||
|
className={`border-b border-border p-2 text-center font-semibold bg-muted/50 min-w-[60px] ${!isLast ? "border-r" : ""}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{displayName}</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sports.map((sport, sportIndex) => {
|
||||||
|
const isLastRow = sportIndex === sports.length - 1;
|
||||||
|
const sportTotal = totalPicksBySport.get(sport.id) ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={sport.id}>
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className={`border-r border-border p-2 font-medium text-sm text-left bg-muted sticky left-0 z-10 ${!isLastRow ? "border-b" : ""}`}
|
||||||
|
>
|
||||||
|
{sport.name}
|
||||||
|
</th>
|
||||||
|
<td
|
||||||
|
className={`border-r border-border p-2 text-center text-sm font-semibold bg-muted/30 ${!isLastRow ? "border-b" : ""}`}
|
||||||
|
>
|
||||||
|
{sportTotal > 0 ? sportTotal : null}
|
||||||
|
</td>
|
||||||
|
{draftSlots.map((slot, slotIndex) => {
|
||||||
|
const count =
|
||||||
|
picksByTeamAndSport.get(slot.team.id)?.get(sport.id)
|
||||||
|
?.length ?? 0;
|
||||||
|
const isLast = slotIndex === draftSlots.length - 1;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${slot.team.id}-${sport.id}`}
|
||||||
|
className={`border-border p-2 text-center text-sm ${
|
||||||
|
count >= 2 ? "bg-accent/30 font-semibold" : "bg-background"
|
||||||
|
} ${!isLastRow ? "border-b" : ""} ${!isLast ? "border-r" : ""}`}
|
||||||
|
>
|
||||||
|
{count > 0 ? count : null}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
121
app/components/draft/TeamRosterView.tsx
Normal file
121
app/components/draft/TeamRosterView.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { memo, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "~/components/ui/select";
|
||||||
|
import { type DraftPick, groupPicksByTeamAndSport } from "./picks-utils";
|
||||||
|
|
||||||
|
interface TeamRosterViewProps {
|
||||||
|
draftSlots: Array<{ id: string; team: { id: string; name: string } }>;
|
||||||
|
ownerMap?: Record<string, string>;
|
||||||
|
picks: DraftPick[];
|
||||||
|
sports: Array<{ id: string; name: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TeamRosterView = memo(function TeamRosterView({
|
||||||
|
draftSlots,
|
||||||
|
ownerMap = {},
|
||||||
|
picks,
|
||||||
|
sports,
|
||||||
|
}: TeamRosterViewProps) {
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState(
|
||||||
|
draftSlots[0]?.team.id ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset selection if the selected team is no longer present in the slot list
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
draftSlots.length > 0 &&
|
||||||
|
!draftSlots.some((s) => s.team.id === selectedTeamId)
|
||||||
|
) {
|
||||||
|
setSelectedTeamId(draftSlots[0].team.id);
|
||||||
|
}
|
||||||
|
}, [draftSlots, selectedTeamId]);
|
||||||
|
|
||||||
|
const picksByTeamAndSport = useMemo(
|
||||||
|
() => groupPicksByTeamAndSport(picks),
|
||||||
|
[picks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const teamPicks = useMemo(
|
||||||
|
() => picksByTeamAndSport.get(selectedTeamId),
|
||||||
|
[picksByTeamAndSport, selectedTeamId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sportsWithPicks = useMemo(
|
||||||
|
() => sports.filter((s) => (teamPicks?.get(s.id)?.length ?? 0) > 0),
|
||||||
|
[sports, teamPicks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPicks = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
teamPicks?.forEach((sp) => (count += sp.length));
|
||||||
|
return count;
|
||||||
|
}, [teamPicks]);
|
||||||
|
|
||||||
|
if (draftSlots.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full p-8 text-muted-foreground">
|
||||||
|
<p>No teams in this draft.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label
|
||||||
|
htmlFor="roster-team-select"
|
||||||
|
className="text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
Team
|
||||||
|
</label>
|
||||||
|
<Select value={selectedTeamId} onValueChange={setSelectedTeamId}>
|
||||||
|
<SelectTrigger id="roster-team-select" className="w-full">
|
||||||
|
<SelectValue placeholder="Select a team" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{draftSlots.map((slot) => (
|
||||||
|
<SelectItem key={slot.id} value={slot.team.id}>
|
||||||
|
{ownerMap[slot.team.id] || slot.team.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPicks === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||||||
|
No picks yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
sportsWithPicks.map((sport) => {
|
||||||
|
const sportPicks = teamPicks?.get(sport.id) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={sport.id}>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h3 className="text-sm font-semibold">{sport.name}</h3>
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||||
|
{sportPicks.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1 pl-2">
|
||||||
|
{sportPicks.map((pick) => (
|
||||||
|
<li key={pick.id} className="text-sm text-foreground">
|
||||||
|
{pick.participant.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
19
app/components/draft/picks-utils.ts
Normal file
19
app/components/draft/picks-utils.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export type DraftPick = {
|
||||||
|
id: string;
|
||||||
|
team: { id: string; name: string };
|
||||||
|
participant: { id: string; name: string };
|
||||||
|
sport: { id: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupPicksByTeamAndSport(
|
||||||
|
picks: DraftPick[]
|
||||||
|
): Map<string, Map<string, DraftPick[]>> {
|
||||||
|
const map = new Map<string, Map<string, DraftPick[]>>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,8 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { getTeamQueue } from "~/models/draft-queue";
|
import { getTeamQueue } from "~/models/draft-queue";
|
||||||
import { buildOwnerMap } from "~/lib/owner-map";
|
import { buildOwnerMap } from "~/lib/owner-map";
|
||||||
import { DraftSidebar } from "~/components/DraftSidebar";
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
||||||
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
|
import { TeamRosterView } from "~/components/draft/TeamRosterView";
|
||||||
|
import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
|
||||||
import { QueueSection } from "~/components/draft/QueueSection";
|
import { QueueSection } from "~/components/draft/QueueSection";
|
||||||
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
||||||
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
||||||
|
|
@ -37,7 +38,7 @@ type QueueItem = typeof schema.draftQueue.$inferSelect;
|
||||||
const MOBILE_TABS_BASE = [
|
const MOBILE_TABS_BASE = [
|
||||||
{ id: "available" as const, label: "Available", Icon: Users },
|
{ id: "available" as const, label: "Available", Icon: Users },
|
||||||
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
|
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
|
||||||
{ id: "roster" as const, label: "Roster", Icon: ListChecks },
|
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
|
||||||
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -466,10 +467,11 @@ export default function DraftRoom() {
|
||||||
const stored = localStorage.getItem("draftSidebarCollapsed");
|
const stored = localStorage.getItem("draftSidebarCollapsed");
|
||||||
return stored ? JSON.parse(stored) : false;
|
return stored ? JSON.parse(stored) : false;
|
||||||
});
|
});
|
||||||
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("board");
|
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("board");
|
||||||
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "roster" | "controls">(
|
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
|
||||||
!userTeam && isCommissioner ? "board" : "available"
|
!userTeam && isCommissioner ? "board" : "available"
|
||||||
);
|
);
|
||||||
|
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
|
|
||||||
// Track autodraft status for all teams
|
// Track autodraft status for all teams
|
||||||
|
|
@ -1395,13 +1397,17 @@ export default function DraftRoom() {
|
||||||
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamsDraftedGridSeason = useMemo(() => ({ numFlexPicks }), [numFlexPicks]);
|
const summaryViewProps = {
|
||||||
|
draftSlots,
|
||||||
const teamsDraftedGridProps = {
|
picks,
|
||||||
|
sports: seasonSportsData,
|
||||||
|
ownerMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rosterViewProps = {
|
||||||
draftSlots,
|
draftSlots,
|
||||||
picks,
|
picks,
|
||||||
sports: seasonSportsData,
|
sports: seasonSportsData,
|
||||||
season: teamsDraftedGridSeason,
|
|
||||||
ownerMap,
|
ownerMap,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1584,8 +1590,38 @@ export default function DraftRoom() {
|
||||||
{mobileTab === "board" && (
|
{mobileTab === "board" && (
|
||||||
<DraftGridSection {...draftGridSectionProps} />
|
<DraftGridSection {...draftGridSectionProps} />
|
||||||
)}
|
)}
|
||||||
{mobileTab === "roster" && (
|
{mobileTab === "teams" && (
|
||||||
<TeamsDraftedGrid {...teamsDraftedGridProps} />
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
|
||||||
|
<button
|
||||||
|
onClick={() => setTeamsSubTab("rosters")}
|
||||||
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||||
|
teamsSubTab === "rosters"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Roster
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTeamsSubTab("summary")}
|
||||||
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
||||||
|
teamsSubTab === "summary"
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{teamsSubTab === "rosters" ? (
|
||||||
|
<TeamRosterView {...rosterViewProps} />
|
||||||
|
) : (
|
||||||
|
<DraftSummaryView {...summaryViewProps} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{mobileTab === "controls" && (
|
{mobileTab === "controls" && (
|
||||||
<div className="h-full overflow-y-auto p-4 space-y-6">
|
<div className="h-full overflow-y-auto p-4 space-y-6">
|
||||||
|
|
@ -1649,7 +1685,7 @@ export default function DraftRoom() {
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setActiveTab(value as "participants" | "board" | "teams")
|
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
|
||||||
}
|
}
|
||||||
className="h-full flex flex-col"
|
className="h-full flex flex-col"
|
||||||
>
|
>
|
||||||
|
|
@ -1662,7 +1698,8 @@ export default function DraftRoom() {
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="participants">Available Participants</TabsTrigger>
|
<TabsTrigger value="participants">Available Participants</TabsTrigger>
|
||||||
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
||||||
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
|
<TabsTrigger value="rosters">Rosters</TabsTrigger>
|
||||||
|
<TabsTrigger value="summary">Summary</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
||||||
|
|
@ -1702,8 +1739,12 @@ export default function DraftRoom() {
|
||||||
<DraftGridSection {...draftGridSectionProps} />
|
<DraftGridSection {...draftGridSectionProps} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
|
<TabsContent value="rosters" className="flex-1 overflow-hidden m-0">
|
||||||
<TeamsDraftedGrid {...teamsDraftedGridProps} />
|
<TeamRosterView {...rosterViewProps} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="summary" className="flex-1 overflow-hidden m-0">
|
||||||
|
<DraftSummaryView {...summaryViewProps} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue