From b0960c3c307fa55d26e1d71f21c43d16b76f0623 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sat, 7 Mar 2026 15:57:51 -0800 Subject: [PATCH] feat: improve league home standings and sports seasons UX (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort sports seasons by status (active → upcoming → completed), with season_standings pattern last within active, then alphabetical - Fix rank display for teams without standings: T1 when no scores exist, T{n+1} when some teams are scored (avoids misleading T1 for unranked teams) - Extract rank logic to getDisplayRank() in standings-display.ts with tests - Pre-compute standingsMap and sortedTeams to eliminate O(n²) render lookups - Fix invite URL SSR flash by deriving origin in the loader - Guard toast useEffect with processedParamsRef to prevent double-fire - Hide Teams Filled and Draft Order Set post-draft; show Standings sidebar link only when active/completed - Show "Final standings" label when season is completed - Remove duplicate Season Status from sidebar; add Flex Spots description - Fix status type cast to occur at the model mapping layer, not in JSX Co-authored-by: Claude Sonnet 4.6 --- app/lib/__tests__/standings-display.test.ts | 40 ++++ app/lib/standings-display.ts | 20 ++ app/routes/leagues/$leagueId.server.ts | 6 +- app/routes/leagues/$leagueId.tsx | 215 +++++++++++--------- 4 files changed, 189 insertions(+), 92 deletions(-) create mode 100644 app/lib/__tests__/standings-display.test.ts create mode 100644 app/lib/standings-display.ts diff --git a/app/lib/__tests__/standings-display.test.ts b/app/lib/__tests__/standings-display.test.ts new file mode 100644 index 0000000..f4e74b9 --- /dev/null +++ b/app/lib/__tests__/standings-display.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { getDisplayRank } from "../standings-display"; + +describe("getDisplayRank", () => { + describe("when the team has a standing", () => { + it("returns the numeric rank regardless of how many other teams have standings", () => { + expect(getDisplayRank({ currentRank: 1 }, 5)).toBe(1); + expect(getDisplayRank({ currentRank: 3 }, 5)).toBe(3); + }); + + it("returns rank 1 when the team is first", () => { + expect(getDisplayRank({ currentRank: 1 }, 1)).toBe(1); + }); + }); + + describe("when the team has no standing and no teams have been scored yet", () => { + it("returns T1 — all teams are genuinely tied at zero", () => { + expect(getDisplayRank(undefined, 0)).toBe("T1"); + }); + }); + + describe("when the team has no standing but other teams have been scored", () => { + it("returns T{n+1} where n is the number of teams with standings", () => { + // 1 team has a standing → unranked teams are tied for 2nd + expect(getDisplayRank(undefined, 1)).toBe("T2"); + + // 3 teams have standings → unranked teams are tied for 4th + expect(getDisplayRank(undefined, 3)).toBe("T4"); + + // 9 teams have standings → unranked team is tied for 10th + expect(getDisplayRank(undefined, 9)).toBe("T10"); + }); + + it("never shows T1 when any other team has a standing", () => { + // Would be wrong to show T1 here — that team isn't first + expect(getDisplayRank(undefined, 1)).not.toBe("T1"); + expect(getDisplayRank(undefined, 5)).not.toBe("T1"); + }); + }); +}); diff --git a/app/lib/standings-display.ts b/app/lib/standings-display.ts new file mode 100644 index 0000000..3f77b2f --- /dev/null +++ b/app/lib/standings-display.ts @@ -0,0 +1,20 @@ +/** + * Returns the display rank for a team on the league home standings preview. + * + * Rules: + * - If the team has a standing, return its numeric rank. + * - If the team has no standing, it is tied at the position just after all + * ranked teams. For example, if 3 teams have standings the unranked teams + * show "T4". If no team has standings yet (scores haven't been calculated) + * every team is genuinely tied at zero and shows "T1". + * + * @param standing - The team's standing record, or undefined if none exists. + * @param standingsCount - Total number of teams that have a standing record. + */ +export function getDisplayRank( + standing: { currentRank: number } | undefined, + standingsCount: number +): number | string { + if (standing) return standing.currentRank; + return `T${standingsCount + 1}`; +} diff --git a/app/routes/leagues/$leagueId.server.ts b/app/routes/leagues/$leagueId.server.ts index c34fd65..bbb229c 100644 --- a/app/routes/leagues/$leagueId.server.ts +++ b/app/routes/leagues/$leagueId.server.ts @@ -115,7 +115,7 @@ export async function loader(args: Route.LoaderArgs) { const sportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ({ id: ss.sportsSeason.id, name: ss.sportsSeason.name, - status: ss.sportsSeason.status, + status: ss.sportsSeason.status as "upcoming" | "active" | "completed", scoringPattern: ss.sportsSeason.scoringPattern, sport: ss.sportsSeason.sport, })) || []; @@ -124,6 +124,9 @@ export async function loader(args: Route.LoaderArgs) { // Check if draft order is set const isDraftOrderSet = draftSlots.length > 0; + // Extract origin for client use (avoids SSR/client mismatch on invite URLs) + const origin = new URL(args.request.url).origin; + return { league, season, @@ -139,5 +142,6 @@ export async function loader(args: Route.LoaderArgs) { isDraftOrderSet, sportsSeasons, standings, + origin, }; } diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 883b77e..57dfcca 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; import { format } from "date-fns"; @@ -12,6 +12,7 @@ import { CardTitle, } from "~/components/ui/card"; import { SportSeasonCard } from "~/components/sports/SportSeasonCard"; +import { getDisplayRank } from "~/lib/standings-display"; export { loader } from "./$leagueId.server"; @@ -31,21 +32,29 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { isDraftOrderSet, sportsSeasons, standings, + origin, } = loaderData; + const myTeam = teams.find((t) => t.ownerId === currentUserId); const [searchParams, setSearchParams] = useSearchParams(); const [copied, setCopied] = useState(false); + // Guard against double-firing toasts (e.g. React Strict Mode double-invoke) + const processedParamsRef = useRef(null); useEffect(() => { + const paramString = searchParams.toString(); + if (!paramString || paramString === processedParamsRef.current) return; + if (searchParams.get("updated") === "true") { + processedParamsRef.current = paramString; toast.success("Team updated successfully"); setSearchParams({}); - } - if (searchParams.get("joined") === "true") { + } else if (searchParams.get("joined") === "true") { + processedParamsRef.current = paramString; toast.success("Welcome to the league! You've been assigned a team."); setSearchParams({}); - } - if (searchParams.get("left") === "true") { + } else if (searchParams.get("left") === "true") { + processedParamsRef.current = paramString; toast.success("You have left the league. Your team is now available."); setSearchParams({}); } @@ -54,7 +63,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { const handleCopyInviteLink = async () => { if (!season?.inviteCode) return; - const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`; + const inviteUrl = `${origin}/i/${season.inviteCode}`; try { await navigator.clipboard.writeText(inviteUrl); setCopied(true); @@ -65,6 +74,33 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { } }; + // Pre-compute standings map to avoid O(n²) lookups in render + const standingsMap = new Map(standings.map((s) => [s.teamId, s])); + + // Pre-compute sorted standings list + const sortedTeams = [...teams].sort((a, b) => { + const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity; + const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity; + return rankA - rankB; + }); + + // Pre-compute sorted sports seasons: active → upcoming → completed, + // season_standings last within active, then alphabetical by sport name + const sortedSportsSeasons = [...sportsSeasons].sort((a, b) => { + const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const; + const statusDiff = statusOrder[a.status] - statusOrder[b.status]; + if (statusDiff !== 0) return statusDiff; + if (a.status === "active") { + const aSeasonLong = a.scoringPattern === "season_standings" ? 1 : 0; + const bSeasonLong = b.scoringPattern === "season_standings" ? 1 : 0; + if (aSeasonLong !== bSeasonLong) return aSeasonLong - bSeasonLong; + } + return a.sport.name.localeCompare(b.sport.name); + }); + + const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft"; + const isActiveOrCompleted = season?.status === "active" || season?.status === "completed"; + return (
@@ -89,7 +125,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{season && (

- {season.year} Season • Status:{" "} + {season.year} Season •{" "} {season.status.replace("_", " ")} @@ -121,13 +157,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { {/* Left Column - 2/3 width on desktop */}

{/* Standings Panel - active/completed seasons */} - {season && (season.status === "active" || season.status === "completed") && teams.length > 0 && ( + {season && isActiveOrCompleted && (
Standings - Current season standings + + {season.status === "completed" ? "Final standings" : "Current season standings"} +