Update draft board page
This commit is contained in:
parent
f17d2a3250
commit
c78067327d
8 changed files with 385 additions and 87 deletions
|
|
@ -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
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
61
app/components/TeamAvatar.tsx
Normal file
61
app/components/TeamAvatar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue