From 3566a97a1e7603a8f035511d1e0301259b6a6815 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:32:11 -0700 Subject: [PATCH 1/2] feat: add projected pick dividers to draft participant list (fixes #82) (#322) Show visual dividers in the available participants list indicating where your future draft picks fall relative to the current pick position. Dividers are suppressed when any filter is active (search, sport filter, hide drafted, etc.) since positional references are meaningless in a filtered view. - Add getProjectedPicks() to draft-order.ts for computing future pick positions in a snake draft - Interleave divider items into the virtualized list in AvailableParticipantsSection - Extract ListItem type to module scope - Add tests for getProjectedPicks, divider rendering, and filter suppression --- .../AvailableParticipantsSection.test.tsx | 152 ++++++++++++- .../draft/AvailableParticipantsSection.tsx | 73 +++++- app/lib/__tests__/draft-order.test.ts | 210 ++++++++++++++++++ app/lib/draft-order.ts | 49 ++++ .../leagues/$leagueId.draft.$seasonId.tsx | 19 +- 5 files changed, 494 insertions(+), 9 deletions(-) create mode 100644 app/lib/__tests__/draft-order.test.ts diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 5c6c433..a3b02a1 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -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( + + ); + + 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( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("does not render dividers when projectedPicks is empty", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("skips dividers where picksFromNow exceeds list length", () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("suppresses dividers when sport filter is active", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("suppresses dividers when hideDrafted is active", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + }); + describe("Sport Filter - sheet/popover", () => { it("opens and shows checkboxes for each sport", async () => { render(); diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 94e5781..b0fac0c 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -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, @@ -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(); + 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(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 ( +
+
+
+ + Projected Round {listItem.round} Pick + +
+
+
+ ); + } + + 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 (
({ + 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 */}
- Brackt + Brackt
@@ -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,