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 {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue