Show tied ranks with T prefix across standings and Discord (#239)
* Show tied ranks with T prefix across standings and Discord notifications, fixes #197 - Add buildTiedRankChecker() utility to standings-display.ts; replaces four copies of inline rank-count logic spread across components, the league home route, and the Discord service - Prefix shared ranks with "T" (e.g. "T3") in StandingsTable (league panel), StandingsTable (full standings), the league home preview, and Discord webhook notifications - Add useMemo wrapping in StandingsTable components so tie detection does not recompute on every render - Fix compareTeamsForRanking to round total points to hundredths before comparing, preventing floating-point noise from producing spurious non-ties in the standings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix discord test to expect escaped period in rank display Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8f55d0d135
commit
611e1ccf0a
8 changed files with 94 additions and 23 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -8,6 +9,7 @@ import {
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { TrendingUp, TrendingDown, Minus, Trophy, Medal, Award } from "lucide-react";
|
import { TrendingUp, TrendingDown, Minus, Trophy, Medal, Award } from "lucide-react";
|
||||||
|
import { buildTiedRankChecker } from "~/lib/standings-display";
|
||||||
|
|
||||||
export interface StandingsRow {
|
export interface StandingsRow {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
@ -32,30 +34,31 @@ interface StandingsTableProps {
|
||||||
showPlacementBreakdown?: boolean;
|
showPlacementBreakdown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRankBadge(rank: number) {
|
function getRankBadge(rank: number, isTied?: boolean) {
|
||||||
|
const t = isTied ? "T" : "";
|
||||||
if (rank === 1) {
|
if (rank === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Trophy className="h-5 w-5 text-yellow-500" />
|
<Trophy className="h-5 w-5 text-yellow-500" />
|
||||||
<span className="font-bold text-lg">{rank}</span>
|
<span className="font-bold text-lg">{t}{rank}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (rank === 2) {
|
} else if (rank === 2) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Medal className="h-5 w-5 text-gray-400" />
|
<Medal className="h-5 w-5 text-gray-400" />
|
||||||
<span className="font-semibold">{rank}</span>
|
<span className="font-semibold">{t}{rank}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (rank === 3) {
|
} else if (rank === 3) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Award className="h-5 w-5 text-amber-700" />
|
<Award className="h-5 w-5 text-amber-700" />
|
||||||
<span className="font-semibold">{rank}</span>
|
<span className="font-semibold">{t}{rank}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <span className="font-medium">{rank}</span>;
|
return <span className="font-medium">{t}{rank}</span>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -64,6 +67,10 @@ export function StandingsTable({
|
||||||
showMovement = true,
|
showMovement = true,
|
||||||
showPlacementBreakdown = false,
|
showPlacementBreakdown = false,
|
||||||
}: StandingsTableProps) {
|
}: StandingsTableProps) {
|
||||||
|
const isTied = useMemo(
|
||||||
|
() => buildTiedRankChecker(standings.map((s) => s.currentRank)),
|
||||||
|
[standings]
|
||||||
|
);
|
||||||
const getMovementIndicator = (current: number, previous?: number | null) => {
|
const getMovementIndicator = (current: number, previous?: number | null) => {
|
||||||
if (!showMovement || !previous) return null;
|
if (!showMovement || !previous) return null;
|
||||||
|
|
||||||
|
|
@ -127,7 +134,7 @@ export function StandingsTable({
|
||||||
standings.map((row) => (
|
standings.map((row) => (
|
||||||
<TableRow key={row.teamId} className={row.currentRank <= 3 ? "bg-muted/30" : undefined}>
|
<TableRow key={row.teamId} className={row.currentRank <= 3 ? "bg-muted/30" : undefined}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{getRankBadge(row.currentRank)}
|
{getRankBadge(row.currentRank, isTied(row.currentRank))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{showMovement && (
|
{showMovement && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { Badge } from "~/components/ui/badge";
|
||||||
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
||||||
import { PointsDisplay } from "~/components/standings/PointsDisplay";
|
import { PointsDisplay } from "~/components/standings/PointsDisplay";
|
||||||
import { useSortableData, type SortConfig, type SortDirection } from "~/hooks/useSortableData";
|
import { useSortableData, type SortConfig, type SortDirection } from "~/hooks/useSortableData";
|
||||||
|
import { buildTiedRankChecker } from "~/lib/standings-display";
|
||||||
import { type TeamStanding } from "~/types/standings";
|
import { type TeamStanding } from "~/types/standings";
|
||||||
|
|
||||||
interface StandingsTableProps {
|
interface StandingsTableProps {
|
||||||
|
|
@ -49,6 +50,11 @@ const comparators = {
|
||||||
* Display team standings with ranking, points, 7-day change, and sortable columns.
|
* Display team standings with ranking, points, 7-day change, and sortable columns.
|
||||||
*/
|
*/
|
||||||
export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) {
|
export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) {
|
||||||
|
const isTied = useMemo(
|
||||||
|
() => buildTiedRankChecker(standings.map((s) => s.currentRank)),
|
||||||
|
[standings]
|
||||||
|
);
|
||||||
|
|
||||||
const rows: StandingRow[] = useMemo(
|
const rows: StandingRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
standings.map((s) => ({
|
standings.map((s) => ({
|
||||||
|
|
@ -128,7 +134,7 @@ export function StandingsTable({ standings, leagueId, seasonId }: StandingsTable
|
||||||
<TableRow key={standing.teamId}>
|
<TableRow key={standing.teamId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<RankBadge rank={standing.currentRank} />
|
<RankBadge rank={standing.currentRank} isTied={isTied(standing.currentRank)} />
|
||||||
{standing.rankChange !== 0 && (
|
{standing.rankChange !== 0 && (
|
||||||
<MovementIndicator change={standing.rankChange} />
|
<MovementIndicator change={standing.rankChange} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -202,31 +208,32 @@ function SortableHead({ label, sortKey, sortConfig, onSort, className }: Sortabl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RankBadge({ rank }: { rank: number }) {
|
function RankBadge({ rank, isTied }: { rank: number; isTied?: boolean }) {
|
||||||
|
const t = isTied ? "T" : "";
|
||||||
if (rank === 1) {
|
if (rank === 1) {
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-amber-accent hover:bg-amber-accent/80 text-background font-bold">
|
<Badge className="bg-amber-accent hover:bg-amber-accent/80 text-background font-bold">
|
||||||
🏆 1st
|
🏆 {t}1st
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rank === 2) {
|
if (rank === 2) {
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
|
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
|
||||||
🥈 2nd
|
🥈 {t}2nd
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rank === 3) {
|
if (rank === 3) {
|
||||||
return (
|
return (
|
||||||
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold">
|
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold">
|
||||||
🥉 3rd
|
🥉 {t}3rd
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="font-semibold">
|
<Badge variant="outline" className="font-semibold">
|
||||||
{rank}
|
{t}{rank}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,34 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { getDisplayRank } from "../standings-display";
|
import { buildTiedRankChecker, getDisplayRank } from "../standings-display";
|
||||||
|
|
||||||
|
describe("buildTiedRankChecker", () => {
|
||||||
|
it("returns false for a rank held by only one team", () => {
|
||||||
|
const isTied = buildTiedRankChecker([1, 2, 3]);
|
||||||
|
expect(isTied(1)).toBe(false);
|
||||||
|
expect(isTied(2)).toBe(false);
|
||||||
|
expect(isTied(3)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a rank shared by two or more teams", () => {
|
||||||
|
const isTied = buildTiedRankChecker([1, 3, 3, 5]);
|
||||||
|
expect(isTied(3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a rank not present in the list", () => {
|
||||||
|
const isTied = buildTiedRankChecker([1, 2]);
|
||||||
|
expect(isTied(99)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all teams tied at rank 1", () => {
|
||||||
|
const isTied = buildTiedRankChecker([1, 1, 1]);
|
||||||
|
expect(isTied(1)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty list without throwing", () => {
|
||||||
|
const isTied = buildTiedRankChecker([]);
|
||||||
|
expect(isTied(1)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("getDisplayRank", () => {
|
describe("getDisplayRank", () => {
|
||||||
describe("when the team has a standing", () => {
|
describe("when the team has a standing", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,23 @@
|
||||||
|
/**
|
||||||
|
* Returns a function that reports whether a given rank is shared by more than
|
||||||
|
* one team. Pass an array of all the rank values in the standings.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isTied = buildTiedRankChecker(standings.map(s => s.currentRank));
|
||||||
|
* isTied(3); // true if two or more teams share rank 3
|
||||||
|
*/
|
||||||
|
export function buildTiedRankChecker(ranks: number[]): (rank: number) => boolean {
|
||||||
|
const counts = new Map<number, number>();
|
||||||
|
for (const rank of ranks) counts.set(rank, (counts.get(rank) ?? 0) + 1);
|
||||||
|
return (rank) => (counts.get(rank) ?? 1) > 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the display rank for a team on the league home standings preview.
|
* Returns the display rank for a team on the league home standings preview.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* - If the team has a standing, return its numeric rank.
|
* - If the team has a standing, return its numeric rank, prefixed with "T" if
|
||||||
|
* the rank is shared by more than one team.
|
||||||
* - If the team has no standing, it is tied at the position just after all
|
* - 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
|
* 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)
|
* show "T4". If no team has standings yet (scores haven't been calculated)
|
||||||
|
|
@ -10,11 +25,13 @@
|
||||||
*
|
*
|
||||||
* @param standing - The team's standing record, or undefined if none exists.
|
* @param standing - The team's standing record, or undefined if none exists.
|
||||||
* @param standingsCount - Total number of teams that have a standing record.
|
* @param standingsCount - Total number of teams that have a standing record.
|
||||||
|
* @param isTied - Whether this team's rank is shared with another team.
|
||||||
*/
|
*/
|
||||||
export function getDisplayRank(
|
export function getDisplayRank(
|
||||||
standing: { currentRank: number } | undefined,
|
standing: { currentRank: number } | undefined,
|
||||||
standingsCount: number
|
standingsCount: number,
|
||||||
|
isTied?: boolean
|
||||||
): number | string {
|
): number | string {
|
||||||
if (standing) return standing.currentRank;
|
if (standing) return isTied ? `T${standing.currentRank}` : standing.currentRank;
|
||||||
return `T${standingsCount + 1}`;
|
return `T${standingsCount + 1}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1070,9 +1070,12 @@ function compareTeamsForRanking(
|
||||||
placementCounts: Record<number, number>;
|
placementCounts: Record<number, number>;
|
||||||
}
|
}
|
||||||
): number {
|
): number {
|
||||||
// First compare by total points (descending)
|
// First compare by total points (descending), rounded to hundredths to avoid
|
||||||
if (teamA.totalPoints !== teamB.totalPoints) {
|
// floating point noise causing spurious non-ties in the display.
|
||||||
return teamB.totalPoints - teamA.totalPoints;
|
const roundedA = Math.round(teamA.totalPoints * 100) / 100;
|
||||||
|
const roundedB = Math.round(teamB.totalPoints * 100) / 100;
|
||||||
|
if (roundedA !== roundedB) {
|
||||||
|
return roundedB - roundedA;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If points are tied, apply tiebreaker cascade
|
// If points are tied, apply tiebreaker cascade
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
||||||
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
||||||
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
||||||
import { getDisplayRank } from "~/lib/standings-display";
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
||||||
|
|
@ -86,6 +86,8 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
// Pre-compute standings map to avoid O(n²) lookups in render
|
// Pre-compute standings map to avoid O(n²) lookups in render
|
||||||
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
|
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
|
||||||
|
|
||||||
|
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
|
||||||
|
|
||||||
// Pre-compute sorted standings list
|
// Pre-compute sorted standings list
|
||||||
const sortedTeams = [...teams].toSorted((a, b) => {
|
const sortedTeams = [...teams].toSorted((a, b) => {
|
||||||
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
|
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
|
||||||
|
|
@ -194,7 +196,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">
|
||||||
{getDisplayRank(standing, standings.length)}
|
{getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<TeamNameDisplay
|
<TeamNameDisplay
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ describe("sendStandingsUpdateNotification", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const desc = getDescription();
|
const desc = getDescription();
|
||||||
expect(desc).toContain("1. Alpha FC (christhrowsrocks) — 150 pts");
|
expect(desc).toContain("1\\. Alpha FC (christhrowsrocks) — 150 pts");
|
||||||
// Beta didn't change points or rank, so it's not shown
|
// Beta didn't change points or rank, so it's not shown
|
||||||
expect(desc).not.toContain("Beta United");
|
expect(desc).not.toContain("Beta United");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { buildTiedRankChecker } from "~/lib/standings-display";
|
||||||
|
|
||||||
interface DiscordEmbed {
|
interface DiscordEmbed {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
@ -91,6 +93,9 @@ export async function sendStandingsUpdateNotification({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isTied = buildTiedRankChecker(standings.map((s) => s.rank));
|
||||||
|
const rankLabel = (rank: number) => (isTied(rank) ? `T${rank}` : `${rank}`);
|
||||||
|
|
||||||
// Standings changes section — show teams whose points or rank changed.
|
// Standings changes section — show teams whose points or rank changed.
|
||||||
const changedTeams = standings.filter((s) => {
|
const changedTeams = standings.filter((s) => {
|
||||||
const prevPoints = previousStandings.get(s.teamId);
|
const prevPoints = previousStandings.get(s.teamId);
|
||||||
|
|
@ -103,6 +108,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
if (changedTeams.length > 0) {
|
if (changedTeams.length > 0) {
|
||||||
sections.push("\n**Standings Changes**");
|
sections.push("\n**Standings Changes**");
|
||||||
for (const s of changedTeams) {
|
for (const s of changedTeams) {
|
||||||
|
const rankPrefix = rankLabel(s.rank);
|
||||||
const prevPoints = previousStandings.get(s.teamId);
|
const prevPoints = previousStandings.get(s.teamId);
|
||||||
let pointDelta = "";
|
let pointDelta = "";
|
||||||
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
|
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
|
||||||
|
|
@ -123,7 +129,7 @@ export async function sendStandingsUpdateNotification({
|
||||||
const escapedName = escapeMarkdown(s.teamName);
|
const escapedName = escapeMarkdown(s.teamName);
|
||||||
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
|
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
|
||||||
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
|
const label = escapedUsername ? `${escapedName} (${escapedUsername})` : escapedName;
|
||||||
sections.push(`${s.rank}. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
sections.push(`${rankPrefix}\\. ${label} — ${Math.round(s.totalPoints)} pts${pointDelta}${rankDelta}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue