- {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 cd544ad..9c940e6 100644
--- a/app/components/navbar.tsx
+++ b/app/components/navbar.tsx
@@ -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 */}
-

+
@@ -105,20 +121,28 @@ export function Navbar({ isAdmin }: NavbarProps) {
Menu
diff --git a/app/lib/__tests__/draft-order.test.ts b/app/lib/__tests__/draft-order.test.ts
new file mode 100644
index 0000000..4e0c937
--- /dev/null
+++ b/app/lib/__tests__/draft-order.test.ts
@@ -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 },
+ ]);
+ });
+});
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/home.tsx b/app/routes/home.tsx
index a50dc04..27ed9ca 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";
@@ -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,
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
index dffb407..0171c34 100644
--- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
@@ -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,