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:
Chris Parsons 2026-04-24 21:47:14 -07:00
commit 878a6cfaf0
12 changed files with 585 additions and 37 deletions

View 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}`)
}
},
}
}

View file

@ -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} />);

View file

@ -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

View file

@ -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);

View file

@ -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 && (

View file

@ -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">

View file

@ -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 (

View file

@ -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>

View 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 },
]);
});
});

View file

@ -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;
}

View file

@ -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,

View file

@ -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,