diff --git a/app/components/StandingsTable.tsx b/app/components/StandingsTable.tsx index 99372c6..6363808 100644 --- a/app/components/StandingsTable.tsx +++ b/app/components/StandingsTable.tsx @@ -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 (
- {rank} + {t}{rank}
); } else if (rank === 2) { return (
- {rank} + {t}{rank}
); } else if (rank === 3) { return (
- {rank} + {t}{rank}
); } else { - return {rank}; + return {t}{rank}; } } @@ -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) => ( - {getRankBadge(row.currentRank)} + {getRankBadge(row.currentRank, isTied(row.currentRank))} {showMovement && ( diff --git a/app/components/standings/StandingsTable.tsx b/app/components/standings/StandingsTable.tsx index e90287a..2571dcb 100644 --- a/app/components/standings/StandingsTable.tsx +++ b/app/components/standings/StandingsTable.tsx @@ -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
- + {standing.rankChange !== 0 && ( )} @@ -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 ( - šŸ† 1st + šŸ† {t}1st ); } if (rank === 2) { return ( - 🄈 2nd + 🄈 {t}2nd ); } if (rank === 3) { return ( - šŸ„‰ 3rd + šŸ„‰ {t}3rd ); } return ( - {rank} + {t}{rank} ); } diff --git a/app/lib/__tests__/standings-display.test.ts b/app/lib/__tests__/standings-display.test.ts index f4e74b9..67cfce0 100644 --- a/app/lib/__tests__/standings-display.test.ts +++ b/app/lib/__tests__/standings-display.test.ts @@ -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", () => { diff --git a/app/lib/standings-display.ts b/app/lib/standings-display.ts index 3f77b2f..b504880 100644 --- a/app/lib/standings-display.ts +++ b/app/lib/standings-display.ts @@ -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(); + 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}`; } diff --git a/app/models/scoring-calculator.ts b/app/models/scoring-calculator.ts index 7aaf20d..649abae 100644 --- a/app/models/scoring-calculator.ts +++ b/app/models/scoring-calculator.ts @@ -1070,9 +1070,12 @@ function compareTeamsForRanking( placementCounts: Record; } ): 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 diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 9ae1dca..81b4eff 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -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" >
- {getDisplayRank(standing, standings.length)} + {getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
{ }); 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"); }); diff --git a/app/services/discord.ts b/app/services/discord.ts index 5a8e4bd..3a37ec3 100644 --- a/app/services/discord.ts +++ b/app/services/discord.ts @@ -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}`); } }