feat: improve league home standings and sports seasons UX (#65)

- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-07 15:57:51 -08:00 committed by GitHub
parent 8c707d2e79
commit b0960c3c30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 189 additions and 92 deletions

View file

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

View file

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

View file

@ -115,7 +115,7 @@ export async function loader(args: Route.LoaderArgs) {
const sportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ({ const sportsSeasons = seasonWithSports?.seasonSports?.map((ss) => ({
id: ss.sportsSeason.id, id: ss.sportsSeason.id,
name: ss.sportsSeason.name, name: ss.sportsSeason.name,
status: ss.sportsSeason.status, status: ss.sportsSeason.status as "upcoming" | "active" | "completed",
scoringPattern: ss.sportsSeason.scoringPattern, scoringPattern: ss.sportsSeason.scoringPattern,
sport: ss.sportsSeason.sport, sport: ss.sportsSeason.sport,
})) || []; })) || [];
@ -124,6 +124,9 @@ export async function loader(args: Route.LoaderArgs) {
// Check if draft order is set // Check if draft order is set
const isDraftOrderSet = draftSlots.length > 0; 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 { return {
league, league,
season, season,
@ -139,5 +142,6 @@ export async function loader(args: Route.LoaderArgs) {
isDraftOrderSet, isDraftOrderSet,
sportsSeasons, sportsSeasons,
standings, standings,
origin,
}; };
} }

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Link, useSearchParams } from "react-router"; import { Link, useSearchParams } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { format } from "date-fns"; import { format } from "date-fns";
@ -12,6 +12,7 @@ import {
CardTitle, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { SportSeasonCard } from "~/components/sports/SportSeasonCard"; import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { getDisplayRank } from "~/lib/standings-display";
export { loader } from "./$leagueId.server"; export { loader } from "./$leagueId.server";
@ -31,21 +32,29 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
isDraftOrderSet, isDraftOrderSet,
sportsSeasons, sportsSeasons,
standings, standings,
origin,
} = loaderData; } = loaderData;
const myTeam = teams.find((t) => t.ownerId === currentUserId); const myTeam = teams.find((t) => t.ownerId === currentUserId);
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
const processedParamsRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const paramString = searchParams.toString();
if (!paramString || paramString === processedParamsRef.current) return;
if (searchParams.get("updated") === "true") { if (searchParams.get("updated") === "true") {
processedParamsRef.current = paramString;
toast.success("Team updated successfully"); toast.success("Team updated successfully");
setSearchParams({}); setSearchParams({});
} } else if (searchParams.get("joined") === "true") {
if (searchParams.get("joined") === "true") { processedParamsRef.current = paramString;
toast.success("Welcome to the league! You've been assigned a team."); toast.success("Welcome to the league! You've been assigned a team.");
setSearchParams({}); setSearchParams({});
} } else if (searchParams.get("left") === "true") {
if (searchParams.get("left") === "true") { processedParamsRef.current = paramString;
toast.success("You have left the league. Your team is now available."); toast.success("You have left the league. Your team is now available.");
setSearchParams({}); setSearchParams({});
} }
@ -54,7 +63,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
const handleCopyInviteLink = async () => { const handleCopyInviteLink = async () => {
if (!season?.inviteCode) return; if (!season?.inviteCode) return;
const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`; const inviteUrl = `${origin}/i/${season.inviteCode}`;
try { try {
await navigator.clipboard.writeText(inviteUrl); await navigator.clipboard.writeText(inviteUrl);
setCopied(true); 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 ( return (
<div className="container mx-auto py-8 px-4"> <div className="container mx-auto py-8 px-4">
<div className="mb-8"> <div className="mb-8">
@ -89,7 +125,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div> </div>
{season && ( {season && (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{season.year} Season Status:{" "} {season.year} Season {" "}
<span className="capitalize"> <span className="capitalize">
{season.status.replace("_", " ")} {season.status.replace("_", " ")}
</span> </span>
@ -121,13 +157,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{/* Left Column - 2/3 width on desktop */} {/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6"> <div className="md:col-span-2 space-y-6">
{/* Standings Panel - active/completed seasons */} {/* Standings Panel - active/completed seasons */}
{season && (season.status === "active" || season.status === "completed") && teams.length > 0 && ( {season && isActiveOrCompleted && (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>Standings</CardTitle> <CardTitle>Standings</CardTitle>
<CardDescription>Current season standings</CardDescription> <CardDescription>
{season.status === "completed" ? "Final standings" : "Current season standings"}
</CardDescription>
</div> </div>
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/standings/${season.id}`}> <Link to={`/leagues/${league.id}/standings/${season.id}`}>
@ -138,12 +176,8 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-1"> <div className="space-y-1">
{[...teams].sort((a, b) => { {sortedTeams.map((team) => {
const rankA = standings.find((s) => s.teamId === a.id)?.currentRank ?? Infinity; const standing = standingsMap.get(team.id);
const rankB = standings.find((s) => s.teamId === b.id)?.currentRank ?? Infinity;
return rankA - rankB;
}).map((team) => {
const standing = standings.find((s) => s.teamId === team.id);
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return ( return (
<div <div
@ -151,7 +185,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
className="flex items-center gap-3 py-2 border-b last:border-0" className="flex items-center gap-3 py-2 border-b last:border-0"
> >
<div className="w-8 text-center text-sm font-bold text-muted-foreground"> <div className="w-8 text-center text-sm font-bold text-muted-foreground">
{standing ? standing.currentRank : "T1"} {getDisplayRank(standing, standings.length)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Link <Link
@ -178,24 +212,24 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
)} )}
{/* Sports Seasons Section */} {/* Sports Seasons Section */}
{season && sportsSeasons.length > 0 && ( {season && sortedSportsSeasons.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Sports Seasons</CardTitle> <CardTitle>Sports Seasons</CardTitle>
<CardDescription> <CardDescription>
{sportsSeasons.length} sport{sportsSeasons.length !== 1 ? "s" : ""} in this season {sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
{sportsSeasons.map((sportSeason) => ( {sortedSportsSeasons.map((sportSeason) => (
<SportSeasonCard <SportSeasonCard
key={sportSeason.id} key={sportSeason.id}
leagueId={league.id} leagueId={league.id}
sportSeasonId={sportSeason.id} sportSeasonId={sportSeason.id}
sportName={sportSeason.sport.name} sportName={sportSeason.sport.name}
seasonName={sportSeason.name} seasonName={sportSeason.name}
status={sportSeason.status as "upcoming" | "active" | "completed"} status={sportSeason.status}
scoringPattern={sportSeason.scoringPattern} scoringPattern={sportSeason.scoringPattern}
/> />
))} ))}
@ -219,7 +253,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<input <input
type="text" type="text"
readOnly readOnly
value={`${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`} value={`${origin}/i/${season.inviteCode}`}
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted" className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
onClick={(e) => e.currentTarget.select()} onClick={(e) => e.currentTarget.select()}
/> />
@ -239,55 +273,55 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
)} )}
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */} {/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
{season && (season.status === "pre_draft" || season.status === "draft") && <Card> {season && isDraftOrPreDraft && (
<CardHeader> <Card>
<CardTitle>Teams</CardTitle> <CardHeader>
<CardDescription> <CardTitle>Teams</CardTitle>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this <CardDescription>
league {teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{teams.map((team) => { {teams.map((team) => {
const isOwned = team.ownerId === currentUserId; const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId const ownerName = team.ownerId
? ownerMap[team.ownerId] ? ownerMap[team.ownerId]
: null; : null;
return ( return (
<Card <Card
key={team.id} key={team.id}
className={isOwned ? "border-primary" : ""} className={isOwned ? "border-primary" : ""}
> >
<CardHeader> <CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle> <CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription> <CardDescription>
{team.ownerId ? ( {team.ownerId ? (
isOwned ? ( isOwned ? (
<span className="text-primary font-medium"> <span className="text-primary font-medium">
Your Team Your Team
</span> </span>
) : (
<span>{ownerName || "Unknown Owner"}</span>
)
) : ( ) : (
<span>{ownerName || "Unknown Owner"}</span> <span className="text-muted-foreground">
) Available
) : ( </span>
<span className="text-muted-foreground"> )}
Available </CardDescription>
</span> </CardHeader>
)} </Card>
</CardDescription> );
</CardHeader> })}
</Card> </div>
); </CardContent>
})} </Card>
</div> )}
</CardContent>
</Card>}
</div> </div>
{/* Right Column - 1/3 width on desktop */} {/* Right Column - 1/3 width on desktop */}
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>League Info</CardTitle> <CardTitle>League Info</CardTitle>
@ -301,23 +335,17 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div> </div>
{season && ( {season && (
<> <>
<div> {isDraftOrPreDraft && (
<p className="text-sm text-muted-foreground"> <div>
Season Status <p className="text-sm text-muted-foreground">
</p> Teams Filled
<p className="font-medium capitalize"> </p>
{season.status.replace("_", " ")} <p className="font-medium">
</p> {teamsWithOwners} of {teams.length}
</div> </p>
<div> </div>
<p className="text-sm text-muted-foreground"> )}
Teams Filled {isDraftOrPreDraft && (
</p>
<p className="font-medium">
{teamsWithOwners} of {teams.length}
</p>
</div>
{(season.status === "pre_draft" || season.status === "draft") && (
<div> <div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Draft Order Set Draft Order Set
@ -357,20 +385,25 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
View Draft Board View Draft Board
</Link> </Link>
</div> </div>
<div> {isActiveOrCompleted && (
<p className="text-sm text-muted-foreground">Standings</p> <div>
<Link <p className="text-sm text-muted-foreground">Standings</p>
to={`/leagues/${league.id}/standings/${season.id}`} <Link
className="text-electric hover:underline font-medium" to={`/leagues/${league.id}/standings/${season.id}`}
> className="text-electric hover:underline font-medium"
View Standings >
</Link> View Standings
</div> </Link>
</div>
)}
<div> <div>
<p className="text-sm text-muted-foreground">Flex Spots</p> <p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary"> <p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)} {Math.max(0, season.draftRounds - sportsCount)}
</p> </p>
<p className="text-xs text-muted-foreground">
Picks not tied to a specific sport
</p>
</div> </div>
{season.draftDateTime && ( {season.draftDateTime && (
<div> <div>