({
+ 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 },
+ ]);
+ });
+});
diff --git a/app/lib/draft-order.ts b/app/lib/draft-order.ts
index e474bca..7503245 100644
--- a/app/lib/draft-order.ts
+++ b/app/lib/draft-order.ts
@@ -16,3 +16,52 @@ export function getTeamForPick
(
}
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;
+}
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
index af39dac..5633dbc 100644
--- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
@@ -35,7 +35,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";
@@ -857,6 +857,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)),
@@ -974,7 +988,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,
From e8e49814653afe997d6ef822e4600b58367398ff Mon Sep 17 00:00:00 2001
From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com>
Date: Fri, 24 Apr 2026 11:11:20 -0700
Subject: [PATCH 2/2] Add opencode oxlint plugin and UI refinements (#323)
* Add opencode oxlint plugin and UI refinements
- Add .opencode/plugins/oxlint.ts to run oxlint automatically on file edits
- Refactor RankingDisplay into shared StatHelpers component
- Add tied rank display support in home loader
- Update navbar with NavLink for active state styling
- Replace .reverse() with .toReversed() in RecentPicksFeed
* Remove unused RankChangeIndicator import in LeagueRow
---
.opencode/plugins/oxlint.ts | 20 ++++++++
app/components/draft/RecentPicksFeed.tsx | 2 +-
app/components/league/LeagueRow.tsx | 16 +++---
app/components/league/StandingsPreview.tsx | 9 +---
app/components/league/StatHelpers.tsx | 17 ++++++
app/components/navbar.tsx | 60 ++++++++++++++++++----
app/routes/home.tsx | 9 +++-
7 files changed, 105 insertions(+), 28 deletions(-)
create mode 100644 .opencode/plugins/oxlint.ts
diff --git a/.opencode/plugins/oxlint.ts b/.opencode/plugins/oxlint.ts
new file mode 100644
index 0000000..365eaa9
--- /dev/null
+++ b/.opencode/plugins/oxlint.ts
@@ -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}`)
+ }
+ },
+ }
+}
diff --git a/app/components/draft/RecentPicksFeed.tsx b/app/components/draft/RecentPicksFeed.tsx
index 13ca77f..959836b 100644
--- a/app/components/draft/RecentPicksFeed.tsx
+++ b/app/components/draft/RecentPicksFeed.tsx
@@ -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(picks.at(-1)?.id);
const [animatingId, setAnimatingId] = useState(undefined);
diff --git a/app/components/league/LeagueRow.tsx b/app/components/league/LeagueRow.tsx
index a0414b6..0784d69 100644
--- a/app/components/league/LeagueRow.tsx
+++ b/app/components/league/LeagueRow.tsx
@@ -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 && (
- {currentRank !== undefined && (
-
- #{currentRank}
- {previousRank !== undefined && previousRank !== currentRank && (
-
- )}
-
+ {displayRank !== undefined && (
+
)}
{currentRank !== undefined && totalPoints !== undefined &&
}
{totalPoints !== undefined && (
diff --git a/app/components/league/StandingsPreview.tsx b/app/components/league/StandingsPreview.tsx
index 1d313ee..c6b5df2 100644
--- a/app/components/league/StandingsPreview.tsx
+++ b/app/components/league/StandingsPreview.tsx
@@ -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 */}
-
- #{String(entry.displayRank).replace(/^T/, "")}
- {entry.rankChange !== undefined && entry.rankChange !== 0 && (
-
- )}
-
+
diff --git a/app/components/league/StatHelpers.tsx b/app/components/league/StatHelpers.tsx
index e1db42a..a21525c 100644
--- a/app/components/league/StatHelpers.tsx
+++ b/app/components/league/StatHelpers.tsx
@@ -39,6 +39,23 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
);
}
+export function RankingDisplay({
+ displayRank,
+ rankChange,
+}: {
+ displayRank: string | number;
+ rankChange?: number;
+}) {
+ return (
+
+ {displayRank}
+ {rankChange !== undefined && rankChange !== 0 && (
+
+ )}
+
+ );
+}
+
export function PointChangeIndicator({ delta }: { delta: number }) {
if (delta >= 0) {
return (
diff --git a/app/components/navbar.tsx b/app/components/navbar.tsx
index 836ea2f..8555596 100644
--- a/app/components/navbar.tsx
+++ b/app/components/navbar.tsx
@@ -1,5 +1,5 @@
import { useState } from "react";
-import { Link } from "react-router";
+import { Link, NavLink } from "react-router";
import {
Sheet,
SheetContent,
@@ -10,7 +10,19 @@ import {
import { Button } from "~/components/ui/button";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
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;
@@ -25,11 +37,17 @@ export function Navbar({ isAdmin }: NavbarProps) {
{/* Left: Logo + Nav links */}
-

+
@@ -102,20 +120,40 @@ export function Navbar({ isAdmin }: NavbarProps) {
Menu
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 84045c6..690dbc5 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -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";
@@ -57,6 +58,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;
@@ -100,6 +102,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;
@@ -138,6 +143,7 @@ export async function loader(args: Route.LoaderArgs) {
...league,
currentSeason: season,
numSports,
+ displayRank,
currentRank,
totalPoints,
previousRank,
@@ -195,6 +201,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,