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:
parent
8c707d2e79
commit
b0960c3c30
4 changed files with 189 additions and 92 deletions
40
app/lib/__tests__/standings-display.test.ts
Normal file
40
app/lib/__tests__/standings-display.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
20
app/lib/standings-display.ts
Normal file
20
app/lib/standings-display.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue