Update draft board page

This commit is contained in:
Chris Parsons 2026-04-16 22:00:00 -07:00
parent f17d2a3250
commit c78067327d
8 changed files with 385 additions and 87 deletions

View file

@ -4,7 +4,8 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "~/components/ui/context-menu"; } from "~/components/ui/context-menu";
import { DraftPickCell } from "~/components/draft/DraftPickCell"; import { DraftPickCell, type CoronaState } from "~/components/draft/DraftPickCell";
import { TeamAvatar } from "~/components/TeamAvatar";
interface DraftCell { interface DraftCell {
pickNumber: number; pickNumber: number;
@ -20,6 +21,7 @@ interface DraftGridProps {
team: { team: {
id: string; id: string;
name: string; name: string;
logoUrl?: string | null;
}; };
}>; }>;
draftGrid: Array<Array<DraftCell | null>>; draftGrid: Array<Array<DraftCell | null>>;
@ -33,6 +35,7 @@ interface DraftGridProps {
autodraftStatus?: Record<string, boolean>; autodraftStatus?: Record<string, boolean>;
connectedTeams?: Set<string>; connectedTeams?: Set<string>;
ownerMap?: Record<string, string>; ownerMap?: Record<string, string>;
coronaStates?: Record<string, CoronaState>;
} }
export function DraftGrid({ export function DraftGrid({
@ -48,6 +51,7 @@ export function DraftGrid({
autodraftStatus = {}, autodraftStatus = {},
connectedTeams = new Set(), connectedTeams = new Set(),
ownerMap = {}, ownerMap = {},
coronaStates = {},
}: DraftGridProps) { }: DraftGridProps) {
const totalTeams = draftSlots.length; const totalTeams = draftSlots.length;
@ -65,6 +69,14 @@ export function DraftGrid({
return ( return (
<div key={slot.id} className="flex-1 min-w-32 text-center"> <div key={slot.id} className="flex-1 min-w-32 text-center">
<div className="flex justify-center mb-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
size="md"
/>
</div>
<div <div
className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`} className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}
> >
@ -94,7 +106,7 @@ export function DraftGrid({
</div> </div>
{/* Draft Grid Rows */} {/* Draft Grid Rows */}
<div className="space-y-2"> <div className="space-y-2.5">
{draftGrid.map((roundPicks, roundIndex) => { {draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1; const round = roundIndex + 1;
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;
@ -129,6 +141,9 @@ export function DraftGrid({
} }
: undefined : undefined
} }
coronaState={
cell ? coronaStates[cell.participant.id] : undefined
}
/> />
); );

View file

@ -0,0 +1,61 @@
const TEAM_AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashTeamId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
interface TeamAvatarProps {
teamId: string;
teamName: string;
logoUrl?: string | null;
size?: "sm" | "md" | "lg";
}
const sizeClasses = {
sm: "h-6 w-6 text-[10px]",
md: "h-9 w-9 text-sm",
lg: "h-12 w-12 text-base",
};
export function TeamAvatar({ teamId, teamName, logoUrl, size = "md" }: TeamAvatarProps) {
if (logoUrl) {
return (
<img
src={logoUrl}
alt={teamName}
className={`${sizeClasses[size]} object-cover shrink-0`}
/>
);
}
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
const initials =
teamName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={teamName}
className={`${sizeClasses[size]} flex shrink-0 items-center justify-center font-bold`}
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}

View file

@ -88,7 +88,7 @@ describe('DraftGrid Component', () => {
/> />
); );
const currentPickCell = screen.getByText('On Clock').parentElement; const currentPickCell = screen.getByText('On Clock').closest('[class*="border-electric"]');
expect(currentPickCell).toHaveClass('border-electric'); expect(currentPickCell).toHaveClass('border-electric');
expect(currentPickCell).toHaveClass('bg-electric/20'); expect(currentPickCell).toHaveClass('bg-electric/20');
}); });

View file

@ -1,4 +1,5 @@
import { memo, useMemo, useState } from "react"; import { memo, useMemo, useState } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -28,6 +29,7 @@ interface DraftGridSectionProps {
team: { team: {
id: string; id: string;
name: string; name: string;
logoUrl?: string | null;
}; };
}>; }>;
draftGrid: Array< draftGrid: Array<
@ -99,6 +101,14 @@ export const DraftGridSection = memo(function DraftGridSection({
const headerInner = ( const headerInner = (
<div className="relative"> <div className="relative">
<div className="flex justify-center mb-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
size="sm"
/>
</div>
<div <div
className={`font-semibold text-sm truncate px-2 ${ className={`font-semibold text-sm truncate px-2 ${
slot.team.id === currentTeamId slot.team.id === currentTeamId
@ -164,7 +174,7 @@ export const DraftGridSection = memo(function DraftGridSection({
</div> </div>
{/* Draft Grid Rows */} {/* Draft Grid Rows */}
<div className="space-y-2"> <div className="space-y-2.5">
{draftGrid.map((roundPicks, roundIndex) => { {draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1; const round = roundIndex + 1;
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;

View file

@ -134,3 +134,107 @@ export const CoronaVariantsRow: Story = {
</div> </div>
), ),
}; };
export const CoronaStateEliminated: Story = {
name: "Corona State — Eliminated (0 pts)",
args: {
...basePickArgs,
coronaState: { type: "eliminated", points: 0 },
},
};
export const CoronaStatePending: Story = {
name: "Corona State — Pending (no result)",
args: {
...basePickArgs,
coronaState: { type: "pending" },
},
};
export const CoronaStateScored100: Story = {
name: "Corona State — Scored 100%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 1, points: 100 },
},
};
export const CoronaStateScored70: Story = {
name: "Corona State — Scored 70%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.7, points: 70 },
},
};
export const CoronaStateScored45: Story = {
name: "Corona State — Scored 45%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.45, points: 45 },
},
};
export const CoronaStateScored25: Story = {
name: "Corona State — Scored 25%",
args: {
...basePickArgs,
coronaState: { type: "scored", brightness: 0.25, points: 25 },
},
};
export const CoronaStatesRow: Story = {
name: "Corona States — All Side by Side",
render: () => (
<div className="flex gap-2 items-stretch">
<DraftPickCell
pickNumber={1}
round={1}
pickInRound={1}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "eliminated", points: 0 }}
/>
<DraftPickCell
pickNumber={2}
round={1}
pickInRound={2}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "pending" }}
/>
<DraftPickCell
pickNumber={3}
round={1}
pickInRound={3}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 1, points: 100 }}
/>
<DraftPickCell
pickNumber={4}
round={1}
pickInRound={4}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.7, points: 70 }}
/>
<DraftPickCell
pickNumber={5}
round={1}
pickInRound={5}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.45, points: 45 }}
/>
<DraftPickCell
pickNumber={6}
round={1}
pickInRound={6}
state="picked"
pick={basePickArgs.pick}
coronaState={{ type: "scored", brightness: 0.25, points: 25 }}
/>
</div>
),
};

View file

@ -1,6 +1,11 @@
import type { ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export type CoronaState =
| { type: "eliminated"; points: 0 }
| { type: "pending" }
| { type: "scored"; brightness: number; points: number };
type CoronaVariant = type CoronaVariant =
| "gradient" | "gradient"
| "electric" | "electric"
@ -18,6 +23,7 @@ interface DraftPickCellProps {
sport: { name: string }; sport: { name: string };
}; };
corona?: CoronaVariant; corona?: CoronaVariant;
coronaState?: CoronaState;
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
} }
@ -30,6 +36,20 @@ const coronaClasses: Record<CoronaVariant, string> = {
coral: "bg-coral-accent", coral: "bg-coral-accent",
}; };
function getCoronaStyle(cs: CoronaState): CSSProperties {
switch (cs.type) {
case "eliminated":
return { background: "oklch(0.45 0.20 25)" };
case "pending":
return { background: "rgba(255, 255, 255, 0.08)" };
case "scored":
return {
background: "linear-gradient(to bottom, #adf661, #2ce1c1)",
opacity: cs.brightness,
};
}
}
export function DraftPickCell({ export function DraftPickCell({
pickNumber, pickNumber,
round, round,
@ -37,6 +57,7 @@ export function DraftPickCell({
state, state,
pick, pick,
corona = "gradient", corona = "gradient",
coronaState,
className, className,
children, children,
}: DraftPickCellProps) { }: DraftPickCellProps) {
@ -49,17 +70,22 @@ export function DraftPickCell({
className, className,
)} )}
> >
{showCorona && ( {showCorona && coronaState ? (
<div
className="absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg"
style={getCoronaStyle(coronaState)}
/>
) : showCorona ? (
<div <div
className={cn( className={cn(
"absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg", "absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg",
coronaClasses[corona], coronaClasses[corona],
)} )}
/> />
)} ) : null}
<div <div
className={cn( className={cn(
"relative z-10 h-20 border-2 rounded-lg p-2 transition-all", "relative z-10 h-14 border-2 rounded-lg p-2 transition-all",
showCorona && "mr-1", showCorona && "mr-1",
state === "current" state === "current"
? "border-electric bg-electric/20 shadow-lg shadow-electric/30 ring-2 ring-electric/50 animate-pulse" ? "border-electric bg-electric/20 shadow-lg shadow-electric/30 ring-2 ring-electric/50 animate-pulse"
@ -69,19 +95,28 @@ export function DraftPickCell({
)} )}
title={`Overall Pick #${pickNumber}`} title={`Overall Pick #${pickNumber}`}
> >
<div className="text-xs font-mono text-muted-foreground mb-1"> <div className="flex gap-1 items-start">
{round}.{String(pickInRound).padStart(2, "0")} {state === "picked" && pick ? (
</div> <div className="text-sm min-w-0 flex-1">
{state === "picked" && pick ? ( <div className="font-semibold truncate text-sm">{pick.participant.name}</div>
<div className="text-xs"> <div className="text-muted-foreground truncate text-xs">
<div className="font-semibold truncate">{pick.participant.name}</div> {pick.sport.name}
<div className="text-muted-foreground truncate"> </div>
{pick.sport.name}
</div> </div>
) : state === "current" ? (
<div className="text-sm font-bold text-electric">On Clock</div>
) : null}
<div className="flex flex-col items-end shrink-0">
<div className="text-xs font-mono text-muted-foreground leading-5">
{round}.{String(pickInRound).padStart(2, "0")}
</div>
{coronaState && coronaState.type !== "pending" && (
<span className={`text-xs font-mono font-bold mt-0.5 ${coronaState.type === "eliminated" ? "text-coral-accent" : "text-emerald-400"}`}>
{coronaState.type === "scored" && "▲"}{coronaState.points}
</span>
)}
</div> </div>
) : state === "current" ? ( </div>
<div className="text-sm font-bold text-electric">On Clock</div>
) : null}
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>} {children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}
</div> </div>
</div> </div>

View file

@ -3,47 +3,7 @@ import { Link } from "react-router";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { Card, CardContent, CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon"; import { GradientIcon } from "~/components/ui/GradientIcon";
import { TeamAvatar } from "~/components/TeamAvatar";
// ─── TeamAvatar ───────────────────────────────────────────────────────────────
const TEAM_AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashTeamId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
function TeamAvatar({ teamId, teamName }: { teamId: string; teamName: string }) {
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
const initials =
teamName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={teamName}
className="h-9 w-9 flex shrink-0 items-center justify-center font-bold text-sm"
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}
// ─── Stat helpers (mirrors LeagueRow) ──────────────────────────────────────── // ─── Stat helpers (mirrors LeagueRow) ────────────────────────────────────────

View file

@ -1,5 +1,5 @@
import { useLoaderData } from "react-router"; import { useLoaderData, Link } from "react-router";
import { eq, asc, and } from "drizzle-orm"; import { eq, asc, and, inArray } from "drizzle-orm";
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
@ -7,6 +7,10 @@ import { DraftGrid } from "~/components/DraftGrid";
import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { buildOwnerMap } from "~/lib/owner-map"; import { buildOwnerMap } from "~/lib/owner-map";
import { Button } from "~/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { calculateFantasyPoints, calculateBracketPoints } from "~/models/scoring-rules";
import type { CoronaState } from "~/components/draft/DraftPickCell";
import type { Route } from "./+types/$leagueId.draft-board.$seasonId"; import type { Route } from "./+types/$leagueId.draft-board.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
@ -92,6 +96,7 @@ export async function loader(args: Route.LoaderArgs) {
team: schema.teams, team: schema.teams,
participant: schema.participants, participant: schema.participants,
sport: schema.sports, sport: schema.sports,
scoringPattern: schema.sportsSeasons.scoringPattern,
}) })
.from(schema.draftPicks) .from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
@ -109,16 +114,113 @@ export async function loader(args: Route.LoaderArgs) {
const ownerMap = await buildOwnerMap(draftSlots); const ownerMap = await buildOwnerMap(draftSlots);
const coronaStates: Record<string, CoronaState> = {};
if (draftPicks.length > 0) {
const participantIds = draftPicks.map((p) => p.participant.id);
const sportsSeasonIds = [
...new Set(draftPicks.map((p) => p.participant.sportsSeasonId)),
];
const results = await db
.select({
participantId: schema.participantResults.participantId,
sportsSeasonId: schema.participantResults.sportsSeasonId,
finalPosition: schema.participantResults.finalPosition,
isPartialScore: schema.participantResults.isPartialScore,
})
.from(schema.participantResults)
.where(inArray(schema.participantResults.participantId, participantIds));
const resultByParticipant = new Map(
results.map((r) => [r.participantId, r])
);
const maxPoints = season.pointsFor1st;
const bracketTemplateBySportsSeason = new Map<string, string | null>();
if (sportsSeasonIds.length > 0) {
const events = await db
.select({
sportsSeasonId: schema.scoringEvents.sportsSeasonId,
bracketTemplateId: schema.scoringEvents.bracketTemplateId,
})
.from(schema.scoringEvents)
.where(inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds));
for (const ev of events) {
if (!bracketTemplateBySportsSeason.has(ev.sportsSeasonId)) {
bracketTemplateBySportsSeason.set(
ev.sportsSeasonId,
ev.bracketTemplateId
);
}
}
}
const scoringRules = {
pointsFor1st: season.pointsFor1st,
pointsFor2nd: season.pointsFor2nd,
pointsFor3rd: season.pointsFor3rd,
pointsFor4th: season.pointsFor4th,
pointsFor5th: season.pointsFor5th,
pointsFor6th: season.pointsFor6th,
pointsFor7th: season.pointsFor7th,
pointsFor8th: season.pointsFor8th,
};
for (const pick of draftPicks) {
const result = resultByParticipant.get(pick.participant.id);
if (!result || result.finalPosition === null) {
coronaStates[pick.participant.id] = { type: "pending" };
continue;
}
if (result.finalPosition === 0 && !result.isPartialScore) {
coronaStates[pick.participant.id] = { type: "eliminated", points: 0 };
continue;
}
if (result.finalPosition > 0) {
const isBracket =
pick.scoringPattern === "playoff_bracket";
const templateId = isBracket
? bracketTemplateBySportsSeason.get(
pick.participant.sportsSeasonId
) ?? null
: null;
const points = isBracket
? calculateBracketPoints(
result.finalPosition,
scoringRules,
templateId
)
: calculateFantasyPoints(result.finalPosition, scoringRules);
const brightness =
maxPoints > 0 ? Math.min(points / maxPoints, 1) : 0;
coronaStates[pick.participant.id] = {
type: "scored",
brightness,
points,
};
continue;
}
coronaStates[pick.participant.id] = { type: "pending" };
}
}
return { return {
season, season,
draftSlots, draftSlots,
draftPicks, draftPicks,
ownerMap, ownerMap,
coronaStates,
}; };
} }
export default function DraftBoard() { export default function DraftBoard() {
const { season, draftSlots, draftPicks: initialPicks, ownerMap } = useLoaderData<typeof loader>(); const { season, draftSlots, draftPicks: initialPicks, ownerMap, coronaStates } = useLoaderData<typeof loader>();
const { isConnected, on, off } = useDraftSocket(season.id); const { isConnected, on, off } = useDraftSocket(season.id);
const [picks, setPicks] = useState(initialPicks); const [picks, setPicks] = useState(initialPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
@ -157,36 +259,46 @@ export default function DraftBoard() {
draftGrid.push(roundPicks); draftGrid.push(roundPicks);
} }
const isDraftActive = season.status === "draft";
const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1;
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header */}
<div className="border-b bg-card sticky top-0 z-10"> <div className="border-b bg-card sticky top-0 z-10">
<div className="w-full px-4 py-4"> <div className="w-full px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold"> <Link to="/">
{season.league.name} - {season.year} Draft Board <img src="/logomark.svg" alt="Brackt" className="h-8" />
</Link>
<h1 className="text-2xl font-bold">
{season.league.name} Draft Board
</h1> </h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</div>
</div> </div>
{season.status === "draft" && ( <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> {isDraftActive && (
<div <div className="flex items-center gap-3 text-sm text-muted-foreground">
className={`w-3 h-3 rounded-full ${ <span>Round {currentRound}</span>
isConnected ? "bg-emerald-500" : "bg-coral-accent" <span>Pick {currentPick}</span>
}`} <div className="flex items-center gap-2">
/> <div
<span className="text-sm font-medium"> className={`w-2.5 h-2.5 rounded-full ${
{isConnected ? "Connected" : "Disconnected"} isConnected ? "bg-emerald-500" : "bg-coral-accent"
</span> }`}
</div> />
)} <span className="font-medium">
{isConnected ? "Connected" : "Disconnected"}
</span>
</div>
</div>
)}
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${season.leagueId}`}>
<ArrowLeft className="mr-1.5 h-4 w-4" />
Back to League
</Link>
</Button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -198,6 +310,7 @@ export default function DraftBoard() {
draftGrid={draftGrid} draftGrid={draftGrid}
currentPick={currentPick} currentPick={currentPick}
ownerMap={ownerMap} ownerMap={ownerMap}
coronaStates={coronaStates}
/> />
</div> </div>
</div> </div>