Merge main — replace SignedIn/SignedOut with session in navbar NavLinks
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
878a6cfaf0
12 changed files with 585 additions and 37 deletions
20
.opencode/plugins/oxlint.ts
Normal file
20
.opencode/plugins/oxlint.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export const OxlintPlugin = async ({ $ }: { $: any }) => {
|
||||
return {
|
||||
"file.edited": async ({ filePath }: { filePath: string }) => {
|
||||
if (
|
||||
!filePath.endsWith(".ts") &&
|
||||
!filePath.endsWith(".tsx") &&
|
||||
!filePath.endsWith(".js") &&
|
||||
!filePath.endsWith(".jsx")
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $`npm run lint:path -- ${filePath}`.quiet()
|
||||
} catch (e: any) {
|
||||
throw new Error(`oxlint found issues in ${filePath}:\n${e}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -9,9 +9,9 @@ vi.mock("@tanstack/react-virtual", () => ({
|
|||
estimateSize,
|
||||
}: {
|
||||
count: number;
|
||||
estimateSize: () => number;
|
||||
estimateSize: (index: number) => number;
|
||||
}) => {
|
||||
const size = estimateSize();
|
||||
const size = estimateSize(0);
|
||||
const items = Array.from({ length: count }, (_, i) => ({
|
||||
index: i,
|
||||
start: i * size,
|
||||
|
|
@ -171,6 +171,154 @@ describe("AvailableParticipantsSection", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Projected Pick Dividers", () => {
|
||||
const manyParticipants = Array.from({ length: 30 }, (_, i) => ({
|
||||
id: String(i + 1),
|
||||
name: `Player ${i + 1}`,
|
||||
sport: { id: "s1", name: "NFL" },
|
||||
}));
|
||||
|
||||
const manyRanks = new Map(
|
||||
Array.from({ length: 30 }, (_, i) => [
|
||||
String(i + 1),
|
||||
{ overallRank: i + 1, sportRank: i + 1 },
|
||||
])
|
||||
);
|
||||
|
||||
it("renders projected pick divider at correct position", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
projectedPicks={[
|
||||
{ round: 3, picksFromNow: 10 },
|
||||
{ round: 4, picksFromNow: 20 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
|
||||
expect(screen.getByText("Projected Round 4 Pick")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dividers when projectedPicks is undefined", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render dividers when projectedPicks is empty", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
projectedPicks={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips dividers where picksFromNow exceeds list length", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
projectedPicks={[
|
||||
{ round: 3, picksFromNow: 10 },
|
||||
{ round: 10, picksFromNow: 100 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Projected Round 10 Pick")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips dividers where picksFromNow is zero or negative", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
projectedPicks={[
|
||||
{ round: 1, picksFromNow: 0 },
|
||||
{ round: 2, picksFromNow: 5 },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Projected Round 1 Pick")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Projected Round 2 Pick")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still renders all participants alongside dividers", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("Player 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Player 10").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Player 30").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("suppresses dividers when search query is active", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
searchQuery="Player"
|
||||
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suppresses dividers when sport filter is active", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
sportFilters={["NFL"]}
|
||||
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("suppresses dividers when hideDrafted is active", () => {
|
||||
render(
|
||||
<AvailableParticipantsSection
|
||||
{...defaultProps}
|
||||
participants={manyParticipants}
|
||||
participantRanks={manyRanks}
|
||||
hideDrafted
|
||||
projectedPicks={[{ round: 3, picksFromNow: 10 }]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sport Filter - sheet/popover", () => {
|
||||
it("opens and shows checkboxes for each sport", async () => {
|
||||
render(<AvailableParticipantsSection {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ import {
|
|||
} from "~/components/ui/sheet";
|
||||
import { ListPlus, ListX, ChevronDown } from "lucide-react";
|
||||
|
||||
type ListItem =
|
||||
| { type: "participant"; index: number; key: string }
|
||||
| { type: "divider"; round: number; key: string };
|
||||
|
||||
function getParticipantState(
|
||||
participant: { id: string; sport: { id: string } },
|
||||
draftedParticipantIds: Set<string>,
|
||||
|
|
@ -157,6 +161,7 @@ interface AvailableParticipantsSectionProps {
|
|||
onMakePick: (participantId: string) => void;
|
||||
onAddToQueue: (participantId: string) => void;
|
||||
onRemoveFromQueue: (queueId: string) => void;
|
||||
projectedPicks?: Array<{ round: number; picksFromNow: number }>;
|
||||
}
|
||||
|
||||
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
||||
|
|
@ -183,6 +188,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
onMakePick,
|
||||
onAddToQueue,
|
||||
onRemoveFromQueue,
|
||||
projectedPicks,
|
||||
}: AvailableParticipantsSectionProps) {
|
||||
const queueMap = useMemo(
|
||||
() => new Map(queue.map((item) => [item.participantId, item.id])),
|
||||
|
|
@ -247,13 +253,41 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
|
||||
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
|
||||
|
||||
const anyFilterActive = searchQuery !== "" || sportFilters.length > 0 || hideDrafted || hideIneligible || hideCompletedSports;
|
||||
|
||||
const listItems = useMemo(() => {
|
||||
const items: ListItem[] = [];
|
||||
if (!projectedPicks || projectedPicks.length === 0 || anyFilterActive) {
|
||||
for (let i = 0; i < participants.length; i++) {
|
||||
items.push({ type: "participant", index: i, key: participants[i].id });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
const dividersByAfterIndex = new Map<number, number>();
|
||||
for (const pp of projectedPicks) {
|
||||
if (pp.picksFromNow <= 0) continue;
|
||||
const afterIndex = pp.picksFromNow - 1;
|
||||
if (afterIndex >= 0 && afterIndex < participants.length - 1) {
|
||||
dividersByAfterIndex.set(afterIndex, pp.round);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < participants.length; i++) {
|
||||
items.push({ type: "participant", index: i, key: participants[i].id });
|
||||
const dividerRound = dividersByAfterIndex.get(i);
|
||||
if (dividerRound !== undefined) {
|
||||
items.push({ type: "divider", round: dividerRound, key: `divider-rd${dividerRound}` });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [participants, projectedPicks, anyFilterActive]);
|
||||
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: participants.length,
|
||||
count: listItems.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
estimateSize: (index) => listItems[index]?.type === "divider" ? 36 : 72,
|
||||
overscan: 5,
|
||||
getItemKey: (index) => participants[index]?.id ?? index,
|
||||
getItemKey: (index) => listItems[index].key,
|
||||
});
|
||||
|
||||
const desktopGridClass = hasTeam
|
||||
|
|
@ -400,10 +434,37 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const participant = participants[virtualItem.index];
|
||||
const listItem = listItems[virtualItem.index];
|
||||
|
||||
if (listItem.type === "divider") {
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: virtualItem.start,
|
||||
left: 0,
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-4 py-2">
|
||||
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
|
||||
<span className="text-sm font-semibold text-electric whitespace-nowrap">
|
||||
Projected Round {listItem.round} Pick
|
||||
</span>
|
||||
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const participant = participants[listItem.index];
|
||||
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
||||
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
||||
const rank = participantRanks.get(participant.id);
|
||||
const followsDivider = listItems[virtualItem.index - 1]?.type === "divider";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -510,7 +571,9 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
|||
</div>
|
||||
|
||||
<div
|
||||
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors border-t ${
|
||||
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
|
||||
followsDivider ? "" : "border-t"
|
||||
} ${
|
||||
isDrafted
|
||||
? "bg-muted/50 opacity-60"
|
||||
: !isEligible
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ type Pick = {
|
|||
};
|
||||
|
||||
export const RecentPicksFeed = memo(function RecentPicksFeed({ picks }: { picks: Pick[] }) {
|
||||
const recentPicks = picks.slice(-3).reverse();
|
||||
const recentPicks = picks.slice(-3).toReversed();
|
||||
const prevNewestIdRef = useRef<string | undefined>(picks.at(-1)?.id);
|
||||
const [animatingId, setAnimatingId] = useState<string | undefined>(undefined);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { formatDistanceToNow } from "date-fns";
|
|||
import { Link } from "react-router";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { LeagueAvatar } from "./LeagueAvatar";
|
||||
import { StatColumn, StatDivider, RankChangeIndicator } from "./StatHelpers";
|
||||
import { StatColumn, StatDivider, RankingDisplay } from "./StatHelpers";
|
||||
|
||||
export interface LeagueRowProps {
|
||||
leagueId: string;
|
||||
|
|
@ -10,6 +10,7 @@ export interface LeagueRowProps {
|
|||
numSports: number;
|
||||
status: "draft" | "active" | "pre_draft" | "completed";
|
||||
seasonId?: string;
|
||||
displayRank?: string | number;
|
||||
currentRank?: number;
|
||||
totalPoints?: number;
|
||||
previousRank?: number;
|
||||
|
|
@ -93,6 +94,7 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
|
|||
function ActiveRow({
|
||||
leagueId,
|
||||
leagueName,
|
||||
displayRank,
|
||||
currentRank,
|
||||
totalPoints,
|
||||
previousRank,
|
||||
|
|
@ -116,13 +118,11 @@ function ActiveRow({
|
|||
{/* Stats — second row on mobile, right side on desktop */}
|
||||
{showStats && (
|
||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
{currentRank !== undefined && (
|
||||
<StatColumn label="Ranking">
|
||||
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
|
||||
{previousRank !== undefined && previousRank !== currentRank && (
|
||||
<RankChangeIndicator delta={previousRank - currentRank} />
|
||||
)}
|
||||
</StatColumn>
|
||||
{displayRank !== undefined && (
|
||||
<RankingDisplay
|
||||
displayRank={displayRank}
|
||||
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
||||
/>
|
||||
)}
|
||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
|
||||
{totalPoints !== undefined && (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Button } from "~/components/ui/button";
|
|||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import { StatColumn, StatDivider, RankChangeIndicator, PointChangeIndicator } from "./StatHelpers";
|
||||
import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers";
|
||||
|
||||
// ─── Row styles ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -38,12 +38,7 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
|||
|
||||
{/* Right: stats — second row on mobile */}
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<StatColumn label="Ranking">
|
||||
<span className="text-2xl font-bold leading-none">#{String(entry.displayRank).replace(/^T/, "")}</span>
|
||||
{entry.rankChange !== undefined && entry.rankChange !== 0 && (
|
||||
<RankChangeIndicator delta={entry.rankChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} />
|
||||
<StatDivider />
|
||||
<StatColumn label="Points">
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
|
|
|
|||
|
|
@ -39,6 +39,23 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function RankingDisplay({
|
||||
displayRank,
|
||||
rankChange,
|
||||
}: {
|
||||
displayRank: string | number;
|
||||
rankChange?: number;
|
||||
}) {
|
||||
return (
|
||||
<StatColumn label="Ranking">
|
||||
<span className="text-2xl font-bold leading-none">{displayRank}</span>
|
||||
{rankChange !== undefined && rankChange !== 0 && (
|
||||
<RankChangeIndicator delta={rankChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
);
|
||||
}
|
||||
|
||||
export function PointChangeIndicator({ delta }: { delta: number }) {
|
||||
if (delta >= 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Link, NavLink, useLocation } from "react-router";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
|
|
@ -11,7 +11,19 @@ import { Button } from "~/components/ui/button";
|
|||
import { authClient } from "~/lib/auth-client";
|
||||
import { UserMenu } from "~/components/UserMenu";
|
||||
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
|
||||
import logoUrl from "../../public/logo.svg?url";
|
||||
|
||||
const NAV_LINK_CLASS =
|
||||
"relative text-sm font-semibold uppercase tracking-wide text-muted-foreground px-3 py-2 transition-colors " +
|
||||
"after:content-[''] after:absolute after:bottom-0 after:left-3 after:right-3 after:h-[3px] " +
|
||||
"after:scale-x-0 hover:after:scale-x-100 after:transition-transform after:[background:var(--brackt-gradient)]";
|
||||
|
||||
function navLinkClass({ isActive }: { isActive: boolean }) {
|
||||
return `${NAV_LINK_CLASS}${isActive ? " !text-emerald-400" : ""}`;
|
||||
}
|
||||
|
||||
function mobileNavLinkClass({ isActive }: { isActive: boolean }) {
|
||||
return `block px-4 py-2 text-lg font-medium rounded-md transition-colors${isActive ? " text-emerald-400" : " text-muted-foreground"}`;
|
||||
}
|
||||
|
||||
interface NavbarProps {
|
||||
isAdmin: boolean;
|
||||
|
|
@ -42,11 +54,15 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
{/* Left: Logo + Nav links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to="/" className="flex items-center mr-2">
|
||||
<img src={logoUrl} alt="Brackt" className="h-14" />
|
||||
<img src="/logo.svg" alt="Brackt" className="h-14" />
|
||||
</Link>
|
||||
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
|
||||
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">How To Play</Link>
|
||||
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:[background:var(--brackt-gradient)] px-3 py-2">Rules</Link>
|
||||
{session
|
||||
? <NavLink to="/" end className={navLinkClass}>Dashboard</NavLink>
|
||||
: <NavLink to="/" end className={navLinkClass}>Home</NavLink>
|
||||
}
|
||||
<NavLink to="/how-to-play" className={navLinkClass}>How To Play</NavLink>
|
||||
<NavLink to="/rules" className={navLinkClass}>Rules</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
|
@ -105,20 +121,28 @@ export function Navbar({ isAdmin }: NavbarProps) {
|
|||
<SheetTitle>Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
|
||||
<Link
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{session ? "Dashboard" : "Home"}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/how-to-play"
|
||||
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
How To Play
|
||||
</Link>
|
||||
<Link
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/rules"
|
||||
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
|
||||
className={mobileNavLinkClass}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Rules
|
||||
</Link>
|
||||
</NavLink>
|
||||
</nav>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
|
|
|||
210
app/lib/__tests__/draft-order.test.ts
Normal file
210
app/lib/__tests__/draft-order.test.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order";
|
||||
|
||||
function makeSlots(teamCount: number) {
|
||||
return Array.from({ length: teamCount }, (_, i) => ({
|
||||
draftOrder: i + 1,
|
||||
teamId: `team-${i + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("getTeamForPick", () => {
|
||||
const slots = makeSlots(4);
|
||||
|
||||
it("returns correct team for round 1 (forward)", () => {
|
||||
expect(getTeamForPick(1, slots)?.teamId).toBe("team-1");
|
||||
expect(getTeamForPick(2, slots)?.teamId).toBe("team-2");
|
||||
expect(getTeamForPick(3, slots)?.teamId).toBe("team-3");
|
||||
expect(getTeamForPick(4, slots)?.teamId).toBe("team-4");
|
||||
});
|
||||
|
||||
it("returns correct team for round 2 (snake reversed)", () => {
|
||||
expect(getTeamForPick(5, slots)?.teamId).toBe("team-4");
|
||||
expect(getTeamForPick(6, slots)?.teamId).toBe("team-3");
|
||||
expect(getTeamForPick(7, slots)?.teamId).toBe("team-2");
|
||||
expect(getTeamForPick(8, slots)?.teamId).toBe("team-1");
|
||||
});
|
||||
|
||||
it("returns correct team for round 3 (forward again)", () => {
|
||||
expect(getTeamForPick(9, slots)?.teamId).toBe("team-1");
|
||||
expect(getTeamForPick(10, slots)?.teamId).toBe("team-2");
|
||||
expect(getTeamForPick(11, slots)?.teamId).toBe("team-3");
|
||||
expect(getTeamForPick(12, slots)?.teamId).toBe("team-4");
|
||||
});
|
||||
|
||||
it("returns undefined for empty slots", () => {
|
||||
expect(getTeamForPick(1, [])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectedPicks", () => {
|
||||
it("returns empty array when no draft slots", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: [],
|
||||
userTeamId: "team-1",
|
||||
currentPick: 1,
|
||||
totalRounds: 10,
|
||||
existingPicks: [],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when user has no team in slots", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: makeSlots(4),
|
||||
userTeamId: "team-999",
|
||||
currentPick: 1,
|
||||
totalRounds: 10,
|
||||
existingPicks: [],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
describe("4-team snake draft, user at position 1", () => {
|
||||
const slots = makeSlots(4);
|
||||
|
||||
it("computes picks from start of draft", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-1",
|
||||
currentPick: 1,
|
||||
totalRounds: 4,
|
||||
existingPicks: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 1, pickNumber: 1, picksFromNow: 0 },
|
||||
{ round: 2, pickNumber: 8, picksFromNow: 7 },
|
||||
{ round: 3, pickNumber: 9, picksFromNow: 8 },
|
||||
{ round: 4, pickNumber: 16, picksFromNow: 15 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("computes picks from mid-draft, skipping past picks", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-1",
|
||||
currentPick: 5,
|
||||
totalRounds: 4,
|
||||
existingPicks: [
|
||||
{ pickNumber: 1, teamId: "team-1" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 2, pickNumber: 8, picksFromNow: 3 },
|
||||
{ round: 3, pickNumber: 9, picksFromNow: 4 },
|
||||
{ round: 4, pickNumber: 16, picksFromNow: 11 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips rounds already picked even if current pick is before them", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-1",
|
||||
currentPick: 1,
|
||||
totalRounds: 4,
|
||||
existingPicks: [
|
||||
{ pickNumber: 1, teamId: "team-1" },
|
||||
{ pickNumber: 8, teamId: "team-1" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 3, pickNumber: 9, picksFromNow: 8 },
|
||||
{ round: 4, pickNumber: 16, picksFromNow: 15 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("4-team snake draft, user at position 3", () => {
|
||||
const slots = makeSlots(4);
|
||||
|
||||
it("computes correct snake positions", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-3",
|
||||
currentPick: 1,
|
||||
totalRounds: 4,
|
||||
existingPicks: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 1, pickNumber: 3, picksFromNow: 2 },
|
||||
{ round: 2, pickNumber: 6, picksFromNow: 5 },
|
||||
{ round: 3, pickNumber: 11, picksFromNow: 10 },
|
||||
{ round: 4, pickNumber: 14, picksFromNow: 13 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("4-team snake draft, user at position 4 (last)", () => {
|
||||
const slots = makeSlots(4);
|
||||
|
||||
it("picks first in round 2 due to snake reversal", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-4",
|
||||
currentPick: 1,
|
||||
totalRounds: 4,
|
||||
existingPicks: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 1, pickNumber: 4, picksFromNow: 3 },
|
||||
{ round: 2, pickNumber: 5, picksFromNow: 4 },
|
||||
{ round: 3, pickNumber: 12, picksFromNow: 11 },
|
||||
{ round: 4, pickNumber: 13, picksFromNow: 12 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty when all picks are in the past", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: makeSlots(4),
|
||||
userTeamId: "team-1",
|
||||
currentPick: 17,
|
||||
totalRounds: 4,
|
||||
existingPicks: [
|
||||
{ pickNumber: 1, teamId: "team-1" },
|
||||
{ pickNumber: 8, teamId: "team-1" },
|
||||
{ pickNumber: 9, teamId: "team-1" },
|
||||
{ pickNumber: 16, teamId: "team-1" },
|
||||
],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles 8-team snake draft correctly", () => {
|
||||
const slots = makeSlots(8);
|
||||
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: slots,
|
||||
userTeamId: "team-5",
|
||||
currentPick: 1,
|
||||
totalRounds: 3,
|
||||
existingPicks: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 1, pickNumber: 5, picksFromNow: 4 },
|
||||
{ round: 2, pickNumber: 12, picksFromNow: 11 },
|
||||
{ round: 3, pickNumber: 21, picksFromNow: 20 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes picks before currentPick", () => {
|
||||
const result = getProjectedPicks({
|
||||
draftSlots: makeSlots(4),
|
||||
userTeamId: "team-1",
|
||||
currentPick: 9,
|
||||
totalRounds: 4,
|
||||
existingPicks: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ round: 3, pickNumber: 9, picksFromNow: 0 },
|
||||
{ round: 4, pickNumber: 16, picksFromNow: 7 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -16,3 +16,52 @@ export function getTeamForPick<T extends { draftOrder: number }>(
|
|||
}
|
||||
return draftSlots.find((slot) => slot.draftOrder === pickInRound);
|
||||
}
|
||||
|
||||
export interface ProjectedPick {
|
||||
round: number;
|
||||
pickNumber: number;
|
||||
picksFromNow: number;
|
||||
}
|
||||
|
||||
export function getProjectedPicks(options: {
|
||||
draftSlots: Array<{ draftOrder: number; teamId: string }>;
|
||||
userTeamId: string;
|
||||
currentPick: number;
|
||||
totalRounds: number;
|
||||
existingPicks: Array<{ pickNumber: number; teamId: string }>;
|
||||
}): ProjectedPick[] {
|
||||
const { draftSlots, userTeamId, currentPick, totalRounds, existingPicks } = options;
|
||||
const totalTeams = draftSlots.length;
|
||||
if (totalTeams === 0) return [];
|
||||
|
||||
const userSlot = draftSlots.find((slot) => slot.teamId === userTeamId);
|
||||
if (!userSlot) return [];
|
||||
|
||||
const userDraftOrder = userSlot.draftOrder;
|
||||
const pickedNumbersForTeam = new Set(
|
||||
existingPicks
|
||||
.filter((p) => p.teamId === userTeamId)
|
||||
.map((p) => p.pickNumber)
|
||||
);
|
||||
|
||||
const result: ProjectedPick[] = [];
|
||||
|
||||
for (let round = 1; round <= totalRounds; round++) {
|
||||
const isEvenRound = round % 2 === 0;
|
||||
const pickInRound = isEvenRound
|
||||
? totalTeams - userDraftOrder + 1
|
||||
: userDraftOrder;
|
||||
const pickNumber = (round - 1) * totalTeams + pickInRound;
|
||||
|
||||
if (pickNumber < currentPick) continue;
|
||||
if (pickedNumbersForTeam.has(pickNumber)) continue;
|
||||
|
||||
result.push({
|
||||
round,
|
||||
pickNumber,
|
||||
picksFromNow: pickNumber - currentPick,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
|
|||
import { findTeamByOwnerAndSeason } from "~/models/team";
|
||||
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
|
||||
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
|
||||
import { getTeamStanding } from "~/models/standings";
|
||||
import { getTeamStanding, getSeasonStandings } from "~/models/standings";
|
||||
import { getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
|
||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
|
||||
import { getTeamForPick } from "~/lib/draft-order";
|
||||
import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel";
|
||||
|
|
@ -58,6 +59,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
let currentRank: number | undefined;
|
||||
let totalPoints: number | undefined;
|
||||
let previousRank: number | undefined;
|
||||
let displayRank: string | number | undefined;
|
||||
let completionPercentage = 0;
|
||||
let picksUntilMyTurn: number | undefined;
|
||||
let draftPosition: number | undefined;
|
||||
|
|
@ -101,6 +103,9 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
currentRank = standing.currentRank ?? undefined;
|
||||
totalPoints = standing.totalPoints ?? undefined;
|
||||
previousRank = standing.previousRank ?? undefined;
|
||||
const allStandings = await getSeasonStandings(league.currentSeasonId);
|
||||
const isTied = buildTiedRankChecker(allStandings.map((s) => s.currentRank));
|
||||
displayRank = getDisplayRank(standing, allStandings.length, isTied(standing.currentRank));
|
||||
} else if (myTeam && season?.status === "active") {
|
||||
// No standing row yet (no scoring events) — default to rank 1, 0 points
|
||||
currentRank = 1;
|
||||
|
|
@ -139,6 +144,7 @@ export async function loader(args: Route.LoaderArgs) {
|
|||
...league,
|
||||
currentSeason: season,
|
||||
numSports,
|
||||
displayRank,
|
||||
currentRank,
|
||||
totalPoints,
|
||||
previousRank,
|
||||
|
|
@ -196,6 +202,7 @@ export default function Home({ loaderData }: Route.ComponentProps) {
|
|||
status: isValidStatus(rawStatus) ? rawStatus : "pre_draft",
|
||||
seasonId: league.currentSeasonId ?? undefined,
|
||||
currentRank: league.currentRank,
|
||||
displayRank: league.displayRank,
|
||||
totalPoints: league.totalPoints,
|
||||
previousRank: league.previousRank,
|
||||
completionPercentage: league.completionPercentage,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
|||
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
|
||||
import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getTeamForPick } from "~/lib/draft-order";
|
||||
import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order";
|
||||
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
||||
import { NotificationSettings } from "~/components/NotificationSettings";
|
||||
import { AutodraftBadgeWithPopover } from "~/components/AutodraftSettings";
|
||||
|
|
@ -854,6 +854,20 @@ export default function DraftRoom() {
|
|||
return grid;
|
||||
}, [picks, draftSlots, season.draftRounds]);
|
||||
|
||||
const projectedPicks = useMemo(() => {
|
||||
if (!userTeam || season.status !== "draft" || isDraftComplete) return [];
|
||||
return getProjectedPicks({
|
||||
draftSlots,
|
||||
userTeamId: userTeam.id,
|
||||
currentPick,
|
||||
totalRounds: season.draftRounds,
|
||||
existingPicks: picks.map((p) => ({
|
||||
pickNumber: p.pickNumber,
|
||||
teamId: p.team.id,
|
||||
})),
|
||||
});
|
||||
}, [userTeam, season.status, isDraftComplete, draftSlots, currentPick, season.draftRounds, picks]);
|
||||
|
||||
// Get drafted participant IDs for filtering
|
||||
const draftedParticipantIds = useMemo(
|
||||
() => new Set(picks.map((p) => p.participant.id)),
|
||||
|
|
@ -971,7 +985,8 @@ export default function DraftRoom() {
|
|||
onMakePick: handleMakePick,
|
||||
onAddToQueue: handleAddToQueue,
|
||||
onRemoveFromQueue: handleRemoveFromQueue,
|
||||
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible]);
|
||||
projectedPicks,
|
||||
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks]);
|
||||
|
||||
const draftGridSectionProps = {
|
||||
draftSlots,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue