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:
Chris Parsons 2026-03-27 20:45:15 -07:00 committed by GitHub
parent 8f55d0d135
commit 611e1ccf0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 94 additions and 23 deletions

View file

@ -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>

View file

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

View file

@ -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", () => {

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

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