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