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) => ({
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,
};
}

View file

@ -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<string | null>(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 (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
@ -89,7 +125,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div>
{season && (
<p className="text-muted-foreground">
{season.year} Season Status:{" "}
{season.year} Season {" "}
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
@ -121,13 +157,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{/* Left Column - 2/3 width on desktop */}
<div className="md:col-span-2 space-y-6">
{/* Standings Panel - active/completed seasons */}
{season && (season.status === "active" || season.status === "completed") && teams.length > 0 && (
{season && isActiveOrCompleted && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Standings</CardTitle>
<CardDescription>Current season standings</CardDescription>
<CardDescription>
{season.status === "completed" ? "Final standings" : "Current season standings"}
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
@ -138,12 +176,8 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</CardHeader>
<CardContent>
<div className="space-y-1">
{[...teams].sort((a, b) => {
const rankA = standings.find((s) => s.teamId === a.id)?.currentRank ?? Infinity;
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);
{sortedTeams.map((team) => {
const standing = standingsMap.get(team.id);
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<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"
>
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
{standing ? standing.currentRank : "T1"}
{getDisplayRank(standing, standings.length)}
</div>
<div className="flex-1 min-w-0">
<Link
@ -178,24 +212,24 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
)}
{/* Sports Seasons Section */}
{season && sportsSeasons.length > 0 && (
{season && sortedSportsSeasons.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Sports Seasons</CardTitle>
<CardDescription>
{sportsSeasons.length} sport{sportsSeasons.length !== 1 ? "s" : ""} in this season
{sortedSportsSeasons.length} sport{sortedSportsSeasons.length !== 1 ? "s" : ""} in this season
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 sm:grid-cols-2">
{sportsSeasons.map((sportSeason) => (
{sortedSportsSeasons.map((sportSeason) => (
<SportSeasonCard
key={sportSeason.id}
leagueId={league.id}
sportSeasonId={sportSeason.id}
sportName={sportSeason.sport.name}
seasonName={sportSeason.name}
status={sportSeason.status as "upcoming" | "active" | "completed"}
status={sportSeason.status}
scoringPattern={sportSeason.scoringPattern}
/>
))}
@ -219,7 +253,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<input
type="text"
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"
onClick={(e) => e.currentTarget.select()}
/>
@ -239,12 +273,12 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
)}
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
{season && (season.status === "pre_draft" || season.status === "draft") && <Card>
{season && isDraftOrPreDraft && (
<Card>
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this
league
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
@ -282,12 +316,12 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
})}
</div>
</CardContent>
</Card>}
</Card>
)}
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
@ -301,14 +335,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</div>
{season && (
<>
<div>
<p className="text-sm text-muted-foreground">
Season Status
</p>
<p className="font-medium capitalize">
{season.status.replace("_", " ")}
</p>
</div>
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Teams Filled
@ -317,7 +344,8 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{teamsWithOwners} of {teams.length}
</p>
</div>
{(season.status === "pre_draft" || season.status === "draft") && (
)}
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Draft Order Set
@ -357,6 +385,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
View Draft Board
</Link>
</div>
{isActiveOrCompleted && (
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
@ -366,11 +395,15 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
View Standings
</Link>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)}
</p>
<p className="text-xs text-muted-foreground">
Picks not tied to a specific sport
</p>
</div>
{season.draftDateTime && (
<div>