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 {
Table,
TableBody,
@ -8,6 +9,7 @@ import {
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { TrendingUp, TrendingDown, Minus, Trophy, Medal, Award } from "lucide-react";
import { buildTiedRankChecker } from "~/lib/standings-display";
export interface StandingsRow {
teamId: string;
@ -32,30 +34,31 @@ interface StandingsTableProps {
showPlacementBreakdown?: boolean;
}
function getRankBadge(rank: number) {
function getRankBadge(rank: number, isTied?: boolean) {
const t = isTied ? "T" : "";
if (rank === 1) {
return (
<div className="flex items-center gap-2">
<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>
);
} else if (rank === 2) {
return (
<div className="flex items-center gap-2">
<Medal className="h-5 w-5 text-gray-400" />
<span className="font-semibold">{rank}</span>
<span className="font-semibold">{t}{rank}</span>
</div>
);
} else if (rank === 3) {
return (
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-700" />
<span className="font-semibold">{rank}</span>
<span className="font-semibold">{t}{rank}</span>
</div>
);
} 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,
showPlacementBreakdown = false,
}: StandingsTableProps) {
const isTied = useMemo(
() => buildTiedRankChecker(standings.map((s) => s.currentRank)),
[standings]
);
const getMovementIndicator = (current: number, previous?: number | null) => {
if (!showMovement || !previous) return null;
@ -127,7 +134,7 @@ export function StandingsTable({
standings.map((row) => (
<TableRow key={row.teamId} className={row.currentRank <= 3 ? "bg-muted/30" : undefined}>
<TableCell>
{getRankBadge(row.currentRank)}
{getRankBadge(row.currentRank, isTied(row.currentRank))}
</TableCell>
{showMovement && (
<TableCell>

View file

@ -4,6 +4,7 @@ import { Badge } from "~/components/ui/badge";
import { TeamNameDisplay } from "~/components/ui/team-name-display";
import { PointsDisplay } from "~/components/standings/PointsDisplay";
import { useSortableData, type SortConfig, type SortDirection } from "~/hooks/useSortableData";
import { buildTiedRankChecker } from "~/lib/standings-display";
import { type TeamStanding } from "~/types/standings";
interface StandingsTableProps {
@ -49,6 +50,11 @@ const comparators = {
* Display team standings with ranking, points, 7-day change, and sortable columns.
*/
export function StandingsTable({ standings, leagueId, seasonId }: StandingsTableProps) {
const isTied = useMemo(
() => buildTiedRankChecker(standings.map((s) => s.currentRank)),
[standings]
);
const rows: StandingRow[] = useMemo(
() =>
standings.map((s) => ({
@ -128,7 +134,7 @@ export function StandingsTable({ standings, leagueId, seasonId }: StandingsTable
<TableRow key={standing.teamId}>
<TableCell>
<div className="flex items-center gap-2">
<RankBadge rank={standing.currentRank} />
<RankBadge rank={standing.currentRank} isTied={isTied(standing.currentRank)} />
{standing.rankChange !== 0 && (
<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) {
return (
<Badge className="bg-amber-accent hover:bg-amber-accent/80 text-background font-bold">
🏆 1st
🏆 {t}1st
</Badge>
);
}
if (rank === 2) {
return (
<Badge className="bg-muted hover:bg-muted/80 text-muted-foreground font-bold">
🥈 2nd
🥈 {t}2nd
</Badge>
);
}
if (rank === 3) {
return (
<Badge className="bg-coral-accent hover:bg-coral-accent/80 text-background font-bold">
🥉 3rd
🥉 {t}3rd
</Badge>
);
}
return (
<Badge variant="outline" className="font-semibold">
{rank}
{t}{rank}
</Badge>
);
}

View file

@ -1,5 +1,34 @@
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("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.
*
* 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
* 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)
@ -10,11 +25,13 @@
*
* @param standing - The team's standing record, or undefined if none exists.
* @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(
standing: { currentRank: number } | undefined,
standingsCount: number
standingsCount: number,
isTied?: boolean
): number | string {
if (standing) return standing.currentRank;
if (standing) return isTied ? `T${standing.currentRank}` : standing.currentRank;
return `T${standingsCount + 1}`;
}

View file

@ -1070,9 +1070,12 @@ function compareTeamsForRanking(
placementCounts: Record<number, number>;
}
): number {
// First compare by total points (descending)
if (teamA.totalPoints !== teamB.totalPoints) {
return teamB.totalPoints - teamA.totalPoints;
// First compare by total points (descending), rounded to hundredths to avoid
// floating point noise causing spurious non-ties in the display.
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

View file

@ -15,7 +15,7 @@ import {
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
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 {
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
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
// Pre-compute sorted standings list
const sortedTeams = [...teams].toSorted((a, b) => {
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"
>
<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 className="flex-1 min-w-0">
<TeamNameDisplay

View file

@ -157,7 +157,7 @@ describe("sendStandingsUpdateNotification", () => {
});
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
expect(desc).not.toContain("Beta United");
});

View file

@ -1,3 +1,5 @@
import { buildTiedRankChecker } from "~/lib/standings-display";
interface DiscordEmbed {
title?: 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.
const changedTeams = standings.filter((s) => {
const prevPoints = previousStandings.get(s.teamId);
@ -103,6 +108,7 @@ export async function sendStandingsUpdateNotification({
if (changedTeams.length > 0) {
sections.push("\n**Standings Changes**");
for (const s of changedTeams) {
const rankPrefix = rankLabel(s.rank);
const prevPoints = previousStandings.get(s.teamId);
let pointDelta = "";
if (prevPoints !== undefined && prevPoints !== s.totalPoints) {
@ -123,7 +129,7 @@ export async function sendStandingsUpdateNotification({
const escapedName = escapeMarkdown(s.teamName);
const escapedUsername = s.username ? escapeMarkdown(s.username) : undefined;
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}`);
}
}