diff --git a/app/components/DraftGrid.tsx b/app/components/DraftGrid.tsx index b99c2d3..0573383 100644 --- a/app/components/DraftGrid.tsx +++ b/app/components/DraftGrid.tsx @@ -4,7 +4,8 @@ import { ContextMenuItem, ContextMenuTrigger, } 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 { pickNumber: number; @@ -20,6 +21,7 @@ interface DraftGridProps { team: { id: string; name: string; + logoUrl?: string | null; }; }>; draftGrid: Array>; @@ -33,6 +35,7 @@ interface DraftGridProps { autodraftStatus?: Record; connectedTeams?: Set; ownerMap?: Record; + coronaStates?: Record; } export function DraftGrid({ @@ -48,6 +51,7 @@ export function DraftGrid({ autodraftStatus = {}, connectedTeams = new Set(), ownerMap = {}, + coronaStates = {}, }: DraftGridProps) { const totalTeams = draftSlots.length; @@ -65,6 +69,14 @@ export function DraftGrid({ return (
+
+ +
@@ -94,7 +106,7 @@ export function DraftGrid({
{/* Draft Grid Rows */} -
+
{draftGrid.map((roundPicks, roundIndex) => { const round = roundIndex + 1; const isEvenRound = round % 2 === 0; @@ -129,6 +141,9 @@ export function DraftGrid({ } : undefined } + coronaState={ + cell ? coronaStates[cell.participant.id] : undefined + } /> ); diff --git a/app/components/TeamAvatar.tsx b/app/components/TeamAvatar.tsx new file mode 100644 index 0000000..9cf53c9 --- /dev/null +++ b/app/components/TeamAvatar.tsx @@ -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 ( + {teamName} + ); + } + + 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 ( +
+ {initials} +
+ ); +} diff --git a/app/components/__tests__/DraftGrid.test.tsx b/app/components/__tests__/DraftGrid.test.tsx index ea123ec..1095462 100644 --- a/app/components/__tests__/DraftGrid.test.tsx +++ b/app/components/__tests__/DraftGrid.test.tsx @@ -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('bg-electric/20'); }); diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx index 462bda6..3505dfe 100644 --- a/app/components/draft/DraftGridSection.tsx +++ b/app/components/draft/DraftGridSection.tsx @@ -1,4 +1,5 @@ import { memo, useMemo, useState } from "react"; +import { TeamAvatar } from "~/components/TeamAvatar"; import { ContextMenu, ContextMenuContent, @@ -28,6 +29,7 @@ interface DraftGridSectionProps { team: { id: string; name: string; + logoUrl?: string | null; }; }>; draftGrid: Array< @@ -99,6 +101,14 @@ export const DraftGridSection = memo(function DraftGridSection({ const headerInner = (
+
+ +
{/* Draft Grid Rows */} -
+
{draftGrid.map((roundPicks, roundIndex) => { const round = roundIndex + 1; const isEvenRound = round % 2 === 0; diff --git a/app/components/draft/DraftPickCell.stories.tsx b/app/components/draft/DraftPickCell.stories.tsx index 8d38e44..cd02721 100644 --- a/app/components/draft/DraftPickCell.stories.tsx +++ b/app/components/draft/DraftPickCell.stories.tsx @@ -134,3 +134,107 @@ export const CoronaVariantsRow: Story = {
), }; + +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: () => ( +
+ + + + + + +
+ ), +}; diff --git a/app/components/draft/DraftPickCell.tsx b/app/components/draft/DraftPickCell.tsx index 62a3002..35a6af8 100644 --- a/app/components/draft/DraftPickCell.tsx +++ b/app/components/draft/DraftPickCell.tsx @@ -1,6 +1,11 @@ -import type { ReactNode } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { cn } from "~/lib/utils"; +export type CoronaState = + | { type: "eliminated"; points: 0 } + | { type: "pending" } + | { type: "scored"; brightness: number; points: number }; + type CoronaVariant = | "gradient" | "electric" @@ -18,6 +23,7 @@ interface DraftPickCellProps { sport: { name: string }; }; corona?: CoronaVariant; + coronaState?: CoronaState; className?: string; children?: ReactNode; } @@ -30,6 +36,20 @@ const coronaClasses: Record = { 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({ pickNumber, round, @@ -37,6 +57,7 @@ export function DraftPickCell({ state, pick, corona = "gradient", + coronaState, className, children, }: DraftPickCellProps) { @@ -49,17 +70,22 @@ export function DraftPickCell({ className, )} > - {showCorona && ( + {showCorona && coronaState ? ( +
+ ) : showCorona ? (
- )} + ) : null}
-
- {round}.{String(pickInRound).padStart(2, "0")} -
- {state === "picked" && pick ? ( -
-
{pick.participant.name}
-
- {pick.sport.name} +
+ {state === "picked" && pick ? ( +
+
{pick.participant.name}
+
+ {pick.sport.name} +
+ ) : state === "current" ? ( +
On Clock
+ ) : null} +
+
+ {round}.{String(pickInRound).padStart(2, "0")} +
+ {coronaState && coronaState.type !== "pending" && ( + + {coronaState.type === "scored" && "▲"}{coronaState.points} + + )}
- ) : state === "current" ? ( -
On Clock
- ) : null} +
{children &&
{children}
}
diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx index ffb93f3..f377b36 100644 --- a/app/components/league/StandingsPreview.tsx +++ b/app/components/league/StandingsPreview.tsx @@ -3,47 +3,7 @@ import { Link } from "react-router"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader } from "~/components/ui/card"; import { GradientIcon } from "~/components/ui/GradientIcon"; - -// ─── 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 ( -
- {initials} -
- ); -} +import { TeamAvatar } from "~/components/TeamAvatar"; // ─── Stat helpers (mirrors LeagueRow) ──────────────────────────────────────── diff --git a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx index edd450e..683a49e 100644 --- a/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft-board.$seasonId.tsx @@ -1,5 +1,5 @@ -import { useLoaderData } from "react-router"; -import { eq, asc, and } from "drizzle-orm"; +import { useLoaderData, Link } from "react-router"; +import { eq, asc, and, inArray } from "drizzle-orm"; import { getAuth } from "@clerk/react-router/server"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; @@ -7,6 +7,10 @@ import { DraftGrid } from "~/components/DraftGrid"; import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useState, useEffect } from "react"; 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"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { @@ -92,6 +96,7 @@ export async function loader(args: Route.LoaderArgs) { team: schema.teams, participant: schema.participants, sport: schema.sports, + scoringPattern: schema.sportsSeasons.scoringPattern, }) .from(schema.draftPicks) .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 coronaStates: Record = {}; + + 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(); + 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 { season, draftSlots, draftPicks, ownerMap, + coronaStates, }; } export default function DraftBoard() { - const { season, draftSlots, draftPicks: initialPicks, ownerMap } = useLoaderData(); + const { season, draftSlots, draftPicks: initialPicks, ownerMap, coronaStates } = useLoaderData(); const { isConnected, on, off } = useDraftSocket(season.id); const [picks, setPicks] = useState(initialPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); @@ -157,36 +259,46 @@ export default function DraftBoard() { draftGrid.push(roundPicks); } + const isDraftActive = season.status === "draft"; + const currentRound = totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1; + return (
- {/* Header */}
-
+
-
-

- {season.league.name} - {season.year} Draft Board +
+ + Brackt + +

+ {season.league.name} Draft Board

-
- Round: {totalTeams > 0 ? Math.ceil(currentPick / totalTeams) : 1} - Pick: {currentPick} - - {season.status.replace("_", " ")} - -
- {season.status === "draft" && ( -
-
- - {isConnected ? "Connected" : "Disconnected"} - -
- )} +
+ {isDraftActive && ( +
+ Round {currentRound} + Pick {currentPick} +
+
+ + {isConnected ? "Connected" : "Disconnected"} + +
+
+ )} + +

@@ -198,6 +310,7 @@ export default function DraftBoard() { draftGrid={draftGrid} currentPick={currentPick} ownerMap={ownerMap} + coronaStates={coronaStates} />